Files
buzz/desktop/tests/helpers/bridge.ts
T
c104eecfb3 feat(desktop): import local Pocket voices (#3259)
## Context

Pocket TTS currently offers bundled reference voices. People also need a
local, private way to add a voice without sending audio to a cloud
service.

## Summary

Add a Pocket voice import flow to Voice settings. Buzz opens the native
file picker, decodes common audio formats in the reusable `buzz-voice`
crate, canonicalizes the selected audio, stores it under a
content-derived identity in app data, selects it, and lets the user
delete it later.

## Changes

- Accept WAV, M4A, MP3, FLAC, OGG, and AIFF files between 2 and 30
seconds, including multichannel sources.
- Decode and downmix accepted audio to canonical mono 32 kHz PCM16 WAV
before hashing and storage.
- Store imported voices behind stable `pocket:imported:<sha256>`
identities and content-addressed files.
- Keep absolute file paths inside the native process and expose only
voice metadata to React.
- Include imported voices in Pocket preview and live huddle playback.
- Add Add voice and delete controls while preserving the bundled Pocket
voice catalog.
- Fall back to Mary when the selected imported voice is deleted.
- Keep durable import, selection, and deletion successful when a live
TTS worker acknowledgement is delayed.
- Preserve bundled voices when optional import metadata is unreadable
and keep failed deletion retryable.

## Related issue

None found.

## Testing

Production decoding was exercised with WAV, M4A with AAC, MP3, FLAC, OGG
Vorbis, and AIFF fixtures. Each format canonicalized to mono 32 kHz
PCM16 WAV. Manual validation in the combined daily-driver build covered
native-picker import, Preview, live-huddle playback, deletion, and Mary
fallback.

## Screenshots

The Voice settings card preserves the bundled Pocket catalog and adds
the local Add voice action.

![Pocket TTS voice
import](https://raw.githubusercontent.com/block/buzz/c03ba29060ca544c5ac3394c212f376651b386a3/pr-3259--pocket-voices.png)

## Reviewer-reproducible examples

Create common-format fixtures and run them through the production
importer:

```bash
. ./bin/activate-hermit
fixtures="$(mktemp -d)"
ffmpeg -hide_banner -loglevel error -f lavfi -i "sine=frequency=220:duration=3" -ac 2 -ar 44100 "$fixtures/voice.wav"
ffmpeg -hide_banner -loglevel error -i "$fixtures/voice.wav" -c:a aac "$fixtures/voice.m4a"
ffmpeg -hide_banner -loglevel error -i "$fixtures/voice.wav" "$fixtures/voice.mp3"
ffmpeg -hide_banner -loglevel error -i "$fixtures/voice.wav" "$fixtures/voice.flac"
ffmpeg -hide_banner -loglevel error -i "$fixtures/voice.wav" -c:a libvorbis "$fixtures/voice.ogg"
ffmpeg -hide_banner -loglevel error -i "$fixtures/voice.wav" -c:a pcm_s16be "$fixtures/voice.aiff"
BUZZ_VOICE_IMPORT_TEST_DIR="$fixtures" \
  cargo test -p buzz-voice imports_common_audio_format_fixtures -- --ignored --nocapture
```

Exercise import persistence, synthesis, deletion, and bundled-voice
fallback with an installed Pocket model:

```bash
BUZZ_POCKET_MODEL_DIR=/path/to/pocket-model-bundle \
  cargo test -p buzz-voice --test pocket_import_audio \
  objective_import_synthesis_delete_and_mary_fallback \
  -- --ignored --nocapture
```

Exercise the native-picker boundary, selection, preview dispatch,
deletion, cancellation, and invalid-file states:

```bash
cd desktop
pnpm build:e2e
pnpm exec playwright test tests/e2e/voice-settings.spec.ts --project=smoke
```

---------

Signed-off-by: John Tennant <jtennant@block.xyz>
Signed-off-by: John Tennant <johnmatthewtennant@gmail.com>
Signed-off-by: John Tennant <jtennant@squareup.com>
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Co-authored-by: John Tennant <jtennant@block.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
2026-07-31 11:15:09 -04:00

976 lines
34 KiB
TypeScript

import type { Page } from "@playwright/test";
import type { ChannelTemplate, RelayEvent } from "../../src/shared/api/types";
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 MockManagedAgentSeed = {
pubkey: string;
name: string;
personaId?: string | null;
status?: "running" | "stopped" | "deployed" | "not_deployed";
channelNames?: string[];
channelIds?: string[];
backend?:
| { type: "local" }
| { type: "provider"; id: string; config: Record<string, unknown> };
lastError?: string | null;
lastErrorCode?: number | null;
needsRestart?: boolean;
autoRestartOnConfigChange?: boolean;
respondTo?: "owner-only" | "allowlist" | "anyone";
respondToAllowlist?: string[];
};
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;
members: Array<{
pubkey: string;
role: "owner" | "admin" | "member" | "guest" | "bot";
}>;
transcriptionEnabled?: 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 = {
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;
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;
/** 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[];
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;
/** 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[];
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;
/**
* Value returned by the `observer_archive_default_enabled` mock command.
* `true` = internal-policy build (toggle locked ON); `false`/omitted = OSS
* build (toggle functional). Drives LocalArchiveSettingsCard policy state.
*/
observerArchiveDefaultEnabled?: boolean;
/**
* Delay (ms) applied to `observer_archive_default_enabled` so specs can
* assert the pending-reconciliation state (toggle disabled, no
* `list_save_subscriptions` call yet) before the policy resolves.
*/
observerArchiveDefaultEnabledDelayMs?: number;
/**
* When set, `observer_archive_default_enabled` throws with this message —
* drives the fail-closed path when the policy check itself fails.
*/
observerArchiveDefaultEnabledError?: string;
// 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;
/** 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;
};
/** 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;
/** 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;
};
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" });
}