From 29049db453145cd45e74d6a385e16cdc8da730fa Mon Sep 17 00:00:00 2001 From: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Date: Mon, 17 Aug 2026 11:39:38 -0400 Subject: [PATCH] test(e2e): verify app origin before storage setup Signed-off-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> --- AGENTS.md | 10 ++- desktop/tests/e2e/channel-browser.spec.ts | 68 ++++++++++--------- .../e2e/drafts-all-fix-screenshots.spec.ts | 58 +++++++++------- desktop/tests/helpers/bootstrap.ts | 62 +++++++++++++++++ desktop/tests/helpers/bridge.ts | 24 +++++-- package.json | 3 +- web/tests/e2e/smoke.spec.ts | 67 ++++++++++-------- web/tests/helpers/bootstrap.ts | 62 +++++++++++++++++ 8 files changed, 259 insertions(+), 95 deletions(-) create mode 100644 desktop/tests/helpers/bootstrap.ts create mode 100644 web/tests/helpers/bootstrap.ts diff --git a/AGENTS.md b/AGENTS.md index e10e49e664..ab1c97f4d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -384,9 +384,13 @@ you; prefer them over a manual build plus `playwright test`. serves old code. Kill port 4173 and re-run `pnpm build:e2e` before re-running tests after code changes. -**`addInitScript` before bridge:** `page.addInitScript` (localStorage seeding) -must run BEFORE `installMockBridge(page)` — React reads state on mount, the -bridge triggers mount. +**Bootstrap before app interaction:** New Playwright specs must use +`bootstrapE2ePage(page, { beforeNavigate })` from `tests/helpers/bootstrap` +for their first app navigation. Register `addInitScript` seeding in +`beforeNavigate` (and before `installMockBridge(page)`) so React reads it at +mount. The helper requires a successful navigation to the configured HTTP +origin and reports a missing build or preview server before a later +`localStorage` access can misreport it as a storage failure. **Live messages:** Call `waitForMockLiveSubscription(page, channelName)` before `__BUZZ_E2E_EMIT_MOCK_MESSAGE__` — messages are silently dropped without a diff --git a/desktop/tests/e2e/channel-browser.spec.ts b/desktop/tests/e2e/channel-browser.spec.ts index 03f3fce386..2cad71eafb 100644 --- a/desktop/tests/e2e/channel-browser.spec.ts +++ b/desktop/tests/e2e/channel-browser.spec.ts @@ -1,5 +1,6 @@ import { expect, test, type Page } from "@playwright/test"; +import { E2E_APP_ORIGIN, bootstrapE2ePage } from "../helpers/bootstrap"; import { installMockBridge, openChannelBrowser } from "../helpers/bridge"; const MOCK_PUBKEY = "deadbeef".repeat(8); @@ -7,13 +8,18 @@ const CUSTOM_SECTION = { id: "sec-projects", name: "Projects", order: 0 }; async function seedCustomSection(page: Page) { await page.addInitScript( - ({ pubkey, section }) => { + ({ expectedOrigin, pubkey, section }) => { + if (location.origin !== expectedOrigin) return; window.localStorage.setItem( `buzz-channel-sections.v1:${pubkey}`, JSON.stringify({ version: 1, sections: [section], assignments: {} }), ); }, - { pubkey: MOCK_PUBKEY, section: CUSTOM_SECTION }, + { + expectedOrigin: E2E_APP_ORIGIN, + pubkey: MOCK_PUBKEY, + section: CUSTOM_SECTION, + }, ); } @@ -27,7 +33,7 @@ test.beforeEach(async ({ page }, testInfo) => { }); test("keyboard shortcut opens the channel browser dialog", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page); await expect(page.getByTestId("app-sidebar")).toBeVisible(); const isMacBrowser = await page.evaluate(() => @@ -53,7 +59,7 @@ test("keyboard shortcut opens the channel browser dialog", async ({ page }) => { }); test("channel browser shows channels not yet joined", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page); await openChannelBrowser(page); await expect(page.getByTestId("channel-browser-dialog")).toBeVisible(); @@ -72,7 +78,7 @@ test("channel browser shows channels not yet joined", async ({ page }) => { test("channel browser sorts alphabetically or by member count", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page); await openChannelBrowser(page); const rows = page.locator('[data-testid^="browse-channel-"]'); @@ -112,7 +118,7 @@ test("channel browser sorts alphabetically or by member count", async ({ }); test("channel browser sorts by recent activity", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page); await openChannelBrowser(page); const rows = page.locator('[data-testid^="browse-channel-"]'); @@ -139,7 +145,7 @@ test("channel browser sorts by recent activity", async ({ page }) => { }); test("channel browser search filters by name", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page); await openChannelBrowser(page); await expect(page.getByTestId("channel-browser-dialog")).toBeVisible(); @@ -152,7 +158,7 @@ test("channel browser search filters by name", async ({ page }) => { }); test("channel browser search filters by description", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page); await openChannelBrowser(page); await page.getByTestId("channel-browser-search").fill("pipeline"); @@ -165,7 +171,7 @@ test("channel browser search filters by description", async ({ page }) => { test("channel browser shows no results for unmatched search", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page); await openChannelBrowser(page); await page.getByTestId("channel-browser-search").fill("zzz-nonexistent"); @@ -174,7 +180,7 @@ test("channel browser shows no results for unmatched search", async ({ }); test("channel browser fuzzy-matches a subsequence", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page); await openChannelBrowser(page); // "engr" is not a substring of "engineering", but it is an in-order @@ -186,7 +192,7 @@ test("channel browser fuzzy-matches a subsequence", async ({ page }) => { }); test("channel browser matches a scattered subsequence", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page); await openChannelBrowser(page); // "sls" is neither a substring nor a prefix of "sales" — it only matches as @@ -198,7 +204,7 @@ test("channel browser matches a scattered subsequence", async ({ page }) => { }); test("channel browser ranks the best match first", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page); await openChannelBrowser(page); // "gen" is a prefix of "general" (strong match) but only a substring of @@ -216,7 +222,7 @@ test("channel browser ranks the best match first", async ({ page }) => { test("sidebar add-channel button creates without treating the click as a callback", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page); await expect(page.getByTestId("app-sidebar")).toBeVisible(); await page.getByTestId("section-actions-channels-quick-create").click(); @@ -235,7 +241,7 @@ test("custom section add button creates directly into that section", async ({ page, }) => { await seedCustomSection(page); - await page.goto("/"); + await bootstrapE2ePage(page); const addButton = page.getByTestId( `section-actions-${CUSTOM_SECTION.id}-quick-create`, @@ -261,7 +267,7 @@ test("canceling section create does not affect the next global create", async ({ page, }) => { await seedCustomSection(page); - await page.goto("/"); + await bootstrapE2ePage(page); await page .getByTestId(`section-actions-${CUSTOM_SECTION.id}-quick-create`) @@ -290,7 +296,7 @@ test("failed section create retry still assigns to the section", async ({ page, }) => { await seedCustomSection(page); - await page.goto("/"); + await bootstrapE2ePage(page); await page .getByTestId(`section-actions-${CUSTOM_SECTION.id}-quick-create`) @@ -309,7 +315,7 @@ test("failed section create retry still assigns to the section", async ({ }); test("create affordance is visible on open before typing", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page); await openChannelBrowser(page); @@ -323,7 +329,7 @@ test("create affordance is visible on open before typing", async ({ page }) => { test("typing a partial match surfaces a persistent create row", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page); await openChannelBrowser(page); // "desig" matches "design" by substring but is not an exact channel name, @@ -337,7 +343,7 @@ test("typing a partial match surfaces a persistent create row", async ({ }); test("exact name match hides the create row", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page); await openChannelBrowser(page); await page.getByTestId("channel-browser-search").fill("general"); @@ -349,7 +355,7 @@ test("exact name match hides the create row", async ({ page }) => { test("no-match search pins a create row above the empty state", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page); await openChannelBrowser(page); await page.getByTestId("channel-browser-search").fill("zzz-nonexistent"); @@ -361,7 +367,7 @@ test("no-match search pins a create row above the empty state", async ({ }); test("create row leads to the prefilled create form", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page); await openChannelBrowser(page); await page.getByTestId("channel-browser-search").fill("desig"); @@ -380,7 +386,7 @@ test("creating from the browser adds the channel to the sidebar", async ({ }) => { const channelName = `browse-created-${Date.now()}`; - await page.goto("/"); + await bootstrapE2ePage(page); await openChannelBrowser(page); await page.getByTestId("channel-browser-search").fill(channelName); @@ -399,7 +405,7 @@ test("creating from the browser adds the channel to the sidebar", async ({ test("Enter with no matches jumps to create", async ({ page }) => { const channelName = `enter-created-${Date.now()}`; - await page.goto("/"); + await bootstrapE2ePage(page); await openChannelBrowser(page); await page.getByTestId("channel-browser-search").fill(channelName); @@ -413,7 +419,7 @@ test("Enter with no matches jumps to create", async ({ page }) => { test("arrow keys reach the pinned create row and Enter activates it", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page); await openChannelBrowser(page); // "desig" keeps a channel match (#design) AND the create row visible, so the @@ -436,7 +442,7 @@ test("arrow keys reach the pinned create row and Enter activates it", async ({ test("Enter selects a channel when create row is not highlighted", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page); await openChannelBrowser(page); // With the create row present but NOT highlighted, Enter should still select @@ -453,7 +459,7 @@ test("Enter selects a channel when create row is not highlighted", async ({ test("joining a channel from browser adds it to the sidebar", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page); // Verify "design" is not in the sidebar const streamList = page.getByTestId("stream-list"); @@ -479,7 +485,7 @@ test("joining a channel from browser adds it to the sidebar", async ({ test("clicking a joined channel in browser navigates to it", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page); await openChannelBrowser(page); await expect(page.getByTestId("channel-browser-dialog")).toBeVisible(); @@ -495,7 +501,7 @@ test("clicking a joined channel in browser navigates to it", async ({ test("channel browser does not show DM or private channels", async ({ page, }) => { - await page.goto("/"); + await bootstrapE2ePage(page); await openChannelBrowser(page); await expect(page.getByTestId("channel-browser-dialog")).toBeVisible(); @@ -509,7 +515,7 @@ test("channel browser does not show DM or private channels", async ({ }); test("channel browser closes on escape", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page); await openChannelBrowser(page); await expect(page.getByTestId("channel-browser-dialog")).toBeVisible(); @@ -519,7 +525,7 @@ test("channel browser closes on escape", async ({ page }) => { }); test("keyboard navigation works in channel browser", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page); await openChannelBrowser(page); await expect(page.getByTestId("channel-browser-dialog")).toBeVisible(); @@ -536,7 +542,7 @@ test("keyboard navigation works in channel browser", async ({ page }) => { }); test("sidebar only shows channels the user has joined", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page); const streamList = page.getByTestId("stream-list"); diff --git a/desktop/tests/e2e/drafts-all-fix-screenshots.spec.ts b/desktop/tests/e2e/drafts-all-fix-screenshots.spec.ts index 82a7df1cc1..6efde5d927 100644 --- a/desktop/tests/e2e/drafts-all-fix-screenshots.spec.ts +++ b/desktop/tests/e2e/drafts-all-fix-screenshots.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "@playwright/test"; import { waitForAnimations } from "../helpers/animations"; +import { E2E_APP_ORIGIN, bootstrapE2ePage } from "../helpers/bootstrap"; import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge"; const SHOTS = "test-results/drafts-all-fix"; @@ -18,34 +19,39 @@ test("Inbox All hides drafts while the Drafts filter keeps them", async ({ page, }) => { const draftKey = `channel:${GENERAL_CHANNEL_ID}`; - await page.addInitScript( - ({ draftStoreKey, draftStorageKey, channelId }) => { - const timestamp = new Date().toISOString(); - window.localStorage.setItem( - draftStoreKey, - JSON.stringify({ - [draftStorageKey]: { - channelId, - content: "Draft that must stay out of the All view", - createdAt: timestamp, - pendingImeta: [], - selectionEnd: 41, - selectionStart: 41, - spoileredAttachmentUrls: [], - status: "active", - updatedAt: timestamp, - }, - }), + await bootstrapE2ePage(page, { + beforeNavigate: async () => { + await page.addInitScript( + ({ expectedOrigin, draftStoreKey, draftStorageKey, channelId }) => { + if (location.origin !== expectedOrigin) return; + const timestamp = new Date().toISOString(); + window.localStorage.setItem( + draftStoreKey, + JSON.stringify({ + [draftStorageKey]: { + channelId, + content: "Draft that must stay out of the All view", + createdAt: timestamp, + pendingImeta: [], + selectionEnd: 41, + selectionStart: 41, + spoileredAttachmentUrls: [], + status: "active", + updatedAt: timestamp, + }, + }), + ); + }, + { + channelId: GENERAL_CHANNEL_ID, + expectedOrigin: E2E_APP_ORIGIN, + draftStorageKey: draftKey, + draftStoreKey: `buzz-drafts.v2:ws://localhost:3000:${MOCK_IDENTITY_PUBKEY}`, + }, ); + await installMockBridge(page); }, - { - channelId: GENERAL_CHANNEL_ID, - draftStorageKey: draftKey, - draftStoreKey: `buzz-drafts.v2:ws://localhost:3000:${MOCK_IDENTITY_PUBKEY}`, - }, - ); - await installMockBridge(page); - await page.goto("/"); + }); await page.waitForFunction(() => { const win = window as MockFeedWindow; return typeof win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ === "function"; diff --git a/desktop/tests/helpers/bootstrap.ts b/desktop/tests/helpers/bootstrap.ts new file mode 100644 index 0000000000..e5f7d6711d --- /dev/null +++ b/desktop/tests/helpers/bootstrap.ts @@ -0,0 +1,62 @@ +import type { Page } from "@playwright/test"; + +export const E2E_APP_ORIGIN = "http://127.0.0.1:4173"; + +type BootstrapE2ePageOptions = { + /** Relative route to load after all test setup has been registered. */ + path?: string; + /** Test setup such as bridge or storage init-script registration. */ + beforeNavigate?: () => Promise; + expectedOrigin?: string; +}; + +/** + * Registers page setup and commits the first app navigation before a spec uses + * browser storage or page evaluation. This makes an unavailable preview server + * fail at its source instead of later as an opaque-origin storage error. + */ +export async function bootstrapE2ePage( + page: Page, + { + path = "/", + beforeNavigate, + expectedOrigin = E2E_APP_ORIGIN, + }: BootstrapE2ePageOptions = {}, +): Promise { + await beforeNavigate?.(); + + const target = new URL(path, expectedOrigin).toString(); + let response: Awaited>; + try { + response = await page.goto(target); + } catch (error) { + throw bootstrapFailure(expectedOrigin, page.url(), error); + } + + if (!response?.ok()) { + throw bootstrapFailure( + expectedOrigin, + page.url(), + `navigation returned HTTP ${response?.status() ?? "no response"}`, + ); + } + + if (new URL(page.url()).origin !== expectedOrigin) { + throw bootstrapFailure( + expectedOrigin, + page.url(), + "navigation changed origin", + ); + } +} + +function bootstrapFailure( + expectedOrigin: string, + currentUrl: string, + cause: unknown, +): Error { + return new Error( + `E2E app navigation did not commit to ${expectedOrigin} (current URL: ${currentUrl}). ` + + `Run pnpm test:e2e:smoke or run pnpm build:e2e and check the preview server. Cause: ${String(cause)}`, + ); +} diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index dbe8c43323..b05df800ab 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -2,6 +2,7 @@ import type { Page } from "@playwright/test"; import type { ChannelTemplate, RelayEvent } from "../../src/shared/api/types"; import type { MockManagedAgentSeed } from "../../src/testing/e2eBridge"; import { FEATURE_OVERRIDES_STORAGE_KEY, PREVIEW_FEATURE_IDS } from "./features"; +import { E2E_APP_ORIGIN } from "./bootstrap"; export const TEST_IDENTITIES = { tyler: { @@ -741,7 +742,14 @@ async function seedOnboardingCompletionForKnownIdentities( ...Object.values(TEST_IDENTITIES).map(({ pubkey }) => pubkey), ]; await page.addInitScript( - ({ onboardingPrefix, pubkeys: pubkeysToSeed, relayUrl, welcomePrefix }) => { + ({ + expectedOrigin, + onboardingPrefix, + pubkeys: pubkeysToSeed, + relayUrl, + welcomePrefix, + }) => { + if (location.origin !== expectedOrigin) return; const welcomeScope = encodeURIComponent(relayUrl); for (const pubkey of pubkeysToSeed) { window.localStorage.setItem(`${onboardingPrefix}${pubkey}`, "true"); @@ -752,6 +760,7 @@ async function seedOnboardingCompletionForKnownIdentities( } }, { + expectedOrigin: E2E_APP_ORIGIN, onboardingPrefix: ONBOARDING_COMPLETION_STORAGE_KEY_PREFIX, pubkeys, relayUrl: relayWsUrl ?? DEFAULT_RELAY_WS_URL, @@ -766,7 +775,8 @@ async function seedDefaultCommunity( relayWsUrl?: string, ) { await page.addInitScript( - ({ fallback, identityOverrideKey, relayUrl }) => { + ({ expectedOrigin, fallback, identityOverrideKey, relayUrl }) => { + if (location.origin !== expectedOrigin) return; // If seedActiveIdentity() ran before this script (the normal ordering), // use its pubkey so the community matches the active identity and // migrateMachineOnboardingCompletion's strict voucher accepts it. @@ -807,6 +817,7 @@ async function seedDefaultCommunity( window.localStorage.setItem("buzz-active-community-id", communityId); }, { + expectedOrigin: E2E_APP_ORIGIN, fallback: fallbackPubkey, identityOverrideKey: "buzz:e2e-identity-override.v1", relayUrl: relayWsUrl ?? DEFAULT_RELAY_WS_URL, @@ -816,12 +827,17 @@ async function seedDefaultCommunity( async function seedPreviewFeaturesEnabled(page: Page) { await page.addInitScript( - ({ key, ids }) => { + ({ expectedOrigin, key, ids }) => { + if (location.origin !== expectedOrigin) return; const overrides: Record = {}; for (const id of ids) overrides[id] = true; window.localStorage.setItem(key, JSON.stringify(overrides)); }, - { key: FEATURE_OVERRIDES_STORAGE_KEY, ids: PREVIEW_FEATURE_IDS }, + { + expectedOrigin: E2E_APP_ORIGIN, + key: FEATURE_OVERRIDES_STORAGE_KEY, + ids: PREVIEW_FEATURE_IDS, + }, ); } diff --git a/package.json b/package.json index dc31ed0aa0..f9fec52a31 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,8 @@ "private": true, "packageManager": "pnpm@11.4.0", "scripts": { - "check": "pnpm -r check" + "check": "pnpm -r check", + "test:e2e:smoke": "pnpm --dir desktop test:e2e:smoke" }, "devDependencies": { "@biomejs/biome": "^2.4.6" diff --git a/web/tests/e2e/smoke.spec.ts b/web/tests/e2e/smoke.spec.ts index 22e2628165..0d93c48756 100644 --- a/web/tests/e2e/smoke.spec.ts +++ b/web/tests/e2e/smoke.spec.ts @@ -1,15 +1,17 @@ import { createHash } from "node:crypto"; import { expect, test } from "@playwright/test"; +import { bootstrapE2ePage } from "../helpers/bootstrap"; + test("home page loads with Buzz branding", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page); await expect( page.getByRole("main").getByRole("img", { name: "Buzz" }), ).toBeVisible(); }); test("home page shows repositories section", async ({ page }) => { - await page.goto("/"); + await bootstrapE2ePage(page); await expect(page.getByText("Repositories")).toBeVisible(); }); @@ -66,7 +68,7 @@ test("invite requires age and legal consent before opening Buzz", async ({ ]), }); }); - await page.goto("/invite/demo-code"); + await bootstrapE2ePage(page, { path: "/invite/demo-code" }); await expect( page.getByRole("link", { name: "Download it now" }), @@ -124,30 +126,32 @@ test("invite can enroll a NIP-07 identity for browser access", async ({ page, }) => { const pubkey = "ab".repeat(32); - await page.addInitScript((extensionPubkey) => { - ( - window as Window & { - nostr?: { - getPublicKey(): Promise; - signEvent( - event: Record, - ): Promise>; - }; - } - ).nostr = { - async getPublicKey() { - return extensionPubkey; - }, - async signEvent(event) { - return { - ...event, - id: "cd".repeat(32), - pubkey: extensionPubkey, - sig: "ef".repeat(64), - }; - }, - }; - }, pubkey); + const installNip07Extension = async () => { + await page.addInitScript((extensionPubkey) => { + ( + window as Window & { + nostr?: { + getPublicKey(): Promise; + signEvent( + event: Record, + ): Promise>; + }; + } + ).nostr = { + async getPublicKey() { + return extensionPubkey; + }, + async signEvent(event) { + return { + ...event, + id: "cd".repeat(32), + pubkey: extensionPubkey, + sig: "ef".repeat(64), + }; + }, + }; + }, pubkey); + }; await page.route("**/api/join-policy", async (route) => { await route.fulfill({ status: 200, @@ -195,7 +199,10 @@ test("invite can enroll a NIP-07 identity for browser access", async ({ }); }); - await page.goto("/invite/browser-code"); + await bootstrapE2ePage(page, { + beforeNavigate: installNip07Extension, + path: "/invite/browser-code", + }); await page.getByRole("button", { name: "Join in browser" }).click(); await expect(page).toHaveURL("/"); expect(claimObserved).toBe(true); @@ -227,7 +234,7 @@ test("invite asks Safari users to choose their Mac download", async ({ await route.fulfill({ status: 500 }); }); - await page.goto("/invite/demo-code"); + await bootstrapE2ePage(page, { path: "/invite/demo-code" }); const download = page.getByRole("link", { name: "Download it now" }); await expect(download).toHaveAttribute("aria-haspopup", "dialog"); await download.click(); @@ -339,7 +346,7 @@ test("invite download falls back for mobile and non-desktop devices", async ({ }); }); - await page.goto("/invite/demo-code"); + await bootstrapE2ePage(page, { path: "/invite/demo-code" }); await expect( page.getByRole("link", { name: "Download it now" }), device.name, diff --git a/web/tests/helpers/bootstrap.ts b/web/tests/helpers/bootstrap.ts new file mode 100644 index 0000000000..a335024509 --- /dev/null +++ b/web/tests/helpers/bootstrap.ts @@ -0,0 +1,62 @@ +import type { Page } from "@playwright/test"; + +export const E2E_APP_ORIGIN = "http://127.0.0.1:4173"; + +type BootstrapE2ePageOptions = { + /** Relative route to load after all test setup has been registered. */ + path?: string; + /** Test setup such as extension or storage init-script registration. */ + beforeNavigate?: () => Promise; + expectedOrigin?: string; +}; + +/** + * Registers page setup and commits the first app navigation before a spec uses + * browser storage or page evaluation. This makes an unavailable preview server + * fail at its source instead of later as an opaque-origin storage error. + */ +export async function bootstrapE2ePage( + page: Page, + { + path = "/", + beforeNavigate, + expectedOrigin = E2E_APP_ORIGIN, + }: BootstrapE2ePageOptions = {}, +): Promise { + await beforeNavigate?.(); + + const target = new URL(path, expectedOrigin).toString(); + let response: Awaited>; + try { + response = await page.goto(target); + } catch (error) { + throw bootstrapFailure(expectedOrigin, page.url(), error); + } + + if (!response?.ok()) { + throw bootstrapFailure( + expectedOrigin, + page.url(), + `navigation returned HTTP ${response?.status() ?? "no response"}`, + ); + } + + if (new URL(page.url()).origin !== expectedOrigin) { + throw bootstrapFailure( + expectedOrigin, + page.url(), + "navigation changed origin", + ); + } +} + +function bootstrapFailure( + expectedOrigin: string, + currentUrl: string, + cause: unknown, +): Error { + return new Error( + `E2E app navigation did not commit to ${expectedOrigin} (current URL: ${currentUrl}). ` + + `Run pnpm test:e2e:smoke or run pnpm build and check the preview server. Cause: ${String(cause)}`, + ); +}