test(e2e): verify app origin before storage setup

Signed-off-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz>
This commit is contained in:
Brother Darryl
2026-08-17 11:39:38 -04:00
parent 93114c9c65
commit 29049db453
8 changed files with 259 additions and 95 deletions

View File

@@ -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

View File

@@ -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");

View File

@@ -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";

View File

@@ -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<void>;
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<void> {
await beforeNavigate?.();
const target = new URL(path, expectedOrigin).toString();
let response: Awaited<ReturnType<Page["goto"]>>;
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)}`,
);
}

View File

@@ -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<string, boolean> = {};
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,
},
);
}

View File

@@ -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"

View File

@@ -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<string>;
signEvent(
event: Record<string, unknown>,
): Promise<Record<string, unknown>>;
};
}
).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<string>;
signEvent(
event: Record<string, unknown>,
): Promise<Record<string, unknown>>;
};
}
).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,

View File

@@ -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<void>;
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<void> {
await beforeNavigate?.();
const target = new URL(path, expectedOrigin).toString();
let response: Awaited<ReturnType<Page["goto"]>>;
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)}`,
);
}