mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
## Overview **Category:** fix **User impact:** Link previews no longer disappear when a message is sent while preview metadata or media is still settling. Fast Enter, rapid Enter, and confirmed-draft auto-send now preserve the preview without duplicate sends or stale tags. **Problem:** The composer could look ready before its sender-authored snapshot tag existed. Send paths could then race preview resolution/upload, while debounced preview state could attach a tag for a URL that had already been removed. The same timing also caused confirmed-draft auto-send to be consumed without sending. **Solution:** - Debounce preview resolution to avoid card flicker while typing, then disable every submit path while a supported external preview settles. A 2-second escape cap still permits a bare-link send if resolution stalls. - Keep submit synchronous: acquire a composer-local lock before asynchronous send work, read ready tags from the live URL set, and reject Enter/form submits while a snapshot is pending. - Retry confirmed-draft auto-submit until preview settling clears, then submit exactly once. - Upload thumbnail and favicon independently. A failed upload shows a toast and degrades to the surviving media (or text-only) rather than leaving the card spinning. - Exclude message-edit mode from preview resolution, upload, and Save gating. Edit-time preview snapshots remain follow-up #5273. - Canonicalize fragment-bearing URLs for preview lookup/snapshot identity while preserving the original fragment links in message text. ## Link preview state walkthrough Captured using PR #5245's actual public Open Graph metadata and artwork. The deterministic E2E bridge controls only upload timing so the transient disabled state can be captured reliably. | State | Expected behavior | Screenshot | | --- | --- | --- | | **1. Snapshot upload pending** | The real PR preview is visible, but Submit remains disabled until its sendable snapshot tag is ready. Click and Enter cannot send a bare link during the settling window. |  | | **2. Snapshot ready** | Once snapshot upload settles and the tag is ready, the same preview remains and Submit becomes active. |  | | **3. Message sent** | The sent event carries the snapshot tag and renders the PR title, description, and artwork inline instead of degrading to a bare URL. |  | ## Regression coverage - Enter during metadata resolution or snapshot upload cannot send early. - Paste-and-immediate-Enter sends after settling; rapid Enter submits exactly once. - Confirmed-draft auto-send waits for settling and fires exactly once. - Removed/replaced URLs cannot leak stale snapshot tags or media refs. - Thumbnail upload failure toasts and sends with the surviving favicon. - Edit mode does not resolve/upload previews or gate Save. - Fragment variants share a canonical preview while original fragment links remain clickable. - Existing ready-preview, suppression, bare-link fallback, and multi-preview behavior remains covered. ## Reproduction steps 1. Open a channel and paste a supported external URL into the composer. 2. Press Enter immediately, before preview metadata/media finishes settling. 3. Before this fix, the event could be sent without its preview snapshot (or confirmed-draft auto-send could be lost). With this fix, submit waits behind the disabled state and fires once with the matching snapshot tag. 4. Remove or replace the URL and press Enter inside the debounce window. The sent event contains tags only for URLs still present in the submitted content. ## Validation All required PR checks are green, including Desktop Core, Desktop Smoke E2E shards, Desktop E2E Integration shards, macOS build, security checks, and DCO. --------- Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
1015 lines
37 KiB
TypeScript
1015 lines
37 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;
|
|
/** Delays link-preview snapshot media uploads so specs can drive an in-flight
|
|
* snapshot upload. See e2eBridge mock.linkPreviewUploadDelayMs. */
|
|
linkPreviewUploadDelayMs?: number;
|
|
/** Substrings of `link-preview-*` upload filenames whose upload should reject,
|
|
* so specs can drive a per-media snapshot upload failure. See e2eBridge
|
|
* mock.linkPreviewUploadErrorFilenames. */
|
|
linkPreviewUploadErrorFilenames?: 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;
|
|
// 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;
|
|
/** Delay (ms) applied to identity import so specs can observe pending navigation. */
|
|
identityImportDelayMs?: number;
|
|
/**
|
|
* 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" });
|
|
}
|