mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
## Summary Projects v3 makes repository work shareable, discussion-aware, and easier to scan in one coherent workspace. People can copy canonical links, reopen the exact workspace tab, understand issue and pull-request context at a glance, find related channel conversations, and assign or unassign issues across Desktop and CLI. - **Unified workspace** — top-level sections sit above repository controls in one rounded workspace, with navigation positioned close to the page heading. README and Files retain branch selection; every section has a labeled icon header, and Issues and Pull Requests expose creation from a consistent right-aligned action. - **Repository management** — the repository selector is always available, including single-repository projects. Its integrated add flow lets project owners create a repository manually or select an existing repository without a separate toolbar button. - **Readable work-item lists** — issue and pull-request rows use plain-language context instead of opaque metadata. Files, commits, issues, pull requests, channels, and contributors share consistent row density and right-aligned timestamps, while deterministic fallback-avatar colors keep participants distinct on light backgrounds. Inbox pull-request metadata wraps between complete phrases and truncates long channel names instead of compressing copy into narrow columns. - **Reliable entity links** — projects, repositories, issues, pull requests, and commits have canonical `buzz://` links, preview cards, OS deep-link routing, and tab-aware navigation. Reopening the same link re-applies its destination instead of leaving the user on a locally selected tab. - **Related conversations** — repository and work-item views surface channels discussing the current entity, including participants, channel navigation, message context, and an explicit notice when discovery reaches its 500-result cap. - **Reversible issue ownership** — trusted assignment and unassignment events work across Desktop, Tauri, `buzz-sdk`, and `buzz issues`. Assignees appear in project views and the assigned inbox, while authorized users can remove assignments directly from the assignee row. Assignment state is derived chronologically from labeled Nostr notes. Issue authors and repository owners may change any assignee; other users may only assign or unassign themselves. Shared golden fixtures keep entity-link grammar and validation aligned across TypeScript and Rust. The branch also updates `webbrowser` to the patched release for RUSTSEC-2026-0257. ### Related issue N/A. ### Testing - [x] `just ci` — formatting, lint, typechecking, unit tests, and builds passed - [x] Full pre-push suite — organization, branch-skew, Desktop checks, typechecking, and tests passed on the latest push - [x] `cargo test -p buzz-cli` and focused `buzz-sdk` assignment tests passed - [x] Focused Tauri recipient-note and 500-result search-limit tests passed - [x] Desktop entity-link and issue-assignment unit tests passed - [x] Playwright smoke coverage passed for assignment, repeated entity-link navigation, repository create/select flows, section headers and actions, timestamp alignment, timeline icons, sentence-style issue/PR metadata, header spacing, avatar contrast, and Inbox metadata at stacked and side-rail breakpoints - [ ] Manual staging pass: link round-trips, Channels tab, assignment flows, and inbox routing ### Screenshots Pull requests explain who opened the request, where it lives, and which branch it comes from; fallback avatars remain visually distinct.  Issues use the same sentence-style hierarchy while keeping status and recency easy to scan.  The wide Inbox detail keeps author, timestamp, and origin context readable beside its metadata rail.  [View the complete six-state Projects v3 screenshot set](https://github.com/block/buzz/pull/5624#issuecomment-5268039672) and [the compact/wide Inbox comparison](https://github.com/block/buzz/pull/5624#issuecomment-5268614585). --- > Supersedes #5624, whose head commit accumulated permanently-queued required check suites (block-dco-check et al.) that GitHub never dispatched. History flattened into a single signed-off commit on latest main; tree verified byte-identical (`git merge-tree`) to merging the original branch into main. --------- Signed-off-by: Thomas Petersen <thomasp@squareup.com> Co-authored-by: Wintermute <3f1797424fd9ad6653a83665c660517777cd7f8c228c0d5907f49e01537f3ca5@buzz.block.builderlab.xyz>
1039 lines
38 KiB
TypeScript
1039 lines
38 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[];
|
|
/** Reject successive relay-agent directory reads, then resume. */
|
|
relayAgentListErrors?: (string | null)[];
|
|
/** 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[];
|
|
channelsReadDelayMs?: number;
|
|
/** Return not-modified for this many reads before resuming full payloads. */
|
|
channelsNotModifiedResponses?: number;
|
|
/** When true, a matching knownHash returns a not-modified channel payload. */
|
|
honorChannelsKnownHash?: boolean;
|
|
/** 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;
|
|
/** Reject `clear_pending_navigation_deep_links` with this message. */
|
|
clearPendingNavigationDeepLinksError?: string;
|
|
openDmDelayMs?: number;
|
|
sendMessageDelayMs?: number;
|
|
/** Hold the media proxy at port 0 until the E2E release seam is invoked. */
|
|
mediaProxyInitiallyUnavailable?: boolean;
|
|
/** 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;
|
|
}>;
|
|
/** Pending channel/message links that arrived before AppShell mounted. */
|
|
pendingNavigationDeepLinks?: Array<{
|
|
id: string;
|
|
kind: "channel" | "message";
|
|
channelId: string;
|
|
messageId?: string | null;
|
|
threadRootId?: string | null;
|
|
}>;
|
|
/** Entity links captured by Rust before the React listener mounts. */
|
|
pendingEntityDeepLinks?: Array<{
|
|
id: string;
|
|
href: string;
|
|
}>;
|
|
/**
|
|
* 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" });
|
|
}
|