import type { Page } from "@playwright/test"; import type { ChannelTemplate, RelayEvent } from "../../src/shared/api/types"; import type { MockManagedAgentSeed } from "../../src/testing/e2eBridge"; import { FEATURE_OVERRIDES_STORAGE_KEY, PREVIEW_FEATURE_IDS } from "./features"; export const TEST_IDENTITIES = { tyler: { privateKey: "3dbaebadb5dfd777ff25149ee230d907a15a9e1294b40b830661e65bb42f6c03", pubkey: "e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34", username: "tyler", }, alice: { privateKey: "3fa69cbac1dcb9b7b6ac83117c74bd23bb1e717fe8fc7cfda67b47bb4323383d", pubkey: "953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f", username: "alice", }, bob: { privateKey: "7667ae87cbc50ac0b2251b115c9c51aca7e2da65301b28ecf82f4e4c5260a6bb", pubkey: "bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260", username: "bob", }, charlie: { privateKey: "813fc3bb90587a82b2bfee9b833503e7686c7480681850b3d789c6987e997fc8", pubkey: "554cef57437abac34522ac2c9f0490d685b72c80478cf9f7ed6f9570ee8624ea", username: "charlie", }, outsider: { privateKey: "91bd673543195c0c78fc74a881545dcc8cd6ea6d0f9f8efb3225d58c4bc70dad", pubkey: "df8e91b86fda13a9a67896df77232f7bdab2ba9c3e165378e1ba3d24c13a328e", username: "outsider", }, } as const; type BridgeMode = "mock" | "relay"; type MockCommandAvailability = { available?: boolean; command?: string; resolvedPath?: string | null; }; type MockSearchProfileSeed = { pubkey: string; displayName: string | null; avatarUrl?: string | null; nip05Handle?: string | null; about?: string | null; ownerPubkey?: string | null; isAgent?: boolean; }; type MockRelayAgentSeed = { pubkey: string; name: string; agentType?: string; capabilities?: string[]; respondTo?: "owner-only" | "allowlist" | "anyone"; respondToAllowlist?: string[]; channelNames?: string[]; channelIds?: string[]; status?: "online" | "away" | "offline"; }; type MockHuddleSeed = { parentChannelId: string; ephemeralChannelId: string; huddleThreadEventId?: string | null; phase?: "creating" | "connected" | "active"; members: Array<{ pubkey: string; role: "owner" | "admin" | "member" | "guest" | "bot"; }>; transcriptionEnabled?: boolean; ttsEnabled?: boolean; isCreator?: boolean; }; type MockPersonaSeed = { id?: string; displayName: string; avatarUrl?: string | null; systemPrompt: string; updatedAt?: string; isActive?: boolean; shared?: boolean; sourceTeam?: string | null; envVars?: Record; /** * Runtime the persona is pinned to (e.g. "goose", "codex", "claude"). Lets a * spec seed a CLI-login runtime whose provider picker is hidden, so the Edit * dialog's provider-aware submit gate can be driven end-to-end. Omitted → * null (definition inherits the app default at open). */ runtime?: string | null; /** Model pinned on the persona (a custom model id for Customize mode). */ model?: string | null; /** Provider pinned on the persona. Leave empty for Codex/Claude runtimes. */ provider?: string | null; namePool?: string[]; respondTo?: "owner-only" | "allowlist" | "anyone"; respondToAllowlist?: string[]; }; type MockTeamSeed = { id?: string; name: string; description?: string | null; personaIds: string[]; }; export type MockEngramEntry = { slug: string; body: string; eventId: string; createdAt: number; outgoingRefs: string[]; }; export type MockAgentMemoryListing = { core: MockEngramEntry | null; memories: MockEngramEntry[]; truncated: boolean; fetchedAt: number; }; /** Result returned by the `install_acp_runtime` mock command. */ type MockInstallRuntimeResult = { success: boolean; steps: { step: string; command: string; success: boolean; stdout: string; stderr: string; exit_code: number | null; hint?: string; }[]; /** Install log the failure message points at. Omitted = no log was written. */ log_path?: string | null; }; type MockBridgeOptions = { /** Tauri window label exposed to the app. Defaults to the main window. */ windowLabel?: string; ttsSettings?: { version: number; agentTextToSpeech: boolean; voicePreferences: string[]; }; /** Native picker boundary result for Pocket voice import tests. */ pocketVoiceImportResult?: "success" | "cancel" | "invalid"; /** Advertised HEAD for the first mock project without adding that branch. */ projectHeadBranch?: string; /** Relay NIP-11 identity used to sign authoritative repository state. */ relaySelf?: string | null; /** Native-like huddle state seeded from authoritative role-bearing membership. */ huddle?: MockHuddleSeed; /** Builderlab account returned by hosted-community onboarding. Null/omitted = signed out. */ builderlabAuth?: { email?: string; name?: string; expiresAt: string } | null; /** Optional policy returned by the native join-policy discovery command. */ joinPolicy?: { terms_markdown?: string; privacy_markdown?: string; age_attestation_required: boolean; version: string; } | null; /** Bound Builderlab Nostr identity. Null/omitted = not linked yet. */ builderlabIdentity?: { npub?: string; pubkey_hex?: string } | null; /** Communities owned by the mocked Builderlab account. */ builderlabCommunities?: Array<{ id?: string; name?: string; slug?: string; normalized_host?: string; archived_at?: string | null; }>; acpRuntimesCatalog?: Record[]; /** Catalog returned after a successful mocked install. */ acpRuntimesCatalogAfterInstall?: Record[]; /** Catalog responses after install for testing later sign-in completion. */ acpRuntimesCatalogAfterInstallSequence?: Record[][]; /** Catalog responses for successive discovery calls. The final response repeats. */ acpRuntimesCatalogSequence?: Record[][]; acpRuntimesDelayMs?: number; /** When true, the mock catalog discovery command throws an error. */ acpRuntimesError?: boolean; acpAuthMethods?: Record[] }>; acpAuthMethodsError?: string; /** When set, the `delete_custom_harness` mock command throws with this message. */ deleteCustomHarnessError?: string; connectAcpRuntimeResult?: { launched: boolean }; connectAcpRuntimeDelayMs?: number; connectAcpRuntimeError?: string; installAcpRuntimeDelayMs?: number; /** Live output lines the mocked install emits before it settles, in order. * Each arrives as an `acp-install-output` event, preceded by the clear * signal the backend sends at the start of an attempt. */ installAcpRuntimeOutputLines?: string[]; /** Override the result returned by the `install_acp_runtime` mock command. * Pass `{ success: false, steps: [...] }` to exercise error/Retry states. */ installAcpRuntimeResult?: MockInstallRuntimeResult; /** Sequence of results for successive `install_acp_runtime` calls. Call N * returns results[N]; when exhausted the last entry repeats. Takes precedence * over `installAcpRuntimeResult`. Use for fail-then-succeed Retry tests. */ installAcpRuntimeResults?: MockInstallRuntimeResult[]; activePersonaIds?: string[]; /** * Listing returned by the mocked `get_agent_memory` command. Pass a single * listing for any managed agent, or a pubkey-keyed record for per-agent data. */ agentMemory?: MockAgentMemoryListing | Record; managedAgentPrereqs?: { acp?: MockCommandAvailability; mcp?: MockCommandAvailability; }; managedAgents?: MockManagedAgentSeed[]; /** Result returned by the mocked `add_agent_to_huddle` command. */ addAgentToHuddleResult?: { ephemeral_added: boolean; parent_added: boolean; parent_error: string | null; }; /** Delay an invocation-time huddle snapshot to exercise hydration ordering. */ huddleStateReadDelayMs?: number; /** Delay companion creation to expose the newly-started huddle handoff state. */ openHuddleWindowDelayMs?: number; /** Delay the native start result after membership arrives in the channel list. */ startHuddleReturnDelayMs?: number; /** Per agent+relay runtime rows for pair-scoped lifecycle commands. */ managedAgentRuntimes?: Array<{ pubkey: string; relayUrl: string; lifecycle?: | "starting" | "listening" | "waking" | "ready" | "failed" | "stopped"; }>; personas?: MockPersonaSeed[]; /** Community catalog replaceable-event heads returned by relay queries. */ personaCatalogEvents?: RelayEvent[]; /** Outcomes for successive explicit persona share publications. */ personaSharePublicationStatuses?: Array<"published" | "queued">; teams?: MockTeamSeed[]; relayAgents?: MockRelayAgentSeed[]; /** Delay both managed and relay agent directory reads. */ agentListDelayMs?: number; createManagedAgentDelayMs?: number; channelTemplates?: ChannelTemplate[]; addChannelMembersDelayMs?: number; /** Sequenced add-member failures. A string fails that call; null succeeds. */ addChannelMembersErrors?: (string | null)[]; channelMembersReadDelayMs?: number; channelsReadError?: string; /** Reject successive mock `get_channels` calls, then resume. */ channelsReadErrors?: (string | null)[]; /** Reject successive mock `create_channel` calls, then resume. */ createChannelErrors?: string[]; /** Reject successive mock `ensure_starter_channels` calls, then resume. */ ensureStarterChannelsErrors?: string[]; /** Reject successive mock `join_channel` calls, then resume. */ joinChannelErrors?: string[]; /** Number of seeded rows in the deep-history fixture. Defaults to 600. */ deepHistoryMessageCount?: number; feedReadError?: string; canvasReadError?: string; /** Delay (ms) for `apply_workspace`; see e2eBridge mock config. */ applyCommunityDelayMs?: number; openDmDelayMs?: number; sendMessageDelayMs?: number; /** Hold mock send live echoes until the E2E release seam is invoked. */ deferSendMessageLiveEcho?: boolean; /** Close the first channel-window live REQ; its retry is accepted. */ closeChannelLiveSubscriptionOnce?: boolean; /** Reject successive kind-9 sends with these messages, then resume. */ sendMessageErrors?: string[]; /** Reject successive managed-agent starts, then resume. */ startManagedAgentErrors?: string[]; /** Delay (ms) after snapshotting a thread-replies page so E2E tests can * deliver live reply/aux events while an older response is in flight. */ threadRepliesDelayMs?: number; usersBatchDelayMs?: number; /** Delay (ms) for older-history fetches; see e2eBridge mock config. */ channelWindowDelayMs?: number; profileReadDelayMs?: number; profileReadError?: string; /** Override whether get_profile reports a real kind:0 event. */ profileHasEvent?: boolean; profileUpdateError?: string; profileUpdateErrors?: string[]; linkPreviewMetadata?: { title: string; siteName: string | null; description: string | null; imageDataUrl: string | null; imageDomain: string | null; imageFetchState?: "none" | "image" | "transient_failure" | "rejected"; imageRetryAfterMs?: number | null; faviconDataUrl?: string | null; } | null; linkPreviewMetadataByHref?: Record< string, { title: string; siteName: string | null; description: string | null; imageDataUrl: string | null; imageDomain: string | null; imageFetchState?: "none" | "image" | "transient_failure" | "rejected"; imageRetryAfterMs?: number | null; faviconDataUrl?: string | null; } | null >; linkPreviewMetadataDelayMs?: number; /** Simulates native cold-cache startup work before the async response. */ linkPreviewMetadataStartBlockMs?: number; searchProfiles?: MockSearchProfileSeed[]; updateAvailable?: boolean; updateChannelDelayMs?: number; updateDownloadDelayMs?: number; updateVersion?: string; /** Set to false to simulate a Linux .deb install where auto-update is not * supported. Defaults to true. See e2eBridge mock.autoUpdateSupported. */ autoUpdateSupported?: boolean; /** Reject browser opener calls to exercise manual pairing fallback UI. */ openerError?: string; /** Delay binding signatures so specs can exercise request supersession. */ nostrBindSignDelayMs?: number; /** Reject successive mock WebSocket connect attempts, then resume. */ websocketConnectErrors?: string[]; stallWebsocketSends?: boolean; userSearchDelayMs?: number; // NIP-IA gate inputs — drive the archive-button gate matrix in // tests/e2e/identity-archive.spec.ts. /** * Lowercase-hex pubkeys returned by `list_archived_identities`. Drives the * "Archived on this relay" flair + Unarchive button. */ archivedIdentities?: string[]; /** * Drives the `is_me` field of `resolve_oa_owner`. When true, the harness * reports the active identity as the verified NIP-OA owner of the viewee * (owner-path branch of the gate). */ oaOwnerIsMe?: boolean; /** Whether the mock relay advertises NIP-43 membership support. Defaults to false. */ relayRequiresMembership?: boolean; /** Delay EOSE for membership snapshots after delivering the event. */ relayMembershipEoseDelayMs?: number; /** * Active identity's role in the seeded `mockRelayMembers`. `null` removes * the active identity from the membership list entirely (admin-path branch * evaluates false). */ relayRole?: "owner" | "admin" | "member" | null; /** * Descriptors returned by the mocked `pick_and_upload_media` / * `upload_media_bytes` commands. When omitted, the bridge returns a single * generic PDF so the file-attachment flow can be exercised by default. An * explicit `[]` is honoured (models a picker cancel / no files selected). */ uploadDelayMs?: number; /** Exercise the production composer path that queues files until send. */ deferredComposerUploads?: boolean; /** Delay (ms) applied to `encode_agent_snapshot_for_send` so E2E tests can * observe the "preparing" phase before the upload begins. 0/undefined = instant. */ encodeDelayMs?: number; /** Delay (ms) applied to `get_relay_self` so E2E tests can prove the * fail-closed race: DMs are withheld while classification is unresolved. */ relaySelfDelayMs?: number; /** Delay (ms) applied to `start_pairing` so pairing loading UI is observable. */ pairingStartDelayMs?: number; /** * Sequenced results for `confirm_team_snapshot_import`. String = throw * with that message; null = succeed. Call N uses results[N]; last entry * repeats when exhausted. Follows the `nsecErrors` precedent. */ teamSnapshotConfirmErrors?: (string | null)[]; /** * When true, `preview_team_snapshot_import` returns a preview with * `hasSourceAllowlist: true` so the allowlist section renders in the * import dialog. */ teamSnapshotPreviewHasSourceAllowlist?: boolean; /** * When set to a non-empty string, `fetch_snapshot_bytes` throws with this * message — lets specs prove malformed/hash/size-mismatch error paths. */ snapshotFetchError?: string; uploadDescriptors?: { url: string; sha256: string; size: number; type: string; uploaded: number; dim?: string; blurhash?: string; thumb?: string; duration?: number; image?: string; filename?: string; }[]; /** * Seed rows returned by the mocked `list_save_subscriptions` command. * Drives the LocalArchiveSettingsCard subscription list view in screenshot * and UI tests without a real SQLite backend. */ saveSubscriptions?: Array<{ scope_type: string; scope_value: string; kinds: string; // JSON-encoded integer array, e.g. "[9,40002]" }>; /** * Event IDs that `get_event` should report as definitively not found. * Causes `useDraftRootStatus` to map the draft to `deleted` state so specs * can exercise the "Thread deleted" label / disabled-send path. */ deletedEventIds?: string[]; /** * When true, `get_identity` returns `lost: true` until `persist_current_identity` * or `import_identity` is invoked. Drives the identity-lost recovery UX in tests. */ identityLost?: boolean; /** * When true, `get_identity` returns `locked: true` until `import_identity` is * invoked. Drives the keyring-locked screen in tests. */ identityLocked?: boolean; /** * Pending community deep links seeded into the mocked Rust-side queue. * The frontend drains these on boot into onboarding or an editable Add * Community prefill. */ pendingCommunityDeepLinks?: Array<{ id: string; kind: "connect" | "join" | "add-community"; relayUrl: string; code?: string | null; name?: string | null; }>; /** * Global agent config returned by `get_global_agent_config`. Defaults to * an empty config (no provider, model, or env vars) if not specified. * Pass a config with a provider to test Inherit-from-global behavior. */ globalAgentConfig?: { env_vars: Record; provider: string | null; model: string | null; preferred_runtime?: string | null; }; ownerOnlyAccessBuild?: boolean; /** File-layer config returned by runtime id. */ runtimeFileConfigs?: Record< string, { provider: string | null; model: string | null; satisfiedEnvKeys: string[]; } | null >; bakedBuildEnv?: Array<{ key: string; masked: boolean; value: string; }>; /** Delay (ms) for `get_baked_build_env` so specs can verify load gating. */ bakedBuildEnvDelayMs?: number; /** Delay (ms) for `set_global_agent_config` — hold saves open in tests. * Alias of `globalConfigSaveDelayMs` (kept for onboarding specs). */ setGlobalAgentConfigDelayMs?: number; /** Sequenced save failures. A string rejects that call; null succeeds. */ setGlobalAgentConfigErrors?: (string | null)[]; /** Errors returned by successive backup verification attempts. Null succeeds. */ backupVerificationErrors?: (string | null)[]; /** Public identities returned by successive successful backup verifications. */ backupVerificationPubkeys?: string[]; /** Delay (ms) applied to backup encryption so specs can observe pending UI. */ backupEncryptionDelayMs?: number; /** Native paths returned by successive backup saves. */ backupSavePaths?: Array; /** * When set, `get_nsec` throws with this message. For a single always-fail * scenario. Use `nsecErrors` for sequenced fail/succeed. */ nsecError?: string; /** * Sequenced results for `get_nsec`. String = throw with that message; * null = succeed. Call N uses results[N]; last entry repeats when exhausted. */ nsecErrors?: (string | null)[]; /** * The `restarted_count` returned by `set_global_agent_config`. Defaults to * 0 (no agents restarted). Set to a positive integer to drive the * "Saved. Restarted N agent(s)." status text in AgentDefaultsSettingsCard. */ globalConfigRestartedCount?: number; /** * The `failed_restart_count` returned by `set_global_agent_config`. Defaults * to 0. Set to a positive integer to drive the "failed to restart — check * the Agents tab." status text in AgentDefaultsSettingsCard. */ globalConfigFailedRestartCount?: number; /** * Milliseconds to delay the mocked `set_global_agent_config` response. * Defaults to 0 (resolve immediately). Use to hold a save in flight so a * test can interleave edits and exercise the mid-save race handling. */ globalConfigSaveDelayMs?: number; /** * Override the `discover_agent_models` mock response. When set, the bridge * returns this catalog instead of the default per-harness model list. * Use `{ models: [], supportsSwitching: false }` to exercise empty discovery * (e.g. optional harnesses that omit the Model control). */ discoverAgentModels?: { models: Array<{ id: string; name: string | null; description?: string | null; }>; supportsSwitching: boolean; agentDefaultModel?: string | null; selectedModel?: string | null; }; /** * When set, `discover_agent_models` throws with this message instead of * returning a catalog. Exercises the discovery-failure UI path. */ discoverAgentModelsError?: string; /** * Providers returned by `discover_backend_providers`. Defaults to `[]` * (the "Run on" section stays hidden). Setting this renders the remote * backend selector in the create-agent dialog. */ backendProviders?: Array<{ id: string; binaryPath: string }>; /** * Result returned by `probe_backend_provider`. Defaults to * `{ ok: false, error: "mock: no providers available" }`. */ backendProviderProbeResult?: Record; /** * Delay (ms) applied to `probe_backend_provider` so a spec can assert the * pre-resolution state (config fields stay probe-gated until the result * lands). Typing while a probe is in flight is unreachable through the UI * for the same reason; that merge path is pinned at the unit level * (`applyProbeResult` in whereToRunIntent.test.mjs). */ backendProviderProbeDelayMs?: number; }; type BridgeOptions = { mode: BridgeMode; mock?: MockBridgeOptions; relayHttpUrl?: string; relayWsUrl?: string; autoConnectDefaultRelay?: boolean; skipOnboardingSeed?: boolean; skipCommunitySeed?: boolean; /** * When true (default), seed every preview feature in preview-features.json as * enabled in localStorage so E2E tests can interact with gated UI without * clicking through the Experiments settings panel. Set to false in specs * that exercise the Experiments toggle UI itself. */ seedPreviewFeatures?: boolean; user?: keyof typeof TEST_IDENTITIES; }; const WELCOME_CHANNEL_ENSURED_STORAGE_KEY_PREFIX = "buzz-welcome-channel-ensured.v2:"; const ONBOARDING_COMPLETION_STORAGE_KEY_PREFIX = "buzz-onboarding-complete.v1:"; const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8); // The relay HTTP/WS URLs follow BUZZ_E2E_RELAY_URL (same env var seed.ts reads), // so a suite pointed at an isolated relay (e.g. the read-model harness on :3030) // uses it without per-spec wiring. Falls back to the shared dev relay when unset. const DEFAULT_RELAY_HTTP_URL = process.env.BUZZ_E2E_RELAY_URL ?? "http://localhost:3000"; const DEFAULT_RELAY_WS_URL = DEFAULT_RELAY_HTTP_URL.replace(/^http/, "ws"); function cloneEngramEntry(entry: MockEngramEntry): MockEngramEntry { return { ...entry, outgoingRefs: [...entry.outgoingRefs], }; } /** * Recreates the old Memories UI development fixture as explicit Playwright * seed data. Use with `installMockBridge(page, { agentMemory: ... })`. */ export function createMockAgentMemoryListing( overrides: Partial = {}, ): MockAgentMemoryListing { const listing: MockAgentMemoryListing = { core: { slug: "core", body: `I am a mock agent used to flesh out the Memories panel. I prefer concise updates, explicit next steps, and visual polish before edge-case handling. See [[mem/preferences/ui-density]] and [[mem/projects/buzz-memory-viewer]] for details. A retired launch checklist used to live at [[mem/archive/deleted-launch-checklist]], but that memory was deleted after the plan changed.`, eventId: "mock-core", createdAt: 1_700_000_000, outgoingRefs: [ "mem/preferences/ui-density", "mem/projects/buzz-memory-viewer", "mem/archive/deleted-launch-checklist", ], }, memories: [ { slug: "mem/preferences/ui-density", body: "Prefer compact lists with generous body text when expanded.\n\nNested ref: [[mem/working-style/review-loop]]", eventId: "mock-ui-density", createdAt: 1_700_000_100, outgoingRefs: ["mem/working-style/review-loop"], }, { slug: "mem/working-style/review-loop", body: "Ship small slices, screenshot the happy path, then iterate on empty/error states.", eventId: "mock-review-loop", createdAt: 1_700_000_200, outgoingRefs: [], }, { slug: "mem/projects/buzz-memory-viewer", body: "Building the IXI-7 read-only memory viewer in the profile panel.\n\nChild memory: [[mem/projects/buzz-memory-viewer/notes]]", eventId: "mock-project", createdAt: 1_700_000_300, outgoingRefs: ["mem/projects/buzz-memory-viewer/notes"], }, { slug: "mem/projects/buzz-memory-viewer/notes", body: "Tree should auto-expand core. Everything else collapsed with a one-line preview.", eventId: "mock-project-notes", createdAt: 1_700_000_400, outgoingRefs: [], }, { slug: "mem/people/alice", body: "Alice prefers async updates in #design.", eventId: "mock-alice", createdAt: 1_700_000_500, outgoingRefs: [], }, { slug: "mem/people/bob", body: "Bob reviews PRs quickly but wants screenshots.", eventId: "mock-bob", createdAt: 1_700_000_600, outgoingRefs: ["mem/people/alice"], }, { slug: "mem/scratch/todo", body: "", eventId: "mock-empty", createdAt: 1_700_000_700, outgoingRefs: [], }, { slug: "mem/orphan/unreferenced", body: "This orphaned note is not reachable from core. It still points at [[mem/research/old-panel-sketches]], a deleted design scratchpad from an earlier pass.", eventId: "mock-orphan", createdAt: 1_700_000_800, outgoingRefs: ["mem/research/old-panel-sketches"], }, ], truncated: true, fetchedAt: Math.floor(Date.now() / 1000), }; return { core: overrides.core === undefined ? listing.core ? cloneEngramEntry(listing.core) : null : overrides.core ? cloneEngramEntry(overrides.core) : null, memories: (overrides.memories ?? listing.memories).map(cloneEngramEntry), truncated: overrides.truncated ?? listing.truncated, fetchedAt: overrides.fetchedAt ?? listing.fetchedAt, }; } async function seedOnboardingCompletionForKnownIdentities( page: Page, relayWsUrl?: string, ) { const pubkeys = [ DEFAULT_MOCK_PUBKEY, ...Object.values(TEST_IDENTITIES).map(({ pubkey }) => pubkey), ]; await page.addInitScript( ({ onboardingPrefix, pubkeys: pubkeysToSeed, relayUrl, welcomePrefix }) => { const welcomeScope = encodeURIComponent(relayUrl); for (const pubkey of pubkeysToSeed) { window.localStorage.setItem(`${onboardingPrefix}${pubkey}`, "true"); window.localStorage.setItem( `${welcomePrefix}${welcomeScope}:${pubkey}`, "true", ); } }, { onboardingPrefix: ONBOARDING_COMPLETION_STORAGE_KEY_PREFIX, pubkeys, relayUrl: relayWsUrl ?? DEFAULT_RELAY_WS_URL, welcomePrefix: WELCOME_CHANNEL_ENSURED_STORAGE_KEY_PREFIX, }, ); } async function seedDefaultCommunity( page: Page, fallbackPubkey: string, relayWsUrl?: string, ) { await page.addInitScript( ({ fallback, identityOverrideKey, relayUrl }) => { // If seedActiveIdentity() ran before this script (the normal ordering), // use its pubkey so the community matches the active identity and // migrateMachineOnboardingCompletion's strict voucher accepts it. // Mirror readStoredIdentityOverride()'s shape validation: require all // three fields to be non-empty strings; fall through to fallback on // malformed JSON, stored null, or partial objects. let overridePubkey: string | undefined; try { const overrideRaw = window.localStorage.getItem(identityOverrideKey); if (overrideRaw !== null) { const parsed: unknown = JSON.parse(overrideRaw); if ( parsed !== null && typeof parsed === "object" && typeof (parsed as Record).pubkey === "string" && typeof (parsed as Record).privateKey === "string" && typeof (parsed as Record).username === "string" ) { overridePubkey = (parsed as { pubkey: string }).pubkey; } } } catch { // malformed entry — fall through to fallback } const communityId = "e2e-default-community"; const community = { id: communityId, name: "E2E Test", relayUrl, pubkey: overridePubkey ?? fallback, addedAt: new Date().toISOString(), }; window.localStorage.setItem( "buzz-communities", JSON.stringify([community]), ); window.localStorage.setItem("buzz-active-community-id", communityId); }, { fallback: fallbackPubkey, identityOverrideKey: "buzz:e2e-identity-override.v1", relayUrl: relayWsUrl ?? DEFAULT_RELAY_WS_URL, }, ); } async function seedPreviewFeaturesEnabled(page: Page) { await page.addInitScript( ({ key, ids }) => { const overrides: Record = {}; for (const id of ids) overrides[id] = true; window.localStorage.setItem(key, JSON.stringify(overrides)); }, { key: FEATURE_OVERRIDES_STORAGE_KEY, ids: PREVIEW_FEATURE_IDS }, ); } export async function installBridge(page: Page, options: BridgeOptions) { const identity = options.mode === "relay" ? TEST_IDENTITIES[options.user ?? "tyler"] : undefined; // Most specs seed a community so useCommunityInit doesn't show WelcomeSetup. // skipOnboardingSeed only controls the onboarding-completion flag. // The community is stamped with the active identity's pubkey so the strict // migrateMachineOnboardingCompletion voucher recognises it. The init script // reads the seedActiveIdentity override key (if present) and falls back to // the bridge identity's pubkey or DEFAULT_MOCK_PUBKEY for mock mode. if (!options.skipCommunitySeed) { const activePubkey = identity?.pubkey ?? DEFAULT_MOCK_PUBKEY; await seedDefaultCommunity(page, activePubkey, options.relayWsUrl); } if (!options.skipOnboardingSeed) { await seedOnboardingCompletionForKnownIdentities(page, options.relayWsUrl); } // Default to opting every preview feature in. Specs that exercise the // Experiments toggle UI itself pass `seedPreviewFeatures: false`. if (options.seedPreviewFeatures !== false) { await seedPreviewFeaturesEnabled(page); } await page.addInitScript( ({ identity: bridgeIdentity, mock, mode, relayHttpUrl, relayWsUrl, autoConnectDefaultRelay, }) => { const notificationLog: Array<{ body: string | null; title: string; }> = []; const notificationInstances: MockNotification[] = []; class MockNotification extends EventTarget { static permission: NotificationPermission = "granted"; static async requestPermission(): Promise { return MockNotification.permission; } body: string | null; onclick: ((event: Event) => void) | null = null; title: string; constructor(title: string, options?: NotificationOptions) { super(); this.title = title; this.body = options?.body ?? null; notificationInstances.push(this); notificationLog.push({ body: this.body, title: this.title, }); } close() {} } Object.defineProperty(window, "Notification", { configurable: true, value: MockNotification, writable: true, }); const testWindow = window as Window & { __BUZZ_E2E__?: Record; __BUZZ_E2E_APP_BADGE_COUNT__?: number; __BUZZ_E2E_APP_BADGE_STATE__?: string; __BUZZ_E2E_CLICK_NOTIFICATION__?: (index: number) => boolean; __BUZZ_E2E_NOTIFICATIONS__?: Array<{ body: string | null; title: string; }>; }; const currentConfig = testWindow.__BUZZ_E2E__ ?? {}; testWindow.__BUZZ_E2E__ = { ...currentConfig, identity: bridgeIdentity ?? currentConfig.identity, mock, mode, relayHttpUrl: relayHttpUrl ?? currentConfig.relayHttpUrl, relayWsUrl: relayWsUrl ?? currentConfig.relayWsUrl, autoConnectDefaultRelay: autoConnectDefaultRelay ?? currentConfig.autoConnectDefaultRelay, }; testWindow.__BUZZ_E2E_APP_BADGE_COUNT__ = 0; testWindow.__BUZZ_E2E_APP_BADGE_STATE__ = "none"; testWindow.__BUZZ_E2E_CLICK_NOTIFICATION__ = (index: number) => { const notification = notificationInstances[index]; if (!notification) { return false; } const event = new Event("click"); notification.dispatchEvent(event); notification.onclick?.(event); return true; }; testWindow.__BUZZ_E2E_NOTIFICATIONS__ = notificationLog; }, { identity, mock: options.mock, mode: options.mode, relayHttpUrl: options.relayHttpUrl, relayWsUrl: options.relayWsUrl, autoConnectDefaultRelay: options.autoConnectDefaultRelay, }, ); } export async function installMockBridge( page: Page, mock?: MockBridgeOptions, options?: { relayWsUrl?: string; autoConnectDefaultRelay?: boolean; skipOnboardingSeed?: boolean; skipCommunitySeed?: boolean; seedPreviewFeatures?: boolean; }, ) { await installBridge(page, { mode: "mock", mock, relayWsUrl: options?.relayWsUrl, autoConnectDefaultRelay: options?.autoConnectDefaultRelay, skipOnboardingSeed: options?.skipOnboardingSeed, skipCommunitySeed: options?.skipCommunitySeed, seedPreviewFeatures: options?.seedPreviewFeatures, }); } export async function installRelayBridge( page: Page, user: keyof typeof TEST_IDENTITIES = "tyler", options?: { seedPreviewFeatures?: boolean }, ) { await installBridge(page, { mode: "relay", user, // Thread BUZZ_E2E_RELAY_URL into BOTH transports. The app defaults these to // :3000 in relay mode; without explicit wiring HTTP queries (channel list, // feed) miss an isolated relay and surface as "Failed to fetch". relayHttpUrl: DEFAULT_RELAY_HTTP_URL, relayWsUrl: DEFAULT_RELAY_WS_URL, seedPreviewFeatures: options?.seedPreviewFeatures, }); } // The sidebar no longer renders a "browse channels" icon button; the channel // browser is opened via the primary-modifier + Shift + O keyboard shortcut. export async function openChannelBrowser(page: Page) { await page.getByTestId("app-sidebar").waitFor({ state: "visible" }); const isMacBrowser = await page.evaluate(() => /mac|iphone|ipad|ipod/i.test(navigator.platform), ); await page.evaluate((isMac) => { window.dispatchEvent( new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ctrlKey: !isMac, key: "O", metaKey: isMac, shiftKey: true, }), ); }, isMacBrowser); } // Section header actions (create channel, new DM, mark all read, sort) now // live inside a per-section "more actions" (⋮) menu instead of standalone // header icon buttons. These helpers open that menu and pick an item. async function openSectionMenu(page: Page, actionsTestId: string) { const trigger = page.getByTestId(actionsTestId); await trigger.scrollIntoViewIfNeeded(); await trigger.click(); } // The Channels section "+" now opens the unified Add-channel browser, so the // standalone create dialog is reached via the primary-modifier + Shift + N // keyboard shortcut (the "New channel" menu item was removed as redundant). export async function openCreateChannelDialog(page: Page) { await page.getByTestId("app-sidebar").waitFor({ state: "visible" }); const isMacBrowser = await page.evaluate(() => /mac|iphone|ipad|ipod/i.test(navigator.platform), ); await page.evaluate((isMac) => { window.dispatchEvent( new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ctrlKey: !isMac, key: "N", metaKey: isMac, shiftKey: true, }), ); }, isMacBrowser); await page.getByTestId("create-channel-dialog").waitFor(); } export async function openNewMessagePage(page: Page) { await openSectionMenu(page, "section-actions-dms"); await page.getByRole("menuitem", { name: "New message" }).click(); await page.getByTestId("new-message-page").waitFor({ state: "visible" }); }