import { hexToBytes } from "@noble/hashes/utils.js"; import { expect, test, type Page } from "@playwright/test"; import { nsecEncode } from "nostr-tools/nip19"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; import { installFakeCamera } from "../helpers/fakeCamera"; import { E2E_IDENTITY_OVERRIDE_STORAGE_KEY, seedActiveIdentity, } from "../helpers/onboarding"; type RelayConnectionState = | "connected" | "connecting" | "disconnected" | "idle" | "reconnecting" | "stalled"; /** * Drive the mock relay connection state via the E2E bridge seam. * When targeting a degraded state, waits for baseline "connected" first so * the mock auth handshake can't race the override back to "connected". */ async function setRelayConnectionState( page: Page, state: RelayConnectionState, ) { await page.waitForFunction( () => typeof ( window as Window & { __BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: unknown; } ).__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__ === "function", ); await page.evaluate((nextState) => { const testWindow = window as Window & { __BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: ( state: RelayConnectionState, ) => void; }; const setConnectionState = testWindow.__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__; if (!setConnectionState) { throw new Error("Mock relay connection state helper is not installed."); } // Open-relay onboarding may not start a socket before the test exercises // connectivity UI. Establish the same connected baseline explicitly so a // delayed mock handshake cannot overwrite the degraded state. if (nextState !== "connected") { setConnectionState("connected"); } setConnectionState(nextState); }, state); } const HOME_SEEN_STORAGE_KEY_PREFIX = "buzz-home-feed-seen.v1:"; const COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY = "buzz-community-onboarding-transaction.v1"; const ONE_PIXEL_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8); const BLANK_TYLER_IDENTITY = { ...TEST_IDENTITIES.tyler, username: "", }; const BLANK_AVATAR_PLACEHOLDER_IDENTITY = { ...TEST_IDENTITIES.tyler, pubkey: "1".repeat(64), username: "", }; const BLANK_AVATAR_EMOJI_IDENTITY = { ...TEST_IDENTITIES.tyler, pubkey: "2".repeat(64), username: "", }; const FIRST_RUN_ALICE = { ...TEST_IDENTITIES.alice, username: "", }; async function seedOnboardingCompletion(page: Page, pubkey: string) { await page.addInitScript( ({ storageKey }) => { window.localStorage.setItem(storageKey, "true"); }, { storageKey: `buzz-onboarding-complete.v1:${pubkey}`, }, ); } async function seedCommunityProfileStage(page: Page, id: string) { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await page.addInitScript( ({ pubkey, transactionId, transactionStorageKey }) => { window.localStorage.setItem( `buzz-machine-onboarding-complete.v2:${pubkey}`, "true", ); const timestamp = new Date().toISOString(); window.localStorage.setItem( transactionStorageKey, JSON.stringify({ id: transactionId, source: "first-community", stage: "profile", relayUrl: "wss://default.example.com", communityName: "Default", communityId: "e2e-default-community", addedCommunity: true, createdAt: timestamp, updatedAt: timestamp, }), ); }, { pubkey: BLANK_TYLER_IDENTITY.pubkey, transactionId: id, transactionStorageKey: COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY, }, ); } async function uploadCommunityAvatar(page: Page, filename: string) { await page.getByTestId("community-avatar-open").click(); await page.getByTestId("community-avatar-input").setInputFiles({ buffer: Buffer.from(ONE_PIXEL_PNG_BASE64, "base64"), mimeType: "image/png", name: filename, }); await page.getByTestId("community-avatar-done").click(); } async function readHomeSeenStorageKeys(page: Page) { return page.evaluate((prefix) => { return Object.keys(window.localStorage).filter((key) => key.startsWith(prefix), ); }, HOME_SEEN_STORAGE_KEY_PREFIX); } async function expectNoHomeSeenEntries(page: Page) { await expect.poll(async () => readHomeSeenStorageKeys(page)).toEqual([]); } async function selectFirstEmojiFromPicker(page: Page) { const picker = page.locator("em-emoji-picker"); await expect(picker).toBeVisible(); await expect .poll(() => picker.evaluate((element) => Boolean(element.shadowRoot?.querySelector(".scroll button")), ), ) .toBe(true); await picker.evaluate((element) => { const button = element.shadowRoot?.querySelector(".scroll button"); if (!(button instanceof HTMLElement)) { throw new Error("Emoji picker did not render an emoji button."); } button.click(); }); } async function expectShellHidden(page: Page) { await expect(page.getByTestId("app-sidebar")).toHaveCount(0); await expect(page.getByTestId("chat-title")).toHaveCount(0); } async function expectHomeView(page: Page) { await expect(page.getByTestId("home-inbox-list")).toBeVisible(); } async function expectWiderThanTall(locator: Locator) { const box = await locator.boundingBox(); if (!box) { throw new Error("Could not measure welcome intro action card"); } expect(box.width).toBeGreaterThan(box.height); } async function expectIntroActionIconStackedAboveTitle( action: Locator, title: string, ) { const iconBox = await action.locator("svg").first().boundingBox(); const titleBox = await action.getByText(title, { exact: true }).boundingBox(); if (!iconBox || !titleBox) { throw new Error("Could not measure welcome intro action content"); } expect(titleBox.y).toBeGreaterThan(iconBox.y + iconBox.height); } async function expectWelcomeComposerBannerLayout(page: Page) { const banner = page.getByTestId("welcome-composer-guide-banner"); const personaMention = page.getByTestId("welcome-composer-persona-mention"); const composer = page.getByTestId("message-composer"); const bannerBox = await banner.boundingBox(); const personaMentionBox = await personaMention.boundingBox(); const composerBox = await composer.boundingBox(); const dockBackdropBox = await page .getByTestId("composer-dock-backdrop") .locator("div") .boundingBox(); const guidanceLayer = page.getByTestId("welcome-composer-guidance-layer"); const guidanceLayerBox = await guidanceLayer.boundingBox(); const guidanceBackdrop = page.getByTestId( "welcome-composer-guidance-backdrop", ); const guidanceBackdropBox = await guidanceBackdrop.boundingBox(); if ( !bannerBox || !personaMentionBox || !composerBox || !dockBackdropBox || !guidanceLayerBox || !guidanceBackdropBox ) { throw new Error("Could not measure welcome composer banner layout"); } expect( await composer.getByTestId("welcome-composer-guide-banner").count(), ).toBe(0); expect(bannerBox.y).toBeLessThan(composerBox.y); // Banner is in normal flow above the composer, no overlap. expect(bannerBox.y + bannerBox.height).toBeLessThanOrEqual(composerBox.y); // The dock backdrop is absolute inset-y-0 inside composer-dock, which now // contains the guidance layer + composer in flow, so its top aligns with the // guidance layer top (not the composer top). expect(Math.abs(dockBackdropBox.y - guidanceLayerBox.y)).toBeLessThanOrEqual( 1, ); expect(guidanceBackdropBox.y).toBeLessThanOrEqual(bannerBox.y); // The guidance backdrop extends bottom-3 (12px) short of the banner's bottom, // visually connecting up to the composer. expect(guidanceBackdropBox.y + guidanceBackdropBox.height).toBeLessThan( composerBox.y, ); expect( await page .getByTestId("channel-composer-overlay") .getByTestId("welcome-composer-guidance-layer") .count(), ).toBe(1); expect( await page .getByTestId("composer-dock-backdrop") .getByTestId("welcome-composer-guidance-layer") .count(), ).toBe(0); const radii = await banner.evaluate((element) => { const styles = window.getComputedStyle(element); return { backdropFilter: styles.backdropFilter, backgroundColor: styles.backgroundColor, bottomLeft: styles.borderBottomLeftRadius, bottomRight: styles.borderBottomRightRadius, filter: styles.filter, zIndex: styles.zIndex, topLeft: styles.borderTopLeftRadius, topRight: styles.borderTopRightRadius, transform: styles.transform, willChange: styles.willChange, }; }); const composerBackgroundColor = await composer.evaluate( (element) => window.getComputedStyle(element).backgroundColor, ); const dockBackdropFilter = await page .getByTestId("composer-dock-backdrop") .locator("div") .evaluate((element) => window.getComputedStyle(element).backdropFilter); const guidanceBackdropFilter = await guidanceBackdrop.evaluate( (element) => window.getComputedStyle(element).backdropFilter, ); expect(radii.topLeft).toBe(radii.topRight); expect(radii.bottomLeft).toBe("0px"); expect(radii.bottomRight).toBe("0px"); expect(radii.backdropFilter).toBe("none"); expect(radii.backgroundColor).not.toBe(composerBackgroundColor); expect(dockBackdropFilter).not.toBe("none"); expect(guidanceBackdropFilter).toBe(dockBackdropFilter); expect(radii.filter).toBe("none"); expect(radii.transform).toBe("none"); expect(radii.willChange).toBe("auto"); expect(radii.zIndex).toBe("1"); expect(personaMentionBox.width).toBeGreaterThan(0); } async function expectWelcomePersonaMention(page: Page) { const banner = page.getByTestId("welcome-composer-guide-banner"); const personaMention = page.getByTestId("welcome-composer-persona-mention"); await expect(personaMention).toBeVisible(); await expect(personaMention).toHaveAttribute("data-persona-options", "Fizz"); await expect(personaMention).toHaveAttribute("data-active-persona", "Fizz"); await expect(personaMention).toHaveAttribute( "data-animation-target", "per-character", ); const activePersona = await personaMention.getAttribute( "data-active-persona", ); expect(activePersona).not.toBeNull(); await expect(personaMention).toContainText(`@${activePersona}`); expect( await personaMention .getByTestId("welcome-composer-persona-character") .count(), ).toBeGreaterThanOrEqual(4); expect( await personaMention .getByTestId("welcome-composer-persona-character") .first() .evaluate((element) => window.getComputedStyle(element).filter), ).toBe("none"); const transition = await personaMention.evaluate((element) => { const styles = window.getComputedStyle(element); const durationMs = Number( element.getAttribute("data-width-animation-duration-ms"), ); return { duration: styles.transitionDuration, durationMs, property: styles.transitionProperty, }; }); expect(transition.durationMs).toBeGreaterThanOrEqual(700); expect(transition.durationMs).toBeLessThanOrEqual(740); expect(Math.round(Number.parseFloat(transition.duration) * 1000)).toBe( transition.durationMs, ); expect(transition.property).toContain("width"); const alignment = await personaMention.evaluate((element) => { const mentionStyles = window.getComputedStyle(element); const bannerStyles = window.getComputedStyle( element.closest('[data-testid="welcome-composer-guide-banner"]') ?? element, ); return { display: mentionStyles.display, lineHeightMatchesBanner: mentionStyles.lineHeight === bannerStyles.lineHeight, verticalAlign: mentionStyles.verticalAlign, }; }); expect(alignment.display).toBe("inline-block"); expect(alignment.verticalAlign).toBe("baseline"); expect(alignment.lineHeightMatchesBanner).toBe(true); await expect(banner).toContainText("Mention"); } async function expectPrivateWelcomeLanding(page: Page) { await expect(page).toHaveURL(/#\/channels\/[^/?#]+$/); await expect(page.getByTestId("channel-Welcome")).toBeVisible(); await expect(page.getByTestId("chat-title")).toContainText("Welcome"); } async function expectWelcomeView(page: Page) { await expectPrivateWelcomeLanding(page); await expect(page.getByTestId("channel-general")).toBeVisible(); await expect(page.getByTestId("channel-welcome-everyone")).toBeVisible(); await expect(page.getByTestId("channel-ephemeral-Welcome")).toHaveCount(0); await expect(page.getByTestId("chat-ephemeral-badge")).toHaveCount(0); await expect(page.getByTestId("message-unread-pill")).toHaveCount(0); await expect(page.getByTestId("message-channel-intro")).toBeVisible(); await expect(page.getByTestId("message-channel-intro")).toContainText( "private welcome channel", ); await expect( page.getByTestId("message-channel-intro").getByRole("button"), ).toHaveText(["Browse channels", "Create a channel", "Create an agent"]); await expect( page.getByTestId("welcome-intro-action-browse-channels"), ).toBeVisible(); await expectWiderThanTall( page.getByTestId("welcome-intro-action-browse-channels"), ); await expectIntroActionIconStackedAboveTitle( page.getByTestId("welcome-intro-action-browse-channels"), "Browse channels", ); await page.getByTestId("welcome-intro-action-browse-channels").click(); await expect(page.getByTestId("channel-browser-dialog")).toBeVisible(); await expect(page.getByTestId("channel-browser-search")).toBeFocused(); await expect(page.getByRole("tab", { name: "All channels" })).toHaveAttribute( "data-state", "active", ); await page.keyboard.press("Escape"); await expect(page.getByTestId("channel-browser-dialog")).toHaveCount(0); await expect( page.getByTestId("welcome-intro-action-create-channel"), ).toBeVisible(); await expectWiderThanTall( page.getByTestId("welcome-intro-action-create-channel"), ); await expectIntroActionIconStackedAboveTitle( page.getByTestId("welcome-intro-action-create-channel"), "Create a channel", ); await expect( page .getByTestId("welcome-intro-action-create-channel") .getByText("Create a channel", { exact: true }), ).toHaveCSS("white-space", "normal"); await expect( page.getByTestId("welcome-intro-action-create-agent"), ).toBeVisible(); await expectWiderThanTall( page.getByTestId("welcome-intro-action-create-agent"), ); await expectIntroActionIconStackedAboveTitle( page.getByTestId("welcome-intro-action-create-agent"), "Create an agent", ); await expect(page.getByTestId("message-composer")).toBeVisible(); await expect(page.getByTestId("welcome-composer-guide-banner")).toBeVisible(); await expect(page.getByTestId("welcome-composer-guide-banner")).toContainText( "Mention", ); await expect(page.getByTestId("welcome-composer-guide-banner")).toContainText( "whenever you want their help.", ); await expectWelcomePersonaMention(page); await expectWelcomeComposerBannerLayout(page); } async function expectWelcomeComposerBannerCompletesAfterPersonaMention( page: Page, ) { const banner = page.getByTestId("welcome-composer-guide-banner"); const channelIntro = page.getByTestId("message-channel-intro"); const composer = page.getByTestId("message-composer"); const initialComposerBox = await composer.boundingBox(); if (!initialComposerBox) { throw new Error("Could not measure the Welcome composer"); } await page.getByTestId("message-input").fill("Thanks @Fizz"); await page.getByTestId("send-message").click(); await expect(banner).toHaveAttribute("data-state", "complete"); await expect(banner).toHaveAttribute("data-tone", "success"); await expect( banner.getByTestId("welcome-composer-complete-icon"), ).toBeVisible(); await expect( banner.locator('[data-animation-target="success-icon"]'), ).toBeVisible(); await expect( banner.locator('[data-animation-target="success-copy"]'), ).toBeVisible(); await expect(banner).toContainText("Nice work."); await expect(banner).not.toContainText("Try mentioning"); await expect(channelIntro).toBeVisible(); const completeComposerBox = await composer.boundingBox(); expect(completeComposerBox).not.toBeNull(); expect( Math.abs((completeComposerBox?.y ?? 0) - initialComposerBox.y), ).toBeLessThanOrEqual(1); await expect(banner).toHaveCount(0, { timeout: 6_000 }); const hiddenComposerBox = await composer.boundingBox(); expect(hiddenComposerBox).not.toBeNull(); expect( Math.abs((hiddenComposerBox?.y ?? 0) - initialComposerBox.y), ).toBeLessThanOrEqual(1); } async function getMockChannels(page: Page) { return page.evaluate(async () => { const bridgeWindow = window as Window & { __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: ( command: string, payload?: Record, ) => Promise; __TAURI_INTERNALS__?: { invoke?: ( command: string, payload?: Record, ) => Promise; }; }; const invoke = bridgeWindow.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ ?? bridgeWindow.__TAURI_INTERNALS__?.invoke; if (!invoke) { throw new Error("Mock invoke bridge is unavailable."); } return (await invoke("get_channels")) as Array<{ id: string; name: string; channel_type: string; visibility: "open" | "private"; member_count: number; is_member: boolean; ttl_seconds: number | null; }>; }); } async function invokeMockCommand( page: Page, command: string, payload?: Record, ) { return page.evaluate( async ({ command, payload }) => { const bridgeWindow = window as Window & { __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: ( command: string, payload?: Record, ) => Promise; __TAURI_INTERNALS__?: { invoke?: ( command: string, payload?: Record, ) => Promise; }; }; const invoke = bridgeWindow.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ ?? bridgeWindow.__TAURI_INTERNALS__?.invoke; if (!invoke) { throw new Error("Mock invoke bridge is unavailable."); } return (await invoke(command, payload)) as T; }, { command, payload }, ); } async function seedCurrentAvatar(page: Page, avatarUrl: string) { await page.waitForFunction(() => { const bridgeWindow = window as Window & { __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown; __TAURI_INTERNALS__?: { invoke?: unknown }; }; return ( typeof bridgeWindow.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function" || typeof bridgeWindow.__TAURI_INTERNALS__?.invoke === "function" ); }); await invokeMockCommand(page, "update_profile", { avatarUrl }); await page.evaluate(() => { window.__BUZZ_E2E_COMMAND_PAYLOADS__ = []; }); } async function getWelcomeChannelId(page: Page) { const channels = await getMockChannels(page); return ( channels.find( (channel) => channel.name === "Welcome" && channel.visibility === "private", )?.id ?? null ); } async function expectStarterChannels(page: Page) { await expect .poll(async () => { const channels = await getMockChannels(page); return ["general", "welcome-everyone"].map((name) => { const channel = channels.find( (candidate) => candidate.name === name && candidate.visibility === "open", ); if (!channel) { return null; } return { channelType: channel.channel_type, isMember: channel.is_member, memberCountAtLeastOne: channel.member_count >= 1, ttlSeconds: channel.ttl_seconds, visibility: channel.visibility, }; }); }) .toEqual([ { channelType: "stream", isMember: true, memberCountAtLeastOne: true, ttlSeconds: null, visibility: "open", }, { channelType: "stream", isMember: true, memberCountAtLeastOne: true, ttlSeconds: null, visibility: "open", }, ]); } async function expectWelcomeGuideIntro( page: Page, { expectVisible = true }: { expectVisible?: boolean } = {}, ) { await expect .poll(async () => { const channelId = await getWelcomeChannelId(page); if (!channelId) { return null; } const [members, agents] = await Promise.all([ invokeMockCommand<{ members: Array<{ pubkey: string; role: string; is_agent: boolean }>; }>(page, "get_channel_members", { channelId }), invokeMockCommand< Array<{ pubkey: string; name: string; persona_id: string | null }> >(page, "list_managed_agents"), ]); const fizz = agents.find( (agent) => agent.name === "Fizz" && agent.persona_id === "builtin:fizz", ); const fizzMember = fizz ? members.members.find((member) => member.pubkey === fizz.pubkey) : null; const profileAvatarUrl = fizz ? ( await invokeMockCommand<{ profiles: Record; }>(page, "get_users_batch", { pubkeys: [fizz.pubkey], }) ).profiles[fizz.pubkey]?.avatar_url : null; return { fizzIsBot: fizzMember?.role === "bot" && fizzMember.is_agent, fizzPersonaId: fizz?.persona_id ?? null, profileAvatarUrl, }; }) .toEqual({ fizzIsBot: true, fizzPersonaId: "builtin:fizz", profileAvatarUrl: null, }); if (expectVisible) { await expect(page.getByTestId("message-channel-intro")).toBeVisible(); await expect(page.getByTestId("system-message-row")).toHaveCount(0); } } async function expectIncompleteOnboarding(page: Page) { await expect(page.getByTestId("onboarding-gate")).toBeVisible(); await expectShellHidden(page); await expect(page.getByTestId("onboarding-page-1")).toBeVisible(); await expect(page.getByTestId("onboarding-display-name")).toHaveValue(""); } async function completeProfileOnboarding(page: Page) { await page.getByTestId("onboarding-next").click(); await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible(); await page .getByTestId("onboarding-avatar-url") .fill("https://example.com/onboarding-avatar.png"); await page.getByTestId("onboarding-next").click(); } test("completed users skip the loading gate while profile is still settling", async ({ page, }) => { await seedOnboardingCompletion(page, DEFAULT_MOCK_PUBKEY); await installMockBridge(page, { profileReadDelayMs: 3_000, }); await page.goto("/"); await expect(page.getByTestId("onboarding-gate")).toHaveCount(0); await expectHomeView(page); }); test("fresh existing-identity path leads with private-key recovery", async ({ page, }) => { await installMockBridge(page, undefined, { skipCommunitySeed: true, skipOnboardingSeed: true, }); await page.goto("/"); await page.getByRole("button", { name: "Use an existing key" }).click(); await expect( page.getByRole("heading", { name: "Enter your private key" }), ).toBeVisible(); await expect( page.getByText("Paste your private key to sign in to Buzz."), ).toBeVisible(); await expect(page.getByTestId("nostr-import-card")).toBeVisible(); await expect(page.getByTestId("nostr-import-file-button")).toHaveText( "backup file", ); await expect(page.getByTestId("nostr-import-phone-link")).toHaveText( "recover from your phone", ); await expect(page.getByTestId("identity-recovery-pairing")).toHaveCount(0); await page.getByTestId("nostr-import-file-button").click(); const backupDialog = page.getByTestId("backup-recovery-dialog"); await expect(backupDialog).toBeVisible(); await expect( backupDialog.getByRole("heading", { name: "Restore from a backup file" }), ).toBeVisible(); await expect( backupDialog.getByTestId("nostr-import-backup-picker"), ).toBeVisible(); const unlockPreview = backupDialog.getByTestId("backup-file-unlock-preview"); await expect(unlockPreview).toBeVisible(); await expect(unlockPreview.locator("span")).toHaveCount(17); await expect( unlockPreview.getByTestId("backup-file-key-dots").locator("span"), ).toHaveCount(9); await expect( unlockPreview.getByTestId("backup-file-unlock-preview-icon"), ).toBeVisible(); await expect( backupDialog.getByTestId("nostr-import-backup-drop"), ).toHaveCount(0); await backupDialog .getByTestId("nostr-import-backup-picker") .evaluate((element) => { const dataTransfer = new DataTransfer(); dataTransfer.items.add( new File(["backup"], "identity.ncryptsec", { type: "text/plain" }), ); element.dispatchEvent( new DragEvent("dragenter", { bubbles: true, cancelable: true, dataTransfer, }), ); }); const backupDrop = backupDialog.getByTestId("nostr-import-backup-drop"); await expect(backupDrop).toHaveAttribute("data-dragging", "true"); await expect(backupDrop).toContainText("Drop your backup file here"); const [backupDropBox, backupFileSectionBox] = await Promise.all([ backupDrop.boundingBox(), unlockPreview.boundingBox(), ]); expect(backupDropBox?.width).toBeGreaterThan( backupFileSectionBox?.width ?? 0, ); await expect( backupDialog.getByTestId("nostr-import-backup-picker"), ).toBeVisible(); await backupDrop.evaluate((element) => { element.dispatchEvent( new DragEvent("dragleave", { bubbles: true, cancelable: true }), ); }); await expect(backupDrop).toHaveCount(0); await expect(page.getByTestId("nostr-import-card")).toBeVisible(); await backupDialog.getByRole("button", { name: "Close" }).click(); await page.getByTestId("nostr-import-phone-link").click(); const phoneDialog = page.getByTestId("phone-recovery-dialog"); await expect(phoneDialog).toBeVisible(); await expect( phoneDialog.getByRole("heading", { name: "Use your Buzz identity" }), ).toBeVisible(); await expect(phoneDialog.getByTestId("identity-recovery-qr")).toBeVisible(); await expect(page.getByTestId("nostr-import-card")).toBeVisible(); }); test("first-launch key import continues to machine setup", async ({ page }) => { await installMockBridge(page, undefined, { skipCommunitySeed: true, skipOnboardingSeed: true, }); await page.goto("/"); await page.getByRole("button", { name: "Use an existing key" }).click(); const importedNsec = nsecEncode(hexToBytes(TEST_IDENTITIES.alice.privateKey)); await page.getByTestId("nostr-import-nsec-input").fill(importedNsec); await page.getByTestId("nostr-import-submit").click(); await expect(page.getByTestId("onboarding-page-2")).toBeVisible(); await expect(page.getByTestId("machine-onboarding-gate")).toBeVisible(); await expect(page.getByTestId("app-loading-gate")).toHaveCount(0); }); test("imported-key users can skip out of harness setup", async ({ page }) => { // Regression: importing an existing key sets the onboarding state machine's // "continuing" marker, which pinned the stage to onboarding even after // complete() ran — so Skip/Next silently did nothing. The fresh-key skip // tests never exercised the import path, so this gap shipped. Prove an // imported-key user actually leaves onboarding on Skip. await installMockBridge(page, undefined, { skipCommunitySeed: true, skipOnboardingSeed: true, }); await page.goto("/"); await page.getByRole("button", { name: "Use an existing key" }).click(); const importedNsec = nsecEncode(hexToBytes(TEST_IDENTITIES.alice.privateKey)); await page.getByTestId("nostr-import-nsec-input").fill(importedNsec); await page.getByTestId("nostr-import-submit").click(); await expect(page.getByTestId("onboarding-page-2")).toBeVisible(); await page.getByTestId("onboarding-setup-skip").click(); // Reaching community onboarding proves machine onboarding completed rather // than staying pinned on the setup step. await expect(page.getByText("Join or create a community")).toBeVisible(); await expect(page.getByTestId("onboarding-page-2")).toHaveCount(0); }); test("first-launch encrypted backup import asks for a passphrase and continues", async ({ page, }) => { await page.emulateMedia({ reducedMotion: "reduce" }); await installMockBridge(page, undefined, { skipCommunitySeed: true, skipOnboardingSeed: true, }); await page.goto("/"); await page.getByRole("button", { name: "Use an existing key" }).click(); // Spec-vector blob the mock bridge accepts with the mock passphrase. const mockNcryptsec = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p"; await page .getByTestId("nostr-import-nsec-input") .fill(mockNcryptsec.slice(0, -1)); await expect( page.getByRole("heading", { name: "Enter your private key" }), ).toBeVisible(); await expect(page.getByTestId("nostr-import-passphrase")).toHaveCount(0); await page .getByTestId("nostr-import-nsec-input") .pressSequentially(mockNcryptsec.slice(-1)); // A complete, checksummed NIP-49 value advances immediately without // submitting. The password stage updates its copy, illustration, and focus. await expect( page.getByRole("heading", { name: "Unlock your account" }), ).toBeVisible(); await expect(page.getByTestId("backup-password-timeline")).toBeVisible(); await expect(page.getByTestId("restore-ncryptsec-affordance")).toBeVisible(); await expect(page.getByTestId("restore-unlock-icon")).toBeVisible(); await expect(page.getByTestId("nostr-import-card")).toHaveCount(0); await expect(page.getByTestId("nostr-import-file-button")).toHaveCount(0); await expect(page.getByTestId("nostr-import-passphrase")).toBeFocused(); await expect(page.getByTestId("nostr-import-submit")).toBeDisabled(); // Wrong passphrase surfaces the decrypt error and stays on the form. await page.getByTestId("nostr-import-passphrase").fill("wrong passphrase"); await page.getByTestId("nostr-import-submit").click(); await expect(page.getByTestId("nostr-import-feedback")).toContainText( /wrong backup password/i, ); await page .getByTestId("nostr-import-passphrase") .fill("mock horse battery staple lake orbit"); await page.getByTestId("nostr-import-submit").click(); await expect(page.getByTestId("onboarding-page-2")).toBeVisible(); await expect(page.getByTestId("machine-onboarding-gate")).toBeVisible(); }); test("first-launch import accepts an .ncryptsec backup file", async ({ page, }) => { await installMockBridge(page, undefined, { skipCommunitySeed: true, skipOnboardingSeed: true, }); await page.goto("/"); await page.getByRole("button", { name: "Use an existing key" }).click(); // The spotlight variant must expose a file path: a wiped user returns with // exactly the identity.ncryptsec our own save dialog produced. The accept // attribute is asserted explicitly because setInputFiles bypasses it — the // OS picker is what filters on it in real use. await page.getByTestId("nostr-import-file-button").click(); const fileInput = page .getByTestId("backup-recovery-dialog") .getByTestId("nostr-import-file-input"); await expect(fileInput).toHaveAttribute( "accept", ".key,.ncryptsec,text/plain", ); await fileInput.setInputFiles({ buffer: Buffer.alloc(1_025, "x"), mimeType: "text/plain", name: "not-a-backup.txt", }); await expect( page .getByTestId("backup-recovery-dialog") .getByTestId("nostr-import-feedback"), ).toContainText(/too large to be a key backup/i); // Spec-vector blob the mock bridge accepts with the mock passphrase. const mockNcryptsec = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p"; // File contents advance to the password stage inside the same dialog. const backupDialog = page.getByTestId("backup-recovery-dialog"); const backupFileSection = backupDialog.getByTestId( "nostr-import-backup-file-section", ); const backupFileSectionHeight = await backupFileSection.evaluate((element) => Number.parseFloat(getComputedStyle(element).height), ); expect(backupFileSectionHeight).toBe(312); const backupPicker = backupDialog.getByTestId("nostr-import-backup-picker"); await backupPicker.evaluate((element) => { const dataTransfer = new DataTransfer(); dataTransfer.items.add( new File(["backup"], "identity.ncryptsec", { type: "text/plain" }), ); element.dispatchEvent( new DragEvent("dragenter", { bubbles: true, cancelable: true, dataTransfer, }), ); }); const backupDrop = backupDialog.getByTestId("nostr-import-backup-drop"); await expect(backupDrop).toBeVisible(); await backupDrop.evaluate((element, contents) => { const dataTransfer = new DataTransfer(); dataTransfer.items.add( new File([contents], "identity.ncryptsec", { type: "text/plain" }), ); element.dispatchEvent( new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer, }), ); }, `${mockNcryptsec}\n`); await expect( backupDialog.getByTestId("backup-password-timeline"), ).toBeVisible(); const passphraseSection = backupDialog.getByTestId( "nostr-import-passphrase-section", ); await expect(passphraseSection).toBeVisible(); const passphraseSectionHeight = await passphraseSection.evaluate((element) => Number.parseFloat(getComputedStyle(element).height), ); expect(passphraseSectionHeight).toBe(backupFileSectionHeight); await expect( backupDialog.getByTestId("nostr-import-passphrase"), ).toBeFocused(); // Back first returns to backup-file selection instead of closing the dialog. await backupDialog.getByRole("button", { name: "Back", exact: true }).click(); await expect( backupDialog.getByRole("heading", { name: "Restore from a backup file" }), ).toBeVisible(); await expect( backupDialog.getByTestId("nostr-import-backup-picker"), ).toBeVisible(); await fileInput.setInputFiles({ buffer: Buffer.from(`${mockNcryptsec}\n`), mimeType: "text/plain", name: "identity.ncryptsec", }); await backupDialog .getByTestId("nostr-import-passphrase") .fill("mock horse battery staple lake orbit"); await backupDialog.getByTestId("nostr-import-submit").click(); await expect(page.getByTestId("onboarding-page-2")).toBeVisible(); await expect(page.getByTestId("machine-onboarding-gate")).toBeVisible(); }); test("non-local runtime override keeps community selection without release flag", async ({ page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await page.addInitScript((pubkey) => { window.localStorage.setItem( `buzz-machine-onboarding-complete.v2:${pubkey}`, "true", ); }, BLANK_TYLER_IDENTITY.pubkey); await installMockBridge(page, undefined, { relayWsUrl: "wss://override.example.com", skipOnboardingSeed: true, skipCommunitySeed: true, }); await page.goto("/"); await expect( page.getByRole("button", { name: /Join a community/ }), ).toBeVisible(); await expect .poll(() => page.evaluate(() => localStorage.getItem("buzz-communities"))) .toBeNull(); }); test("non-local default auto-connects when the release flag is enabled", async ({ page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await page.addInitScript((pubkey) => { window.localStorage.setItem( `buzz-machine-onboarding-complete.v2:${pubkey}`, "true", ); }, BLANK_TYLER_IDENTITY.pubkey); await installMockBridge(page, undefined, { relayWsUrl: "wss://default.example.com", autoConnectDefaultRelay: true, skipOnboardingSeed: true, skipCommunitySeed: true, }); await page.goto("/"); await expectIncompleteOnboarding(page); await expect .poll(() => page.evaluate(() => { const raw = window.localStorage.getItem("buzz-communities"); const communities = raw ? (JSON.parse(raw) as Array<{ id: string; relayUrl: string }>) : []; return { activeMatchesCommunity: communities.length === 1 && window.localStorage.getItem("buzz-active-community-id") === communities[0]?.id, relayUrl: communities[0]?.relayUrl ?? null, }; }), ) .toEqual({ activeMatchesCommunity: true, relayUrl: "wss://default.example.com", }); }); test("first-community choices route join, create, owner, and member intents", async ({ page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await page.addInitScript((pubkey) => { window.localStorage.setItem( `buzz-machine-onboarding-complete.v2:${pubkey}`, "true", ); }, BLANK_TYLER_IDENTITY.pubkey); await installMockBridge(page, undefined, { relayWsUrl: "ws://localhost:3000", skipOnboardingSeed: true, skipCommunitySeed: true, }); await page.goto("/"); await expect( page.getByRole("button", { name: /Join a community/ }), ).toBeVisible(); await expect( page.getByRole("button", { name: /Create a community/ }), ).toBeVisible(); const existing = page.getByRole("button", { name: /I already have a community/, }); await expect(existing).toBeVisible(); await existing.click(); // Owner/member split lives on its own page, mirroring the hub layout. await expect( page.getByRole("heading", { name: "Reconnect to your community" }), ).toBeVisible(); await expect( page.getByRole("button", { name: "I own the community" }), ).toBeVisible(); await expect( page.getByRole("button", { name: "I’m a member or admin" }), ).toBeVisible(); await page.getByRole("button", { name: "I’m a member or admin" }).click(); await expect( page.getByRole("heading", { name: "Reconnect to your community" }), ).toBeVisible(); const accessInput = page.getByTestId("invite-redeem-input"); await expect(accessInput).toHaveAttribute( "placeholder", "Invite link or community URL", ); await accessInput.fill("https://default.example.com"); await expect(page.getByTestId("invite-redeem-submit")).toBeEnabled(); // Back from the member form returns to the role choice, then to the hub. await page.getByRole("button", { name: "Back" }).click(); await expect( page.getByRole("button", { name: "I own the community" }), ).toBeVisible(); await page.getByTestId("existing-back").click(); await page.getByRole("button", { name: /Join a community/ }).click(); await expect( page.getByRole("heading", { name: "Join a community" }), ).toBeVisible(); await expect(page.getByText("Joining a private community?")).toBeVisible(); await expect(page.getByTestId("welcome-join-npub")).toBeVisible(); await expect( page.getByRole("button", { name: "Copy public ID" }), ).toBeVisible(); await accessInput.fill("https://default.example.com/invite/abc123"); await expect(page.getByTestId("invite-redeem-submit")).toBeEnabled(); }); test("first-community owner can connect an existing hosted community", async ({ page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await page.addInitScript((pubkey) => { window.localStorage.setItem( `buzz-machine-onboarding-complete.v2:${pubkey}`, "true", ); }, BLANK_TYLER_IDENTITY.pubkey); await installMockBridge( page, { builderlabAuth: { email: "owner@example.com", expiresAt: "2099-01-01T00:00:00Z", }, builderlabIdentity: { pubkey_hex: BLANK_TYLER_IDENTITY.pubkey }, builderlabCommunities: [ { id: "owned-community", name: "North Star", normalized_host: "north-star.communities.buzz.xyz", }, ], }, { relayWsUrl: "ws://localhost:3000", skipOnboardingSeed: true, skipCommunitySeed: true, }, ); await page.goto("/"); await page.getByTestId("community-choice-create").click(); await expect(page.getByText("North Star")).toBeVisible(); await page.getByRole("button", { name: "Connect", exact: true }).click(); await expect( page.getByRole("heading", { name: "Build your profile" }), ).toBeVisible(); await expect .poll(() => page.evaluate(() => window.localStorage.getItem("buzz-community-onboarding-transaction.v1"), ), ) .toContain('"source":"first-community"'); await expect .poll(() => page.evaluate(() => window.localStorage.getItem("buzz-community-onboarding-transaction.v1"), ), ) .toContain("wss://north-star.communities.buzz.xyz"); await page.getByTestId("community-profile-back").click(); await expect( page.getByRole("heading", { name: "Choose a community" }), ).toBeVisible(); await expect(page.getByText("North Star")).toBeVisible(); await expect( page.getByRole("heading", { name: "Join a community" }), ).toHaveCount(0); await expect .poll(() => page.evaluate(() => window.localStorage.getItem("buzz-community-onboarding-transaction.v1"), ), ) .toBeNull(); }); test("first-community owner can create and connect a hosted community", async ({ page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await page.addInitScript((pubkey) => { window.localStorage.setItem( `buzz-machine-onboarding-complete.v2:${pubkey}`, "true", ); }, BLANK_TYLER_IDENTITY.pubkey); await installMockBridge( page, {}, { relayWsUrl: "ws://localhost:3000", skipOnboardingSeed: true, skipCommunitySeed: true, }, ); await page.goto("/"); await page.getByTestId("community-choice-create").click(); await page.getByRole("button", { name: "Sign in to continue" }).click(); await expect( page.getByRole("heading", { name: "Finish connecting Buzz" }), ).toBeVisible(); await page.getByRole("button", { name: "Connect and continue" }).click(); const createSurface = page.getByTestId("hosted-community-create-surface"); const surfaceBoxBeforeFeedback = await createSurface.boundingBox(); const communityNameInput = page.getByTestId("hosted-community-address-input"); await communityNameInput.fill("bee-lab"); await expect(communityNameInput).toHaveAttribute("style", /width: 7ch;/); const availabilityFeedback = page.getByText("That address is available."); await expect(availabilityFeedback).toBeVisible(); const [feedbackBox, surfaceBox, inputBox, suffixBox] = await Promise.all([ availabilityFeedback.boundingBox(), createSurface.boundingBox(), page.getByTestId("hosted-community-address-input").boundingBox(), page.locator("#hosted-community-suffix").boundingBox(), ]); if ( !surfaceBoxBeforeFeedback || !feedbackBox || !surfaceBox || !inputBox || !suffixBox ) { throw new Error("Could not measure hosted community creation layout"); } expect(surfaceBox.y).toBe(surfaceBoxBeforeFeedback.y); expect(surfaceBox.height).toBe(surfaceBoxBeforeFeedback.height); const addressLeft = inputBox.x; const addressRight = suffixBox.x + suffixBox.width; expect( Math.abs( (addressLeft + addressRight) / 2 - (surfaceBox.x + surfaceBox.width / 2), ), ).toBeLessThanOrEqual(1); expect(feedbackBox.y).toBeGreaterThanOrEqual( surfaceBox.y + surfaceBox.height, ); await page.getByRole("button", { name: "Next" }).click(); await expect( page.getByRole("heading", { name: "Build your profile" }), ).toBeVisible(); await expect .poll(() => page.evaluate(() => window.localStorage.getItem("buzz-community-onboarding-transaction.v1"), ), ) .toContain("wss://bee-lab.communities.buzz.xyz"); }); test("hosted community address line stays within the card for a long name", async ({ page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await page.addInitScript((pubkey) => { window.localStorage.setItem( `buzz-machine-onboarding-complete.v2:${pubkey}`, "true", ); }, BLANK_TYLER_IDENTITY.pubkey); await installMockBridge( page, {}, { relayWsUrl: "ws://localhost:3000", skipOnboardingSeed: true, skipCommunitySeed: true, }, ); // The 800px app minimum is the worst case for the full-width address line. await page.setViewportSize({ width: 800, height: 720 }); await page.goto("/"); await page.getByTestId("community-choice-create").click(); await page.getByRole("button", { name: "Sign in to continue" }).click(); await expect( page.getByRole("heading", { name: "Finish connecting Buzz" }), ).toBeVisible(); await page.getByRole("button", { name: "Connect and continue" }).click(); const createSurface = page.getByTestId("hosted-community-create-surface"); const communityNameInput = page.getByTestId("hosted-community-address-input"); // A maximum-length (63 char) valid name — the overflow case Wes flagged; the // 7-char check above cannot catch it. const longName = "a".repeat(63); await communityNameInput.fill(longName); await expect(communityNameInput).toHaveValue(longName); const [surfaceBox, inputBox, suffixBox] = await Promise.all([ createSurface.boundingBox(), communityNameInput.boundingBox(), page.locator("#hosted-community-suffix").boundingBox(), ]); if (!surfaceBox || !inputBox || !suffixBox) { throw new Error("Could not measure hosted community creation layout"); } const addressLeft = inputBox.x; const addressRight = suffixBox.x + suffixBox.width; // The composed `.` line must stay within the card — no // horizontal overflow past the surface or the 800px window. expect(addressLeft).toBeGreaterThanOrEqual(surfaceBox.x); expect(addressRight).toBeLessThanOrEqual(surfaceBox.x + surfaceBox.width); expect(addressRight).toBeLessThanOrEqual(800); // …and it stays centered within the card. expect( Math.abs( (addressLeft + addressRight) / 2 - (surfaceBox.x + surfaceBox.width / 2), ), ).toBeLessThanOrEqual(2); }); test("first-community reports a created community without a relay address", async ({ page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await page.addInitScript((pubkey) => { window.localStorage.setItem( `buzz-machine-onboarding-complete.v2:${pubkey}`, "true", ); }, BLANK_TYLER_IDENTITY.pubkey); await installMockBridge( page, { builderlabAuth: { email: "owner@example.com", expiresAt: "2099-01-01T00:00:00Z", }, builderlabIdentity: { pubkey_hex: BLANK_TYLER_IDENTITY.pubkey }, builderlabCreatedCommunity: { id: "hosted-bee-lab", name: "bee-lab", }, }, { relayWsUrl: "ws://localhost:3000", skipOnboardingSeed: true, skipCommunitySeed: true, }, ); await page.goto("/"); await page.getByTestId("community-choice-create").click(); await page.getByRole("textbox", { name: "Community name" }).fill("bee-lab"); await expect(page.getByText("That address is available.")).toBeVisible(); await page.getByRole("button", { name: "Next" }).click(); await expect(page.getByRole("alert")).toContainText( "The community was created, but Builderlab did not return its relay address.", ); await expect( page.getByRole("heading", { name: "Build your profile" }), ).toHaveCount(0); }); test("first-community X cancels a pending sign-in", async ({ page }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await page.addInitScript((pubkey) => { window.localStorage.setItem( `buzz-machine-onboarding-complete.v2:${pubkey}`, "true", ); }, BLANK_TYLER_IDENTITY.pubkey); await installMockBridge( page, { builderlabLoginDelayMs: 5_000 }, { relayWsUrl: "ws://localhost:3000", skipOnboardingSeed: true, skipCommunitySeed: true, }, ); await page.goto("/"); await page.getByTestId("community-choice-create").click(); await page.getByRole("button", { name: "Sign in to continue" }).click(); await expect(page.getByText("Waiting for your browser…")).toBeVisible(); await expect( page.getByRole("button", { name: "Cancel sign-in" }), ).toHaveCount(0); await page.getByRole("button", { name: "Close" }).click(); await expect( page.getByRole("button", { name: /Create a community/ }), ).toBeVisible(); await expect .poll(() => page.evaluate(() => window.__BUZZ_E2E_COMMANDS__ ?? [])) .toEqual(expect.arrayContaining(["cancel_builderlab_login"])); }); test("first-community owner can replace a mismatched account identity", async ({ page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await page.addInitScript((pubkey) => { window.localStorage.setItem( `buzz-machine-onboarding-complete.v2:${pubkey}`, "true", ); }, BLANK_TYLER_IDENTITY.pubkey); await installMockBridge( page, { builderlabAuth: { email: "old-owner@example.com", expiresAt: "2099-01-01T00:00:00Z", }, builderlabIdentity: { pubkey_hex: "f".repeat(64) }, }, { relayWsUrl: "ws://localhost:3000", skipOnboardingSeed: true, skipCommunitySeed: true, }, ); await page.goto("/"); await page.getByTestId("community-choice-create").click(); await expect( page.getByRole("heading", { name: "This account uses a different Buzz identity", }), ).toBeVisible(); await page .getByRole("button", { name: "Use this device's identity" }) .click(); await expect( page.getByRole("textbox", { name: "Community name" }), ).toBeVisible(); await expect .poll(() => page.evaluate(() => window.__BUZZ_E2E_COMMANDS__ ?? [])) .toEqual( expect.arrayContaining([ "delete_builderlab_nostr_identity", "bind_builderlab_nostr_identity", ]), ); }); test("first-community explains when the local identity belongs to another account", async ({ page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await page.addInitScript((pubkey) => { window.localStorage.setItem( `buzz-machine-onboarding-complete.v2:${pubkey}`, "true", ); }, BLANK_TYLER_IDENTITY.pubkey); await installMockBridge( page, { builderlabAuth: { email: "wrong-owner@example.com", expiresAt: "2099-01-01T00:00:00Z", }, builderlabIdentity: { pubkey_hex: "e".repeat(64) }, builderlabBindError: { code: "pubkey_already_bound" }, }, { relayWsUrl: "ws://localhost:3000", skipOnboardingSeed: true, skipCommunitySeed: true, }, ); await page.goto("/"); await page.getByTestId("community-choice-create").click(); await page .getByRole("button", { name: "Use this device's identity" }) .click(); await expect( page.getByText( "This device's Buzz identity belongs to a different Builderlab account and can't be moved from here. Sign out, then sign in with the account that already owns this identity.", ), ).toBeVisible(); await expect( page.getByRole("heading", { name: "Finish connecting Buzz" }), ).toBeVisible(); }); test("back clears Builderlab auth before returning to first-community choices", async ({ page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await page.addInitScript((pubkey) => { window.localStorage.setItem( `buzz-machine-onboarding-complete.v2:${pubkey}`, "true", ); }, BLANK_TYLER_IDENTITY.pubkey); await installMockBridge( page, { builderlabAuth: { email: "owner@example.com", expiresAt: "2099-01-01T00:00:00Z", }, builderlabIdentity: { pubkey_hex: BLANK_TYLER_IDENTITY.pubkey }, }, { relayWsUrl: "ws://localhost:3000", skipOnboardingSeed: true, skipCommunitySeed: true, }, ); await page.goto("/"); await page.getByTestId("community-choice-create").click(); await page.getByRole("button", { name: "Back" }).click(); await page.getByTestId("community-choice-create").click(); await expect(page.getByRole("button", { name: "Continue" })).toBeVisible(); }); test("first-community shows the scenario cards for localhost", async ({ page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await page.addInitScript((pubkey) => { window.localStorage.setItem( `buzz-machine-onboarding-complete.v2:${pubkey}`, "true", ); }, BLANK_TYLER_IDENTITY.pubkey); await installMockBridge( page, { acpRuntimesCatalog: [ { id: "claude", label: "Claude Code", avatar_url: "", availability: "available", command: "claude", binary_path: "/usr/local/bin/claude", default_args: [], mcp_command: null, install_hint: "Install Claude Code", install_instructions_url: "https://example.com", can_auto_install: true, underlying_cli_path: null, node_required: false, auth_status: { status: "logged_in" }, login_hint: "Sign in to Claude Code", }, ], }, { relayWsUrl: "ws://localhost:3000", skipOnboardingSeed: true, skipCommunitySeed: true, }, ); await page.goto("/"); await expect( page.getByRole("button", { name: "Join default community" }), ).toHaveCount(0); await expect( page.getByRole("button", { name: /Join a community/ }), ).toBeVisible(); await expect( page.getByRole("button", { name: /Create a community/, }), ).toBeVisible(); await page.getByTestId("welcome-setup-back").click(); await expect(page.getByTestId("onboarding-page-config")).toBeVisible(); await expect( page.getByRole("heading", { name: "Configure your default model settings", }), ).toBeVisible(); await expect(page.getByTestId("global-agent-default-harness")).toHaveText( "Claude Code", ); await expect(page.getByTestId("onboarding-finish")).toBeEnabled(); }); test("first-community direct join reaches profile", async ({ page }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await page.addInitScript((pubkey) => { window.localStorage.setItem( `buzz-machine-onboarding-complete.v2:${pubkey}`, "true", ); }, BLANK_TYLER_IDENTITY.pubkey); await installMockBridge(page, undefined, { relayWsUrl: "ws://localhost:3000", skipOnboardingSeed: true, skipCommunitySeed: true, }); await page.goto("/"); await page.getByRole("button", { name: /Join a community/ }).click(); await page .getByTestId("invite-redeem-input") .fill("wss://onboarding.communities.buzz.xyz"); await page.getByTestId("invite-redeem-submit").click(); await expect( page.getByRole("heading", { name: "Build your profile" }), ).toBeVisible(); await expect(page.getByText("Connecting securely…")).toHaveCount(0); await expect(page.getByText("Create an identity key")).toHaveCount(0); await expect .poll(() => page.evaluate((transactionStorageKey) => { const communitiesRaw = window.localStorage.getItem("buzz-communities"); const transactionRaw = window.localStorage.getItem( transactionStorageKey, ); const communities = communitiesRaw ? (JSON.parse(communitiesRaw) as Array<{ id: string }>) : []; const transaction = transactionRaw ? (JSON.parse(transactionRaw) as { communityId?: string }) : null; return { communityCount: communities.length, transactionMatchesOnlyCommunity: communities.length === 1 && transaction?.communityId === communities[0]?.id, }; }, COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY), ) .toEqual({ communityCount: 1, transactionMatchesOnlyCommunity: true }); }); test("community onboarding reuses an existing relay profile", async ({ page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await page.addInitScript( ({ pubkey, transactionStorageKey }) => { window.localStorage.setItem( `buzz-machine-onboarding-complete.v2:${pubkey}`, "true", ); const timestamp = new Date().toISOString(); window.localStorage.setItem( transactionStorageKey, JSON.stringify({ id: "txn-existing-profile", source: "add-community", stage: "profile", relayUrl: "wss://onboarding.communities.buzz.xyz", communityName: "Onboarding", communityId: "e2e-default-community", createdAt: timestamp, updatedAt: timestamp, }), ); }, { pubkey: BLANK_TYLER_IDENTITY.pubkey, transactionStorageKey: COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY, }, ); await installMockBridge( page, { profileHasEvent: true }, { relayWsUrl: "wss://onboarding.communities.buzz.xyz", skipOnboardingSeed: true, }, ); await page.goto("/"); await expect .poll(() => page.evaluate( () => ( window as Window & { __BUZZ_E2E_COMMANDS__?: string[] } ).__BUZZ_E2E_COMMANDS__?.filter( (command) => command === "get_profile", ).length ?? 0, ), ) .toBeGreaterThan(0); await expect( page.getByRole("heading", { name: "Meet your starter team" }), ).toBeVisible(); await expect( page.getByRole("heading", { name: "Build your profile" }), ).toHaveCount(0); }); test("first-community direct join cancel returns to request access", async ({ page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await page.addInitScript((pubkey) => { window.localStorage.setItem( `buzz-machine-onboarding-complete.v2:${pubkey}`, "true", ); }, BLANK_TYLER_IDENTITY.pubkey); await installMockBridge( page, { applyCommunityDelayMs: 5_000 }, { relayWsUrl: "ws://localhost:3000", skipOnboardingSeed: true, skipCommunitySeed: true, }, ); await page.goto("/"); await page.getByRole("button", { name: /Join a community/ }).click(); await page .getByTestId("invite-redeem-input") .fill("wss://onboarding.communities.buzz.xyz"); await page.getByTestId("invite-redeem-submit").click(); await expect(page.getByText("Connecting securely…")).toBeVisible(); await page.getByRole("button", { name: "Cancel" }).click(); await expect( page.getByRole("heading", { name: "Join a community" }), ).toBeVisible(); await expect(page.getByTestId("community-change-overlay")).toHaveCount(0); await expect(page.getByText("Create an identity key")).toHaveCount(0); await expect .poll(() => page.evaluate( (storageKey) => ({ communities: window.localStorage.getItem("buzz-communities"), transaction: window.localStorage.getItem(storageKey), }), COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY, ), ) .toEqual({ communities: null, transaction: null }); }); test("canceling a join to an existing inactive community preserves it", async ({ page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await page.addInitScript( ({ pubkey, relayUrl }) => { window.localStorage.setItem( `buzz-machine-onboarding-complete.v2:${pubkey}`, "true", ); const timestamp = new Date().toISOString(); window.localStorage.setItem( "buzz-communities", JSON.stringify([ { id: "active-community", name: "Active", relayUrl: "wss://active.example.com", addedAt: timestamp, }, { id: "existing-community", name: "Existing", relayUrl, addedAt: timestamp, }, ]), ); window.localStorage.setItem( "buzz-active-community-id", "active-community", ); }, { pubkey: BLANK_TYLER_IDENTITY.pubkey, relayUrl: "wss://onboarding.communities.buzz.xyz", }, ); await installMockBridge( page, { applyCommunityDelayMs: 5_000 }, { relayWsUrl: "wss://active.example.com", skipOnboardingSeed: true, skipCommunitySeed: true, }, ); await page.goto("/"); await page.evaluate((transactionStorageKey) => { const timestamp = new Date().toISOString(); window.localStorage.setItem( transactionStorageKey, JSON.stringify({ id: "existing-community-join", source: "add-community", stage: "connecting", relayUrl: "wss://onboarding.communities.buzz.xyz", communityName: "Existing", createdAt: timestamp, updatedAt: timestamp, }), ); window.location.reload(); }, COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY); await expect(page.getByText("Connecting securely…")).toBeVisible(); await page.getByRole("button", { name: "Cancel" }).click(); await expect(page.getByText("Connecting securely…")).toHaveCount(0); await expect .poll(() => page.evaluate(() => { const raw = window.localStorage.getItem("buzz-communities"); return raw ? (JSON.parse(raw) as Array<{ id: string }>).map(({ id }) => id) : []; }), ) .toEqual(["active-community", "existing-community"]); }); test("connected first-community profile step offers equal-width Next and Back controls", async ({ page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await page.addInitScript( ({ pubkey, transactionStorageKey }) => { window.localStorage.setItem( `buzz-machine-onboarding-complete.v2:${pubkey}`, "true", ); const timestamp = new Date().toISOString(); window.localStorage.setItem( transactionStorageKey, JSON.stringify({ id: "txn-profile-step", source: "first-community", stage: "profile", relayUrl: "ws://localhost:3000", communityName: "Default", communityId: "e2e-default-community", addedCommunity: true, createdAt: timestamp, updatedAt: timestamp, }), ); }, { pubkey: BLANK_TYLER_IDENTITY.pubkey, transactionStorageKey: COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY, }, ); await installFakeCamera(page, { failRequests: 1 }); const uploadedAvatarUrl = "https://mock.relay/media/community-avatar.png"; let avatarRequestCount = 0; await page.route(`${uploadedAvatarUrl}*`, async (route) => { avatarRequestCount += 1; if (avatarRequestCount === 1) { await route.fulfill({ status: 404 }); return; } await new Promise((resolve) => setTimeout(resolve, 1_000)); await route.fulfill({ body: Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64", ), contentType: "image/png", }); }); await installMockBridge( page, { uploadDelayMs: 1_000, uploadDescriptors: [ { filename: "community-avatar.png", sha256: "c".repeat(64), size: 128, type: "image/png", uploaded: 1_779_900_000, url: "https://mock.relay/media/community-avatar.png", }, ], }, { relayWsUrl: "ws://localhost:3000", skipOnboardingSeed: true, }, ); await page.goto("/"); await expect(page.getByTestId("community-onboarding-flow")).toBeVisible(); await expect( page.getByRole("heading", { name: "Build your profile" }), ).toBeVisible(); const profileMain = page.getByTestId("community-profile-main"); const profileHeading = page.getByRole("heading", { name: "Build your profile", }); await expect(profileHeading).toBeVisible(); const profileHeadingBox = await profileHeading.boundingBox(); if (!profileHeadingBox) { throw new Error("Could not measure community profile heading position"); } expect(Math.abs(profileHeadingBox.y - 106)).toBeLessThan(8); const nameKey = page.getByTestId("community-profile-name-key"); const avatarButton = page.getByTestId("community-avatar-open"); await expect(nameKey).toBeVisible(); await expect(avatarButton).toBeVisible(); const nameKeyBox = await nameKey.boundingBox(); const avatarButtonBox = await avatarButton.boundingBox(); expect(nameKeyBox?.width).toBeGreaterThan(380); expect(avatarButtonBox?.width).toBe(144); const nameKeyStyles = await nameKey.evaluate((element) => { const styles = window.getComputedStyle(element); return { backgroundColor: styles.backgroundColor, borderColor: styles.borderColor, borderRadius: styles.borderRadius, boxShadow: styles.boxShadow, fontSize: styles.fontSize, }; }); expect(nameKeyStyles.backgroundColor).toMatch( /^(rgba\(255, 255, 255, 0\.95\)|oklab\(.+ \/ 0\.95\))$/, ); expect(nameKeyStyles.borderColor).toBe("rgba(113, 113, 6, 0.28)"); expect(nameKeyStyles.boxShadow).toContain( "rgba(113, 113, 6, 0.5) 0px 0px 0px 1px inset", ); expect(nameKeyStyles).toMatchObject({ borderRadius: "16px", fontSize: "14px", }); await expect(page.getByText("Your username", { exact: true })).toBeVisible(); await expect(page.getByTestId("community-onboarding-flow")).toHaveAttribute( "data-system-color-scheme", /^(light|dark)$/, ); await page.emulateMedia({ colorScheme: "dark" }); await expect(page.getByTestId("community-onboarding-flow")).toHaveAttribute( "data-system-color-scheme", "dark", ); await avatarButton.click(); const avatarDialog = page.getByRole("dialog", { name: "Edit your avatar" }); await expect(avatarDialog).toBeVisible(); await expect(avatarDialog).toHaveAttribute( "data-system-color-scheme", "light", ); const dialogStyles = await avatarDialog.evaluate((element) => { const styles = window.getComputedStyle(element); return { backgroundColor: styles.backgroundColor, boxShadow: styles.boxShadow, color: styles.color, }; }); expect(dialogStyles.backgroundColor).toBe("rgb(255, 255, 255)"); expect(dialogStyles.color).toBe("rgb(23, 23, 23)"); expect(dialogStyles.boxShadow).not.toBe("none"); const dialogOverlay = page.getByTestId("dialog-overlay"); const overlayStyles = await dialogOverlay.evaluate((element) => { const styles = window.getComputedStyle(element); return { backdropFilter: styles.backdropFilter, backgroundColor: styles.backgroundColor, }; }); expect(overlayStyles.backgroundColor).toBe("rgba(0, 0, 0, 0)"); expect(overlayStyles.backdropFilter).toBe("none"); const dialogLayout = await avatarDialog.evaluate((element) => ({ clientHeight: element.clientHeight, clientWidth: element.clientWidth, scrollHeight: element.scrollHeight, })); const editorWidth = await page .getByTestId("community-avatar-editor") .evaluate((element) => element.clientWidth); const uploadHeight = await page .getByTestId("community-avatar-upload") .evaluate((element) => element.clientHeight); const urlBox = await page.getByTestId("community-avatar-url").boundingBox(); const dialogBox = await avatarDialog.boundingBox(); if (!dialogBox || !urlBox) { throw new Error("Could not measure avatar dialog layout"); } expect(dialogLayout.clientWidth).toBeLessThanOrEqual(560); const imageDialogHeight = dialogLayout.clientHeight; const dialogTransition = await avatarDialog.evaluate( (element) => window.getComputedStyle(element).transitionProperty, ); expect(dialogTransition).toContain("height"); expect(editorWidth).toBe(456); expect(uploadHeight).toBe(126); expect(dialogLayout.scrollHeight).toBeLessThanOrEqual( dialogLayout.clientHeight, ); expect(urlBox.y).toBeGreaterThanOrEqual(dialogBox.y); expect(urlBox.y + urlBox.height).toBeLessThanOrEqual( dialogBox.y + dialogBox.height, ); const saveButton = page.getByTestId("community-avatar-done"); await page.getByTestId("community-avatar-input").setInputFiles({ buffer: Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64", ), mimeType: "image/png", name: "community-avatar.png", }); const previewImage = page.getByTestId( "community-avatar-upload-preview-image", ); await expect(previewImage).toHaveAttribute("src", /^blob:/); await expect(saveButton).toBeDisabled(); await expect(saveButton).toHaveText("Save"); const localPreviewUrl = await previewImage.getAttribute("src"); await expect(previewImage).toHaveAttribute("src", localPreviewUrl ?? ""); await saveButton.click(); await expect(avatarDialog).toHaveCount(0); const avatarCircleImage = page.getByTestId("community-avatar-circle-image"); await expect(avatarCircleImage).toHaveAttribute("src", /^blob:/); await expect( page.getByTestId("community-avatar-circle-upload-pending"), ).toBeVisible(); await expect(page.getByTestId("community-profile-name-key")).toBeEnabled(); await expect .poll(() => avatarCircleImage.getAttribute("src")) .not.toMatch(/^blob:/); await expect( page.getByTestId("community-avatar-circle-upload-pending"), ).toHaveCount(0); await avatarButton.click(); await expect(avatarDialog).toBeVisible(); await expect(previewImage).toHaveAttribute( "src", new RegExp(`^${uploadedAvatarUrl}`), ); const modeContentShell = page.getByTestId( "community-avatar-mode-content-shell", ); await page.waitForTimeout(300); const measureAnchoredEditorLayout = async () => { const [tabsBox, contentShellBox, contentBox, saveBox] = await Promise.all([ page.getByRole("tablist", { name: "Avatar type" }).boundingBox(), modeContentShell.boundingBox(), modeContentShell.locator(":scope > div").boundingBox(), saveButton.boundingBox(), ]); if (!tabsBox || !contentShellBox || !contentBox || !saveBox) { throw new Error("Could not measure anchored avatar editor layout"); } return { tabsBox, contentShellBox, contentBox, saveBox }; }; const imageEditorLayout = await measureAnchoredEditorLayout(); expect( Math.abs( imageEditorLayout.contentBox.y + imageEditorLayout.contentBox.height / 2 - (imageEditorLayout.contentShellBox.y + imageEditorLayout.contentShellBox.height / 2), ), ).toBeLessThanOrEqual(1); const saveStyles = await saveButton.evaluate((element) => { const styles = window.getComputedStyle(element); return { backgroundColor: styles.backgroundColor, color: styles.color }; }); expect(saveStyles).toEqual({ backgroundColor: "rgb(23, 23, 23)", color: "rgb(240, 240, 205)", }); const defaultDialogHeight = imageDialogHeight; await page.getByRole("tab", { name: "Emoji" }).click(); await expect .poll(() => avatarDialog.evaluate((element) => element.clientHeight)) .toBe(defaultDialogHeight); await page.waitForTimeout(300); const emojiEditorLayout = await measureAnchoredEditorLayout(); expect(emojiEditorLayout.saveBox.y).toBe(imageEditorLayout.saveBox.y); await page.getByRole("tab", { name: "Animated" }).click(); await expect(saveButton).toHaveCount(0); await expect( page.getByTestId("community-avatar-animated-error"), ).toContainText("Could not access the camera"); const retryCameraButton = page.getByTestId("community-avatar-animated-retry"); await expect(retryCameraButton).toHaveText("Try camera again"); await retryCameraButton.click(); const captureButton = page.getByTestId("community-avatar-animated-record"); await expect(captureButton).toHaveText("Capture 3 sec video"); await captureButton.click(); await expect( page.getByTestId("community-avatar-animated-sections"), ).toBeVisible({ timeout: 60_000 }); await expect(saveButton).toBeVisible(); await page.getByRole("tab", { name: "Emoji" }).click(); await selectFirstEmojiFromPicker(page); await expect .poll(() => avatarDialog.evaluate((element) => element.clientHeight)) .toBeGreaterThan(defaultDialogHeight); await page.waitForTimeout(300); const selectedEmojiDialogHeight = await avatarDialog.evaluate( (element) => element.clientHeight, ); const expandedEmojiLayout = await measureAnchoredEditorLayout(); expect( expandedEmojiLayout.contentBox.y - expandedEmojiLayout.contentShellBox.y, ).toBeGreaterThanOrEqual(24); expect( expandedEmojiLayout.contentShellBox.y + expandedEmojiLayout.contentShellBox.height - (expandedEmojiLayout.contentBox.y + expandedEmojiLayout.contentBox.height), ).toBeGreaterThanOrEqual(24); expect( expandedEmojiLayout.contentBox.y - (expandedEmojiLayout.tabsBox.y + expandedEmojiLayout.tabsBox.height), ).toBeGreaterThanOrEqual(24); expect( expandedEmojiLayout.saveBox.y - (expandedEmojiLayout.contentBox.y + expandedEmojiLayout.contentBox.height), ).toBeGreaterThanOrEqual(24); await page.getByTestId("community-avatar-custom-color").click(); await expect .poll(() => avatarDialog.evaluate((element) => element.clientHeight)) .toBeGreaterThan(selectedEmojiDialogHeight); await page.getByTestId("community-avatar-custom-color-done").click(); await expect .poll(() => avatarDialog.evaluate((element) => element.clientHeight)) .toBe(selectedEmojiDialogHeight); await page.getByRole("tab", { name: "Image" }).click(); await expect .poll(() => avatarDialog.evaluate((element) => element.clientHeight)) .toBe(imageDialogHeight); await expect(profileMain).toHaveClass(/opacity-45/); await expect(profileMain).toHaveClass(/blur-\[3px\]/); await expect( page.getByTestId("community-profile-name-key"), ).not.toBeFocused(); await page.keyboard.press("Escape"); await expect(avatarDialog).toHaveCount(0); await expect(avatarButton).toBeFocused(); const nextButton = page.getByTestId("community-profile-next"); const backButton = page.getByTestId("community-profile-back"); await expect(nextButton).toHaveText("Next"); await expect(nextButton).toBeDisabled(); await expect(backButton).toHaveText("Back"); await expect(backButton).toBeEnabled(); const [nextBox, backBox] = await Promise.all([ nextButton.boundingBox(), backButton.boundingBox(), ]); if (!nextBox || !backBox) { throw new Error("Could not measure community profile navigation controls"); } expect(Math.abs(nextBox.width - backBox.width)).toBeLessThanOrEqual(1); expect(nextBox.width).toBeLessThanOrEqual(160); await backButton.click(); await expect( page.getByRole("heading", { name: "Join a community" }), ).toBeVisible(); await expect .poll(() => page.evaluate( (key) => window.localStorage.getItem(key), COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY, ), ) .toBeNull(); }); test("name-only community profile save preserves an existing avatar", async ({ page, }) => { await seedCommunityProfileStage(page, "txn-avatar-preserve-existing"); await installMockBridge(page, undefined, { relayWsUrl: "wss://default.example.com", skipOnboardingSeed: true, }); await page.goto("/"); const existingAvatarUrl = "https://mock.relay/media/existing-community-avatar.png"; await seedCurrentAvatar(page, existingAvatarUrl); await page.getByTestId("community-profile-name-key").fill("Tyler"); await page.getByTestId("community-profile-next").click(); await expect .poll(() => page.evaluate(() => (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []) .filter( ({ command }) => command === "update_profile" || command === "update_profile_at_relay", ) .map(({ payload }) => (payload as { avatarUrl?: string }).avatarUrl), ), ) .toEqual([undefined]); const profile = await invokeMockCommand<{ avatar_url: string | null }>( page, "get_profile", ); expect(profile.avatar_url).toBe(existingAvatarUrl); }); test("pending avatar stays navigable, clears failures, and retries", async ({ page, }) => { await seedCommunityProfileStage(page, "txn-avatar-propagation"); const uploadedAvatarUrl = "https://mock.relay/media/pending-community-avatar.png"; let avatarReady = false; await page.route(`${uploadedAvatarUrl}*`, async (route) => { if (!avatarReady) { await route.fulfill({ status: 404 }); return; } await new Promise((resolve) => setTimeout(resolve, 500)); await route.fulfill({ body: Buffer.from(ONE_PIXEL_PNG_BASE64, "base64"), contentType: "image/png", }); }); await installMockBridge( page, { uploadDelayMs: 250, uploadDescriptors: [ { filename: "pending-community-avatar.png", sha256: "d".repeat(64), size: 128, type: "image/png", uploaded: 1_779_900_000, url: uploadedAvatarUrl, }, ], }, { relayWsUrl: "wss://default.example.com", skipOnboardingSeed: true, }, ); await page.goto("/"); await page.getByTestId("community-profile-name-key").fill("Tyler"); await uploadCommunityAvatar(page, "pending-community-avatar.png"); const avatarImage = page.getByTestId("community-avatar-circle-image"); await expect(avatarImage).toHaveAttribute("src", /^blob:/); await expect( page.getByTestId("community-avatar-circle-upload-pending"), ).toBeVisible(); await expect(avatarImage).toHaveClass(/brightness-75/); await expect(page.getByTestId("community-profile-next")).toBeEnabled(); await page.waitForTimeout(500); await expect( page.getByTestId("community-avatar-circle-upload-pending"), ).toBeVisible(); const avatar = page.getByTestId("community-avatar-circle"); const pendingSpinner = page .getByTestId("community-avatar-circle-upload-pending") .locator(".sprout-arc-spinner"); const [avatarBox, spinnerBox] = await Promise.all([ avatar.boundingBox(), pendingSpinner.boundingBox(), ]); if (!avatarBox || !spinnerBox) { throw new Error("Could not measure pending avatar spinner"); } expect( Math.abs( avatarBox.x + avatarBox.width / 2 - (spinnerBox.x + spinnerBox.width / 2), ), ).toBeLessThanOrEqual(1); expect( Math.abs( avatarBox.y + avatarBox.height / 2 - (spinnerBox.y + spinnerBox.height / 2), ), ).toBeLessThanOrEqual(1); expect(spinnerBox.width / avatarBox.width).toBeLessThanOrEqual(0.2); await expect(page.getByTestId("community-avatar-empty")).toBeVisible({ timeout: 10_000, }); await expect( page.getByRole("button", { name: "Add an avatar" }), ).toBeVisible(); await expect( page.getByTestId("community-avatar-circle-fallback"), ).toHaveCount(0); await expect(avatarImage).toHaveCount(0); await expect( page.getByText("Avatar couldn’t finish uploading"), ).toBeVisible(); await expect( page.getByText("Your default avatar is showing instead."), ).toBeVisible(); await page.getByTestId("community-profile-next").click(); await expect .poll(() => page.evaluate(() => (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []) .filter( ({ command }) => command === "update_profile" || command === "update_profile_at_relay", ) .map(({ payload }) => (payload as { avatarUrl?: string }).avatarUrl), ), ) .toEqual([undefined]); avatarReady = true; await page.getByRole("button", { name: "Retry" }).click(); await expect .poll(() => page.evaluate(() => (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []) .filter( ({ command }) => command === "update_profile" || command === "update_profile_at_relay", ) .map(({ payload }) => (payload as { avatarUrl?: string }).avatarUrl), ), ) .toEqual([undefined, uploadedAvatarUrl]); await expect(page.getByText("Avatar couldn’t finish uploading")).toHaveCount( 0, ); }); test("a pending avatar never becomes durable if propagation fails after onboarding unmounts", async ({ page, }) => { await seedCommunityProfileStage(page, "txn-avatar-saved-before-failure"); const uploadedAvatarUrl = "https://mock.relay/media/saved-pending-community-avatar.png"; let allowAvatarFailure = false; await page.route(`${uploadedAvatarUrl}*`, async (route) => { while (!allowAvatarFailure) { await new Promise((resolve) => setTimeout(resolve, 50)); } await route.fulfill({ status: 404 }); }); await installMockBridge( page, { uploadDescriptors: [ { filename: "saved-pending-community-avatar.png", sha256: "f".repeat(64), size: 128, type: "image/png", uploaded: 1_779_900_002, url: uploadedAvatarUrl, }, ], }, { relayWsUrl: "wss://default.example.com", skipOnboardingSeed: true, }, ); await page.goto("/"); await page.getByTestId("community-profile-name-key").fill("Tyler"); await uploadCommunityAvatar(page, "saved-pending-community-avatar.png"); await expect( page.getByTestId("community-avatar-circle-upload-pending"), ).toBeVisible(); await page.getByTestId("community-profile-next").click(); await page.getByTestId("community-team-intro-enter").click(); await expect(page.getByTestId("community-onboarding-flow")).toHaveCount(0, { timeout: 10_000, }); allowAvatarFailure = true; await expect .poll(() => page.evaluate(() => (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []) .filter( ({ command }) => command === "update_profile" || command === "update_profile_at_relay", ) .map(({ payload }) => (payload as { avatarUrl?: string }).avatarUrl), ), ) .toEqual([undefined]); await expect( page.getByText("Avatar couldn’t finish uploading"), ).toBeVisible(); const profile = await invokeMockCommand<{ avatar_url: string | null }>( page, "get_profile", ); expect(profile.avatar_url).toBeNull(); }); test("a pending avatar becomes durable after onboarding unmounts once ready", async ({ page, }) => { await seedCommunityProfileStage(page, "txn-avatar-ready-after-unmount"); const uploadedAvatarUrl = "https://mock.relay/media/ready-after-unmount-community-avatar.png"; let allowAvatarReady = false; await page.route(`${uploadedAvatarUrl}*`, async (route) => { while (!allowAvatarReady) { await new Promise((resolve) => setTimeout(resolve, 50)); } await route.fulfill({ body: Buffer.from(ONE_PIXEL_PNG_BASE64, "base64"), contentType: "image/png", }); }); await installMockBridge( page, { uploadDescriptors: [ { filename: "ready-after-unmount-community-avatar.png", sha256: "b".repeat(64), size: 128, type: "image/png", uploaded: 1_779_900_004, url: uploadedAvatarUrl, }, ], }, { relayWsUrl: "wss://default.example.com", skipOnboardingSeed: true, }, ); await page.goto("/"); await page.getByTestId("community-profile-name-key").fill("Tyler"); await uploadCommunityAvatar(page, "ready-after-unmount-community-avatar.png"); await page.getByTestId("community-profile-next").click(); await page.getByTestId("community-team-intro-enter").click(); await expect(page.getByTestId("community-onboarding-flow")).toHaveCount(0, { timeout: 10_000, }); await expect .poll(() => page.evaluate(() => (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []) .filter( ({ command }) => command === "update_profile" || command === "update_profile_at_relay", ) .map(({ payload }) => (payload as { avatarUrl?: string }).avatarUrl), ), ) .toEqual([undefined]); allowAvatarReady = true; await expect .poll(() => page.evaluate(() => (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []) .filter( ({ command }) => command === "update_profile" || command === "update_profile_at_relay", ) .map(({ payload }) => (payload as { avatarUrl?: string }).avatarUrl), ), ) .toEqual([undefined, uploadedAvatarUrl]); const profile = await invokeMockCommand<{ avatar_url: string | null }>( page, "get_profile", ); expect(profile.avatar_url).toBe(uploadedAvatarUrl); }); test("a failed pending replacement leaves the confirmed avatar untouched", async ({ page, }) => { await seedCommunityProfileStage(page, "txn-avatar-restore-existing"); const existingAvatarUrl = "https://mock.relay/media/existing-community-avatar.png"; const uploadedAvatarUrl = "https://mock.relay/media/replacement-community-avatar.png"; await page.route(`${uploadedAvatarUrl}*`, (route) => route.fulfill({ status: 404 }), ); await installMockBridge( page, { uploadDescriptors: [ { filename: "replacement-community-avatar.png", sha256: "a".repeat(64), size: 128, type: "image/png", uploaded: 1_779_900_003, url: uploadedAvatarUrl, }, ], }, { relayWsUrl: "wss://default.example.com", skipOnboardingSeed: true, }, ); await page.goto("/"); await seedCurrentAvatar(page, existingAvatarUrl); await page.getByTestId("community-profile-name-key").fill("Tyler"); await uploadCommunityAvatar(page, "replacement-community-avatar.png"); await page.getByTestId("community-profile-next").click(); await expect .poll(() => page.evaluate(() => (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []) .filter( ({ command }) => command === "update_profile" || command === "update_profile_at_relay", ) .map(({ payload }) => (payload as { avatarUrl?: string }).avatarUrl), ), ) .toEqual([undefined]); const profile = await invokeMockCommand<{ avatar_url: string | null }>( page, "get_profile", ); expect(profile.avatar_url).toBe(existingAvatarUrl); }); test("replacing a pending upload disposes its verifier and local preview", async ({ page, }) => { await seedCommunityProfileStage(page, "txn-avatar-replacement"); await page.addInitScript(() => { const testWindow = window as Window & { __BUZZ_E2E_REVOKED_OBJECT_URLS__?: string[]; }; const revokedUrls: string[] = []; const revokeObjectUrl = URL.revokeObjectURL.bind(URL); testWindow.__BUZZ_E2E_REVOKED_OBJECT_URLS__ = revokedUrls; URL.revokeObjectURL = (url) => { revokedUrls.push(url); revokeObjectUrl(url); }; }); const uploadedAvatarUrl = "https://mock.relay/media/superseded-community-avatar.png"; await page.route(`${uploadedAvatarUrl}*`, (route) => route.fulfill({ status: 404 }), ); await installMockBridge( page, { uploadDescriptors: [ { filename: "superseded-community-avatar.png", sha256: "e".repeat(64), size: 128, type: "image/png", uploaded: 1_779_900_001, url: uploadedAvatarUrl, }, ], }, { relayWsUrl: "wss://default.example.com", skipOnboardingSeed: true, }, ); await page.goto("/"); await page.getByTestId("community-profile-name-key").fill("Tyler"); await uploadCommunityAvatar(page, "superseded-community-avatar.png"); const supersededPreviewUrl = await page .getByTestId("community-avatar-circle-image") .getAttribute("src"); expect(supersededPreviewUrl).toMatch(/^blob:/); await page.getByTestId("community-avatar-open").click(); await page.getByRole("tab", { name: "Emoji" }).click(); await selectFirstEmojiFromPicker(page); await page.getByTestId("community-avatar-done").click(); await expect .poll(() => page.evaluate(() => { const testWindow = window as Window & { __BUZZ_E2E_REVOKED_OBJECT_URLS__?: string[]; }; return testWindow.__BUZZ_E2E_REVOKED_OBJECT_URLS__ ?? []; }), ) .toContain(supersededPreviewUrl); await page.waitForTimeout(6_000); await expect(page.getByText("Avatar couldn’t finish uploading")).toHaveCount( 0, ); await expect(page.getByRole("button", { name: "Retry" })).toHaveCount(0); }); test("membership denial on community profile save offers recovery", async ({ page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await page.addInitScript( ({ pubkey, transactionStorageKey }) => { window.localStorage.setItem( `buzz-machine-onboarding-complete.v2:${pubkey}`, "true", ); const timestamp = new Date().toISOString(); window.localStorage.setItem( transactionStorageKey, JSON.stringify({ id: "txn-membership-denied", source: "first-community", stage: "profile", relayUrl: "wss://denied.example.com", communityName: "Denied", communityId: "e2e-default-community", createdAt: timestamp, updatedAt: timestamp, }), ); }, { pubkey: BLANK_TYLER_IDENTITY.pubkey, transactionStorageKey: COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY, }, ); await installMockBridge( page, { profileUpdateError: "relay returned 403 Forbidden: You must be a relay member to access this relay", }, { relayWsUrl: "wss://denied.example.com", skipOnboardingSeed: true, }, ); await page.goto("/"); await page.getByTestId("community-profile-name-key").fill("Kalvin"); await page.getByTestId("community-profile-next").click(); await expect(page.getByTestId("membership-denied")).toBeVisible(); await expect( page.getByRole("heading", { name: "Not a member yet" }), ).toBeVisible(); await page.getByRole("button", { name: "Change community" }).click(); await expect(page.getByTestId("community-change-overlay")).toBeVisible(); await page.getByLabel("Community URL").fill("wss://invited.example.com"); await page.getByRole("button", { name: "Save changes" }).click(); await page.getByRole("button", { name: "Use anyway" }).click(); await expect( page.getByRole("heading", { name: "Build your profile" }), ).toBeVisible(); await expect .poll(() => page.evaluate( (key) => window.localStorage.getItem(key), COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY, ), ) .toContain("wss://invited.example.com"); }); test("identity fallback text does not count as a real onboarding name", async ({ page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await installMockBridge(page, undefined, { skipOnboardingSeed: true }); await page.goto("/"); await expectIncompleteOnboarding(page); await expect(page.getByTestId("onboarding-next")).toBeDisabled(); }); // Regression test for the H2 predicate fix (PR #1508). // A blank first-run identity (no kind:0 event on the relay) must see // onboarding even when the mock bridge returns display_name: "" — the gate // must depend on `hasProfileEvent`, not on `typeof displayName === "string"`. test("first-run blank identity with no profile event sees onboarding", async ({ page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); // Do NOT seed searchProfiles for tyler's pubkey, so ensureMockProfile // constructs a synthesised profile with has_profile_event: false. await installMockBridge(page, undefined, { skipOnboardingSeed: true }); await page.goto("/"); await expectIncompleteOnboarding(page); }); // Regression test for the H2 predicate fix (PR #1508). // A returning user who has a real kind:0 profile event with an empty // display_name must skip onboarding — they are already onboarded. test("returning user with blank display name and real profile event skips onboarding", async ({ page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); // Seed tyler's pubkey into searchProfiles with an empty displayName. // seedMockSearchProfiles stores this into mockProfiles with // has_profile_event: true, simulating a real kind:0 event with no name. await installMockBridge( page, { searchProfiles: [ { pubkey: TEST_IDENTITIES.tyler.pubkey, displayName: "", }, ], }, { skipOnboardingSeed: true }, ); await page.goto("/"); // Profile event exists → onboarding is skipped, app renders. await expect(page.getByTestId("onboarding-gate")).toHaveCount(0); await expectHomeView(page); }); // Regression test for the cache-seed defect (PR #1508 CRITICAL fix). // Sequence: no-event profile fetched and cached with hasProfileEvent absent → // reload with cache present → onboarding must still show. Previously the // initialData seed hardcoded hasProfileEvent: true for any updatedAt > 0 // entry, reopening the original bug on the second app load. test("no-event profile cached then reloaded still sees onboarding", async ({ page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); // Seed a stale v1 cache entry WITHOUT hasProfileEvent (simulating a cache // written by the old code path or a no-event fallback). updatedAt > 0 so // the seed is eligible, but hasProfileEvent is absent → conservative false. const SELF_PROFILE_CACHE_KEY = `buzz-self-profile.v1:ws://localhost:3000:${TEST_IDENTITIES.tyler.pubkey}`; await page.addInitScript( ({ key, cache }) => { window.localStorage.setItem(key, JSON.stringify(cache)); }, { key: SELF_PROFILE_CACHE_KEY, cache: { version: 1, displayName: null, avatarUrl: null, avatarDataUrl: null, updatedAt: 1_700_000_000_000, // hasProfileEvent deliberately absent — legacy/no-event entry. }, }, ); // No profile event on the relay either — ensureMockProfile uses false. await installMockBridge(page, undefined, { skipOnboardingSeed: true }); await page.goto("/"); // Cache seed must NOT promote hasProfileEvent to true. Onboarding shows. await expectIncompleteOnboarding(page); }); test("avatar step uses an add-image placeholder before an avatar is chosen", async ({ page, }) => { await seedActiveIdentity(page, BLANK_AVATAR_PLACEHOLDER_IDENTITY); await installMockBridge(page, undefined, { skipOnboardingSeed: true }); await page.goto("/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await page.getByTestId("onboarding-next").click(); await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible(); const preview = page.getByTestId("onboarding-avatar-preview"); await expect(preview).toBeVisible(); await expect(preview).toHaveAttribute("aria-label", "Add a display image"); await expect(preview).toHaveClass(/border-dashed/); }); test("avatar step reveals preset backgrounds after the first emoji pick", async ({ page, }) => { await seedActiveIdentity(page, BLANK_AVATAR_EMOJI_IDENTITY); await installMockBridge(page, undefined, { skipOnboardingSeed: true }); await page.goto("/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await page.getByTestId("onboarding-next").click(); await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible(); await page.getByRole("tab", { name: "Emoji" }).click(); const colorGridShell = page.getByTestId("onboarding-avatar-color-grid-shell"); await expect(colorGridShell).toHaveAttribute("aria-hidden", "true"); await selectFirstEmojiFromPicker(page); await expect(colorGridShell).toHaveAttribute("aria-hidden", "false"); await expect(page.getByTestId("onboarding-avatar-color-grid")).toBeVisible(); await expect(page.getByTestId("onboarding-avatar-preview")).not.toHaveCSS( "background-color", "rgb(255, 255, 255)", ); }); test("avatar step accepts an avatar URL before completing onboarding", async ({ page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await installMockBridge(page, undefined, { skipOnboardingSeed: true }); await page.goto("/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await page.getByTestId("onboarding-next").click(); await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible(); await page .getByTestId("onboarding-avatar-url") .fill("https://example.com/morty.png"); const preview = page.getByTestId("onboarding-avatar-preview"); await expect(preview).toBeVisible(); const box = await preview.boundingBox(); expect(box?.width).toBeCloseTo(192, 0); expect(box?.height).toBeCloseTo(192, 0); await page.getByTestId("onboarding-next").click(); await expect(page.getByTestId("onboarding-gate")).toHaveCount(0); await expectWelcomeView(page); }); test("failed avatar saves can continue without saving the avatar", async ({ page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await installMockBridge(page, {}, { skipOnboardingSeed: true }); await page.goto("/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await page.getByTestId("onboarding-next").click(); await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible(); await page .getByTestId("onboarding-avatar-url") .fill("https://example.com/morty.png"); await page.evaluate(() => { const testWindow = window as Window & { __BUZZ_E2E__?: { mock?: { profileUpdateError?: string } }; }; if (testWindow.__BUZZ_E2E__?.mock) { testWindow.__BUZZ_E2E__.mock.profileUpdateError = "Temporary avatar sync failure."; } }); await page.getByTestId("onboarding-next").click(); await expect(page.getByText("Temporary avatar sync failure.")).toBeVisible(); await expect( page.getByTestId("onboarding-next-without-saving"), ).toBeVisible(); await page.getByTestId("onboarding-next-without-saving").click(); await expect(page.getByTestId("onboarding-gate")).toHaveCount(0); await expectWelcomeView(page); }); test("avatar upload rejects a file whose server-detected MIME is not an image", async ({ page, }) => { // Models a spoofed/blank picker MIME: the picked file claims to be an image // (passes the browser-side accept filter) but the shared generic upload path // returns a non-image descriptor. The post-upload backstop must reject it so // a non-image can't become an avatar (regression guard — the shared upload // path no longer rejects non-images server-side). await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await installMockBridge( page, { uploadDescriptors: [ { url: `https://mock.relay/media/${"b".repeat(64)}.pdf`, sha256: "b".repeat(64), size: 4096, type: "application/pdf", uploaded: Math.floor(Date.now() / 1000), filename: "not-an-image.pdf", }, ], }, { skipOnboardingSeed: true }, ); await page.goto("/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await page.getByTestId("onboarding-next").click(); await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible(); await page.getByTestId("onboarding-avatar-input").setInputFiles({ name: "looks-like.png", mimeType: "image/png", buffer: Buffer.from("not really a png"), }); await expect(page.getByRole("alert")).toContainText( "Choose a PNG, JPG, GIF, or WebP image.", ); await expect(page.getByTestId("onboarding-avatar-url")).toHaveValue(""); }); test("avatar upload accepts a file whose server-detected MIME is an image", async ({ page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); const url = `https://mock.relay/media/${"c".repeat(64)}.png`; await installMockBridge( page, { uploadDescriptors: [ { url, sha256: "c".repeat(64), size: 2048, type: "image/png", uploaded: Math.floor(Date.now() / 1000), }, ], }, { skipOnboardingSeed: true }, ); await page.goto("/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await page.getByTestId("onboarding-next").click(); await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible(); await page.getByTestId("onboarding-avatar-input").setInputFiles({ name: "avatar.png", mimeType: "image/png", buffer: Buffer.from("png bytes"), }); await expect(page.getByTestId("onboarding-avatar-url")).toHaveValue(""); await expect( page.getByTestId("onboarding-avatar-preview-fallback"), ).toHaveText("MQ"); await expect(page.getByTestId("onboarding-avatar-error")).toHaveCount(0); }); test("first-run onboarding keeps the shell hidden and lands on private Welcome after profile setup", async ({ page, }) => { await seedActiveIdentity(page, FIRST_RUN_ALICE); await installMockBridge(page, undefined, { skipOnboardingSeed: true }); await page.goto("/"); await expect(page.getByTestId("onboarding-gate")).toBeVisible(); await expect(page.getByTestId("onboarding-page-1")).toBeVisible(); await expect(page.getByTestId("onboarding-display-name")).toHaveValue(""); await expectNoHomeSeenEntries(page); await page.getByTestId("onboarding-display-name").fill("Alice"); await completeProfileOnboarding(page); await expect(page.getByTestId("onboarding-gate")).toHaveCount(0); await expectWelcomeView(page); await expectStarterChannels(page); await expectWelcomeGuideIntro(page); }); async function commandCount(page: Page, command: string) { return page.evaluate( (target) => window.__BUZZ_E2E_COMMANDS__?.filter((entry) => entry === target) .length ?? 0, command, ); } test("failed public starter channel setup does not show a retry toast", async ({ page, }) => { const starterError = "Mock starter channel setup failed."; await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await installMockBridge( page, { ensureStarterChannelsErrors: [starterError, starterError] }, { skipOnboardingSeed: true }, ); await page.goto("/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await completeProfileOnboarding(page); await expectPrivateWelcomeLanding(page); await expect( page .locator("[data-sonner-toast][data-removed='false']") .filter({ hasText: "Couldn't set up starter channels" }), ).toHaveCount(0); expect(await commandCount(page, "ensure_starter_channels")).toBe(2); }); test("first-run onboarding posts the live Fizz kickoff", async ({ page }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await installMockBridge( page, { globalAgentConfig: { env_vars: { OPENAI_API_KEY: "e2e-placeholder" }, provider: "openai", model: "gpt-5.5", }, }, { skipOnboardingSeed: true }, ); await page.goto("/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await completeProfileOnboarding(page); await expectPrivateWelcomeLanding(page); // Greeted by the name typed above — the @mention pill also files the opener // into the new user's Inbox mentions feed. await expect(page.getByTestId("message-timeline")).toContainText( "Hi @Morty QA, I'm Fizz. Welcome to Buzz.", ); await expect(page.getByTestId("message-timeline")).toContainText( "Honey and Bumble, introduce yourselves", ); }); test("first-run onboarding lands before Welcome team bootstrap completes", async ({ page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await installMockBridge( page, { createManagedAgentDelayMs: 1_000 }, { skipOnboardingSeed: true }, ); await page.goto("/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await completeProfileOnboarding(page); await expectPrivateWelcomeLanding(page); await expect(page.getByTestId("app-loading-gate")).toHaveCount(0); await expect(page.getByTestId("message-timeline")).toContainText( "Hi @Morty QA, I'm Fizz. Welcome to Buzz.", ); await page.waitForTimeout(1_500); expect(await commandCount(page, "create_managed_agent")).toBe(3); }); test("existing relay profile with display name auto-skips onboarding without localStorage", async ({ page, }) => { // A user whose relay profile already has a display name should skip // onboarding even without the localStorage completion flag. // Seed alice's pubkey into searchProfiles so seedMockSearchProfiles writes // has_profile_event: true into mockProfiles — the harness-intended mechanism // for simulating a returning user with a real kind:0 event. Do NOT use the // static mockProfiles seed (removed in PR #1508); that path collides with // FIRST_RUN_ALICE (same pubkey) and breaks the first-run onboarding specs. await seedActiveIdentity(page, TEST_IDENTITIES.alice); await installMockBridge( page, { searchProfiles: [ { pubkey: TEST_IDENTITIES.alice.pubkey, displayName: "alice" }, ], }, { skipOnboardingSeed: true }, ); await page.goto("/"); await expect(page.getByTestId("onboarding-gate")).toHaveCount(0); await expectHomeView(page); }); test("onboarding uses the existing identity when the community is already set up", async ({ page, }) => { // Community exists (default seed), and machine onboarding has already created // this identity. Profile setup must not offer to create or replace it. await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await installMockBridge(page, undefined, { skipOnboardingSeed: true }); await page.goto("/"); await expect(page.getByTestId("onboarding-display-name")).toHaveValue(""); await expect(page.getByTestId("onboarding-next")).toHaveText("Continue"); await expect(page.getByTestId("onboarding-import-key")).toHaveCount(0); await expect(page.getByText("Create an identity key")).toHaveCount(0); }); test("completed onboarding backfills missing starter channels", async ({ page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await seedOnboardingCompletion(page, BLANK_TYLER_IDENTITY.pubkey); await installMockBridge(page, undefined, { skipOnboardingSeed: true }); await page.goto("/"); await expect(page.getByTestId("onboarding-gate")).toHaveCount(0); await expectHomeView(page); await expect(page.getByTestId("channel-general")).toBeVisible(); await expect(page.getByTestId("channel-welcome-everyone")).toBeVisible(); await expectStarterChannels(page); await expectWelcomeGuideIntro(page, { expectVisible: false }); }); test("finishing onboarding creates starter channels and focuses welcome-everyone for a new member", async ({ page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await installMockBridge(page, undefined, { skipOnboardingSeed: true }); await page.goto("/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await completeProfileOnboarding(page); await expectWelcomeView(page); await expect(page.getByTestId("channel-general")).toBeVisible(); await expectStarterChannels(page); await expectWelcomeGuideIntro(page); await expectWelcomeComposerBannerCompletesAfterPersonaMention(page); }); test("welcome-everywhere banner: X dismiss removes the guidance surface", async ({ page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await installMockBridge(page, undefined, { skipOnboardingSeed: true }); await page.goto("/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await completeProfileOnboarding(page); const banner = page.getByTestId("welcome-composer-guide-banner"); const guidanceLayer = page.getByTestId("welcome-composer-guidance-layer"); const dismissButton = page.getByTestId("welcome-composer-dismiss-button"); // Banner and guidance layer are visible in the prompt state. await expect(banner).toBeVisible(); await expect(guidanceLayer).toBeVisible(); await expect(dismissButton).toBeVisible(); await dismissButton.click(); // After dismiss the entire guidance surface must be gone. await expect(banner).toHaveCount(0, { timeout: 2_000 }); await expect(guidanceLayer).toHaveCount(0); }); test("welcome-everywhere banner: dismiss persists after channel re-entry", async ({ page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await installMockBridge(page, undefined, { skipOnboardingSeed: true }); await page.goto("/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await completeProfileOnboarding(page); const banner = page.getByTestId("welcome-composer-guide-banner"); await expect(banner).toBeVisible(); await page.getByTestId("welcome-composer-dismiss-button").click(); await expect(banner).toHaveCount(0, { timeout: 2_000 }); // Leave the Welcome channel. await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toContainText("general"); await expect(banner).toHaveCount(0); // Return — banner must stay hidden. await page.getByTestId("channel-welcome-everyone").click(); await expect(page.getByTestId("chat-title")).toContainText("Welcome"); await expect(banner).toHaveCount(0); }); test("initial profile read failures still hold incomplete users in onboarding", async ({ page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await installMockBridge( page, { profileReadError: "Temporary profile read failure.", }, { skipOnboardingSeed: true }, ); await page.goto("/"); await expectIncompleteOnboarding(page); }); test("failed first profile saves can be skipped for the current session", async ({ page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await installMockBridge( page, { profileUpdateError: "Temporary profile sync failure.", }, { skipOnboardingSeed: true }, ); await page.goto("/"); await expect(page.getByTestId("onboarding-gate")).toBeVisible(); await expect(page.getByTestId("onboarding-display-name")).toHaveValue(""); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await page.getByTestId("onboarding-next").click(); await expect(page.getByText("Temporary profile sync failure.")).toBeVisible(); await page.getByTestId("onboarding-skip").click(); await expect(page.getByTestId("onboarding-gate")).toHaveCount(0); await expectHomeView(page); }); test("generic relay save failures use the generic reconnect card", async ({ page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await installMockBridge( page, { profileUpdateError: "relay unreachable: could not connect to relay", }, { skipOnboardingSeed: true }, ); await page.goto("/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await page.getByTestId("onboarding-next").click(); await expect( page.getByTestId("onboarding-relay-reconnect-card"), ).toBeVisible(); await expect( page.getByTestId("onboarding-relay-reconnect-card"), ).toContainText("Can't reach the relay"); await expect( page.getByTestId("onboarding-relay-reconnect-card"), ).toContainText("Click to connect"); }); test("custom relay proxy sign-in failures use the generic reconnect card", async ({ page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await installMockBridge( page, { profileUpdateError: "relay unreachable: relay returned an unexpected HTML page (network sign-in?)", }, { skipOnboardingSeed: true }, ); await page.goto("/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await page.getByTestId("onboarding-next").click(); await expect( page.getByTestId("onboarding-relay-reconnect-card"), ).toBeVisible(); await expect( page.getByTestId("onboarding-relay-reconnect-card"), ).toContainText("Can't reach the relay"); }); test("community access failures use the generic reconnect card", async ({ page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await installMockBridge( page, { profileUpdateError: "relay unreachable: 403 Forbidden", }, { skipOnboardingSeed: true }, ); await page.goto("/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await page.getByTestId("onboarding-next").click(); await expect( page.getByTestId("onboarding-relay-reconnect-card"), ).toBeVisible(); await expect( page.getByTestId("onboarding-relay-reconnect-card"), ).toContainText("Can't reach the relay"); }); test("dismissed relay save failures reappear on retry", async ({ page }) => { const relayError = "relay unreachable: could not connect to relay"; await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await installMockBridge( page, { profileUpdateErrors: [relayError, relayError], }, { skipOnboardingSeed: true }, ); await page.goto("/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await page.getByTestId("onboarding-next").click(); await expect( page.getByTestId("onboarding-relay-reconnect-card"), ).toBeVisible(); await page.getByTestId("onboarding-relay-reconnect-card").hover(); await page.getByTestId("onboarding-relay-reconnect-card-dismiss").click(); await expect(page.getByTestId("onboarding-relay-reconnect-card")).toHaveCount( 0, ); await page.getByTestId("onboarding-next").click(); await expect( page.getByTestId("onboarding-relay-reconnect-card"), ).toBeVisible(); }); test("existing relay profile with display name auto-completes onboarding", async ({ page, }) => { // A user whose relay profile already has a display name should skip // onboarding entirely — they've already set up their identity previously // (possibly on another machine or app data directory). // Seed alice's pubkey into searchProfiles so seedMockSearchProfiles writes // has_profile_event: true into mockProfiles — the harness-intended mechanism // for simulating a returning user with a real kind:0 event. Do NOT use the // static mockProfiles seed (removed in PR #1508); that path collides with // FIRST_RUN_ALICE (same pubkey) and breaks the first-run onboarding specs. await seedActiveIdentity(page, TEST_IDENTITIES.alice); await installMockBridge( page, { searchProfiles: [ { pubkey: TEST_IDENTITIES.alice.pubkey, displayName: "alice" }, ], }, { skipOnboardingSeed: true }, ); await page.goto("/"); await expect(page.getByTestId("onboarding-gate")).toHaveCount(0); await expectHomeView(page); }); test("open relay skips membership gating during onboarding", async ({ page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await installMockBridge( page, { relayRequiresMembership: false, relayRole: null, }, { skipOnboardingSeed: true }, ); await page.goto("/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await page.getByTestId("onboarding-next").click(); await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible(); await expect(page.getByTestId("membership-denied")).toHaveCount(0); }); test("membership denial can import a different invited key", async ({ page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await installMockBridge( page, { relayRequiresMembership: true, relayRole: null, }, { skipOnboardingSeed: true }, ); await page.goto("/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await page.getByTestId("onboarding-next").click(); await expect(page.getByTestId("membership-denied")).toBeVisible(); await page.getByTestId("membership-denied-change-key").click(); const importedNsec = nsecEncode(hexToBytes(TEST_IDENTITIES.alice.privateKey)); await page.getByTestId("membership-denied-nsec-input").fill(importedNsec); await expect( page.getByTestId("membership-denied-npub-preview"), ).toBeVisible(); await page.getByTestId("membership-denied-import-key").click(); // Alice already has a relay profile with a display name, so after the // identity swap the onboarding gate auto-completes. // The identity swap must tear down the old relay socket through the native // disconnect command before the replacement identity connects. await expect .poll(() => page.evaluate( () => ( window as Window & { __BUZZ_E2E_COMMANDS__?: string[]; } ).__BUZZ_E2E_COMMANDS__?.includes("plugin:websocket|disconnect") ?? false, ), ) .toBe(true); await expect .poll(() => page.evaluate((storageKey) => { const rawIdentity = window.localStorage.getItem(storageKey); const identity = rawIdentity ? (JSON.parse(rawIdentity) as { pubkey?: string }) : null; return identity?.pubkey ?? null; }, E2E_IDENTITY_OVERRIDE_STORAGE_KEY), ) .toBe(TEST_IDENTITIES.alice.pubkey); await expect(page.getByTestId("onboarding-gate")).toHaveCount(0); await expectHomeView(page); }); test("onboarding relay reconnect — click shows Connected then auto-dismisses", async ({ page, }) => { // Produce the relay reconnect card via a relay-unreachable profile save error. await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await installMockBridge( page, { profileUpdateError: "relay unreachable: could not connect to relay", }, { skipOnboardingSeed: true }, ); await page.goto("/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await page.getByTestId("onboarding-next").click(); const card = page.getByTestId("onboarding-relay-reconnect-card"); await expect(card).toBeVisible(); await expect(card).toContainText("Can't reach the relay"); // Drive degraded before clicking so the card is in the expected error state. await setRelayConnectionState(page, "disconnected"); // Click the reconnect button. The controller attempts reconnect; the mock // relay reconnects successfully (fast-path or via the state seam). Either // path should result in the card transitioning to Connected and then // auto-dismissing. await page.getByTestId("onboarding-reconnect-relay").click(); // Drive connected to ensure the controller's connection-state path fires // and the component's hadActiveReconnectRef guard is satisfied. await setRelayConnectionState(page, "connected"); await expect(card).toContainText("Connected", { timeout: 5_000 }); await expect(card).not.toContainText("Can't reach the relay"); // Auto-dismiss fires after ONBOARDING_CONNECTIVITY_SUCCESS_AUTO_DISMISS_MS // (2500ms). Allow generous headroom for CI. await expect(card).toBeHidden({ timeout: 10_000 }); }); test("onboarding relay reconnect — connected without a prior click does not show Connected", async ({ page, }) => { // Tests the hadActiveReconnectRef guard: if the relay transitions to // "connected" without the user having clicked the reconnect button, the card // must NOT show Connected. This guards against spurious success on initial // connection or background recovery with no user action. await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await installMockBridge( page, { profileUpdateError: "relay unreachable: could not connect to relay", }, { skipOnboardingSeed: true }, ); await page.goto("/"); await page.getByTestId("onboarding-display-name").fill("Morty QA"); await page.getByTestId("onboarding-next").click(); const card = page.getByTestId("onboarding-relay-reconnect-card"); await expect(card).toBeVisible(); // Drive to disconnected state (no reconnect click has happened). await setRelayConnectionState(page, "disconnected"); // Drive to connected WITHOUT clicking — this would happen on a spontaneous // background recovery. The hadActiveReconnectRef guard must block markSuccess(). await setRelayConnectionState(page, "connected"); // Wait long enough for any spurious markSuccess() to fire, then assert the // card stayed in the error state. await page.waitForTimeout(500); await expect(card).toBeVisible(); await expect(card).toContainText("Can't reach the relay"); await expect(card).not.toContainText("Connected"); }); test("membership denied shows all four affordances and change-community edits non-destructively", async ({ page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await installMockBridge( page, { relayRequiresMembership: true, relayRole: null, }, { skipOnboardingSeed: true }, ); await page.goto("/"); // Fill the display name and advance — membership check triggers denial. await page.getByTestId("onboarding-display-name").fill("Morty QA"); await page.getByTestId("onboarding-next").click(); // Membership-denied screen renders with all four affordances. const denied = page.getByTestId("membership-denied"); await expect(denied).toBeVisible(); await expect(denied.getByRole("button", { name: "Try again" })).toBeVisible(); await expect(denied.getByRole("button", { name: "Back" })).toBeVisible(); await expect( denied.getByRole("button", { name: "Change community" }), ).toBeVisible(); await expect(page.getByTestId("membership-denied-change-key")).toBeVisible(); await expect( page.getByTestId("membership-denied-redeem-invite"), ).toBeVisible(); // Click "Change community" → the overlay opens. await denied.getByRole("button", { name: "Change community" }).click(); const overlay = page.getByTestId("community-change-overlay"); await expect(overlay).toBeVisible(); // Change the relay URL to a new one. The probe will time out for a fake URL // so we wait for the "Use anyway" button. await overlay .locator("#community-edit-url") .fill("wss://new-relay.example.com"); await overlay.getByRole("button", { name: "Save changes" }).click(); // The fields are frozen while the probe is pending, so the saved URL and // any warning cannot get out of sync with a subsequent edit. await expect(overlay.locator("#community-edit-url")).toBeDisabled(); await expect(overlay.locator("#community-edit-name")).toBeDisabled(); await expect( overlay.getByRole("button", { name: "Use anyway" }), ).toBeVisible(); await overlay.getByRole("button", { name: "Use anyway" }).click(); // The community update triggers a remount (reinitKey bump). The persisted // community should now point to the new relay URL. await expect .poll(() => page.evaluate(() => { const raw = window.localStorage.getItem("buzz-communities"); const communities = raw ? (JSON.parse(raw) as Array<{ relayUrl?: string }>) : []; return communities[0]?.relayUrl ?? null; }), ) .toBe("wss://new-relay.example.com"); // Identity was NOT wiped — the override storage key is still intact. await expect .poll(() => page.evaluate((storageKey) => { return window.localStorage.getItem(storageKey) !== null; }, E2E_IDENTITY_OVERRIDE_STORAGE_KEY), ) .toBe(true); }); test("cancel from profile Back preserves drafts and denied Back returns to interrupted page", async ({ page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await installMockBridge( page, { relayRequiresMembership: true, relayRole: null, }, { skipOnboardingSeed: true }, ); await page.goto("/"); // --- Profile step: Back opens community-change overlay --- const nameInput = page.getByTestId("onboarding-display-name"); await nameInput.fill("Morty QA"); await page.getByTestId("onboarding-back").click(); // The community change overlay should open. const overlay = page.getByTestId("community-change-overlay"); await expect(overlay).toBeVisible(); // Cancel the overlay — profile should still be visible with the name intact. await overlay.getByRole("button", { name: "Cancel" }).click(); await expect(overlay).toHaveCount(0); await expect(page.getByTestId("onboarding-page-1")).toBeVisible(); await expect(nameInput).toHaveValue("Morty QA"); // --- Profile → membership denied → Back returns to profile --- // Advance triggers membership check → denied (relayRole is null). await page.getByTestId("onboarding-next").click(); await expect(page.getByTestId("membership-denied")).toBeVisible(); // Press Back on the denied screen — should return to the profile page // (deniedFromPage = "profile") with the name draft intact. await page .getByTestId("membership-denied") .getByRole("button", { name: "Back" }) .click(); await expect(page.getByTestId("onboarding-page-1")).toBeVisible(); await expect(nameInput).toHaveValue("Morty QA"); }); test("denied on relay A then paste relay B invite URL switches community to B", async ({ page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await installMockBridge( page, { relayRequiresMembership: true, relayRole: null, }, { skipOnboardingSeed: true }, ); await page.goto("/"); // Record the initial relay URL (relay A). const initialRelayUrl = await page.evaluate(() => { const raw = window.localStorage.getItem("buzz-communities"); const communities = raw ? (JSON.parse(raw) as Array<{ relayUrl?: string }>) : []; return communities[0]?.relayUrl ?? null; }); expect(initialRelayUrl).not.toBeNull(); // Fill name, advance → denied on relay A. await page.getByTestId("onboarding-display-name").fill("Morty QA"); await page.getByTestId("onboarding-next").click(); await expect(page.getByTestId("membership-denied")).toBeVisible(); // Intercept the claimInvite POST to relay B so it succeeds. const relayBUrl = "wss://relay-b.example.com"; const relayBHttpUrl = "https://relay-b.example.com"; const policyReceipt = "relay-signed-policy-receipt"; await page.route(`${relayBHttpUrl}/api/join-policy`, async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ policy: { terms_markdown: "# Terms", privacy_markdown: "# Privacy", age_attestation_required: true, version: "policy-v1", }, }), }); }); await page.route( `${relayBHttpUrl}/api/invites/accept-policy`, async (route) => { expect(route.request().postDataJSON()).toEqual({ code: "test-invite-code", policy_version: "policy-v1", age_confirmed: true, }); await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ receipt: policyReceipt }), }); }, ); await page.route(`${relayBHttpUrl}/api/invites/claim`, async (route) => { expect(route.request().postDataJSON()).toMatchObject({ code: "test-invite-code", policy_receipt: policyReceipt, }); await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ status: "joined", community_id: "mock-community", host: "relay-b.example.com", role: "member", }), }); }); // Click "Have an invite?" and enter an HTTPS invite URL for relay B. await page.getByTestId("membership-denied-redeem-invite").click(); await page .getByTestId("invite-redeem-input") .fill(`${relayBHttpUrl}/invite/test-invite-code`); await page.getByTestId("invite-redeem-submit").click(); await expect(page.getByText("I am 18 years of age or older.")).toBeVisible(); await page.getByLabel("I am 18 years of age or older.").check(); await page .getByLabel("I agree to the Buzz Terms of Service and Privacy Policy.") .check(); await page.getByTestId("invite-redeem-submit").click(); // After successful claim, relay B is added and becomes active; relay A remains // in the community list for future switching. await expect .poll(() => page.evaluate(() => { const raw = window.localStorage.getItem("buzz-communities"); const activeCommunityId = window.localStorage.getItem( "buzz-active-community-id", ); const communities = raw ? (JSON.parse(raw) as Array<{ id?: string; relayUrl?: string }>) : []; return communities.find(({ id }) => id === activeCommunityId)?.relayUrl; }), ) .toBe(relayBUrl); });