diff --git a/packages/host/app/services/matrix-service.ts b/packages/host/app/services/matrix-service.ts index 329faef5cf..a02fb0c1de 100644 --- a/packages/host/app/services/matrix-service.ts +++ b/packages/host/app/services/matrix-service.ts @@ -2100,7 +2100,12 @@ export default class MatrixService extends Service { this._systemCard = undefined; this._systemCardLoadFailed = false; this.startedAtTs = -1; - this.#clientReadyDeferred = new Deferred(); + // `#clientReadyDeferred` is deliberately left alone. It gates on "the SDK + // has loaded and a client exists" — a condition this reset preserves, since + // the client is recreated synchronously above whenever the SDK is loaded. + // Replacing it with a fresh deferred would strand every post-logout + // `createRealmSession()` await: `loadSDK()`, the only fulfiller, runs once + // at boot and never re-runs. } private teardownClient() { diff --git a/packages/host/tests/integration/matrix-service-relogin-test.ts b/packages/host/tests/integration/matrix-service-relogin-test.ts new file mode 100644 index 0000000000..75f75ee1e0 --- /dev/null +++ b/packages/host/tests/integration/matrix-service-relogin-test.ts @@ -0,0 +1,74 @@ +import type { RenderingTestContext } from '@ember/test-helpers'; + +import { getService } from '@universal-ember/test-support'; +import { rawTimeout } from 'ember-concurrency'; +import { module, test } from 'qunit'; + +import { baseRealm } from '@cardstack/runtime-common'; + +import type MatrixService from '@cardstack/host/services/matrix-service'; + +import { + testRealmURL, + setupIntegrationTestRealm, + setupLocalIndexing, +} from '../helpers'; + +import { setupBaseRealm } from '../helpers/base-realm'; + +import { setupMockMatrix } from '../helpers/mock-matrix'; + +import { setupRenderingTest } from '../helpers/setup'; + +// `MatrixService` is a singleton whose client-ready barrier +// (`#clientReadyDeferred`) is only fulfilled once, at boot, inside `loadSDK()`. +// Logout stays in-app (a router transition, not a page reload) and its +// `finally` runs `resetState()`, so the barrier must survive that reset: if it +// were replaced with a fresh, never-fulfilled deferred, the next login's +// `createRealmSession()` would await it forever and the auth page would never +// progress. This test locks down the invariant that `createRealmSession()` +// still resolves after a `resetState()`. +module( + 'Integration | matrix-service | re-login after logout', + function (hooks) { + setupRenderingTest(hooks); + setupLocalIndexing(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + activeRealms: [baseRealm.url, testRealmURL], + autostart: true, + }); + + setupBaseRealm(hooks); + + hooks.beforeEach(async function (this: RenderingTestContext) { + await setupIntegrationTestRealm({ + mockMatrixUtils, + contents: {}, + }); + }); + + test('createRealmSession resolves after resetState (logout) instead of hanging', async function (assert) { + let matrixService = getService('matrix-service') as MatrixService; + await matrixService.ready; + + // `logout()` runs `resetState()` in its `finally`. Drive that reset + // directly so the test doesn't depend on the full logout network + // roundtrip / router transition. + matrixService.resetState(); + + const TIMEOUT = Symbol('timeout'); + let result = await Promise.race([ + matrixService.createRealmSession(new URL(testRealmURL)), + rawTimeout(2000).then(() => TIMEOUT), + ]); + + assert.notStrictEqual( + result, + TIMEOUT, + 'createRealmSession settles after resetState — the clientReadyDeferred barrier survives the reset rather than being left pending', + ); + }); + }, +); diff --git a/packages/matrix/tests/login.spec.ts b/packages/matrix/tests/login.spec.ts index 34ccae82d7..cedb7f8594 100644 --- a/packages/matrix/tests/login.spec.ts +++ b/packages/matrix/tests/login.spec.ts @@ -358,6 +358,38 @@ test.describe('Login', () => { await expect(page.locator('[data-test-login-btn]')).toBeVisible(); }); + test('it can log back in after logout without a page reload', async ({ + page, + }) => { + await login(page, username, password, { url: appURL }); + await assertLoggedIn(page, { + displayName: username, + userId: credentials.userId, + }); + + await logout(page); + await assertLoggedOut(page); + // The login form renders as soon as auth is cleared, but logout finishes + // asynchronously with a router transition to index-root that remounts the + // form. Wait for that transition to land (the path leaves the realm route) + // before filling, or the fields get wiped mid-fill — faster than any real + // user could type. + await page.waitForURL((url) => url.pathname === '/'); + + // Logout stays in-app (a router transition, not a page reload), so this + // second login runs against the matrix client that logout's `resetState()` + // recreated rather than a freshly booted one. + await page.locator('[data-test-username-field]').fill(username); + await page.locator('[data-test-password-field]').fill(password); + await page.locator('[data-test-login-btn]').click(); + + await expect(page.locator('[data-test-workspace-chooser]')).toHaveCount(1); + await assertLoggedIn(page, { + displayName: username, + userId: credentials.userId, + }); + }); + test('it shows an error when invalid credentials are provided', async ({ page, }) => {