diff --git a/packages/matrix/helpers/index.ts b/packages/matrix/helpers/index.ts index b2ebb3b62fb..409d8fe2915 100644 --- a/packages/matrix/helpers/index.ts +++ b/packages/matrix/helpers/index.ts @@ -165,13 +165,49 @@ export async function createRealm( endpoint: string, name = endpoint, ) { - await page.locator('[data-test-add-workspace]').click(); - await page.locator('[data-test-display-name-field]').fill(name); - await page.locator('[data-test-endpoint-field]').fill(endpoint); - await page.locator('[data-test-create-workspace-submit]').click(); - await expect(page.locator(`[data-test-workspace="${name}"]`)).toHaveCount(1, { - timeout: 30_000, - }); + // Creating a workspace provisions a matrix room + personal realm. Under + // load that can transiently fail: the modal surfaces an error + // (`data-test-error-message`) and stays open instead of closing and + // rendering the workspace tile. Submitting and waiting only for the tile + // then burns the full timeout on a modal that will never resolve. Instead + // wait for whichever outcome happens first and, on a surfaced error, retry + // the whole form from a clean modal (cancel re-opens with error cleared). + let workspaceTile = page.locator(`[data-test-workspace="${name}"]`); + let errorMessage = page.locator('[data-test-error-message]'); + let maxAttempts = 3; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + // A previous attempt's provisioning may have landed late — if the tile is + // already present, don't try to create a duplicate endpoint. + if ((await workspaceTile.count()) > 0) { + return; + } + + await page.locator('[data-test-add-workspace]').click(); + await page.locator('[data-test-display-name-field]').fill(name); + await page.locator('[data-test-endpoint-field]').fill(endpoint); + await page.locator('[data-test-create-workspace-submit]').click(); + + await expect(workspaceTile.or(errorMessage).first()).toBeVisible({ + timeout: 30_000, + }); + if ((await workspaceTile.count()) > 0) { + return; + } + + let message = (await errorMessage.textContent())?.trim() ?? '(no message)'; + if (attempt === maxAttempts) { + throw new Error( + `createRealm("${endpoint}") failed after ${maxAttempts} attempts: ${message}`, + ); + } + console.log( + `[createRealm] "${endpoint}" attempt ${attempt}/${maxAttempts} errored, retrying: ${message}`, + ); + // Dismiss the errored modal so the retry opens a fresh one (which clears + // the error and resets the fields). + await page.locator('[data-test-cancel-create-workspace]').click(); + await expect(errorMessage).toBeHidden(); + } } export async function openRoot(page: Page, url = testHost) { diff --git a/packages/matrix/helpers/isolated-realm-server.ts b/packages/matrix/helpers/isolated-realm-server.ts index 4347011a480..415c9d33377 100644 --- a/packages/matrix/helpers/isolated-realm-server.ts +++ b/packages/matrix/helpers/isolated-realm-server.ts @@ -277,6 +277,16 @@ export async function startPrerenderServer( BOXEL_HOST_URL: 'https://localhost:4200', LOG_LEVELS: process.env.TEST_HARNESS_PRERENDER_LOG_LEVELS ?? process.env.LOG_LEVELS, + // One prerender server is shared by both Playwright workers + // (fullyParallel) for the whole shard. With the pool size unset it + // collapses to a fixed 4 tabs, which the shard's concurrent publish + + // index work can exhaust — the pool thrashes (`standby refill failed + // to produce a fresh tab`, cross-affinity steals) and realm-server + // requests stall, surfacing as 60s page.goto / _publish-realm + // timeouts. Enable the dynamic envelope: keep a 4-tab idle floor (no + // extra baseline memory) but let it burst to 8 under load. + PRERENDER_PAGE_POOL_MIN: process.env.PRERENDER_PAGE_POOL_MIN ?? '4', + PRERENDER_PAGE_POOL_MAX: process.env.PRERENDER_PAGE_POOL_MAX ?? '8', }; let prerenderArgs = [ '--transpileOnly', diff --git a/packages/matrix/tests/host-mode.spec.ts b/packages/matrix/tests/host-mode.spec.ts index 773d114b6c4..b2b218fd43a 100644 --- a/packages/matrix/tests/host-mode.spec.ts +++ b/packages/matrix/tests/host-mode.spec.ts @@ -2,240 +2,370 @@ import { expect, test } from './fixtures'; import { createRealm, createSubscribedUserAndLogin, - login, logout, postCardSource, + setRealmRedirects, waitUntil, } from '../helpers'; import { appURL } from '../helpers/isolated-realm-server'; import { randomUUID } from 'crypto'; +import type { Page } from '@playwright/test'; + +interface PublishedHostModeRealm { + username: string; + password: string; + realmURL: string; + publishedRealmURL: string; + publishedCardURL: string; + publishedWhitePaperCardURL: string; + publishedMyCardURL: string; + connectRouteURL: string; +} + +// POST /_publish-realm using the source realm's session token. Returns once +// the server accepts the request; the published realm finishes re-indexing +// asynchronously, so callers must poll the published URL before navigating. +async function publishRealm( + page: Page, + realmURL: string, + publishedRealmURL: string, +) { + await page.evaluate( + async ({ realmURL, publishedRealmURL }) => { + let sessions = JSON.parse( + window.localStorage.getItem('boxel-session') ?? '{}', + ); + let token = sessions[realmURL]; + if (!token) { + throw new Error(`No session token found for ${realmURL}`); + } -test.describe('Host mode', () => { - let realmURL: string; - let publishedRealmURL: string; - let publishedCardURL: string; - let publishedWhitePaperCardURL: string; - let publishedMyCardURL: string; - let connectRouteURL: string; - let username: string; - let password: string; - - test.beforeEach(async ({ page }) => { - const serverIndexUrl = new URL(appURL).origin; - const user = await createSubscribedUserAndLogin( - page, - 'host-mode', - serverIndexUrl, - ); - username = user.username; - password = user.password; + let response = await fetch('https://localhost:4205/_publish-realm', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: token, + }, + body: JSON.stringify({ + sourceRealmURL: realmURL, + publishedRealmURL, + }), + }); - const realmName = `host-mode-${randomUUID()}`; + if (!response.ok) { + throw new Error(await response.text()); + } + }, + { realmURL, publishedRealmURL }, + ); +} + +// Create a fresh source realm, seed it with the host-mode fixture cards, and +// publish it. Leaves the page logged out. The `page` must already have realm +// redirects registered (the per-test `page` fixture does this; a hand-rolled +// context page must call `setRealmRedirects` first). +// +// `options.routingRulePath` seeds a `realm.json` host routing rule (mapping +// that path to the white-paper card) BEFORE the single publish — so routing +// tests don't have to publish, rewrite realm.json, and re-publish. Each +// `_publish-realm` POST is the heaviest, contention-prone step in the suite, +// so collapsing two publishes into one is what keeps the routing tests from +// timing out on a first-attempt publish under shard load. +async function createAndPublishHostModeRealm( + page: Page, + options: { routingRulePath?: string } = {}, +): Promise { + const serverIndexUrl = new URL(appURL).origin; + const { username, password } = await createSubscribedUserAndLogin( + page, + 'host-mode', + serverIndexUrl, + ); - await createRealm(page, realmName); - realmURL = new URL(`${username}/${realmName}/`, serverIndexUrl).href; + const realmName = `host-mode-${randomUUID()}`; - await page.goto(realmURL); - await page.locator('[data-test-stack-item-content]').first().waitFor(); + await createRealm(page, realmName); + const realmURL = new URL(`${username}/${realmName}/`, serverIndexUrl).href; - await postCardSource( - page, - realmURL, - 'host-mode-isolated-card.gts', - ` - import { CardDef, Component } from 'https://cardstack.com/base/card-api'; - - export class HostModeIsolatedCard extends CardDef { - static isolated = class Isolated extends Component { - - }; - } - `, - ); + await page.goto(realmURL, { waitUntil: 'domcontentloaded' }); + await page.locator('[data-test-stack-item-content]').first().waitFor(); - await postCardSource( - page, - realmURL, - 'white-paper-card.gts', - ` - import { CardDef, Component } from 'https://cardstack.com/base/card-api'; - - export class WhitePaperCard extends CardDef { - static prefersWideFormat = true; - - static isolated = class Isolated extends Component { - - }; - } - `, - ); + } + + + }; + } + `, + ); - await postCardSource( - page, - realmURL, - 'index.json', - JSON.stringify({ - data: { - type: 'card', - attributes: {}, - meta: { - adoptsFrom: { - module: './host-mode-isolated-card.gts', - name: 'HostModeIsolatedCard', - }, + await postCardSource( + page, + realmURL, + 'index.json', + JSON.stringify({ + data: { + type: 'card', + attributes: {}, + meta: { + adoptsFrom: { + module: './host-mode-isolated-card.gts', + name: 'HostModeIsolatedCard', }, }, - }), - ); + }, + }), + ); - await postCardSource( - page, - realmURL, - 'white-paper.json', - JSON.stringify({ - data: { - type: 'card', - attributes: {}, - meta: { - adoptsFrom: { - module: './white-paper-card.gts', - name: 'WhitePaperCard', - }, + await postCardSource( + page, + realmURL, + 'white-paper.json', + JSON.stringify({ + data: { + type: 'card', + attributes: {}, + meta: { + adoptsFrom: { + module: './white-paper-card.gts', + name: 'WhitePaperCard', }, }, - }), - ); + }, + }), + ); - await postCardSource( - page, - realmURL, - 'card-with-head-title.gts', - ` - import { CardDef, Component } from 'https://cardstack.com/base/card-api'; - - export class CardWithHeadTitle extends CardDef { - static displayName = 'Card With Head Title'; - - static head = class Head extends Component { - - }; - - static isolated = class Isolated extends Component { - - }; - } - `, - ); + await postCardSource( + page, + realmURL, + 'card-with-head-title.gts', + ` + import { CardDef, Component } from 'https://cardstack.com/base/card-api'; + + export class CardWithHeadTitle extends CardDef { + static displayName = 'Card With Head Title'; + + static head = class Head extends Component { + + }; + + static isolated = class Isolated extends Component { + + }; + } + `, + ); + await postCardSource( + page, + realmURL, + 'my-card.json', + JSON.stringify({ + data: { + type: 'card', + attributes: {}, + meta: { + adoptsFrom: { + module: './card-with-head-title.gts', + name: 'CardWithHeadTitle', + }, + }, + }, + }), + ); + + if (options.routingRulePath) { + // Overwrite the auto-generated realm.json with a host routing rule that + // maps the given path to the white-paper card posted above. Seeding it + // here means the rule is present at the initial publish, so the routing + // tests need only one publish instead of publish-then-republish. await postCardSource( page, realmURL, - 'my-card.json', + 'realm.json', JSON.stringify({ data: { type: 'card', - attributes: {}, + attributes: { + cardInfo: { name: `Routed Realm ${randomUUID()}` }, + hostRoutingRules: [{ path: options.routingRulePath }], + }, + relationships: { + 'hostRoutingRules.0.instance': { + links: { self: './white-paper' }, + }, + }, meta: { adoptsFrom: { - module: './card-with-head-title.gts', - name: 'CardWithHeadTitle', + module: 'https://cardstack.com/base/realm-config', + name: 'RealmConfig', }, }, }, }), ); + } - await page.reload(); - await page.locator('[data-test-host-mode-isolated]').waitFor(); + await page.reload(); + await page.locator('[data-test-host-mode-isolated]').waitFor(); - publishedRealmURL = `https://published.localhost:4205/${username}/${realmName}/`; + const publishedRealmURL = `https://published.localhost:4205/${username}/${realmName}/`; - await page.evaluate( - async ({ realmURL, publishedRealmURL }) => { - let sessions = JSON.parse( - window.localStorage.getItem('boxel-session') ?? '{}', - ); - let token = sessions[realmURL]; - if (!token) { - throw new Error(`No session token found for ${realmURL}`); - } - - let response = await fetch('https://localhost:4205/_publish-realm', { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - Authorization: token, - }, - body: JSON.stringify({ - sourceRealmURL: realmURL, - publishedRealmURL, - }), - }); + await publishRealm(page, realmURL, publishedRealmURL); - if (!response.ok) { - throw new Error(await response.text()); - } + await logout(page); - return response.json(); - }, - { realmURL, publishedRealmURL }, - ); - - publishedCardURL = `${publishedRealmURL}index.json`; - publishedWhitePaperCardURL = `${publishedRealmURL}white-paper.json`; - publishedMyCardURL = `${publishedRealmURL}my-card.json`; - connectRouteURL = `https://localhost:4205/connect/${encodeURIComponent( + return { + username, + password, + realmURL, + publishedRealmURL, + publishedCardURL: `${publishedRealmURL}index.json`, + publishedWhitePaperCardURL: `${publishedRealmURL}white-paper.json`, + publishedMyCardURL: `${publishedRealmURL}my-card.json`, + connectRouteURL: `https://localhost:4205/connect/${encodeURIComponent( publishedRealmURL, - )}`; - - await logout(page); + )}`, + }; +} + +// Poll the server-rendered HTML at `url` until it contains `marker`. The +// `_publish-realm` POST returns before the published realm has finished +// re-indexing/prerendering, so navigating "cold" races that work and is the +// source of the flaky `page.goto` timeouts this suite previously hit. +// +// Budget generously (not waitUntil's 10s default): this is the readiness gate +// for a realm that may still be indexing under CI load, so a tight poll would +// fail a slow-but-eventually-ready realm earlier than the old bare navigation +// (which was bounded by the 60s test timeout). 45s stays under that test +// timeout while leaving headroom for the navigation/assertions that follow. +async function waitForPublishedMarker( + page: Page, + url: string, + marker: string, + timeout = 45_000, +) { + await waitUntil(async () => { + let response = await page.request.get(url, { + headers: { Accept: 'text/html' }, + }); + if (!response.ok()) { + return false; + } + let text = await response.text(); + return text.includes(marker); + }, timeout); +} + +// Read-only tests share a single published realm created once per worker. +// Publishing fans out a full reindex + prerender; doing it once instead of in +// a per-test `beforeEach` removes the prerender storm that drove the flake. +test.describe('Host mode', () => { + let realm: PublishedHostModeRealm; + + test.beforeAll(async ({ browser }) => { + // This shared setup does the work of several tests' setup, and a failure + // here fails the whole read-only group — so give it a longer budget and + // retry with a fresh context. Retrying in-hook (rather than leaning on + // Playwright's hook retry) avoids the failure mode where the hand-rolled + // context wedges and every subsequent retry times out closing it. + test.setTimeout(180_000); + let lastError: unknown; + for (let attempt = 1; attempt <= 3; attempt++) { + // `beforeAll` only has access to worker-scoped fixtures, so build a + // throwaway context by hand. Publishing is server-side state that + // outlives this context, so we close it once setup is done. + const context = await browser.newContext(); + const page = await context.newPage(); + try { + await setRealmRedirects(page); + realm = await createAndPublishHostModeRealm(page); + // Don't let a wedged context's close error mask a successful setup. + await context.close().catch(() => {}); + return; + } catch (e) { + lastError = e; + console.log( + `[host-mode beforeAll] setup attempt ${attempt}/3 failed: ${ + (e as Error)?.message + }`, + ); + await context.close().catch(() => {}); + } + } + throw lastError; }); test('published card response includes isolated template markup', async ({ page, }) => { + // Same readiness-gate budget as waitForPublishedMarker (not waitUntil's + // 10s default) — the realm may still be indexing under CI load. let html = await waitUntil(async () => { - let response = await page.request.get(publishedCardURL, { + let response = await page.request.get(realm.publishedCardURL, { headers: { Accept: 'text/html' }, }); @@ -245,11 +375,11 @@ test.describe('Host mode', () => { let text = await response.text(); return text.includes('data-test-host-mode-isolated') ? text : false; - }); + }, 45_000); expect(html).toContain('data-test-host-mode-isolated'); - await page.goto(publishedCardURL); + await page.goto(realm.publishedCardURL, { waitUntil: 'domcontentloaded' }); await expect(page.locator('[data-test-host-mode-isolated]')).toBeVisible(); await expect(page.locator('body.boxel-ready')).toBeAttached(); }); @@ -257,7 +387,28 @@ test.describe('Host mode', () => { test('printed isolated card produces a stable page count', async ({ page, }) => { - await page.goto(publishedWhitePaperCardURL); + // Warm up so we only navigate once the published card is render-ready; + // navigating cold is what previously timed out under load. + let warmupStart = Date.now(); + await waitForPublishedMarker( + page, + realm.publishedWhitePaperCardURL, + 'data-test-white-paper', + ); + // Diagnostic: if this test ever times out again, the warm-up timing + // tells us whether the prerender was the slow part (long warm-up) or + // the navigation itself stalled (short warm-up, then goto hangs). + console.log( + `[host-mode print] warm-up ready after ${Date.now() - warmupStart}ms`, + ); + + let gotoStart = Date.now(); + await page.goto(realm.publishedWhitePaperCardURL, { + waitUntil: 'domcontentloaded', + }); + console.log( + `[host-mode print] page.goto resolved after ${Date.now() - gotoStart}ms`, + ); await page.locator('[data-test-white-paper]').waitFor(); await page.locator('[data-test-host-mode-card-loaded]').waitFor(); await page.emulateMedia({ media: 'print' }); @@ -271,10 +422,10 @@ test.describe('Host mode', () => { test.skip('card in a published realm renders in host mode with a connect button', async ({ page, }) => { - await page.goto(publishedCardURL); + await page.goto(realm.publishedCardURL, { waitUntil: 'domcontentloaded' }); await expect( - page.locator(`[data-test-card="${publishedRealmURL}index"]`), + page.locator(`[data-test-card="${realm.publishedRealmURL}index"]`), ).toBeVisible(); let connectIframe = page.frameLocator('iframe'); @@ -284,24 +435,24 @@ test.describe('Host mode', () => { test.skip('clicking connect button logs in on main site and redirects back to host mode', async ({ page, }) => { - await page.goto(publishedCardURL); + await page.goto(realm.publishedCardURL, { waitUntil: 'domcontentloaded' }); await expect(page.locator('iframe')).toBeVisible(); let connectIframe = page.frameLocator('iframe'); await connectIframe.locator('[data-test-connect]').click(); - await page.locator('[data-test-username-field]').fill(username); - await page.locator('[data-test-password-field]').fill(password); + await page.locator('[data-test-username-field]').fill(realm.username); + await page.locator('[data-test-password-field]').fill(realm.password); await page.locator('[data-test-login-btn]').click(); - await expect(page).toHaveURL(publishedCardURL); + await expect(page).toHaveURL(realm.publishedCardURL); await expect(page.locator('iframe')).toBeVisible(); connectIframe = page.frameLocator('iframe'); await expect( connectIframe.locator( - `[data-test-profile-icon-userid="@${username}:localhost"]`, + `[data-test-profile-icon-userid="@${realm.username}:localhost"]`, ), ).toBeVisible(); }); @@ -309,10 +460,12 @@ test.describe('Host mode', () => { test('visiting connect route with known origin includes a matching frame-ancestors CSP', async ({ page, }) => { - let response = await page.goto(connectRouteURL); + let response = await page.goto(realm.connectRouteURL, { + waitUntil: 'domcontentloaded', + }); expect(response?.headers()['content-security-policy']).toBe( - `frame-ancestors ${publishedRealmURL}`, + `frame-ancestors ${realm.publishedRealmURL}`, ); }); @@ -321,6 +474,7 @@ test.describe('Host mode', () => { }) => { let response = await page.goto( 'https://localhost:4205/connect/http%3A%2F%2Fexample.com', + { waitUntil: 'domcontentloaded' }, ); expect(response?.status()).toBe(404); @@ -330,7 +484,16 @@ test.describe('Host mode', () => { }); test('page title comes from head format template', async ({ page }) => { - await page.goto(publishedMyCardURL); + // Warm up before the cold navigation (see `waitForPublishedMarker`). + await waitForPublishedMarker( + page, + realm.publishedMyCardURL, + 'data-test-card-with-head-title', + ); + + await page.goto(realm.publishedMyCardURL, { + waitUntil: 'domcontentloaded', + }); await page.locator('[data-test-card-with-head-title]').waitFor(); // Wait for the head template to be injected @@ -342,7 +505,13 @@ test.describe('Host mode', () => { const pageTitle = await page.title(); expect(pageTitle).toBe('My Custom Title From Head Template'); }); +}); +// Each test gets its own realm, published once with the routing rule already +// seeded into realm.json (see `createAndPublishHostModeRealm`) — no +// rewrite-and-republish, which is what made these the suite's heaviest, +// flakiest tests. +test.describe('Host mode routing rules', () => { // CS-10054 + CS-10055: routing rules in the realm config card resolve a // bare path (no .json extension) to a target card and render it in host // mode. This test fails until the host-mode request handler reads the @@ -350,90 +519,17 @@ test.describe('Host mode', () => { test('routing rule resolves a bare path to its target card', async ({ page, }) => { - // beforeEach logged out — re-login so we can write to the source realm. - await login(page, username, password); - await page.goto(realmURL); - await page.locator('[data-test-stack-item-content]').first().waitFor(); - - // Overwrite realm.json with a routing rule mapping /whitepaper to the - // existing white-paper card. The auto-generated realm.json from - // createRealm has no rules; we replace it before re-publishing. - await postCardSource( - page, - realmURL, - 'realm.json', - JSON.stringify({ - data: { - type: 'card', - attributes: { - cardInfo: { name: `Routed Realm ${randomUUID()}` }, - hostRoutingRules: [{ path: '/whitepaper' }], - }, - relationships: { - 'hostRoutingRules.0.instance': { - links: { self: './white-paper' }, - }, - }, - meta: { - adoptsFrom: { - module: 'https://cardstack.com/base/realm-config', - name: 'RealmConfig', - }, - }, - }, - }), - ); - - // Re-publish so the routing rule lands in the published realm. - await page.evaluate( - async ({ realmURL, publishedRealmURL }) => { - let sessions = JSON.parse( - window.localStorage.getItem('boxel-session') ?? '{}', - ); - let token = sessions[realmURL]; - if (!token) { - throw new Error(`No session token found for ${realmURL}`); - } - let response = await fetch('https://localhost:4205/_publish-realm', { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - Authorization: token, - }, - body: JSON.stringify({ - sourceRealmURL: realmURL, - publishedRealmURL, - }), - }); - if (!response.ok) { - throw new Error(await response.text()); - } - }, - { realmURL, publishedRealmURL }, - ); - - await logout(page); - - // The _publish-realm POST returns 202 before the published realm has - // finished re-indexing the new realm.json. Poll the bare URL until the - // server-rendered HTML contains the target card's marker — that - // confirms the routing rule is indexed AND the server cardURL rewrite - // is applying it. Mirrors the waitUntil pattern in the - // `published card response` test above. - let routedURL = `${publishedRealmURL}whitepaper`; - await waitUntil(async () => { - let response = await page.request.get(routedURL, { - headers: { Accept: 'text/html' }, - }); - if (!response.ok()) { - return false; - } - let text = await response.text(); - return text.includes('data-test-white-paper'); + let realm = await createAndPublishHostModeRealm(page, { + routingRulePath: '/whitepaper', }); - await page.goto(routedURL); + // Poll the bare URL until the server-rendered HTML contains the target + // card's marker — that confirms the routing rule is indexed in the + // published realm AND the server cardURL rewrite is applying it. + let routedURL = `${realm.publishedRealmURL}whitepaper`; + await waitForPublishedMarker(page, routedURL, 'data-test-white-paper'); + + await page.goto(routedURL, { waitUntil: 'domcontentloaded' }); await expect(page.locator('[data-test-white-paper]')).toBeVisible(); }); @@ -450,84 +546,23 @@ test.describe('Host mode', () => { // match the client's `params.path === '/'`, and // hydration would replace the SSR'd card with the bare-shell // fallback. This test pins the canonicalized comparator. - await login(page, username, password); - await page.goto(realmURL); - await page.locator('[data-test-stack-item-content]').first().waitFor(); - - await postCardSource( - page, - realmURL, - 'realm.json', - JSON.stringify({ - data: { - type: 'card', - attributes: { - cardInfo: { name: `Routed Realm ${randomUUID()}` }, - hostRoutingRules: [{ path: '/' }], - }, - relationships: { - 'hostRoutingRules.0.instance': { - links: { self: './white-paper' }, - }, - }, - meta: { - adoptsFrom: { - module: 'https://cardstack.com/base/realm-config', - name: 'RealmConfig', - }, - }, - }, - }), - ); - - await page.evaluate( - async ({ realmURL, publishedRealmURL }) => { - let sessions = JSON.parse( - window.localStorage.getItem('boxel-session') ?? '{}', - ); - let token = sessions[realmURL]; - if (!token) { - throw new Error(`No session token found for ${realmURL}`); - } - let response = await fetch('https://localhost:4205/_publish-realm', { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - Authorization: token, - }, - body: JSON.stringify({ - sourceRealmURL: realmURL, - publishedRealmURL, - }), - }); - if (!response.ok) { - throw new Error(await response.text()); - } - }, - { realmURL, publishedRealmURL }, - ); - - await logout(page); + let realm = await createAndPublishHostModeRealm(page, { + routingRulePath: '/', + }); // Wait until the SSR HTML at the canonical (trailing-slash) URL // contains the routed card's marker, then navigate to the // NO-TRAILING-SLASH variant and assert the marker stays visible // through hydration. The no-slash navigation is what the // canonicalization fix targets. - await waitUntil(async () => { - let response = await page.request.get(publishedRealmURL, { - headers: { Accept: 'text/html' }, - }); - if (!response.ok()) { - return false; - } - let text = await response.text(); - return text.includes('data-test-white-paper'); - }); + await waitForPublishedMarker( + page, + realm.publishedRealmURL, + 'data-test-white-paper', + ); - let noSlashURL = publishedRealmURL.replace(/\/$/, ''); - await page.goto(noSlashURL); + let noSlashURL = realm.publishedRealmURL.replace(/\/$/, ''); + await page.goto(noSlashURL, { waitUntil: 'domcontentloaded' }); // `[data-test-host-mode-card=""]` is set by the host SPA's // CardRenderer — that attribute exists ONLY post-hydration (it's // not in the SSR'd isolated_html). Pinning it to the rule's target @@ -539,7 +574,7 @@ test.describe('Host mode', () => { // the realm index card, the attribute value is `…/index` // (or similar) and this assertion fails with a clear diff // instead of silently catching the SSR'd marker. - let expectedRoutedCardId = `${publishedRealmURL}white-paper`; + let expectedRoutedCardId = `${realm.publishedRealmURL}white-paper`; await expect( page.locator(`[data-test-host-mode-card="${expectedRoutedCardId}"]`), ).toBeVisible();