Files
cls 9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
feat: import Chinese-localized Buzz source snapshot
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2026-08-13 18:34:25 +08:00

1006 lines
36 KiB
TypeScript

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<string, string>;
/**
* 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<string, unknown>[];
/** Catalog returned after a successful mocked install. */
acpRuntimesCatalogAfterInstall?: Record<string, unknown>[];
/** Catalog responses after install for testing later sign-in completion. */
acpRuntimesCatalogAfterInstallSequence?: Record<string, unknown>[][];
/** Catalog responses for successive discovery calls. The final response repeats. */
acpRuntimesCatalogSequence?: Record<string, unknown>[][];
acpRuntimesDelayMs?: number;
/** When true, the mock catalog discovery command throws an error. */
acpRuntimesError?: boolean;
acpAuthMethods?: Record<string, { methods: Record<string, unknown>[] }>;
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<string, MockAgentMemoryListing>;
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<string, string>;
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<string | null>;
/**
* 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<string, unknown>;
/**
* 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> = {},
): 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<string, unknown>).pubkey === "string" &&
typeof (parsed as Record<string, unknown>).privateKey ===
"string" &&
typeof (parsed as Record<string, unknown>).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<string, boolean> = {};
for (const id of ids) overrides[id] = true;
window.localStorage.setItem(key, JSON.stringify(overrides));
},
{ key: FEATURE_OVERRIDES_STORAGE_KEY, ids: PREVIEW_FEATURE_IDS },
);
}
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<NotificationPermission> {
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<string, unknown>;
__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" });
}