mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
## Summary Repair the mock-backed desktop smoke fixtures blocking release PR #7817. No application unread logic, styling, audio component, scheduler, or release-tool changes. - Require an exact channel-scoped kind-9 subscription before injecting unread messages, including after navigation. The old readiness check could accept a global-only, wrong-channel, or wrong-kind consumer. The extracted predicate is used by the real mock bridge and has regression coverage for those false positives and legacy behavior. - Seed the 24 audio messages offscreen with distinct timestamps, then open the channel with all cards in the load margin. Same-second random event IDs previously reordered the settling timeline, causing different visible cards to start loads. This was viewport churn, not a component restart defect. - Expose the existing real audio scheduler snapshot through the E2E bridge. Assert 3 active / 21 queued before navigation and 0 active / 0 queued afterward. Preserve the peak-3 cap, exact 3 fetches / cancellations / releases, zero object URLs, and existing unread/bold assertions. No sleeps, retries, skipped tests, or relaxed limits were added. ## Validation - Before commit, all 30 tests in `channel-activity-popover.spec.ts`, `channel-mute.spec.ts`, and `voice-note.spec.ts` passed five repetitions with retries disabled: **150/150**. This was the working repair on base `312cf674f`, before final formatting/cleanup, not the entire smoke suite. - At committed head `1b126afbe75c2a2afbdfe13744f0938fa584764e`, normal pre-push hooks passed: **6,591/6,591 desktop unit tests**, desktop checks, TypeScript typechecking, differential file-size gate, and branch-skew check. Pre-commit Biome formatting passed; working tree is clean. - Independent sidebar and audio reviews found no substantive blocker. Their formatting findings are resolved in this commit. - Full repository `just ci`, native release packaging, and the entire Playwright smoke suite were not run locally. GitHub CI is the remaining broader gate; this PR is not a release certification. ## Release follow-up After this repair is merged and required checks pass, regenerate #7817 through the desktop release tooling and validate the new candidate's exact head. This PR does not merge or publish a release. Originating discussion: buzz://message?channel=3428ec3f-a58b-429b-afbc-dc6a77918ce8&id=3303565b14334696e9bbf433ef3ad1aea4307acc69834f7f685a412e81354493 Signed-off-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
15176 lines
493 KiB
TypeScript
15176 lines
493 KiB
TypeScript
import * as React from "react";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel";
|
|
import { CommunitiesProvider } from "@/features/communities/useCommunities";
|
|
import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js";
|
|
import { emit, listen } from "@tauri-apps/api/event";
|
|
import { mockIPC, mockWindows } from "@tauri-apps/api/mocks";
|
|
import { decode, npubEncode, nsecEncode } from "nostr-tools/nip19";
|
|
import { finalizeEvent, getPublicKey } from "nostr-tools/pure";
|
|
import { parse as yamlParse } from "yaml";
|
|
import {
|
|
mergeMockCustomHarnesses,
|
|
handleSaveCustomHarness,
|
|
handleDeleteCustomHarness,
|
|
} from "./e2eBridgeCustomHarnesses.ts";
|
|
|
|
import type {
|
|
ObservedUnreadProjection,
|
|
ObservedUnreadResponse,
|
|
} from "@/shared/api/tauriObservedUnread";
|
|
import type { UnreadCatchUpChannelResult } from "@/shared/api/tauriUnreadCatchUp";
|
|
import { relayClient } from "@/shared/api/relayClient";
|
|
import { activateRateLimit } from "@/shared/api/relayRateLimitGate";
|
|
import { resolveAgentParallelism } from "@/features/agents/lib/agentParallelism";
|
|
import { awaitLiveSwitchOutcome } from "@/features/agents/lib/liveSwitchOutcome";
|
|
import {
|
|
_testRegisterKnownAgents,
|
|
ensureRelayObserverSubscription,
|
|
subscribeControlResults,
|
|
} from "@/features/agents/observerRelayStore";
|
|
import { switchManagedAgentModel } from "@/shared/api/agentControl";
|
|
import { getAudioMediaLoadSchedulerSnapshot } from "@/features/messages/lib/audioMediaLoadScheduler";
|
|
import { mockSearchHitMatches } from "./e2eBridgeSearch.ts";
|
|
import { selectMockHistory } from "./e2eBridgeHistory.ts";
|
|
import {
|
|
createMockSubscription,
|
|
hasMockSubscription,
|
|
} from "./e2eBridgeSubscriptions.ts";
|
|
export { mockSearchHitMatches };
|
|
import type { ConnectionState } from "@/shared/api/relayClientShared";
|
|
import type {
|
|
ChannelTemplate,
|
|
FeedItemCategory,
|
|
RelayEvent,
|
|
} from "@/shared/api/types";
|
|
import { getMarkdownParseCount } from "@/shared/ui/markdown/nodeCache";
|
|
import { syncAgentTurnsFromEvents } from "@/features/agents/activeAgentTurnsStore";
|
|
import { recordTimeoutFromRejection } from "@/features/moderation/lib/timeoutStore";
|
|
import {
|
|
injectObserverEventsForE2E,
|
|
syncAgentObserverEvents,
|
|
} from "@/features/agents/observerRelayStore";
|
|
import {
|
|
CUSTOM_EMOJI_SET_D_TAG,
|
|
KIND_EMOJI_SET,
|
|
} from "@/shared/api/customEmoji";
|
|
import {
|
|
KIND_AGENT_OBSERVER_FRAME,
|
|
KIND_CHANNEL_THREAD_SUMMARY,
|
|
KIND_CHANNEL_WINDOW_BOUNDS,
|
|
KIND_DM_VISIBILITY,
|
|
KIND_EVENT_REMINDER,
|
|
KIND_GIT_ISSUE,
|
|
KIND_GIT_PATCH,
|
|
KIND_GIT_PR_UPDATE,
|
|
KIND_GIT_PULL_REQUEST,
|
|
KIND_GIT_STATUS_CLOSED,
|
|
KIND_GIT_STATUS_DRAFT,
|
|
KIND_GIT_STATUS_MERGED,
|
|
KIND_GIT_STATUS_OPEN,
|
|
KIND_HUDDLE_STARTED,
|
|
KIND_MEMBER_ADDED_NOTIFICATION,
|
|
KIND_MEMBER_REMOVED_NOTIFICATION,
|
|
KIND_PERSONA,
|
|
KIND_PROJECT_ANNOUNCEMENT,
|
|
KIND_REPO_ANNOUNCEMENT,
|
|
KIND_REPO_STATE,
|
|
KIND_STREAM_MESSAGE_EDIT,
|
|
KIND_SYSTEM_MESSAGE,
|
|
KIND_TEXT_NOTE,
|
|
KIND_TEAM_CATALOG,
|
|
KIND_USER_STATUS,
|
|
} from "@/shared/constants/kinds";
|
|
import type {
|
|
RawAcpAuthMethodsResult,
|
|
RawConnectAcpRuntimeResult,
|
|
} from "@/shared/api/tauriAgentAuth";
|
|
import type {
|
|
RawAcpRuntimeCatalogEntry,
|
|
RawInstallRuntimeResult,
|
|
RuntimeFileConfigSubset,
|
|
} from "@/shared/api/tauri";
|
|
import {
|
|
ensureRelayOriginFetch,
|
|
resetMediaCaches,
|
|
} from "@/shared/lib/mediaUrl";
|
|
import { normalizePubkey } from "@/shared/lib/pubkey";
|
|
import {
|
|
isValidLinkPreviewSnapshotCanonicalUrl,
|
|
parseLinkPreviewSnapshots,
|
|
} from "@/shared/lib/linkPreviewSnapshot";
|
|
|
|
type TestIdentity = {
|
|
privateKey: string;
|
|
pubkey: string;
|
|
username: string;
|
|
};
|
|
|
|
type MockCommandAvailability = {
|
|
available?: boolean;
|
|
command?: string;
|
|
resolvedPath?: string | null;
|
|
};
|
|
|
|
export type MockManagedAgentSeed = {
|
|
pubkey: string;
|
|
name: string;
|
|
avatarUrl?: string | null;
|
|
personaId?: string | null;
|
|
/** Harness/runtime id pin; `null` = inherit from persona (native default). */
|
|
runtime?: string | null;
|
|
status?: RawManagedAgent["status"];
|
|
channelNames?: string[];
|
|
channelIds?: string[];
|
|
backend?: RawManagedAgent["backend"];
|
|
lastError?: string | null;
|
|
lastErrorCode?: number | null;
|
|
needsRestart?: boolean;
|
|
restartDiff?: Array<{ field: string; change: unknown }>;
|
|
autoRestartOnConfigChange?: boolean;
|
|
respondTo?: RawManagedAgent["respond_to"];
|
|
respondToAllowlist?: string[];
|
|
/** Per-agent env vars seeded into the mock store. */
|
|
envVars?: Record<string, string>;
|
|
};
|
|
|
|
type MockManagedAgentRuntimeSeed = {
|
|
pubkey: string;
|
|
relayUrl: string;
|
|
lifecycle?: MockManagedAgentRuntimeRow["lifecycle"];
|
|
};
|
|
|
|
type MockRelayAgentSeed = {
|
|
pubkey: string;
|
|
ownerPubkey?: string | null;
|
|
name: string;
|
|
agentType?: string;
|
|
capabilities?: string[];
|
|
respondTo?: RawRelayAgent["respond_to"];
|
|
respondToAllowlist?: string[];
|
|
channelNames?: string[];
|
|
channelIds?: string[];
|
|
status?: PresenceStatus;
|
|
};
|
|
|
|
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?: string | null;
|
|
model?: string | null;
|
|
provider?: string | null;
|
|
namePool?: string[];
|
|
respondTo?: "owner-only" | "allowlist" | "anyone";
|
|
respondToAllowlist?: string[];
|
|
sessionPolicy?: "channel" | "thread";
|
|
};
|
|
|
|
type MockTeamSeed = {
|
|
id?: string;
|
|
name: string;
|
|
description?: string | null;
|
|
personaIds: string[];
|
|
};
|
|
|
|
type MockSearchProfileSeed = {
|
|
pubkey: string;
|
|
displayName: string | null;
|
|
avatarUrl?: string | null;
|
|
nip05Handle?: string | null;
|
|
about?: string | null;
|
|
ownerPubkey?: string | null;
|
|
isAgent?: boolean;
|
|
};
|
|
|
|
type MockHuddleMemberSeed = {
|
|
pubkey: string;
|
|
role: "owner" | "admin" | "member" | "guest" | "bot";
|
|
};
|
|
|
|
type MockHuddleSeed = {
|
|
parentChannelId: string;
|
|
ephemeralChannelId: string;
|
|
huddleThreadEventId?: string | null;
|
|
phase?: "creating" | "connected" | "active";
|
|
members: MockHuddleMemberSeed[];
|
|
transcriptionEnabled?: boolean;
|
|
ttsEnabled?: boolean;
|
|
isCreator?: boolean;
|
|
};
|
|
|
|
type E2eConfig = {
|
|
mode?: "mock" | "relay";
|
|
mock?: {
|
|
/** 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;
|
|
/** Override the repository access channel for project authorization states. */
|
|
projectAccessChannelId?: string;
|
|
/** Make remote project snapshots fail with this git-facing message. */
|
|
projectRepoSnapshotError?: string;
|
|
/** Delay remote repository snapshots so project loading UI is observable. */
|
|
projectRepoSnapshotDelayMs?: number;
|
|
/** 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;
|
|
/** Delay Builderlab login completion so cancellation/retry UI can be tested. */
|
|
builderlabLoginDelayMs?: number;
|
|
/** Bound Builderlab Nostr identity. Null/omitted = not linked yet. */
|
|
builderlabIdentity?: { npub?: string; pubkey_hex?: string } | null;
|
|
/** Structured error returned when onboarding tries to bind the local identity. */
|
|
builderlabBindError?: { code?: string; message?: string };
|
|
/** Communities owned by the mocked Builderlab account. */
|
|
builderlabCommunities?: Array<{
|
|
id?: string;
|
|
name?: string;
|
|
slug?: string;
|
|
normalized_host?: string;
|
|
archived_at?: string | null;
|
|
}>;
|
|
/** Override the community returned after hosted creation. */
|
|
builderlabCreatedCommunity?: {
|
|
id?: string;
|
|
name?: string;
|
|
slug?: string;
|
|
normalized_host?: string;
|
|
archived_at?: string | null;
|
|
};
|
|
acpRuntimesCatalog?: RawAcpRuntimeCatalogEntry[];
|
|
/** Catalog returned after a successful mocked install. */
|
|
acpRuntimesCatalogAfterInstall?: RawAcpRuntimeCatalogEntry[];
|
|
/** Catalog responses after install for testing later sign-in completion. */
|
|
acpRuntimesCatalogAfterInstallSequence?: RawAcpRuntimeCatalogEntry[][];
|
|
/** Catalog responses for successive discovery calls. The final response repeats. */
|
|
acpRuntimesCatalogSequence?: RawAcpRuntimeCatalogEntry[][];
|
|
acpRuntimesDelayMs?: number;
|
|
/** When true, the catalog discovery call throws — simulates a failed query. */
|
|
acpRuntimesError?: boolean;
|
|
acpAuthMethods?: Record<string, RawAcpAuthMethodsResult>;
|
|
acpAuthMethodsErrors?: Record<string, string>;
|
|
acpAuthMethodsError?: string;
|
|
/** When set, workflow updates fail with this message. */
|
|
workflowUpdateError?: string;
|
|
/** When set, workflow deletion fails with this message. */
|
|
workflowDeleteError?: string;
|
|
/** Reject workflow run creation with this message. */
|
|
workflowTriggerError?: string;
|
|
/** When set, the `delete_custom_harness` mock command throws with this message. */
|
|
deleteCustomHarnessError?: string;
|
|
connectAcpRuntimeResult?: RawConnectAcpRuntimeResult;
|
|
connectAcpRuntimeDelayMs?: number;
|
|
connectAcpRuntimeError?: string;
|
|
/** Catalog returned after a successful mocked connect (sign-in). */
|
|
acpRuntimesCatalogAfterConnect?: RawAcpRuntimeCatalogEntry[];
|
|
activePersonaIds?: string[];
|
|
installAcpRuntimeDelayMs?: number;
|
|
/** Live output lines the mocked install emits before it settles. */
|
|
installAcpRuntimeOutputLines?: string[];
|
|
installAcpRuntimeResult?: RawInstallRuntimeResult;
|
|
/** Sequence of results for successive `install_acp_runtime` calls.
|
|
* Call N returns results[N]; when exhausted the last entry repeats.
|
|
* Takes precedence over `installAcpRuntimeResult`. */
|
|
installAcpRuntimeResults?: RawInstallRuntimeResult[];
|
|
/** Per-runtime install configuration keyed by runtimeId.
|
|
* When a runtimeId matches, its entry overrides the global
|
|
* installAcpRuntime* fields for that specific runtime. */
|
|
installAcpRuntimeByRuntime?: Record<
|
|
string,
|
|
{
|
|
delayMs?: number;
|
|
result?: RawInstallRuntimeResult;
|
|
/** Call-order sequence — same semantics as installAcpRuntimeResults. */
|
|
results?: RawInstallRuntimeResult[];
|
|
}
|
|
>;
|
|
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;
|
|
};
|
|
/** When set, the mocked `add_agent_to_huddle` command throws. */
|
|
addAgentToHuddleError?: string;
|
|
/** Delay an invocation-time huddle snapshot to exercise hydration ordering. */
|
|
huddleStateReadDelayMs?: number;
|
|
/** Delay (ms) for `sync_agents_to_active_huddle` so e2e tests can hold the
|
|
* send path open across a leg that writes nothing to the relay (no active
|
|
* huddle) and revoke a mention mid-hold.
|
|
* Releasable early via `__BUZZ_E2E_RELEASE_HUDDLE_AGENT_SYNCS__()`. */
|
|
syncAgentsToActiveHuddleDelayMs?: 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 the pair-scoped lifecycle commands
|
|
* (`list/start/stop/restart_managed_agent_runtime`). */
|
|
managedAgentRuntimes?: MockManagedAgentRuntimeSeed[];
|
|
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[];
|
|
/** Community team-catalog (kind:30178) heads returned by relay queries. */
|
|
teamCatalogEvents?: RelayEvent[];
|
|
/** Outcomes for successive explicit team share publications. */
|
|
teamSharePublicationStatuses?: Array<"published" | "queued">;
|
|
relayAgents?: MockRelayAgentSeed[];
|
|
/** Reject successive relay-agent directory reads, then resume. */
|
|
relayAgentListErrors?: (string | null)[];
|
|
/** Pubkeys omitted only from targeted send-time authorization checks. */
|
|
relayAgentRevalidationRevokedPubkeys?: string[];
|
|
/** Native-like huddle state seeded from authoritative role-bearing membership. */
|
|
huddle?: MockHuddleSeed;
|
|
agentListDelayMs?: number;
|
|
agentMemory?: RawAgentMemoryListing | Record<string, RawAgentMemoryListing>;
|
|
addChannelMembersDelayMs?: number;
|
|
/** Sequenced add-member failures. A string fails that call; null succeeds. */
|
|
addChannelMembersErrors?: (string | null)[];
|
|
channelMembersReadDelayMs?: number;
|
|
createManagedAgentDelayMs?: number;
|
|
channelTemplates?: ChannelTemplate[];
|
|
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` so e2e tests can observe the
|
|
* community-switch gate. 0/undefined = instant. */
|
|
applyCommunityDelayMs?: number;
|
|
/** Reject `clear_pending_navigation_deep_links` with this message. */
|
|
clearPendingNavigationDeepLinksError?: string;
|
|
openDmDelayMs?: number;
|
|
sendMessageDelayMs?: number;
|
|
/** Delay (ms) for `start_managed_agent` so e2e tests can switch the
|
|
* community mid-startup and observe the fail-closed scope check.
|
|
* Releasable early via `__BUZZ_E2E_RELEASE_MANAGED_AGENT_STARTS__()`. */
|
|
startManagedAgentDelayMs?: 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[];
|
|
/** Test-only observer control requests captured after mock publish. */
|
|
observerControlResults?: Array<{
|
|
type: "cancel_turn" | "switch_model";
|
|
status: string;
|
|
channelId?: string | null;
|
|
requestId?: string;
|
|
modelId?: 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;
|
|
/** Hold every `get_thread_replies` response until
|
|
* `__BUZZ_E2E_RELEASE_THREAD_REPLIES__()` is called. Unlike
|
|
* `threadRepliesDelayMs` (a timer that self-heals inside Playwright's
|
|
* auto-retry window), this is a manual gate: the thread-aux backfill
|
|
* provably never lands until the test releases it, so a spec can assert
|
|
* the panel's head state before any backfill can heal it. */
|
|
deferThreadReplies?: boolean;
|
|
usersBatchDelayMs?: number;
|
|
/** Delay (ms) applied to continuation channel-window requests so e2e
|
|
* tests can observe the in-flight prepend window. 0/undefined = instant. */
|
|
channelWindowDelayMs?: number;
|
|
/** Delay (ms) applied to newest-page channel-window requests. */
|
|
channelHeadDelayMs?: number;
|
|
profileReadDelayMs?: number;
|
|
/** Hold `get_profile` responses until the E2E release seam is invoked. */
|
|
deferProfileReads?: boolean;
|
|
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;
|
|
/** Hold metadata until the E2E release seam is invoked. */
|
|
deferLinkPreviewMetadata?: boolean;
|
|
/** Simulates native cold-cache startup work before the async response. */
|
|
linkPreviewMetadataStartBlockMs?: number;
|
|
/** Delays link-preview snapshot media uploads so specs can exercise the
|
|
* composer's settle-gated disabled state before the snapshot tag is ready. */
|
|
linkPreviewUploadDelayMs?: number;
|
|
/** Hold link-preview uploads before mock-native cancellation registration. */
|
|
deferLinkPreviewUploadRegistration?: boolean;
|
|
/** Substrings of `link-preview-*` upload filenames whose `upload_media_bytes`
|
|
* call should reject, so specs can drive a per-media snapshot upload failure
|
|
* (e.g. `["link-preview-image"]` fails only the thumbnail, favicon survives). */
|
|
linkPreviewUploadErrorFilenames?: string[];
|
|
searchProfiles?: MockSearchProfileSeed[];
|
|
updateAvailable?: boolean;
|
|
updateChannelDelayMs?: number;
|
|
updateDownloadDelayMs?: number;
|
|
restartDelayMs?: number;
|
|
updateVersion?: string;
|
|
/** When false, `is_auto_update_supported` returns false (simulates a
|
|
* Linux .deb install where Tauri's updater cannot swap the binary).
|
|
* Defaults to true for all existing tests. */
|
|
autoUpdateSupported?: boolean;
|
|
/** Reject `plugin:opener|open_url` to exercise browser-return 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[];
|
|
/** Deliver AUTH synchronously, before the mock connect command resolves. */
|
|
websocketAuthBeforeConnectResolves?: boolean;
|
|
/** Stall the first AUTH signing command forever; later attempts complete. */
|
|
stallFirstAuthSigning?: boolean;
|
|
stallWebsocketSends?: boolean;
|
|
userSearchDelayMs?: number;
|
|
// NIP-IA gate inputs — see tests/helpers/bridge.ts:MockBridgeOptions for
|
|
// semantics. These three drive the archive-button gate matrix in
|
|
// tests/e2e/identity-archive.spec.ts; they're plumbed into:
|
|
// - `list_archived_identities` (archivedIdentities)
|
|
// - `resolve_oa_owner` (oaOwnerIsMe)
|
|
// - `resetMockRelayMembers` (relayRole)
|
|
archivedIdentities?: string[];
|
|
// Relay's NIP-11 `self` pubkey (hex) for `get_relay_self`. A DM whose peer
|
|
// equals this is treated as a moderation DM (composer disabled). Absent →
|
|
// fail open (no mod-DM detection), matching the Rust command's contract.
|
|
relaySelf?: string | null;
|
|
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;
|
|
relayRole?: "owner" | "admin" | "member" | null;
|
|
// Descriptors returned by the mocked `pick_and_upload_media` /
|
|
// `upload_media_bytes` commands. Lets a spec drive the attachment flow
|
|
// (e.g. a generic PDF) without a real upload pipeline. See
|
|
// tests/helpers/bridge.ts:MockBridgeOptions.uploadDescriptors.
|
|
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?: RawBlobDescriptor[];
|
|
// Seed rows returned by `list_save_subscriptions`. Each entry uses the same
|
|
// snake_case wire shape the Rust backend returns so tests can drive the
|
|
// LocalArchiveSettingsCard without a real SQLite database.
|
|
agentMetricArchiveDefaultEnabled?: boolean;
|
|
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 classify as `deleted`.
|
|
deletedEventIds?: string[];
|
|
// Pending community deep links (buzz://join / buzz://connect / buzz://add-community) seeded into
|
|
// the mocked Rust-side queue. Mirrors the real queue's semantics:
|
|
// `take_pending_community_deep_link` peeks the head and
|
|
// `acknowledge_pending_community_deep_link` removes by id. Drives the
|
|
// pending-invite gate and deep-link drain path in tests.
|
|
pendingCommunityDeepLinks?: Array<{
|
|
id: string;
|
|
kind: "connect" | "join" | "add-community";
|
|
relayUrl: string;
|
|
code?: string | null;
|
|
name?: string | null;
|
|
}>;
|
|
pendingNavigationDeepLinks?: Array<{
|
|
id: string;
|
|
kind: "channel" | "message";
|
|
channelId: string;
|
|
messageId?: string | null;
|
|
threadRootId?: string | null;
|
|
}>;
|
|
// Entity links captured before React mounts. The app drains and acknowledges
|
|
// this mocked Rust-side queue after installing its event listener.
|
|
pendingEntityDeepLinks?: Array<{
|
|
id: string;
|
|
href: string;
|
|
}>;
|
|
/** Reject one `get_identity` call after this many successful reads. */
|
|
identityReadErrorAfter?: { message: string; successfulReads: number };
|
|
// When true, `get_identity` returns `lost: true` until `persist_current_identity`
|
|
// or `import_identity` is called. Drives the identity-lost recovery UX in tests.
|
|
identityLost?: boolean;
|
|
// When true, `get_identity` returns `locked: true` until `import_identity` is
|
|
// called. Drives the keyring-locked screen in tests.
|
|
identityLocked?: boolean;
|
|
/** Delay (ms) applied to identity import so specs can observe pending navigation. */
|
|
identityImportDelayMs?: number;
|
|
/**
|
|
* 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;
|
|
};
|
|
/** Explicit owner-only agent-access capability; independent of baked defaults. */
|
|
ownerOnlyAccessBuild?: boolean;
|
|
/** File-layer config returned by runtime id. */
|
|
runtimeFileConfigs?: Record<string, RuntimeFileConfigSubset | null>;
|
|
/** Baked build env returned by the display and key-name Tauri commands. */
|
|
bakedBuildEnv?: Array<{
|
|
key: string;
|
|
masked: boolean;
|
|
value: string;
|
|
}>;
|
|
/** Delay (ms) applied to `get_baked_build_env` so specs can observe
|
|
* initial render gating around build defaults. 0/undefined = instant. */
|
|
bakedBuildEnvDelayMs?: number;
|
|
/** Delay (ms) applied to `set_global_agent_config` so tests can observe
|
|
* pending save behaviour. 0/undefined = instant. 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 instead of returning the
|
|
* mock nsec string. Use `nsecErrors` for sequenced failure/success.
|
|
*/
|
|
nsecError?: string;
|
|
/**
|
|
* Sequenced results for `get_nsec`. Each element is either a string
|
|
* (error message) or null (success — returns the default mock nsec).
|
|
* Call N uses results[N]; when exhausted the last entry repeats.
|
|
*/
|
|
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 "M 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
|
|
* spec can interleave edits and exercise the mid-save race handling.
|
|
*/
|
|
globalConfigSaveDelayMs?: number;
|
|
/**
|
|
* Override the `discover_agent_models` mock response. When set, returns
|
|
* this catalog instead of the default per-harness model list.
|
|
*/
|
|
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.
|
|
*/
|
|
discoverAgentModelsError?: string;
|
|
// Backend provider mocks for the create-agent "Run on" section. See
|
|
// tests/helpers/bridge.ts:MockBridgeOptions for semantics.
|
|
backendProviders?: Array<{ id: string; binaryPath: string }>;
|
|
backendProviderProbeResult?: Record<string, unknown>;
|
|
backendProviderProbeDelayMs?: number;
|
|
};
|
|
relayHttpUrl?: string;
|
|
relayWsUrl?: string;
|
|
autoConnectDefaultRelay?: boolean;
|
|
identity?: TestIdentity;
|
|
};
|
|
|
|
type RawBlobDescriptor = {
|
|
url: string;
|
|
sha256: string;
|
|
size: number;
|
|
type: string;
|
|
uploaded: number;
|
|
dim?: string;
|
|
blurhash?: string;
|
|
thumb?: string;
|
|
duration?: number;
|
|
image?: string;
|
|
filename?: string;
|
|
};
|
|
|
|
type RawRelayMember = {
|
|
pubkey: string;
|
|
role: "owner" | "admin" | "member";
|
|
added_by: string | null;
|
|
created_at: string;
|
|
};
|
|
|
|
type RawProfile = {
|
|
pubkey: string;
|
|
display_name: string | null;
|
|
/** Kind-0 `name` field, kept separate from `display_name` so mention
|
|
* resolution can match either alias. */
|
|
name?: string | null;
|
|
avatar_url: string | null;
|
|
about: string | null;
|
|
nip05_handle: string | null;
|
|
owner_pubkey: string | null;
|
|
is_agent?: boolean;
|
|
/** Mirrors the Rust `has_profile_event` flag: true when a real kind:0 event
|
|
* backed this profile, false for the synthesized empty fallback. */
|
|
has_profile_event: boolean;
|
|
};
|
|
|
|
type RawUserProfileSummary = {
|
|
display_name: string | null;
|
|
name?: string | null;
|
|
avatar_url: string | null;
|
|
nip05_handle: string | null;
|
|
owner_pubkey: string | null;
|
|
is_agent?: boolean;
|
|
};
|
|
|
|
type RawUsersBatchResponse = {
|
|
profiles: Record<string, RawUserProfileSummary>;
|
|
missing: string[];
|
|
};
|
|
|
|
type RawUserSearchResult = {
|
|
pubkey: string;
|
|
display_name: string | null;
|
|
avatar_url: string | null;
|
|
nip05_handle: string | null;
|
|
owner_pubkey: string | null;
|
|
is_agent?: boolean;
|
|
};
|
|
|
|
type RawSearchUsersResponse = {
|
|
users: RawUserSearchResult[];
|
|
next_cursor?: string | null;
|
|
};
|
|
|
|
type PresenceStatus = "online" | "away" | "offline";
|
|
|
|
type RawPresenceLookup = Record<string, PresenceStatus>;
|
|
|
|
type RawChannel = {
|
|
id: string;
|
|
name: string;
|
|
channel_type: "stream" | "forum" | "dm";
|
|
visibility: "open" | "private";
|
|
description: string;
|
|
topic: string | null;
|
|
purpose: string | null;
|
|
member_count: number;
|
|
member_pubkeys: string[];
|
|
last_message_at: string | null;
|
|
archived_at: string | null;
|
|
participants: string[];
|
|
participant_pubkeys: string[];
|
|
ttl_seconds: number | null;
|
|
ttl_deadline: string | null;
|
|
};
|
|
|
|
type RawChannelWithMembership = RawChannel & {
|
|
is_member: boolean;
|
|
};
|
|
|
|
type RawChannelDetail = RawChannel & {
|
|
created_by: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
topic_set_by: string | null;
|
|
topic_set_at: string | null;
|
|
purpose_set_by: string | null;
|
|
purpose_set_at: string | null;
|
|
topic_required: boolean;
|
|
max_members: number | null;
|
|
nip29_group_id: string | null;
|
|
};
|
|
|
|
type RawChannelMember = {
|
|
pubkey: string;
|
|
role: "owner" | "admin" | "member" | "guest" | "bot";
|
|
is_agent?: boolean;
|
|
joined_at: string;
|
|
display_name: string | null;
|
|
};
|
|
|
|
type RawChannelMembersResponse = {
|
|
members: RawChannelMember[];
|
|
next_cursor: string | null;
|
|
};
|
|
|
|
type RawAddChannelMembersResponse = {
|
|
added: string[];
|
|
errors: Array<{
|
|
pubkey: string;
|
|
error: string;
|
|
}>;
|
|
};
|
|
|
|
type MockChannel = Omit<RawChannelDetail, "member_pubkeys"> & {
|
|
members: RawChannelMember[];
|
|
};
|
|
|
|
type RawFeedItem = {
|
|
id: string;
|
|
kind: number;
|
|
pubkey: string;
|
|
content: string;
|
|
created_at: number;
|
|
channel_id: string | null;
|
|
channel_name: string;
|
|
// Mirrors native FeedItemInfo.channel_type (Option<String>): the Tauri
|
|
// backend always emits the key, as `null` when unknown.
|
|
channel_type?: string | null;
|
|
tags: string[][];
|
|
category: FeedItemCategory;
|
|
};
|
|
|
|
type RawHomeFeedResponse = {
|
|
feed: {
|
|
mentions: RawFeedItem[];
|
|
needs_action: RawFeedItem[];
|
|
activity: RawFeedItem[];
|
|
agent_activity: RawFeedItem[];
|
|
};
|
|
meta: {
|
|
since: number;
|
|
total: number;
|
|
generated_at: number;
|
|
};
|
|
};
|
|
|
|
type RawThreadSummary = {
|
|
reply_count: number;
|
|
descendant_count: number;
|
|
last_reply_at: number | null;
|
|
participants: string[];
|
|
};
|
|
|
|
type RawForumPost = {
|
|
event_id: string;
|
|
pubkey: string;
|
|
content: string;
|
|
kind: number;
|
|
created_at: number;
|
|
channel_id: string;
|
|
tags: string[][];
|
|
thread_summary: RawThreadSummary | null;
|
|
reactions: unknown;
|
|
};
|
|
|
|
type RawForumPostsResponse = {
|
|
messages: RawForumPost[];
|
|
next_cursor: number | null;
|
|
};
|
|
|
|
type RawForumReply = {
|
|
event_id: string;
|
|
pubkey: string;
|
|
content: string;
|
|
kind: number;
|
|
created_at: number;
|
|
channel_id: string;
|
|
tags: string[][];
|
|
parent_event_id: string | null;
|
|
root_event_id: string | null;
|
|
depth: number;
|
|
broadcast: boolean;
|
|
reactions: unknown;
|
|
};
|
|
|
|
type RawForumThreadResponse = {
|
|
root: RawForumPost;
|
|
replies: RawForumReply[];
|
|
total_replies: number;
|
|
next_cursor: string | null;
|
|
};
|
|
|
|
type RawUserNote = {
|
|
id: string;
|
|
pubkey: string;
|
|
created_at: number;
|
|
content: string;
|
|
tags: string[][];
|
|
};
|
|
|
|
type RawUserNotesCursor = {
|
|
before: number;
|
|
before_id: string;
|
|
};
|
|
|
|
type RawUserNotesResponse = {
|
|
notes: RawUserNote[];
|
|
next_cursor: RawUserNotesCursor | null;
|
|
};
|
|
|
|
type RawSearchHit = {
|
|
event_id: string;
|
|
content: string;
|
|
kind: number;
|
|
pubkey: string;
|
|
channel_id: string | null;
|
|
channel_name: string | null;
|
|
created_at: number;
|
|
score: number;
|
|
};
|
|
|
|
type RawSearchResponse = {
|
|
hits: RawSearchHit[];
|
|
found: number;
|
|
};
|
|
|
|
type RawSendChannelMessageResponse = {
|
|
event_id: string;
|
|
parent_event_id: string | null;
|
|
root_event_id: string | null;
|
|
depth: number;
|
|
created_at: number;
|
|
};
|
|
|
|
type RawRelayAgent = {
|
|
pubkey: string;
|
|
owner_pubkey?: string | null;
|
|
name: string;
|
|
agent_type: string;
|
|
channels: string[];
|
|
channel_ids: string[];
|
|
capabilities: string[];
|
|
status: PresenceStatus | "unknown";
|
|
respond_to?: "owner-only" | "allowlist" | "anyone";
|
|
respond_to_allowlist?: string[];
|
|
};
|
|
|
|
type RawManagedAgent = {
|
|
pubkey: string;
|
|
name: string;
|
|
persona_id: string | null;
|
|
/** Record-level harness/runtime pin (`null` when inheriting from the persona). */
|
|
runtime: string | null;
|
|
relay_url: string;
|
|
acp_command: string;
|
|
agent_command: string;
|
|
agent_args: string[];
|
|
mcp_command: string;
|
|
turn_timeout_seconds: number;
|
|
idle_timeout_seconds: number | null;
|
|
max_turn_duration_seconds: number | null;
|
|
parallelism: number;
|
|
system_prompt: string | null;
|
|
avatar_url: string | null;
|
|
model: string | null;
|
|
provider?: string | null;
|
|
env_vars?: Record<string, string>;
|
|
status: "running" | "stopped" | "deployed" | "not_deployed";
|
|
pid: number | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
last_started_at: string | null;
|
|
last_stopped_at: string | null;
|
|
last_exit_code: number | null;
|
|
last_error: string | null;
|
|
last_error_code: number | null;
|
|
needs_restart?: boolean;
|
|
restart_diff?: Array<{ field: string; change: unknown }>;
|
|
log_path: string;
|
|
start_on_app_launch: boolean;
|
|
auto_restart_on_config_change?: boolean;
|
|
backend:
|
|
| { type: "local" }
|
|
| { type: "provider"; id: string; config: Record<string, unknown> };
|
|
backend_agent_id: string | null;
|
|
respond_to: "owner-only" | "allowlist" | "anyone";
|
|
respond_to_allowlist: string[];
|
|
};
|
|
|
|
type RawCreateManagedAgentResponse = {
|
|
agent: RawManagedAgent;
|
|
private_key_nsec: string;
|
|
profile_sync_error: string | null;
|
|
spawn_error: string | null;
|
|
};
|
|
|
|
type RawManagedAgentLog = {
|
|
content: string;
|
|
log_path: string;
|
|
};
|
|
|
|
type RawEngramEntry = {
|
|
slug: string;
|
|
body: string;
|
|
eventId: string;
|
|
createdAt: number;
|
|
outgoingRefs: string[];
|
|
};
|
|
|
|
type RawAgentMemoryListing = {
|
|
core: RawEngramEntry | null;
|
|
memories: RawEngramEntry[];
|
|
truncated: boolean;
|
|
fetchedAt: number;
|
|
};
|
|
|
|
type RawCommandAvailability = {
|
|
command: string;
|
|
resolved_path: string | null;
|
|
available: boolean;
|
|
};
|
|
|
|
type RawManagedAgentPrereqs = {
|
|
acp: RawCommandAvailability;
|
|
mcp: RawCommandAvailability;
|
|
};
|
|
|
|
type RawPersona = {
|
|
id: string;
|
|
display_name: string;
|
|
avatar_url: string | null;
|
|
description?: string | null;
|
|
system_prompt: string;
|
|
runtime?: string | null;
|
|
model?: string | null;
|
|
provider?: string | null;
|
|
name_pool?: string[];
|
|
is_builtin: boolean;
|
|
is_active: boolean;
|
|
shared: boolean;
|
|
source_team?: string | null;
|
|
catalog_source?: { owner_pubkey: string; persona_id: string } | null;
|
|
env_vars?: Record<string, string>;
|
|
respond_to?: string | null;
|
|
respond_to_allowlist?: string[];
|
|
parallelism?: number | null;
|
|
session_policy?: "channel" | "thread";
|
|
created_at: string;
|
|
updated_at: string;
|
|
};
|
|
|
|
type RawTeam = {
|
|
id: string;
|
|
name: string;
|
|
description: string | null;
|
|
persona_ids: string[];
|
|
is_builtin: boolean;
|
|
shared?: boolean;
|
|
catalog_source?: { owner_pubkey: string; team_d_tag: string } | null;
|
|
source_dir: string | null;
|
|
is_symlink: boolean;
|
|
symlink_target: string | null;
|
|
version: string | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
};
|
|
|
|
type MockManagedAgent = RawManagedAgent & {
|
|
private_key_nsec: string;
|
|
log_lines: string[];
|
|
};
|
|
|
|
// Mirrors the Rust `ManagedAgentRuntimeStatus` camelCase wire shape for the
|
|
// pair-scoped lifecycle commands.
|
|
type MockManagedAgentRuntimeRow = {
|
|
pubkey: string;
|
|
relayUrl: string;
|
|
localSetup: boolean;
|
|
lifecycle:
|
|
| "starting"
|
|
| "listening"
|
|
| "waking"
|
|
| "ready"
|
|
| "failed"
|
|
| "stopped";
|
|
pid: number | null;
|
|
error: string | null;
|
|
logPath: string | null;
|
|
};
|
|
|
|
type WsHandler = (message: unknown) => void;
|
|
const GLOBAL_MOCK_SUBSCRIPTION = "*";
|
|
|
|
type MockSubscription = ReturnType<typeof createMockSubscription>;
|
|
|
|
type MockFilter = {
|
|
"#a"?: string[];
|
|
"#buzz-channel"?: string[];
|
|
"#d"?: string[];
|
|
"#e"?: string[];
|
|
"#h"?: string[];
|
|
"#p"?: string[];
|
|
authors?: string[];
|
|
ids?: string[];
|
|
kinds?: number[];
|
|
limit?: number;
|
|
since?: number;
|
|
until?: number;
|
|
};
|
|
|
|
type MockSocket = {
|
|
handler: WsHandler;
|
|
subscriptions: Map<string, MockSubscription>;
|
|
};
|
|
|
|
function createMockRelayMembershipEvent(): RelayEvent {
|
|
return createMockEvent(
|
|
13534,
|
|
"",
|
|
mockRelayMembers.map((member) => ["member", member.pubkey, member.role]),
|
|
"f".repeat(64),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Per-user custom emoji sets (kind:30030) the mock WS serves for
|
|
* `listCustomEmoji` REQs. The community palette is the client-side UNION of
|
|
* every member's own set (d=`buzz:custom-emoji`). We serve TWO member-authored
|
|
* sets from distinct pubkeys so the e2e exercises the union/collapse path, not
|
|
* a single relay-owned set. `:buzz:` is the stable shortcode exercised by
|
|
* custom-emoji.spec.ts (claimed by BOTH members with different URLs, so the
|
|
* palette must collapse it to one deterministic winner); `:narf:` and
|
|
* `:bufo_joy:` prove a second member's distinct emoji unions in.
|
|
*/
|
|
function createMockCustomEmojiSetEvents(): RelayEvent[] {
|
|
return [
|
|
createMockEvent(
|
|
KIND_EMOJI_SET,
|
|
"",
|
|
[
|
|
["d", CUSTOM_EMOJI_SET_D_TAG],
|
|
["emoji", "buzz", "https://example.com/e2e/buzz.png"],
|
|
// A relay-hosted emoji whose URL matches rewriteRelayUrl()'s pattern,
|
|
// used by the reaction guard to assert the proxy rewrite fires.
|
|
["emoji", REACTION_EMOJI_SHORTCODE, REACTION_EMOJI_URL],
|
|
],
|
|
// The current mock identity owns this set, so the settings card's
|
|
// "My emoji" section is non-empty and removable.
|
|
MOCK_IDENTITY_PUBKEY,
|
|
),
|
|
createMockEvent(
|
|
KIND_EMOJI_SET,
|
|
"",
|
|
[
|
|
["d", CUSTOM_EMOJI_SET_D_TAG],
|
|
["emoji", "narf", "https://example.com/e2e/narf.png"],
|
|
// member B claims :buzz: with a DIFFERENT url — unionCustomEmoji must
|
|
// collapse it to one deterministic winner, never expose two URLs.
|
|
["emoji", "buzz", "https://example.com/e2e/buzz-b.png"],
|
|
["emoji", "bufo_joy", "https://example.com/e2e/bufo-joy.png"],
|
|
],
|
|
"b".repeat(64),
|
|
),
|
|
];
|
|
}
|
|
|
|
function updateMockRelayMembershipFromAdminEvent(event: RelayEvent): boolean {
|
|
const targetPubkey = event.tags
|
|
.find((tag) => tag[0] === "p")?.[1]
|
|
?.toLowerCase();
|
|
if (!targetPubkey) return false;
|
|
|
|
if (event.kind === 9030) {
|
|
const role = event.tags.find((tag) => tag[0] === "role")?.[1] ?? "member";
|
|
if (role !== "admin" && role !== "member") return false;
|
|
if (mockRelayMembers.some((member) => member.pubkey === targetPubkey)) {
|
|
return true;
|
|
}
|
|
mockRelayMembers.push({
|
|
pubkey: targetPubkey,
|
|
role,
|
|
added_by: event.pubkey,
|
|
created_at: new Date().toISOString(),
|
|
});
|
|
return true;
|
|
}
|
|
|
|
if (event.kind === 9031) {
|
|
mockRelayMembers = mockRelayMembers.filter(
|
|
(member) => member.pubkey !== targetPubkey,
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (event.kind === 9032) {
|
|
const role = event.tags.find((tag) => tag[0] === "role")?.[1];
|
|
if (role !== "admin" && role !== "member") return false;
|
|
mockRelayMembers = mockRelayMembers.map((member) =>
|
|
member.pubkey === targetPubkey ? { ...member, role } : member,
|
|
);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Mirror the native clipboard command's dual-flavor write.
|
|
*
|
|
* `navigator.clipboard.writeText` can only carry the plain flavor, so specs
|
|
* asserting on the identity sidecar read the captured payload instead. The
|
|
* rich write is still attempted so real paste round-trips work.
|
|
*/
|
|
async function writeClipboardFlavors({
|
|
html,
|
|
text,
|
|
}: {
|
|
html?: string;
|
|
text: string;
|
|
}): Promise<void> {
|
|
window.__BUZZ_E2E_LAST_CLIPBOARD__ = { html: html ?? null, text };
|
|
if (
|
|
html &&
|
|
typeof ClipboardItem !== "undefined" &&
|
|
navigator.clipboard?.write
|
|
) {
|
|
try {
|
|
await navigator.clipboard.write([
|
|
new ClipboardItem({
|
|
"text/html": new Blob([html], { type: "text/html" }),
|
|
"text/plain": new Blob([text], { type: "text/plain" }),
|
|
}),
|
|
]);
|
|
return;
|
|
} catch {
|
|
// Fall back to the plain flavor; headless permissions vary by browser.
|
|
}
|
|
}
|
|
await navigator.clipboard.writeText(text);
|
|
}
|
|
|
|
declare global {
|
|
interface Window {
|
|
__BUZZ_E2E__?: E2eConfig;
|
|
/** Last payload written through the native clipboard command. */
|
|
__BUZZ_E2E_LAST_CLIPBOARD__?: { html: string | null; text: string };
|
|
__BUZZ_E2E_COMMANDS__?: string[];
|
|
__BUZZ_E2E_COMMAND_PAYLOADS__?: Array<{
|
|
command: string;
|
|
payload: unknown;
|
|
}>;
|
|
__BUZZ_E2E_COMMAND_LOG__?: Array<{
|
|
command: string;
|
|
payload: unknown;
|
|
}>;
|
|
/** Release a mock media proxy held at port 0 and return its ready port. */
|
|
__BUZZ_E2E_RELEASE_MEDIA_PROXY__?: () => number;
|
|
/** Release mock send events that were stored but withheld from live subscribers. */
|
|
__BUZZ_E2E_RELEASE_SEND_MESSAGE_LIVE_ECHO__?: () => number;
|
|
__BUZZ_E2E_EMIT_MEDIA_UPLOAD_PHASE__?: (input: {
|
|
id: string;
|
|
phase: string;
|
|
}) => Promise<void>;
|
|
__BUZZ_E2E_EMIT_MEDIA_UPLOAD_PROGRESS__?: (input: {
|
|
id: string;
|
|
sent: number;
|
|
total: number;
|
|
}) => Promise<void>;
|
|
__BUZZ_E2E_EMIT_MOCK_HUDDLE_TTS_SPEAKER__?: (payload: {
|
|
pubkey: string | null;
|
|
level: number;
|
|
}) => Promise<void>;
|
|
__BUZZ_E2E_WEBVIEW_ZOOM__?: number;
|
|
__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
|
|
channelName: string;
|
|
kind?: number;
|
|
exactChannel?: boolean;
|
|
}) => boolean;
|
|
__BUZZ_E2E_HAS_MOCK_OWNER_KIND_SUBSCRIPTION__?: (input: {
|
|
ownerPubkey: string;
|
|
kind: number;
|
|
}) => boolean;
|
|
__BUZZ_E2E_HAS_MOCK_GLOBAL_KIND_SUBSCRIPTION__?: (kind: number) => boolean;
|
|
__BUZZ_E2E_SET_MOCK_USER_STATUS__?: (input: {
|
|
text: string;
|
|
emoji?: string;
|
|
expiresAt?: number;
|
|
createdAt?: number;
|
|
}) => RelayEvent;
|
|
/** Explicit presence evidence; independent of managed-agent runtime state. */
|
|
__BUZZ_E2E_EMIT_MOCK_PRESENCE__?: (input: {
|
|
pubkey: string;
|
|
status: PresenceStatus;
|
|
}) => void;
|
|
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
|
|
channelName: string;
|
|
content: string;
|
|
parentEventId?: string | null;
|
|
pubkey?: string;
|
|
kind?: number;
|
|
mentionPubkeys?: string[];
|
|
extraTags?: string[][];
|
|
createdAt?: number;
|
|
/** Marks this test-only message as locally pending. */
|
|
pending?: boolean;
|
|
/** 64-hex id required for the event to be a valid reaction target. */
|
|
id?: string;
|
|
}) => RelayEvent;
|
|
/** Prepend `count` synthetic older messages to a channel's mock store so
|
|
* an older-history fetch has something to paginate. Mirrors how the real
|
|
* relay backfills history. Returns the created events. */
|
|
__BUZZ_E2E_PREPEND_MOCK_HISTORY__?: (input: {
|
|
channelName: string;
|
|
count: number;
|
|
startIndex?: number;
|
|
lineCount?: number;
|
|
createdAtStart?: number;
|
|
emit?: boolean;
|
|
}) => RelayEvent[];
|
|
__BUZZ_E2E_EMIT_MOCK_TYPING__?: (input: {
|
|
channelName: string;
|
|
createdAt?: number;
|
|
pubkey?: string;
|
|
threadHeadId?: string;
|
|
}) => RelayEvent;
|
|
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
|
|
command: string,
|
|
payload?: Record<string, unknown>,
|
|
) => Promise<unknown>;
|
|
__BUZZ_E2E_EMIT_TAURI_EVENT__?: (
|
|
event: string,
|
|
payload: unknown,
|
|
) => Promise<void>;
|
|
__BUZZ_E2E_SET_MOCK_HUDDLE_SNAPSHOT__?: (input: {
|
|
members: MockHuddleMemberSeed[];
|
|
transcriptionEnabled: boolean;
|
|
}) => Promise<void>;
|
|
/**
|
|
* Replace the stored kind:30178 head for a coordinate WITHOUT notifying
|
|
* live subscribers. Reproduces a head that moved on the relay while a
|
|
* catalog dialog sat open holding the superseded event id.
|
|
*/
|
|
__BUZZ_E2E_REPLACE_MOCK_TEAM_CATALOG_HEAD__?: (event: RelayEvent) => void;
|
|
__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?: (item: RawFeedItem) => RawFeedItem;
|
|
/** Replace an existing feed item by id (or push if not found) and fire the updated event. */
|
|
__BUZZ_E2E_REPLACE_MOCK_FEED_ITEM__?: (
|
|
oldId: string,
|
|
item: RawFeedItem,
|
|
) => RawFeedItem;
|
|
__BUZZ_E2E_SIGNED_EVENTS__?: Array<{
|
|
content: string;
|
|
createdAt?: number;
|
|
kind: number;
|
|
tags: string[][];
|
|
}>;
|
|
/** Omits kind 30621 seeds while retaining standalone kind 30617 repositories. */
|
|
__BUZZ_E2E_REPOSITORY_ONLY_PROJECTS__?: boolean;
|
|
/** Leaves broad project enumeration pending while scoped project queries remain available. */
|
|
__BUZZ_E2E_DEFER_FULL_PROJECT_QUERIES__?: boolean;
|
|
/** Omits all seeded project and repository events for empty-state tests. */
|
|
__BUZZ_E2E_EMPTY_PROJECTS__?: boolean;
|
|
/** Project-scoped events accepted by the mock relay. */
|
|
__BUZZ_E2E_ACCEPTED_PROJECT_EVENTS__?: Array<{
|
|
content: string;
|
|
kind: number;
|
|
tags: string[][];
|
|
}>;
|
|
/** Project event kinds rejected once, in order, to exercise retry flows. */
|
|
__BUZZ_E2E_REJECT_PROJECT_EVENT_KINDS__?: number[];
|
|
/** Makes the mock relay reject project announcements as an unknown kind. */
|
|
__BUZZ_E2E_UNSUPPORTED_PROJECT_ANNOUNCEMENTS__?: boolean;
|
|
/** Project event kinds accepted once but reported as failed to test lost acknowledgements. */
|
|
__BUZZ_E2E_FAIL_PROJECT_EVENT_ACK_KINDS__?: number[];
|
|
/**
|
|
* Extra project events appended to the mock store on first access.
|
|
* Use to seed standalone repositories (kind 30617) or other project-scoped
|
|
* events before a test interaction, without mutating the fixed seed data.
|
|
* Events must be complete RelayEvent shapes; id is a required field so that
|
|
* id-keyed queries (e.g. lost-ACK recovery) can match them.
|
|
*/
|
|
__BUZZ_E2E_EXTRA_PROJECT_EVENTS__?: Array<{
|
|
id: string;
|
|
kind: number;
|
|
pubkey: string;
|
|
created_at: number;
|
|
content: string;
|
|
tags: string[][];
|
|
}>;
|
|
/** Structured merge error returned by the mock native merge command. */
|
|
__BUZZ_E2E_PROJECT_MERGE_ERROR__?: {
|
|
code: string;
|
|
message: string;
|
|
recovery: {
|
|
action: "open_terminal";
|
|
sourceBranch: string;
|
|
targetBranch: string;
|
|
} | null;
|
|
};
|
|
/** Overrides the first mock repository owner for delegated-owner tests. */
|
|
__BUZZ_E2E_PROJECT_OWNER_OVERRIDE__?: string;
|
|
/** Project history kinds rejected with CLOSED for aggregate-query tests. */
|
|
__BUZZ_E2E_REJECT_PROJECT_QUERY_KINDS__?: number[];
|
|
/** Captured aggregate project-history filters for request-count assertions. */
|
|
__BUZZ_E2E_PROJECT_QUERY_FILTERS__?: MockFilter[];
|
|
/** Optional local repository snapshot returned for project branch tests. */
|
|
__BUZZ_E2E_PROJECT_LOCAL_REPO_SNAPSHOT__?: unknown;
|
|
/** Optional bounded file contents returned by repository content reads. */
|
|
__BUZZ_E2E_PROJECT_REPO_FILE_CONTENTS__?: Record<string, string | null>;
|
|
__BUZZ_E2E_PROJECT_REPO_SYNC_STATUS__?: {
|
|
local_path: string | null;
|
|
local_branch: string | null;
|
|
local_branches: string[];
|
|
local_head: string | null;
|
|
local_short_head: string | null;
|
|
remote_branch: string | null;
|
|
remote_head: string | null;
|
|
remote_short_head: string | null;
|
|
merge_base: string | null;
|
|
ahead_count: number;
|
|
behind_count: number;
|
|
has_uncommitted_changes: boolean;
|
|
has_untracked_files: boolean;
|
|
can_push: boolean;
|
|
push_block_reason: string | null;
|
|
can_pull: boolean;
|
|
pull_block_reason: string | null;
|
|
};
|
|
__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: (state: ConnectionState) => void;
|
|
__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?: () => ConnectionState;
|
|
/** Queue deterministic mock AUTH outcomes, consumed in order. */
|
|
__BUZZ_E2E_QUEUE_AUTH_RESPONSES__?: (
|
|
responses: Array<{ success: boolean; message: string }>,
|
|
) => void;
|
|
/** Inject CLOSED into every active mock live subscription. */
|
|
__BUZZ_E2E_CLOSE_LIVE_SUBSCRIPTIONS__?: (reason: string) => number;
|
|
/** Queue CLOSED responses for channel history REQs. */
|
|
__BUZZ_E2E_QUEUE_CHANNEL_HISTORY_CLOSES__?: (reasons: string[]) => void;
|
|
__BUZZ_E2E_SET_STALL_WEBSOCKET_SENDS__?: (stall: boolean) => void;
|
|
__BUZZ_E2E_DISCONNECT_MOCK_WEBSOCKETS__?: () => number;
|
|
__BUZZ_E2E_RESTART_MOCK_WEBSOCKETS__?: () => number;
|
|
__BUZZ_E2E_SET_MOCK_WEBSOCKET_UNAVAILABLE__?: (
|
|
unavailable: boolean,
|
|
) => void;
|
|
__BUZZ_E2E_GET_WEBSOCKET_CONNECT_ATTEMPTS__?: () => number[];
|
|
__BUZZ_E2E_ACTIVATE_RELAY_RATE_LIMIT__?: (seconds: number) => void;
|
|
__BUZZ_E2E_RESET_WEBSOCKET_CONNECT_ATTEMPTS__?: () => void;
|
|
__BUZZ_E2E_SET_MESH__?: (mesh: {
|
|
admitted?: boolean;
|
|
models?: Array<{ id: string; name: string | null }>;
|
|
denyReason?: string;
|
|
/** Seed the runtime slot's lifecycle state (default "off"). */
|
|
nodeState?: "off" | "running";
|
|
/**
|
|
* Seed the runtime slot's role. "client" models this machine CONSUMING a
|
|
* peer's compute — it shares the single slot and reports state:"running",
|
|
* so the Share toggle must stay off. Drives the toggle-on regression test.
|
|
*/
|
|
nodeMode?: "serve" | "client" | null;
|
|
/** Seed host-side serving usage to exercise the "who's using my compute" indicator. */
|
|
servingUsage?: Partial<{
|
|
inflight: number;
|
|
peakInflight: number;
|
|
requestsServed: number;
|
|
tokensServed: number;
|
|
tokensPerSecond: number;
|
|
localAttempts: number;
|
|
remoteAttempts: number;
|
|
endpointAttempts: number;
|
|
peers: number;
|
|
}>;
|
|
}) => void;
|
|
/** Run the real live-switch outcome and control transport through mock relay. */
|
|
__BUZZ_E2E_RUN_MODEL_SWITCH__?: (input: {
|
|
agentPubkey: string;
|
|
channelIds: string[];
|
|
modelId: string;
|
|
requestId: string;
|
|
timeoutMs?: number;
|
|
}) => Promise<string>;
|
|
/** Mount AgentSessionThreadPanel directly for channelId-only UI coverage. */
|
|
__BUZZ_E2E_MOUNT_AGENT_SESSION_PANEL__?: (input: {
|
|
agentPubkey: string;
|
|
channelId: string;
|
|
canInterruptTurn?: boolean;
|
|
}) => Promise<void>;
|
|
/** Resolve decoded test-only observer controls back through the mock relay. */
|
|
__BUZZ_E2E_OBSERVER_CONTROLS__?: Array<{
|
|
agentPubkey: string;
|
|
payload: unknown;
|
|
}>;
|
|
__BUZZ_E2E_SEED_ACTIVE_TURNS__?: (input: {
|
|
agentPubkey: string;
|
|
channelId: string;
|
|
turnId: string;
|
|
kind?: "turn_started" | "turn_completed";
|
|
}) => void;
|
|
__BUZZ_E2E_SEED_OBSERVER_EVENTS__?: (input: {
|
|
agentPubkey: string;
|
|
events: Array<{
|
|
seq: number;
|
|
timestamp: string;
|
|
kind: string;
|
|
agentIndex: number | null;
|
|
channelId: string | null;
|
|
sessionId: string | null;
|
|
turnId: string | null;
|
|
payload: unknown;
|
|
}>;
|
|
}) => void;
|
|
__BUZZ_E2E_EMIT_MOCK_READ_STATE__?: (input: {
|
|
clientId: string;
|
|
contexts: Record<string, number>;
|
|
createdAt: number;
|
|
slotId: string;
|
|
}) => unknown;
|
|
__BUZZ_E2E_SEED_MOCK_REMINDERS__?: (reminders: RelayEvent[]) => void;
|
|
__BUZZ_E2E_QUERY_CLIENT__?: {
|
|
invalidateQueries: (filters: {
|
|
queryKey: readonly unknown[];
|
|
exact?: boolean;
|
|
}) => unknown;
|
|
getQueryState: (queryKey: readonly unknown[]) =>
|
|
| {
|
|
fetchStatus: "fetching" | "paused" | "idle";
|
|
status: "pending" | "error" | "success";
|
|
}
|
|
| undefined;
|
|
};
|
|
__BUZZ_E2E_MD_PARSE_COUNT__?: () => number;
|
|
/**
|
|
* Activate the community timeout store as if a send was rejected with a
|
|
* timeout message. Lets E2E tests prove the timeout gate fires before encode.
|
|
* Call after page load. Pass expiresAtMs (epoch ms) or 0 for unknown expiry.
|
|
*/
|
|
__BUZZ_E2E_ACTIVATE_TIMEOUT__?: (expiresAtMs: number) => void;
|
|
/**
|
|
* Invalidate the channels React Query cache so E2E tests can trigger a
|
|
* re-fetch after calling archive_channel / update_channel via
|
|
* __BUZZ_E2E_INVOKE_MOCK_COMMAND__. Call after the mutation to make the
|
|
* updated channel state visible to subscribers.
|
|
*/
|
|
__BUZZ_E2E_INVALIDATE_CHANNELS__?: () => Promise<void>;
|
|
/**
|
|
* Directly mutate a mock channel's properties without going through a
|
|
* command handler. Use for E2E regressions that need to change
|
|
* channel_type or remove isMember in a single synchronous step, then
|
|
* follow up with __BUZZ_E2E_INVALIDATE_CHANNELS__ to flush the cache.
|
|
*
|
|
* Only the listed fields are writeable; omitted fields are left unchanged.
|
|
*/
|
|
__BUZZ_E2E_MUTATE_CHANNEL__?: (opts: {
|
|
channelId: string;
|
|
channelType?: "stream" | "forum" | "dm";
|
|
description?: string;
|
|
removeMemberPubkey?: string;
|
|
}) => void;
|
|
/**
|
|
* When set to an event ID string, `get_event` calls for that specific ID
|
|
* are held in a queue and not resolved until `__BUZZ_E2E_RELEASE_GET_EVENT__()`
|
|
* is called. Calls for any other event ID proceed normally. Used by the
|
|
* cold-recovery race test to prove mid-flight feedItems updates do not
|
|
* cancel the in-flight promise for the cold anchor specifically.
|
|
* Set to undefined/null to disable deferral.
|
|
*/
|
|
__BUZZ_E2E_DEFER_GET_EVENT__?: string | null;
|
|
/** Flush all deferred `get_event` calls for the target ID. Each queued
|
|
* request is resolved (or rejected) immediately. Returns the number of
|
|
* requests released. */
|
|
__BUZZ_E2E_RELEASE_GET_EVENT__?: () => number;
|
|
/** Count of `get_event` invocations for the current defer-target ID since
|
|
* the last time `__BUZZ_E2E_DEFER_GET_EVENT__` was set. */
|
|
__BUZZ_E2E_GET_EVENT_CALL_COUNT__?: number;
|
|
/** Release every `start_managed_agent` currently held behind
|
|
* `startManagedAgentDelayMs`, letting it settle (scope checks, then any
|
|
* armed `startManagedAgentErrors` rejection) without waiting out the
|
|
* delay. Returns the number of holds flushed. */
|
|
__BUZZ_E2E_RELEASE_MANAGED_AGENT_STARTS__?: () => number;
|
|
/** Release every `sync_agents_to_active_huddle` currently held behind
|
|
* `syncAgentsToActiveHuddleDelayMs`, letting it resolve without waiting
|
|
* out the delay. Returns the number of holds flushed. */
|
|
__BUZZ_E2E_RELEASE_HUDDLE_AGENT_SYNCS__?: () => number;
|
|
/** Hold the next channel read until released. */
|
|
__BUZZ_E2E_DEFER_NEXT_CHANNELS_READ__?: () => void;
|
|
/** Disarm the latch and release the held channel read, if any. */
|
|
__BUZZ_E2E_RELEASE_CHANNELS_READ__?: () => number;
|
|
/** Number of channel reads currently held by the seam. */
|
|
__BUZZ_E2E_CHANNELS_READ_PENDING__?: number;
|
|
/** Release all link-preview metadata commands held by the mock bridge. */
|
|
__BUZZ_E2E_RELEASE_LINK_PREVIEW_METADATA__?: () => number;
|
|
/** Release link-preview uploads held before mock-native registration. */
|
|
__BUZZ_E2E_RELEASE_LINK_PREVIEW_UPLOADS__?: () => number;
|
|
/** Flush every `get_thread_replies` call held by `deferThreadReplies`.
|
|
* Returns the number of held requests released. */
|
|
__BUZZ_E2E_RELEASE_THREAD_REPLIES__?: () => number;
|
|
/** Number of `get_thread_replies` calls currently held by
|
|
* `deferThreadReplies`. */
|
|
__BUZZ_E2E_THREAD_REPLIES_PENDING__?: () => number;
|
|
/** Start or stop holding `get_users_batch` responses. Turning the hold off
|
|
* flushes everything held; either call returns the number released, so a
|
|
* spec can prove a relay identity lookup was genuinely pinned open. */
|
|
__BUZZ_E2E_HOLD_USERS_BATCH__?: (hold: boolean) => number;
|
|
/** Number of `get_users_batch` calls currently held. */
|
|
__BUZZ_E2E_USERS_BATCH_PENDING__?: () => number;
|
|
/** Release every `get_profile` response held by `deferProfileReads`. */
|
|
__BUZZ_E2E_RELEASE_PROFILE_READS__?: () => number;
|
|
/** Number of `get_profile` responses currently held. */
|
|
__BUZZ_E2E_PROFILE_READS_PENDING__?: () => number;
|
|
/** Uploads that passed mock-native registration and began relay work. */
|
|
__BUZZ_E2E_LINK_PREVIEW_UPLOAD_STARTS__?: number;
|
|
/** Hold renderer-owned media fetches until their cancellation command. */
|
|
__BUZZ_E2E_HOLD_MEDIA_FETCHES__?: boolean;
|
|
/** Real scheduler ownership, including work not yet admitted to native fetch. */
|
|
__BUZZ_E2E_AUDIO_LOAD_STATE__?: typeof getAudioMediaLoadSchedulerSnapshot;
|
|
/** Exact active/peak native media-fetch ownership for scheduler tests. */
|
|
__BUZZ_E2E_MEDIA_FETCH_STATE__?: { active: number; peak: number };
|
|
/** Object-URL lifecycle counters installed by the audio E2E regression. */
|
|
__BUZZ_E2E_AUDIO_OBJECT_URLS__?: { created: number; revoked: number };
|
|
}
|
|
}
|
|
|
|
const DEFAULT_RELAY_HTTP_URL = "http://localhost:3000";
|
|
const DEFAULT_RELAY_WS_URL = "ws://localhost:3000";
|
|
|
|
// NIP event kinds the mock reaction handlers emit.
|
|
const KIND_REACTION = 7; // NIP-25 reaction
|
|
const KIND_DELETION = 5; // NIP-09 deletion
|
|
const KIND_NIP29_DELETION = 9005;
|
|
const CHANNEL_WINDOW_AUX_KINDS = new Set([
|
|
KIND_REACTION,
|
|
KIND_DELETION,
|
|
KIND_NIP29_DELETION,
|
|
KIND_STREAM_MESSAGE_EDIT,
|
|
]);
|
|
const CHANNEL_WINDOW_AUX_DELETION_KINDS = new Set([
|
|
KIND_DELETION,
|
|
KIND_NIP29_DELETION,
|
|
]);
|
|
|
|
// Fake media-proxy port the mock answers for `get_media_proxy_port`, so
|
|
// `rewriteRelayUrl()` produces a real `http://127.0.0.1:<port>/media/...` src
|
|
// in e2e (instead of the `buzz-media://` fallback). The reaction guard
|
|
// asserts against this exact port.
|
|
const MOCK_MEDIA_PROXY_PORT = 54321;
|
|
let mockMediaProxyPort = MOCK_MEDIA_PROXY_PORT;
|
|
|
|
// A relay-hosted custom emoji used by the reaction guard. Its URL matches
|
|
// `rewriteRelayUrl()`'s `/media/{64-hex}.{ext}` pattern on the relay origin, so
|
|
// reacting with it exercises the proxy rewrite (unlike the `:buzz:` fixture,
|
|
// whose external example.com URL passes through unchanged).
|
|
const REACTION_EMOJI_SHORTCODE = "react";
|
|
const REACTION_EMOJI_SHA = "c".repeat(64);
|
|
const REACTION_EMOJI_URL = `${DEFAULT_RELAY_HTTP_URL}/media/${REACTION_EMOJI_SHA}.png`;
|
|
|
|
// A reaction-target message seeded into `general` with a real 64-hex event id.
|
|
// The reaction guard reacts to THIS message: getReactionTargetId() only accepts
|
|
// a 64-hex `e` tag, and the other mock seeds (and user-sent messages) use short
|
|
// non-hex ids, so they can't be reaction targets. Content is distinctive so the
|
|
// test locates its row without relying on seed ordering.
|
|
const REACTION_TARGET_EVENT_ID = "d".repeat(64);
|
|
const REACTION_TARGET_CONTENT = "React to me with a custom emoji";
|
|
// System-message reaction target id (kind:40099 join event). Distinct 64-hex
|
|
// id so it is a valid reaction target and never collides with the regular
|
|
// REACTION_TARGET_EVENT_ID.
|
|
const SYSTEM_REACTION_TARGET_EVENT_ID = "e".repeat(64);
|
|
const E2E_IDENTITY_OVERRIDE_STORAGE_KEY = "buzz:e2e-identity-override.v1";
|
|
/** Stands in for `tauri.conf.json`'s version, which no mock IPC call can read. */
|
|
const MOCK_APP_VERSION = "0.0.0-e2e";
|
|
const DEFAULT_MOCK_IDENTITY = {
|
|
pubkey: "deadbeef".repeat(8),
|
|
display_name: "npub1mock...",
|
|
};
|
|
const DEFAULT_REAL_IDENTITY = {
|
|
privateKey:
|
|
"3dbaebadb5dfd777ff25149ee230d907a15a9e1294b40b830661e65bb42f6c03",
|
|
pubkey: "e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34",
|
|
username: "tyler",
|
|
} satisfies TestIdentity;
|
|
|
|
const ALICE_PUBKEY =
|
|
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f";
|
|
const BOB_PUBKEY =
|
|
"bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260";
|
|
const CHARLIE_PUBKEY =
|
|
"554cef57437abac34522ac2c9f0490d685b72c80478cf9f7ed6f9570ee8624ea";
|
|
const OUTSIDER_PUBKEY =
|
|
"df8e91b86fda13a9a67896df77232f7bdab2ba9c3e165378e1ba3d24c13a328e";
|
|
// A non-member human whose display name has a space in it. Multi-word names are
|
|
// the case a plain-text copy cannot recover — "@John Smith" is indistinguishable
|
|
// from "@John" followed by a word — so the clipboard round-trip fixtures use it.
|
|
const MULTI_WORD_NON_MEMBER_PUBKEY =
|
|
"7c1f2ad0b4e93856a1d0c2f4e6b8093a5d7f1c3e5a79b1d3f5072a4c6e80931b";
|
|
const PROFILE_ONLY_AGENT_PUBKEY =
|
|
"8f83d6b7f3d74f7d933ae3a54dd8c6cc85c7f98e531c16e5a827b953441a8d67";
|
|
// A relay-classified bot agent whose declared NIP-OA owner is the mock viewer,
|
|
// but which is NOT locally managed. This is the fixture that exercises the
|
|
// sidebar's owner-gate path (`viewerIsOwner`), distinct from the local-managed
|
|
// path that `mira` (profile-only) and managed-agent fixtures cover.
|
|
const OWNED_RELAY_AGENT_PUBKEY =
|
|
"a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00";
|
|
const MOCK_IDENTITY_PUBKEY = DEFAULT_MOCK_IDENTITY.pubkey;
|
|
const STARTER_GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
|
|
const STARTER_PROJECT_HOME_CHANNEL_ID = "cf63feec-21bb-5bf0-a2f8-0e4c3de8ec73";
|
|
const STARTER_WELCOME_CHANNEL_ID = "5f0b1b3c-2a37-5366-9b8c-31a4b21d8e77";
|
|
const STARTER_GENERAL_CHANNEL_NAME = "general";
|
|
const STARTER_WELCOME_CHANNEL_NAME = "welcome-everyone";
|
|
|
|
// Tracks whether `persist_current_identity` or `import_identity` has cleared
|
|
// the lost flag set by `mock.identityLost`. Reset to false on each fresh page
|
|
// load (module re-evaluation), so tests start in a clean state.
|
|
let mockIdentityLostCleared = false;
|
|
// Same pattern for `mock.identityLocked`.
|
|
let mockIdentityLockedCleared = false;
|
|
let identityReadCount = 0;
|
|
let identityReadErrorConsumed = false;
|
|
|
|
// ── get_event defer/release seam ────────────────────────────────────────────
|
|
// When `window.__BUZZ_E2E_DEFER_GET_EVENT__` is set to a target event ID,
|
|
// `handleGetEvent` holds calls for that ID in this queue. All other event IDs
|
|
// continue to resolve immediately.
|
|
// `window.__BUZZ_E2E_RELEASE_GET_EVENT__()` flushes the queue and returns the
|
|
// count of released requests, giving the race test a deterministic way to prove
|
|
// that a mid-flight feedItems update does NOT cancel the in-flight promise for
|
|
// the specific cold anchor under test.
|
|
type DeferredGetEvent = {
|
|
resolve: (value: string) => void;
|
|
reject: (reason: unknown) => void;
|
|
run: () => Promise<string>;
|
|
};
|
|
let deferredGetEventQueue: DeferredGetEvent[] = [];
|
|
let deferredLinkPreviewMetadataQueue: Array<() => void> = [];
|
|
let deferredLinkPreviewUploadQueue: Array<() => void> = [];
|
|
let deferredThreadRepliesQueue: Array<() => void> = [];
|
|
type DeferredProfileRead = {
|
|
reject: (reason: unknown) => void;
|
|
resolve: (value: unknown) => void;
|
|
run: () => Promise<unknown>;
|
|
};
|
|
let deferredProfileReadQueue: DeferredProfileRead[] = [];
|
|
let profileReadsReleased = false;
|
|
// ── get_users_batch hold seam ───────────────────────────────────────────────
|
|
// Toggled at runtime by `__BUZZ_E2E_HOLD_USERS_BATCH__(hold)` rather than fixed
|
|
// at boot by a mock-config flag, because a mention-identity spec needs both
|
|
// sides of the hold in one page: profiles resolved normally first (so one paste
|
|
// verifies instantly from local state), then the relay lookup pinned open (so a
|
|
// second paste of the same label is provably still deciding).
|
|
let holdUsersBatch = false;
|
|
let heldUsersBatchReleases: Array<() => void> = [];
|
|
// Starts currently held behind `startManagedAgentDelayMs`, releasable early
|
|
// via `__BUZZ_E2E_RELEASE_MANAGED_AGENT_STARTS__()`: a spec that holds a
|
|
// start across a community round-trip needs the hold long enough to be
|
|
// deterministic AND a way to settle it on demand afterwards.
|
|
let heldManagedAgentStartReleases: Array<() => void> = [];
|
|
// Huddle agent syncs currently held behind `syncAgentsToActiveHuddleDelayMs`,
|
|
// releasable early via `__BUZZ_E2E_RELEASE_HUDDLE_AGENT_SYNCS__()`. The hold
|
|
// must outlast the mid-send mutation a spec injects, then settle on demand
|
|
// rather than on a timer, so the publish never races the injection.
|
|
let heldHuddleAgentSyncReleases: Array<() => void> = [];
|
|
let cancelledMediaUploadIds = new Set<string>();
|
|
let cancelledMediaFetchIds = new Set<string>();
|
|
let mockMediaFetchControllers = new Map<string, AbortController>();
|
|
let deferNextChannelsRead = false;
|
|
let deferredChannelsReadResolve: (() => void) | null = null;
|
|
|
|
const mockDisplayNames = new Map<string, string>([
|
|
[MOCK_IDENTITY_PUBKEY, DEFAULT_MOCK_IDENTITY.display_name],
|
|
[ALICE_PUBKEY, "alice"],
|
|
[BOB_PUBKEY, "bob"],
|
|
[CHARLIE_PUBKEY, "charlie"],
|
|
[PROFILE_ONLY_AGENT_PUBKEY, "mira"],
|
|
[OWNED_RELAY_AGENT_PUBKEY, "nadia"],
|
|
[OUTSIDER_PUBKEY, "outsider"],
|
|
[MULTI_WORD_NON_MEMBER_PUBKEY, "John Smith"],
|
|
[DEFAULT_REAL_IDENTITY.pubkey, DEFAULT_REAL_IDENTITY.username],
|
|
]);
|
|
const mockAgentPubkeys = new Set([
|
|
ALICE_PUBKEY,
|
|
CHARLIE_PUBKEY,
|
|
PROFILE_ONLY_AGENT_PUBKEY,
|
|
OWNED_RELAY_AGENT_PUBKEY,
|
|
]);
|
|
// Kind-0 `name` aliases, distinct from the display name, for exercising the
|
|
// alias-tolerant mention resolution path (e.g. a message that says "@bobby"
|
|
// while bob's display name is "bob").
|
|
const mockKind0Names = new Map<string, string>([[BOB_PUBKEY, "bobby"]]);
|
|
|
|
function isoMinutesAgo(minutesAgo: number): string {
|
|
return new Date(Date.now() - minutesAgo * 60_000).toISOString();
|
|
}
|
|
|
|
function cloneMembers(members: RawChannelMember[]): RawChannelMember[] {
|
|
return members.map((member) => ({ ...member }));
|
|
}
|
|
|
|
function toRawChannel(
|
|
channel: MockChannel,
|
|
config?: E2eConfig,
|
|
): RawChannelWithMembership {
|
|
const currentPubkey = getMockMemberPubkey(config).toLowerCase();
|
|
|
|
return {
|
|
id: channel.id,
|
|
name: channel.name,
|
|
channel_type: channel.channel_type,
|
|
visibility: channel.visibility,
|
|
description: channel.description,
|
|
topic: channel.topic,
|
|
purpose: channel.purpose,
|
|
member_count: channel.member_count,
|
|
member_pubkeys: channel.members.map((member) => member.pubkey),
|
|
last_message_at: channel.last_message_at,
|
|
archived_at: channel.archived_at,
|
|
participants: [...channel.participants],
|
|
participant_pubkeys: [...channel.participant_pubkeys],
|
|
ttl_seconds: channel.ttl_seconds ?? null,
|
|
ttl_deadline: channel.ttl_deadline ?? null,
|
|
is_member: channel.members.some(
|
|
(member) => member.pubkey.toLowerCase() === currentPubkey,
|
|
),
|
|
};
|
|
}
|
|
|
|
function toRawChannelDetail(
|
|
channel: MockChannel,
|
|
config?: E2eConfig,
|
|
): RawChannelDetail {
|
|
return {
|
|
...toRawChannel(channel, config),
|
|
created_by: channel.created_by,
|
|
created_at: channel.created_at,
|
|
updated_at: channel.updated_at,
|
|
topic_set_by: channel.topic_set_by,
|
|
topic_set_at: channel.topic_set_at,
|
|
purpose_set_by: channel.purpose_set_by,
|
|
purpose_set_at: channel.purpose_set_at,
|
|
topic_required: channel.topic_required,
|
|
max_members: channel.max_members,
|
|
nip29_group_id: channel.nip29_group_id,
|
|
};
|
|
}
|
|
|
|
function createMockMember(
|
|
pubkey: string,
|
|
role: RawChannelMember["role"],
|
|
joinedMinutesAgo: number,
|
|
): RawChannelMember {
|
|
return {
|
|
pubkey,
|
|
role,
|
|
is_agent: role === "bot" || mockAgentPubkeys.has(pubkey),
|
|
joined_at: isoMinutesAgo(joinedMinutesAgo),
|
|
display_name: mockDisplayNames.get(pubkey) ?? null,
|
|
};
|
|
}
|
|
|
|
function createMockChannel(
|
|
seed: Omit<
|
|
MockChannel,
|
|
| "created_at"
|
|
| "member_count"
|
|
| "members"
|
|
| "updated_at"
|
|
| "participant_pubkeys"
|
|
| "participants"
|
|
| "ttl_seconds"
|
|
| "ttl_deadline"
|
|
> & {
|
|
created_minutes_ago: number;
|
|
members: RawChannelMember[];
|
|
participant_pubkeys?: string[];
|
|
participants?: string[];
|
|
ttl_seconds?: number | null;
|
|
ttl_deadline?: string | null;
|
|
updated_minutes_ago?: number;
|
|
},
|
|
): MockChannel {
|
|
return {
|
|
...seed,
|
|
created_at: isoMinutesAgo(seed.created_minutes_ago),
|
|
member_count: seed.members.length,
|
|
members: cloneMembers(seed.members),
|
|
participant_pubkeys: [...(seed.participant_pubkeys ?? [])],
|
|
participants: [...(seed.participants ?? [])],
|
|
ttl_seconds: seed.ttl_seconds ?? null,
|
|
ttl_deadline: seed.ttl_deadline ?? null,
|
|
updated_at: isoMinutesAgo(
|
|
seed.updated_minutes_ago ?? seed.created_minutes_ago,
|
|
),
|
|
};
|
|
}
|
|
|
|
function syncMockChannel(channel: MockChannel) {
|
|
channel.member_count = channel.members.length;
|
|
|
|
if (channel.channel_type !== "dm") {
|
|
return;
|
|
}
|
|
|
|
channel.participant_pubkeys = channel.members.map((member) => member.pubkey);
|
|
channel.participants = channel.members.map(
|
|
(member) => member.display_name ?? member.pubkey.slice(0, 8),
|
|
);
|
|
}
|
|
|
|
function touchMockChannel(channel: MockChannel) {
|
|
channel.updated_at = new Date().toISOString();
|
|
}
|
|
|
|
function normalizeParticipantPubkeys(pubkeys: string[]) {
|
|
return [...new Set(pubkeys.map((pubkey) => pubkey.toLowerCase()))].sort();
|
|
}
|
|
|
|
function findMockDmByParticipantPubkeys(pubkeys: string[]) {
|
|
const normalizedPubkeys = normalizeParticipantPubkeys(pubkeys);
|
|
|
|
return (
|
|
mockChannels.find((channel) => {
|
|
if (channel.channel_type !== "dm") {
|
|
return false;
|
|
}
|
|
|
|
const channelPubkeys = normalizeParticipantPubkeys(
|
|
channel.participant_pubkeys,
|
|
);
|
|
|
|
return (
|
|
channelPubkeys.length === normalizedPubkeys.length &&
|
|
channelPubkeys.every(
|
|
(pubkey, index) => pubkey === normalizedPubkeys[index],
|
|
)
|
|
);
|
|
}) ?? null
|
|
);
|
|
}
|
|
|
|
function getMockIdentity() {
|
|
return {
|
|
pubkey: MOCK_IDENTITY_PUBKEY,
|
|
displayName: DEFAULT_MOCK_IDENTITY.display_name,
|
|
};
|
|
}
|
|
|
|
function cloneProfile(profile: RawProfile): RawProfile {
|
|
return { ...profile };
|
|
}
|
|
|
|
function cloneRelayAgent(agent: RawRelayAgent): RawRelayAgent {
|
|
return {
|
|
...agent,
|
|
channels: [...agent.channels],
|
|
channel_ids: [...agent.channel_ids],
|
|
capabilities: [...agent.capabilities],
|
|
};
|
|
}
|
|
|
|
function cloneManagedAgent(agent: MockManagedAgent): RawManagedAgent {
|
|
return {
|
|
pubkey: agent.pubkey,
|
|
name: agent.name,
|
|
persona_id: agent.persona_id,
|
|
runtime: agent.runtime ?? null,
|
|
relay_url: agent.relay_url,
|
|
acp_command: agent.acp_command,
|
|
agent_command: agent.agent_command,
|
|
agent_args: [...agent.agent_args],
|
|
mcp_command: agent.mcp_command,
|
|
turn_timeout_seconds: agent.turn_timeout_seconds,
|
|
idle_timeout_seconds: agent.idle_timeout_seconds ?? null,
|
|
max_turn_duration_seconds: agent.max_turn_duration_seconds ?? null,
|
|
parallelism: agent.parallelism,
|
|
system_prompt: agent.system_prompt,
|
|
avatar_url: agent.avatar_url ?? null,
|
|
model: agent.model,
|
|
provider: agent.provider ?? null,
|
|
env_vars: { ...(agent.env_vars ?? {}) },
|
|
status: agent.status,
|
|
pid: agent.pid,
|
|
created_at: agent.created_at,
|
|
updated_at: agent.updated_at,
|
|
last_started_at: agent.last_started_at,
|
|
last_stopped_at: agent.last_stopped_at,
|
|
last_exit_code: agent.last_exit_code,
|
|
last_error: agent.last_error,
|
|
last_error_code: agent.last_error_code,
|
|
needs_restart: agent.needs_restart ?? false,
|
|
restart_diff: agent.restart_diff ? [...agent.restart_diff] : [],
|
|
log_path: agent.log_path,
|
|
start_on_app_launch: agent.start_on_app_launch,
|
|
auto_restart_on_config_change: agent.auto_restart_on_config_change ?? true,
|
|
backend: agent.backend ?? { type: "local" as const },
|
|
backend_agent_id: agent.backend_agent_id ?? null,
|
|
respond_to: agent.respond_to ?? "owner-only",
|
|
respond_to_allowlist: agent.respond_to_allowlist
|
|
? [...agent.respond_to_allowlist]
|
|
: [],
|
|
};
|
|
}
|
|
|
|
function cloneEngramEntry(entry: RawEngramEntry): RawEngramEntry {
|
|
return {
|
|
...entry,
|
|
outgoingRefs: [...entry.outgoingRefs],
|
|
};
|
|
}
|
|
|
|
function cloneAgentMemoryListing(
|
|
listing: RawAgentMemoryListing,
|
|
): RawAgentMemoryListing {
|
|
return {
|
|
core: listing.core ? cloneEngramEntry(listing.core) : null,
|
|
memories: listing.memories.map(cloneEngramEntry),
|
|
truncated: listing.truncated,
|
|
fetchedAt: listing.fetchedAt,
|
|
};
|
|
}
|
|
|
|
function resetMockRelayMembers(config: E2eConfig | undefined) {
|
|
const pubkey = getMockMemberPubkey(config);
|
|
// Drive the active identity's role from `mock.relayRole` so the e2e harness
|
|
// can exercise the NIP-IA admin gate (owner/admin → true, member/null →
|
|
// false). Default stays `owner` to preserve existing test behavior.
|
|
const role = config?.mock?.relayRole;
|
|
const activeRoleMember =
|
|
role === null
|
|
? null
|
|
: {
|
|
pubkey,
|
|
role: role ?? "owner",
|
|
added_by: null,
|
|
created_at: isoMinutesAgo(120),
|
|
};
|
|
mockRelayMembers = [
|
|
...(activeRoleMember ? [activeRoleMember] : []),
|
|
{
|
|
pubkey: ALICE_PUBKEY,
|
|
role: "admin",
|
|
added_by: pubkey,
|
|
created_at: isoMinutesAgo(90),
|
|
},
|
|
{
|
|
pubkey: BOB_PUBKEY,
|
|
role: "member",
|
|
added_by: pubkey,
|
|
created_at: isoMinutesAgo(60),
|
|
},
|
|
];
|
|
}
|
|
|
|
function buildMockConfigSurface(pubkey: string): {
|
|
runtimeId: string | null;
|
|
runtimeLabel: string | null;
|
|
isPreSpawn: boolean;
|
|
normalized: Record<string, unknown>;
|
|
advanced: unknown[];
|
|
extensions: unknown[];
|
|
sources: Record<string, unknown>;
|
|
} {
|
|
// Goose running — mixed origins, override on model
|
|
// The `writeVia` payloads below are camelCase because that is what the
|
|
// backend emits — pinned by `wire_format_matches_typescript_contract` in
|
|
// `desktop/src-tauri/src/managed_agents/config_bridge/types.rs`. This mock
|
|
// agreed with `api/types.ts` while the real serializer emitted `env_key` /
|
|
// `config_id` / `config_key`, so a test against it certified a contract
|
|
// nothing produced. Change these only alongside that Rust test.
|
|
const gooseSurface = {
|
|
runtimeId: "goose",
|
|
runtimeLabel: "Goose",
|
|
isPreSpawn: false,
|
|
normalized: {
|
|
model: {
|
|
value: "gpt-4o",
|
|
origin: "buzzExplicit",
|
|
overriddenValue: "gpt-4o-mini",
|
|
overriddenOrigin: "configFile",
|
|
isRequired: false,
|
|
writeVia: { type: "readOnly" },
|
|
},
|
|
provider: {
|
|
value: "openai",
|
|
origin: "configFile",
|
|
overriddenValue: null,
|
|
overriddenOrigin: null,
|
|
isRequired: false,
|
|
writeVia: {
|
|
type: "gooseNativeConfigWrite",
|
|
configKey: "goose.provider",
|
|
},
|
|
},
|
|
mode: {
|
|
value: "auto",
|
|
origin: "envVar",
|
|
overriddenValue: null,
|
|
overriddenOrigin: null,
|
|
isRequired: false,
|
|
writeVia: { type: "respawnWithEnvVar", envKey: "GOOSE_MODE" },
|
|
},
|
|
thinkingEffort: {
|
|
value: "medium",
|
|
origin: "configFile",
|
|
overriddenValue: null,
|
|
overriddenOrigin: null,
|
|
isRequired: false,
|
|
writeVia: {
|
|
type: "gooseNativeConfigWrite",
|
|
configKey: "goose.thinkingEffort",
|
|
},
|
|
},
|
|
maxOutputTokens: null,
|
|
contextLimit: null,
|
|
systemPrompt: null,
|
|
},
|
|
advanced: [
|
|
{
|
|
key: "active_provider",
|
|
label: "active_provider",
|
|
value: "openai",
|
|
origin: "configFile",
|
|
schemaType: { type: "string" },
|
|
writeVia: { type: "readOnly" },
|
|
},
|
|
],
|
|
extensions: [
|
|
{ name: "developer", kind: "stdio", enabled: true },
|
|
{ name: "web_search", kind: "stdio", enabled: true },
|
|
{ name: "memory", kind: "stdio", enabled: false },
|
|
],
|
|
sources: {
|
|
acpNative: "available",
|
|
acpConfigOptions: "available",
|
|
envVars: "available",
|
|
configFile: "available",
|
|
configFilePath: "~/.config/goose/config.yaml",
|
|
mcpConfigFilePath: "~/.config/goose/config.yaml",
|
|
},
|
|
};
|
|
|
|
// Claude Code — mostly ACP-sourced
|
|
const claudeSurface = {
|
|
runtimeId: "claude-code",
|
|
runtimeLabel: "Claude Code",
|
|
isPreSpawn: false,
|
|
normalized: {
|
|
model: {
|
|
value: "claude-sonnet-4-20250514",
|
|
origin: "acpConfigOption",
|
|
overriddenValue: null,
|
|
overriddenOrigin: null,
|
|
isRequired: false,
|
|
writeVia: { type: "acpSetConfigOption", configId: "model" },
|
|
},
|
|
provider: {
|
|
value: "anthropic",
|
|
origin: "acpConfigOption",
|
|
overriddenValue: null,
|
|
overriddenOrigin: null,
|
|
isRequired: false,
|
|
writeVia: { type: "acpSetConfigOption", configId: "provider" },
|
|
},
|
|
mode: {
|
|
value: "code",
|
|
origin: "acpConfigOption",
|
|
overriddenValue: null,
|
|
overriddenOrigin: null,
|
|
isRequired: false,
|
|
writeVia: { type: "acpSetConfigOption", configId: "mode" },
|
|
},
|
|
thinkingEffort: {
|
|
value: "high",
|
|
origin: "acpConfigOption",
|
|
overriddenValue: null,
|
|
overriddenOrigin: null,
|
|
isRequired: false,
|
|
writeVia: { type: "acpSetConfigOption", configId: "thinkingEffort" },
|
|
},
|
|
maxOutputTokens: {
|
|
value: "16384",
|
|
origin: "acpConfigOption",
|
|
overriddenValue: null,
|
|
overriddenOrigin: null,
|
|
isRequired: false,
|
|
writeVia: { type: "acpSetConfigOption", configId: "maxOutputTokens" },
|
|
},
|
|
contextLimit: null,
|
|
systemPrompt: null,
|
|
},
|
|
advanced: [],
|
|
extensions: [
|
|
{ name: "filesystem", kind: "mcp", enabled: true },
|
|
{ name: "github", kind: "mcp", enabled: true },
|
|
],
|
|
sources: {
|
|
acpNative: "available",
|
|
acpConfigOptions: "available",
|
|
envVars: "notApplicable",
|
|
configFile: "available",
|
|
configFilePath: "~/.claude/settings.json",
|
|
mcpConfigFilePath: "~/.claude.json",
|
|
},
|
|
};
|
|
|
|
// Pre-spawn — model from config file, ACP fields pending
|
|
const preSpawnSurface = {
|
|
runtimeId: "goose",
|
|
runtimeLabel: "Goose",
|
|
isPreSpawn: true,
|
|
normalized: {
|
|
model: {
|
|
value: "gpt-4o-mini",
|
|
origin: "configFile",
|
|
overriddenValue: null,
|
|
overriddenOrigin: null,
|
|
isRequired: false,
|
|
writeVia: { type: "gooseNativeConfigWrite", configKey: "goose.model" },
|
|
},
|
|
provider: {
|
|
value: "openai",
|
|
origin: "configFile",
|
|
overriddenValue: null,
|
|
overriddenOrigin: null,
|
|
isRequired: false,
|
|
writeVia: {
|
|
type: "gooseNativeConfigWrite",
|
|
configKey: "goose.provider",
|
|
},
|
|
},
|
|
mode: {
|
|
value: null,
|
|
origin: "acpNativeRead",
|
|
overriddenValue: null,
|
|
overriddenOrigin: null,
|
|
isRequired: false,
|
|
writeVia: { type: "readOnly" },
|
|
},
|
|
thinkingEffort: {
|
|
value: null,
|
|
origin: "acpNativeRead",
|
|
overriddenValue: null,
|
|
overriddenOrigin: null,
|
|
isRequired: false,
|
|
writeVia: { type: "readOnly" },
|
|
},
|
|
maxOutputTokens: null,
|
|
contextLimit: null,
|
|
systemPrompt: null,
|
|
},
|
|
advanced: [],
|
|
extensions: [{ name: "developer", kind: "stdio", enabled: true }],
|
|
sources: {
|
|
acpNative: "pending",
|
|
acpConfigOptions: "pending",
|
|
envVars: "available",
|
|
configFile: "available",
|
|
configFilePath: "~/.config/goose/config.yaml",
|
|
mcpConfigFilePath: "~/.config/goose/config.yaml",
|
|
},
|
|
};
|
|
|
|
// Codex — dual-axis mode
|
|
const codexSurface = {
|
|
runtimeId: "codex",
|
|
runtimeLabel: "Codex",
|
|
isPreSpawn: false,
|
|
normalized: {
|
|
model: {
|
|
value: "codex-mini",
|
|
origin: "configFile",
|
|
overriddenValue: null,
|
|
overriddenOrigin: null,
|
|
isRequired: false,
|
|
writeVia: { type: "gooseNativeConfigWrite", configKey: "goose.model" },
|
|
},
|
|
provider: {
|
|
value: "openai",
|
|
origin: "configFile",
|
|
overriddenValue: null,
|
|
overriddenOrigin: null,
|
|
isRequired: false,
|
|
writeVia: {
|
|
type: "gooseNativeConfigWrite",
|
|
configKey: "goose.provider",
|
|
},
|
|
},
|
|
mode: {
|
|
value: "suggest / auto-edit",
|
|
origin: "configFile",
|
|
overriddenValue: null,
|
|
overriddenOrigin: null,
|
|
isRequired: false,
|
|
writeVia: { type: "gooseNativeConfigWrite", configKey: "goose.mode" },
|
|
},
|
|
thinkingEffort: null,
|
|
maxOutputTokens: null,
|
|
contextLimit: null,
|
|
systemPrompt: null,
|
|
},
|
|
advanced: [
|
|
{
|
|
key: "approval_policy",
|
|
label: "Approval Policy",
|
|
value: "unless-allow-listed",
|
|
origin: "configFile",
|
|
schemaType: {
|
|
type: "enum",
|
|
options: ["suggest", "auto-edit", "full-auto", "unless-allow-listed"],
|
|
},
|
|
writeVia: {
|
|
type: "gooseNativeConfigWrite",
|
|
configKey: "goose.approval_policy",
|
|
},
|
|
},
|
|
{
|
|
key: "sandbox_mode",
|
|
label: "Sandbox Mode",
|
|
value: "container",
|
|
origin: "envVar",
|
|
schemaType: {
|
|
type: "enum",
|
|
options: ["container", "host", "none"],
|
|
},
|
|
writeVia: { type: "respawnWithEnvVar", envKey: "GOOSE_SANDBOX_MODE" },
|
|
},
|
|
],
|
|
extensions: [
|
|
{ name: "filesystem", kind: "mcp", enabled: true },
|
|
{ name: "github", kind: "mcp", enabled: true },
|
|
],
|
|
sources: {
|
|
acpNative: "notApplicable",
|
|
acpConfigOptions: "notApplicable",
|
|
envVars: "available",
|
|
configFile: "available",
|
|
configFilePath: "~/.codex/config.toml",
|
|
mcpConfigFilePath: "~/.codex/config.toml",
|
|
},
|
|
};
|
|
|
|
// Live runtime override — a persona-linked agent whose session model was
|
|
// switched at runtime. The live model rides over the persona baseline as a
|
|
// secondary value WITHOUT strikethrough (the headline runtimeOverride render).
|
|
const runtimeOverrideSurface = {
|
|
runtimeId: "goose",
|
|
runtimeLabel: "Goose",
|
|
isPreSpawn: false,
|
|
normalized: {
|
|
model: {
|
|
value: "claude-opus-4-20250514",
|
|
origin: "runtimeOverride",
|
|
overriddenValue: "gpt-4o",
|
|
overriddenOrigin: "personaDefault",
|
|
isRequired: false,
|
|
writeVia: { type: "acpSetSessionModel" },
|
|
},
|
|
provider: {
|
|
value: "anthropic",
|
|
origin: "acpConfigOption",
|
|
overriddenValue: null,
|
|
overriddenOrigin: null,
|
|
isRequired: false,
|
|
writeVia: { type: "acpSetConfigOption", configId: "provider" },
|
|
},
|
|
mode: {
|
|
value: "auto",
|
|
origin: "envVar",
|
|
overriddenValue: null,
|
|
overriddenOrigin: null,
|
|
isRequired: false,
|
|
writeVia: { type: "respawnWithEnvVar", envKey: "GOOSE_MODE" },
|
|
},
|
|
thinkingEffort: {
|
|
value: "high",
|
|
origin: "configFile",
|
|
overriddenValue: null,
|
|
overriddenOrigin: null,
|
|
isRequired: false,
|
|
writeVia: {
|
|
type: "gooseNativeConfigWrite",
|
|
configKey: "goose.thinkingEffort",
|
|
},
|
|
},
|
|
maxOutputTokens: null,
|
|
contextLimit: null,
|
|
systemPrompt: null,
|
|
},
|
|
advanced: [],
|
|
extensions: [{ name: "web_search", kind: "stdio", enabled: true }],
|
|
sources: {
|
|
acpNative: "available",
|
|
acpConfigOptions: "available",
|
|
envVars: "available",
|
|
configFile: "available",
|
|
configFilePath: "~/.config/goose/config.yaml",
|
|
mcpConfigFilePath: "~/.config/goose/config.yaml",
|
|
},
|
|
};
|
|
|
|
// Mixed-provenance showcase — top-level rows carry different origins so the
|
|
// panel witnesses distinct provenance labels in one frame: "Set in Buzz",
|
|
// "Inherited from template", "From config file (...)" and
|
|
// "From environment variable (...)".
|
|
const multiOriginSurface = {
|
|
runtimeId: "goose",
|
|
runtimeLabel: "Goose",
|
|
isPreSpawn: false,
|
|
normalized: {
|
|
model: {
|
|
value: "gpt-4o",
|
|
origin: "buzzExplicit",
|
|
overriddenValue: null,
|
|
overriddenOrigin: null,
|
|
isRequired: false,
|
|
writeVia: { type: "readOnly" },
|
|
},
|
|
provider: {
|
|
value: "openai",
|
|
origin: "personaDefault",
|
|
overriddenValue: null,
|
|
overriddenOrigin: null,
|
|
isRequired: false,
|
|
writeVia: { type: "readOnly" },
|
|
},
|
|
mode: {
|
|
value: "auto",
|
|
origin: "envVar",
|
|
overriddenValue: null,
|
|
overriddenOrigin: null,
|
|
isRequired: false,
|
|
writeVia: { type: "respawnWithEnvVar", envKey: "GOOSE_MODE" },
|
|
},
|
|
thinkingEffort: {
|
|
value: "medium",
|
|
origin: "configFile",
|
|
overriddenValue: null,
|
|
overriddenOrigin: null,
|
|
isRequired: false,
|
|
writeVia: {
|
|
type: "gooseNativeConfigWrite",
|
|
configKey: "goose.thinkingEffort",
|
|
},
|
|
},
|
|
maxOutputTokens: null,
|
|
contextLimit: null,
|
|
systemPrompt: null,
|
|
},
|
|
advanced: [],
|
|
extensions: [],
|
|
sources: {
|
|
acpNative: "available",
|
|
acpConfigOptions: "available",
|
|
envVars: "available",
|
|
configFile: "available",
|
|
configFilePath: "~/.config/goose/config.yaml",
|
|
mcpConfigFilePath: "~/.config/goose/config.yaml",
|
|
},
|
|
};
|
|
|
|
const buzzAgentSurface = {
|
|
...gooseSurface,
|
|
runtimeId: "buzz-agent",
|
|
runtimeLabel: "Buzz Agent",
|
|
advanced: [],
|
|
extensions: [],
|
|
sources: {
|
|
...gooseSurface.sources,
|
|
configFilePath: null,
|
|
mcpConfigFilePath: null,
|
|
},
|
|
};
|
|
|
|
// Map well-known test pubkeys to specific fixtures.
|
|
// Synthetic agents are intentionally not TEST_IDENTITIES.
|
|
const PUBKEY_MULTI_ORIGIN =
|
|
"abc1230000000000000000000000000000000000000000000000000000000def";
|
|
const PUBKEY_BUZZ_AGENT =
|
|
"b0220000000000000000000000000000000000000000000000000000000000a9";
|
|
|
|
switch (pubkey) {
|
|
case ALICE_PUBKEY:
|
|
return claudeSurface;
|
|
case BOB_PUBKEY:
|
|
return preSpawnSurface;
|
|
case CHARLIE_PUBKEY:
|
|
return codexSurface;
|
|
case OUTSIDER_PUBKEY:
|
|
return runtimeOverrideSurface;
|
|
case PUBKEY_MULTI_ORIGIN:
|
|
return multiOriginSurface;
|
|
case PUBKEY_BUZZ_AGENT:
|
|
return buzzAgentSurface;
|
|
default:
|
|
return gooseSurface;
|
|
}
|
|
}
|
|
|
|
function buildSeededManagedAgent(seed: MockManagedAgentSeed): MockManagedAgent {
|
|
const now = new Date().toISOString();
|
|
const status = seed.status ?? "stopped";
|
|
|
|
// Resolve agent_command and agent_args from the well-known default catalog
|
|
// so the fixture mirrors real wire shape. Hardcoding ["acp"] for all runtimes
|
|
// is incorrect: buzz-agent ships with no default args.
|
|
const DEFAULT_RUNTIME_COMMAND: Record<
|
|
string,
|
|
{ command: string; args: string[] }
|
|
> = {
|
|
goose: { command: "goose", args: ["acp"] },
|
|
"buzz-agent": { command: "buzz-agent", args: [] },
|
|
claude: { command: "claude", args: [] },
|
|
codex: { command: "codex", args: [] },
|
|
};
|
|
const catalogEntry = seed.runtime
|
|
? DEFAULT_RUNTIME_COMMAND[seed.runtime]
|
|
: undefined;
|
|
const agentCommand = catalogEntry?.command ?? seed.runtime ?? "goose";
|
|
const agentArgs = catalogEntry?.args ?? ["acp"];
|
|
|
|
return {
|
|
pubkey: seed.pubkey,
|
|
name: seed.name,
|
|
persona_id: seed.personaId ?? null,
|
|
// Native serde always emits this key (`null` when unpinned) — the bridge
|
|
// must mirror the wire shape, not omit the key.
|
|
runtime: seed.runtime ?? null,
|
|
relay_url: DEFAULT_RELAY_WS_URL,
|
|
acp_command: "buzz-acp",
|
|
agent_command: agentCommand,
|
|
agent_args: agentArgs,
|
|
mcp_command: "",
|
|
turn_timeout_seconds: 320,
|
|
idle_timeout_seconds: null,
|
|
max_turn_duration_seconds: null,
|
|
parallelism: 1,
|
|
system_prompt: null,
|
|
avatar_url: seed.avatarUrl ?? null,
|
|
model: null,
|
|
env_vars: { ...(seed.envVars ?? {}) },
|
|
status,
|
|
pid: status === "running" ? 42000 + mockManagedAgents.length : null,
|
|
created_at: now,
|
|
updated_at: now,
|
|
last_started_at: status === "running" ? now : null,
|
|
last_stopped_at: status === "stopped" ? now : null,
|
|
last_exit_code: null,
|
|
last_error: seed.lastError ?? null,
|
|
last_error_code: seed.lastErrorCode ?? null,
|
|
needs_restart: seed.needsRestart ?? false,
|
|
restart_diff: seed.restartDiff ?? [],
|
|
log_path: `/tmp/mock-agent-${seed.pubkey}.log`,
|
|
start_on_app_launch: true,
|
|
auto_restart_on_config_change: seed.autoRestartOnConfigChange ?? true,
|
|
backend: seed.backend ?? { type: "local" },
|
|
backend_agent_id: null,
|
|
respond_to: seed.respondTo ?? "owner-only",
|
|
respond_to_allowlist: seed.respondToAllowlist ?? [],
|
|
private_key_nsec: `nsec1mock${seed.pubkey.slice(0, 20)}`,
|
|
log_lines: [
|
|
`buzz-acp starting: relay=${DEFAULT_RELAY_WS_URL} agent_pubkey=${seed.pubkey} parallelism=1`,
|
|
"profile created; harness not started",
|
|
],
|
|
};
|
|
}
|
|
|
|
function resetMockRelayAgents(config?: E2eConfig) {
|
|
mockRelayAgents = defaultMockRelayAgents.map((agent) => ({
|
|
...agent,
|
|
channels: [...agent.channels],
|
|
channel_ids: [...agent.channel_ids],
|
|
capabilities: [...agent.capabilities],
|
|
respond_to_allowlist: [...(agent.respond_to_allowlist ?? [])],
|
|
}));
|
|
|
|
for (const seed of config?.mock?.relayAgents ?? []) {
|
|
const channels = mockChannels.filter((channel) => {
|
|
return (
|
|
seed.channelIds?.includes(channel.id) ||
|
|
seed.channelNames?.includes(channel.name)
|
|
);
|
|
});
|
|
mockRelayAgents.push({
|
|
pubkey: seed.pubkey,
|
|
owner_pubkey: seed.ownerPubkey ?? null,
|
|
name: seed.name,
|
|
agent_type: seed.agentType ?? "goose",
|
|
channels: channels.map((channel) => channel.name),
|
|
channel_ids: channels.map((channel) => channel.id),
|
|
capabilities: seed.capabilities ?? ["messages", "channels", "mcp"],
|
|
status: seed.status ?? "online",
|
|
respond_to: seed.respondTo ?? "owner-only",
|
|
respond_to_allowlist: seed.respondToAllowlist ?? [],
|
|
});
|
|
}
|
|
}
|
|
|
|
function resetMockManagedAgents(config?: E2eConfig) {
|
|
mockManagedAgents = [];
|
|
mockBestieAssignment = null;
|
|
mockManagedAgentRuntimes = (config?.mock?.managedAgentRuntimes ?? []).map(
|
|
(seed) => ({
|
|
pubkey: seed.pubkey,
|
|
relayUrl: seed.relayUrl,
|
|
localSetup: true,
|
|
lifecycle: seed.lifecycle ?? "ready",
|
|
pid: seed.lifecycle === "stopped" ? null : 43000,
|
|
error: null,
|
|
logPath: null,
|
|
}),
|
|
);
|
|
|
|
for (const seed of config?.mock?.managedAgents ?? []) {
|
|
mockManagedAgents.push(buildSeededManagedAgent(seed));
|
|
applyMockDisplayName(seed.pubkey, seed.name);
|
|
mockAgentPubkeys.add(seed.pubkey);
|
|
mockProfiles.set(seed.pubkey, {
|
|
pubkey: seed.pubkey,
|
|
display_name: seed.name,
|
|
avatar_url: null,
|
|
about: null,
|
|
nip05_handle: null,
|
|
owner_pubkey: MOCK_IDENTITY_PUBKEY,
|
|
is_agent: true,
|
|
has_profile_event: true,
|
|
});
|
|
for (const channel of mockChannels) {
|
|
const isSeedChannel =
|
|
seed.channelIds?.includes(channel.id) ||
|
|
seed.channelNames?.includes(channel.name);
|
|
if (
|
|
!isSeedChannel ||
|
|
channel.members.some((member) => member.pubkey === seed.pubkey)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
channel.members.push({
|
|
pubkey: seed.pubkey,
|
|
role: "bot",
|
|
is_agent: true,
|
|
joined_at: new Date().toISOString(),
|
|
display_name: seed.name,
|
|
});
|
|
syncMockChannel(channel);
|
|
touchMockChannel(channel);
|
|
}
|
|
}
|
|
|
|
syncMockRelayAgentsFromManagedAgents();
|
|
}
|
|
|
|
function resetMockPersonas(config?: E2eConfig) {
|
|
const now = new Date().toISOString();
|
|
const activePersonaIds = new Set(config?.mock?.activePersonaIds ?? []);
|
|
const builtInPersonas = [
|
|
{
|
|
id: "builtin:fizz",
|
|
display_name: "Fizz",
|
|
avatar_url: null,
|
|
system_prompt: "You are Fizz.",
|
|
},
|
|
{
|
|
id: "builtin:honey",
|
|
display_name: "Honey",
|
|
avatar_url: null,
|
|
system_prompt: "You are Honey.",
|
|
},
|
|
{
|
|
id: "builtin:bumble",
|
|
display_name: "Pollen",
|
|
avatar_url: null,
|
|
system_prompt: "You are Pollen.",
|
|
},
|
|
];
|
|
mockPersonas = builtInPersonas.map((persona) => ({
|
|
id: persona.id,
|
|
display_name: persona.display_name,
|
|
avatar_url: persona.avatar_url,
|
|
system_prompt: persona.system_prompt,
|
|
runtime: null,
|
|
model: null,
|
|
provider: null,
|
|
name_pool: [],
|
|
session_policy: "channel",
|
|
is_builtin: true,
|
|
is_active: activePersonaIds.has(persona.id),
|
|
shared: false,
|
|
source_team: null,
|
|
created_at: now,
|
|
updated_at: now,
|
|
}));
|
|
|
|
for (const persona of config?.mock?.personas ?? []) {
|
|
mockPersonas.push({
|
|
id: persona.id ?? crypto.randomUUID(),
|
|
display_name: persona.displayName,
|
|
avatar_url: persona.avatarUrl ?? null,
|
|
system_prompt: persona.systemPrompt,
|
|
runtime: persona.runtime ?? null,
|
|
model: persona.model ?? null,
|
|
provider: persona.provider ?? null,
|
|
name_pool: persona.namePool ?? [],
|
|
respond_to: persona.respondTo ?? null,
|
|
respond_to_allowlist:
|
|
persona.respondTo === "allowlist"
|
|
? [...(persona.respondToAllowlist ?? [])]
|
|
: [],
|
|
session_policy: persona.sessionPolicy ?? "channel",
|
|
is_builtin: false,
|
|
is_active: persona.isActive ?? true,
|
|
shared: persona.shared ?? false,
|
|
source_team: persona.sourceTeam ?? null,
|
|
env_vars: { ...(persona.envVars ?? {}) },
|
|
created_at: now,
|
|
updated_at: persona.updatedAt ?? now,
|
|
});
|
|
}
|
|
}
|
|
|
|
function resetMockTeams(config?: E2eConfig) {
|
|
const now = new Date().toISOString();
|
|
mockTeams = [
|
|
{
|
|
id: "team-engineering-001",
|
|
name: "Engineering",
|
|
description: "Core engineering personas",
|
|
persona_ids: [],
|
|
is_builtin: false,
|
|
source_dir: null,
|
|
is_symlink: false,
|
|
symlink_target: null,
|
|
version: null,
|
|
created_at: now,
|
|
updated_at: now,
|
|
},
|
|
{
|
|
id: "team-research-002",
|
|
name: "Research Agents",
|
|
description: "Directory-backed research team",
|
|
persona_ids: [],
|
|
is_builtin: false,
|
|
source_dir: "/Users/dev/agents/research",
|
|
is_symlink: false,
|
|
symlink_target: null,
|
|
version: "1.2.0",
|
|
created_at: now,
|
|
updated_at: now,
|
|
},
|
|
{
|
|
id: "team-platform-003",
|
|
name: "Platform Tools",
|
|
description: "Symlinked platform team",
|
|
persona_ids: [],
|
|
is_builtin: false,
|
|
source_dir: "/Users/dev/agents/platform",
|
|
is_symlink: true,
|
|
symlink_target: "/opt/shared-teams/platform",
|
|
version: "2.0.1",
|
|
created_at: now,
|
|
updated_at: now,
|
|
},
|
|
];
|
|
|
|
for (const team of config?.mock?.teams ?? []) {
|
|
mockTeams.push({
|
|
id: team.id ?? crypto.randomUUID(),
|
|
name: team.name,
|
|
description: team.description ?? null,
|
|
persona_ids: [...team.personaIds],
|
|
is_builtin: false,
|
|
source_dir: null,
|
|
is_symlink: false,
|
|
symlink_target: null,
|
|
version: null,
|
|
created_at: now,
|
|
updated_at: now,
|
|
});
|
|
}
|
|
}
|
|
|
|
function seedMockSearchProfiles(config?: E2eConfig) {
|
|
for (const seed of config?.mock?.searchProfiles ?? []) {
|
|
const pubkey = seed.pubkey.toLowerCase();
|
|
const profile = {
|
|
pubkey,
|
|
display_name: seed.displayName,
|
|
avatar_url: seed.avatarUrl ?? null,
|
|
about: seed.about ?? null,
|
|
nip05_handle: seed.nip05Handle ?? null,
|
|
owner_pubkey: seed.ownerPubkey ?? null,
|
|
is_agent: seed.isAgent ?? false,
|
|
has_profile_event: true,
|
|
};
|
|
mockProfiles.set(pubkey, profile);
|
|
applyMockDisplayName(pubkey, seed.displayName);
|
|
if (seed.isAgent) {
|
|
mockAgentPubkeys.add(pubkey);
|
|
}
|
|
}
|
|
}
|
|
|
|
function getMockProfileByPubkey(pubkey: string): RawProfile | null {
|
|
const normalizedPubkey = pubkey.toLowerCase();
|
|
const existing = mockProfiles.get(normalizedPubkey);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
|
|
if (!mockDisplayNames.has(normalizedPubkey)) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
pubkey: normalizedPubkey,
|
|
display_name: mockDisplayNames.get(normalizedPubkey) ?? null,
|
|
name: mockKind0Names.get(normalizedPubkey) ?? null,
|
|
avatar_url: null,
|
|
about: null,
|
|
nip05_handle: null,
|
|
owner_pubkey: null,
|
|
is_agent: mockAgentPubkeys.has(normalizedPubkey),
|
|
has_profile_event: true,
|
|
};
|
|
}
|
|
|
|
function listMockProfiles(): RawProfile[] {
|
|
const pubkeys = new Set<string>([
|
|
...mockProfiles.keys(),
|
|
...mockDisplayNames.keys(),
|
|
DEFAULT_REAL_IDENTITY.pubkey,
|
|
]);
|
|
|
|
return [...pubkeys]
|
|
.map((pubkey) => getMockProfileByPubkey(pubkey))
|
|
.filter((profile): profile is RawProfile => profile !== null);
|
|
}
|
|
|
|
function listMockChannels(config?: E2eConfig): RawChannelWithMembership[] {
|
|
return mockChannels.map((channel) => toRawChannel(channel, config));
|
|
}
|
|
|
|
function getMockChannel(channelId: string): MockChannel {
|
|
const channel = mockChannels.find((candidate) => candidate.id === channelId);
|
|
if (!channel) {
|
|
throw new Error(`Channel ${channelId} not found.`);
|
|
}
|
|
|
|
return channel;
|
|
}
|
|
|
|
function getMockMemberPubkey(config: E2eConfig | undefined): string {
|
|
return getActiveIdentity(config)?.pubkey ?? getMockIdentity().pubkey;
|
|
}
|
|
|
|
function getMockMemberDisplayName(config: E2eConfig | undefined): string {
|
|
return getActiveIdentity(config)?.username ?? getMockIdentity().displayName;
|
|
}
|
|
|
|
function createCurrentMember(
|
|
config: E2eConfig | undefined,
|
|
role: RawChannelMember["role"],
|
|
): RawChannelMember {
|
|
return {
|
|
pubkey: getMockMemberPubkey(config),
|
|
role,
|
|
joined_at: new Date().toISOString(),
|
|
display_name: getMockMemberDisplayName(config),
|
|
};
|
|
}
|
|
|
|
const mockChannels: MockChannel[] = [
|
|
createMockChannel({
|
|
id: STARTER_GENERAL_CHANNEL_ID,
|
|
name: STARTER_GENERAL_CHANNEL_NAME,
|
|
channel_type: "stream",
|
|
visibility: "open",
|
|
description: "General discussion for everyone",
|
|
topic: "Company-wide updates",
|
|
purpose: "Coordinate day-to-day work and unblock the team.",
|
|
last_message_at: isoMinutesAgo(5),
|
|
archived_at: null,
|
|
created_by: MOCK_IDENTITY_PUBKEY,
|
|
topic_set_by: MOCK_IDENTITY_PUBKEY,
|
|
topic_set_at: isoMinutesAgo(90),
|
|
purpose_set_by: MOCK_IDENTITY_PUBKEY,
|
|
purpose_set_at: isoMinutesAgo(80),
|
|
topic_required: false,
|
|
max_members: null,
|
|
nip29_group_id: null,
|
|
created_minutes_ago: 1440,
|
|
updated_minutes_ago: 5,
|
|
members: [
|
|
createMockMember(MOCK_IDENTITY_PUBKEY, "owner", 1440),
|
|
createMockMember(ALICE_PUBKEY, "admin", 1200),
|
|
createMockMember(BOB_PUBKEY, "member", 960),
|
|
createMockMember(PROFILE_ONLY_AGENT_PUBKEY, "member", 840),
|
|
],
|
|
}),
|
|
createMockChannel({
|
|
id: STARTER_PROJECT_HOME_CHANNEL_ID,
|
|
name: "buzz",
|
|
channel_type: "stream",
|
|
visibility: "open",
|
|
description: "Project home for the Buzz community platform.",
|
|
topic: null,
|
|
purpose: null,
|
|
last_message_at: null,
|
|
archived_at: null,
|
|
created_by: MOCK_IDENTITY_PUBKEY,
|
|
topic_set_by: null,
|
|
topic_set_at: null,
|
|
purpose_set_by: null,
|
|
purpose_set_at: null,
|
|
topic_required: false,
|
|
max_members: null,
|
|
nip29_group_id: null,
|
|
created_minutes_ago: 1440,
|
|
updated_minutes_ago: 1440,
|
|
members: [createMockMember(MOCK_IDENTITY_PUBKEY, "owner", 1440)],
|
|
}),
|
|
createMockChannel({
|
|
id: STARTER_WELCOME_CHANNEL_ID,
|
|
name: STARTER_WELCOME_CHANNEL_NAME,
|
|
channel_type: "stream",
|
|
visibility: "open",
|
|
description: "Say hi, ask a question, or share what brought you here.",
|
|
topic: null,
|
|
purpose: null,
|
|
last_message_at: null,
|
|
archived_at: null,
|
|
created_by: MOCK_IDENTITY_PUBKEY,
|
|
topic_set_by: null,
|
|
topic_set_at: null,
|
|
purpose_set_by: null,
|
|
purpose_set_at: null,
|
|
topic_required: false,
|
|
max_members: null,
|
|
nip29_group_id: null,
|
|
created_minutes_ago: 1440,
|
|
updated_minutes_ago: 1440,
|
|
members: [createMockMember(MOCK_IDENTITY_PUBKEY, "owner", 1440)],
|
|
}),
|
|
createMockChannel({
|
|
id: "9dae0116-799b-5071-a0a8-fdd30a91a35d",
|
|
name: "random",
|
|
channel_type: "stream",
|
|
visibility: "open",
|
|
description: "Off-topic, fun stuff",
|
|
topic: null,
|
|
purpose: null,
|
|
last_message_at: null,
|
|
archived_at: null,
|
|
created_by: ALICE_PUBKEY,
|
|
topic_set_by: null,
|
|
topic_set_at: null,
|
|
purpose_set_by: null,
|
|
purpose_set_at: null,
|
|
topic_required: false,
|
|
max_members: null,
|
|
nip29_group_id: null,
|
|
created_minutes_ago: 1400,
|
|
updated_minutes_ago: 1400,
|
|
members: [
|
|
createMockMember(ALICE_PUBKEY, "owner", 1400),
|
|
createMockMember(MOCK_IDENTITY_PUBKEY, "member", 1300),
|
|
createMockMember(BOB_PUBKEY, "member", 1000),
|
|
],
|
|
}),
|
|
// Reproduces the all-replies-window regression (NIP-RS Fix A): a busy
|
|
// single-thread channel whose top-level root has scrolled past the history
|
|
// limit. `last_message_at` is a far-future timestamp standing in for the
|
|
// backend's reply-inclusive MAX(created_at) — it is NEWER than any top-level
|
|
// message the window can load, so falling back to it would advance the
|
|
// channel marker past unread replies. Seeded as its own channel so existing
|
|
// channels' unread state is undisturbed.
|
|
createMockChannel({
|
|
id: "fa11bac0-0000-4000-8000-000000000012",
|
|
name: "all-replies",
|
|
channel_type: "stream",
|
|
visibility: "open",
|
|
description: "Single-thread channel with the root past the history limit",
|
|
topic: null,
|
|
purpose: null,
|
|
last_message_at: new Date("2999-01-01T00:00:00.000Z").toISOString(),
|
|
archived_at: null,
|
|
created_by: ALICE_PUBKEY,
|
|
topic_set_by: null,
|
|
topic_set_at: null,
|
|
purpose_set_by: null,
|
|
purpose_set_at: null,
|
|
topic_required: false,
|
|
max_members: null,
|
|
nip29_group_id: null,
|
|
created_minutes_ago: 1400,
|
|
updated_minutes_ago: 1400,
|
|
members: [
|
|
createMockMember(ALICE_PUBKEY, "owner", 1400),
|
|
createMockMember(MOCK_IDENTITY_PUBKEY, "member", 1300),
|
|
],
|
|
}),
|
|
createMockChannel({
|
|
id: "b5e2f8a1-3c44-5912-9e67-4a8d1f2b3c4e",
|
|
name: "design",
|
|
channel_type: "stream",
|
|
visibility: "open",
|
|
description: "Design system and UX discussions with engineering partners",
|
|
topic: null,
|
|
purpose: null,
|
|
last_message_at: isoMinutesAgo(120),
|
|
archived_at: null,
|
|
created_by: ALICE_PUBKEY,
|
|
topic_set_by: null,
|
|
topic_set_at: null,
|
|
purpose_set_by: null,
|
|
purpose_set_at: null,
|
|
topic_required: false,
|
|
max_members: null,
|
|
nip29_group_id: null,
|
|
created_minutes_ago: 1350,
|
|
updated_minutes_ago: 120,
|
|
members: [
|
|
createMockMember(ALICE_PUBKEY, "owner", 1350),
|
|
createMockMember(BOB_PUBKEY, "member", 1100),
|
|
],
|
|
}),
|
|
createMockChannel({
|
|
id: "c6f3a9b2-4d55-5a23-bf78-5b9e2g3c5d6f",
|
|
name: "sales",
|
|
channel_type: "stream",
|
|
visibility: "open",
|
|
description: "Sales team coordination and pipeline updates",
|
|
topic: "Q1 targets",
|
|
purpose: null,
|
|
last_message_at: isoMinutesAgo(30),
|
|
archived_at: null,
|
|
created_by: BOB_PUBKEY,
|
|
topic_set_by: BOB_PUBKEY,
|
|
topic_set_at: isoMinutesAgo(200),
|
|
purpose_set_by: null,
|
|
purpose_set_at: null,
|
|
topic_required: false,
|
|
max_members: null,
|
|
nip29_group_id: null,
|
|
created_minutes_ago: 1300,
|
|
updated_minutes_ago: 30,
|
|
members: [
|
|
createMockMember(BOB_PUBKEY, "owner", 1300),
|
|
createMockMember(CHARLIE_PUBKEY, "member", 900),
|
|
],
|
|
}),
|
|
createMockChannel({
|
|
id: "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9",
|
|
name: "engineering",
|
|
channel_type: "stream",
|
|
visibility: "open",
|
|
description: "Engineering discussions",
|
|
topic: "Desktop release train",
|
|
purpose: "Track implementation details and release readiness.",
|
|
last_message_at: isoMinutesAgo(42),
|
|
archived_at: null,
|
|
created_by: ALICE_PUBKEY,
|
|
topic_set_by: ALICE_PUBKEY,
|
|
topic_set_at: isoMinutesAgo(120),
|
|
purpose_set_by: ALICE_PUBKEY,
|
|
purpose_set_at: isoMinutesAgo(130),
|
|
topic_required: false,
|
|
max_members: null,
|
|
nip29_group_id: null,
|
|
created_minutes_ago: 1320,
|
|
updated_minutes_ago: 42,
|
|
members: [
|
|
createMockMember(ALICE_PUBKEY, "owner", 1320),
|
|
createMockMember(MOCK_IDENTITY_PUBKEY, "member", 1180),
|
|
createMockMember(BOB_PUBKEY, "member", 900),
|
|
],
|
|
}),
|
|
createMockChannel({
|
|
id: "94a444a4-c0a3-5966-ab05-530c6ddc2301",
|
|
name: "agents",
|
|
channel_type: "stream",
|
|
visibility: "open",
|
|
description: "AI agent testing and collaboration",
|
|
topic: "Coordination board",
|
|
purpose: "Track agent work and relay activity.",
|
|
last_message_at: isoMinutesAgo(15),
|
|
archived_at: null,
|
|
created_by: MOCK_IDENTITY_PUBKEY,
|
|
topic_set_by: MOCK_IDENTITY_PUBKEY,
|
|
topic_set_at: isoMinutesAgo(60),
|
|
purpose_set_by: MOCK_IDENTITY_PUBKEY,
|
|
purpose_set_at: isoMinutesAgo(65),
|
|
topic_required: false,
|
|
max_members: null,
|
|
nip29_group_id: null,
|
|
created_minutes_ago: 1000,
|
|
updated_minutes_ago: 15,
|
|
members: [
|
|
createMockMember(MOCK_IDENTITY_PUBKEY, "owner", 1000),
|
|
createMockMember(CHARLIE_PUBKEY, "bot", 800),
|
|
createMockMember(OWNED_RELAY_AGENT_PUBKEY, "member", 600),
|
|
],
|
|
}),
|
|
createMockChannel({
|
|
id: "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11",
|
|
name: "watercooler",
|
|
channel_type: "forum",
|
|
visibility: "open",
|
|
description: "Casual forum for async discussions",
|
|
topic: null,
|
|
purpose: null,
|
|
// Authoritative forum-post recency returned by get_channels. This must come
|
|
// from the native 45001/45003 query, not the stream-only 9/40002 query.
|
|
last_message_at: isoMinutesAgo(10),
|
|
archived_at: null,
|
|
created_by: ALICE_PUBKEY,
|
|
topic_set_by: null,
|
|
topic_set_at: null,
|
|
purpose_set_by: null,
|
|
purpose_set_at: null,
|
|
topic_required: false,
|
|
max_members: null,
|
|
nip29_group_id: null,
|
|
created_minutes_ago: 900,
|
|
updated_minutes_ago: 10,
|
|
members: [
|
|
createMockMember(ALICE_PUBKEY, "owner", 900),
|
|
createMockMember(MOCK_IDENTITY_PUBKEY, "member", 750),
|
|
],
|
|
}),
|
|
createMockChannel({
|
|
id: "1be1dcdb-4c31-5a8c-81de-ac102552ca10",
|
|
name: "announcements",
|
|
channel_type: "forum",
|
|
visibility: "private",
|
|
description: "Company announcements",
|
|
topic: "Leadership updates",
|
|
purpose: "Read-only announcements for the community.",
|
|
last_message_at: null,
|
|
archived_at: null,
|
|
created_by: ALICE_PUBKEY,
|
|
topic_set_by: ALICE_PUBKEY,
|
|
topic_set_at: isoMinutesAgo(200),
|
|
purpose_set_by: ALICE_PUBKEY,
|
|
purpose_set_at: isoMinutesAgo(210),
|
|
topic_required: false,
|
|
max_members: null,
|
|
nip29_group_id: null,
|
|
created_minutes_ago: 880,
|
|
updated_minutes_ago: 200,
|
|
members: [
|
|
createMockMember(ALICE_PUBKEY, "owner", 880),
|
|
createMockMember(MOCK_IDENTITY_PUBKEY, "guest", 700),
|
|
],
|
|
}),
|
|
createMockChannel({
|
|
id: "3c2d9f0a-1b44-5e77-9a21-6f8b0c4d2e91",
|
|
name: "secret-projects",
|
|
channel_type: "stream",
|
|
visibility: "private",
|
|
description: "Private project room",
|
|
topic: "Skunkworks",
|
|
purpose: "Coordinate confidential project work.",
|
|
last_message_at: null,
|
|
archived_at: null,
|
|
created_by: ALICE_PUBKEY,
|
|
topic_set_by: ALICE_PUBKEY,
|
|
topic_set_at: isoMinutesAgo(120),
|
|
purpose_set_by: ALICE_PUBKEY,
|
|
purpose_set_at: isoMinutesAgo(130),
|
|
topic_required: false,
|
|
max_members: null,
|
|
nip29_group_id: null,
|
|
created_minutes_ago: 600,
|
|
updated_minutes_ago: 120,
|
|
members: [
|
|
createMockMember(ALICE_PUBKEY, "owner", 600),
|
|
createMockMember(MOCK_IDENTITY_PUBKEY, "member", 540),
|
|
],
|
|
}),
|
|
createMockChannel({
|
|
id: "f48efb06-0c93-5025-aac9-2e646bb6bfa8",
|
|
name: "alice-tyler",
|
|
channel_type: "dm",
|
|
visibility: "private",
|
|
description: "DM between alice and tyler",
|
|
topic: null,
|
|
purpose: null,
|
|
last_message_at: null,
|
|
archived_at: null,
|
|
created_by: ALICE_PUBKEY,
|
|
topic_set_by: null,
|
|
topic_set_at: null,
|
|
purpose_set_by: null,
|
|
purpose_set_at: null,
|
|
topic_required: false,
|
|
max_members: 2,
|
|
nip29_group_id: null,
|
|
created_minutes_ago: 720,
|
|
updated_minutes_ago: 720,
|
|
participants: ["alice", "tyler"],
|
|
participant_pubkeys: [ALICE_PUBKEY, MOCK_IDENTITY_PUBKEY],
|
|
members: [
|
|
createMockMember(ALICE_PUBKEY, "member", 720),
|
|
createMockMember(MOCK_IDENTITY_PUBKEY, "member", 720),
|
|
],
|
|
}),
|
|
createMockChannel({
|
|
id: "7eb9f239-9393-50b0-bd76-d85eef0511c7",
|
|
name: "bob-tyler",
|
|
channel_type: "dm",
|
|
visibility: "private",
|
|
description: "DM between bob and tyler",
|
|
topic: null,
|
|
purpose: null,
|
|
last_message_at: null,
|
|
archived_at: null,
|
|
created_by: BOB_PUBKEY,
|
|
topic_set_by: null,
|
|
topic_set_at: null,
|
|
purpose_set_by: null,
|
|
purpose_set_at: null,
|
|
topic_required: false,
|
|
max_members: 2,
|
|
nip29_group_id: null,
|
|
created_minutes_ago: 700,
|
|
updated_minutes_ago: 700,
|
|
participants: ["bob", "tyler"],
|
|
participant_pubkeys: [BOB_PUBKEY, MOCK_IDENTITY_PUBKEY],
|
|
members: [
|
|
createMockMember(BOB_PUBKEY, "member", 700),
|
|
createMockMember(MOCK_IDENTITY_PUBKEY, "member", 700),
|
|
],
|
|
}),
|
|
// Generic-named DM — name is "DM" so resolveChannelDisplayLabel must resolve
|
|
// the participant display name instead of returning the raw channel name.
|
|
// Used by agent-snapshot-send.spec.ts to prove picker/search/memgate/done
|
|
// all use the same resolved label from useUsersBatchQuery.
|
|
createMockChannel({
|
|
id: "d1ec7000-d000-4000-8000-000000000001",
|
|
name: "DM",
|
|
channel_type: "dm",
|
|
visibility: "private",
|
|
description: "Generic-named DM with charlie",
|
|
topic: null,
|
|
purpose: null,
|
|
last_message_at: null,
|
|
archived_at: null,
|
|
created_by: CHARLIE_PUBKEY,
|
|
topic_set_by: null,
|
|
topic_set_at: null,
|
|
purpose_set_by: null,
|
|
purpose_set_at: null,
|
|
topic_required: false,
|
|
max_members: 2,
|
|
nip29_group_id: null,
|
|
created_minutes_ago: 680,
|
|
updated_minutes_ago: 680,
|
|
participants: ["charlie", "tyler"],
|
|
participant_pubkeys: [CHARLIE_PUBKEY, MOCK_IDENTITY_PUBKEY],
|
|
members: [
|
|
createMockMember(CHARLIE_PUBKEY, "member", 680),
|
|
createMockMember(MOCK_IDENTITY_PUBKEY, "member", 680),
|
|
],
|
|
}),
|
|
// Generic-named Group DM — name "Group DM (3)" so resolveChannelDisplayLabel
|
|
// must resolve all OTHER participants' display names (bob, charlie).
|
|
// Used by agent-snapshot-send.spec.ts group-DM label test.
|
|
// NOTE: participants are BOB + CHARLIE (not ALICE, which conflicts with
|
|
// ANALYST_PUBKEY in managed-agent tests).
|
|
createMockChannel({
|
|
id: "d1ec7000-d000-4000-8000-000000000003",
|
|
name: "Group DM (3)",
|
|
channel_type: "dm",
|
|
visibility: "private",
|
|
description: "Generic-named group DM with bob and charlie",
|
|
topic: null,
|
|
purpose: null,
|
|
last_message_at: null,
|
|
archived_at: null,
|
|
created_by: BOB_PUBKEY,
|
|
topic_set_by: null,
|
|
topic_set_at: null,
|
|
purpose_set_by: null,
|
|
purpose_set_at: null,
|
|
topic_required: false,
|
|
max_members: 3,
|
|
nip29_group_id: null,
|
|
created_minutes_ago: 660,
|
|
updated_minutes_ago: 660,
|
|
participants: ["bob", "charlie", "tyler"],
|
|
participant_pubkeys: [BOB_PUBKEY, CHARLIE_PUBKEY, MOCK_IDENTITY_PUBKEY],
|
|
members: [
|
|
createMockMember(BOB_PUBKEY, "member", 660),
|
|
createMockMember(CHARLIE_PUBKEY, "member", 660),
|
|
createMockMember(MOCK_IDENTITY_PUBKEY, "member", 660),
|
|
],
|
|
}),
|
|
// Deep history channel for the load-older-under-virtualization E2E. Seeded
|
|
// with more messages than CHANNEL_HISTORY_LIMIT (300) so the initial load
|
|
// windows to the newest page and a `fetchOlder` (until-cursor) prepend has
|
|
// genuinely older rows to add — exercising the scroll-restore anchor under
|
|
// virtualization. Its own channel so existing channels' row-index and unread
|
|
// assertions stay undisturbed.
|
|
createMockChannel({
|
|
id: "feedf00d-0000-4000-8000-000000000007",
|
|
name: "deep-history",
|
|
channel_type: "stream",
|
|
visibility: "open",
|
|
description: "Channel with paginated history for load-older tests",
|
|
topic: null,
|
|
purpose: null,
|
|
last_message_at: isoMinutesAgo(1),
|
|
archived_at: null,
|
|
created_by: ALICE_PUBKEY,
|
|
topic_set_by: null,
|
|
topic_set_at: null,
|
|
purpose_set_by: null,
|
|
purpose_set_at: null,
|
|
topic_required: false,
|
|
max_members: null,
|
|
nip29_group_id: null,
|
|
created_minutes_ago: 2000,
|
|
updated_minutes_ago: 1,
|
|
members: [
|
|
createMockMember(ALICE_PUBKEY, "owner", 2000),
|
|
createMockMember(MOCK_IDENTITY_PUBKEY, "member", 1900),
|
|
],
|
|
}),
|
|
];
|
|
|
|
const mockMessages = new Map<string, RelayEvent[]>();
|
|
const deferredSendMessageLiveEchoes: Array<{
|
|
channelId: string;
|
|
event: RelayEvent;
|
|
}> = [];
|
|
const mockUserStatuses: RelayEvent[] = [];
|
|
const mockReminderEvents: RelayEvent[] = [];
|
|
const mockPersonaEvents: RelayEvent[] = [];
|
|
const mockTeamCatalogEvents: RelayEvent[] = [];
|
|
let mockRelayMembers: RawRelayMember[] = [];
|
|
const mockSockets = new Map<number, MockSocket>();
|
|
const mockAuthResponses: Array<{ success: boolean; message: string }> = [];
|
|
const mockChannelHistoryCloses: string[] = [];
|
|
let mockWebsocketUnavailable = false;
|
|
const relayWebsocketConnectAttemptStarts: number[] = [];
|
|
let mockAuthSigningAttempts = 0;
|
|
let mockWebsocketSendMutexWedged = false;
|
|
let mockClosedChannelLiveSubscription = false;
|
|
const realSockets = new Map<number, WebSocket>();
|
|
let mockManagedAgents: MockManagedAgent[] = [];
|
|
let mockManagedAgentRuntimes: MockManagedAgentRuntimeRow[] = [];
|
|
let mockBestieAssignment: {
|
|
agent_pubkey: string;
|
|
} | null = null;
|
|
|
|
// Mutable `save_subscriptions` table mirror — TEST-ONLY.
|
|
//
|
|
// Cloned from `activeConfig.mock.saveSubscriptions` at install time, then
|
|
// mutated by `create_save_subscription` / `delete_save_subscription` /
|
|
// `merge_save_subscription_kinds` / `remove_save_subscription_kind` exactly
|
|
// as the real SQLite-backed Rust commands would (see `archive/store.rs`).
|
|
// This lets E2E specs drive the default-on seeding path (start from `[]`,
|
|
// reconcile, observe a kind-24200 row appear) and toggle ON/OFF, neither
|
|
// of which an immutable seed can represent.
|
|
type MockSaveSubscriptionRow = {
|
|
scope_type: string;
|
|
scope_value: string;
|
|
kinds: string; // JSON-encoded integer array, e.g. "[9,40002]"
|
|
};
|
|
let mockSaveSubscriptions: MockSaveSubscriptionRow[] = [];
|
|
|
|
type MockObservedUnreadScope = {
|
|
generation: string;
|
|
revision: number;
|
|
lastSequence: number;
|
|
migrationComplete: boolean;
|
|
events: Map<
|
|
string,
|
|
{
|
|
channelId: string;
|
|
id: string;
|
|
createdAt: number;
|
|
rootId: string | null;
|
|
highPriority: boolean;
|
|
countsTowardBadge: boolean;
|
|
countsTowardAppBadge: boolean;
|
|
}
|
|
>;
|
|
channelLatest: Map<string, number>;
|
|
markers: Map<string, number>;
|
|
};
|
|
|
|
const mockObservedUnreadScopes = new Map<string, MockObservedUnreadScope>();
|
|
|
|
function mockObservedUnreadScopeKey(scope: {
|
|
pubkey: string;
|
|
relayUrl: string;
|
|
}) {
|
|
return `${scope.pubkey.trim().toLowerCase()}:${scope.relayUrl
|
|
.trim()
|
|
.replace(/\/+$/, "")}`;
|
|
}
|
|
|
|
function getMockObservedUnreadScope(scope: {
|
|
pubkey: string;
|
|
relayUrl: string;
|
|
}) {
|
|
const key = mockObservedUnreadScopeKey(scope);
|
|
const existing = mockObservedUnreadScopes.get(key);
|
|
if (existing) return existing;
|
|
const created: MockObservedUnreadScope = {
|
|
generation: "e2e",
|
|
revision: 0,
|
|
lastSequence: 0,
|
|
migrationComplete: false,
|
|
events: new Map(),
|
|
channelLatest: new Map(),
|
|
markers: new Map(),
|
|
};
|
|
mockObservedUnreadScopes.set(key, created);
|
|
return created;
|
|
}
|
|
|
|
function mockObservedUnreadProjections(
|
|
scope: MockObservedUnreadScope,
|
|
): ObservedUnreadProjection[] {
|
|
const channels = new Map<string, ObservedUnreadProjection>();
|
|
for (const [channelId, latest] of scope.channelLatest) {
|
|
channels.set(channelId, {
|
|
channelId,
|
|
latest,
|
|
count: 0,
|
|
badgeCount: 0,
|
|
appBadgeCount: 0,
|
|
topLevelUnread: false,
|
|
highPriorityCount: 0,
|
|
});
|
|
}
|
|
for (const event of scope.events.values()) {
|
|
let readAt = Math.max(
|
|
scope.markers.get(event.channelId) ?? 0,
|
|
scope.markers.get(`msg:${event.id}`) ?? 0,
|
|
);
|
|
if (event.rootId) {
|
|
readAt = Math.max(
|
|
readAt,
|
|
scope.markers.get(`thread:${event.rootId}`) ?? 0,
|
|
);
|
|
}
|
|
if (event.createdAt <= readAt) continue;
|
|
const projection = channels.get(event.channelId) ?? {
|
|
channelId: event.channelId,
|
|
latest: 0,
|
|
count: 0,
|
|
badgeCount: 0,
|
|
appBadgeCount: 0,
|
|
topLevelUnread: false,
|
|
highPriorityCount: 0,
|
|
};
|
|
projection.latest = Math.max(projection.latest, event.createdAt);
|
|
projection.count += 1;
|
|
projection.badgeCount += event.countsTowardBadge ? 1 : 0;
|
|
projection.appBadgeCount += event.countsTowardAppBadge ? 1 : 0;
|
|
projection.topLevelUnread ||= event.rootId === null;
|
|
projection.highPriorityCount += event.highPriority ? 1 : 0;
|
|
channels.set(event.channelId, projection);
|
|
}
|
|
return [...channels.values()].sort((left, right) =>
|
|
left.channelId.localeCompare(right.channelId),
|
|
);
|
|
}
|
|
|
|
function resetMockObservedUnread() {
|
|
mockObservedUnreadScopes.clear();
|
|
}
|
|
|
|
function resetMockSaveSubscriptions(config: E2eConfig | undefined) {
|
|
mockSaveSubscriptions = (config?.mock?.saveSubscriptions ?? []).map((s) => ({
|
|
...s,
|
|
}));
|
|
}
|
|
|
|
function resetMockPersonaCatalogEvents(config: E2eConfig | undefined) {
|
|
mockPersonaEvents.length = 0;
|
|
for (const event of config?.mock?.personaCatalogEvents ?? []) {
|
|
mockPersonaEvents.push({
|
|
...event,
|
|
tags: event.tags.map((tag) => [...tag]),
|
|
});
|
|
}
|
|
}
|
|
|
|
function mockPersonaCatalogPublications() {
|
|
const publications = [];
|
|
const claimed = new Set<string>();
|
|
for (const event of [...mockPersonaEvents].sort(
|
|
(a, b) => b.created_at - a.created_at || a.id.localeCompare(b.id),
|
|
)) {
|
|
const dTags = event.tags.filter((tag) => tag[0] === "d");
|
|
if (dTags.length !== 1 || !dTags[0]?.[1]) continue;
|
|
const sourcePersonaId = dTags[0][1];
|
|
const ownerPubkey = event.pubkey.toLowerCase();
|
|
const coordinate = `${ownerPubkey}:${sourcePersonaId}`;
|
|
if (claimed.has(coordinate)) continue;
|
|
claimed.add(coordinate);
|
|
if (!hasExactSharedTag(event)) continue;
|
|
let content: Record<string, unknown>;
|
|
try {
|
|
content = JSON.parse(event.content) as Record<string, unknown>;
|
|
} catch {
|
|
continue;
|
|
}
|
|
const displayName = content.display_name;
|
|
const systemPrompt = content.system_prompt ?? "";
|
|
const optionalString = (value: unknown) =>
|
|
typeof value === "string" && value.trim() ? value : null;
|
|
const isExtendedPictographic = (value: string) =>
|
|
/^\p{Extended_Pictographic}$/u.test(value);
|
|
const hasValidVisibleText = (value: string, allowLayout: boolean) => {
|
|
const characters = [...value];
|
|
return characters.every((character, index) => {
|
|
if (allowLayout && (character === "\n" || character === "\t"))
|
|
return true;
|
|
if (/\p{Control}/u.test(character)) return false;
|
|
const codepoint = character.codePointAt(0) ?? 0;
|
|
const defaultIgnorable =
|
|
codepoint === 0x00ad ||
|
|
codepoint === 0x034f ||
|
|
codepoint === 0x061c ||
|
|
(codepoint >= 0x115f && codepoint <= 0x1160) ||
|
|
(codepoint >= 0x17b4 && codepoint <= 0x17b5) ||
|
|
(codepoint >= 0x180b && codepoint <= 0x180f) ||
|
|
(codepoint >= 0x200b && codepoint <= 0x200f) ||
|
|
(codepoint >= 0x202a && codepoint <= 0x202e) ||
|
|
(codepoint >= 0x2060 && codepoint <= 0x206f) ||
|
|
codepoint === 0x3164 ||
|
|
(codepoint >= 0xfe00 && codepoint <= 0xfe0f) ||
|
|
codepoint === 0xfeff ||
|
|
codepoint === 0xffa0 ||
|
|
(codepoint >= 0xfff0 && codepoint <= 0xfff8) ||
|
|
(codepoint >= 0x1bca0 && codepoint <= 0x1bca3) ||
|
|
(codepoint >= 0x1d173 && codepoint <= 0x1d17a) ||
|
|
(codepoint >= 0xe0000 && codepoint <= 0xe0fff);
|
|
if (!defaultIgnorable) return true;
|
|
if (character === "\ufe0f") {
|
|
const previous = characters[index - 1];
|
|
return (
|
|
previous !== undefined &&
|
|
(/^[#*0-9]$/u.test(previous) || isExtendedPictographic(previous))
|
|
);
|
|
}
|
|
if (character !== "\u200d") return false;
|
|
let previousIndex = index - 1;
|
|
while (
|
|
previousIndex >= 0 &&
|
|
(characters[previousIndex] === "\ufe0f" ||
|
|
/[\u{1f3fb}-\u{1f3ff}]/u.test(characters[previousIndex] ?? ""))
|
|
) {
|
|
previousIndex -= 1;
|
|
}
|
|
return (
|
|
previousIndex >= 0 &&
|
|
isExtendedPictographic(characters[previousIndex] ?? "") &&
|
|
isExtendedPictographic(characters[index + 1] ?? "")
|
|
);
|
|
});
|
|
};
|
|
const rawDescription = content.description;
|
|
const sessionPolicy =
|
|
content.session_policy === "thread" ? "thread" : "channel";
|
|
if (
|
|
typeof displayName !== "string" ||
|
|
!displayName.trim() ||
|
|
[...displayName].length > 128 ||
|
|
typeof systemPrompt !== "string" ||
|
|
new TextEncoder().encode(systemPrompt).length > 64 * 1024 ||
|
|
!hasValidVisibleText(displayName, false) ||
|
|
!hasValidVisibleText(systemPrompt, true) ||
|
|
(rawDescription !== undefined &&
|
|
rawDescription !== null &&
|
|
typeof rawDescription !== "string") ||
|
|
(typeof rawDescription === "string" &&
|
|
([...rawDescription].length > 280 ||
|
|
!hasValidVisibleText(rawDescription, false)))
|
|
)
|
|
continue;
|
|
publications.push({
|
|
eventId: event.id,
|
|
ownerPubkey,
|
|
sourcePersonaId,
|
|
createdAt: event.created_at,
|
|
agent: {
|
|
displayName,
|
|
avatarUrl: optionalString(content.avatar_url),
|
|
description: optionalString(rawDescription),
|
|
systemPrompt,
|
|
runtime: optionalString(content.runtime),
|
|
model: optionalString(content.model),
|
|
provider: optionalString(content.provider),
|
|
namePool: Array.isArray(content.name_pool)
|
|
? content.name_pool.filter(
|
|
(value): value is string => typeof value === "string",
|
|
)
|
|
: [],
|
|
respondTo:
|
|
content.respond_to === "allowlist"
|
|
? "owner-only"
|
|
: content.respond_to === "owner-only" ||
|
|
content.respond_to === "anyone"
|
|
? content.respond_to
|
|
: null,
|
|
parallelism:
|
|
typeof content.parallelism === "number" ? content.parallelism : null,
|
|
sessionPolicy,
|
|
},
|
|
});
|
|
}
|
|
return publications;
|
|
}
|
|
|
|
function resetMockTeamCatalogEvents(config: E2eConfig | undefined) {
|
|
mockTeamCatalogEvents.length = 0;
|
|
for (const event of config?.mock?.teamCatalogEvents ?? []) {
|
|
mockTeamCatalogEvents.push({
|
|
...event,
|
|
tags: event.tags.map((tag) => [...tag]),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Mirrors the head-selection half of `fetch_team_catalog` (team_catalog.rs):
|
|
// NIP-33 head selection per coordinate and the exact `shared` gate. A
|
|
// coordinate is claimed before the shared/parse checks so an unshared or
|
|
// malformed newest head cannot resurrect an older shared one. Content parsing
|
|
// here is a shallow shape check (`v`, `name`, `members` is an array), not the
|
|
// native per-member validation — production trust rests on the Rust command.
|
|
function mockTeamCatalogPublications() {
|
|
const publications = [];
|
|
const claimed = new Set<string>();
|
|
for (const event of [...mockTeamCatalogEvents].sort(
|
|
(a, b) => b.created_at - a.created_at || a.id.localeCompare(b.id),
|
|
)) {
|
|
const dTags = event.tags.filter((tag) => tag[0] === "d");
|
|
if (dTags.length !== 1 || !dTags[0]?.[1]) continue;
|
|
const teamDTag = dTags[0][1];
|
|
const ownerPubkey = event.pubkey.toLowerCase();
|
|
const coordinate = `${ownerPubkey}:${teamDTag}`;
|
|
if (claimed.has(coordinate)) continue;
|
|
claimed.add(coordinate);
|
|
if (!hasExactSharedTag(event)) continue;
|
|
let content: Record<string, unknown>;
|
|
try {
|
|
content = JSON.parse(event.content) as Record<string, unknown>;
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (content.v !== 1 || typeof content.name !== "string") continue;
|
|
if (!Array.isArray(content.members)) continue;
|
|
const optionalString = (value: unknown) =>
|
|
typeof value === "string" && value.trim() ? value : null;
|
|
publications.push({
|
|
eventId: event.id,
|
|
ownerPubkey,
|
|
teamDTag,
|
|
name: content.name,
|
|
description: optionalString(content.description),
|
|
instructions: optionalString(content.instructions),
|
|
members: content.members.map((member) => {
|
|
const record = member as Record<string, unknown>;
|
|
return {
|
|
memberKey: record.member_key,
|
|
displayName: record.display_name,
|
|
systemPrompt:
|
|
typeof record.system_prompt === "string"
|
|
? record.system_prompt
|
|
: "",
|
|
avatarUrl: optionalString(record.avatar_url),
|
|
runtime: optionalString(record.runtime),
|
|
model: optionalString(record.model),
|
|
provider: optionalString(record.provider),
|
|
};
|
|
}),
|
|
});
|
|
}
|
|
return publications;
|
|
}
|
|
|
|
// Mesh-compute mock state — TEST-ONLY.
|
|
//
|
|
// This entire module (e2eBridge.ts) is loaded only when `window.__BUZZ_E2E__`
|
|
// is set by the Playwright harness; it never runs in a shipped build. These
|
|
// handlers stub the `mesh_*` Tauri commands with the SHAPES the UI expects
|
|
// (availability, node status, preset) so the desktop UI flow can be exercised
|
|
// in a browser. They deliberately do NOT model real admission, real inference,
|
|
// or real mesh routing — those are proven by the Rust layer-2 tests and the
|
|
// on-hardware layer-1 example. Do not port any of this into production code.
|
|
type MockServingUsage = {
|
|
inflight: number;
|
|
peakInflight: number;
|
|
requestsServed: number;
|
|
tokensServed: number;
|
|
tokensPerSecond: number;
|
|
localAttempts: number;
|
|
remoteAttempts: number;
|
|
endpointAttempts: number;
|
|
peers: number;
|
|
};
|
|
|
|
const ZERO_SERVING_USAGE: MockServingUsage = {
|
|
inflight: 0,
|
|
peakInflight: 0,
|
|
requestsServed: 0,
|
|
tokensServed: 0,
|
|
tokensPerSecond: 0,
|
|
localAttempts: 0,
|
|
remoteAttempts: 0,
|
|
endpointAttempts: 0,
|
|
peers: 0,
|
|
};
|
|
|
|
const mockMeshState: {
|
|
admitted: boolean;
|
|
models: Array<{ id: string; name: string | null }>;
|
|
activeModel: { id: string; name: string | null } | null;
|
|
denyReason: string;
|
|
nodeState: "off" | "running";
|
|
nodeMode: "serve" | "client" | null;
|
|
servingUsage: MockServingUsage;
|
|
} = {
|
|
admitted: true,
|
|
models: [{ id: "Gemma-4-E4B-it-Q4_K_M", name: "Gemma 4 E4B" }],
|
|
activeModel: null,
|
|
denyReason: "not a relay member",
|
|
nodeState: "off",
|
|
nodeMode: null,
|
|
servingUsage: { ...ZERO_SERVING_USAGE },
|
|
};
|
|
|
|
function resetMockMesh() {
|
|
mockMeshState.admitted = true;
|
|
mockMeshState.models = [{ id: "Gemma-4-E4B-it-Q4_K_M", name: "Gemma 4 E4B" }];
|
|
mockMeshState.activeModel = null;
|
|
mockMeshState.denyReason = "not a relay member";
|
|
mockMeshState.nodeState = "off";
|
|
mockMeshState.nodeMode = null;
|
|
mockMeshState.servingUsage = { ...ZERO_SERVING_USAGE };
|
|
}
|
|
let mockPersonas: RawPersona[] = [];
|
|
let mockTeams: RawTeam[] = [];
|
|
|
|
type MockHuddleState = {
|
|
phase: "creating" | "connected" | "active";
|
|
parent_channel_id: string;
|
|
ephemeral_channel_id: string;
|
|
huddle_thread_event_id: string | null;
|
|
participants: string[];
|
|
agent_pubkeys: string[];
|
|
agent_voice_settings: Record<string, { enabled: boolean; voice_key: string }>;
|
|
tts_enabled: boolean;
|
|
transcription_enabled: boolean;
|
|
is_creator: boolean;
|
|
voice_input_mode: "push_to_talk" | "voice_activity";
|
|
};
|
|
|
|
type PersistedMockHuddle = {
|
|
members: MockHuddleMemberSeed[];
|
|
state: MockHuddleState;
|
|
};
|
|
|
|
const MOCK_HUDDLE_STORAGE_KEY = "buzz.e2e.mock-huddle.v1";
|
|
let mockHuddle: PersistedMockHuddle | null = null;
|
|
|
|
function persistMockHuddle() {
|
|
if (mockHuddle) {
|
|
window.sessionStorage.setItem(
|
|
MOCK_HUDDLE_STORAGE_KEY,
|
|
JSON.stringify(mockHuddle),
|
|
);
|
|
}
|
|
}
|
|
|
|
async function emitMockHuddleState() {
|
|
if (!mockHuddle) return;
|
|
await emit("huddle-state-changed", structuredClone(mockHuddle.state));
|
|
}
|
|
|
|
const MOCK_POCKET_VOICE_KEYS = [
|
|
"pocket:anna",
|
|
"pocket:vera",
|
|
"pocket:fantine",
|
|
"pocket:charles",
|
|
"pocket:paul",
|
|
"pocket:eponine",
|
|
"pocket:azelma",
|
|
"pocket:george",
|
|
"pocket:mary",
|
|
"pocket:jane",
|
|
"pocket:michael",
|
|
"pocket:eve",
|
|
];
|
|
|
|
function refreshMockHuddleMembership(config?: E2eConfig | null) {
|
|
if (!mockHuddle) return;
|
|
mockHuddle.state.agent_pubkeys = mockHuddle.members
|
|
.filter((member) => member.role === "bot")
|
|
.map((member) => member.pubkey);
|
|
mockHuddle.state.participants = mockHuddle.members.map(
|
|
(member) => member.pubkey,
|
|
);
|
|
mockHuddle.state.agent_voice_settings ??= {};
|
|
const agentSet = new Set(mockHuddle.state.agent_pubkeys);
|
|
for (const pubkey of Object.keys(mockHuddle.state.agent_voice_settings)) {
|
|
if (!agentSet.has(pubkey)) {
|
|
delete mockHuddle.state.agent_voice_settings[pubkey];
|
|
}
|
|
}
|
|
const defaultVoice =
|
|
config?.mock?.ttsSettings?.voicePreferences.find((key) =>
|
|
key.startsWith("pocket:"),
|
|
) ?? "pocket:mary";
|
|
const used = new Set(
|
|
Object.values(mockHuddle.state.agent_voice_settings).map(
|
|
(settings) => settings.voice_key,
|
|
),
|
|
);
|
|
mockHuddle.state.agent_pubkeys.forEach((pubkey, index) => {
|
|
if (mockHuddle?.state.agent_voice_settings[pubkey]) return;
|
|
const voiceKey =
|
|
index === 0 && !used.has(defaultVoice)
|
|
? defaultVoice
|
|
: (MOCK_POCKET_VOICE_KEYS.find(
|
|
(key) => key !== defaultVoice && !used.has(key),
|
|
) ?? defaultVoice);
|
|
used.add(voiceKey);
|
|
if (mockHuddle) {
|
|
mockHuddle.state.agent_voice_settings[pubkey] = {
|
|
enabled: true,
|
|
voice_key: voiceKey,
|
|
};
|
|
}
|
|
});
|
|
}
|
|
|
|
function initializeMockHuddle(
|
|
seed: MockHuddleSeed | undefined,
|
|
config?: E2eConfig,
|
|
) {
|
|
mockHuddle = null;
|
|
if (!seed) {
|
|
window.sessionStorage.removeItem(MOCK_HUDDLE_STORAGE_KEY);
|
|
return;
|
|
}
|
|
|
|
const persisted = window.sessionStorage.getItem(MOCK_HUDDLE_STORAGE_KEY);
|
|
if (persisted) {
|
|
try {
|
|
mockHuddle = JSON.parse(persisted) as PersistedMockHuddle;
|
|
} catch {
|
|
window.sessionStorage.removeItem(MOCK_HUDDLE_STORAGE_KEY);
|
|
}
|
|
}
|
|
|
|
if (!mockHuddle) {
|
|
mockHuddle = {
|
|
members: structuredClone(seed.members),
|
|
state: {
|
|
phase: seed.phase ?? "active",
|
|
parent_channel_id: seed.parentChannelId,
|
|
ephemeral_channel_id: seed.ephemeralChannelId,
|
|
huddle_thread_event_id: seed.huddleThreadEventId ?? null,
|
|
participants: [],
|
|
agent_pubkeys: [],
|
|
agent_voice_settings: {},
|
|
tts_enabled: seed.ttsEnabled ?? false,
|
|
transcription_enabled: seed.transcriptionEnabled ?? false,
|
|
is_creator: seed.isCreator ?? true,
|
|
voice_input_mode: "push_to_talk",
|
|
},
|
|
};
|
|
}
|
|
if (!mockChannels.some((channel) => channel.id === seed.ephemeralChannelId)) {
|
|
const owner = createCurrentMember(config, "owner");
|
|
mockChannels.push(
|
|
createMockChannel({
|
|
id: seed.ephemeralChannelId,
|
|
name: "huddle",
|
|
channel_type: "stream",
|
|
visibility: "private",
|
|
description: "",
|
|
topic: null,
|
|
purpose: null,
|
|
last_message_at: null,
|
|
archived_at: null,
|
|
created_by: owner.pubkey,
|
|
topic_set_by: null,
|
|
topic_set_at: null,
|
|
purpose_set_by: null,
|
|
purpose_set_at: null,
|
|
topic_required: false,
|
|
max_members: null,
|
|
nip29_group_id: null,
|
|
ttl_seconds: 3_600,
|
|
created_minutes_ago: 0,
|
|
members: [owner],
|
|
}),
|
|
);
|
|
}
|
|
refreshMockHuddleMembership(config);
|
|
persistMockHuddle();
|
|
}
|
|
const openedExternalUrls: string[] = [];
|
|
const defaultMockRelayAgents: RawRelayAgent[] = [
|
|
{
|
|
pubkey: ALICE_PUBKEY,
|
|
name: "alice",
|
|
agent_type: "goose",
|
|
channels: ["general", "agents"],
|
|
channel_ids: [
|
|
"9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
|
|
"94a444a4-c0a3-5966-ab05-530c6ddc2301",
|
|
],
|
|
capabilities: ["search", "summaries", "workflows"],
|
|
status: "online",
|
|
respond_to: "anyone",
|
|
respond_to_allowlist: [],
|
|
},
|
|
{
|
|
pubkey: CHARLIE_PUBKEY,
|
|
name: "charlie",
|
|
agent_type: "codex",
|
|
channels: ["general"],
|
|
channel_ids: ["9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"],
|
|
capabilities: ["code", "reviews"],
|
|
status: "away",
|
|
respond_to: "anyone",
|
|
respond_to_allowlist: [],
|
|
},
|
|
];
|
|
let mockRelayAgents: RawRelayAgent[] = defaultMockRelayAgents.map((agent) => ({
|
|
...agent,
|
|
channels: [...agent.channels],
|
|
channel_ids: [...agent.channel_ids],
|
|
capabilities: [...agent.capabilities],
|
|
respond_to_allowlist: [...(agent.respond_to_allowlist ?? [])],
|
|
}));
|
|
|
|
// ── Workflow mocks ─────────────────────────────────────────────────────────
|
|
|
|
type MockWorkflow = {
|
|
id: string;
|
|
revision: string;
|
|
name: string;
|
|
owner_pubkey: string;
|
|
channel_id: string | null;
|
|
definition: Record<string, unknown>;
|
|
status: "active" | "disabled" | "archived";
|
|
created_at: number;
|
|
updated_at: number;
|
|
};
|
|
|
|
type RawWorkflowTraceEntry = {
|
|
step_id: string;
|
|
status: string;
|
|
output?: Record<string, unknown>;
|
|
started_at?: number | null;
|
|
completed_at?: number | null;
|
|
error?: string | null;
|
|
};
|
|
|
|
type RawWorkflowRun = {
|
|
id: string;
|
|
workflow_id: string;
|
|
status:
|
|
| "pending"
|
|
| "running"
|
|
| "completed"
|
|
| "failed"
|
|
| "cancelled"
|
|
| "waiting_approval";
|
|
current_step: number | null;
|
|
execution_trace: RawWorkflowTraceEntry[];
|
|
started_at: number | null;
|
|
completed_at: number | null;
|
|
error_code: string | null;
|
|
error_message: string | null;
|
|
created_at: number;
|
|
};
|
|
|
|
const mockWorkflows: MockWorkflow[] = [];
|
|
let mockWorkflowRuns: RawWorkflowRun[] = [];
|
|
let mockWorkflowIdCounter = 0;
|
|
|
|
function resetMockWorkflows() {
|
|
mockWorkflows.length = 0;
|
|
mockWorkflowRuns = [];
|
|
mockWorkflowIdCounter = 0;
|
|
}
|
|
|
|
function parseWorkflowDefinition(
|
|
yamlDefinition: string,
|
|
): Record<string, unknown> {
|
|
const parsed = yamlParse(yamlDefinition);
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
throw new Error("Workflow definition must be a YAML object");
|
|
}
|
|
return parsed as Record<string, unknown>;
|
|
}
|
|
|
|
function handleGetChannelWorkflows(args: { channelId: string }) {
|
|
return mockWorkflows.filter((w) => w.channel_id === args.channelId);
|
|
}
|
|
|
|
function handleGetChannelsWorkflows(args: { channelIds: string[] }) {
|
|
const ids = new Set(args.channelIds);
|
|
return mockWorkflows.filter(
|
|
(w) => w.channel_id != null && ids.has(w.channel_id),
|
|
);
|
|
}
|
|
|
|
function handleGetWorkflow(args: { workflowId: string }) {
|
|
const workflow = mockWorkflows.find((w) => w.id === args.workflowId);
|
|
if (!workflow) throw new Error(`Workflow ${args.workflowId} not found`);
|
|
return workflow;
|
|
}
|
|
|
|
function handleCreateWorkflow(args: {
|
|
channelId: string;
|
|
yamlDefinition: string;
|
|
}) {
|
|
mockWorkflowIdCounter += 1;
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const definition = parseWorkflowDefinition(args.yamlDefinition);
|
|
const name =
|
|
typeof definition.name === "string"
|
|
? definition.name
|
|
: `workflow_${mockWorkflowIdCounter}`;
|
|
const workflow: MockWorkflow = {
|
|
id: `mock-wf-${mockWorkflowIdCounter}`,
|
|
revision: `mock-revision-${mockWorkflowIdCounter}-1`,
|
|
name,
|
|
owner_pubkey: MOCK_IDENTITY_PUBKEY,
|
|
channel_id: args.channelId,
|
|
definition,
|
|
status: "active",
|
|
created_at: now,
|
|
updated_at: now,
|
|
};
|
|
mockWorkflows.push(workflow);
|
|
|
|
const trigger = definition.trigger as Record<string, unknown> | undefined;
|
|
return {
|
|
...workflow,
|
|
webhook_secret:
|
|
trigger?.on === "webhook"
|
|
? `mock-webhook-secret-${mockWorkflowIdCounter}`
|
|
: undefined,
|
|
};
|
|
}
|
|
|
|
function handleUpdateWorkflow(args: {
|
|
workflowId: string;
|
|
yamlDefinition: string;
|
|
expectedRevision: string;
|
|
}) {
|
|
const workflow = mockWorkflows.find((w) => w.id === args.workflowId);
|
|
if (!workflow) throw new Error(`Workflow ${args.workflowId} not found`);
|
|
const configuredError = window.__BUZZ_E2E__?.mock?.workflowUpdateError;
|
|
if (configuredError) throw new Error(configuredError);
|
|
if (workflow.revision !== args.expectedRevision) {
|
|
throw new Error(
|
|
"workflow changed since it was loaded; refresh and try again",
|
|
);
|
|
}
|
|
const definition = parseWorkflowDefinition(args.yamlDefinition);
|
|
if (typeof definition.name === "string") workflow.name = definition.name;
|
|
workflow.definition = definition;
|
|
workflow.updated_at = Math.floor(Date.now() / 1000);
|
|
workflow.revision = `mock-revision-${workflow.id}-${workflow.updated_at}-${Math.random()}`;
|
|
|
|
const trigger = definition.trigger as Record<string, unknown> | undefined;
|
|
return {
|
|
...workflow,
|
|
webhook_secret:
|
|
trigger?.on === "webhook"
|
|
? `mock-webhook-secret-${workflow.id}`
|
|
: undefined,
|
|
};
|
|
}
|
|
|
|
function handleDeleteWorkflow(args: { workflowId: string }) {
|
|
const configuredError = window.__BUZZ_E2E__?.mock?.workflowDeleteError;
|
|
if (configuredError) throw new Error(configuredError);
|
|
const index = mockWorkflows.findIndex((w) => w.id === args.workflowId);
|
|
if (index === -1) throw new Error(`Workflow ${args.workflowId} not found`);
|
|
mockWorkflows.splice(index, 1);
|
|
mockWorkflowRuns = mockWorkflowRuns.filter(
|
|
(run) => run.workflow_id !== args.workflowId,
|
|
);
|
|
}
|
|
|
|
function buildMockWorkflowRun(workflow: MockWorkflow): RawWorkflowRun {
|
|
const createdAt = Math.floor(Date.now() / 1000);
|
|
const rawSteps = Array.isArray(workflow.definition.steps)
|
|
? workflow.definition.steps
|
|
: [];
|
|
const executionTrace = rawSteps.map((candidate, index) => {
|
|
const step =
|
|
candidate && typeof candidate === "object"
|
|
? (candidate as Record<string, unknown>)
|
|
: {};
|
|
const startedAt = createdAt + index;
|
|
const completedAt = startedAt + 1;
|
|
const output: Record<string, unknown> = {};
|
|
|
|
if (typeof step.action === "string") {
|
|
output.action = step.action;
|
|
}
|
|
if (typeof step.name === "string" && step.name.trim().length > 0) {
|
|
output.name = step.name;
|
|
}
|
|
if (typeof step.text === "string" && step.text.trim().length > 0) {
|
|
output.preview = step.text;
|
|
}
|
|
|
|
return {
|
|
step_id:
|
|
typeof step.id === "string" && step.id.trim().length > 0
|
|
? step.id
|
|
: `step_${index + 1}`,
|
|
status: "completed",
|
|
output,
|
|
started_at: startedAt,
|
|
completed_at: completedAt,
|
|
error: null,
|
|
};
|
|
});
|
|
|
|
const startedAt =
|
|
executionTrace.length > 0
|
|
? (executionTrace[0].started_at ?? createdAt)
|
|
: createdAt;
|
|
const lastTraceEntry = executionTrace[executionTrace.length - 1];
|
|
const completedAt =
|
|
executionTrace.length > 0
|
|
? (lastTraceEntry?.completed_at ?? createdAt)
|
|
: createdAt;
|
|
|
|
return {
|
|
id: `mock-run-${Date.now()}`,
|
|
workflow_id: workflow.id,
|
|
status: "completed",
|
|
current_step: null,
|
|
execution_trace: executionTrace,
|
|
started_at: startedAt,
|
|
completed_at: completedAt,
|
|
error_code: null,
|
|
error_message: null,
|
|
created_at: createdAt,
|
|
};
|
|
}
|
|
|
|
function handleTriggerWorkflow(args: { workflowId: string }) {
|
|
const workflow = mockWorkflows.find((w) => w.id === args.workflowId);
|
|
if (!workflow) throw new Error(`Workflow ${args.workflowId} not found`);
|
|
const configuredError = window.__BUZZ_E2E__?.mock?.workflowTriggerError;
|
|
if (configuredError) throw new Error(configuredError);
|
|
const run = buildMockWorkflowRun(workflow);
|
|
mockWorkflowRuns = [run, ...mockWorkflowRuns];
|
|
return {
|
|
run_id: run.id,
|
|
workflow_id: workflow.id,
|
|
status: run.status,
|
|
};
|
|
}
|
|
|
|
function handleGetWorkflowRuns(args: {
|
|
limit?: number | null;
|
|
workflowId: string;
|
|
}) {
|
|
const runs = mockWorkflowRuns.filter(
|
|
(run) => run.workflow_id === args.workflowId,
|
|
);
|
|
return {
|
|
runs: args.limit ? runs.slice(0, args.limit) : runs,
|
|
next: null,
|
|
};
|
|
}
|
|
|
|
function handleGetRunApprovals(_args: { workflowId: string; runId: string }) {
|
|
return { approvals: [] };
|
|
}
|
|
|
|
const mockProfiles = new Map<string, RawProfile>([
|
|
[
|
|
MOCK_IDENTITY_PUBKEY,
|
|
{
|
|
pubkey: MOCK_IDENTITY_PUBKEY,
|
|
display_name: DEFAULT_MOCK_IDENTITY.display_name,
|
|
avatar_url: null,
|
|
about: null,
|
|
nip05_handle: null,
|
|
owner_pubkey: null,
|
|
is_agent: false,
|
|
has_profile_event: true,
|
|
},
|
|
],
|
|
// alice, bob, and charlie are intentionally NOT seeded here — they are
|
|
// covered by mockDisplayNames + mockAgentPubkeys and synthesised on demand
|
|
// by getMockProfileByPubkey. Static seeds would cause ensureMockProfile to
|
|
// return has_profile_event:true when alice/bob/charlie are used as the
|
|
// active first-run identity, incorrectly skipping onboarding page 1.
|
|
[
|
|
PROFILE_ONLY_AGENT_PUBKEY,
|
|
{
|
|
pubkey: PROFILE_ONLY_AGENT_PUBKEY,
|
|
display_name: "mira",
|
|
avatar_url: null,
|
|
about: null,
|
|
nip05_handle: null,
|
|
owner_pubkey: MOCK_IDENTITY_PUBKEY,
|
|
is_agent: true,
|
|
has_profile_event: true,
|
|
},
|
|
],
|
|
[
|
|
OWNED_RELAY_AGENT_PUBKEY,
|
|
{
|
|
pubkey: OWNED_RELAY_AGENT_PUBKEY,
|
|
display_name: "nadia",
|
|
avatar_url: null,
|
|
about: null,
|
|
nip05_handle: null,
|
|
owner_pubkey: MOCK_IDENTITY_PUBKEY,
|
|
is_agent: true,
|
|
has_profile_event: true,
|
|
},
|
|
],
|
|
]);
|
|
const mockPresence = new Map<string, PresenceStatus>([
|
|
[MOCK_IDENTITY_PUBKEY, "offline"],
|
|
[DEFAULT_REAL_IDENTITY.pubkey, "offline"],
|
|
[ALICE_PUBKEY, "online"],
|
|
[BOB_PUBKEY, "away"],
|
|
[CHARLIE_PUBKEY, "online"],
|
|
[PROFILE_ONLY_AGENT_PUBKEY, "online"],
|
|
[OWNED_RELAY_AGENT_PUBKEY, "online"],
|
|
[OUTSIDER_PUBKEY, "offline"],
|
|
[MULTI_WORD_NON_MEMBER_PUBKEY, "offline"],
|
|
]);
|
|
const mockFeedOverrides: RawHomeFeedResponse["feed"] = {
|
|
mentions: [],
|
|
needs_action: [],
|
|
activity: [],
|
|
agent_activity: [],
|
|
};
|
|
|
|
let installed = false;
|
|
let directPanelRoot: Root | null = null;
|
|
let directPanelContainer: HTMLDivElement | null = null;
|
|
let directPanelQueryClient: QueryClient | null = null;
|
|
let nextSocketId = 1;
|
|
|
|
function syncMockRelayAgentsFromManagedAgents() {
|
|
const baseAgents = mockRelayAgents.filter(
|
|
(agent) =>
|
|
!mockManagedAgents.some((managed) => managed.pubkey === agent.pubkey),
|
|
);
|
|
const managedAgentsAsRelay: RawRelayAgent[] = mockManagedAgents.map(
|
|
(agent) => {
|
|
const memberships = getManagedAgentRelayMembership(agent.pubkey);
|
|
|
|
return {
|
|
pubkey: agent.pubkey,
|
|
name: agent.name,
|
|
agent_type: agent.agent_command,
|
|
channels: memberships.channels,
|
|
channel_ids: memberships.channelIds,
|
|
capabilities: ["messages", "channels", "mcp"],
|
|
status:
|
|
agent.status === "running" || agent.status === "deployed"
|
|
? "online"
|
|
: "offline",
|
|
respond_to: agent.respond_to,
|
|
respond_to_allowlist: [...agent.respond_to_allowlist],
|
|
};
|
|
},
|
|
);
|
|
|
|
// Owned discovery includes nonmembers, but membership must come from the
|
|
// actual roster, including additions and newly created DMs.
|
|
for (const agent of baseAgents) {
|
|
if (agent.owner_pubkey !== MOCK_IDENTITY_PUBKEY) continue;
|
|
const membership = getManagedAgentRelayMembership(agent.pubkey);
|
|
agent.channel_ids = membership.channelIds;
|
|
agent.channels = membership.channels;
|
|
}
|
|
mockRelayAgents = [...baseAgents, ...managedAgentsAsRelay];
|
|
}
|
|
|
|
function getManagedAgentRelayMembership(pubkey: string) {
|
|
const memberships = mockChannels.filter((channel) =>
|
|
channel.members.some((member) => member.pubkey === pubkey),
|
|
);
|
|
|
|
return {
|
|
channelIds: memberships.map((channel) => channel.id),
|
|
channels: memberships.map((channel) => channel.name),
|
|
};
|
|
}
|
|
|
|
function getConfig(): E2eConfig | undefined {
|
|
return window.__BUZZ_E2E__;
|
|
}
|
|
|
|
function readStoredIdentityOverride(): TestIdentity | undefined {
|
|
try {
|
|
const rawValue = window.localStorage.getItem(
|
|
E2E_IDENTITY_OVERRIDE_STORAGE_KEY,
|
|
);
|
|
if (!rawValue) {
|
|
return undefined;
|
|
}
|
|
|
|
const parsed = JSON.parse(rawValue);
|
|
if (
|
|
!parsed ||
|
|
typeof parsed !== "object" ||
|
|
typeof parsed.privateKey !== "string" ||
|
|
typeof parsed.pubkey !== "string" ||
|
|
typeof parsed.username !== "string"
|
|
) {
|
|
return undefined;
|
|
}
|
|
|
|
return {
|
|
privateKey: parsed.privateKey,
|
|
pubkey: parsed.pubkey,
|
|
username: parsed.username,
|
|
};
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function writeStoredIdentityOverride(identity: TestIdentity) {
|
|
window.localStorage.setItem(
|
|
E2E_IDENTITY_OVERRIDE_STORAGE_KEY,
|
|
JSON.stringify(identity),
|
|
);
|
|
}
|
|
|
|
function importMockIdentity(nsec: string) {
|
|
const decoded = decode(nsec.trim());
|
|
if (decoded.type !== "nsec") {
|
|
throw new Error("Invalid Nostr private key.");
|
|
}
|
|
|
|
const privateKey = bytesToHex(decoded.data);
|
|
const pubkey = getPublicKey(decoded.data);
|
|
const username = mockDisplayNames.get(pubkey) ?? "";
|
|
const identity = {
|
|
privateKey,
|
|
pubkey,
|
|
username,
|
|
};
|
|
writeStoredIdentityOverride(identity);
|
|
if (!mockProfiles.has(pubkey)) {
|
|
mockProfiles.set(pubkey, {
|
|
pubkey,
|
|
display_name: username || null,
|
|
avatar_url: null,
|
|
about: null,
|
|
nip05_handle: null,
|
|
owner_pubkey: null,
|
|
// A non-empty username means this identity is registered in
|
|
// mockDisplayNames — it has a real mock relay profile (kind:0).
|
|
// A truly new identity (no username) has no event yet.
|
|
has_profile_event: username.length > 0,
|
|
});
|
|
}
|
|
|
|
return {
|
|
pubkey,
|
|
display_name: username,
|
|
};
|
|
}
|
|
|
|
function isRelayMode(config: E2eConfig | undefined): boolean {
|
|
return config?.mode === "relay";
|
|
}
|
|
|
|
function getRelayHttpUrl(config: E2eConfig | undefined): string {
|
|
return config?.relayHttpUrl ?? DEFAULT_RELAY_HTTP_URL;
|
|
}
|
|
|
|
function getRelayWsUrl(config: E2eConfig | undefined): string {
|
|
return config?.relayWsUrl ?? DEFAULT_RELAY_WS_URL;
|
|
}
|
|
|
|
/**
|
|
* Mirror of the backend's `assert_expected_relay_scope`: a caller-captured
|
|
* tenant scope must still match the active community when the command runs.
|
|
* The mock's "active relay" is the active community's relayUrl in
|
|
* localStorage (specs switch communities by rewriting it), falling back to
|
|
* the configured mock relay. Lets specs drive the mid-flight community
|
|
* switch with `openDmDelayMs` / `sendMessageDelayMs` and prove the send
|
|
* fails closed.
|
|
*/
|
|
function assertExpectedRelayScope(
|
|
expectedRelayUrl: string | null | undefined,
|
|
config: E2eConfig | undefined,
|
|
): void {
|
|
const expected = expectedRelayUrl?.trim();
|
|
if (!expected) return;
|
|
let active: string | null = null;
|
|
try {
|
|
const activeId = window.localStorage.getItem("buzz-active-community-id");
|
|
const communities = JSON.parse(
|
|
window.localStorage.getItem("buzz-communities") ?? "[]",
|
|
) as { id: string; relayUrl: string }[];
|
|
active =
|
|
communities.find((community) => community.id === activeId)?.relayUrl ??
|
|
null;
|
|
} catch {
|
|
active = null;
|
|
}
|
|
if (
|
|
normalizeMockRelayUrl(active ?? getRelayWsUrl(config)) !==
|
|
normalizeMockRelayUrl(expected)
|
|
) {
|
|
throw new Error(
|
|
"active community changed before the message was submitted; not sent",
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Same ws(s) normalization the app applies to community relay URLs. */
|
|
function normalizeMockRelayUrl(url: string): string {
|
|
if (!url.startsWith("ws://") && !url.startsWith("wss://")) {
|
|
return `wss://${url}`;
|
|
}
|
|
return url;
|
|
}
|
|
|
|
/**
|
|
* Mirror of the backend's `assert_expected_signer`: a caller-captured signer
|
|
* identity must still match the active identity when the command runs. The
|
|
* real backend reads relay and keys under separate locks, so the signer scope
|
|
* is asserted independently of the relay scope.
|
|
*/
|
|
function assertExpectedSigner(
|
|
expectedSignerPubkey: string | null | undefined,
|
|
config: E2eConfig | undefined,
|
|
): void {
|
|
const expected = expectedSignerPubkey?.trim();
|
|
if (!expected) return;
|
|
const active = getMockMemberPubkey(config);
|
|
if (expected.toLowerCase() !== active.toLowerCase()) {
|
|
throw new Error(
|
|
"active identity changed before the message was submitted; not sent",
|
|
);
|
|
}
|
|
}
|
|
|
|
function getIdentity(config: E2eConfig | undefined): TestIdentity | undefined {
|
|
if (!isRelayMode(config)) {
|
|
return undefined;
|
|
}
|
|
|
|
return config?.identity ?? DEFAULT_REAL_IDENTITY;
|
|
}
|
|
|
|
function getActiveIdentity(config: E2eConfig | undefined) {
|
|
return readStoredIdentityOverride() ?? getIdentity(config);
|
|
}
|
|
|
|
function ensureMockProfile(config: E2eConfig | undefined): RawProfile {
|
|
const pubkey = getMockMemberPubkey(config);
|
|
const existing = mockProfiles.get(pubkey);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
|
|
const displayName = getMockMemberDisplayName(config);
|
|
const profile = {
|
|
pubkey,
|
|
display_name: displayName,
|
|
avatar_url: null,
|
|
about: null,
|
|
nip05_handle: null,
|
|
owner_pubkey: null,
|
|
// Synthesised fallback: no kind:0 event exists on the relay for this
|
|
// identity. Always false regardless of display name so the onboarding
|
|
// gate cannot mistake a blank first-run identity for a returning user.
|
|
has_profile_event: false,
|
|
};
|
|
mockProfiles.set(pubkey, profile);
|
|
return profile;
|
|
}
|
|
|
|
function applyMockDisplayName(pubkey: string, displayName: string | null) {
|
|
if (displayName) {
|
|
mockDisplayNames.set(pubkey, displayName);
|
|
} else {
|
|
mockDisplayNames.delete(pubkey);
|
|
}
|
|
|
|
for (const channel of mockChannels) {
|
|
for (const member of channel.members) {
|
|
if (member.pubkey === pubkey) {
|
|
member.display_name = displayName;
|
|
}
|
|
}
|
|
syncMockChannel(channel);
|
|
}
|
|
}
|
|
|
|
function getMockPresenceStatus(pubkey: string): PresenceStatus {
|
|
return mockPresence.get(pubkey.toLowerCase()) ?? "offline";
|
|
}
|
|
|
|
function setMockPresenceStatus(pubkey: string, status: PresenceStatus) {
|
|
mockPresence.set(pubkey.toLowerCase(), status);
|
|
}
|
|
|
|
function resolveHandler(handler: unknown): WsHandler {
|
|
const deliver = resolveRawHandler(handler);
|
|
// The native plugin coalesces inbound frames and always delivers an array
|
|
// (native_websocket.rs `FrameBatch::flush`). Emitting bare frames here would
|
|
// green the e2e suites against a transport shape production no longer sends.
|
|
return (message) => deliver([message]);
|
|
}
|
|
|
|
function resolveRawHandler(handler: unknown): WsHandler {
|
|
if (typeof handler === "function") {
|
|
return handler as WsHandler;
|
|
}
|
|
|
|
if (
|
|
typeof handler === "object" &&
|
|
handler !== null &&
|
|
"onmessage" in handler &&
|
|
typeof handler.onmessage === "function"
|
|
) {
|
|
return handler.onmessage as WsHandler;
|
|
}
|
|
|
|
throw new Error("Invalid websocket message handler.");
|
|
}
|
|
|
|
function sendWsText(handler: WsHandler, payload: unknown[]) {
|
|
handler({
|
|
type: "Text",
|
|
data: JSON.stringify(payload),
|
|
});
|
|
}
|
|
|
|
function sendWsClose(handler: WsHandler, code?: number, reason?: string) {
|
|
handler({
|
|
type: "Close",
|
|
data: code === undefined ? undefined : { code, reason: reason ?? "" },
|
|
});
|
|
}
|
|
|
|
function getChannelIdFromTags(tags: string[][]): string | undefined {
|
|
return tags.find((tag) => tag[0] === "h")?.[1];
|
|
}
|
|
|
|
function getThreadReferenceFromTags(tags: string[][]) {
|
|
const eventTags = tags.filter(
|
|
(tag) => tag[0] === "e" && typeof tag[1] === "string",
|
|
);
|
|
|
|
if (eventTags.length === 0) {
|
|
return {
|
|
parentEventId: null,
|
|
rootEventId: null,
|
|
};
|
|
}
|
|
|
|
const rootTag = eventTags.find((tag) => tag[3] === "root");
|
|
const replyTag =
|
|
[...eventTags].reverse().find((tag) => tag[3] === "reply") ?? null;
|
|
|
|
if (!replyTag) {
|
|
return {
|
|
parentEventId: null,
|
|
rootEventId: null,
|
|
};
|
|
}
|
|
|
|
return {
|
|
parentEventId: replyTag[1] ?? null,
|
|
rootEventId: rootTag?.[1] ?? replyTag[1] ?? null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A reply broadcast to the channel timeline carries the exact tag
|
|
* `["broadcast", "1"]` (NIP-CW §Top-level Classification).
|
|
*/
|
|
function isMockBroadcastReply(tags: string[][]): boolean {
|
|
return tags.some((tag) => tag[0] === "broadcast" && tag[1] === "1");
|
|
}
|
|
|
|
/**
|
|
* Mirror the relay's channel-window row set (buzz-db `thread.rs`, NIP-CW
|
|
* §Top-level Classification): an event is a timeline row iff its depth is 0
|
|
* (no reply marker → `rootEventId === null`) OR its depth is 1 (its parent is
|
|
* the thread root) AND it is broadcast. Depth ≥ 2 replies never surface on the
|
|
* timeline. A bare-`rootEventId === null` predicate silently dropped broadcast
|
|
* depth-1 replies the real relay serves.
|
|
*/
|
|
function isMockTopLevelRow(event: RelayEvent): boolean {
|
|
const { parentEventId, rootEventId } = getThreadReferenceFromTags(event.tags);
|
|
if (rootEventId === null) {
|
|
return true;
|
|
}
|
|
const isDepthOne = parentEventId !== null && parentEventId === rootEventId;
|
|
return isDepthOne && isMockBroadcastReply(event.tags);
|
|
}
|
|
|
|
function appendMentionTags(
|
|
tags: string[][],
|
|
mentionPubkeys: string[] | undefined,
|
|
selfPubkey: string,
|
|
) {
|
|
const selfLower = selfPubkey.toLowerCase();
|
|
const seen = new Set<string>([selfLower]);
|
|
for (const pk of mentionPubkeys ?? []) {
|
|
const lower = pk.toLowerCase();
|
|
if (seen.has(lower)) {
|
|
continue;
|
|
}
|
|
seen.add(lower);
|
|
tags.push(["p", lower]);
|
|
}
|
|
}
|
|
|
|
function buildTopLevelMessageTags(
|
|
channelId: string,
|
|
mentionPubkeys: string[] | undefined,
|
|
selfPubkey: string,
|
|
) {
|
|
const tags: string[][] = [["h", channelId]];
|
|
appendMentionTags(tags, mentionPubkeys, selfPubkey);
|
|
return tags;
|
|
}
|
|
|
|
function buildReplyMessageTags(
|
|
channelId: string,
|
|
authorPubkey: string,
|
|
parentEventId: string,
|
|
rootEventId: string,
|
|
mentionPubkeys: string[] | undefined,
|
|
) {
|
|
// Preserve the reply tag ordering that the desktop message hooks already
|
|
// expect locally: author p, h, mention ps, then thread e-tags.
|
|
const tags: string[][] = [
|
|
["p", authorPubkey],
|
|
["h", channelId],
|
|
];
|
|
appendMentionTags(tags, mentionPubkeys, authorPubkey);
|
|
|
|
if (parentEventId === rootEventId) {
|
|
tags.push(["e", rootEventId, "", "reply"]);
|
|
return tags;
|
|
}
|
|
|
|
tags.push(["e", rootEventId, "", "root"]);
|
|
tags.push(["e", parentEventId, "", "reply"]);
|
|
return tags;
|
|
}
|
|
|
|
function getMockMessageStore(channelId: string): RelayEvent[] {
|
|
const existing = mockMessages.get(channelId);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
|
|
const seeded: RelayEvent[] =
|
|
channelId === "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"
|
|
? [
|
|
{
|
|
id: "mock-general-welcome",
|
|
pubkey: DEFAULT_MOCK_IDENTITY.pubkey,
|
|
created_at: Math.floor(Date.now() / 1000) - 120,
|
|
kind: 9,
|
|
tags: [["h", channelId]],
|
|
content: "Welcome to #general",
|
|
sig: "mocksig".repeat(20).slice(0, 128),
|
|
},
|
|
// Alice authored — gives e2e specs a non-self profile pane to open
|
|
// by clicking the second message-row's author button. Used by
|
|
// tests/e2e/identity-archive.spec.ts to exercise the admin / OA /
|
|
// none-of-the-above branches of the NIP-IA gate. Both seeds are
|
|
// backdated (welcome at -120s, Alice at -60s) so user-sent messages
|
|
// in other specs always land after both — preserving
|
|
// `message-row.first()` = welcome and `.last()` = sent.
|
|
{
|
|
id: "mock-general-alice",
|
|
pubkey: ALICE_PUBKEY,
|
|
created_at: Math.floor(Date.now() / 1000) - 60,
|
|
kind: 9,
|
|
tags: [["h", channelId]],
|
|
content: "Hey team — checking in.",
|
|
sig: "mocksig".repeat(20).slice(0, 128),
|
|
},
|
|
// Reaction-target seed for the custom-emoji reaction guard. Real
|
|
// 64-hex id so getReactionTargetId() accepts it as a reaction target
|
|
// (the short-id seeds above can't be reacted to). Backdated after the
|
|
// other seeds, so it stays at row index >= 2 and never displaces
|
|
// first()=welcome / nth(1)=alice that other specs rely on.
|
|
{
|
|
id: REACTION_TARGET_EVENT_ID,
|
|
pubkey: ALICE_PUBKEY,
|
|
created_at: Math.floor(Date.now() / 1000) - 45,
|
|
kind: 9,
|
|
tags: [["h", channelId]],
|
|
content: REACTION_TARGET_CONTENT,
|
|
sig: "mocksig".repeat(20).slice(0, 128),
|
|
},
|
|
// System-message reaction target. A kind:40099 join event renders via
|
|
// SystemMessageRow (testid `system-message-row`, NOT `message-row`),
|
|
// so it never displaces the `message-row` index assertions other
|
|
// specs rely on. Real 64-hex id so getReactionTargetId() accepts it
|
|
// as a reaction target — this is the surface the original "react to a
|
|
// system message" bug lived on. Backdated like the other seeds.
|
|
{
|
|
id: SYSTEM_REACTION_TARGET_EVENT_ID,
|
|
pubkey: ALICE_PUBKEY,
|
|
created_at: Math.floor(Date.now() / 1000) - 30,
|
|
kind: KIND_SYSTEM_MESSAGE,
|
|
tags: [["h", channelId]],
|
|
content: JSON.stringify({
|
|
type: "member_joined",
|
|
actor: ALICE_PUBKEY,
|
|
target: ALICE_PUBKEY,
|
|
}),
|
|
sig: "mocksig".repeat(20).slice(0, 128),
|
|
},
|
|
]
|
|
: channelId === "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11"
|
|
? [
|
|
{
|
|
id: "mock-forum-release-thread",
|
|
pubkey:
|
|
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f",
|
|
created_at: Math.floor(Date.now() / 1000) - 90 * 60,
|
|
kind: 45001,
|
|
tags: [["h", channelId]],
|
|
content: "Release checklist: async feedback thread.",
|
|
sig: "mocksig".repeat(20).slice(0, 128),
|
|
},
|
|
{
|
|
id: "mock-forum-offsite-thread",
|
|
pubkey: ALICE_PUBKEY,
|
|
created_at: Math.floor(Date.now() / 1000) - 85 * 60,
|
|
kind: 45001,
|
|
tags: [["h", channelId]],
|
|
content: "Team offsite planning and travel notes.",
|
|
sig: "mocksig".repeat(20).slice(0, 128),
|
|
},
|
|
{
|
|
id: "mock-forum-release-reply",
|
|
pubkey: ALICE_PUBKEY,
|
|
created_at: Math.floor(Date.now() / 1000) - 80 * 60,
|
|
kind: 45003,
|
|
tags: buildReplyMessageTags(
|
|
channelId,
|
|
ALICE_PUBKEY,
|
|
"mock-forum-release-thread",
|
|
"mock-forum-release-thread",
|
|
undefined,
|
|
),
|
|
content: "Looks good to me. We should ship it.",
|
|
sig: "mocksig".repeat(20).slice(0, 128),
|
|
},
|
|
// Filler replies so the thread overflows the panel viewport — the
|
|
// deep-link target (mock-forum-release-deeplink) sits below the fold
|
|
// at open, proving scrollIntoView lands an offscreen content-
|
|
// visibility row. Named IDs above are untouched.
|
|
...Array.from({ length: 24 }, (_, index) => ({
|
|
id:
|
|
index === 23
|
|
? "mock-forum-release-deeplink"
|
|
: `mock-forum-release-filler-${index}`,
|
|
pubkey: ALICE_PUBKEY,
|
|
created_at: Math.floor(Date.now() / 1000) - (79 - index) * 60,
|
|
kind: 45003,
|
|
tags: buildReplyMessageTags(
|
|
channelId,
|
|
ALICE_PUBKEY,
|
|
"mock-forum-release-thread",
|
|
"mock-forum-release-thread",
|
|
undefined,
|
|
),
|
|
content:
|
|
index === 23
|
|
? "Deep-link target: confirmed the rollout plan end to end."
|
|
: `Follow-up note #${index + 1} on the release checklist.`,
|
|
sig: "mocksig".repeat(20).slice(0, 128),
|
|
})),
|
|
]
|
|
: channelId === "94a444a4-c0a3-5966-ab05-530c6ddc2301"
|
|
? [
|
|
// Charlie is a `bot` member of #agents (see channel seed), so this
|
|
// message renders with role="bot" — the surface whose avatar opens
|
|
// a managed-agent profile panel / hover popover with active-turn
|
|
// badges. #agents has no message-row index assertions, so seeding
|
|
// here is safe for existing specs.
|
|
{
|
|
id: "mock-agents-charlie",
|
|
pubkey: CHARLIE_PUBKEY,
|
|
created_at: Math.floor(Date.now() / 1000) - 90,
|
|
kind: 9,
|
|
tags: [["h", channelId]],
|
|
content: "Indexing the channel catalog now.",
|
|
sig: "mocksig".repeat(20).slice(0, 128),
|
|
},
|
|
// Owned remote relay agent: declared-owned by the mock viewer,
|
|
// present in the relay registry, but NOT locally managed. This
|
|
// keeps the profile Runtime-tab owner gate honest.
|
|
{
|
|
id: "mock-agents-owned-relay-nadia",
|
|
pubkey: OWNED_RELAY_AGENT_PUBKEY,
|
|
created_at: Math.floor(Date.now() / 1000) - 85,
|
|
kind: 9,
|
|
tags: [["h", channelId]],
|
|
content: "Indexing remotely for my owner.",
|
|
sig: "mocksig".repeat(20).slice(0, 128),
|
|
},
|
|
// Seed one message per managed agent that is a member of #agents.
|
|
// This lets e2e specs open the profile panel by clicking the
|
|
// agent's avatar in a message-row (the same pattern as charlie).
|
|
...mockManagedAgents
|
|
.filter((agent) =>
|
|
mockChannels
|
|
.find((ch) => ch.id === channelId)
|
|
?.members.some((m) => m.pubkey === agent.pubkey),
|
|
)
|
|
.map((agent, index) => ({
|
|
id: `mock-agents-managed-${agent.pubkey.slice(0, 8)}`,
|
|
pubkey: agent.pubkey,
|
|
created_at: Math.floor(Date.now() / 1000) - 80 + index,
|
|
kind: 9 as const,
|
|
tags: [["h", channelId]],
|
|
content: `${agent.name} reporting in.`,
|
|
sig: "mocksig".repeat(20).slice(0, 128),
|
|
})),
|
|
]
|
|
: channelId === "feedf00d-0000-4000-8000-000000000007"
|
|
? (() => {
|
|
const count = getConfig()?.mock?.deepHistoryMessageCount ?? 600;
|
|
return Array.from({ length: count }, (_, index) => ({
|
|
id: `mock-deep-history-${index}`,
|
|
pubkey: index % 2 === 0 ? ALICE_PUBKEY : MOCK_IDENTITY_PUBKEY,
|
|
created_at:
|
|
Math.floor(Date.now() / 1000) - (count - index) * 60,
|
|
kind: 9,
|
|
tags: [["h", channelId]],
|
|
content:
|
|
count > 600
|
|
? `Deep history message #${index}\n${"variable wrapped history ".repeat((index % 12) + 1)}`
|
|
: `Deep history message #${index}`,
|
|
sig: "mocksig".repeat(20).slice(0, 128),
|
|
}));
|
|
})()
|
|
: [];
|
|
|
|
mockMessages.set(channelId, seeded);
|
|
return seeded;
|
|
}
|
|
|
|
function prependMockHistory(input: {
|
|
channelName: string;
|
|
count: number;
|
|
startIndex?: number;
|
|
lineCount?: number;
|
|
createdAtStart?: number;
|
|
emit?: boolean;
|
|
}) {
|
|
const channel = mockChannels.find(
|
|
(candidate) => candidate.name === input.channelName,
|
|
);
|
|
if (!channel) {
|
|
throw new Error(`Unknown mock channel: ${input.channelName}`);
|
|
}
|
|
|
|
const store = getMockMessageStore(channel.id);
|
|
const earliestCreatedAt = store.reduce(
|
|
(earliest, event) => Math.min(earliest, event.created_at),
|
|
Math.floor(Date.now() / 1000),
|
|
);
|
|
const createdAtStart =
|
|
input.createdAtStart ?? earliestCreatedAt - input.count - 1;
|
|
const startIndex = input.startIndex ?? 0;
|
|
const lineCount = input.lineCount ?? 1;
|
|
|
|
const events = Array.from({ length: input.count }, (_, offset) => {
|
|
const index = startIndex + offset;
|
|
const body = Array.from(
|
|
{ length: lineCount },
|
|
(_unused, lineIndex) => `mock older ${index} line ${lineIndex + 1}`,
|
|
).join("\n");
|
|
|
|
return createMockEvent(
|
|
9,
|
|
body,
|
|
[["h", channel.id]],
|
|
ALICE_PUBKEY,
|
|
createdAtStart + offset,
|
|
`mock-older-${channel.name}-${index}`.replace(/[^a-zA-Z0-9]/g, ""),
|
|
);
|
|
});
|
|
|
|
store.unshift(...events);
|
|
store.sort((left, right) => left.created_at - right.created_at);
|
|
|
|
if (input.emit) {
|
|
for (const event of events) {
|
|
emitMockLiveEvent(channel.id, event);
|
|
}
|
|
}
|
|
|
|
return events;
|
|
}
|
|
|
|
function emitMockHistory(
|
|
socket: MockSocket,
|
|
subId: string,
|
|
channelIds: string[],
|
|
filter: MockFilter,
|
|
) {
|
|
const events = selectMockHistory(
|
|
new Map(channelIds.map((id) => [id, getMockMessageStore(id)])),
|
|
[filter],
|
|
);
|
|
|
|
const emit = () => {
|
|
for (const event of events) {
|
|
sendWsText(socket.handler, ["EVENT", subId, event]);
|
|
}
|
|
sendWsText(socket.handler, ["EOSE", subId]);
|
|
};
|
|
|
|
emit();
|
|
}
|
|
|
|
function emitMockLiveEvent(channelId: string, event: RelayEvent) {
|
|
for (const socket of mockSockets.values()) {
|
|
for (const [subId, subscription] of socket.subscriptions) {
|
|
if (
|
|
(subscription.channelIds.includes(channelId) ||
|
|
subscription.channelIds.includes(GLOBAL_MOCK_SUBSCRIPTION)) &&
|
|
(!subscription.kinds || subscription.kinds.includes(event.kind))
|
|
) {
|
|
sendWsText(socket.handler, ["EVENT", subId, event]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let mockObserverControlSeq = 0;
|
|
|
|
function emitMockObserverControlResult(
|
|
agentPubkey: string,
|
|
request: {
|
|
type: "cancel_turn" | "switch_model";
|
|
channelId?: string | null;
|
|
requestId?: string;
|
|
modelId?: string;
|
|
},
|
|
result: {
|
|
type: "cancel_turn" | "switch_model";
|
|
status: string;
|
|
channelId?: string | null;
|
|
requestId?: string;
|
|
modelId?: string;
|
|
},
|
|
) {
|
|
const channelId = result.channelId ?? request.channelId ?? null;
|
|
const payload = {
|
|
type: result.type,
|
|
status: result.status,
|
|
...(result.requestId !== undefined
|
|
? { requestId: result.requestId }
|
|
: request.requestId !== undefined
|
|
? { requestId: request.requestId }
|
|
: {}),
|
|
...(result.modelId !== undefined
|
|
? { modelId: result.modelId }
|
|
: request.modelId !== undefined
|
|
? { modelId: request.modelId }
|
|
: {}),
|
|
};
|
|
mockObserverControlSeq += 1;
|
|
const observerEvent = createMockEvent(
|
|
KIND_AGENT_OBSERVER_FRAME,
|
|
JSON.stringify({
|
|
seq: mockObserverControlSeq,
|
|
timestamp: new Date().toISOString(),
|
|
kind: "control_result",
|
|
agentIndex: null,
|
|
channelId,
|
|
sessionId: null,
|
|
turnId: null,
|
|
payload,
|
|
}),
|
|
[
|
|
["p", MOCK_IDENTITY_PUBKEY],
|
|
["agent", agentPubkey],
|
|
["frame", "telemetry"],
|
|
],
|
|
agentPubkey,
|
|
);
|
|
emitMockLiveEvent(GLOBAL_MOCK_SUBSCRIPTION, observerEvent);
|
|
}
|
|
|
|
function emitOrDeferMockSendMessageLiveEcho(
|
|
channelId: string,
|
|
event: RelayEvent,
|
|
config: E2eConfig | undefined,
|
|
) {
|
|
if (config?.mock?.deferSendMessageLiveEcho) {
|
|
deferredSendMessageLiveEchoes.push({ channelId, event });
|
|
return;
|
|
}
|
|
emitMockLiveEvent(channelId, event);
|
|
}
|
|
|
|
function emitMockGlobalEvent(event: RelayEvent) {
|
|
if (
|
|
(event.kind === KIND_PERSONA || event.kind === KIND_TEAM_CATALOG) &&
|
|
event.pubkey.toLowerCase() !== MOCK_IDENTITY_PUBKEY.toLowerCase() &&
|
|
!hasExactSharedTag(event)
|
|
) {
|
|
return;
|
|
}
|
|
for (const socket of mockSockets.values()) {
|
|
for (const [subId, subscription] of socket.subscriptions) {
|
|
if (subscription.kinds && !subscription.kinds.includes(event.kind)) {
|
|
continue;
|
|
}
|
|
sendWsText(socket.handler, ["EVENT", subId, event]);
|
|
}
|
|
}
|
|
}
|
|
|
|
function hasMockLiveSubscription(
|
|
channelId: string,
|
|
kind?: number,
|
|
exactChannel = false,
|
|
) {
|
|
return [...mockSockets.values()].some((socket) =>
|
|
hasMockSubscription(
|
|
socket.subscriptions.values(),
|
|
channelId,
|
|
kind,
|
|
exactChannel,
|
|
),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* True iff a live REQ subscription is open with an `#p` filter containing
|
|
* `ownerPubkey` and a `kinds` filter containing `kind`. Used to assert the
|
|
* observer-archive reconciliation gate actually opens an owner-scoped live
|
|
* filter (not just that `list_save_subscriptions` was called) once the
|
|
* gate resolves.
|
|
*/
|
|
function hasMockOwnerKindSubscription(ownerPubkey: string, kind: number) {
|
|
for (const socket of mockSockets.values()) {
|
|
for (const subscription of socket.subscriptions.values()) {
|
|
if (
|
|
subscription.ownerPubkeys.includes(ownerPubkey) &&
|
|
(subscription.kinds?.includes(kind) ?? false)
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function recordMockMessage(channelId: string, event: RelayEvent) {
|
|
const history = getMockMessageStore(channelId);
|
|
history.push(event);
|
|
|
|
const channel = mockChannels.find((candidate) => candidate.id === channelId);
|
|
if (!channel) {
|
|
return;
|
|
}
|
|
|
|
channel.last_message_at = new Date(event.created_at * 1_000).toISOString();
|
|
touchMockChannel(channel);
|
|
}
|
|
|
|
function resetMockUserStatuses() {
|
|
mockUserStatuses.length = 0;
|
|
}
|
|
|
|
// Mocked Rust-side pending deep-link queue (see desktop/src-tauri/src/deep_link.rs).
|
|
let mockPendingCommunityDeepLinks: Array<{
|
|
id: string;
|
|
kind: string;
|
|
relayUrl: string;
|
|
code: string | null;
|
|
name: string | null;
|
|
}> = [];
|
|
|
|
function resetMockPendingCommunityDeepLinks(config: E2eConfig | null) {
|
|
mockPendingCommunityDeepLinks = (
|
|
config?.mock?.pendingCommunityDeepLinks ?? []
|
|
).map((pending) => ({
|
|
...pending,
|
|
code: pending.code ?? null,
|
|
name: pending.name ?? null,
|
|
}));
|
|
}
|
|
|
|
let mockPendingNavigationDeepLinks: Array<{
|
|
id: string;
|
|
kind: "channel" | "message";
|
|
channelId: string;
|
|
messageId: string | null;
|
|
threadRootId: string | null;
|
|
}> = [];
|
|
|
|
function resetMockPendingNavigationDeepLinks(config: E2eConfig | null) {
|
|
mockPendingNavigationDeepLinks = (
|
|
config?.mock?.pendingNavigationDeepLinks ?? []
|
|
).map((pending) => ({
|
|
...pending,
|
|
messageId: pending.messageId ?? null,
|
|
threadRootId: pending.threadRootId ?? null,
|
|
}));
|
|
}
|
|
|
|
let mockPendingEntityDeepLinks: Array<{ id: string; href: string }> = [];
|
|
|
|
function resetMockPendingEntityDeepLinks(config: E2eConfig | null) {
|
|
mockPendingEntityDeepLinks = (config?.mock?.pendingEntityDeepLinks ?? []).map(
|
|
(pending) => ({ ...pending }),
|
|
);
|
|
}
|
|
|
|
function recordMockUserStatus(event: RelayEvent) {
|
|
const dTag = event.tags.find((tag) => tag[0] === "d")?.[1];
|
|
if (dTag) {
|
|
const index = mockUserStatuses.findIndex(
|
|
(stored) =>
|
|
stored.pubkey.toLowerCase() === event.pubkey.toLowerCase() &&
|
|
stored.tags.some((tag) => tag[0] === "d" && tag[1] === dTag),
|
|
);
|
|
if (index >= 0) {
|
|
mockUserStatuses.splice(index, 1);
|
|
}
|
|
}
|
|
|
|
mockUserStatuses.push(event);
|
|
}
|
|
|
|
function filterMockUserStatuses(filter: MockFilter) {
|
|
const authors = filter.authors?.map((author) => author.toLowerCase());
|
|
const dTags = filter["#d"];
|
|
|
|
return mockUserStatuses
|
|
.filter((event) => {
|
|
if (authors && !authors.includes(event.pubkey.toLowerCase())) {
|
|
return false;
|
|
}
|
|
if (
|
|
dTags &&
|
|
!event.tags.some((tag) => tag[0] === "d" && dTags.includes(tag[1]))
|
|
) {
|
|
return false;
|
|
}
|
|
return true;
|
|
})
|
|
.sort((a, b) => b.created_at - a.created_at);
|
|
}
|
|
|
|
function emitMockChannelMessage(
|
|
channelId: string,
|
|
content: string,
|
|
parentEventId?: string | null,
|
|
pubkey?: string,
|
|
kind?: number,
|
|
mentionPubkeys?: string[],
|
|
extraTags?: string[][],
|
|
createdAt?: number,
|
|
pending?: boolean,
|
|
id?: string,
|
|
) {
|
|
const eventKind = kind ?? 9;
|
|
if (!parentEventId) {
|
|
const tags = buildTopLevelMessageTags(
|
|
channelId,
|
|
mentionPubkeys,
|
|
pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey,
|
|
);
|
|
if (extraTags) tags.push(...extraTags);
|
|
const event = createMockEvent(
|
|
eventKind,
|
|
content,
|
|
tags,
|
|
pubkey,
|
|
createdAt,
|
|
id,
|
|
);
|
|
if (pending) event.pending = true;
|
|
recordMockMessage(channelId, event);
|
|
emitMockLiveEvent(channelId, event);
|
|
return event;
|
|
}
|
|
|
|
const history = getMockMessageStore(channelId);
|
|
const parentEvent =
|
|
history.find((event) => event.id === parentEventId) ?? null;
|
|
const parentThread = parentEvent
|
|
? getThreadReferenceFromTags(parentEvent.tags)
|
|
: {
|
|
parentEventId: null,
|
|
rootEventId: null,
|
|
};
|
|
const rootEventId = parentThread.rootEventId ?? parentEventId;
|
|
const authorPubkey = pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey;
|
|
const tags = buildReplyMessageTags(
|
|
channelId,
|
|
authorPubkey,
|
|
parentEventId,
|
|
rootEventId,
|
|
mentionPubkeys,
|
|
);
|
|
if (extraTags) tags.push(...extraTags);
|
|
const event = createMockEvent(
|
|
eventKind,
|
|
content,
|
|
tags,
|
|
authorPubkey,
|
|
createdAt,
|
|
id,
|
|
);
|
|
if (pending) event.pending = true;
|
|
recordMockMessage(channelId, event);
|
|
emitMockLiveEvent(channelId, event);
|
|
const rootEvent = history.find((candidate) => candidate.id === rootEventId);
|
|
if (rootEvent && mockHuddle?.state.ephemeral_channel_id === channelId) {
|
|
const summary = buildMockChannelThreadSummary(
|
|
channelId,
|
|
rootEvent,
|
|
getMockMessageStore(channelId),
|
|
);
|
|
if (summary) emitMockLiveEvent(channelId, summary);
|
|
}
|
|
return event;
|
|
}
|
|
|
|
function emitMockTypingIndicator(
|
|
channelId: string,
|
|
pubkey: string,
|
|
threadHeadId?: string,
|
|
createdAt?: number,
|
|
) {
|
|
const event: RelayEvent = {
|
|
id: crypto.randomUUID().replace(/-/g, ""),
|
|
pubkey,
|
|
created_at: createdAt ?? Math.floor(Date.now() / 1000),
|
|
kind: 20002,
|
|
tags: [
|
|
["h", channelId],
|
|
...(threadHeadId ? [["e", threadHeadId, "", "reply"]] : []),
|
|
],
|
|
content: "",
|
|
sig: "mocksig".repeat(20).slice(0, 128),
|
|
};
|
|
|
|
emitMockLiveEvent(channelId, event);
|
|
return event;
|
|
}
|
|
|
|
function toRawForumPost(
|
|
event: RelayEvent,
|
|
channelId: string,
|
|
threadSummary: RawThreadSummary | null,
|
|
): RawForumPost {
|
|
return {
|
|
event_id: event.id,
|
|
pubkey: event.pubkey,
|
|
content: event.content,
|
|
kind: event.kind,
|
|
created_at: event.created_at,
|
|
channel_id: channelId,
|
|
tags: event.tags,
|
|
thread_summary: threadSummary,
|
|
reactions: null,
|
|
};
|
|
}
|
|
|
|
function toRawForumReply(event: RelayEvent, channelId: string): RawForumReply {
|
|
const thread = getThreadReferenceFromTags(event.tags);
|
|
|
|
return {
|
|
event_id: event.id,
|
|
pubkey: event.pubkey,
|
|
content: event.content,
|
|
kind: event.kind,
|
|
created_at: event.created_at,
|
|
channel_id: channelId,
|
|
tags: event.tags,
|
|
parent_event_id: thread.parentEventId,
|
|
root_event_id: thread.rootEventId,
|
|
depth:
|
|
thread.rootEventId && thread.parentEventId !== thread.rootEventId ? 2 : 1,
|
|
broadcast: false,
|
|
reactions: null,
|
|
};
|
|
}
|
|
|
|
async function handleGetForumPosts(args: {
|
|
channelId: string;
|
|
limit?: number | null;
|
|
before?: number | null;
|
|
}): Promise<RawForumPostsResponse> {
|
|
const events = getMockMessageStore(args.channelId);
|
|
const posts = events
|
|
.filter((event) => event.kind === 45001)
|
|
.filter((event) => (args.before ? event.created_at < args.before : true))
|
|
.sort((left, right) => right.created_at - left.created_at)
|
|
.slice(0, args.limit ?? 50)
|
|
.map((event) => {
|
|
const replies = events.filter((candidate) => {
|
|
if (candidate.kind !== 45003) {
|
|
return false;
|
|
}
|
|
|
|
const thread = getThreadReferenceFromTags(candidate.tags);
|
|
return (thread.rootEventId ?? thread.parentEventId) === event.id;
|
|
});
|
|
|
|
return toRawForumPost(event, args.channelId, {
|
|
reply_count: replies.length,
|
|
descendant_count: replies.length,
|
|
last_reply_at:
|
|
replies.length > 0 ? replies[replies.length - 1].created_at : null,
|
|
participants: [...new Set(replies.map((reply) => reply.pubkey))],
|
|
});
|
|
});
|
|
|
|
return {
|
|
messages: posts,
|
|
next_cursor: null,
|
|
};
|
|
}
|
|
|
|
async function handleGetForumThread(args: {
|
|
channelId: string;
|
|
eventId: string;
|
|
}): Promise<RawForumThreadResponse> {
|
|
const events = getMockMessageStore(args.channelId);
|
|
const root = events.find(
|
|
(event) => event.id === args.eventId && event.kind === 45001,
|
|
);
|
|
if (!root) {
|
|
throw new Error(`Mock forum thread not found: ${args.eventId}`);
|
|
}
|
|
|
|
const replies = events
|
|
.filter((event) => event.kind === 45003)
|
|
.filter((event) => {
|
|
const thread = getThreadReferenceFromTags(event.tags);
|
|
return (thread.rootEventId ?? thread.parentEventId) === root.id;
|
|
})
|
|
.sort((left, right) => left.created_at - right.created_at)
|
|
.map((event) => toRawForumReply(event, args.channelId));
|
|
|
|
return {
|
|
root: toRawForumPost(root, args.channelId, {
|
|
reply_count: replies.length,
|
|
descendant_count: replies.length,
|
|
last_reply_at:
|
|
replies.length > 0 ? replies[replies.length - 1].created_at : null,
|
|
participants: [...new Set(replies.map((reply) => reply.pubkey))],
|
|
}),
|
|
replies,
|
|
total_replies: replies.length,
|
|
next_cursor: null,
|
|
};
|
|
}
|
|
|
|
type RawThreadCursor = {
|
|
created_at: number;
|
|
event_id: string;
|
|
};
|
|
|
|
type RawThreadRepliesResponse = {
|
|
events: RelayEvent[];
|
|
next_cursor: RawThreadCursor | null;
|
|
};
|
|
|
|
/**
|
|
* Mirror of the desktop `get_thread_replies` command: return the full reply
|
|
* subtree under a root, chronological (oldest first), excluding the root itself,
|
|
* with gap-free `(created_at, event_id)` keyset paging.
|
|
*
|
|
* The event-id tiebreak is load-bearing — same-second replies must all page
|
|
* through even when they cross a page boundary. This lets a Playwright spec
|
|
* assert the paged union equals the whole subtree, matching the relay contract.
|
|
*/
|
|
async function handleGetThreadReplies(
|
|
args: {
|
|
rootEventId: string;
|
|
channelId?: string | null;
|
|
limit?: number | null;
|
|
depthLimit?: number | null;
|
|
cursor?: RawThreadCursor | null;
|
|
},
|
|
config: E2eConfig | undefined,
|
|
): Promise<RawThreadRepliesResponse> {
|
|
const cap = Math.min(args.limit ?? 200, 500);
|
|
const filter: MockFilter & Record<string, unknown> = {
|
|
"#e": [args.rootEventId],
|
|
depth_limit: args.depthLimit ?? 64,
|
|
kinds: [...TIMELINE_KINDS],
|
|
limit: cap,
|
|
};
|
|
if (args.channelId) {
|
|
filter["#h"] = [args.channelId];
|
|
}
|
|
if (args.cursor) {
|
|
filter.thread_cursor = args.cursor.created_at;
|
|
filter.thread_cursor_id = args.cursor.event_id;
|
|
}
|
|
const identity = getIdentity(config);
|
|
if (
|
|
!isPGatedFilterAuthorized(
|
|
filter,
|
|
identity?.pubkey ?? getMockMemberPubkey(config),
|
|
)
|
|
) {
|
|
throw new Error(P_GATED_REJECTION_MESSAGE);
|
|
}
|
|
|
|
let subtree: RelayEvent[];
|
|
if (!identity) {
|
|
// Mock store: walk the reply forest transitively from the root so nested
|
|
// replies (reply-to-a-reply) are included, matching thread_metadata depth.
|
|
const events = args.channelId
|
|
? getMockMessageStore(args.channelId)
|
|
: Array.from(mockMessages.values()).flat();
|
|
const byId = new Map(events.map((event) => [event.id, event]));
|
|
const root = byId.get(args.rootEventId);
|
|
const collected: RelayEvent[] = [];
|
|
const included = new Set<string>();
|
|
if (!root) {
|
|
subtree = collected;
|
|
} else {
|
|
const frontier = new Set<string>([root.id]);
|
|
for (;;) {
|
|
let added = false;
|
|
for (const event of events) {
|
|
if (included.has(event.id)) {
|
|
continue;
|
|
}
|
|
const ref = getThreadReferenceFromTags(event.tags);
|
|
if (!ref.parentEventId || !frontier.has(ref.parentEventId)) {
|
|
continue;
|
|
}
|
|
included.add(event.id);
|
|
collected.push(event);
|
|
frontier.add(event.id);
|
|
added = true;
|
|
}
|
|
if (!added) {
|
|
break;
|
|
}
|
|
}
|
|
subtree = collected;
|
|
}
|
|
} else {
|
|
// Config mode: exercise the real bridge thread path over /query.
|
|
const events = await relayQuery(config, [filter]);
|
|
const nextCursor =
|
|
events.length >= cap
|
|
? {
|
|
created_at: events[events.length - 1].created_at,
|
|
event_id: events[events.length - 1].id,
|
|
}
|
|
: null;
|
|
return { events, next_cursor: nextCursor };
|
|
}
|
|
|
|
// Mock mode paging: sort by the composite key, then slice strictly after the
|
|
// cursor so same-second ties can never be skipped across a page boundary.
|
|
subtree.sort(
|
|
(left, right) =>
|
|
left.created_at - right.created_at || left.id.localeCompare(right.id),
|
|
);
|
|
let start = 0;
|
|
if (args.cursor) {
|
|
const cursor = args.cursor;
|
|
start = subtree.findIndex(
|
|
(event) =>
|
|
event.created_at > cursor.created_at ||
|
|
(event.created_at === cursor.created_at &&
|
|
event.id.localeCompare(cursor.event_id) > 0),
|
|
);
|
|
if (start < 0) {
|
|
start = subtree.length;
|
|
}
|
|
}
|
|
const page = subtree.slice(start, start + cap);
|
|
const nextCursor =
|
|
page.length >= cap && start + cap < subtree.length
|
|
? {
|
|
created_at: page[page.length - 1].created_at,
|
|
event_id: page[page.length - 1].id,
|
|
}
|
|
: null;
|
|
const delayMs = config?.mock?.threadRepliesDelayMs ?? 0;
|
|
if (delayMs > 0) {
|
|
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
|
|
}
|
|
if (config?.mock?.deferThreadReplies) {
|
|
await new Promise<void>((resolve) => {
|
|
deferredThreadRepliesQueue.push(resolve);
|
|
});
|
|
}
|
|
|
|
return { events: page, next_cursor: nextCursor };
|
|
}
|
|
|
|
const TIMELINE_KINDS = new Set([
|
|
9,
|
|
40002,
|
|
40008,
|
|
40099,
|
|
43001,
|
|
43002,
|
|
43003,
|
|
43004,
|
|
43005,
|
|
43006,
|
|
KIND_HUDDLE_STARTED,
|
|
]);
|
|
|
|
const KIND_GIFT_WRAP = 1059;
|
|
const P_GATED_KINDS = new Set([
|
|
KIND_AGENT_OBSERVER_FRAME,
|
|
KIND_MEMBER_ADDED_NOTIFICATION,
|
|
KIND_MEMBER_REMOVED_NOTIFICATION,
|
|
KIND_GIFT_WRAP,
|
|
KIND_DM_VISIBILITY,
|
|
]);
|
|
const P_GATED_REJECTION_MESSAGE =
|
|
"restricted: p-gated kinds require #p tag matching your pubkey";
|
|
|
|
function filterKinds(filter: { kinds?: unknown }): number[] | undefined {
|
|
return Array.isArray(filter.kinds)
|
|
? filter.kinds.filter((kind): kind is number => typeof kind === "number")
|
|
: undefined;
|
|
}
|
|
|
|
function filterCanMatchPGated(filter: { kinds?: unknown }) {
|
|
const kinds = filterKinds(filter);
|
|
return !kinds || kinds.some((kind) => P_GATED_KINDS.has(kind));
|
|
}
|
|
|
|
function filterHasOwnPTag(filter: { "#p"?: unknown }, pubkey: string) {
|
|
const values = filter["#p"];
|
|
return (
|
|
Array.isArray(values) &&
|
|
values.length > 0 &&
|
|
values.every((value) => value === pubkey)
|
|
);
|
|
}
|
|
|
|
function isPGatedFilterAuthorized(
|
|
filter: { "#p"?: unknown; ids?: unknown; kinds?: unknown },
|
|
pubkey: string,
|
|
) {
|
|
if (!filterCanMatchPGated(filter)) {
|
|
return true;
|
|
}
|
|
|
|
const kinds = filterKinds(filter);
|
|
const ids = filter.ids;
|
|
const explicitlyDmVisibility = kinds?.includes(KIND_DM_VISIBILITY);
|
|
if (!explicitlyDmVisibility && Array.isArray(ids) && ids.length > 0) {
|
|
return true;
|
|
}
|
|
|
|
return filterHasOwnPTag(filter, pubkey);
|
|
}
|
|
|
|
type RawChannelMessagesPageResponse = {
|
|
events: RelayEvent[];
|
|
next_cursor: RawThreadCursor | null;
|
|
};
|
|
|
|
/**
|
|
* Mirror of the desktop `get_channel_messages_before` command: return one
|
|
* keyset page of *top-level* channel history strictly older than a composite
|
|
* `(before, before_id)` cursor, newest first (relay order `created_at DESC,
|
|
* id ASC`).
|
|
*
|
|
* This is the dense-second escape hatch — the id tiebreak is load-bearing so a
|
|
* single `created_at` second denser than one WS page can still be paged
|
|
* through: within a tied second the relay advances via `id > before_id`. Lets a
|
|
* Playwright spec assert the keyset union reaches every top-level message even
|
|
* when a second holds more than one page.
|
|
*/
|
|
async function handleGetChannelMessagesBefore(
|
|
args: {
|
|
channelId: string;
|
|
before: number;
|
|
beforeId?: string | null;
|
|
limit?: number | null;
|
|
},
|
|
config: E2eConfig | undefined,
|
|
): Promise<RawChannelMessagesPageResponse> {
|
|
const cap = Math.min(args.limit ?? 200, 500);
|
|
const identity = getIdentity(config);
|
|
|
|
let events: RelayEvent[];
|
|
if (!identity) {
|
|
// Mock store: top-level timeline events for this channel.
|
|
events = getMockMessageStore(args.channelId).filter((event) => {
|
|
if (!TIMELINE_KINDS.has(event.kind)) {
|
|
return false;
|
|
}
|
|
return isMockTopLevelRow(event);
|
|
});
|
|
} else {
|
|
// Config mode: exercise the real bridge keyset over /query.
|
|
const filter: Record<string, unknown> = {
|
|
"#h": [args.channelId],
|
|
kinds: [...TIMELINE_KINDS],
|
|
until: args.before,
|
|
limit: cap,
|
|
};
|
|
if (args.beforeId) {
|
|
filter.before_id = args.beforeId;
|
|
}
|
|
const page = await relayQuery(config, [filter]);
|
|
const nextCursor =
|
|
page.length >= cap
|
|
? {
|
|
created_at: page[page.length - 1].created_at,
|
|
event_id: page[page.length - 1].id,
|
|
}
|
|
: null;
|
|
return { events: page, next_cursor: nextCursor };
|
|
}
|
|
|
|
// Mock mode paging: relay order (created_at DESC, id ASC), then take the
|
|
// slice strictly older than the composite cursor. Strictly-older means
|
|
// `created_at < before OR (created_at === before AND id > before_id)` — the
|
|
// id tiebreak walks *forward* through a tied second under ASC id order.
|
|
events.sort(
|
|
(left, right) =>
|
|
right.created_at - left.created_at || left.id.localeCompare(right.id),
|
|
);
|
|
const before = args.before;
|
|
const beforeId = args.beforeId ?? null;
|
|
const older = events.filter((event) => {
|
|
if (event.created_at < before) {
|
|
return true;
|
|
}
|
|
if (event.created_at === before && beforeId !== null) {
|
|
return event.id.localeCompare(beforeId) > 0;
|
|
}
|
|
return false;
|
|
});
|
|
const page = older.slice(0, cap);
|
|
const nextCursor =
|
|
page.length >= cap
|
|
? {
|
|
created_at: page[page.length - 1].created_at,
|
|
event_id: page[page.length - 1].id,
|
|
}
|
|
: null;
|
|
|
|
return { events: page, next_cursor: nextCursor };
|
|
}
|
|
|
|
function getEventTargets(event: RelayEvent) {
|
|
return event.tags.flatMap((tag) =>
|
|
tag[0] === "e" && typeof tag[1] === "string" ? [tag[1]] : [],
|
|
);
|
|
}
|
|
|
|
function buildMockChannelWindowAux(
|
|
events: RelayEvent[],
|
|
rows: RelayEvent[],
|
|
): RelayEvent[] {
|
|
const collectHop = (kinds: Set<number>, targetIds: Set<string>) =>
|
|
events.filter(
|
|
(event) =>
|
|
kinds.has(event.kind) &&
|
|
getEventTargets(event).some((target) => targetIds.has(target)),
|
|
);
|
|
|
|
const firstHop = collectHop(
|
|
CHANNEL_WINDOW_AUX_KINDS,
|
|
new Set(rows.map((row) => row.id)),
|
|
);
|
|
const secondHop = collectHop(
|
|
CHANNEL_WINDOW_AUX_DELETION_KINDS,
|
|
new Set(firstHop.map((event) => event.id)),
|
|
);
|
|
const byId = new Map(firstHop.map((event) => [event.id, event]));
|
|
for (const event of secondHop) byId.set(event.id, event);
|
|
return [...byId.values()];
|
|
}
|
|
|
|
function buildMockChannelThreadSummary(
|
|
channelId: string,
|
|
root: RelayEvent,
|
|
events: RelayEvent[],
|
|
): RelayEvent | null {
|
|
const replies = events.filter((event) => {
|
|
const thread = getThreadReferenceFromTags(event.tags);
|
|
return thread.rootEventId === root.id;
|
|
});
|
|
if (replies.length === 0) return null;
|
|
|
|
const directReplies = replies.filter(
|
|
(event) => getThreadReferenceFromTags(event.tags).parentEventId === root.id,
|
|
);
|
|
const participants = [
|
|
...new Set(
|
|
replies
|
|
.sort(
|
|
(left, right) =>
|
|
right.created_at - left.created_at ||
|
|
left.id.localeCompare(right.id),
|
|
)
|
|
.map((event) => event.pubkey),
|
|
),
|
|
].slice(0, 10);
|
|
const lastReplyAt = Math.max(...replies.map((event) => event.created_at));
|
|
return {
|
|
id: `mock-window-summary-${root.id}`,
|
|
pubkey: DEFAULT_MOCK_IDENTITY.pubkey,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
kind: KIND_CHANNEL_THREAD_SUMMARY,
|
|
tags: [
|
|
["e", root.id],
|
|
["d", root.id],
|
|
["h", channelId],
|
|
],
|
|
content: JSON.stringify({
|
|
reply_count: directReplies.length,
|
|
descendant_count: replies.length,
|
|
last_reply_at: lastReplyAt,
|
|
participants,
|
|
}),
|
|
sig: "mocksig".repeat(20).slice(0, 128),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build the single kind-39006 bounds event a channel window response must carry.
|
|
* The `d` tag key must match `expectedBoundsKey` in channelWindowResponse.ts:
|
|
* `<channel>:head` at the frontier, else `<channel>:<created_at>:<event_id>` of
|
|
* the request cursor (lower-cased). `has_more`/`next_cursor` must agree — the
|
|
* parser rejects a bounds event where they disagree.
|
|
*/
|
|
function buildMockChannelWindowBounds(
|
|
args: {
|
|
channelId: string;
|
|
cursor?: { created_at: number; event_id: string } | null;
|
|
},
|
|
hasMore: boolean,
|
|
nextCursor: { created_at: number; id: string } | null,
|
|
): RelayEvent {
|
|
const suffix = args.cursor
|
|
? `${args.cursor.created_at}:${args.cursor.event_id.toLowerCase()}`
|
|
: "head";
|
|
const boundsKey = `${args.channelId.toLowerCase()}:${suffix}`;
|
|
return {
|
|
id: `mock-window-bounds-${boundsKey}`,
|
|
pubkey: DEFAULT_MOCK_IDENTITY.pubkey,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
kind: KIND_CHANNEL_WINDOW_BOUNDS,
|
|
tags: [["d", boundsKey]],
|
|
content: JSON.stringify({ has_more: hasMore, next_cursor: nextCursor }),
|
|
sig: "mocksig".repeat(20).slice(0, 128),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* one server-assembled channel window over the `/query` bridge. Emits the flat
|
|
* event array the relay assembles — top-level rows (newest first), then the aux
|
|
* closure, then relay-signed `39005` summaries and exactly one `39006` bounds
|
|
* event carrying `has_more` + `next_cursor`. The client derives its cursor and
|
|
* exhaustion solely from `39006`, never from the rows, so this handler returns
|
|
* the raw array unchanged.
|
|
*
|
|
* This is the window read-model surface the overhaul introduced; without it the
|
|
* relay-mode bridge has no handler and the timeline renders empty.
|
|
*/
|
|
async function handleGetChannelReconnectRepair(
|
|
args: {
|
|
channelId: string;
|
|
since: number;
|
|
limit: number;
|
|
until?: number | null;
|
|
beforeId?: string | null;
|
|
},
|
|
config: E2eConfig | undefined,
|
|
): Promise<RelayEvent[]> {
|
|
const kinds = new Set([
|
|
5, 7, 9, 9005, 40001, 40002, 40003, 40008, 40099, 45001, 45003, 48100,
|
|
48101, 48102, 48103,
|
|
]);
|
|
const filter: Record<string, unknown> = {
|
|
"#h": [args.channelId],
|
|
kinds: [...kinds],
|
|
since: args.since,
|
|
limit: args.limit,
|
|
};
|
|
if (args.until != null) filter.until = args.until;
|
|
if (args.beforeId != null) filter.before_id = args.beforeId;
|
|
|
|
if (getIdentity(config)) return relayQuery(config, [filter]);
|
|
return getMockMessageStore(args.channelId)
|
|
.filter(
|
|
(event) =>
|
|
kinds.has(event.kind) &&
|
|
event.created_at >= args.since &&
|
|
(args.until == null ||
|
|
event.created_at < args.until ||
|
|
(event.created_at === args.until &&
|
|
(args.beforeId == null || event.id > args.beforeId))),
|
|
)
|
|
.sort(
|
|
(left, right) =>
|
|
right.created_at - left.created_at || left.id.localeCompare(right.id),
|
|
)
|
|
.slice(0, args.limit);
|
|
}
|
|
|
|
async function handleGetChannelWindow(
|
|
args: {
|
|
channelId: string;
|
|
limitRows?: number | null;
|
|
cursor?: { created_at: number; event_id: string } | null;
|
|
},
|
|
config: E2eConfig | undefined,
|
|
): Promise<RelayEvent[]> {
|
|
const execute = async () => {
|
|
const cap = Math.min(args.limitRows ?? 50, 200);
|
|
const identity = getIdentity(config);
|
|
|
|
if (!identity) {
|
|
// Mock store: server-assembled channel window over the mock event store,
|
|
// mirroring the relay path's shape so callers (parseChannelWindowResponse)
|
|
// parse both modes identically. Top-level timeline rows in relay order,
|
|
// then exactly one kind-39006 bounds event.
|
|
const events = getMockMessageStore(args.channelId);
|
|
const candidates = events
|
|
.filter(
|
|
(event) => TIMELINE_KINDS.has(event.kind) && isMockTopLevelRow(event),
|
|
)
|
|
.sort(
|
|
(left, right) =>
|
|
right.created_at - left.created_at ||
|
|
left.id.localeCompare(right.id),
|
|
);
|
|
// Honor the composite (until, before_id) cursor exactly like the relay's
|
|
// keyset: keep only rows strictly older than the cursor under the
|
|
// (created_at DESC, id ASC) order — older created_at, or the same second
|
|
// with a strictly greater id.
|
|
const cursor = args.cursor;
|
|
const afterCursor = cursor
|
|
? candidates.filter(
|
|
(event) =>
|
|
event.created_at < cursor.created_at ||
|
|
(event.created_at === cursor.created_at &&
|
|
event.id > cursor.event_id),
|
|
)
|
|
: candidates;
|
|
const rows = afterCursor.slice(0, cap);
|
|
// Exhaustion probe mirrors the relay's limit+1: more rows past the cursor
|
|
// than the page cap means another page exists. next_cursor is the last
|
|
// retained row.
|
|
const hasMore = afterCursor.length > cap;
|
|
const lastRow = rows[rows.length - 1];
|
|
const nextCursor =
|
|
hasMore && lastRow
|
|
? { created_at: lastRow.created_at, id: lastRow.id }
|
|
: null;
|
|
const aux = buildMockChannelWindowAux(events, rows);
|
|
const summaries = rows.flatMap((row) => {
|
|
const summary = buildMockChannelThreadSummary(
|
|
args.channelId,
|
|
row,
|
|
events,
|
|
);
|
|
return summary ? [summary] : [];
|
|
});
|
|
return [
|
|
...rows,
|
|
...aux,
|
|
...summaries,
|
|
buildMockChannelWindowBounds(args, hasMore, nextCursor),
|
|
];
|
|
}
|
|
|
|
// Relay mode: mirror build_channel_window_filter exactly — top-level dispatch
|
|
// with summaries + aux, composite (until, before_id) cursor (both or neither).
|
|
const filter: Record<string, unknown> = {
|
|
"#h": [args.channelId],
|
|
kinds: [...TIMELINE_KINDS],
|
|
limit: cap,
|
|
top_level: true,
|
|
include_summaries: true,
|
|
include_aux: true,
|
|
};
|
|
if (args.cursor) {
|
|
filter.until = args.cursor.created_at;
|
|
filter.before_id = args.cursor.event_id;
|
|
}
|
|
return relayQuery(config, [filter]);
|
|
};
|
|
|
|
const probe = window as unknown as {
|
|
__CHANNEL_WINDOW_FETCH_COUNT__?: number;
|
|
__CHANNEL_WINDOW_INFLIGHT__?: number;
|
|
__CHANNEL_WINDOW_INFLIGHT_PEAK__?: number;
|
|
};
|
|
if (args.cursor !== null) {
|
|
probe.__CHANNEL_WINDOW_FETCH_COUNT__ =
|
|
(probe.__CHANNEL_WINDOW_FETCH_COUNT__ ?? 0) + 1;
|
|
}
|
|
|
|
const delayMs =
|
|
args.cursor === null
|
|
? (getConfig()?.mock?.channelHeadDelayMs ?? 0)
|
|
: (getConfig()?.mock?.channelWindowDelayMs ?? 0);
|
|
if (delayMs <= 0) {
|
|
return execute();
|
|
}
|
|
|
|
probe.__CHANNEL_WINDOW_INFLIGHT__ =
|
|
(probe.__CHANNEL_WINDOW_INFLIGHT__ ?? 0) + 1;
|
|
probe.__CHANNEL_WINDOW_INFLIGHT_PEAK__ = Math.max(
|
|
probe.__CHANNEL_WINDOW_INFLIGHT_PEAK__ ?? 0,
|
|
probe.__CHANNEL_WINDOW_INFLIGHT__,
|
|
);
|
|
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
|
|
try {
|
|
return await execute();
|
|
} finally {
|
|
probe.__CHANNEL_WINDOW_INFLIGHT__ =
|
|
(probe.__CHANNEL_WINDOW_INFLIGHT__ ?? 1) - 1;
|
|
}
|
|
}
|
|
|
|
function getMockUserNotes(pubkey: string): RawUserNote[] {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
if (pubkey === DEFAULT_MOCK_IDENTITY.pubkey) {
|
|
// Two named notes plus generated filler so the Pulse feed overflows the
|
|
// viewport — required to exercise windowed scroll + sticky-composer offset.
|
|
const named: RawUserNote[] = [
|
|
{
|
|
id: "mock-note-launch",
|
|
pubkey,
|
|
created_at: now - 20 * 60,
|
|
content: "Shipped the new desktop sidebar polish today.",
|
|
tags: [],
|
|
},
|
|
{
|
|
id: "mock-note-forum",
|
|
pubkey,
|
|
created_at: now - 3 * 60 * 60,
|
|
content: "Forum threads feel like the right home for slower decisions.",
|
|
tags: [],
|
|
},
|
|
];
|
|
const filler: RawUserNote[] = Array.from({ length: 28 }, (_, index) => ({
|
|
id: `mock-note-filler-${index}`,
|
|
pubkey,
|
|
created_at: now - (4 + index) * 60 * 60,
|
|
content: `Pulse update #${index + 1}: tracking virtualization rollout across desktop surfaces.`,
|
|
tags: [],
|
|
}));
|
|
return [...named, ...filler];
|
|
}
|
|
|
|
if (pubkey === ALICE_PUBKEY) {
|
|
return [
|
|
{
|
|
id: "mock-alice-note-release",
|
|
pubkey,
|
|
created_at: now - 45 * 60,
|
|
content: "Release checklist is ready for async feedback.",
|
|
tags: [],
|
|
},
|
|
{
|
|
id: "mock-alice-note-design",
|
|
pubkey,
|
|
created_at: now - 5 * 60 * 60,
|
|
content: "Trying a lighter forum layout for longer-form notes.",
|
|
tags: [],
|
|
},
|
|
];
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
async function handleGetUserNotes(
|
|
args: {
|
|
pubkey: string;
|
|
limit?: number | null;
|
|
before?: number | null;
|
|
beforeId?: string | null;
|
|
},
|
|
config: E2eConfig | undefined,
|
|
): Promise<RawUserNotesResponse> {
|
|
const identity = getIdentity(config);
|
|
if (!identity) {
|
|
const notes = getMockUserNotes(args.pubkey)
|
|
.filter((note) => (args.before ? note.created_at < args.before : true))
|
|
.sort((left, right) => right.created_at - left.created_at)
|
|
.slice(0, args.limit ?? 50);
|
|
|
|
return {
|
|
notes,
|
|
next_cursor: null,
|
|
};
|
|
}
|
|
|
|
// Query kind:1 notes for the user
|
|
const limit = args.limit ?? 50;
|
|
const filter: Record<string, unknown> = {
|
|
kinds: [1],
|
|
authors: [args.pubkey],
|
|
limit,
|
|
};
|
|
if (args.before !== undefined && args.before !== null) {
|
|
filter.until = args.before;
|
|
}
|
|
const events = await relayQuery(config, [filter]);
|
|
const notes = events.map((ev) => ({
|
|
id: ev.id,
|
|
pubkey: ev.pubkey,
|
|
content: ev.content,
|
|
created_at: ev.created_at,
|
|
tags: ev.tags,
|
|
}));
|
|
return { notes, next_cursor: null };
|
|
}
|
|
|
|
async function handleGetGlobalNotes(
|
|
args: { limit?: number | null; before?: number | null } | null,
|
|
config: E2eConfig | undefined,
|
|
): Promise<RawUserNotesResponse> {
|
|
const notes = [
|
|
...getMockUserNotes(DEFAULT_MOCK_IDENTITY.pubkey),
|
|
...getMockUserNotes(ALICE_PUBKEY),
|
|
]
|
|
.filter((note) => (args?.before ? note.created_at < args.before : true))
|
|
.sort((left, right) => right.created_at - left.created_at)
|
|
.slice(0, args?.limit ?? 50);
|
|
|
|
if (!getIdentity(config)) {
|
|
return { notes, next_cursor: null };
|
|
}
|
|
|
|
const events = await relayQuery(config, [
|
|
{ kinds: [1], limit: args?.limit ?? 50, until: args?.before ?? undefined },
|
|
]);
|
|
return {
|
|
notes: events.map((ev) => ({
|
|
id: ev.id,
|
|
pubkey: ev.pubkey,
|
|
content: ev.content,
|
|
created_at: ev.created_at,
|
|
tags: ev.tags,
|
|
})),
|
|
next_cursor: null,
|
|
};
|
|
}
|
|
|
|
function handleGetNotesTimeline(args: {
|
|
pubkeys?: string[];
|
|
limitPerUser?: number | null;
|
|
}) {
|
|
const pubkeys = args.pubkeys ?? [];
|
|
const limitPerUser = args.limitPerUser ?? 10;
|
|
const notes = pubkeys
|
|
.flatMap((pubkey) => getMockUserNotes(pubkey).slice(0, limitPerUser))
|
|
.sort((left, right) => right.created_at - left.created_at);
|
|
return { notes, next_cursor: null };
|
|
}
|
|
|
|
function handleGetNote(args: { noteId?: string }) {
|
|
const noteId = args.noteId;
|
|
return (
|
|
[
|
|
...getMockUserNotes(DEFAULT_MOCK_IDENTITY.pubkey),
|
|
...getMockUserNotes(ALICE_PUBKEY),
|
|
].find((note) => note.id === noteId) ?? null
|
|
);
|
|
}
|
|
|
|
function handleGetNoteReactions() {
|
|
return [];
|
|
}
|
|
|
|
function handleGetLikedNotes(): RawUserNotesResponse {
|
|
return { notes: [], next_cursor: null };
|
|
}
|
|
|
|
// A random 64-hex event id, matching the shape of real Nostr event ids
|
|
// (sha256 → 64 hex). Most mock events use the 32-hex `createMockEvent` default,
|
|
// but kind:7 reactions need a real 64-hex id: the timeline's deletion path only
|
|
// accepts 64-hex `e` tags (getDeletionTargets in formatTimelineMessages.ts), so
|
|
// a kind:5 targeting a 32-hex reaction id would be silently ignored and the
|
|
// reaction pill would never clear on toggle-off.
|
|
// --- Mock projects (NIP-34 repo announcements + git activity) ---
|
|
// Deterministic fixtures so the Projects view (cards, stat pills, and the
|
|
// contribution heatmap) renders with data in screenshots and e2e specs.
|
|
|
|
const MOCK_PROJECT_SEEDS = [
|
|
{
|
|
dtag: "buzz",
|
|
name: "buzz",
|
|
description:
|
|
"Relay, desktop, and mobile clients for the Buzz community platform.",
|
|
cloneUrl: `${DEFAULT_RELAY_HTTP_URL}/git/${MOCK_IDENTITY_PUBKEY}/buzz`,
|
|
webUrl: null,
|
|
owner: MOCK_IDENTITY_PUBKEY,
|
|
contributors: [ALICE_PUBKEY, BOB_PUBKEY, CHARLIE_PUBKEY],
|
|
activityLevel: 4,
|
|
},
|
|
{
|
|
dtag: "relay-tools",
|
|
name: "relay-tools",
|
|
description: "Operator tooling and admin CLI for relay deployments.",
|
|
cloneUrl: "https://github.com/block/relay-tools.git",
|
|
webUrl: "https://github.com/block/relay-tools",
|
|
owner: ALICE_PUBKEY,
|
|
contributors: [MOCK_IDENTITY_PUBKEY, BOB_PUBKEY],
|
|
activityLevel: 2,
|
|
},
|
|
{
|
|
dtag: "design-system",
|
|
name: "design-system",
|
|
description: "Shared UI tokens, typography ramps, and component library.",
|
|
cloneUrl: `${DEFAULT_RELAY_HTTP_URL}/git/${BOB_PUBKEY}/design-system`,
|
|
webUrl: null,
|
|
owner: BOB_PUBKEY,
|
|
contributors: [ALICE_PUBKEY],
|
|
activityLevel: 1,
|
|
},
|
|
] as const;
|
|
|
|
const MOCK_PROJECT_SUBJECTS = [
|
|
"Fix reconnect backoff jitter",
|
|
"Polish overview cards",
|
|
"Add contribution heatmap",
|
|
"Refactor filter matching",
|
|
"Speed up event dedup",
|
|
"Handle empty clone URLs",
|
|
"Update onboarding copy",
|
|
"Tighten p-gate checks",
|
|
];
|
|
|
|
const MOCK_PROJECT_KINDS = new Set<number>([
|
|
KIND_PROJECT_ANNOUNCEMENT,
|
|
KIND_REPO_ANNOUNCEMENT,
|
|
KIND_REPO_STATE,
|
|
KIND_GIT_PATCH,
|
|
KIND_GIT_PULL_REQUEST,
|
|
KIND_GIT_PR_UPDATE,
|
|
KIND_GIT_ISSUE,
|
|
KIND_GIT_STATUS_OPEN,
|
|
KIND_GIT_STATUS_MERGED,
|
|
KIND_GIT_STATUS_CLOSED,
|
|
KIND_GIT_STATUS_DRAFT,
|
|
]);
|
|
|
|
function mulberry32(seed: number) {
|
|
let state = seed;
|
|
return () => {
|
|
state |= 0;
|
|
state = (state + 0x6d2b79f5) | 0;
|
|
let t = Math.imul(state ^ (state >>> 15), 1 | state);
|
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
};
|
|
}
|
|
|
|
let mockProjectEventStore: RelayEvent[] | null = null;
|
|
const MOCK_PROJECT_BRANCHES_KEY = "buzz-e2e-project-branches";
|
|
|
|
function readMockProjectBranches(): Record<string, Record<string, string>> {
|
|
try {
|
|
return JSON.parse(
|
|
window.sessionStorage.getItem(MOCK_PROJECT_BRANCHES_KEY) ?? "{}",
|
|
);
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function writeMockProjectBranch(
|
|
dtag: string,
|
|
branch: string,
|
|
commit: string | null,
|
|
) {
|
|
const projects = readMockProjectBranches();
|
|
const branches = { ...(projects[dtag] ?? {}) };
|
|
if (commit) {
|
|
branches[branch] = commit;
|
|
} else {
|
|
delete branches[branch];
|
|
}
|
|
projects[dtag] = branches;
|
|
window.sessionStorage.setItem(
|
|
MOCK_PROJECT_BRANCHES_KEY,
|
|
JSON.stringify(projects),
|
|
);
|
|
}
|
|
|
|
function buildMockProjectEvents(): RelayEvent[] {
|
|
if (window.__BUZZ_E2E_EMPTY_PROJECTS__) {
|
|
return [];
|
|
}
|
|
|
|
const events: RelayEvent[] = [];
|
|
const daySeconds = 86_400;
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const historyDays = 26 * 7;
|
|
|
|
for (const [projectIndex, seed] of MOCK_PROJECT_SEEDS.entries()) {
|
|
const owner =
|
|
projectIndex === 0
|
|
? (window.__BUZZ_E2E_PROJECT_OWNER_OVERRIDE__ ?? seed.owner)
|
|
: seed.owner;
|
|
const repoAddress = `${KIND_REPO_ANNOUNCEMENT}:${owner}:${seed.dtag}`;
|
|
const authors = [seed.owner, ...seed.contributors];
|
|
const random = mulberry32(projectIndex + 1);
|
|
|
|
events.push(
|
|
createMockEvent(
|
|
KIND_REPO_ANNOUNCEMENT,
|
|
seed.description,
|
|
[
|
|
["d", seed.dtag],
|
|
["name", seed.name],
|
|
["description", seed.description],
|
|
[
|
|
"buzz-channel",
|
|
getConfig()?.mock?.projectAccessChannelId ??
|
|
STARTER_PROJECT_HOME_CHANNEL_ID,
|
|
],
|
|
["clone", seed.cloneUrl],
|
|
...(seed.webUrl ? [["web", seed.webUrl]] : []),
|
|
...seed.contributors.map((pubkey) => ["p", pubkey]),
|
|
],
|
|
owner,
|
|
now - (historyDays + 30 + projectIndex) * daySeconds,
|
|
`mock-project-${seed.dtag}`.replace(/[^a-zA-Z0-9]/g, ""),
|
|
),
|
|
);
|
|
events.push(
|
|
createMockEvent(
|
|
KIND_REPO_STATE,
|
|
"",
|
|
[
|
|
["d", seed.dtag],
|
|
[
|
|
"HEAD",
|
|
`ref: refs/heads/${
|
|
projectIndex === 0
|
|
? (getConfig()?.mock?.projectHeadBranch ?? "main")
|
|
: "main"
|
|
}`,
|
|
],
|
|
["refs/heads/main", "0123456789abcdef0123456789abcdef01234567"],
|
|
["refs/tags/v1.0.0", "0123456789abcdef0123456789abcdef01234567"],
|
|
...Object.entries(readMockProjectBranches()[seed.dtag] ?? {}).map(
|
|
([branch, commit]) => [`refs/heads/${branch}`, commit],
|
|
),
|
|
],
|
|
getConfig()?.mock?.relaySelf ?? owner,
|
|
now - projectIndex,
|
|
`mock-repo-state-${seed.dtag}`.replace(/[^a-zA-Z0-9]/g, ""),
|
|
),
|
|
);
|
|
|
|
for (let dayOffset = historyDays; dayOffset >= 0; dayOffset -= 1) {
|
|
// Roughly half the days are quiet; busy days scale with activityLevel.
|
|
if (random() < 0.5) continue;
|
|
const dayEventCount = 1 + Math.floor(random() * seed.activityLevel);
|
|
|
|
for (let index = 0; index < dayEventCount; index += 1) {
|
|
const createdAt =
|
|
now - dayOffset * daySeconds - Math.floor(random() * 10) * 3_600;
|
|
const author = authors[Math.floor(random() * authors.length)];
|
|
const subject =
|
|
MOCK_PROJECT_SUBJECTS[
|
|
Math.floor(random() * MOCK_PROJECT_SUBJECTS.length)
|
|
];
|
|
const commitHash = `${seed.dtag}${dayOffset}x${index}`
|
|
.padEnd(40, "0")
|
|
.slice(0, 40);
|
|
const roll = random();
|
|
const kind =
|
|
roll < 0.7
|
|
? KIND_GIT_PATCH
|
|
: roll < 0.85
|
|
? KIND_GIT_PULL_REQUEST
|
|
: KIND_GIT_ISSUE;
|
|
const tags = [
|
|
["a", repoAddress],
|
|
["subject", subject],
|
|
...(kind === KIND_GIT_ISSUE ? [] : [["c", commitHash]]),
|
|
...(kind === KIND_GIT_PULL_REQUEST
|
|
? [
|
|
["h", "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"],
|
|
["branch-name", `feature/mock-${dayOffset}-${index}`],
|
|
["clone", seed.cloneUrl],
|
|
]
|
|
: []),
|
|
];
|
|
|
|
events.push(createMockEvent(kind, subject, tags, author, createdAt));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!window.__BUZZ_E2E_REPOSITORY_ONLY_PROJECTS__) {
|
|
const projectOwner =
|
|
window.__BUZZ_E2E_PROJECT_OWNER_OVERRIDE__ ?? MOCK_PROJECT_SEEDS[0].owner;
|
|
events.push(
|
|
createMockEvent(
|
|
KIND_PROJECT_ANNOUNCEMENT,
|
|
"",
|
|
[
|
|
["d", "buzz"],
|
|
["name", "buzz"],
|
|
["description", "The complete Buzz community platform."],
|
|
["a", `${KIND_REPO_ANNOUNCEMENT}:${projectOwner}:buzz`],
|
|
["a", `${KIND_REPO_ANNOUNCEMENT}:${ALICE_PUBKEY}:relay-tools`],
|
|
[
|
|
"buzz-channel",
|
|
getConfig()?.mock?.projectAccessChannelId ??
|
|
STARTER_PROJECT_HOME_CHANNEL_ID,
|
|
],
|
|
["buzz-related-channel", "9dae0116-799b-5071-a0a8-fdd30a91a35d"],
|
|
],
|
|
projectOwner,
|
|
now,
|
|
"project-buzz".padEnd(64, "0"),
|
|
),
|
|
);
|
|
}
|
|
|
|
return events;
|
|
}
|
|
|
|
function getMockProjectEventStore(): RelayEvent[] {
|
|
if (!mockProjectEventStore) {
|
|
mockProjectEventStore = buildMockProjectEvents();
|
|
// Append any extra events injected by the test via addInitScript before
|
|
// the app boots. This lets a test seed standalone repositories or other
|
|
// project-scoped events without modifying the fixed seed data.
|
|
const extras = window.__BUZZ_E2E_EXTRA_PROJECT_EVENTS__;
|
|
if (extras && extras.length > 0) {
|
|
mockProjectEventStore.push(...(extras as RelayEvent[]));
|
|
}
|
|
}
|
|
return mockProjectEventStore;
|
|
}
|
|
|
|
/** Project-scoped publishes (PR/issue comments, NIP-34 status events) carry
|
|
* a repo-address `a` tag instead of a channel `h` tag — store them with the
|
|
* seeded project events so refetches see them. */
|
|
function isMockProjectScopedEvent(event: RelayEvent): boolean {
|
|
const hasRepoAddressTag = event.tags.some(
|
|
(tag) => tag[0] === "a" && (tag[1] ?? "").startsWith("30617:"),
|
|
);
|
|
return (
|
|
(event.kind === KIND_REPO_ANNOUNCEMENT || hasRepoAddressTag) &&
|
|
(event.kind === 1 || MOCK_PROJECT_KINDS.has(event.kind))
|
|
);
|
|
}
|
|
|
|
function filterMockProjectEvents(filter: MockFilter): RelayEvent[] {
|
|
const authors = filter.authors?.map((author) => author.toLowerCase());
|
|
const idsSet =
|
|
filter.ids && filter.ids.length > 0 ? new Set(filter.ids) : null;
|
|
return getMockProjectEventStore()
|
|
.filter((event) => {
|
|
if (filter.kinds && !filter.kinds.includes(event.kind)) return false;
|
|
if (authors && !authors.includes(event.pubkey.toLowerCase())) {
|
|
return false;
|
|
}
|
|
// Filter by event ids when present — required for lost-ACK recovery queries.
|
|
if (idsSet && !idsSet.has(event.id)) return false;
|
|
if (
|
|
filter["#d"] &&
|
|
!event.tags.some(
|
|
(tag) => tag[0] === "d" && filter["#d"]?.includes(tag[1]),
|
|
)
|
|
) {
|
|
return false;
|
|
}
|
|
if (
|
|
filter["#a"] &&
|
|
!event.tags.some(
|
|
(tag) => tag[0] === "a" && filter["#a"]?.includes(tag[1]),
|
|
)
|
|
) {
|
|
return false;
|
|
}
|
|
if (
|
|
filter["#e"] &&
|
|
!event.tags.some(
|
|
(tag) => tag[0] === "e" && filter["#e"]?.includes(tag[1]),
|
|
)
|
|
) {
|
|
return false;
|
|
}
|
|
// Inclusive `until`, like the relay's SQL `created_at <= $until`.
|
|
if (filter.until !== undefined && event.created_at > filter.until) {
|
|
return false;
|
|
}
|
|
return true;
|
|
})
|
|
.sort((left, right) => right.created_at - left.created_at)
|
|
.slice(0, filter.limit ?? 500);
|
|
}
|
|
|
|
function mockEventId(): string {
|
|
const bytes = new Uint8Array(32);
|
|
crypto.getRandomValues(bytes);
|
|
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
}
|
|
|
|
function createMockEvent(
|
|
kind: number,
|
|
content: string,
|
|
tags: string[][],
|
|
pubkey = DEFAULT_MOCK_IDENTITY.pubkey,
|
|
createdAt = Math.floor(Date.now() / 1000),
|
|
// 64 hex chars like a real event id — share-link builders reject shorter
|
|
// ids, so copy-link buttons only render with full-length ids.
|
|
id = (crypto.randomUUID() + crypto.randomUUID()).replace(/-/g, ""),
|
|
): RelayEvent {
|
|
return {
|
|
id,
|
|
pubkey,
|
|
created_at: createdAt,
|
|
kind,
|
|
tags,
|
|
content,
|
|
sig: "mocksig".repeat(20).slice(0, 128),
|
|
};
|
|
}
|
|
|
|
async function signWithIdentity(
|
|
identity: TestIdentity,
|
|
template: {
|
|
kind: number;
|
|
content: string;
|
|
createdAt?: number;
|
|
tags: string[][];
|
|
},
|
|
) {
|
|
const secretKey = hexToBytes(identity.privateKey);
|
|
|
|
return finalizeEvent(
|
|
{
|
|
kind: template.kind,
|
|
content: template.content,
|
|
tags: template.tags,
|
|
created_at: template.createdAt ?? Math.floor(Date.now() / 1000),
|
|
},
|
|
secretKey,
|
|
);
|
|
}
|
|
|
|
async function assertOk(response: Response) {
|
|
if (response.ok) {
|
|
return;
|
|
}
|
|
|
|
const body = await response.text();
|
|
throw new Error(body || `Request failed with ${response.status}`);
|
|
}
|
|
|
|
function getRelayIdentity(config: E2eConfig | undefined): TestIdentity {
|
|
const identity = getIdentity(config);
|
|
if (!identity) {
|
|
throw new Error("Relay identity required.");
|
|
}
|
|
|
|
return identity;
|
|
}
|
|
|
|
async function relayJsonRequest<T>(
|
|
config: E2eConfig | undefined,
|
|
path: string,
|
|
init: RequestInit = {},
|
|
): Promise<T> {
|
|
const identity = getRelayIdentity(config);
|
|
const headers = new Headers(init.headers);
|
|
|
|
headers.set("X-Pubkey", identity.pubkey);
|
|
if (init.body && !headers.has("Content-Type")) {
|
|
headers.set("Content-Type", "application/json");
|
|
}
|
|
|
|
const response = await fetch(`${getRelayHttpUrl(config)}${path}`, {
|
|
...init,
|
|
headers,
|
|
});
|
|
await assertOk(response);
|
|
return response.json() as Promise<T>;
|
|
}
|
|
|
|
/**
|
|
* Query the relay via POST /query (pure Nostr HTTP bridge).
|
|
* Returns an array of raw Nostr events matching the filters.
|
|
*/
|
|
async function relayQuery(
|
|
config: E2eConfig | undefined,
|
|
filters: Array<Record<string, unknown>>,
|
|
): Promise<RelayEvent[]> {
|
|
const identity = getRelayIdentity(config);
|
|
if (
|
|
!filters.every((filter) =>
|
|
isPGatedFilterAuthorized(filter, identity.pubkey),
|
|
)
|
|
) {
|
|
throw new Error(P_GATED_REJECTION_MESSAGE);
|
|
}
|
|
|
|
const response = await fetch(`${getRelayHttpUrl(config)}/query`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"X-Pubkey": identity.pubkey,
|
|
},
|
|
body: JSON.stringify(filters),
|
|
});
|
|
await assertOk(response);
|
|
return response.json() as Promise<RelayEvent[]>;
|
|
}
|
|
|
|
async function submitSignedEvent(
|
|
config: E2eConfig | undefined,
|
|
template: { kind: number; content: string; tags: string[][] },
|
|
): Promise<{ event_id: string; accepted: boolean; message: string }> {
|
|
const identity = getRelayIdentity(config);
|
|
const signed = await signWithIdentity(identity, template);
|
|
return relayJsonRequest(config, "/events", {
|
|
method: "POST",
|
|
body: JSON.stringify(signed),
|
|
});
|
|
}
|
|
|
|
/** Build the channel-id → last-message-at map from the returned channel list. */
|
|
function buildLastMessages(
|
|
channels: Array<{ id: string; last_message_at: string | null }>,
|
|
): Record<string, string> {
|
|
const map: Record<string, string> = {};
|
|
for (const ch of channels) {
|
|
if (ch.last_message_at !== null) {
|
|
map[ch.id] = ch.last_message_at;
|
|
}
|
|
}
|
|
return map;
|
|
}
|
|
|
|
/**
|
|
* Mirrors the real backend split: `get_channels` (member-only) drops
|
|
* non-member open channels, while `get_open_channel_directory` keeps the
|
|
* discovery superset. Filtering on `is_member` lets specs assert the poll no
|
|
* longer leaks the whole relay's open channels into the member list.
|
|
*/
|
|
function scopeChannelsForMembership<T extends { is_member: boolean }>(
|
|
channels: T[],
|
|
scope: "member-only" | "open-directory",
|
|
): T[] {
|
|
return scope === "open-directory"
|
|
? channels
|
|
: channels.filter((channel) => channel.is_member);
|
|
}
|
|
|
|
async function handleGetChannels(
|
|
payload: unknown,
|
|
config: E2eConfig | undefined,
|
|
scope: "member-only" | "open-directory" = "member-only",
|
|
) {
|
|
const channelsReadDelayMs = config?.mock?.channelsReadDelayMs ?? 0;
|
|
if (channelsReadDelayMs > 0) {
|
|
await new Promise((resolve) =>
|
|
window.setTimeout(resolve, channelsReadDelayMs),
|
|
);
|
|
}
|
|
|
|
const channelsReadError =
|
|
config?.mock?.channelsReadErrors?.shift() ??
|
|
config?.mock?.channelsReadError;
|
|
if (channelsReadError) {
|
|
throw new Error(channelsReadError);
|
|
}
|
|
|
|
const identity = getIdentity(config);
|
|
if (!identity) {
|
|
// The hash is constant ("mock-hash") and full lists remain the default:
|
|
// mock channel data mutates during tests while the hash does not. Focused
|
|
// snapshot specs can opt into the not-modified branch explicitly.
|
|
const channels = scopeChannelsForMembership(
|
|
listMockChannels(config),
|
|
scope,
|
|
);
|
|
const hash = "mock-hash";
|
|
const knownHash = (payload as { knownHash?: unknown } | null)?.knownHash;
|
|
const forcedNotModified =
|
|
(config?.mock?.channelsNotModifiedResponses ?? 0) > 0;
|
|
if (forcedNotModified && config?.mock) {
|
|
config.mock.channelsNotModifiedResponses =
|
|
(config.mock.channelsNotModifiedResponses ?? 1) - 1;
|
|
}
|
|
return {
|
|
hash,
|
|
channels:
|
|
forcedNotModified ||
|
|
(config?.mock?.honorChannelsKnownHash && knownHash === hash)
|
|
? null
|
|
: channels,
|
|
last_messages: buildLastMessages(channels),
|
|
};
|
|
}
|
|
|
|
// Pure Nostr: query kind:39002 (membership) for our pubkey, extract channel
|
|
// UUIDs from d-tags, then query kind:39000 (metadata) for those channels.
|
|
const memberEvents = await relayQuery(config, [
|
|
{ kinds: [39002], "#p": [identity.pubkey], limit: 1000 },
|
|
]);
|
|
|
|
const channelIds = [
|
|
...new Set(
|
|
memberEvents.flatMap((ev) =>
|
|
(ev.tags ?? [])
|
|
.filter((t: string[]) => t[0] === "d")
|
|
.map((t: string[]) => t[1]),
|
|
),
|
|
),
|
|
];
|
|
|
|
// Also fetch ALL open channel metadata (for channel browser — shows joinable channels)
|
|
const allMetaEvents = await relayQuery(config, [
|
|
{ kinds: [39000], limit: 200 },
|
|
]);
|
|
|
|
// Merge: use all metadata events, mark membership
|
|
const memberSet = new Set(channelIds);
|
|
const metaEvents = allMetaEvents;
|
|
|
|
// NIP-DV: query the viewer's latest DM visibility snapshot (kind:30622).
|
|
// The snapshot is `#p`-gated to its owner, so we query by `#p`=my pubkey.
|
|
// Its `h` tags are the DM channel ids to hide from the sidebar.
|
|
const visibilityEvents = await relayQuery(config, [
|
|
{ kinds: [KIND_DM_VISIBILITY], "#p": [identity.pubkey], limit: 1 },
|
|
]);
|
|
const latestVisibility = visibilityEvents.reduce<RelayEvent | null>(
|
|
(latest, ev) =>
|
|
!latest || ev.created_at > latest.created_at ? ev : latest,
|
|
null,
|
|
);
|
|
const hiddenDms = new Set(
|
|
((latestVisibility?.tags ?? []) as string[][])
|
|
.filter((t) => t[0] === "h")
|
|
.map((t) => t[1]),
|
|
);
|
|
|
|
// Convert kind:39000 events to the RawChannel shape the frontend expects.
|
|
const channels = metaEvents
|
|
.map((ev) => {
|
|
const tags = (ev.tags ?? []) as string[][];
|
|
const getTag = (name: string) =>
|
|
tags.find((t) => t[0] === name)?.[1] ?? null;
|
|
const channelId = getTag("d") ?? "";
|
|
const channelType = getTag("t") ?? "stream";
|
|
const isPrivate = tags.some((t) => t[0] === "private");
|
|
const isArchived = tags.some(
|
|
(t) => t[0] === "archived" && t[1] === "true",
|
|
);
|
|
|
|
// Get participant pubkeys from the membership event for this channel
|
|
const memberEvent = memberEvents.find((me) =>
|
|
(me.tags ?? []).some(
|
|
(t: string[]) => t[0] === "d" && t[1] === channelId,
|
|
),
|
|
);
|
|
const pTags = memberEvent
|
|
? ((memberEvent.tags ?? []) as string[][])
|
|
.filter((t) => t[0] === "p")
|
|
.map((t) => t[1])
|
|
: [];
|
|
|
|
return {
|
|
id: channelId,
|
|
name: getTag("name") ?? "",
|
|
description: getTag("about") ?? "",
|
|
channel_type: channelType as "stream" | "forum" | "dm",
|
|
visibility: (isPrivate ? "private" : "open") as "open" | "private",
|
|
topic: getTag("topic") ?? null,
|
|
purpose: getTag("purpose") ?? null,
|
|
member_count: pTags.length,
|
|
last_message_at: null,
|
|
archived_at: isArchived ? new Date().toISOString() : null,
|
|
participants: pTags,
|
|
participant_pubkeys: pTags,
|
|
ttl_seconds: getTag("ttl") ? Number(getTag("ttl")) : null,
|
|
ttl_deadline: getTag("ttl_deadline") ?? null,
|
|
is_member: memberSet.has(channelId),
|
|
};
|
|
})
|
|
.filter((c) => c.channel_type !== "dm" || !hiddenDms.has(c.id));
|
|
|
|
const scopedChannels = scopeChannelsForMembership(channels, scope);
|
|
|
|
return {
|
|
hash: "mock-hash",
|
|
channels: scopedChannels,
|
|
last_messages: buildLastMessages(scopedChannels),
|
|
};
|
|
}
|
|
|
|
async function runGetProfile(config: E2eConfig | undefined) {
|
|
const identity = getIdentity(config);
|
|
const profileReadDelayMs = config?.mock?.profileReadDelayMs ?? 0;
|
|
if (profileReadDelayMs > 0) {
|
|
await new Promise<void>((resolve) => {
|
|
window.setTimeout(resolve, profileReadDelayMs);
|
|
});
|
|
}
|
|
|
|
const forcedHasProfileEvent = config?.mock?.profileHasEvent;
|
|
if (forcedHasProfileEvent !== undefined) {
|
|
return {
|
|
...cloneProfile(ensureMockProfile(config)),
|
|
has_profile_event: forcedHasProfileEvent,
|
|
};
|
|
}
|
|
if (!identity) {
|
|
const profileReadError = config?.mock?.profileReadError;
|
|
if (profileReadError) {
|
|
throw new Error(profileReadError);
|
|
}
|
|
|
|
return cloneProfile(ensureMockProfile(config));
|
|
}
|
|
|
|
// Pure Nostr: query kind:0 (profile metadata) for our pubkey.
|
|
const events = await relayQuery(config, [
|
|
{ kinds: [0], authors: [identity.pubkey], limit: 1 },
|
|
]);
|
|
if (events.length === 0) {
|
|
return {
|
|
pubkey: identity.pubkey,
|
|
display_name: null,
|
|
about: null,
|
|
avatar_url: null,
|
|
nip05_handle: null,
|
|
owner_pubkey: null,
|
|
has_profile_event: false,
|
|
};
|
|
}
|
|
const content = JSON.parse(events[0].content ?? "{}");
|
|
return {
|
|
pubkey: identity.pubkey,
|
|
display_name: content.display_name ?? content.name ?? null,
|
|
about: content.about ?? null,
|
|
avatar_url: content.picture ?? null,
|
|
nip05_handle: content.nip05 ?? null,
|
|
owner_pubkey: null,
|
|
has_profile_event: true,
|
|
};
|
|
}
|
|
|
|
async function handleGetProfile(config: E2eConfig | undefined) {
|
|
if (!config?.mock?.deferProfileReads || profileReadsReleased) {
|
|
return runGetProfile(config);
|
|
}
|
|
|
|
return new Promise<unknown>((resolve, reject) => {
|
|
deferredProfileReadQueue.push({
|
|
resolve,
|
|
reject,
|
|
run: () => runGetProfile(config),
|
|
});
|
|
});
|
|
}
|
|
|
|
async function handleUpdateProfile(
|
|
args: {
|
|
displayName?: string;
|
|
avatarUrl?: string;
|
|
about?: string;
|
|
nip05Handle?: string;
|
|
},
|
|
config: E2eConfig | undefined,
|
|
) {
|
|
const identity = getIdentity(config);
|
|
if (!identity) {
|
|
const profileUpdateError = config?.mock?.profileUpdateError;
|
|
const profileUpdateErrors = config?.mock?.profileUpdateErrors;
|
|
const nextProfileUpdateError = profileUpdateErrors?.shift();
|
|
if (nextProfileUpdateError) {
|
|
throw new Error(nextProfileUpdateError);
|
|
}
|
|
|
|
if (profileUpdateError) {
|
|
if (config?.mock) {
|
|
config.mock.profileUpdateError = undefined;
|
|
}
|
|
throw new Error(profileUpdateError);
|
|
}
|
|
|
|
const profile = ensureMockProfile(config);
|
|
const hasDisplayNameUpdate = typeof args.displayName === "string";
|
|
const hasAvatarUrlUpdate = typeof args.avatarUrl === "string";
|
|
const hasAboutUpdate = typeof args.about === "string";
|
|
const hasNip05HandleUpdate = typeof args.nip05Handle === "string";
|
|
const nextDisplayName = args.displayName?.trim() ?? "";
|
|
const nextAvatarUrl = args.avatarUrl?.trim() ?? "";
|
|
const nextAbout = args.about?.trim() ?? "";
|
|
const nextNip05Handle = args.nip05Handle?.trim() ?? "";
|
|
|
|
if (hasDisplayNameUpdate && nextDisplayName !== profile.display_name) {
|
|
profile.display_name = nextDisplayName || null;
|
|
applyMockDisplayName(profile.pubkey, profile.display_name);
|
|
}
|
|
if (hasAvatarUrlUpdate && nextAvatarUrl !== profile.avatar_url) {
|
|
profile.avatar_url = nextAvatarUrl || null;
|
|
}
|
|
if (hasAboutUpdate && nextAbout !== profile.about) {
|
|
profile.about = nextAbout || null;
|
|
}
|
|
if (hasNip05HandleUpdate && nextNip05Handle !== profile.nip05_handle) {
|
|
profile.nip05_handle = nextNip05Handle || null;
|
|
}
|
|
|
|
return cloneProfile(profile);
|
|
}
|
|
|
|
// Read-merge-write: fetch current profile, merge, sign kind:0.
|
|
const currentEvents = await relayQuery(config, [
|
|
{ kinds: [0], authors: [identity.pubkey], limit: 1 },
|
|
]);
|
|
const currentContent = currentEvents[0]
|
|
? JSON.parse(currentEvents[0].content ?? "{}")
|
|
: {};
|
|
const profileContent = JSON.stringify({
|
|
display_name: args.displayName ?? currentContent.display_name ?? undefined,
|
|
name: currentContent.display_name ?? undefined,
|
|
picture: args.avatarUrl ?? currentContent.picture ?? undefined,
|
|
about: args.about ?? currentContent.about ?? undefined,
|
|
nip05: args.nip05Handle ?? currentContent.nip05 ?? undefined,
|
|
});
|
|
await submitSignedEvent(config, {
|
|
kind: 0,
|
|
content: profileContent,
|
|
tags: [],
|
|
});
|
|
|
|
// Return the updated profile in RawProfile shape
|
|
const updated = JSON.parse(profileContent);
|
|
return {
|
|
pubkey: identity.pubkey,
|
|
display_name: updated.display_name ?? null,
|
|
about: updated.about ?? null,
|
|
avatar_url: updated.picture ?? null,
|
|
nip05_handle: updated.nip05 ?? null,
|
|
owner_pubkey: null,
|
|
has_profile_event: true,
|
|
};
|
|
}
|
|
|
|
async function handleGetUserProfile(
|
|
args: {
|
|
pubkey?: string;
|
|
},
|
|
config: E2eConfig | undefined,
|
|
) {
|
|
const identity = getIdentity(config);
|
|
if (!identity) {
|
|
const pubkey = (args.pubkey ?? getMockMemberPubkey(config)).toLowerCase();
|
|
const profile = getMockProfileByPubkey(pubkey);
|
|
if (!profile) {
|
|
throw new Error(`User ${pubkey} not found.`);
|
|
}
|
|
|
|
return cloneProfile(profile);
|
|
}
|
|
|
|
const targetPubkey = args.pubkey ?? identity.pubkey;
|
|
const events = await relayQuery(config, [
|
|
{ kinds: [0], authors: [targetPubkey], limit: 1 },
|
|
]);
|
|
if (events.length === 0) {
|
|
return {
|
|
pubkey: targetPubkey,
|
|
display_name: null,
|
|
about: null,
|
|
avatar_url: null,
|
|
nip05_handle: null,
|
|
owner_pubkey: null,
|
|
has_profile_event: false,
|
|
};
|
|
}
|
|
const content = JSON.parse(events[0].content ?? "{}");
|
|
return {
|
|
pubkey: targetPubkey,
|
|
display_name: content.display_name ?? content.name ?? null,
|
|
about: content.about ?? null,
|
|
avatar_url: content.picture ?? null,
|
|
nip05_handle: content.nip05 ?? null,
|
|
owner_pubkey: null,
|
|
has_profile_event: true,
|
|
};
|
|
}
|
|
|
|
async function handleGetUsersBatch(
|
|
args: {
|
|
pubkeys: string[];
|
|
},
|
|
config: E2eConfig | undefined,
|
|
) {
|
|
const usersBatchDelayMs = config?.mock?.usersBatchDelayMs ?? 0;
|
|
if (usersBatchDelayMs > 0) {
|
|
await new Promise<void>((resolve) => {
|
|
window.setTimeout(resolve, usersBatchDelayMs);
|
|
});
|
|
}
|
|
if (holdUsersBatch) {
|
|
await new Promise<void>((resolve) => {
|
|
heldUsersBatchReleases.push(resolve);
|
|
});
|
|
}
|
|
|
|
const identity = getIdentity(config);
|
|
if (!identity) {
|
|
const profiles: RawUsersBatchResponse["profiles"] = {};
|
|
const missing: string[] = [];
|
|
|
|
for (const pubkey of args.pubkeys) {
|
|
const normalizedPubkey = pubkey.toLowerCase();
|
|
const profile = getMockProfileByPubkey(normalizedPubkey);
|
|
|
|
if (!profile) {
|
|
missing.push(pubkey);
|
|
continue;
|
|
}
|
|
|
|
profiles[normalizedPubkey] = {
|
|
display_name: profile.display_name,
|
|
name: profile.name ?? null,
|
|
avatar_url: profile.avatar_url,
|
|
nip05_handle: profile.nip05_handle,
|
|
owner_pubkey: profile.owner_pubkey,
|
|
is_agent: profile.is_agent ?? false,
|
|
};
|
|
}
|
|
|
|
return {
|
|
profiles,
|
|
missing,
|
|
};
|
|
}
|
|
|
|
const events = await relayQuery(config, [
|
|
{ kinds: [0], authors: args.pubkeys, limit: args.pubkeys.length },
|
|
]);
|
|
const profiles: RawUsersBatchResponse["profiles"] = {};
|
|
const found = new Set<string>();
|
|
for (const ev of events) {
|
|
const pk = ev.pubkey?.toLowerCase() ?? "";
|
|
found.add(pk);
|
|
const content = JSON.parse(ev.content ?? "{}");
|
|
profiles[pk] = {
|
|
display_name: content.display_name ?? content.name ?? null,
|
|
name: content.name ?? null,
|
|
avatar_url: content.picture ?? null,
|
|
nip05_handle: content.nip05 ?? null,
|
|
owner_pubkey:
|
|
((ev.tags ?? []) as string[][]).find(
|
|
(tag) => Array.isArray(tag) && tag[0] === "auth" && tag.length === 4,
|
|
)?.[1] ?? null,
|
|
is_agent: Array.isArray(ev.tags)
|
|
? ev.tags.some(
|
|
(tag) =>
|
|
Array.isArray(tag) && tag[0] === "auth" && tag.length === 4,
|
|
)
|
|
: false,
|
|
};
|
|
}
|
|
for (const pubkey of args.pubkeys) {
|
|
const normalizedPubkey = pubkey.toLowerCase();
|
|
if (found.has(normalizedPubkey)) {
|
|
continue;
|
|
}
|
|
|
|
const profile = getMockProfileByPubkey(normalizedPubkey);
|
|
if (!profile) {
|
|
continue;
|
|
}
|
|
|
|
found.add(normalizedPubkey);
|
|
profiles[normalizedPubkey] = {
|
|
display_name: profile.display_name,
|
|
name: profile.name ?? null,
|
|
avatar_url: profile.avatar_url,
|
|
nip05_handle: profile.nip05_handle,
|
|
owner_pubkey: profile.owner_pubkey,
|
|
is_agent: profile.is_agent ?? false,
|
|
};
|
|
}
|
|
const missing = args.pubkeys.filter((p) => !found.has(p.toLowerCase()));
|
|
return { profiles, missing };
|
|
}
|
|
|
|
async function handleSearchUsers(
|
|
args: {
|
|
query: string;
|
|
limit?: number;
|
|
cursor?: string | null;
|
|
},
|
|
config: E2eConfig | undefined,
|
|
) {
|
|
const userSearchDelayMs = config?.mock?.userSearchDelayMs ?? 0;
|
|
if (userSearchDelayMs > 0) {
|
|
await new Promise<void>((resolve) => {
|
|
window.setTimeout(resolve, userSearchDelayMs);
|
|
});
|
|
}
|
|
|
|
const identity = getIdentity(config);
|
|
if (!identity) {
|
|
const normalizedQuery = args.query.trim().toLowerCase();
|
|
|
|
const limit = args.limit ?? 8;
|
|
const page = Math.max(Number(args.cursor ?? 1) || 1, 1);
|
|
const allResults = listMockProfiles()
|
|
.filter((profile) => {
|
|
if (normalizedQuery.length === 0) {
|
|
return true;
|
|
}
|
|
|
|
const displayName = profile.display_name?.toLowerCase() ?? "";
|
|
const nip05Handle = profile.nip05_handle?.toLowerCase() ?? "";
|
|
const pubkey = profile.pubkey.toLowerCase();
|
|
return (
|
|
displayName.includes(normalizedQuery) ||
|
|
nip05Handle.includes(normalizedQuery) ||
|
|
pubkey.includes(normalizedQuery)
|
|
);
|
|
})
|
|
.sort((left, right) => {
|
|
const leftName = left.display_name ?? left.nip05_handle ?? left.pubkey;
|
|
const rightName =
|
|
right.display_name ?? right.nip05_handle ?? right.pubkey;
|
|
return leftName.localeCompare(rightName);
|
|
});
|
|
const results = allResults
|
|
.slice((page - 1) * limit, page * limit)
|
|
.map((profile) => ({
|
|
pubkey: profile.pubkey,
|
|
display_name: profile.display_name,
|
|
avatar_url: profile.avatar_url,
|
|
nip05_handle: profile.nip05_handle,
|
|
owner_pubkey: profile.owner_pubkey,
|
|
is_agent: profile.is_agent ?? false,
|
|
}));
|
|
|
|
return {
|
|
users: results,
|
|
next_cursor: page * limit < allResults.length ? String(page + 1) : null,
|
|
} satisfies RawSearchUsersResponse;
|
|
}
|
|
|
|
// NIP-50 search on kind:0 profiles
|
|
const limit = args.limit ?? 8;
|
|
const normalizedQuery = args.query.trim();
|
|
const page = Math.max(Number(args.cursor ?? 1) || 1, 1);
|
|
const filter =
|
|
normalizedQuery.length === 0
|
|
? { kinds: [0], limit, page }
|
|
: { kinds: [0], search: args.query, limit, page };
|
|
const events = await relayQuery(config, [filter]);
|
|
const users = events.map((ev) => {
|
|
const content = JSON.parse(ev.content ?? "{}");
|
|
return {
|
|
pubkey: ev.pubkey ?? "",
|
|
display_name: content.display_name ?? content.name ?? null,
|
|
avatar_url: content.picture ?? null,
|
|
nip05_handle: content.nip05 ?? null,
|
|
owner_pubkey:
|
|
((ev.tags ?? []) as string[][]).find(
|
|
(tag) => Array.isArray(tag) && tag[0] === "auth" && tag.length === 4,
|
|
)?.[1] ?? null,
|
|
is_agent: Array.isArray(ev.tags)
|
|
? ev.tags.some(
|
|
(tag) =>
|
|
Array.isArray(tag) && tag[0] === "auth" && tag.length === 4,
|
|
)
|
|
: false,
|
|
};
|
|
});
|
|
return {
|
|
users,
|
|
next_cursor: users.length >= limit ? String(page + 1) : null,
|
|
};
|
|
}
|
|
|
|
async function handleGetPresence(
|
|
args: {
|
|
pubkeys: string[];
|
|
},
|
|
config: E2eConfig | undefined,
|
|
) {
|
|
const identity = getIdentity(config);
|
|
if (!identity) {
|
|
return Object.fromEntries(
|
|
args.pubkeys.map((pubkey) => [
|
|
pubkey.toLowerCase(),
|
|
getMockPresenceStatus(pubkey),
|
|
]),
|
|
) satisfies RawPresenceLookup;
|
|
}
|
|
|
|
if (args.pubkeys.length === 0) {
|
|
return {} satisfies RawPresenceLookup;
|
|
}
|
|
|
|
// Presence is ephemeral (kind:20001) — mock returns from in-memory map.
|
|
const events = await relayQuery(config, [
|
|
{ kinds: [20001], authors: args.pubkeys, limit: args.pubkeys.length },
|
|
]);
|
|
const result: RawPresenceLookup = {};
|
|
for (const ev of events) {
|
|
// Synthesized presence events have ["p", subject_pubkey] tag
|
|
const pTag = ((ev.tags ?? []) as string[][]).find((t) => t[0] === "p");
|
|
const pk = pTag?.[1] ?? ev.pubkey ?? "";
|
|
result[pk.toLowerCase()] = (ev.content ?? "offline") as PresenceStatus;
|
|
}
|
|
// Fill missing pubkeys with "offline"
|
|
for (const pk of args.pubkeys) {
|
|
if (!result[pk.toLowerCase()]) {
|
|
result[pk.toLowerCase()] = "offline";
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async function handleEnsureStarterChannels(
|
|
config: E2eConfig | undefined,
|
|
): Promise<RawChannelWithMembership[]> {
|
|
const starterChannelError =
|
|
config?.mock?.ensureStarterChannelsErrors?.shift();
|
|
if (starterChannelError) {
|
|
throw new Error(starterChannelError);
|
|
}
|
|
|
|
const currentPubkey = getMockMemberPubkey(config);
|
|
const ensureMember = (channel: MockChannel) => {
|
|
if (
|
|
channel.members.some(
|
|
(member) =>
|
|
normalizePubkey(member.pubkey) === normalizePubkey(currentPubkey),
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
channel.members.push(createCurrentMember(config, "member"));
|
|
syncMockChannel(channel);
|
|
touchMockChannel(channel);
|
|
};
|
|
|
|
for (const channelName of [
|
|
STARTER_GENERAL_CHANNEL_NAME,
|
|
STARTER_WELCOME_CHANNEL_NAME,
|
|
]) {
|
|
const channel = mockChannels.find(
|
|
(candidate) =>
|
|
candidate.name === channelName &&
|
|
candidate.channel_type === "stream" &&
|
|
candidate.visibility === "open" &&
|
|
candidate.archived_at === null,
|
|
);
|
|
if (!channel) {
|
|
throw new Error(`Starter channel ${channelName} not found.`);
|
|
}
|
|
ensureMember(channel);
|
|
}
|
|
|
|
return listMockChannels(config);
|
|
}
|
|
|
|
async function handleCreateChannel(
|
|
args: {
|
|
name: string;
|
|
channelType: "stream" | "forum";
|
|
visibility: "open" | "private";
|
|
description?: string;
|
|
ttlSeconds?: number;
|
|
},
|
|
config: E2eConfig | undefined,
|
|
) {
|
|
const identity = getIdentity(config);
|
|
const ttlDeadline =
|
|
typeof args.ttlSeconds === "number"
|
|
? new Date(Date.now() + args.ttlSeconds * 1_000).toISOString()
|
|
: null;
|
|
if (!identity) {
|
|
const createChannelError = config?.mock?.createChannelErrors?.shift();
|
|
if (createChannelError) {
|
|
throw new Error(createChannelError);
|
|
}
|
|
|
|
const owner = createCurrentMember(config, "owner");
|
|
const channel = createMockChannel({
|
|
id: crypto.randomUUID(),
|
|
name: args.name,
|
|
channel_type: args.channelType,
|
|
visibility: args.visibility,
|
|
description: args.description ?? "",
|
|
topic: null,
|
|
purpose: null,
|
|
last_message_at: null,
|
|
archived_at: null,
|
|
created_by: owner.pubkey,
|
|
topic_set_by: null,
|
|
topic_set_at: null,
|
|
purpose_set_by: null,
|
|
purpose_set_at: null,
|
|
ttl_seconds: args.ttlSeconds ?? null,
|
|
ttl_deadline: ttlDeadline,
|
|
topic_required: false,
|
|
max_members: null,
|
|
nip29_group_id: null,
|
|
created_minutes_ago: 0,
|
|
updated_minutes_ago: 0,
|
|
members: [owner],
|
|
});
|
|
mockChannels.push(channel);
|
|
return toRawChannel(channel, config);
|
|
}
|
|
|
|
const channelId = crypto.randomUUID();
|
|
const tags: string[][] = [
|
|
["h", channelId],
|
|
["name", args.name],
|
|
["channel_type", args.channelType],
|
|
["visibility", args.visibility],
|
|
];
|
|
if (args.description) {
|
|
tags.push(["about", args.description]);
|
|
}
|
|
if (typeof args.ttlSeconds === "number") {
|
|
tags.push(["ttl", String(args.ttlSeconds)]);
|
|
}
|
|
await submitSignedEvent(config, { kind: 9007, content: "", tags });
|
|
|
|
// Fetch the created channel via pure Nostr query.
|
|
// The relay emits kind:39000 as a side effect of kind:9007.
|
|
const metaEvents = await relayQuery(config, [
|
|
{ kinds: [39000], "#d": [channelId], limit: 1 },
|
|
]);
|
|
const ev = metaEvents[0];
|
|
if (!ev) {
|
|
throw new Error(`Channel "${args.name}" not found after creation`);
|
|
}
|
|
const evTags = (ev.tags ?? []) as string[][];
|
|
const getTag = (name: string) =>
|
|
evTags.find((t) => t[0] === name)?.[1] ?? null;
|
|
return {
|
|
id: channelId,
|
|
name: getTag("name") ?? args.name,
|
|
description: getTag("about") ?? args.description ?? null,
|
|
channel_type: args.channelType,
|
|
visibility: args.visibility,
|
|
topic: null,
|
|
purpose: null,
|
|
role: "owner",
|
|
archived_at: null,
|
|
ttl_seconds: args.ttlSeconds ?? null,
|
|
ttl_deadline: ttlDeadline,
|
|
created_at: ev.created_at
|
|
? new Date(ev.created_at * 1000).toISOString()
|
|
: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
async function handleOpenDm(
|
|
args: {
|
|
pubkeys: string[];
|
|
expectedRelayUrl?: string | null;
|
|
expectedSignerPubkey?: string | null;
|
|
},
|
|
config: E2eConfig | undefined,
|
|
) {
|
|
const delayMs = config?.mock?.openDmDelayMs ?? 0;
|
|
if (delayMs > 0) {
|
|
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
|
|
}
|
|
// After the injected delay, like the real command's post-await check: a
|
|
// caller-captured tenant scope and signer identity must still match the
|
|
// active community/identity.
|
|
assertExpectedRelayScope(args.expectedRelayUrl, config);
|
|
assertExpectedSigner(args.expectedSignerPubkey, config);
|
|
|
|
const normalizedPubkeys = normalizeParticipantPubkeys(args.pubkeys);
|
|
if (normalizedPubkeys.length === 0) {
|
|
throw new Error("Select at least one person to start a DM.");
|
|
}
|
|
|
|
const currentPubkey = getMockMemberPubkey(config).toLowerCase();
|
|
const participantPubkeys = normalizeParticipantPubkeys([
|
|
currentPubkey,
|
|
...normalizedPubkeys.filter((pubkey) => pubkey !== currentPubkey),
|
|
]);
|
|
const existingChannel = findMockDmByParticipantPubkeys(participantPubkeys);
|
|
if (existingChannel) {
|
|
return toRawChannel(existingChannel, config);
|
|
}
|
|
|
|
const identity = getIdentity(config);
|
|
if (!identity) {
|
|
const members = participantPubkeys.map((pubkey) =>
|
|
createMockMember(pubkey, "member", 0),
|
|
);
|
|
const channel = createMockChannel({
|
|
id: crypto.randomUUID(),
|
|
name:
|
|
participantPubkeys.length === 2
|
|
? "DM"
|
|
: `Group DM (${participantPubkeys.length})`,
|
|
channel_type: "dm",
|
|
visibility: "private",
|
|
description: "Direct message conversation",
|
|
topic: null,
|
|
purpose: null,
|
|
last_message_at: null,
|
|
archived_at: null,
|
|
created_by: getMockMemberPubkey(config),
|
|
topic_set_by: null,
|
|
topic_set_at: null,
|
|
purpose_set_by: null,
|
|
purpose_set_at: null,
|
|
topic_required: false,
|
|
max_members: participantPubkeys.length,
|
|
nip29_group_id: null,
|
|
created_minutes_ago: 0,
|
|
updated_minutes_ago: 0,
|
|
members,
|
|
});
|
|
syncMockChannel(channel);
|
|
mockChannels.push(channel);
|
|
return toRawChannel(channel, config);
|
|
}
|
|
|
|
// Submit kind:41010 (DM open) with p-tags for participants
|
|
const tags = normalizedPubkeys.map((pk) => ["p", pk]);
|
|
const result = await submitSignedEvent(config, {
|
|
kind: 41010,
|
|
content: "",
|
|
tags,
|
|
});
|
|
// Parse channel_id from response message
|
|
const respJson = JSON.parse(result.message.replace("response:", "") || "{}");
|
|
const channelId = respJson.channel_id ?? "";
|
|
|
|
// Fetch channel metadata
|
|
const metaEvents = await relayQuery(config, [
|
|
{ kinds: [39000], "#d": [channelId], limit: 1 },
|
|
]);
|
|
const ev = metaEvents[0];
|
|
const evTags = (ev?.tags ?? []) as string[][];
|
|
const getTag = (name: string) =>
|
|
evTags.find((t) => t[0] === name)?.[1] ?? null;
|
|
return {
|
|
id: channelId,
|
|
name: getTag("name") ?? "DM",
|
|
description: null,
|
|
channel_type: "dm",
|
|
visibility: "private",
|
|
topic: null,
|
|
purpose: null,
|
|
role: "member",
|
|
archived_at: null,
|
|
ttl_seconds: null,
|
|
ttl_deadline: null,
|
|
created_at: ev?.created_at
|
|
? new Date(ev.created_at * 1000).toISOString()
|
|
: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
async function handleHideDm(
|
|
args: { channelId: string },
|
|
config: E2eConfig | undefined,
|
|
) {
|
|
const identity = getIdentity(config);
|
|
if (!identity) {
|
|
const index = mockChannels.findIndex(
|
|
(channel) => channel.id === args.channelId,
|
|
);
|
|
if (index === -1) {
|
|
throw new Error(`DM ${args.channelId} not found.`);
|
|
}
|
|
// Remove from mock list (simulates hiding from sidebar).
|
|
mockChannels.splice(index, 1);
|
|
return;
|
|
}
|
|
|
|
// Submit kind:41012 (DM hide) with h-tag
|
|
await submitSignedEvent(config, {
|
|
kind: 41012,
|
|
content: "",
|
|
tags: [["h", args.channelId]],
|
|
});
|
|
}
|
|
|
|
async function handleGetChannelDetails(
|
|
args: { channelId: string },
|
|
config: E2eConfig | undefined,
|
|
) {
|
|
const identity = getIdentity(config);
|
|
if (!identity) {
|
|
return toRawChannelDetail(getMockChannel(args.channelId), config);
|
|
}
|
|
|
|
const metaEvents = await relayQuery(config, [
|
|
{ kinds: [39000], "#d": [args.channelId], limit: 1 },
|
|
]);
|
|
const ev = metaEvents[0];
|
|
const evTags = (ev?.tags ?? []) as string[][];
|
|
const getTag = (name: string) =>
|
|
evTags.find((t) => t[0] === name)?.[1] ?? null;
|
|
|
|
// Get members for member_count
|
|
const memberEvents = await relayQuery(config, [
|
|
{ kinds: [39002], "#d": [args.channelId], limit: 1 },
|
|
]);
|
|
const memberTags = ((memberEvents[0]?.tags ?? []) as string[][]).filter(
|
|
(t) => t[0] === "p",
|
|
);
|
|
|
|
return {
|
|
id: args.channelId,
|
|
name: getTag("name") ?? "",
|
|
description: getTag("about") ?? null,
|
|
channel_type: getTag("t") ?? "stream",
|
|
visibility: evTags.some((t) => t[0] === "private") ? "private" : "open",
|
|
topic: getTag("topic") ?? null,
|
|
purpose: getTag("purpose") ?? null,
|
|
member_count: memberTags.length,
|
|
role: "member",
|
|
archived_at: evTags.some((t) => t[0] === "archived" && t[1] === "true")
|
|
? new Date().toISOString()
|
|
: null,
|
|
ttl_seconds: getTag("ttl") ? Number(getTag("ttl")) : null,
|
|
ttl_deadline: getTag("ttl_deadline") ?? null,
|
|
created_at: ev?.created_at
|
|
? new Date(ev.created_at * 1000).toISOString()
|
|
: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
async function handleGetChannelMembers(
|
|
args: { channelId: string },
|
|
config: E2eConfig | undefined,
|
|
): Promise<RawChannelMembersResponse> {
|
|
const delayMs = config?.mock?.channelMembersReadDelayMs ?? 0;
|
|
if (delayMs > 0) {
|
|
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
|
|
}
|
|
|
|
const identity = getIdentity(config);
|
|
if (!identity) {
|
|
const channel = getMockChannel(args.channelId);
|
|
return {
|
|
members: cloneMembers(channel.members),
|
|
next_cursor: null,
|
|
};
|
|
}
|
|
|
|
const memberEvents = await relayQuery(config, [
|
|
{ kinds: [39002], "#d": [args.channelId], limit: 1 },
|
|
]);
|
|
const memberTags = ((memberEvents[0]?.tags ?? []) as string[][]).filter(
|
|
(t) => t[0] === "p",
|
|
);
|
|
const members = memberTags.map((t) => ({
|
|
pubkey: t[1],
|
|
role: (t[3] ?? t[2] ?? "member") as
|
|
| "owner"
|
|
| "admin"
|
|
| "member"
|
|
| "guest"
|
|
| "bot",
|
|
is_agent: (t[3] ?? t[2]) === "bot",
|
|
display_name: null,
|
|
avatar_url: null,
|
|
joined_at: new Date().toISOString(),
|
|
}));
|
|
return { members, next_cursor: null };
|
|
}
|
|
|
|
async function handleUpdateChannel(
|
|
args: {
|
|
channelId: string;
|
|
name?: string;
|
|
description?: string;
|
|
visibility?: "open" | "private";
|
|
ttlSeconds?: number | null;
|
|
},
|
|
config: E2eConfig | undefined,
|
|
) {
|
|
const delayMs = config?.mock?.updateChannelDelayMs ?? 0;
|
|
if (delayMs > 0) {
|
|
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
|
|
}
|
|
|
|
const identity = getIdentity(config);
|
|
if (!identity) {
|
|
const channel = getMockChannel(args.channelId);
|
|
if (args.name !== undefined) {
|
|
channel.name = args.name;
|
|
}
|
|
if (args.description !== undefined) {
|
|
channel.description = args.description;
|
|
}
|
|
if (args.visibility !== undefined) {
|
|
channel.visibility = args.visibility;
|
|
}
|
|
if (args.ttlSeconds !== undefined) {
|
|
channel.ttl_seconds = args.ttlSeconds;
|
|
channel.ttl_deadline =
|
|
args.ttlSeconds === null
|
|
? null
|
|
: new Date(Date.now() + args.ttlSeconds * 1000).toISOString();
|
|
}
|
|
touchMockChannel(channel);
|
|
return toRawChannelDetail(channel, config);
|
|
}
|
|
|
|
const tags: string[][] = [["h", args.channelId]];
|
|
if (args.name !== undefined) {
|
|
tags.push(["name", args.name]);
|
|
}
|
|
if (args.description !== undefined) {
|
|
tags.push(["about", args.description]);
|
|
}
|
|
if (args.visibility !== undefined) {
|
|
tags.push(["visibility", args.visibility]);
|
|
}
|
|
if (args.ttlSeconds !== undefined) {
|
|
tags.push(["ttl", args.ttlSeconds === null ? "" : String(args.ttlSeconds)]);
|
|
}
|
|
await submitSignedEvent(config, { kind: 9002, content: "", tags });
|
|
|
|
// Re-fetch updated metadata
|
|
const metaEvents = await relayQuery(config, [
|
|
{ kinds: [39000], "#d": [args.channelId], limit: 1 },
|
|
]);
|
|
const ev = metaEvents[0];
|
|
const evTags = (ev?.tags ?? []) as string[][];
|
|
const getTag = (name: string) =>
|
|
evTags.find((t) => t[0] === name)?.[1] ?? null;
|
|
const ttlTag = getTag("ttl");
|
|
const ttlSeconds = ttlTag === null || ttlTag === "" ? null : Number(ttlTag);
|
|
return {
|
|
id: args.channelId,
|
|
name: getTag("name") ?? "",
|
|
description: getTag("about") ?? null,
|
|
channel_type: getTag("t") ?? "stream",
|
|
visibility: getTag("visibility") ?? "open",
|
|
topic: getTag("topic") ?? null,
|
|
purpose: getTag("purpose") ?? null,
|
|
member_count: 0,
|
|
role: "owner",
|
|
archived_at: null,
|
|
ttl_seconds: ttlSeconds,
|
|
ttl_deadline:
|
|
ttlSeconds === null
|
|
? null
|
|
: new Date(Date.now() + ttlSeconds * 1000).toISOString(),
|
|
created_at: ev?.created_at
|
|
? new Date(ev.created_at * 1000).toISOString()
|
|
: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
async function handleSetChannelTopic(
|
|
args: {
|
|
channelId: string;
|
|
topic: string;
|
|
},
|
|
config: E2eConfig | undefined,
|
|
) {
|
|
const identity = getIdentity(config);
|
|
if (!identity) {
|
|
const channel = getMockChannel(args.channelId);
|
|
const nextTopic = args.topic.trim();
|
|
|
|
channel.topic = nextTopic.length > 0 ? nextTopic : null;
|
|
channel.topic_set_by = getMockMemberPubkey(config);
|
|
channel.topic_set_at = new Date().toISOString();
|
|
touchMockChannel(channel);
|
|
return;
|
|
}
|
|
|
|
await submitSignedEvent(config, {
|
|
kind: 9002,
|
|
content: "",
|
|
tags: [
|
|
["h", args.channelId],
|
|
["topic", args.topic],
|
|
],
|
|
});
|
|
}
|
|
|
|
async function handleSetChannelPurpose(
|
|
args: {
|
|
channelId: string;
|
|
purpose: string;
|
|
},
|
|
config: E2eConfig | undefined,
|
|
) {
|
|
const identity = getIdentity(config);
|
|
if (!identity) {
|
|
const channel = getMockChannel(args.channelId);
|
|
const nextPurpose = args.purpose.trim();
|
|
|
|
channel.purpose = nextPurpose.length > 0 ? nextPurpose : null;
|
|
channel.purpose_set_by = getMockMemberPubkey(config);
|
|
channel.purpose_set_at = new Date().toISOString();
|
|
touchMockChannel(channel);
|
|
return;
|
|
}
|
|
|
|
await submitSignedEvent(config, {
|
|
kind: 9002,
|
|
content: "",
|
|
tags: [
|
|
["h", args.channelId],
|
|
["purpose", args.purpose],
|
|
],
|
|
});
|
|
}
|
|
|
|
type MockUpdaterChannel = {
|
|
onmessage?: (event: { event: "Finished" }) => void;
|
|
};
|
|
|
|
function notifyUpdaterFinished(payload: unknown) {
|
|
const channel = (payload as { onEvent?: MockUpdaterChannel } | null)?.onEvent;
|
|
channel?.onmessage?.({ event: "Finished" });
|
|
}
|
|
|
|
function handleUpdaterCheck(config: E2eConfig | undefined) {
|
|
if (!config?.mock?.updateAvailable) {
|
|
return null;
|
|
}
|
|
|
|
const version = config.mock.updateVersion ?? "0.3.18";
|
|
|
|
return {
|
|
rid: 42,
|
|
currentVersion: "0.3.17",
|
|
version,
|
|
date: "2026-06-12T00:00:00Z",
|
|
body: `Mock update ${version}`,
|
|
rawJson: null,
|
|
};
|
|
}
|
|
|
|
async function handleUpdaterDownload(
|
|
payload: unknown,
|
|
config: E2eConfig | undefined,
|
|
) {
|
|
const delayMs = config?.mock?.updateDownloadDelayMs ?? 0;
|
|
|
|
if (delayMs > 0) {
|
|
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
|
|
}
|
|
|
|
notifyUpdaterFinished(payload);
|
|
return 43;
|
|
}
|
|
|
|
function handleUpdaterInstall() {
|
|
return null;
|
|
}
|
|
|
|
async function handleRestart(config: E2eConfig | undefined) {
|
|
const delayMs = config?.mock?.restartDelayMs ?? 0;
|
|
|
|
if (delayMs > 0) {
|
|
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function handleArchiveChannel(
|
|
args: { channelId: string },
|
|
config: E2eConfig | undefined,
|
|
) {
|
|
const identity = getIdentity(config);
|
|
if (!identity) {
|
|
const channel = getMockChannel(args.channelId);
|
|
channel.archived_at = new Date().toISOString();
|
|
touchMockChannel(channel);
|
|
return;
|
|
}
|
|
|
|
await submitSignedEvent(config, {
|
|
kind: 9002,
|
|
content: "",
|
|
tags: [
|
|
["h", args.channelId],
|
|
["archived", "true"],
|
|
],
|
|
});
|
|
}
|
|
|
|
async function handleUnarchiveChannel(
|
|
args: { channelId: string },
|
|
config: E2eConfig | undefined,
|
|
) {
|
|
const identity = getIdentity(config);
|
|
if (!identity) {
|
|
const channel = getMockChannel(args.channelId);
|
|
channel.archived_at = null;
|
|
touchMockChannel(channel);
|
|
return;
|
|
}
|
|
|
|
await submitSignedEvent(config, {
|
|
kind: 9002,
|
|
content: "",
|
|
tags: [
|
|
["h", args.channelId],
|
|
["archived", "false"],
|
|
],
|
|
});
|
|
}
|
|
|
|
async function handleDeleteChannel(
|
|
args: { channelId: string },
|
|
config: E2eConfig | undefined,
|
|
) {
|
|
const identity = getIdentity(config);
|
|
if (!identity) {
|
|
const index = mockChannels.findIndex(
|
|
(channel) => channel.id === args.channelId,
|
|
);
|
|
if (index === -1) {
|
|
throw new Error(`Channel ${args.channelId} not found.`);
|
|
}
|
|
|
|
mockChannels.splice(index, 1);
|
|
mockMessages.delete(args.channelId);
|
|
return;
|
|
}
|
|
|
|
await submitSignedEvent(config, {
|
|
kind: 9008,
|
|
content: "",
|
|
tags: [["h", args.channelId]],
|
|
});
|
|
}
|
|
|
|
async function handleAddChannelMembers(
|
|
args: {
|
|
channelId: string;
|
|
pubkeys: string[];
|
|
role?: RawChannelMember["role"];
|
|
expectedRelayUrl?: string | null;
|
|
expectedSignerPubkey?: string | null;
|
|
},
|
|
config: E2eConfig | undefined,
|
|
): Promise<RawAddChannelMembersResponse> {
|
|
const addChannelMembersDelayMs = config?.mock?.addChannelMembersDelayMs ?? 0;
|
|
if (addChannelMembersDelayMs > 0) {
|
|
await new Promise((resolve) =>
|
|
window.setTimeout(resolve, addChannelMembersDelayMs),
|
|
);
|
|
}
|
|
assertExpectedRelayScope(args.expectedRelayUrl, config);
|
|
assertExpectedSigner(args.expectedSignerPubkey, config);
|
|
const configuredErrors = config?.mock?.addChannelMembersErrors;
|
|
if (configuredErrors && configuredErrors.length > 0) {
|
|
const index = Math.min(
|
|
addChannelMembersCallCount,
|
|
configuredErrors.length - 1,
|
|
);
|
|
addChannelMembersCallCount += 1;
|
|
const error = configuredErrors[index];
|
|
if (error) {
|
|
return {
|
|
added: [],
|
|
errors: args.pubkeys.map((pubkey) => ({ pubkey, error })),
|
|
};
|
|
}
|
|
}
|
|
const identity = getIdentity(config);
|
|
if (!identity) {
|
|
const channel = getMockChannel(args.channelId);
|
|
const added: string[] = [];
|
|
const errors: RawAddChannelMembersResponse["errors"] = [];
|
|
const existingPubkeys = new Set(
|
|
channel.members.map((member) => normalizePubkey(member.pubkey)),
|
|
);
|
|
|
|
for (const pubkey of args.pubkeys) {
|
|
const normalizedPubkey = normalizePubkey(pubkey);
|
|
if (existingPubkeys.has(normalizedPubkey)) {
|
|
errors.push({
|
|
pubkey,
|
|
error: "Already a member.",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
existingPubkeys.add(normalizedPubkey);
|
|
added.push(pubkey);
|
|
}
|
|
|
|
// DM participant sets are immutable. Adding a member creates or reuses a
|
|
// separate DM for the expanded set instead of mutating the source channel.
|
|
const targetChannel =
|
|
channel.channel_type === "dm" && added.length > 0
|
|
? getMockChannel(
|
|
(
|
|
await handleOpenDm(
|
|
{
|
|
pubkeys: [
|
|
...channel.members.map((member) => member.pubkey),
|
|
...added,
|
|
],
|
|
},
|
|
config,
|
|
)
|
|
).id,
|
|
)
|
|
: channel;
|
|
|
|
for (const pubkey of added) {
|
|
const existingMember = targetChannel.members.find(
|
|
(member) => normalizePubkey(member.pubkey) === normalizePubkey(pubkey),
|
|
);
|
|
if (existingMember) {
|
|
existingMember.role = args.role ?? "member";
|
|
existingMember.is_agent =
|
|
args.role === "bot" ||
|
|
mockAgentPubkeys.has(pubkey) ||
|
|
mockManagedAgents.some((agent) => agent.pubkey === pubkey);
|
|
existingMember.display_name = mockDisplayNames.get(pubkey) ?? null;
|
|
continue;
|
|
}
|
|
targetChannel.members.push({
|
|
pubkey,
|
|
role: args.role ?? "member",
|
|
is_agent:
|
|
args.role === "bot" ||
|
|
mockAgentPubkeys.has(pubkey) ||
|
|
mockManagedAgents.some((agent) => agent.pubkey === pubkey),
|
|
joined_at: new Date().toISOString(),
|
|
display_name: mockDisplayNames.get(pubkey) ?? null,
|
|
});
|
|
}
|
|
|
|
syncMockChannel(targetChannel);
|
|
touchMockChannel(targetChannel);
|
|
syncMockRelayAgentsFromManagedAgents();
|
|
return {
|
|
added,
|
|
errors,
|
|
};
|
|
}
|
|
|
|
const added: string[] = [];
|
|
const errors: RawAddChannelMembersResponse["errors"] = [];
|
|
for (const pubkey of args.pubkeys) {
|
|
try {
|
|
const tags: string[][] = [
|
|
["h", args.channelId],
|
|
["p", pubkey],
|
|
];
|
|
if (args.role) {
|
|
tags.push(["role", args.role]);
|
|
}
|
|
await submitSignedEvent(config, { kind: 9000, content: "", tags });
|
|
added.push(pubkey);
|
|
} catch (e) {
|
|
errors.push({ pubkey, error: String(e) });
|
|
}
|
|
}
|
|
return { added, errors };
|
|
}
|
|
|
|
async function handleRemoveChannelMember(
|
|
args: {
|
|
channelId: string;
|
|
pubkey: string;
|
|
},
|
|
config: E2eConfig | undefined,
|
|
) {
|
|
const identity = getIdentity(config);
|
|
if (!identity) {
|
|
const channel = getMockChannel(args.channelId);
|
|
channel.members = channel.members.filter(
|
|
(member) => member.pubkey !== args.pubkey,
|
|
);
|
|
syncMockChannel(channel);
|
|
touchMockChannel(channel);
|
|
syncMockRelayAgentsFromManagedAgents();
|
|
return;
|
|
}
|
|
|
|
await submitSignedEvent(config, {
|
|
kind: 9001,
|
|
content: "",
|
|
tags: [
|
|
["h", args.channelId],
|
|
["p", args.pubkey],
|
|
],
|
|
});
|
|
}
|
|
|
|
async function handleJoinChannel(
|
|
args: {
|
|
channelId: string;
|
|
},
|
|
config: E2eConfig | undefined,
|
|
) {
|
|
const identity = getIdentity(config);
|
|
if (!identity) {
|
|
const joinChannelError = config?.mock?.joinChannelErrors?.shift();
|
|
if (joinChannelError) {
|
|
throw new Error(joinChannelError);
|
|
}
|
|
|
|
const channel = getMockChannel(args.channelId);
|
|
const currentPubkey = getMockMemberPubkey(config);
|
|
|
|
if (channel.members.some((member) => member.pubkey === currentPubkey)) {
|
|
return;
|
|
}
|
|
|
|
channel.members.push(createCurrentMember(config, "member"));
|
|
syncMockChannel(channel);
|
|
touchMockChannel(channel);
|
|
return;
|
|
}
|
|
|
|
await submitSignedEvent(config, {
|
|
kind: 9021,
|
|
content: "",
|
|
tags: [["h", args.channelId]],
|
|
});
|
|
}
|
|
|
|
async function handleLeaveChannel(
|
|
args: {
|
|
channelId: string;
|
|
},
|
|
config: E2eConfig | undefined,
|
|
) {
|
|
const identity = getIdentity(config);
|
|
if (!identity) {
|
|
const channel = getMockChannel(args.channelId);
|
|
const currentPubkey = getMockMemberPubkey(config);
|
|
|
|
channel.members = channel.members.filter(
|
|
(member) => member.pubkey !== currentPubkey,
|
|
);
|
|
syncMockChannel(channel);
|
|
touchMockChannel(channel);
|
|
return;
|
|
}
|
|
|
|
await submitSignedEvent(config, {
|
|
kind: 9022,
|
|
content: "",
|
|
tags: [["h", args.channelId]],
|
|
});
|
|
}
|
|
|
|
async function handleGetFeed(
|
|
args: {
|
|
since?: number;
|
|
limit?: number;
|
|
types?: string;
|
|
},
|
|
config: E2eConfig | undefined,
|
|
): Promise<RawHomeFeedResponse> {
|
|
const feedReadError = config?.mock?.feedReadError;
|
|
if (feedReadError) {
|
|
throw new Error(feedReadError);
|
|
}
|
|
|
|
const identity = getIdentity(config);
|
|
if (!identity) {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const limit = args.limit ?? 50;
|
|
const wantedTypes =
|
|
args.types
|
|
?.split(",")
|
|
.map((value) => value.trim())
|
|
.filter((value) => value.length > 0) ?? [];
|
|
const includeType = (type: string) =>
|
|
wantedTypes.length === 0 || wantedTypes.includes(type);
|
|
|
|
const currentPubkey = getMockMemberPubkey(config).toLowerCase();
|
|
const defaultFeed: RawHomeFeedResponse["feed"] =
|
|
currentPubkey === ALICE_PUBKEY
|
|
? {
|
|
mentions: [
|
|
{
|
|
id: "mock-feed-alice-mention",
|
|
kind: 9,
|
|
pubkey: BOB_PUBKEY,
|
|
content: "Alice, can you sanity-check the new design mocks?",
|
|
created_at: now - 90,
|
|
channel_id: "b5e2f8a1-3c44-5912-9e67-4a8d1f2b3c4e",
|
|
channel_name: "design",
|
|
tags: [
|
|
["e", "b5e2f8a1-3c44-5912-9e67-4a8d1f2b3c4e"],
|
|
["p", ALICE_PUBKEY],
|
|
],
|
|
category: "mention" as const,
|
|
},
|
|
],
|
|
needs_action: [
|
|
{
|
|
id: "mock-feed-alice-reminder",
|
|
kind: 40007,
|
|
pubkey:
|
|
"0000000000000000000000000000000000000000000000000000000000000000",
|
|
content: "Reminder: post the engineering launch note.",
|
|
created_at: now - 15 * 60,
|
|
channel_id: "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9",
|
|
channel_name: "engineering",
|
|
tags: [
|
|
["e", "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9"],
|
|
["p", ALICE_PUBKEY],
|
|
],
|
|
category: "needs_action" as const,
|
|
},
|
|
],
|
|
activity: [
|
|
{
|
|
id: "mock-feed-alice-self-activity",
|
|
kind: 9,
|
|
pubkey: ALICE_PUBKEY,
|
|
content: "I posted the latest design review summary.",
|
|
created_at: now - 25 * 60,
|
|
channel_id: "b5e2f8a1-3c44-5912-9e67-4a8d1f2b3c4e",
|
|
channel_name: "design",
|
|
tags: [["e", "b5e2f8a1-3c44-5912-9e67-4a8d1f2b3c4e"]],
|
|
category: "activity" as const,
|
|
},
|
|
{
|
|
id: "mock-feed-alice-activity",
|
|
kind: 9,
|
|
pubkey: BOB_PUBKEY,
|
|
content: "Engineering signed off on the desktop build.",
|
|
created_at: now - 42 * 60,
|
|
channel_id: "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9",
|
|
channel_name: "engineering",
|
|
tags: [["e", "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9"]],
|
|
category: "activity" as const,
|
|
},
|
|
],
|
|
agent_activity: [
|
|
{
|
|
id: "mock-feed-alice-agent",
|
|
kind: 43003,
|
|
pubkey:
|
|
"db0b028cd36f4d3e36c8300cce87252c1f7fc9495ffecc53f393fcac341ffd36",
|
|
content: "Agent progress: design review summary complete.",
|
|
created_at: now - 2 * 60 * 60,
|
|
channel_id: "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9",
|
|
channel_name: "engineering",
|
|
tags: [["e", "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9"]],
|
|
category: "agent_activity" as const,
|
|
},
|
|
],
|
|
}
|
|
: currentPubkey === DEFAULT_REAL_IDENTITY.pubkey.toLowerCase()
|
|
? {
|
|
mentions: [
|
|
{
|
|
id: "mock-feed-tyler-mention",
|
|
kind: 9,
|
|
pubkey: ALICE_PUBKEY,
|
|
content: "Tyler, can you review the DM onboarding copy?",
|
|
created_at: now - 90,
|
|
channel_id: "f48efb06-0c93-5025-aac9-2e646bb6bfa8",
|
|
channel_name: "alice-tyler",
|
|
tags: [
|
|
["e", "f48efb06-0c93-5025-aac9-2e646bb6bfa8"],
|
|
["p", DEFAULT_REAL_IDENTITY.pubkey],
|
|
],
|
|
category: "mention" as const,
|
|
},
|
|
],
|
|
needs_action: [
|
|
{
|
|
id: "mock-feed-tyler-reminder",
|
|
kind: 40007,
|
|
pubkey:
|
|
"0000000000000000000000000000000000000000000000000000000000000000",
|
|
content: "Reminder: answer Bob in the launch DM thread.",
|
|
created_at: now - 15 * 60,
|
|
channel_id: "7eb9f239-9393-50b0-bd76-d85eef0511c7",
|
|
channel_name: "bob-tyler",
|
|
tags: [
|
|
["e", "7eb9f239-9393-50b0-bd76-d85eef0511c7"],
|
|
["p", DEFAULT_REAL_IDENTITY.pubkey],
|
|
],
|
|
category: "needs_action" as const,
|
|
},
|
|
],
|
|
activity: [
|
|
{
|
|
id: "mock-feed-tyler-self-activity",
|
|
kind: 9,
|
|
pubkey: DEFAULT_REAL_IDENTITY.pubkey,
|
|
content: "I sent the follow-up in the Alice DM.",
|
|
created_at: now - 25 * 60,
|
|
channel_id: "f48efb06-0c93-5025-aac9-2e646bb6bfa8",
|
|
channel_name: "alice-tyler",
|
|
tags: [["e", "f48efb06-0c93-5025-aac9-2e646bb6bfa8"]],
|
|
category: "activity" as const,
|
|
},
|
|
],
|
|
agent_activity: [
|
|
{
|
|
id: "mock-feed-tyler-agent",
|
|
kind: 43003,
|
|
pubkey:
|
|
"db0b028cd36f4d3e36c8300cce87252c1f7fc9495ffecc53f393fcac341ffd36",
|
|
content: "Agent progress: DM summary complete.",
|
|
created_at: now - 2 * 60 * 60,
|
|
channel_id: "f48efb06-0c93-5025-aac9-2e646bb6bfa8",
|
|
channel_name: "alice-tyler",
|
|
tags: [["e", "f48efb06-0c93-5025-aac9-2e646bb6bfa8"]],
|
|
category: "agent_activity" as const,
|
|
},
|
|
],
|
|
}
|
|
: {
|
|
mentions: [
|
|
{
|
|
id: "mock-feed-mention",
|
|
kind: 9,
|
|
pubkey: ALICE_PUBKEY,
|
|
content: "Please review the release checklist.",
|
|
created_at: now - 90,
|
|
channel_id: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
|
|
channel_name: "general",
|
|
tags: [
|
|
["e", "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"],
|
|
["p", currentPubkey],
|
|
],
|
|
category: "mention" as const,
|
|
},
|
|
],
|
|
needs_action: [
|
|
{
|
|
id: "mock-feed-reminder",
|
|
kind: 40007,
|
|
pubkey:
|
|
"0000000000000000000000000000000000000000000000000000000000000000",
|
|
content: "Reminder: update the launch plan before lunch.",
|
|
created_at: now - 15 * 60,
|
|
channel_id: "94a444a4-c0a3-5966-ab05-530c6ddc2301",
|
|
channel_name: "agents",
|
|
tags: [
|
|
["e", "94a444a4-c0a3-5966-ab05-530c6ddc2301"],
|
|
["p", currentPubkey],
|
|
],
|
|
category: "needs_action" as const,
|
|
},
|
|
],
|
|
activity: [
|
|
{
|
|
id: "mock-feed-self-activity",
|
|
kind: 9,
|
|
pubkey: currentPubkey,
|
|
content: "I posted a note about the launch checklist.",
|
|
created_at: now - 25 * 60,
|
|
channel_id: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
|
|
channel_name: "general",
|
|
tags: [["e", "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"]],
|
|
category: "activity" as const,
|
|
},
|
|
{
|
|
id: "mock-feed-activity",
|
|
kind: 9,
|
|
pubkey: BOB_PUBKEY,
|
|
content: "Engineering shipped the desktop build.",
|
|
created_at: now - 42 * 60,
|
|
channel_id: "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9",
|
|
channel_name: "engineering",
|
|
tags: [["e", "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9"]],
|
|
category: "activity" as const,
|
|
},
|
|
],
|
|
agent_activity: [
|
|
{
|
|
id: "mock-feed-agent",
|
|
kind: 43003,
|
|
pubkey:
|
|
"db0b028cd36f4d3e36c8300cce87252c1f7fc9495ffecc53f393fcac341ffd36",
|
|
content: "Agent progress: channel index complete.",
|
|
created_at: now - 2 * 60 * 60,
|
|
channel_id: "94a444a4-c0a3-5966-ab05-530c6ddc2301",
|
|
channel_name: "agents",
|
|
tags: [["e", "94a444a4-c0a3-5966-ab05-530c6ddc2301"]],
|
|
category: "agent_activity" as const,
|
|
},
|
|
],
|
|
};
|
|
|
|
const mergeFeedCategory = (
|
|
category: keyof RawHomeFeedResponse["feed"],
|
|
): RawFeedItem[] =>
|
|
includeType(category)
|
|
? [...mockFeedOverrides[category], ...defaultFeed[category]]
|
|
.sort((left, right) => right.created_at - left.created_at)
|
|
.slice(0, limit)
|
|
: [];
|
|
|
|
const mentions = mergeFeedCategory("mentions");
|
|
const needsAction = mergeFeedCategory("needs_action");
|
|
const activity = mergeFeedCategory("activity");
|
|
const agentActivity = mergeFeedCategory("agent_activity");
|
|
|
|
return {
|
|
feed: {
|
|
mentions,
|
|
needs_action: needsAction,
|
|
activity,
|
|
agent_activity: agentActivity,
|
|
},
|
|
meta: {
|
|
since: args.since ?? now - 7 * 24 * 60 * 60,
|
|
total:
|
|
mentions.length +
|
|
needsAction.length +
|
|
activity.length +
|
|
agentActivity.length,
|
|
generated_at: now,
|
|
},
|
|
};
|
|
}
|
|
|
|
// Feed is composed of multiple queries: mentions (#p), activity, approvals.
|
|
// For e2e, return a minimal feed structure with mentions.
|
|
const limit = args.limit ?? 50;
|
|
const mentionEvents = await relayQuery(config, [
|
|
{
|
|
kinds: [
|
|
9,
|
|
40002,
|
|
1,
|
|
45001,
|
|
45003,
|
|
KIND_GIT_PULL_REQUEST,
|
|
KIND_GIT_PR_UPDATE,
|
|
KIND_GIT_ISSUE,
|
|
KIND_GIT_STATUS_OPEN,
|
|
KIND_GIT_STATUS_MERGED,
|
|
KIND_GIT_STATUS_CLOSED,
|
|
KIND_GIT_STATUS_DRAFT,
|
|
],
|
|
"#p": [identity.pubkey],
|
|
limit,
|
|
},
|
|
]);
|
|
|
|
// Look up channel names for feed items
|
|
const channelIdsInFeed = [
|
|
...new Set(
|
|
mentionEvents
|
|
.map(
|
|
(ev) =>
|
|
((ev.tags ?? []) as string[][]).find((t) => t[0] === "h")?.[1],
|
|
)
|
|
.filter(Boolean) as string[],
|
|
),
|
|
];
|
|
const channelNameMap = new Map<string, string>();
|
|
if (channelIdsInFeed.length > 0) {
|
|
const metaEvents = await relayQuery(config, [
|
|
{
|
|
kinds: [39000],
|
|
"#d": channelIdsInFeed,
|
|
limit: channelIdsInFeed.length,
|
|
},
|
|
]);
|
|
for (const me of metaEvents) {
|
|
const d = ((me.tags ?? []) as string[][]).find((t) => t[0] === "d")?.[1];
|
|
const name = ((me.tags ?? []) as string[][]).find(
|
|
(t) => t[0] === "name",
|
|
)?.[1];
|
|
if (d && name) channelNameMap.set(d, name);
|
|
}
|
|
}
|
|
|
|
const items = mentionEvents.map((ev) => {
|
|
const chId =
|
|
((ev.tags ?? []) as string[][]).find((t) => t[0] === "h")?.[1] ?? null;
|
|
return {
|
|
id: ev.id ?? "",
|
|
pubkey: ev.pubkey ?? "",
|
|
content: ev.content ?? "",
|
|
created_at: ev.created_at ?? 0,
|
|
kind: ev.kind ?? 9,
|
|
tags: (ev.tags ?? []) as string[][],
|
|
channel_id: chId,
|
|
channel_name: chId ? (channelNameMap.get(chId) ?? "") : "",
|
|
// Native-shaped: get_feed emits channel_type: null (Option<String>),
|
|
// never omits the key. Keeping the bridge faithful here is what lets
|
|
// the DM dedupe e2e catch null-vs-undefined regressions at the API
|
|
// conversion seam.
|
|
channel_type: null,
|
|
category: "mention" as const,
|
|
};
|
|
});
|
|
return {
|
|
feed: {
|
|
mentions: items,
|
|
needs_action: [],
|
|
activity: [],
|
|
agent_activity: [],
|
|
},
|
|
meta: {
|
|
since: Math.floor(Date.now() / 1000) - 7 * 86400,
|
|
total: items.length,
|
|
generated_at: Math.floor(Date.now() / 1000),
|
|
},
|
|
};
|
|
}
|
|
|
|
async function delayAgentList(config: E2eConfig | undefined) {
|
|
const agentListDelayMs = config?.mock?.agentListDelayMs ?? 0;
|
|
if (agentListDelayMs > 0) {
|
|
await new Promise<void>((resolve) => {
|
|
window.setTimeout(resolve, agentListDelayMs);
|
|
});
|
|
}
|
|
}
|
|
|
|
async function handleListRelayAgents(
|
|
config: E2eConfig | undefined,
|
|
): Promise<RawRelayAgent[]> {
|
|
await delayAgentList(config);
|
|
const error = config?.mock?.relayAgentListErrors?.shift();
|
|
if (error) throw new Error(error);
|
|
syncMockRelayAgentsFromManagedAgents();
|
|
return mockRelayAgents.map(cloneRelayAgent);
|
|
}
|
|
|
|
function withMockRuntimeConfigMetadata(
|
|
runtime: RawAcpRuntimeCatalogEntry,
|
|
): RawAcpRuntimeCatalogEntry {
|
|
return {
|
|
...runtime,
|
|
node_required: runtime.node_required ?? false,
|
|
auth_status: runtime.auth_status ?? { status: "unknown" },
|
|
model_env_var:
|
|
"model_env_var" in runtime
|
|
? runtime.model_env_var
|
|
: runtime.id === "buzz-agent"
|
|
? "BUZZ_AGENT_MODEL"
|
|
: runtime.id === "goose"
|
|
? "GOOSE_MODEL"
|
|
: null,
|
|
provider_env_var:
|
|
"provider_env_var" in runtime
|
|
? runtime.provider_env_var
|
|
: runtime.id === "buzz-agent"
|
|
? "BUZZ_AGENT_PROVIDER"
|
|
: runtime.id === "goose"
|
|
? "GOOSE_PROVIDER"
|
|
: null,
|
|
thinking_env_var:
|
|
"thinking_env_var" in runtime
|
|
? runtime.thinking_env_var
|
|
: runtime.id === "buzz-agent"
|
|
? "BUZZ_AGENT_THINKING_EFFORT"
|
|
: runtime.id === "goose"
|
|
? "GOOSE_THINKING_EFFORT"
|
|
: null,
|
|
max_tokens_env_var:
|
|
"max_tokens_env_var" in runtime
|
|
? runtime.max_tokens_env_var
|
|
: runtime.id === "buzz-agent"
|
|
? "BUZZ_AGENT_MAX_OUTPUT_TOKENS"
|
|
: runtime.id === "goose"
|
|
? "GOOSE_MAX_TOKENS"
|
|
: null,
|
|
context_limit_env_var:
|
|
"context_limit_env_var" in runtime
|
|
? runtime.context_limit_env_var
|
|
: runtime.id === "buzz-agent"
|
|
? "BUZZ_AGENT_MAX_CONTEXT_TOKENS"
|
|
: runtime.id === "goose"
|
|
? "GOOSE_CONTEXT_LIMIT"
|
|
: null,
|
|
max_rounds_env_var:
|
|
"max_rounds_env_var" in runtime
|
|
? runtime.max_rounds_env_var
|
|
: runtime.id === "buzz-agent"
|
|
? "BUZZ_AGENT_MAX_ROUNDS"
|
|
: null,
|
|
};
|
|
}
|
|
|
|
let runtimeCatalogDiscoveryCount = 0;
|
|
let mockInstallCompleted = false;
|
|
let mockConnectCompleted = false;
|
|
|
|
async function handleDiscoverAcpRuntimes(
|
|
config: E2eConfig | undefined,
|
|
): Promise<RawAcpRuntimeCatalogEntry[]> {
|
|
const delayMs = config?.mock?.acpRuntimesDelayMs ?? 0;
|
|
if (delayMs > 0) {
|
|
await new Promise<void>((resolve) => {
|
|
window.setTimeout(resolve, delayMs);
|
|
});
|
|
}
|
|
|
|
if (config?.mock?.acpRuntimesError) {
|
|
throw new Error("Mocked catalog discovery failure");
|
|
}
|
|
|
|
const afterInstallSequence =
|
|
config?.mock?.acpRuntimesCatalogAfterInstallSequence;
|
|
if (mockInstallCompleted && afterInstallSequence?.length) {
|
|
const index = Math.min(
|
|
runtimeCatalogDiscoveryCount,
|
|
afterInstallSequence.length - 1,
|
|
);
|
|
runtimeCatalogDiscoveryCount += 1;
|
|
return afterInstallSequence[index].map(withMockRuntimeConfigMetadata);
|
|
}
|
|
const afterInstall = config?.mock?.acpRuntimesCatalogAfterInstall;
|
|
if (mockInstallCompleted && afterInstall) {
|
|
return afterInstall.map(withMockRuntimeConfigMetadata);
|
|
}
|
|
const afterConnect = config?.mock?.acpRuntimesCatalogAfterConnect;
|
|
if (mockConnectCompleted && afterConnect) {
|
|
return afterConnect.map(withMockRuntimeConfigMetadata);
|
|
}
|
|
const sequence = config?.mock?.acpRuntimesCatalogSequence;
|
|
if (sequence && sequence.length > 0) {
|
|
const index = Math.min(runtimeCatalogDiscoveryCount, sequence.length - 1);
|
|
runtimeCatalogDiscoveryCount += 1;
|
|
return sequence[index].map(withMockRuntimeConfigMetadata);
|
|
}
|
|
const configured = config?.mock?.acpRuntimesCatalog;
|
|
if (configured) {
|
|
return mergeMockCustomHarnesses(
|
|
configured.map(withMockRuntimeConfigMetadata),
|
|
);
|
|
}
|
|
const defaultCatalog: RawAcpRuntimeCatalogEntry[] = [
|
|
{
|
|
id: "goose",
|
|
label: "Goose",
|
|
avatar_url: "",
|
|
availability: "available",
|
|
command: "goose",
|
|
binary_path: "/usr/local/bin/goose",
|
|
default_args: ["acp"],
|
|
mcp_command: null,
|
|
install_hint: "Install Goose via the official install script.",
|
|
install_instructions_url: "https://block.github.io/goose/",
|
|
can_auto_install: true,
|
|
requires_external_cli: true,
|
|
underlying_cli_path: null,
|
|
node_required: false,
|
|
auth_status: { status: "not_applicable" },
|
|
source: "builtin",
|
|
login_hint: undefined,
|
|
},
|
|
{
|
|
id: "claude",
|
|
label: "Claude Code",
|
|
avatar_url: "",
|
|
availability: "adapter_missing",
|
|
command: null,
|
|
binary_path: null,
|
|
default_args: [],
|
|
mcp_command: null,
|
|
install_hint: "Install the Claude Code ACP adapter via npm.",
|
|
install_instructions_url:
|
|
"https://www.npmjs.com/package/@anthropic-ai/claude-agent-acp",
|
|
can_auto_install: true,
|
|
requires_external_cli: true,
|
|
underlying_cli_path: "/usr/local/bin/claude",
|
|
node_required: false,
|
|
auth_status: { status: "unknown" },
|
|
source: "builtin",
|
|
login_hint: undefined,
|
|
},
|
|
{
|
|
id: "codex",
|
|
label: "Codex",
|
|
avatar_url: "",
|
|
availability: "not_installed",
|
|
command: null,
|
|
binary_path: null,
|
|
default_args: [],
|
|
mcp_command: null,
|
|
install_hint:
|
|
"The codex-acp adapter must be built from source. See the GitHub repo.",
|
|
install_instructions_url: "https://github.com/openai/codex",
|
|
can_auto_install: false,
|
|
requires_external_cli: true,
|
|
underlying_cli_path: null,
|
|
node_required: false,
|
|
auth_status: { status: "unknown" },
|
|
source: "builtin",
|
|
login_hint: undefined,
|
|
},
|
|
{
|
|
id: "buzz-agent",
|
|
label: "Buzz Agent",
|
|
avatar_url: "",
|
|
availability: "available",
|
|
command: "buzz-agent",
|
|
binary_path: "/usr/local/bin/buzz-agent",
|
|
default_args: [],
|
|
mcp_command: "buzz-dev-mcp",
|
|
install_hint: "Ships with the Buzz desktop app.",
|
|
install_instructions_url: "https://github.com/block/buzz",
|
|
can_auto_install: false,
|
|
requires_external_cli: false,
|
|
underlying_cli_path: null,
|
|
node_required: false,
|
|
auth_status: { status: "not_applicable" },
|
|
source: "builtin",
|
|
login_hint: undefined,
|
|
},
|
|
];
|
|
return mergeMockCustomHarnesses(
|
|
defaultCatalog.map(withMockRuntimeConfigMetadata),
|
|
);
|
|
}
|
|
|
|
async function handleDiscoverAcpAuthMethods(
|
|
args: { runtimeId?: string },
|
|
config: E2eConfig | undefined,
|
|
): Promise<RawAcpAuthMethodsResult> {
|
|
const globalError = config?.mock?.acpAuthMethodsError;
|
|
if (globalError) {
|
|
throw new Error(globalError);
|
|
}
|
|
const runtimeId = args.runtimeId ?? "";
|
|
const perRuntimeError = config?.mock?.acpAuthMethodsErrors?.[runtimeId];
|
|
if (perRuntimeError) {
|
|
throw new Error(perRuntimeError);
|
|
}
|
|
const configured = config?.mock?.acpAuthMethods?.[runtimeId];
|
|
if (configured) {
|
|
return configured;
|
|
}
|
|
return { methods: [] };
|
|
}
|
|
|
|
async function handleConnectAcpRuntime(
|
|
_args: { request?: { runtimeId?: string; methodId?: string } },
|
|
config: E2eConfig | undefined,
|
|
): Promise<RawConnectAcpRuntimeResult> {
|
|
const error = config?.mock?.connectAcpRuntimeError;
|
|
if (error) {
|
|
throw new Error(error);
|
|
}
|
|
const delayMs = config?.mock?.connectAcpRuntimeDelayMs ?? 0;
|
|
if (delayMs > 0) {
|
|
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
|
|
}
|
|
mockConnectCompleted = true;
|
|
return config?.mock?.connectAcpRuntimeResult ?? { launched: true };
|
|
}
|
|
|
|
// Per-page install call counter. Reset each test run because this module is
|
|
// re-evaluated via addInitScript, so the counter starts at 0 for every test.
|
|
let installCallCount = 0;
|
|
/** Per-runtime call counters for `installAcpRuntimeByRuntime` sequences. */
|
|
const installCallCountByRuntime: Record<string, number> = {};
|
|
let addChannelMembersCallCount = 0;
|
|
let setGlobalAgentConfigCallCount = 0;
|
|
let mockGlobalAgentConfig: {
|
|
env_vars: Record<string, string>;
|
|
provider: string | null;
|
|
model: string | null;
|
|
preferred_runtime?: string | null;
|
|
} | null = null;
|
|
|
|
// Per-page get_nsec call counter for sequenced error testing.
|
|
let nsecCallCount = 0;
|
|
let backupVerificationCallCount = 0;
|
|
let backupSaveCallCount = 0;
|
|
|
|
const MOCK_NCRYPTSEC =
|
|
"ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p";
|
|
const MOCK_BACKUP_PASSPHRASE = "mock horse battery staple lake orbit";
|
|
const MOCK_PASSPHRASE_WORDS = [
|
|
"mock",
|
|
"horse",
|
|
"battery",
|
|
"staple",
|
|
"lake",
|
|
"orbit",
|
|
"cedar",
|
|
"plume",
|
|
"raven",
|
|
"tundra",
|
|
];
|
|
|
|
// Per-page explicit catalog publication outcomes.
|
|
let personaSharePublicationCallCount = 0;
|
|
let teamSharePublicationCallCount = 0;
|
|
|
|
// Per-page confirm_team_snapshot_import call counter for sequenced error testing.
|
|
let teamSnapshotConfirmCallCount = 0;
|
|
|
|
// Live-output sequence for the install currently being replayed. The backend
|
|
// counter is per run (`InstallReporter::for_run` starts a fresh one), so this
|
|
// restarts too — a bridge that stayed monotonic across installs would hide a UI
|
|
// that carried a stale sequence number into the next run and rejected all of it.
|
|
let installOutputSeq = 0;
|
|
|
|
/**
|
|
* Replay the live output the Rust reporter emits while an install runs: a clear
|
|
* signal, then one line per entry. `seq` is install-wide and monotonic, matching
|
|
* the backend contract the UI's ordering depends on.
|
|
*
|
|
* The clear and the first line emit synchronously with the install invocation,
|
|
* exactly as the backend does — the command is invoked from the click handler,
|
|
* so those events land before React has committed the pending install state.
|
|
* Delaying them would let a listener that mounts on that state still catch them,
|
|
* hiding the very race the UI has to survive.
|
|
*
|
|
* Later lines are spaced so each is observable rather than collapsing into one
|
|
* frame with the next.
|
|
*/
|
|
const INSTALL_OUTPUT_REPLAY_GAP_MS = 1000;
|
|
|
|
async function replayInstallOutput(
|
|
runtimeId: string,
|
|
lines: string[],
|
|
): Promise<void> {
|
|
installOutputSeq = 0;
|
|
// The leading null is the clear signal the backend sends when an attempt
|
|
// starts, so this replays a whole attempt rather than only its output.
|
|
const events: (string | null)[] = [null, ...lines];
|
|
for (const [index, line] of events.entries()) {
|
|
// Index 0 and 1 are the clear and the first line: no gap before either.
|
|
if (index > 1) {
|
|
await new Promise((resolve) =>
|
|
window.setTimeout(resolve, INSTALL_OUTPUT_REPLAY_GAP_MS),
|
|
);
|
|
}
|
|
// `emit` reaches listeners registered through the real `listen` API, which
|
|
// is what the UI hook uses; mockIPC's shouldMockEvents wires the two.
|
|
await emit("acp-install-output", {
|
|
runtime_id: runtimeId,
|
|
seq: installOutputSeq++,
|
|
line,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function handleInstallAcpRuntime(
|
|
args: {
|
|
runtimeId?: string;
|
|
},
|
|
config: E2eConfig | undefined,
|
|
): Promise<RawInstallRuntimeResult> {
|
|
const runtimeId = args.runtimeId ?? "";
|
|
const outputLines = config?.mock?.installAcpRuntimeOutputLines;
|
|
if (outputLines && outputLines.length > 0) {
|
|
await replayInstallOutput(runtimeId, outputLines);
|
|
}
|
|
const perRuntime = config?.mock?.installAcpRuntimeByRuntime?.[runtimeId];
|
|
|
|
if (perRuntime) {
|
|
const delayMs = perRuntime.delayMs ?? 0;
|
|
if (delayMs > 0) {
|
|
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
|
|
}
|
|
const seq = perRuntime.results;
|
|
if (seq && seq.length > 0) {
|
|
const idx = Math.min(
|
|
installCallCountByRuntime[runtimeId] ?? 0,
|
|
seq.length - 1,
|
|
);
|
|
installCallCountByRuntime[runtimeId] = idx + 1;
|
|
const result = seq[idx];
|
|
if (result.success) mockInstallCompleted = true;
|
|
return result;
|
|
}
|
|
if (perRuntime.result) {
|
|
if (perRuntime.result.success) mockInstallCompleted = true;
|
|
return perRuntime.result;
|
|
}
|
|
}
|
|
|
|
const delayMs = config?.mock?.installAcpRuntimeDelayMs ?? 0;
|
|
if (delayMs > 0) {
|
|
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
|
|
}
|
|
const sequence = config?.mock?.installAcpRuntimeResults;
|
|
if (sequence && sequence.length > 0) {
|
|
const idx = Math.min(installCallCount, sequence.length - 1);
|
|
installCallCount++;
|
|
const result = sequence[idx];
|
|
if (result.success) mockInstallCompleted = true;
|
|
return result;
|
|
}
|
|
const configured = config?.mock?.installAcpRuntimeResult;
|
|
if (configured) {
|
|
if (configured.success) mockInstallCompleted = true;
|
|
return configured;
|
|
}
|
|
mockInstallCompleted = true;
|
|
return {
|
|
success: true,
|
|
steps: [
|
|
{
|
|
step: "adapter",
|
|
command: `mock install ${args.runtimeId ?? "unknown"}`,
|
|
success: true,
|
|
stdout: "mock: installed successfully",
|
|
stderr: "",
|
|
exit_code: 0,
|
|
},
|
|
],
|
|
restarted_count: 0,
|
|
failed_restart_count: 0,
|
|
log_path: null,
|
|
};
|
|
}
|
|
|
|
async function handleDiscoverManagedAgentPrereqs(
|
|
args: {
|
|
input?: {
|
|
acpCommand?: string;
|
|
mcpCommand?: string;
|
|
};
|
|
},
|
|
config: E2eConfig | undefined,
|
|
): Promise<RawManagedAgentPrereqs> {
|
|
const configuredPrereqs = config?.mock?.managedAgentPrereqs;
|
|
|
|
return {
|
|
acp: {
|
|
command:
|
|
configuredPrereqs?.acp?.command ?? args.input?.acpCommand ?? "buzz-acp",
|
|
resolved_path:
|
|
configuredPrereqs?.acp?.resolvedPath ??
|
|
"/Users/wesb/dev/buzz/target/debug/buzz-acp",
|
|
available: configuredPrereqs?.acp?.available ?? true,
|
|
},
|
|
mcp: {
|
|
command: configuredPrereqs?.mcp?.command ?? args.input?.mcpCommand ?? "",
|
|
resolved_path: configuredPrereqs?.mcp?.resolvedPath ?? "",
|
|
available: configuredPrereqs?.mcp?.available ?? true,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function handleListManagedAgents(
|
|
config: E2eConfig | undefined,
|
|
): Promise<RawManagedAgent[]> {
|
|
await delayAgentList(config);
|
|
return mockManagedAgents.map(cloneManagedAgent);
|
|
}
|
|
|
|
function isAgentMemoryListing(
|
|
value: RawAgentMemoryListing | Record<string, RawAgentMemoryListing>,
|
|
): value is RawAgentMemoryListing {
|
|
return (
|
|
"memories" in value &&
|
|
Array.isArray(value.memories) &&
|
|
"truncated" in value &&
|
|
"fetchedAt" in value
|
|
);
|
|
}
|
|
|
|
async function handleGetAgentMemory(
|
|
args: { agentPubkey?: string },
|
|
config: E2eConfig | undefined,
|
|
): Promise<RawAgentMemoryListing> {
|
|
const pubkey = args.agentPubkey?.toLowerCase();
|
|
if (!pubkey) {
|
|
throw new Error("mock get_agent_memory: missing agent pubkey");
|
|
}
|
|
|
|
const isManagedAgent = mockManagedAgents.some(
|
|
(agent) => agent.pubkey.toLowerCase() === pubkey,
|
|
);
|
|
if (!isManagedAgent) {
|
|
throw new Error(`mock get_agent_memory: unmanaged agent ${pubkey}`);
|
|
}
|
|
|
|
const configuredMemory = config?.mock?.agentMemory;
|
|
if (!configuredMemory) {
|
|
return {
|
|
core: null,
|
|
memories: [],
|
|
truncated: false,
|
|
fetchedAt: Math.floor(Date.now() / 1000),
|
|
};
|
|
}
|
|
|
|
if (isAgentMemoryListing(configuredMemory)) {
|
|
return cloneAgentMemoryListing(configuredMemory);
|
|
}
|
|
|
|
const listing =
|
|
configuredMemory[pubkey] ?? configuredMemory[args.agentPubkey ?? ""];
|
|
return listing
|
|
? cloneAgentMemoryListing(listing)
|
|
: {
|
|
core: null,
|
|
memories: [],
|
|
truncated: false,
|
|
fetchedAt: Math.floor(Date.now() / 1000),
|
|
};
|
|
}
|
|
|
|
async function handleListPersonas(): Promise<RawPersona[]> {
|
|
return mockPersonas.map((persona) => ({ ...persona }));
|
|
}
|
|
|
|
type PersonaBehaviorInput = {
|
|
respondTo?: "owner-only" | "allowlist" | "anyone";
|
|
respondToAllowlist?: string[];
|
|
parallelism?: number;
|
|
sessionPolicy?: "channel" | "thread";
|
|
};
|
|
|
|
/** Mirrors `apply_persona_behavior`: replace all four as a unit. */
|
|
function applyMockPersonaBehavior(
|
|
persona: RawPersona,
|
|
behavior: PersonaBehaviorInput | undefined,
|
|
) {
|
|
if (behavior === undefined) {
|
|
return;
|
|
}
|
|
persona.respond_to = behavior.respondTo ?? null;
|
|
persona.respond_to_allowlist =
|
|
behavior.respondTo === "allowlist"
|
|
? [...(behavior.respondToAllowlist ?? [])]
|
|
: [];
|
|
persona.parallelism = behavior.parallelism ?? null;
|
|
persona.session_policy = behavior.sessionPolicy ?? "channel";
|
|
}
|
|
|
|
async function handleCreatePersona(args: {
|
|
input: {
|
|
displayName: string;
|
|
avatarUrl?: string;
|
|
description?: string | null;
|
|
systemPrompt: string;
|
|
runtime?: string;
|
|
model?: string;
|
|
provider?: string;
|
|
envVars?: Record<string, string>;
|
|
behavior?: PersonaBehaviorInput;
|
|
catalogSource?: { ownerPubkey: string; personaId: string };
|
|
};
|
|
}): Promise<RawPersona> {
|
|
const now = new Date().toISOString();
|
|
const persona: RawPersona = {
|
|
id: crypto.randomUUID(),
|
|
display_name: args.input.displayName.trim(),
|
|
avatar_url: args.input.avatarUrl?.trim() || null,
|
|
description: args.input.description?.trim() || null,
|
|
system_prompt: args.input.systemPrompt.trim(),
|
|
runtime: args.input.runtime?.trim() || null,
|
|
model: args.input.model?.trim() || null,
|
|
provider: args.input.provider?.trim() || null,
|
|
is_builtin: false,
|
|
is_active: true,
|
|
shared: false,
|
|
source_team: null,
|
|
// Mirrors `CatalogSource::normalized`: the coordinate a catalog copy keeps
|
|
// so the catalog can tell an already-added foreign entry from a new one.
|
|
catalog_source: args.input.catalogSource
|
|
? {
|
|
owner_pubkey: args.input.catalogSource.ownerPubkey
|
|
.trim()
|
|
.toLowerCase(),
|
|
persona_id: args.input.catalogSource.personaId.trim(),
|
|
}
|
|
: null,
|
|
env_vars: { ...(args.input.envVars ?? {}) },
|
|
session_policy: "channel",
|
|
created_at: now,
|
|
updated_at: now,
|
|
};
|
|
applyMockPersonaBehavior(persona, args.input.behavior);
|
|
mockPersonas.push(persona);
|
|
upsertMockPersonaEvent(persona);
|
|
return { ...persona };
|
|
}
|
|
|
|
type MockUpdatePersonaInput = {
|
|
id: string;
|
|
displayName: string;
|
|
avatarUrl?: string;
|
|
description?: string | null;
|
|
systemPrompt: string;
|
|
runtime?: string;
|
|
model?: string;
|
|
provider?: string;
|
|
envVars?: Record<string, string>;
|
|
behavior?: PersonaBehaviorInput;
|
|
};
|
|
|
|
async function handleUpdatePersona(args: {
|
|
input: MockUpdatePersonaInput;
|
|
}): Promise<RawPersona> {
|
|
return { ...(await applyMockPersonaUpdate(args.input)) };
|
|
}
|
|
|
|
/**
|
|
* Save an edit to the mock persona store, exactly like `update_persona_with`,
|
|
* and return the live record so a caller can publish it.
|
|
*
|
|
* Deliberately does NOT publish a catalog event: the real `update_persona`
|
|
* only enqueues a pending head for the out-of-band flush loop, so nothing has
|
|
* reached the relay by the time the command returns. Publishing here would
|
|
* make a UI that never calls `update_persona_and_publish` look like it kept
|
|
* the "Save and publish" promise.
|
|
*/
|
|
async function applyMockPersonaUpdate(
|
|
input: MockUpdatePersonaInput,
|
|
): Promise<RawPersona> {
|
|
const persona = mockPersonas.find((candidate) => candidate.id === input.id);
|
|
if (!persona) {
|
|
throw new Error(`agent ${input.id} not found`);
|
|
}
|
|
persona.display_name = input.displayName.trim();
|
|
persona.avatar_url = input.avatarUrl?.trim() || null;
|
|
persona.description = input.description?.trim() || null;
|
|
persona.system_prompt = input.systemPrompt.trim();
|
|
persona.runtime = input.runtime?.trim() || null;
|
|
persona.model = input.model?.trim() || null;
|
|
persona.provider = input.provider?.trim() || null;
|
|
if (input.envVars !== undefined) {
|
|
// Absent = preserve; present = replace entirely (matches Rust handler).
|
|
persona.env_vars = { ...input.envVars };
|
|
}
|
|
applyMockPersonaBehavior(persona, input.behavior);
|
|
persona.updated_at = new Date().toISOString();
|
|
|
|
await emit("agents-data-changed");
|
|
return persona;
|
|
}
|
|
|
|
async function handleDeletePersona(args: { id: string }): Promise<void> {
|
|
const persona = mockPersonas.find((candidate) => candidate.id === args.id);
|
|
if (!persona) {
|
|
throw new Error(`agent ${args.id} not found`);
|
|
}
|
|
if (persona.is_builtin) {
|
|
throw new Error("Built-in agents cannot be deleted.");
|
|
}
|
|
if (mockTeams.some((team) => team.persona_ids.includes(args.id))) {
|
|
throw new Error(
|
|
`${persona.display_name} is still referenced by a team. Remove it from those teams first.`,
|
|
);
|
|
}
|
|
|
|
mockPersonas = mockPersonas.filter((candidate) => candidate.id !== args.id);
|
|
const now = new Date().toISOString();
|
|
for (const agent of mockManagedAgents) {
|
|
if (agent.persona_id === args.id) {
|
|
agent.persona_id = null;
|
|
agent.updated_at = now;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function handleSetPersonaActive(args: {
|
|
id: string;
|
|
active: boolean;
|
|
}): Promise<RawPersona> {
|
|
const persona = mockPersonas.find((candidate) => candidate.id === args.id);
|
|
if (!persona) {
|
|
throw new Error(`agent ${args.id} not found`);
|
|
}
|
|
if (!persona.is_builtin) {
|
|
throw new Error(
|
|
"Only built-in agents can be added to or removed from My Agents.",
|
|
);
|
|
}
|
|
if (
|
|
!args.active &&
|
|
mockManagedAgents.some((agent) => agent.persona_id === args.id)
|
|
) {
|
|
throw new Error(
|
|
`${persona.display_name} is still assigned to a managed agent. Remove or reassign those agents first.`,
|
|
);
|
|
}
|
|
if (
|
|
!args.active &&
|
|
mockTeams.some((team) => team.persona_ids.includes(args.id))
|
|
) {
|
|
throw new Error(
|
|
`${persona.display_name} is still referenced by a team. Remove it from those teams first.`,
|
|
);
|
|
}
|
|
|
|
persona.is_active = args.active;
|
|
persona.updated_at = new Date().toISOString();
|
|
return { ...persona };
|
|
}
|
|
|
|
function hasExactSharedTag(event: RelayEvent): boolean {
|
|
const tags = event.tags.filter((tag) => tag[0] === "shared");
|
|
return tags.length === 1 && tags[0]?.length === 2 && tags[0]?.[1] === "true";
|
|
}
|
|
|
|
function upsertMockPersonaRelayEvent(event: RelayEvent): void {
|
|
const sourceId = event.tags.find((tag) => tag[0] === "d")?.[1];
|
|
if (!sourceId) return;
|
|
const existingIndex = mockPersonaEvents.findIndex(
|
|
(candidate) =>
|
|
candidate.pubkey.toLowerCase() === event.pubkey.toLowerCase() &&
|
|
candidate.tags.some((tag) => tag[0] === "d" && tag[1] === sourceId),
|
|
);
|
|
if (existingIndex >= 0) {
|
|
mockPersonaEvents.splice(existingIndex, 1);
|
|
}
|
|
mockPersonaEvents.push(event);
|
|
}
|
|
|
|
function upsertMockPersonaEvent(
|
|
persona: RawPersona,
|
|
identity?: TestIdentity,
|
|
): void {
|
|
const template = {
|
|
created_at: Math.floor(Date.now() / 1_000),
|
|
kind: KIND_PERSONA,
|
|
tags: [["d", persona.id], ...(persona.shared ? [["shared", "true"]] : [])],
|
|
content: JSON.stringify({
|
|
display_name: persona.display_name,
|
|
system_prompt: persona.system_prompt,
|
|
avatar_url: persona.avatar_url,
|
|
description: persona.description ?? null,
|
|
runtime: persona.runtime ?? null,
|
|
model: persona.model ?? null,
|
|
provider: persona.provider ?? null,
|
|
name_pool: persona.name_pool ?? [],
|
|
respond_to: persona.respond_to ?? null,
|
|
respond_to_allowlist: persona.respond_to_allowlist ?? [],
|
|
parallelism: persona.parallelism ?? null,
|
|
}),
|
|
};
|
|
const event: RelayEvent = identity
|
|
? finalizeEvent(template, hexToBytes(identity.privateKey))
|
|
: {
|
|
...template,
|
|
id: mockEventId(),
|
|
pubkey: MOCK_IDENTITY_PUBKEY,
|
|
sig: "0".repeat(128),
|
|
};
|
|
upsertMockPersonaRelayEvent(event);
|
|
emitMockGlobalEvent(event);
|
|
}
|
|
|
|
type MockPersonaPublicationResult = {
|
|
persona: RawPersona;
|
|
publicationStatus: "published" | "queued";
|
|
relayMessage?: string;
|
|
};
|
|
|
|
/**
|
|
* Publish a persona's catalog head and report the relay outcome, like
|
|
* `publish_prepared_persona`. A `queued` outcome must NOT make the event
|
|
* visible to catalog readers — that is the whole distinction the UI reports.
|
|
*/
|
|
function publishMockPersonaHead(
|
|
persona: RawPersona,
|
|
config: E2eConfig | undefined,
|
|
): MockPersonaPublicationResult {
|
|
const publicationStatus =
|
|
config?.mock?.personaSharePublicationStatuses?.[
|
|
personaSharePublicationCallCount++
|
|
] ?? "published";
|
|
if (publicationStatus === "published") {
|
|
upsertMockPersonaEvent(persona, getActiveIdentity(config));
|
|
}
|
|
return {
|
|
persona: { ...persona },
|
|
publicationStatus,
|
|
...(publicationStatus === "queued"
|
|
? { relayMessage: "relay unreachable: could not connect to relay" }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
async function handleSetPersonaShared(
|
|
args: {
|
|
id: string;
|
|
shared: boolean;
|
|
},
|
|
config?: E2eConfig,
|
|
): Promise<MockPersonaPublicationResult> {
|
|
const persona = mockPersonas.find((candidate) => candidate.id === args.id);
|
|
if (!persona) {
|
|
throw new Error(`agent ${args.id} not found`);
|
|
}
|
|
if (persona.is_builtin) {
|
|
throw new Error("Built-in agents cannot be shared to the catalog.");
|
|
}
|
|
persona.shared = args.shared;
|
|
persona.updated_at = new Date().toISOString();
|
|
return publishMockPersonaHead(persona, config);
|
|
}
|
|
|
|
/** Mirrors `update_persona_and_publish`: save the edit, then await the relay. */
|
|
async function handleUpdatePersonaAndPublish(
|
|
args: { input: MockUpdatePersonaInput },
|
|
config?: E2eConfig,
|
|
): Promise<MockPersonaPublicationResult> {
|
|
return publishMockPersonaHead(
|
|
await applyMockPersonaUpdate(args.input),
|
|
config,
|
|
);
|
|
}
|
|
|
|
function ensureMockPersonaIsActive(personaId: string) {
|
|
const persona = mockPersonas.find((candidate) => candidate.id === personaId);
|
|
if (!persona) {
|
|
throw new Error(`agent ${personaId} not found`);
|
|
}
|
|
if (!persona.is_active) {
|
|
throw new Error(`${persona.display_name} is not in My Agents.`);
|
|
}
|
|
}
|
|
|
|
function ensureMockPersonaIdsAreActive(personaIds: string[]) {
|
|
for (const personaId of personaIds) {
|
|
ensureMockPersonaIsActive(personaId);
|
|
}
|
|
}
|
|
|
|
function cloneMockTeam(team: RawTeam): RawTeam {
|
|
return { ...team, persona_ids: [...team.persona_ids] };
|
|
}
|
|
|
|
async function handleListTeams(): Promise<RawTeam[]> {
|
|
return mockTeams.map(cloneMockTeam);
|
|
}
|
|
|
|
async function handleCreateTeam(args: {
|
|
input: {
|
|
name: string;
|
|
description?: string;
|
|
personaIds: string[];
|
|
};
|
|
}): Promise<RawTeam> {
|
|
ensureMockPersonaIdsAreActive(args.input.personaIds);
|
|
const now = new Date().toISOString();
|
|
const team: RawTeam = {
|
|
id: crypto.randomUUID(),
|
|
name: args.input.name.trim(),
|
|
description: args.input.description?.trim() || null,
|
|
persona_ids: [...args.input.personaIds],
|
|
is_builtin: false,
|
|
source_dir: null,
|
|
is_symlink: false,
|
|
symlink_target: null,
|
|
version: null,
|
|
created_at: now,
|
|
updated_at: now,
|
|
};
|
|
mockTeams.push(team);
|
|
return { ...team, persona_ids: [...team.persona_ids] };
|
|
}
|
|
|
|
async function handleUpdateTeam(args: {
|
|
input: {
|
|
id: string;
|
|
name: string;
|
|
description?: string;
|
|
personaIds: string[];
|
|
};
|
|
}): Promise<RawTeam> {
|
|
const team = mockTeams.find((candidate) => candidate.id === args.input.id);
|
|
if (!team) {
|
|
throw new Error(`Team ${args.input.id} not found.`);
|
|
}
|
|
|
|
ensureMockPersonaIdsAreActive(args.input.personaIds);
|
|
team.name = args.input.name.trim();
|
|
team.description = args.input.description?.trim() || null;
|
|
team.persona_ids = [...args.input.personaIds];
|
|
team.updated_at = new Date().toISOString();
|
|
|
|
return { ...team, persona_ids: [...team.persona_ids] };
|
|
}
|
|
|
|
async function handleDeleteTeam(args: { id: string }): Promise<void> {
|
|
const team = mockTeams.find((candidate) => candidate.id === args.id);
|
|
if (team?.is_builtin) {
|
|
throw new Error("Built-in teams cannot be deleted.");
|
|
}
|
|
mockTeams = mockTeams.filter((candidate) => candidate.id !== args.id);
|
|
}
|
|
|
|
// ── Team catalog (kind:30178) ───────────────────────────────────────────────
|
|
|
|
/** The team's catalog projection, as `team_catalog_content` builds it. */
|
|
function mockTeamCatalogContent(team: RawTeam): string {
|
|
return JSON.stringify({
|
|
v: 1,
|
|
name: team.name,
|
|
description: team.description,
|
|
instructions: null,
|
|
members: team.persona_ids.map((personaId) => {
|
|
const persona = mockPersonas.find(
|
|
(candidate) => candidate.id === personaId,
|
|
);
|
|
return {
|
|
member_key: personaId,
|
|
display_name: persona?.display_name ?? personaId,
|
|
system_prompt: persona?.system_prompt ?? "",
|
|
avatar_url: persona?.avatar_url ?? null,
|
|
runtime: persona?.runtime ?? null,
|
|
model: persona?.model ?? null,
|
|
};
|
|
}),
|
|
});
|
|
}
|
|
|
|
function upsertMockTeamCatalogEvent(
|
|
team: RawTeam,
|
|
identity?: TestIdentity,
|
|
): void {
|
|
const template = {
|
|
created_at: Math.floor(Date.now() / 1_000),
|
|
kind: KIND_TEAM_CATALOG,
|
|
tags: [["d", team.id], ...(team.shared ? [["shared", "true"]] : [])],
|
|
content: mockTeamCatalogContent(team),
|
|
};
|
|
const event: RelayEvent = identity
|
|
? finalizeEvent(template, hexToBytes(identity.privateKey))
|
|
: {
|
|
...template,
|
|
id: mockEventId(),
|
|
pubkey: MOCK_IDENTITY_PUBKEY,
|
|
sig: "0".repeat(128),
|
|
};
|
|
const existingIndex = mockTeamCatalogEvents.findIndex(
|
|
(candidate) =>
|
|
candidate.pubkey.toLowerCase() === event.pubkey.toLowerCase() &&
|
|
candidate.tags.some((tag) => tag[0] === "d" && tag[1] === team.id),
|
|
);
|
|
if (existingIndex >= 0) {
|
|
mockTeamCatalogEvents.splice(existingIndex, 1);
|
|
}
|
|
mockTeamCatalogEvents.push(event);
|
|
emitMockGlobalEvent(event);
|
|
}
|
|
|
|
type MockTeamPublicationResult = {
|
|
team: RawTeam;
|
|
publicationStatus: "published" | "queued";
|
|
};
|
|
|
|
/**
|
|
* Mirrors `set_team_shared`. A `queued` outcome must NOT make the head visible
|
|
* to catalog readers — that lag is exactly what the UI copy reports.
|
|
*/
|
|
async function handleSetTeamShared(
|
|
args: { id: string; shared: boolean },
|
|
config?: E2eConfig,
|
|
): Promise<MockTeamPublicationResult> {
|
|
const team = mockTeams.find((candidate) => candidate.id === args.id);
|
|
if (!team) {
|
|
throw new Error(`Team ${args.id} not found.`);
|
|
}
|
|
if (team.is_builtin) {
|
|
throw new Error("Built-in teams cannot be shared to the catalog.");
|
|
}
|
|
team.shared = args.shared;
|
|
team.updated_at = new Date().toISOString();
|
|
|
|
const publicationStatus =
|
|
config?.mock?.teamSharePublicationStatuses?.[
|
|
teamSharePublicationCallCount++
|
|
] ?? "published";
|
|
if (publicationStatus === "published") {
|
|
upsertMockTeamCatalogEvent(team, getActiveIdentity(config));
|
|
}
|
|
return {
|
|
team: cloneMockTeam(team),
|
|
publicationStatus,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Mirrors `add_team_from_catalog`, including the canonical-head check: the
|
|
* coordinate is re-resolved against the current heads and the add is rejected
|
|
* unless that head is still `eventId` and still shared. A test that stales the
|
|
* head must see the same failure the real command produces.
|
|
*/
|
|
async function handleAddTeamFromCatalog(args: {
|
|
input: { ownerPubkey: string; teamDTag: string; eventId: string };
|
|
}): Promise<{ team: RawTeam; alreadyPresent: boolean }> {
|
|
const { ownerPubkey, teamDTag, eventId } = args.input;
|
|
const owner = ownerPubkey.toLowerCase();
|
|
const head = mockTeamCatalogEvents
|
|
.filter(
|
|
(event) =>
|
|
event.pubkey.toLowerCase() === owner &&
|
|
event.tags.filter((tag) => tag[0] === "d").length === 1 &&
|
|
event.tags.some((tag) => tag[0] === "d" && tag[1] === teamDTag),
|
|
)
|
|
.sort(
|
|
(left, right) =>
|
|
right.created_at - left.created_at || left.id.localeCompare(right.id),
|
|
)[0];
|
|
|
|
if (!head || !hasExactSharedTag(head)) {
|
|
throw new Error("This team is no longer shared to the catalog.");
|
|
}
|
|
if (head.id !== eventId) {
|
|
throw new Error(
|
|
"This team was updated since you opened the catalog. Reopen it and try again.",
|
|
);
|
|
}
|
|
|
|
const existing = mockTeams.find(
|
|
(candidate) =>
|
|
candidate.catalog_source?.owner_pubkey === owner &&
|
|
candidate.catalog_source?.team_d_tag === teamDTag,
|
|
);
|
|
if (existing) {
|
|
return { team: cloneMockTeam(existing), alreadyPresent: true };
|
|
}
|
|
|
|
const content = JSON.parse(head.content) as {
|
|
name: string;
|
|
description: string | null;
|
|
members: Array<{
|
|
member_key: string;
|
|
display_name: string;
|
|
system_prompt: string;
|
|
avatar_url: string | null;
|
|
}>;
|
|
};
|
|
const now = new Date().toISOString();
|
|
const personaIds = content.members.map((member) => {
|
|
const id = crypto.randomUUID();
|
|
mockPersonas.push({
|
|
id,
|
|
display_name: member.display_name,
|
|
avatar_url: member.avatar_url,
|
|
system_prompt: member.system_prompt,
|
|
is_builtin: false,
|
|
is_active: true,
|
|
shared: false,
|
|
env_vars: {},
|
|
created_at: now,
|
|
updated_at: now,
|
|
});
|
|
return id;
|
|
});
|
|
const team: RawTeam = {
|
|
id: crypto.randomUUID(),
|
|
name: content.name,
|
|
description: content.description,
|
|
persona_ids: personaIds,
|
|
is_builtin: false,
|
|
shared: false,
|
|
catalog_source: { owner_pubkey: owner, team_d_tag: teamDTag },
|
|
source_dir: null,
|
|
is_symlink: false,
|
|
symlink_target: null,
|
|
version: null,
|
|
created_at: now,
|
|
updated_at: now,
|
|
};
|
|
mockTeams.push(team);
|
|
return { team: cloneMockTeam(team), alreadyPresent: false };
|
|
}
|
|
|
|
async function handleExportTeamToJson(args: { id: string }): Promise<boolean> {
|
|
const team = mockTeams.find((candidate) => candidate.id === args.id);
|
|
if (!team) {
|
|
throw new Error(`Team ${args.id} not found.`);
|
|
}
|
|
|
|
const missingPersonaIds = team.persona_ids.filter(
|
|
(personaId) =>
|
|
!mockPersonas.some((candidate) => candidate.id === personaId),
|
|
);
|
|
if (missingPersonaIds.length > 0) {
|
|
throw new Error(
|
|
`Team ${team.name} references missing personas: ${missingPersonaIds.join(", ")}. Repair the team before exporting.`,
|
|
);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
async function handlePickTeamDirectory(): Promise<string | null> {
|
|
return "/Users/dev/agents/new-team";
|
|
}
|
|
|
|
async function handleInstallTeamFromDirectory(args: {
|
|
path: string;
|
|
symlink: boolean;
|
|
}): Promise<RawTeam> {
|
|
const now = new Date().toISOString();
|
|
const team: RawTeam = {
|
|
id: crypto.randomUUID(),
|
|
name: "Installed Team",
|
|
description: null,
|
|
persona_ids: [],
|
|
is_builtin: false,
|
|
source_dir: args.path,
|
|
is_symlink: args.symlink,
|
|
symlink_target: args.symlink ? args.path : null,
|
|
version: null,
|
|
created_at: now,
|
|
updated_at: now,
|
|
};
|
|
mockTeams.push(team);
|
|
return { ...team, persona_ids: [...team.persona_ids] };
|
|
}
|
|
|
|
async function handleSyncTeamDirectory(args: { teamId: string }): Promise<{
|
|
personas_added: string[];
|
|
personas_removed: string[];
|
|
personas_updated: string[];
|
|
metadata_changed: boolean;
|
|
}> {
|
|
const team = mockTeams.find((candidate) => candidate.id === args.teamId);
|
|
if (!team) {
|
|
throw new Error(`Team ${args.teamId} not found.`);
|
|
}
|
|
return {
|
|
personas_added: [],
|
|
personas_removed: [],
|
|
personas_updated: [],
|
|
metadata_changed: false,
|
|
};
|
|
}
|
|
|
|
async function handleParseTeamFile(): Promise<{
|
|
name: string;
|
|
description: string | null;
|
|
personas: Array<{
|
|
display_name: string;
|
|
system_prompt: string;
|
|
avatar_url: string | null;
|
|
}>;
|
|
}> {
|
|
return {
|
|
name: "Imported Team",
|
|
description: null,
|
|
personas: [],
|
|
};
|
|
}
|
|
|
|
async function handleCreateManagedAgent(
|
|
args: {
|
|
input: {
|
|
name: string;
|
|
personaId?: string;
|
|
relayUrl?: string;
|
|
acpCommand?: string;
|
|
agentCommand?: string;
|
|
agentArgs?: string[];
|
|
mcpCommand?: string;
|
|
turnTimeoutSeconds?: number;
|
|
idleTimeoutSeconds?: number;
|
|
maxTurnDurationSeconds?: number;
|
|
parallelism?: number;
|
|
systemPrompt?: string;
|
|
avatarUrl?: string;
|
|
model?: string;
|
|
provider?: string;
|
|
envVars?: Record<string, string>;
|
|
spawnAfterCreate?: boolean;
|
|
startOnAppLaunch?: boolean;
|
|
backend?:
|
|
| { type: "local" }
|
|
| { type: "provider"; id: string; config: Record<string, unknown> };
|
|
respondTo?: "owner-only" | "allowlist" | "anyone";
|
|
respondToAllowlist?: string[];
|
|
};
|
|
},
|
|
config: E2eConfig | undefined,
|
|
): Promise<RawCreateManagedAgentResponse> {
|
|
const delayMs = config?.mock?.createManagedAgentDelayMs ?? 0;
|
|
if (delayMs > 0) {
|
|
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
|
|
}
|
|
|
|
if (args.input.personaId) {
|
|
ensureMockPersonaIsActive(args.input.personaId);
|
|
}
|
|
// Mint-parity with resolve_mint_behavioral_defaults: an explicit input
|
|
// wins; otherwise the linked definition's stored quad applies (mode+list
|
|
// travel together); otherwise the schema default.
|
|
const linkedPersona = args.input.personaId
|
|
? (mockPersonas.find((persona) => persona.id === args.input.personaId) ??
|
|
null)
|
|
: null;
|
|
const mintRespondTo =
|
|
args.input.respondTo ??
|
|
(linkedPersona?.respond_to as RawManagedAgent["respond_to"] | null) ??
|
|
"owner-only";
|
|
const mintRespondToAllowlist =
|
|
args.input.respondTo !== undefined
|
|
? (args.input.respondToAllowlist ?? [])
|
|
: (linkedPersona?.respond_to_allowlist ?? []);
|
|
const mintParallelism = resolveAgentParallelism(
|
|
args.input.parallelism,
|
|
linkedPersona?.parallelism,
|
|
);
|
|
const personaAvatarUrl =
|
|
args.input.personaId === undefined
|
|
? null
|
|
: (mockPersonas.find((persona) => persona.id === args.input.personaId)
|
|
?.avatar_url ?? null);
|
|
const avatarUrl = args.input.avatarUrl?.trim() || personaAvatarUrl;
|
|
const name = args.input.name.trim();
|
|
const now = new Date().toISOString();
|
|
const pubkey = crypto
|
|
.randomUUID()
|
|
.replace(/-/g, "")
|
|
.padEnd(64, "0")
|
|
.slice(0, 64);
|
|
const agentCommand = args.input.agentCommand ?? "buzz-agent";
|
|
const agentArgs =
|
|
args.input.agentArgs && args.input.agentArgs.length > 0
|
|
? [...args.input.agentArgs]
|
|
: agentCommand === "goose"
|
|
? ["acp"]
|
|
: [];
|
|
const managedAgent: MockManagedAgent = {
|
|
pubkey,
|
|
name,
|
|
persona_id: args.input.personaId ?? null,
|
|
// Create never pins a harness id — the record inherits from the persona.
|
|
runtime: null,
|
|
relay_url: args.input.relayUrl ?? DEFAULT_RELAY_WS_URL,
|
|
acp_command: args.input.acpCommand ?? "buzz-acp",
|
|
agent_command: agentCommand,
|
|
agent_args: agentArgs,
|
|
mcp_command: args.input.mcpCommand ?? "",
|
|
turn_timeout_seconds: args.input.turnTimeoutSeconds ?? 320,
|
|
idle_timeout_seconds: args.input.idleTimeoutSeconds ?? null,
|
|
max_turn_duration_seconds: args.input.maxTurnDurationSeconds ?? null,
|
|
parallelism: mintParallelism,
|
|
system_prompt: args.input.systemPrompt?.trim() || null,
|
|
avatar_url: avatarUrl,
|
|
model: args.input.model?.trim() || linkedPersona?.model || null,
|
|
provider: args.input.provider?.trim() || linkedPersona?.provider || null,
|
|
env_vars: { ...(args.input.envVars ?? {}) },
|
|
status: args.input.spawnAfterCreate ? "running" : "stopped",
|
|
pid: args.input.spawnAfterCreate ? 42000 + mockManagedAgents.length : null,
|
|
created_at: now,
|
|
updated_at: now,
|
|
last_started_at: args.input.spawnAfterCreate ? now : null,
|
|
last_stopped_at: null,
|
|
last_exit_code: null,
|
|
last_error: null,
|
|
last_error_code: null,
|
|
log_path: `/tmp/mock-agent-${pubkey}.log`,
|
|
start_on_app_launch: args.input.startOnAppLaunch ?? true,
|
|
auto_restart_on_config_change: true,
|
|
backend: args.input.backend ?? { type: "local" as const },
|
|
backend_agent_id: null,
|
|
respond_to: mintRespondTo,
|
|
respond_to_allowlist: [...mintRespondToAllowlist],
|
|
private_key_nsec: `nsec1mock${pubkey.slice(0, 20)}`,
|
|
log_lines: [
|
|
`buzz-acp starting: relay=${args.input.relayUrl ?? DEFAULT_RELAY_WS_URL} agent_pubkey=${pubkey} parallelism=${mintParallelism}`,
|
|
args.input.systemPrompt?.trim()
|
|
? `system prompt override configured (${args.input.systemPrompt.trim().length} chars)`
|
|
: "system prompt override not set",
|
|
args.input.spawnAfterCreate
|
|
? "connected to relay at ws://localhost:3000"
|
|
: "profile created; harness not started",
|
|
],
|
|
};
|
|
|
|
mockManagedAgents.unshift(managedAgent);
|
|
if (args.input.spawnAfterCreate && managedAgent.backend.type === "local") {
|
|
// The real create command spawns a pair runtime on the agent's effective
|
|
// relay (`start_local_agent_with_preflight`), so mirror that row here.
|
|
upsertMockManagedAgentRuntime(pubkey, managedAgent.relay_url, "ready");
|
|
}
|
|
applyMockDisplayName(pubkey, name);
|
|
mockAgentPubkeys.add(pubkey);
|
|
mockProfiles.set(pubkey, {
|
|
pubkey,
|
|
display_name: name,
|
|
avatar_url: avatarUrl,
|
|
about: args.input.systemPrompt?.trim() || null,
|
|
nip05_handle: null,
|
|
owner_pubkey: MOCK_IDENTITY_PUBKEY,
|
|
is_agent: true,
|
|
has_profile_event: true,
|
|
});
|
|
syncMockRelayAgentsFromManagedAgents();
|
|
|
|
return {
|
|
agent: cloneManagedAgent(managedAgent),
|
|
private_key_nsec: managedAgent.private_key_nsec,
|
|
profile_sync_error: null,
|
|
spawn_error: null,
|
|
};
|
|
}
|
|
|
|
function getMockManagedAgent(pubkey: string): MockManagedAgent {
|
|
const agent = mockManagedAgents.find(
|
|
(candidate) => candidate.pubkey === pubkey,
|
|
);
|
|
if (!agent) {
|
|
throw new Error(`Managed agent ${pubkey} not found.`);
|
|
}
|
|
|
|
return agent;
|
|
}
|
|
|
|
function upsertMockManagedAgentRuntime(
|
|
pubkey: string,
|
|
relayUrl: string,
|
|
lifecycle: MockManagedAgentRuntimeRow["lifecycle"],
|
|
): MockManagedAgentRuntimeRow {
|
|
let row = mockManagedAgentRuntimes.find(
|
|
(candidate) =>
|
|
candidate.pubkey === pubkey && candidate.relayUrl === relayUrl,
|
|
);
|
|
if (!row) {
|
|
row = {
|
|
pubkey,
|
|
relayUrl,
|
|
localSetup: true,
|
|
lifecycle,
|
|
pid: null,
|
|
error: null,
|
|
logPath: null,
|
|
};
|
|
mockManagedAgentRuntimes.push(row);
|
|
}
|
|
row.lifecycle = lifecycle;
|
|
row.pid =
|
|
lifecycle === "stopped"
|
|
? null
|
|
: (row.pid ?? 43000 + mockManagedAgentRuntimes.length);
|
|
row.error = null;
|
|
return row;
|
|
}
|
|
|
|
// Pair-scoped lifecycle, mirroring `runtime_commands.rs`: the command touches
|
|
// exactly one agent+relay runtime and rejects non-local agents.
|
|
function handleManagedAgentRuntimeAction(
|
|
action: "start" | "stop" | "restart",
|
|
args: { pubkey: string; relayUrl: string },
|
|
): MockManagedAgentRuntimeRow {
|
|
const agent = getMockManagedAgent(args.pubkey);
|
|
if (agent.backend.type !== "local") {
|
|
throw new Error("managed runtime pairs require a local agent");
|
|
}
|
|
return {
|
|
...upsertMockManagedAgentRuntime(
|
|
args.pubkey,
|
|
args.relayUrl,
|
|
action === "stop" ? "stopped" : "ready",
|
|
),
|
|
};
|
|
}
|
|
|
|
function isRelayMeshManagedAgent(agent: MockManagedAgent): boolean {
|
|
return agent.backend.type === "local" && agent.provider === "relay-mesh";
|
|
}
|
|
|
|
async function handleStartManagedAgent(
|
|
args: {
|
|
pubkey: string;
|
|
expectedRelayUrl?: string | null;
|
|
expectedSignerPubkey?: string | null;
|
|
},
|
|
config?: E2eConfig,
|
|
): Promise<RawManagedAgent> {
|
|
const delayMs = config?.mock?.startManagedAgentDelayMs ?? 0;
|
|
if (delayMs > 0) {
|
|
await new Promise<void>((resolve) => {
|
|
let settled = false;
|
|
const release = () => {
|
|
if (settled) return;
|
|
settled = true;
|
|
window.clearTimeout(timer);
|
|
heldManagedAgentStartReleases = heldManagedAgentStartReleases.filter(
|
|
(held) => held !== release,
|
|
);
|
|
resolve();
|
|
};
|
|
const timer = window.setTimeout(release, delayMs);
|
|
heldManagedAgentStartReleases.push(release);
|
|
});
|
|
}
|
|
// After the injected delay, like the real command's checks before any
|
|
// spawn/deploy side effect: a caller-captured tenant scope and signer
|
|
// identity must still match the active community/identity.
|
|
assertExpectedRelayScope(args.expectedRelayUrl, config);
|
|
assertExpectedSigner(args.expectedSignerPubkey, config);
|
|
|
|
const startError = config?.mock?.startManagedAgentErrors?.shift();
|
|
if (startError) {
|
|
throw new Error(startError);
|
|
}
|
|
|
|
const agent = getMockManagedAgent(args.pubkey);
|
|
if (isRelayMeshManagedAgent(agent)) {
|
|
// Model the backend start preflight (ensure_relay_mesh_for_record): a
|
|
// saved relay-mesh agent re-resolves a live serve target for its model
|
|
// and only fails when no peer currently serves it.
|
|
const modelId = agent.model ?? "auto";
|
|
const hasLiveTarget =
|
|
mockMeshState.admitted &&
|
|
(modelId === "auto" ||
|
|
mockMeshState.models.some((model) => model.id === modelId));
|
|
if (!hasLiveTarget) {
|
|
throw new Error(
|
|
"Buzz shared compute cannot start because no live member is serving this model.",
|
|
);
|
|
}
|
|
}
|
|
|
|
const now = new Date().toISOString();
|
|
if (agent.backend.type === "provider") {
|
|
agent.status = "deployed";
|
|
agent.pid = null;
|
|
agent.backend_agent_id =
|
|
agent.backend_agent_id ?? `mock-provider-${agent.pubkey.slice(0, 12)}`;
|
|
} else {
|
|
agent.status = "running";
|
|
agent.pid = agent.pid ?? 42000 + mockManagedAgents.indexOf(agent);
|
|
// The real command spawns a pair runtime keyed by the agent's effective
|
|
// relay (`start_managed_agent_process`), so mirror that row here.
|
|
upsertMockManagedAgentRuntime(agent.pubkey, agent.relay_url, "ready");
|
|
}
|
|
agent.updated_at = now;
|
|
agent.last_started_at = now;
|
|
agent.last_error = null;
|
|
// Lifecycle is not presence; scenarios publish explicit relay evidence.
|
|
agent.log_lines.push(
|
|
agent.backend.type === "provider"
|
|
? `deployed mock provider harness at ${now}`
|
|
: `started mock harness at ${now}`,
|
|
);
|
|
syncMockRelayAgentsFromManagedAgents();
|
|
return cloneManagedAgent(agent);
|
|
}
|
|
|
|
async function handleStopManagedAgent(args: {
|
|
pubkey: string;
|
|
}): Promise<RawManagedAgent> {
|
|
const agent = getMockManagedAgent(args.pubkey);
|
|
const now = new Date().toISOString();
|
|
agent.status = "stopped";
|
|
agent.pid = null;
|
|
agent.updated_at = now;
|
|
agent.last_stopped_at = now;
|
|
// Legacy agent-wide stop deliberately drains EVERY pair runtime for the
|
|
// agent (`runtime/stop.rs`) — mirror that so specs can prove the sidebar
|
|
// no longer routes through it.
|
|
for (const row of mockManagedAgentRuntimes) {
|
|
if (row.pubkey === args.pubkey) {
|
|
row.lifecycle = "stopped";
|
|
row.pid = null;
|
|
}
|
|
}
|
|
// A stop result alone is not an authored offline presence event.
|
|
agent.log_lines.push(`stopped mock harness at ${now}`);
|
|
syncMockRelayAgentsFromManagedAgents();
|
|
return cloneManagedAgent(agent);
|
|
}
|
|
|
|
async function handleDeleteManagedAgent(args: {
|
|
pubkey: string;
|
|
forceRemoteDelete?: boolean | null;
|
|
}): Promise<void> {
|
|
// Model the backend invariant: reject deletion of deployed remote agents
|
|
// unless force_remote_delete is true.
|
|
const agent = mockManagedAgents.find((a) => a.pubkey === args.pubkey);
|
|
if (
|
|
agent &&
|
|
agent.backend.type === "provider" &&
|
|
agent.backend_agent_id != null &&
|
|
!args.forceRemoteDelete
|
|
) {
|
|
throw new Error(
|
|
"cannot delete a deployed remote agent without force_remote_delete: true",
|
|
);
|
|
}
|
|
mockManagedAgents = mockManagedAgents.filter(
|
|
(candidate) => candidate.pubkey !== args.pubkey,
|
|
);
|
|
if (mockBestieAssignment?.agent_pubkey === args.pubkey) {
|
|
mockBestieAssignment = null;
|
|
}
|
|
syncMockRelayAgentsFromManagedAgents();
|
|
}
|
|
|
|
async function handleSetManagedAgentStartOnAppLaunch(args: {
|
|
pubkey: string;
|
|
startOnAppLaunch: boolean;
|
|
}): Promise<RawManagedAgent> {
|
|
const agent = getMockManagedAgent(args.pubkey);
|
|
agent.start_on_app_launch = args.startOnAppLaunch;
|
|
agent.updated_at = new Date().toISOString();
|
|
return cloneManagedAgent(agent);
|
|
}
|
|
|
|
async function handleSetManagedAgentAutoRestart(args: {
|
|
pubkey: string;
|
|
autoRestartOnConfigChange: boolean;
|
|
}): Promise<RawManagedAgent> {
|
|
const agent = getMockManagedAgent(args.pubkey);
|
|
agent.auto_restart_on_config_change = args.autoRestartOnConfigChange;
|
|
agent.updated_at = new Date().toISOString();
|
|
return cloneManagedAgent(agent);
|
|
}
|
|
|
|
async function handleGetManagedAgentLog(args: {
|
|
pubkey: string;
|
|
lineCount?: number;
|
|
}): Promise<RawManagedAgentLog> {
|
|
const agent = getMockManagedAgent(args.pubkey);
|
|
const count = args.lineCount ?? 120;
|
|
return {
|
|
content: agent.log_lines.slice(-count).join("\n"),
|
|
log_path: agent.log_path,
|
|
};
|
|
}
|
|
|
|
async function handleUpdateManagedAgent(args: {
|
|
input: {
|
|
pubkey: string;
|
|
name?: string;
|
|
model?: string | null;
|
|
systemPrompt?: string | null;
|
|
envVars?: Record<string, string>;
|
|
respondTo?: "owner-only" | "allowlist" | "anyone";
|
|
respondToAllowlist?: string[];
|
|
};
|
|
}): Promise<{ agent: RawManagedAgent; profile_sync_error: string | null }> {
|
|
const agent = getMockManagedAgent(args.input.pubkey);
|
|
if (args.input.name !== undefined) {
|
|
agent.name = args.input.name;
|
|
}
|
|
if (args.input.model !== undefined) {
|
|
agent.model = args.input.model;
|
|
}
|
|
if (args.input.systemPrompt !== undefined) {
|
|
agent.system_prompt = args.input.systemPrompt;
|
|
}
|
|
if (args.input.envVars !== undefined) {
|
|
agent.env_vars = { ...args.input.envVars };
|
|
}
|
|
if (args.input.respondTo !== undefined) {
|
|
agent.respond_to = args.input.respondTo;
|
|
}
|
|
if (args.input.respondToAllowlist !== undefined) {
|
|
agent.respond_to_allowlist = args.input.respondToAllowlist;
|
|
}
|
|
agent.updated_at = new Date().toISOString();
|
|
return { agent: cloneManagedAgent(agent), profile_sync_error: null };
|
|
}
|
|
|
|
async function handleSearchMessages(
|
|
args: {
|
|
q: string;
|
|
limit?: number;
|
|
channelId?: string;
|
|
authors?: string[];
|
|
since?: number;
|
|
until?: number;
|
|
},
|
|
config: E2eConfig | undefined,
|
|
): Promise<RawSearchResponse> {
|
|
const identity = getIdentity(config);
|
|
if (!identity) {
|
|
const query = args.q.trim().toLowerCase();
|
|
const limit = args.limit ?? 20;
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
const mockHits: RawSearchHit[] = [
|
|
{
|
|
event_id: "mock-general-welcome",
|
|
content: "Welcome to #general",
|
|
kind: 9,
|
|
pubkey: DEFAULT_MOCK_IDENTITY.pubkey,
|
|
channel_id: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
|
|
channel_name: "general",
|
|
created_at: now - 60,
|
|
score: 8.5,
|
|
},
|
|
{
|
|
event_id: "mock-engineering-shipped",
|
|
content: "Engineering shipped the desktop build.",
|
|
kind: 9,
|
|
pubkey:
|
|
"bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260",
|
|
channel_id: "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9",
|
|
channel_name: "engineering",
|
|
created_at: now - 42 * 60,
|
|
score: 7.2,
|
|
},
|
|
{
|
|
event_id: "mock-design-critique",
|
|
content: "Design critique notes for the browse flow.",
|
|
kind: 9,
|
|
pubkey:
|
|
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f",
|
|
channel_id: "b5e2f8a1-3c44-5912-9e67-4a8d1f2b3c4e",
|
|
channel_name: "design",
|
|
created_at: now - 75 * 60,
|
|
score: 6.6,
|
|
},
|
|
{
|
|
event_id: "mock-forum-release-thread",
|
|
content: "Release checklist: async feedback thread.",
|
|
kind: 45001,
|
|
pubkey:
|
|
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f",
|
|
channel_id: "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11",
|
|
channel_name: "watercooler",
|
|
created_at: now - 90 * 60,
|
|
score: 5.8,
|
|
},
|
|
{
|
|
event_id: "mock-forum-release-reply",
|
|
content: "Looks good to me. We should ship it.",
|
|
kind: 45003,
|
|
pubkey: ALICE_PUBKEY,
|
|
channel_id: "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11",
|
|
channel_name: "watercooler",
|
|
created_at: now - 80 * 60,
|
|
score: 5.2,
|
|
},
|
|
];
|
|
for (const [channelId, events] of mockMessages) {
|
|
const channel = mockChannels.find(
|
|
(candidate) => candidate.id === channelId,
|
|
);
|
|
for (const event of events) {
|
|
mockHits.push({
|
|
event_id: event.id,
|
|
content: event.content,
|
|
kind: event.kind,
|
|
pubkey: event.pubkey,
|
|
channel_id: channelId,
|
|
channel_name: channel?.name ?? null,
|
|
created_at: event.created_at,
|
|
score: 1,
|
|
});
|
|
}
|
|
}
|
|
|
|
const authorSet = args.authors?.length
|
|
? new Set(args.authors.map((author) => author.toLowerCase()))
|
|
: null;
|
|
|
|
const hits = mockHits
|
|
.filter((hit) =>
|
|
mockSearchHitMatches(hit, {
|
|
query,
|
|
channelId: args.channelId,
|
|
authorSet,
|
|
since: args.since,
|
|
until: args.until,
|
|
}),
|
|
)
|
|
.slice(0, limit);
|
|
|
|
return {
|
|
hits,
|
|
found: hits.length,
|
|
};
|
|
}
|
|
|
|
// NIP-50 search via POST /query — forward operator pushdown fields.
|
|
const limit = args.limit ?? 20;
|
|
const filter: Record<string, unknown> = {
|
|
kinds: [9, 40002, 45001, 45003],
|
|
search: args.q,
|
|
limit,
|
|
};
|
|
if (args.channelId) {
|
|
filter["#h"] = [args.channelId];
|
|
}
|
|
if (args.authors?.length) {
|
|
filter.authors = args.authors;
|
|
}
|
|
if (args.since != null) {
|
|
filter.since = args.since;
|
|
}
|
|
if (args.until != null) {
|
|
filter.until = args.until;
|
|
}
|
|
const events = await relayQuery(config, [filter]);
|
|
const hits = events.map((ev) => ({
|
|
event_id: ev.id ?? "",
|
|
pubkey: ev.pubkey ?? "",
|
|
content: ev.content ?? "",
|
|
created_at: ev.created_at ?? 0,
|
|
kind: ev.kind ?? 9,
|
|
tags: ev.tags ?? [],
|
|
sig: ev.sig ?? "",
|
|
channel_id:
|
|
((ev.tags ?? []) as string[][]).find((t) => t[0] === "h")?.[1] ?? null,
|
|
channel_name: null,
|
|
score: 1.0,
|
|
}));
|
|
return { hits, found: hits.length };
|
|
}
|
|
|
|
/**
|
|
* Descriptors returned by the mocked upload commands. A spec can override via
|
|
* `MockBridgeOptions.uploadDescriptors`; otherwise we return a single generic
|
|
* PDF so the file-attachment flow (chip → send → FileCard) can be exercised
|
|
* out of the box.
|
|
*/
|
|
async function resolveMockUploadDescriptors(
|
|
config: E2eConfig | undefined,
|
|
): Promise<RawBlobDescriptor[]> {
|
|
const delayMs = config?.mock?.uploadDelayMs ?? 0;
|
|
if (delayMs > 0) {
|
|
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
|
|
}
|
|
|
|
const configured = config?.mock?.uploadDescriptors;
|
|
// `undefined` means "not configured" → default PDF. An explicit `[]` is a
|
|
// valid override (e.g. modelling a picker cancel / no-files-selected), so it
|
|
// must pass through rather than fall back to the default.
|
|
if (configured !== undefined) return configured;
|
|
return [
|
|
{
|
|
url: `https://mock.relay/media/${"a".repeat(64)}.pdf`,
|
|
sha256: "a".repeat(64),
|
|
size: 12345,
|
|
type: "application/pdf",
|
|
uploaded: Math.floor(Date.now() / 1000),
|
|
filename: "quarterly-report.pdf",
|
|
},
|
|
];
|
|
}
|
|
|
|
async function resolveMockUploadDescriptorForBytes(
|
|
args: { data: number[] | Uint8Array; filename?: string | null },
|
|
config: E2eConfig | undefined,
|
|
): Promise<RawBlobDescriptor> {
|
|
const uploadDelayMs = config?.mock?.linkPreviewUploadDelayMs ?? 0;
|
|
if (args.filename?.startsWith("link-preview-")) {
|
|
if (uploadDelayMs > 0) {
|
|
await new Promise((resolve) => setTimeout(resolve, uploadDelayMs));
|
|
}
|
|
const errorFilenames = config?.mock?.linkPreviewUploadErrorFilenames;
|
|
if (errorFilenames?.some((needle) => args.filename?.includes(needle))) {
|
|
throw new Error(`mock upload failed for ${args.filename}`);
|
|
}
|
|
}
|
|
const configured = config?.mock?.uploadDescriptors;
|
|
if (configured !== undefined) {
|
|
const descriptors = await resolveMockUploadDescriptors(config);
|
|
const descriptor = descriptors[0];
|
|
if (!descriptor) throw new Error("mock upload returned no descriptor");
|
|
return descriptor;
|
|
}
|
|
|
|
const bytes = Uint8Array.from(args.data);
|
|
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
const sha256 = Array.from(new Uint8Array(digest), (value) =>
|
|
value.toString(16).padStart(2, "0"),
|
|
).join("");
|
|
const filename = args.filename ?? "upload.bin";
|
|
const normalizedFilename = filename.toLowerCase();
|
|
const isAgentJson = normalizedFilename.endsWith(".agent.json");
|
|
const extension = isAgentJson
|
|
? "json"
|
|
: normalizedFilename.endsWith(".png")
|
|
? "png"
|
|
: normalizedFilename.endsWith(".jpg") ||
|
|
normalizedFilename.endsWith(".jpeg")
|
|
? "jpg"
|
|
: normalizedFilename.endsWith(".gif")
|
|
? "gif"
|
|
: normalizedFilename.endsWith(".webp")
|
|
? "webp"
|
|
: "bin";
|
|
const type = isAgentJson
|
|
? "application/json"
|
|
: extension === "bin"
|
|
? "application/octet-stream"
|
|
: `image/${extension === "jpg" ? "jpeg" : extension}`;
|
|
return {
|
|
url: `${getRelayHttpUrl(config)}/media/${sha256}.${extension}`,
|
|
sha256,
|
|
size: bytes.length,
|
|
type,
|
|
uploaded: Math.floor(Date.now() / 1000),
|
|
filename,
|
|
};
|
|
}
|
|
|
|
async function handleSendChannelMessage(
|
|
args: {
|
|
channelId: string;
|
|
content: string;
|
|
parentEventId?: string | null;
|
|
rootEventId?: string | null;
|
|
kind?: number | null;
|
|
mentionPubkeys?: string[];
|
|
mediaTags?: string[][] | null;
|
|
emojiTags?: string[][] | null;
|
|
mentionTags?: string[][] | null;
|
|
linkPreviewTags?: string[][] | null;
|
|
sentFromThreadTag?: string[] | null;
|
|
suppressLinkPreviews?: boolean;
|
|
expectedRelayUrl?: string | null;
|
|
expectedSignerPubkey?: string | null;
|
|
},
|
|
config: E2eConfig | undefined,
|
|
): Promise<RawSendChannelMessageResponse> {
|
|
const kind = args.kind ?? 9;
|
|
const sendMessageDelayMs = config?.mock?.sendMessageDelayMs ?? 0;
|
|
if (sendMessageDelayMs > 0) {
|
|
await new Promise((resolve) =>
|
|
window.setTimeout(resolve, sendMessageDelayMs),
|
|
);
|
|
}
|
|
// After the injected delay, like the real command's post-await check: a
|
|
// caller-captured tenant scope and signer identity must still match the
|
|
// active community/identity.
|
|
assertExpectedRelayScope(args.expectedRelayUrl, config);
|
|
assertExpectedSigner(args.expectedSignerPubkey, config);
|
|
|
|
// Mirror the WebSocket send path's failure injection so specs that route
|
|
// the first message through the acknowledged HTTP transport still exercise
|
|
// `sendMessageErrors`. The real command rejects on a relay `OK false`, which
|
|
// surfaces to callers as a thrown error carrying the relay reason.
|
|
const sendMessageError =
|
|
kind === 9 ? config?.mock?.sendMessageErrors?.shift() : null;
|
|
if (sendMessageError) {
|
|
throw new Error(sendMessageError);
|
|
}
|
|
|
|
// NIP-92 imeta attachments. The real relay echoes these back on the stored
|
|
// event; mirror that here so attachment renderers (FileCard, images, video)
|
|
// have the imeta tags they key on. `null`/empty → no extra tags.
|
|
const mediaTags = args.mediaTags ?? [];
|
|
// NIP-30 custom-emoji tags ride their own validated arg server-side; the
|
|
// relay echoes them back on the stored event too, so mirror that here so the
|
|
// emoji renderer keeps resolving `:shortcode:` after the round-trip.
|
|
const emojiTags = args.emojiTags ?? [];
|
|
// Reference-only mentions are already part of the outbound event. Preserve
|
|
// them in the mock event too so local echoes match the complete sent tag set.
|
|
const mentionTags = args.mentionTags ?? [];
|
|
// Sender-authored link preview snapshots are independently validated by the
|
|
// real command and echoed on the stored event. Preserve them in the mock so
|
|
// E2E recipient rendering exercises the same authored-snapshot path.
|
|
const linkPreviewTags = args.linkPreviewTags ?? [];
|
|
if (linkPreviewTags.length > 0) {
|
|
const suppression = linkPreviewTags.some(
|
|
(tag) =>
|
|
tag.length === 2 && tag[0] === "link-preview" && tag[1] === "none",
|
|
);
|
|
const snapshots = linkPreviewTags.filter(
|
|
(tag) => tag[0] === "link-preview" && tag[1] === "snapshot",
|
|
);
|
|
const validSnapshots = parseLinkPreviewSnapshots(
|
|
snapshots,
|
|
args.content,
|
|
new URL(getRelayHttpUrl(config)).origin,
|
|
);
|
|
if (
|
|
(suppression && linkPreviewTags.length !== 1) ||
|
|
(!suppression &&
|
|
(snapshots.length !== linkPreviewTags.length ||
|
|
validSnapshots.length !== snapshots.length ||
|
|
snapshots.some(
|
|
(tag) => !isValidLinkPreviewSnapshotCanonicalUrl(tag[3] ?? ""),
|
|
)))
|
|
) {
|
|
throw new Error("invalid link-preview snapshot tag");
|
|
}
|
|
}
|
|
// All independently validated kinds end up on the stored event's tag set,
|
|
// just like the real relay.
|
|
const extraTags = [
|
|
...mediaTags,
|
|
...emojiTags,
|
|
...mentionTags,
|
|
...linkPreviewTags,
|
|
...(args.sentFromThreadTag ? [args.sentFromThreadTag] : []),
|
|
...(args.suppressLinkPreviews ? [["link-preview", "none"]] : []),
|
|
];
|
|
const identity = getIdentity(config);
|
|
if (!identity) {
|
|
const createdAt = Math.floor(Date.now() / 1000);
|
|
const mockPubkey = getMockMemberPubkey(config);
|
|
|
|
if (!args.parentEventId) {
|
|
const event = createMockEvent(kind, args.content, [
|
|
...buildTopLevelMessageTags(
|
|
args.channelId,
|
|
args.mentionPubkeys,
|
|
mockPubkey,
|
|
),
|
|
...extraTags,
|
|
]);
|
|
recordMockMessage(args.channelId, event);
|
|
emitOrDeferMockSendMessageLiveEcho(args.channelId, event, config);
|
|
|
|
return {
|
|
event_id: event.id,
|
|
parent_event_id: null,
|
|
root_event_id: null,
|
|
depth: 0,
|
|
created_at: createdAt,
|
|
};
|
|
}
|
|
|
|
const history = getMockMessageStore(args.channelId);
|
|
const parentEvent = history.find(
|
|
(event) => event.id === args.parentEventId,
|
|
);
|
|
const parentThread = parentEvent
|
|
? getThreadReferenceFromTags(parentEvent.tags)
|
|
: {
|
|
parentEventId: null,
|
|
rootEventId: null,
|
|
};
|
|
const rootEventId =
|
|
args.rootEventId ?? parentThread.rootEventId ?? args.parentEventId;
|
|
const depth = parentEvent
|
|
? (() => {
|
|
let currentEvent: RelayEvent | undefined = parentEvent;
|
|
let nextDepth = 1;
|
|
|
|
while (currentEvent) {
|
|
const reference = getThreadReferenceFromTags(currentEvent.tags);
|
|
if (!reference.parentEventId) {
|
|
return nextDepth;
|
|
}
|
|
|
|
nextDepth += 1;
|
|
currentEvent = history.find(
|
|
(event) => event.id === reference.parentEventId,
|
|
);
|
|
}
|
|
|
|
return nextDepth;
|
|
})()
|
|
: 1;
|
|
|
|
const event: RelayEvent = {
|
|
id: mockEventId(),
|
|
pubkey: mockPubkey,
|
|
created_at: createdAt,
|
|
kind,
|
|
tags: [
|
|
...buildReplyMessageTags(
|
|
args.channelId,
|
|
mockPubkey,
|
|
args.parentEventId,
|
|
rootEventId,
|
|
args.mentionPubkeys,
|
|
),
|
|
...extraTags,
|
|
],
|
|
content: args.content.trim(),
|
|
sig: "mocksig".repeat(20).slice(0, 128),
|
|
};
|
|
|
|
recordMockMessage(args.channelId, event);
|
|
emitOrDeferMockSendMessageLiveEcho(args.channelId, event, config);
|
|
|
|
return {
|
|
event_id: event.id,
|
|
parent_event_id: args.parentEventId,
|
|
root_event_id: rootEventId,
|
|
depth,
|
|
created_at: createdAt,
|
|
};
|
|
}
|
|
|
|
const relayIdentity = getRelayIdentity(config);
|
|
const tags = args.parentEventId
|
|
? buildReplyMessageTags(
|
|
args.channelId,
|
|
relayIdentity.pubkey,
|
|
args.parentEventId,
|
|
args.rootEventId ?? args.parentEventId,
|
|
args.mentionPubkeys,
|
|
)
|
|
: buildTopLevelMessageTags(
|
|
args.channelId,
|
|
args.mentionPubkeys,
|
|
relayIdentity.pubkey,
|
|
);
|
|
|
|
const result = await submitSignedEvent(config, {
|
|
kind,
|
|
content: args.content.trim(),
|
|
tags: [...tags, ...extraTags],
|
|
});
|
|
|
|
return {
|
|
event_id: result.event_id,
|
|
parent_event_id: args.parentEventId ?? null,
|
|
root_event_id: args.rootEventId ?? args.parentEventId ?? null,
|
|
depth: args.parentEventId
|
|
? args.rootEventId && args.rootEventId !== args.parentEventId
|
|
? 2
|
|
: 1
|
|
: 0,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
};
|
|
}
|
|
|
|
async function handleSendManagedAgentChannelMessage(
|
|
args: {
|
|
agentPubkey: string;
|
|
channelId: string;
|
|
content: string;
|
|
marker?: string | null;
|
|
markerScope?: "agent" | "channel" | null;
|
|
mentionPubkeys?: string[] | null;
|
|
parentEventId?: string | null;
|
|
additionalMarkers?: string[] | null;
|
|
},
|
|
_config: E2eConfig | undefined,
|
|
): Promise<RawSendChannelMessageResponse> {
|
|
const agent = getMockManagedAgent(args.agentPubkey);
|
|
const marker = args.marker?.trim();
|
|
if (marker) {
|
|
const existing = getMockMessageStore(args.channelId).find(
|
|
(event) =>
|
|
(args.markerScope === "channel" || event.pubkey === agent.pubkey) &&
|
|
event.tags.some((tag) => tag[0] === "client" && tag[1] === marker),
|
|
);
|
|
if (existing) {
|
|
return {
|
|
event_id: existing.id,
|
|
parent_event_id: args.parentEventId ?? null,
|
|
root_event_id: args.parentEventId ?? null,
|
|
depth: args.parentEventId ? 1 : 0,
|
|
created_at: existing.created_at,
|
|
};
|
|
}
|
|
}
|
|
|
|
const createdAt = Math.floor(Date.now() / 1000);
|
|
const tags = args.parentEventId
|
|
? buildReplyMessageTags(
|
|
args.channelId,
|
|
agent.pubkey,
|
|
args.parentEventId,
|
|
args.parentEventId,
|
|
args.mentionPubkeys ?? undefined,
|
|
)
|
|
: buildTopLevelMessageTags(
|
|
args.channelId,
|
|
args.mentionPubkeys ?? undefined,
|
|
agent.pubkey,
|
|
);
|
|
for (const clientMarker of [marker, ...(args.additionalMarkers ?? [])]) {
|
|
if (clientMarker?.trim()) tags.push(["client", clientMarker.trim()]);
|
|
}
|
|
const event = createMockEvent(
|
|
9,
|
|
args.content.trim(),
|
|
tags,
|
|
agent.pubkey,
|
|
createdAt,
|
|
);
|
|
recordMockMessage(args.channelId, event);
|
|
emitMockLiveEvent(args.channelId, event);
|
|
|
|
return {
|
|
event_id: event.id,
|
|
parent_event_id: args.parentEventId ?? null,
|
|
root_event_id: args.parentEventId ?? null,
|
|
depth: args.parentEventId ? 1 : 0,
|
|
created_at: createdAt,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Mock the `delete_message` Tauri command. Removes the event from the
|
|
* in-memory mock store and records the kind:5 structural event used by Inbox
|
|
* refreshes. Do not emit it live: mock target IDs may fail the production
|
|
* 64-hex deletion filter, letting a live merge restore the flattened row.
|
|
*/
|
|
function handleDeleteMessage(
|
|
args: {
|
|
channelId: string;
|
|
eventId: string;
|
|
},
|
|
config: E2eConfig | undefined,
|
|
): void {
|
|
const history = mockMessages.get(args.channelId);
|
|
if (history) {
|
|
const index = history.findIndex((ev) => ev.id === args.eventId);
|
|
if (index !== -1) history.splice(index, 1);
|
|
}
|
|
|
|
const deletion = createMockEvent(
|
|
KIND_DELETION,
|
|
"",
|
|
[
|
|
["e", args.eventId],
|
|
["h", args.channelId],
|
|
],
|
|
getMockMemberPubkey(config),
|
|
);
|
|
recordMockMessage(args.channelId, deletion);
|
|
}
|
|
|
|
/**
|
|
* Mock the `edit_message` Tauri command. Mirrors the real Rust command
|
|
* (`build_message_edit`): emit a kind:40003 edit event carrying `["e", target]`
|
|
* plus the new content, media (imeta) tags, and NIP-30 emoji tags. The timeline
|
|
* (`formatTimelineMessages`) scans for these edit events and overlays the new
|
|
* content + media/emoji tags onto the original via `applyEditTagOverlay`, so
|
|
* recording + emitting the edit event is all the bridge needs to do — the same
|
|
* path the real relay drives. `null`/empty tag args → no extra tags.
|
|
*/
|
|
async function handleEditMessage(
|
|
args: {
|
|
channelId: string;
|
|
eventId: string;
|
|
content: string;
|
|
mediaTags?: string[][] | null;
|
|
emojiTags?: string[][] | null;
|
|
mentionPubkeys?: string[] | null;
|
|
mentionTags?: string[][] | null;
|
|
suppressLinkPreviews?: boolean;
|
|
},
|
|
config: E2eConfig | undefined,
|
|
): Promise<void> {
|
|
const mediaTags = args.mediaTags ?? [];
|
|
const emojiTags = args.emojiTags ?? [];
|
|
const mentionPubkeys = args.mentionPubkeys ?? [];
|
|
const mentionTags = args.mentionTags;
|
|
const extraTags = [
|
|
...mediaTags,
|
|
...emojiTags,
|
|
...mentionPubkeys.map((pubkey) => ["p", pubkey]),
|
|
...(mentionTags ?? []),
|
|
...(mentionTags ? [["buzz:mention-snapshot"]] : []),
|
|
...(args.suppressLinkPreviews ? [["link-preview", "none"]] : []),
|
|
];
|
|
const tags = [["h", args.channelId], ["e", args.eventId], ...extraTags];
|
|
const content = args.content.trim();
|
|
const identity = getIdentity(config);
|
|
|
|
if (!identity) {
|
|
const editEvent = createMockEvent(
|
|
KIND_STREAM_MESSAGE_EDIT,
|
|
content,
|
|
tags,
|
|
getMockMemberPubkey(config),
|
|
);
|
|
recordMockMessage(args.channelId, editEvent);
|
|
emitMockLiveEvent(args.channelId, editEvent);
|
|
return;
|
|
}
|
|
|
|
await submitSignedEvent(config, {
|
|
kind: KIND_STREAM_MESSAGE_EDIT,
|
|
content,
|
|
tags,
|
|
});
|
|
}
|
|
|
|
/** Locate the channel a stored mock event lives in (reactions carry no channel arg). */
|
|
function findMockEventChannel(eventId: string): string | undefined {
|
|
for (const [channelId, events] of mockMessages) {
|
|
if (events.some((event) => event.id === eventId)) {
|
|
return channelId;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Mock the `add_reaction` Tauri command. Mirrors the real Rust command: a
|
|
* kind:7 whose content is the emoji, plus — for a custom emoji — the NIP-30
|
|
* `["emoji", shortcode, url]` tag (shortcode normalized to match the relay).
|
|
* Recorded into the target's channel store and emitted live so the timeline's
|
|
* reaction aggregation renders the pill (the channel subscription includes
|
|
* kind:7). Unicode reactions carry no emoji tag, like the real command.
|
|
*/
|
|
async function handleAddReaction(
|
|
args: { eventId: string; emoji: string; emojiUrl?: string | null },
|
|
config: E2eConfig | undefined,
|
|
): Promise<void> {
|
|
const channelId = findMockEventChannel(args.eventId);
|
|
if (!channelId) {
|
|
throw new Error(`mock add_reaction: unknown target event ${args.eventId}`);
|
|
}
|
|
|
|
const emoji = args.emoji.trim();
|
|
// Real add_reaction events carry only the target `e` tag. Channel live
|
|
// subscriptions already know which channel matched and restore that context
|
|
// before merging the event into the timeline cache.
|
|
const tags: string[][] = [["e", args.eventId]];
|
|
if (args.emojiUrl) {
|
|
const shortcode = emoji.replace(/^:+/, "").replace(/:+$/, "").toLowerCase();
|
|
tags.push(["emoji", shortcode, args.emojiUrl]);
|
|
}
|
|
|
|
const event = createMockEvent(
|
|
KIND_REACTION,
|
|
emoji,
|
|
tags,
|
|
getMockMemberPubkey(config),
|
|
Math.floor(Date.now() / 1000),
|
|
// 64-hex id so the kind:5 deletion emitted by remove_reaction is accepted
|
|
// by the timeline (getDeletionTargets requires a 64-hex `e` tag).
|
|
mockEventId(),
|
|
);
|
|
recordMockMessage(channelId, event);
|
|
emitMockLiveEvent(channelId, event);
|
|
}
|
|
|
|
/**
|
|
* Mock the `remove_reaction` Tauri command. Finds the active member's own
|
|
* kind:7 for this target+emoji, removes it from the store, and emits a kind:5
|
|
* deletion so the timeline drops the reaction (the real command deletes via a
|
|
* kind:5 too).
|
|
*/
|
|
async function handleRemoveReaction(
|
|
args: { eventId: string; emoji: string },
|
|
config: E2eConfig | undefined,
|
|
): Promise<void> {
|
|
const channelId = findMockEventChannel(args.eventId);
|
|
if (!channelId) {
|
|
return;
|
|
}
|
|
|
|
const myPubkey = getMockMemberPubkey(config).toLowerCase();
|
|
const emoji = args.emoji.trim();
|
|
const store = getMockMessageStore(channelId);
|
|
const reaction = store.find(
|
|
(event) =>
|
|
event.kind === KIND_REACTION &&
|
|
event.pubkey.toLowerCase() === myPubkey &&
|
|
event.content.trim() === emoji &&
|
|
event.tags.some((t) => t[0] === "e" && t[1] === args.eventId),
|
|
);
|
|
if (!reaction) {
|
|
return;
|
|
}
|
|
|
|
const index = store.indexOf(reaction);
|
|
store.splice(index, 1);
|
|
|
|
const deletion = createMockEvent(
|
|
KIND_DELETION,
|
|
"",
|
|
[["e", reaction.id]],
|
|
getMockMemberPubkey(config),
|
|
);
|
|
recordMockMessage(channelId, deletion);
|
|
emitMockLiveEvent(channelId, deletion);
|
|
}
|
|
|
|
async function handleGetEvent(
|
|
args: {
|
|
eventId: string;
|
|
},
|
|
config: E2eConfig | undefined,
|
|
) {
|
|
// Defer/release seam: when __BUZZ_E2E_DEFER_GET_EVENT__ is set to this
|
|
// event's ID, hold this call in the queue until __BUZZ_E2E_RELEASE_GET_EVENT__()
|
|
// is called. Only the target ID is deferred; all other IDs resolve normally.
|
|
// This keeps ancestor-lookup and context loads from being stalled or counted.
|
|
if (
|
|
window.__BUZZ_E2E_DEFER_GET_EVENT__ &&
|
|
window.__BUZZ_E2E_DEFER_GET_EVENT__ === args.eventId
|
|
) {
|
|
// Increment the count only for calls that are actually deferred.
|
|
window.__BUZZ_E2E_GET_EVENT_CALL_COUNT__ =
|
|
(window.__BUZZ_E2E_GET_EVENT_CALL_COUNT__ ?? 0) + 1;
|
|
return new Promise<string>((resolve, reject) => {
|
|
deferredGetEventQueue.push({
|
|
resolve,
|
|
reject,
|
|
run: () => resolveGetEvent(args, config),
|
|
});
|
|
});
|
|
}
|
|
|
|
return resolveGetEvent(args, config);
|
|
}
|
|
|
|
async function resolveGetEvent(
|
|
args: {
|
|
eventId: string;
|
|
},
|
|
config: E2eConfig | undefined,
|
|
) {
|
|
// Allow test specs to mark specific event IDs as definitively deleted.
|
|
if (config?.mock?.deletedEventIds?.includes(args.eventId)) {
|
|
throw new Error("event not found");
|
|
}
|
|
const identity = getIdentity(config);
|
|
if (!identity) {
|
|
const knownEvents: RelayEvent[] = [
|
|
...Array.from(mockMessages.values()).flat(),
|
|
{
|
|
id: "mock-engineering-shipped",
|
|
pubkey:
|
|
"bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260",
|
|
created_at: Math.floor(Date.now() / 1000) - 42 * 60,
|
|
kind: 9,
|
|
tags: [["h", "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9"]],
|
|
content: "Engineering shipped the desktop build.",
|
|
sig: "mocksig".repeat(20).slice(0, 128),
|
|
},
|
|
{
|
|
id: "mock-design-critique",
|
|
pubkey:
|
|
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f",
|
|
created_at: Math.floor(Date.now() / 1000) - 75 * 60,
|
|
kind: 9,
|
|
tags: [["h", "b5e2f8a1-3c44-5912-9e67-4a8d1f2b3c4e"]],
|
|
content: "Design critique notes for the browse flow.",
|
|
sig: "mocksig".repeat(20).slice(0, 128),
|
|
},
|
|
{
|
|
id: "mock-forum-release-thread",
|
|
pubkey:
|
|
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f",
|
|
created_at: Math.floor(Date.now() / 1000) - 90 * 60,
|
|
kind: 45001,
|
|
tags: [["e", "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11"]],
|
|
content: "Release checklist: async feedback thread.",
|
|
sig: "mocksig".repeat(20).slice(0, 128),
|
|
},
|
|
{
|
|
id: "mock-forum-release-reply",
|
|
pubkey: ALICE_PUBKEY,
|
|
created_at: Math.floor(Date.now() / 1000) - 80 * 60,
|
|
kind: 45003,
|
|
tags: buildReplyMessageTags(
|
|
"a27e1ee9-76a6-5bdf-a5d5-1d85610dad11",
|
|
ALICE_PUBKEY,
|
|
"mock-forum-release-thread",
|
|
"mock-forum-release-thread",
|
|
undefined,
|
|
),
|
|
content: "Looks good to me. We should ship it.",
|
|
sig: "mocksig".repeat(20).slice(0, 128),
|
|
},
|
|
];
|
|
const event = knownEvents.find((item) => item.id === args.eventId);
|
|
if (!event) {
|
|
throw new Error(`Event not found: ${args.eventId}`);
|
|
}
|
|
|
|
return JSON.stringify(event);
|
|
}
|
|
|
|
// Query single event by ID via POST /query
|
|
const events = await relayQuery(config, [{ ids: [args.eventId], limit: 1 }]);
|
|
if (events.length === 0) {
|
|
throw new Error(`Event not found: ${args.eventId}`);
|
|
}
|
|
return JSON.stringify(events[0]);
|
|
}
|
|
|
|
async function connectRealSocket(args: { url?: string; onMessage: unknown }) {
|
|
relayWebsocketConnectAttemptStarts.push(Date.now());
|
|
const wsId = nextSocketId++;
|
|
const ws = new WebSocket(args.url ?? DEFAULT_RELAY_WS_URL);
|
|
const handler = resolveHandler(args.onMessage);
|
|
|
|
realSockets.set(wsId, ws);
|
|
ws.addEventListener("message", (event) => {
|
|
handler({
|
|
type: "Text",
|
|
data: event.data,
|
|
});
|
|
});
|
|
ws.addEventListener("close", () => {
|
|
sendWsClose(handler);
|
|
realSockets.delete(wsId);
|
|
});
|
|
ws.addEventListener("error", () => {
|
|
handler({
|
|
type: "Error",
|
|
});
|
|
});
|
|
|
|
return await new Promise<number>((resolve) => {
|
|
ws.addEventListener("open", () => resolve(wsId), { once: true });
|
|
ws.addEventListener("error", () => resolve(wsId), { once: true });
|
|
});
|
|
}
|
|
|
|
async function connectMockSocket(args: { onMessage: unknown }) {
|
|
relayWebsocketConnectAttemptStarts.push(Date.now());
|
|
if (mockWebsocketUnavailable) {
|
|
throw new Error("mock relay unavailable");
|
|
}
|
|
const connectError = getConfig()?.mock?.websocketConnectErrors?.shift();
|
|
if (connectError) {
|
|
throw new Error(connectError);
|
|
}
|
|
|
|
if (mockWebsocketSendMutexWedged) {
|
|
return new Promise<number>(() => {});
|
|
}
|
|
|
|
const wsId = nextSocketId++;
|
|
const handler = resolveHandler(args.onMessage);
|
|
|
|
mockSockets.set(wsId, {
|
|
handler,
|
|
subscriptions: new Map(),
|
|
});
|
|
|
|
if (getConfig()?.mock?.websocketAuthBeforeConnectResolves) {
|
|
sendWsText(handler, ["AUTH", `mock-challenge-${wsId}`]);
|
|
await new Promise<void>((resolve) => window.setTimeout(resolve, 50));
|
|
} else {
|
|
window.setTimeout(() => {
|
|
sendWsText(handler, ["AUTH", `mock-challenge-${wsId}`]);
|
|
}, 0);
|
|
}
|
|
|
|
return wsId;
|
|
}
|
|
|
|
async function sendToRealSocket(args: {
|
|
id: number;
|
|
message?: {
|
|
type: "Text" | "Close";
|
|
data?: string;
|
|
};
|
|
}) {
|
|
const socket = realSockets.get(args.id);
|
|
if (!socket) {
|
|
return;
|
|
}
|
|
|
|
if (args.message?.type === "Close") {
|
|
socket.close();
|
|
return;
|
|
}
|
|
|
|
if (args.message?.type === "Text") {
|
|
socket.send(args.message.data ?? "");
|
|
}
|
|
}
|
|
|
|
function sendToMockSocket(args: {
|
|
id: number;
|
|
message?: {
|
|
type: "Text" | "Close";
|
|
data?: string;
|
|
};
|
|
}) {
|
|
const socket = mockSockets.get(args.id);
|
|
if (
|
|
getConfig()?.mock?.stallWebsocketSends &&
|
|
args.message?.type !== "Close"
|
|
) {
|
|
mockWebsocketSendMutexWedged = true;
|
|
return new Promise<void>(() => {});
|
|
}
|
|
|
|
if (!socket || !args.message) {
|
|
return;
|
|
}
|
|
|
|
if (args.message.type === "Close") {
|
|
mockSockets.delete(args.id);
|
|
sendWsClose(socket.handler);
|
|
return;
|
|
}
|
|
|
|
if (args.message.type !== "Text" || !args.message.data) {
|
|
return;
|
|
}
|
|
|
|
const [type, ...rest] = JSON.parse(args.message.data) as [
|
|
string,
|
|
...unknown[],
|
|
];
|
|
|
|
if (type === "AUTH") {
|
|
const event = rest[0] as RelayEvent;
|
|
const response = mockAuthResponses.shift() ?? {
|
|
success: true,
|
|
message: "",
|
|
};
|
|
sendWsText(socket.handler, [
|
|
"OK",
|
|
event.id,
|
|
response.success,
|
|
response.message,
|
|
]);
|
|
return;
|
|
}
|
|
|
|
if (type === "REQ") {
|
|
const subId = rest[0] as string;
|
|
const filters = rest.slice(1) as MockFilter[];
|
|
if (
|
|
!filters.every((filter) =>
|
|
isPGatedFilterAuthorized(filter, MOCK_IDENTITY_PUBKEY),
|
|
)
|
|
) {
|
|
sendWsText(socket.handler, ["CLOSED", subId, P_GATED_REJECTION_MESSAGE]);
|
|
return;
|
|
}
|
|
|
|
if (subId.startsWith("live-")) {
|
|
const subscription = createMockSubscription(filters);
|
|
const onlyChannelId =
|
|
subscription.channelIds.length === 1 &&
|
|
subscription.channelIds[0] !== GLOBAL_MOCK_SUBSCRIPTION
|
|
? subscription.channelIds[0]
|
|
: undefined;
|
|
if (
|
|
getConfig()?.mock?.closeChannelLiveSubscriptionOnce &&
|
|
!mockClosedChannelLiveSubscription &&
|
|
onlyChannelId &&
|
|
subscription.kinds?.includes(KIND_CHANNEL_THREAD_SUMMARY)
|
|
) {
|
|
mockClosedChannelLiveSubscription = true;
|
|
sendWsText(socket.handler, ["CLOSED", subId, "rate-limited"]);
|
|
return;
|
|
}
|
|
socket.subscriptions.set(subId, subscription);
|
|
// Live requests still replay stored matches; pacing can admit them after
|
|
// a publish. Ephemeral/global fixtures are not channel history.
|
|
const history = new Map(
|
|
subscription.channelIds
|
|
.filter((id) => id !== GLOBAL_MOCK_SUBSCRIPTION)
|
|
.map((id) => [id, getMockMessageStore(id)]),
|
|
);
|
|
for (const event of selectMockHistory(history, filters)) {
|
|
sendWsText(socket.handler, ["EVENT", subId, event]);
|
|
}
|
|
sendWsText(socket.handler, ["EOSE", subId]);
|
|
return;
|
|
}
|
|
|
|
const filter = rest[1] as MockFilter;
|
|
if (filter.kinds?.includes(13534)) {
|
|
sendWsText(socket.handler, [
|
|
"EVENT",
|
|
subId,
|
|
createMockRelayMembershipEvent(),
|
|
]);
|
|
const eoseDelayMs = getConfig()?.mock?.relayMembershipEoseDelayMs ?? 0;
|
|
if (eoseDelayMs > 0) {
|
|
window.setTimeout(
|
|
() => sendWsText(socket.handler, ["EOSE", subId]),
|
|
eoseDelayMs,
|
|
);
|
|
} else {
|
|
sendWsText(socket.handler, ["EOSE", subId]);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (filter.kinds?.includes(KIND_EMOJI_SET)) {
|
|
// Honor `authors` so `fetchOwnEmoji` (authors:[me]) sees only the
|
|
// caller's set, while the union fetch (no authors) sees every member's —
|
|
// matching the real relay and the own-vs-community split in the UI.
|
|
const authors = filter.authors?.map((a) => a.toLowerCase());
|
|
for (const emojiEvent of createMockCustomEmojiSetEvents()) {
|
|
if (authors && !authors.includes(emojiEvent.pubkey.toLowerCase())) {
|
|
continue;
|
|
}
|
|
sendWsText(socket.handler, ["EVENT", subId, emojiEvent]);
|
|
}
|
|
sendWsText(socket.handler, ["EOSE", subId]);
|
|
return;
|
|
}
|
|
|
|
if (filter.kinds?.includes(KIND_USER_STATUS)) {
|
|
for (const statusEvent of filterMockUserStatuses(filter)) {
|
|
sendWsText(socket.handler, ["EVENT", subId, statusEvent]);
|
|
}
|
|
sendWsText(socket.handler, ["EOSE", subId]);
|
|
return;
|
|
}
|
|
|
|
if (filter.kinds?.includes(KIND_EVENT_REMINDER)) {
|
|
const authors = filter.authors?.map((a) => a.toLowerCase());
|
|
for (const event of mockReminderEvents) {
|
|
if (authors && !authors.includes(event.pubkey.toLowerCase())) continue;
|
|
sendWsText(socket.handler, ["EVENT", subId, event]);
|
|
}
|
|
sendWsText(socket.handler, ["EOSE", subId]);
|
|
return;
|
|
}
|
|
|
|
if (filter.kinds?.includes(KIND_PERSONA)) {
|
|
const authors = filter.authors?.map((author) => author.toLowerCase());
|
|
const sourceIds = filter["#d"];
|
|
for (const event of mockPersonaEvents) {
|
|
if (authors && !authors.includes(event.pubkey.toLowerCase())) continue;
|
|
if (
|
|
event.pubkey.toLowerCase() !== MOCK_IDENTITY_PUBKEY.toLowerCase() &&
|
|
!hasExactSharedTag(event)
|
|
) {
|
|
continue;
|
|
}
|
|
const sourceId = event.tags.find((tag) => tag[0] === "d")?.[1];
|
|
if (sourceIds && (!sourceId || !sourceIds.includes(sourceId))) continue;
|
|
sendWsText(socket.handler, ["EVENT", subId, event]);
|
|
}
|
|
sendWsText(socket.handler, ["EOSE", subId]);
|
|
return;
|
|
}
|
|
|
|
if (filter.kinds?.includes(KIND_TEAM_CATALOG)) {
|
|
const authors = filter.authors?.map((author) => author.toLowerCase());
|
|
const teamDTags = filter["#d"];
|
|
for (const event of mockTeamCatalogEvents) {
|
|
if (authors && !authors.includes(event.pubkey.toLowerCase())) continue;
|
|
// Own heads are readable unshared (the owner sees their own state);
|
|
// anyone else's must carry the exact shared tag, like the relay gate.
|
|
if (
|
|
event.pubkey.toLowerCase() !== MOCK_IDENTITY_PUBKEY.toLowerCase() &&
|
|
!hasExactSharedTag(event)
|
|
) {
|
|
continue;
|
|
}
|
|
const teamDTag = event.tags.find((tag) => tag[0] === "d")?.[1];
|
|
if (teamDTags && (!teamDTag || !teamDTags.includes(teamDTag))) continue;
|
|
sendWsText(socket.handler, ["EVENT", subId, event]);
|
|
}
|
|
sendWsText(socket.handler, ["EOSE", subId]);
|
|
return;
|
|
}
|
|
|
|
// Project queries: NIP-34 kinds, or kind:1 comments scoped by repo `a`
|
|
// tag or by issue/PR root `e` tag (discussions, approvals, review
|
|
// requests, assignment operations). Channel messages are kind 9, so a
|
|
// kind:1 `#e` query can only target project discussion events.
|
|
if (
|
|
filter.kinds?.some((kind) => MOCK_PROJECT_KINDS.has(kind)) ||
|
|
(filter.kinds?.includes(1) && (filter["#a"] || filter["#e"]))
|
|
) {
|
|
window.__BUZZ_E2E_PROJECT_QUERY_FILTERS__ ??= [];
|
|
window.__BUZZ_E2E_PROJECT_QUERY_FILTERS__.push(filter);
|
|
const rejectedKinds =
|
|
window.__BUZZ_E2E_REJECT_PROJECT_QUERY_KINDS__ ?? [];
|
|
if (filter.kinds?.some((kind) => rejectedKinds.includes(kind))) {
|
|
sendWsText(socket.handler, [
|
|
"CLOSED",
|
|
subId,
|
|
"mock project query failure",
|
|
]);
|
|
return;
|
|
}
|
|
if (
|
|
window.__BUZZ_E2E_DEFER_FULL_PROJECT_QUERIES__ &&
|
|
!filter.authors &&
|
|
!filter["#a"] &&
|
|
!filter["#buzz-channel"] &&
|
|
!filter["#d"] &&
|
|
!filter["#e"] &&
|
|
!filter.ids
|
|
) {
|
|
return;
|
|
}
|
|
for (const event of filterMockProjectEvents(filter)) {
|
|
sendWsText(socket.handler, ["EVENT", subId, event]);
|
|
}
|
|
sendWsText(socket.handler, ["EOSE", subId]);
|
|
return;
|
|
}
|
|
|
|
const channelIds = filter["#h"] ?? [];
|
|
if (channelIds.length > 0 && subId.startsWith("history-")) {
|
|
const closeReason = mockChannelHistoryCloses.shift();
|
|
if (closeReason) {
|
|
sendWsText(socket.handler, ["CLOSED", subId, closeReason]);
|
|
return;
|
|
}
|
|
}
|
|
if (channelIds.length === 0) {
|
|
// Aux-backfill filters (reactions/deletions) are `#e`-keyed with no
|
|
// channel tag — serve them across all channel stores like the relay.
|
|
const referencedIds = filter["#e"];
|
|
if (referencedIds && referencedIds.length > 0) {
|
|
const targets = new Set(referencedIds);
|
|
for (const events of mockMessages.values()) {
|
|
for (const event of events) {
|
|
if (filter.kinds && !filter.kinds.includes(event.kind)) {
|
|
continue;
|
|
}
|
|
if (
|
|
event.tags.some(
|
|
(tag) => tag[0] === "e" && tag[1] && targets.has(tag[1]),
|
|
)
|
|
) {
|
|
sendWsText(socket.handler, ["EVENT", subId, event]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
sendWsText(socket.handler, ["EOSE", subId]);
|
|
return;
|
|
}
|
|
|
|
emitMockHistory(socket, subId, channelIds, filter);
|
|
return;
|
|
}
|
|
|
|
if (type === "CLOSE") {
|
|
const subId = rest[0] as string;
|
|
socket.subscriptions.delete(subId);
|
|
return;
|
|
}
|
|
|
|
if (type === "EVENT") {
|
|
const event = rest[0] as RelayEvent;
|
|
|
|
if (event.kind === KIND_AGENT_OBSERVER_FRAME) {
|
|
const frame = event.tags.find((tag) => tag[0] === "frame")?.[1];
|
|
if (frame === "control") {
|
|
let payload: {
|
|
type: "cancel_turn" | "switch_model";
|
|
channelId?: string | null;
|
|
requestId?: string;
|
|
modelId?: string;
|
|
};
|
|
try {
|
|
payload = JSON.parse(event.content);
|
|
} catch {
|
|
sendWsText(socket.handler, [
|
|
"OK",
|
|
event.id,
|
|
false,
|
|
"mock observer control payload is not JSON",
|
|
]);
|
|
return;
|
|
}
|
|
window.__BUZZ_E2E_OBSERVER_CONTROLS__?.push({
|
|
agentPubkey: event.tags.find((tag) => tag[0] === "agent")?.[1] ?? "",
|
|
payload,
|
|
});
|
|
const configured = getConfig()?.mock?.observerControlResults ?? [];
|
|
const resultIndex = configured.findIndex(
|
|
(candidate) => candidate.type === payload.type,
|
|
);
|
|
const result =
|
|
resultIndex >= 0
|
|
? configured.splice(resultIndex, 1)[0]
|
|
: {
|
|
type: payload.type,
|
|
status: payload.type === "cancel_turn" ? "sent" : "switched",
|
|
};
|
|
emitMockObserverControlResult(
|
|
event.tags.find((tag) => tag[0] === "agent")?.[1] ?? "",
|
|
payload,
|
|
result,
|
|
);
|
|
sendWsText(socket.handler, ["OK", event.id, true, ""]);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (event.kind === 28936) {
|
|
sendWsText(socket.handler, ["OK", event.id, true, ""]);
|
|
return;
|
|
}
|
|
|
|
if ([9030, 9031, 9032].includes(event.kind)) {
|
|
const accepted = updateMockRelayMembershipFromAdminEvent(event);
|
|
sendWsText(socket.handler, [
|
|
"OK",
|
|
event.id,
|
|
accepted,
|
|
accepted ? "" : "Invalid relay admin event.",
|
|
]);
|
|
return;
|
|
}
|
|
|
|
if (event.kind === 9033) {
|
|
sendWsText(socket.handler, ["OK", event.id, true, ""]);
|
|
return;
|
|
}
|
|
|
|
if (event.kind === 30078) {
|
|
sendWsText(socket.handler, ["OK", event.id, true, ""]);
|
|
return;
|
|
}
|
|
|
|
if (event.kind === KIND_EVENT_REMINDER) {
|
|
// Upsert by d-tag (replaceable event)
|
|
const dTag = event.tags.find((t) => t[0] === "d")?.[1];
|
|
if (dTag) {
|
|
const idx = mockReminderEvents.findIndex(
|
|
(e) =>
|
|
e.pubkey.toLowerCase() === event.pubkey.toLowerCase() &&
|
|
e.tags.some((t) => t[0] === "d" && t[1] === dTag),
|
|
);
|
|
if (idx >= 0) mockReminderEvents.splice(idx, 1);
|
|
}
|
|
mockReminderEvents.push(event);
|
|
sendWsText(socket.handler, ["OK", event.id, true, ""]);
|
|
return;
|
|
}
|
|
|
|
if (event.kind === KIND_PERSONA) {
|
|
const sourceId = event.tags.find((tag) => tag[0] === "d")?.[1];
|
|
if (!sourceId) {
|
|
sendWsText(socket.handler, [
|
|
"OK",
|
|
event.id,
|
|
false,
|
|
"invalid: persona event missing d tag.",
|
|
]);
|
|
return;
|
|
}
|
|
const sharedTags = event.tags.filter((tag) => tag[0] === "shared");
|
|
if (
|
|
sharedTags.length > 1 ||
|
|
(sharedTags.length === 1 && !hasExactSharedTag(event))
|
|
) {
|
|
sendWsText(socket.handler, [
|
|
"OK",
|
|
event.id,
|
|
false,
|
|
'invalid: shared tag must be exactly ["shared","true"].',
|
|
]);
|
|
return;
|
|
}
|
|
upsertMockPersonaRelayEvent(event);
|
|
emitMockGlobalEvent(event);
|
|
sendWsText(socket.handler, ["OK", event.id, true, ""]);
|
|
return;
|
|
}
|
|
|
|
if (event.kind === 20001) {
|
|
const status = event.content;
|
|
if (status === "online" || status === "away" || status === "offline") {
|
|
setMockPresenceStatus(event.pubkey, status);
|
|
}
|
|
emitMockGlobalEvent(event);
|
|
sendWsText(socket.handler, ["OK", event.id, true, ""]);
|
|
return;
|
|
}
|
|
|
|
if (event.kind === KIND_USER_STATUS) {
|
|
const hasGeneralDTag = event.tags.some(
|
|
(tag) => tag[0] === "d" && tag[1] === "general",
|
|
);
|
|
if (!hasGeneralDTag) {
|
|
sendWsText(socket.handler, [
|
|
"OK",
|
|
event.id,
|
|
false,
|
|
"invalid: user status missing d tag.",
|
|
]);
|
|
return;
|
|
}
|
|
|
|
recordMockUserStatus(event);
|
|
emitMockGlobalEvent(event);
|
|
sendWsText(socket.handler, ["OK", event.id, true, ""]);
|
|
return;
|
|
}
|
|
|
|
if (isMockProjectScopedEvent(event)) {
|
|
if (event.pubkey !== DEFAULT_MOCK_IDENTITY.pubkey) {
|
|
sendWsText(socket.handler, [
|
|
"OK",
|
|
event.id,
|
|
false,
|
|
"invalid: event pubkey does not match authenticated identity",
|
|
]);
|
|
return;
|
|
}
|
|
if (
|
|
event.kind === KIND_PROJECT_ANNOUNCEMENT &&
|
|
window.__BUZZ_E2E_UNSUPPORTED_PROJECT_ANNOUNCEMENTS__
|
|
) {
|
|
sendWsText(socket.handler, [
|
|
"OK",
|
|
event.id,
|
|
false,
|
|
"restricted: unknown event kind",
|
|
]);
|
|
return;
|
|
}
|
|
const rejectionIndex =
|
|
window.__BUZZ_E2E_REJECT_PROJECT_EVENT_KINDS__?.indexOf(event.kind) ??
|
|
-1;
|
|
if (rejectionIndex >= 0) {
|
|
window.__BUZZ_E2E_REJECT_PROJECT_EVENT_KINDS__?.splice(
|
|
rejectionIndex,
|
|
1,
|
|
);
|
|
sendWsText(socket.handler, [
|
|
"OK",
|
|
event.id,
|
|
false,
|
|
"mock project event rejection",
|
|
]);
|
|
return;
|
|
}
|
|
getMockProjectEventStore().push(event);
|
|
const acceptedProjectEvents =
|
|
window.__BUZZ_E2E_ACCEPTED_PROJECT_EVENTS__ ?? [];
|
|
acceptedProjectEvents.push({
|
|
content: event.content,
|
|
kind: event.kind,
|
|
tags: event.tags,
|
|
});
|
|
window.__BUZZ_E2E_ACCEPTED_PROJECT_EVENTS__ = acceptedProjectEvents;
|
|
const failedAckIndex =
|
|
window.__BUZZ_E2E_FAIL_PROJECT_EVENT_ACK_KINDS__?.indexOf(event.kind) ??
|
|
-1;
|
|
if (failedAckIndex >= 0) {
|
|
window.__BUZZ_E2E_FAIL_PROJECT_EVENT_ACK_KINDS__?.splice(
|
|
failedAckIndex,
|
|
1,
|
|
);
|
|
sendWsText(socket.handler, [
|
|
"OK",
|
|
event.id,
|
|
false,
|
|
"mock lost project acknowledgement",
|
|
]);
|
|
return;
|
|
}
|
|
sendWsText(socket.handler, ["OK", event.id, true, ""]);
|
|
return;
|
|
}
|
|
|
|
const channelId = getChannelIdFromTags(event.tags);
|
|
if (!channelId) {
|
|
sendWsText(socket.handler, [
|
|
"OK",
|
|
event.id,
|
|
false,
|
|
"Missing channel tag.",
|
|
]);
|
|
return;
|
|
}
|
|
|
|
const sendMessageError =
|
|
event.kind === 9 ? getConfig()?.mock?.sendMessageErrors?.shift() : null;
|
|
if (sendMessageError) {
|
|
sendWsText(socket.handler, ["OK", event.id, false, sendMessageError]);
|
|
return;
|
|
}
|
|
|
|
recordMockMessage(channelId, event);
|
|
emitMockLiveEvent(channelId, event);
|
|
sendWsText(socket.handler, ["OK", event.id, true, ""]);
|
|
}
|
|
}
|
|
|
|
function disconnectMockSocket(id: number) {
|
|
const socket = mockSockets.get(id);
|
|
if (!socket) {
|
|
return;
|
|
}
|
|
|
|
mockSockets.delete(id);
|
|
sendWsClose(socket.handler);
|
|
}
|
|
|
|
export function maybeInstallE2eTauriMocks() {
|
|
if (installed) {
|
|
return;
|
|
}
|
|
|
|
const config = getConfig();
|
|
if (!config) {
|
|
return;
|
|
}
|
|
|
|
mockClosedChannelLiveSubscription = false;
|
|
mockWebsocketUnavailable = false;
|
|
mockAuthResponses.length = 0;
|
|
mockChannelHistoryCloses.length = 0;
|
|
relayWebsocketConnectAttemptStarts.length = 0;
|
|
mockAuthSigningAttempts = 0;
|
|
deferredSendMessageLiveEchoes.length = 0;
|
|
deferredLinkPreviewMetadataQueue = [];
|
|
deferredLinkPreviewUploadQueue = [];
|
|
deferredThreadRepliesQueue = [];
|
|
deferredProfileReadQueue = [];
|
|
profileReadsReleased = false;
|
|
holdUsersBatch = false;
|
|
heldUsersBatchReleases = [];
|
|
cancelledMediaUploadIds = new Set<string>();
|
|
for (const controller of mockMediaFetchControllers.values()) {
|
|
controller.abort();
|
|
}
|
|
cancelledMediaFetchIds = new Set<string>();
|
|
mockMediaFetchControllers = new Map<string, AbortController>();
|
|
window.__BUZZ_E2E_LINK_PREVIEW_UPLOAD_STARTS__ = 0;
|
|
window.__BUZZ_E2E_AUDIO_LOAD_STATE__ = getAudioMediaLoadSchedulerSnapshot;
|
|
window.__BUZZ_E2E_MEDIA_FETCH_STATE__ = { active: 0, peak: 0 };
|
|
window.__BUZZ_E2E_RELEASE_LINK_PREVIEW_METADATA__ = () => {
|
|
const queued = deferredLinkPreviewMetadataQueue.splice(0);
|
|
for (const release of queued) release();
|
|
return queued.length;
|
|
};
|
|
window.__BUZZ_E2E_RELEASE_LINK_PREVIEW_UPLOADS__ = () => {
|
|
const queued = deferredLinkPreviewUploadQueue.splice(0);
|
|
for (const release of queued) release();
|
|
return queued.length;
|
|
};
|
|
window.__BUZZ_E2E_RELEASE_THREAD_REPLIES__ = () => {
|
|
const queued = deferredThreadRepliesQueue.splice(0);
|
|
for (const release of queued) release();
|
|
return queued.length;
|
|
};
|
|
window.__BUZZ_E2E_THREAD_REPLIES_PENDING__ = () =>
|
|
deferredThreadRepliesQueue.length;
|
|
window.__BUZZ_E2E_RELEASE_PROFILE_READS__ = () => {
|
|
profileReadsReleased = true;
|
|
const queued = deferredProfileReadQueue.splice(0);
|
|
for (const deferred of queued) {
|
|
void deferred.run().then(deferred.resolve, deferred.reject);
|
|
}
|
|
return queued.length;
|
|
};
|
|
window.__BUZZ_E2E_PROFILE_READS_PENDING__ = () =>
|
|
deferredProfileReadQueue.length;
|
|
window.__BUZZ_E2E_HOLD_USERS_BATCH__ = (hold: boolean) => {
|
|
holdUsersBatch = hold;
|
|
// Releasing on the way out of the hold, not on the way in, is what lets a
|
|
// spec keep one lookup pinned while another resolves from local state.
|
|
const queued = hold ? [] : heldUsersBatchReleases.splice(0);
|
|
for (const release of queued) release();
|
|
return queued.length;
|
|
};
|
|
window.__BUZZ_E2E_USERS_BATCH_PENDING__ = () => heldUsersBatchReleases.length;
|
|
mockGlobalAgentConfig = config.mock?.globalAgentConfig
|
|
? { ...config.mock.globalAgentConfig }
|
|
: null;
|
|
resetMockRelayMembers(config);
|
|
resetMockRelayAgents(config);
|
|
resetMockManagedAgents(config);
|
|
resetMockPersonas(config);
|
|
resetMockTeams(config);
|
|
seedMockSearchProfiles(config);
|
|
resetMockWorkflows();
|
|
resetMockMesh();
|
|
resetMockUserStatuses();
|
|
resetMockPersonaCatalogEvents(config);
|
|
resetMockObservedUnread();
|
|
resetMockTeamCatalogEvents(config);
|
|
resetMockSaveSubscriptions(config);
|
|
resetMockPendingCommunityDeepLinks(config);
|
|
resetMockPendingNavigationDeepLinks(config);
|
|
resetMockPendingEntityDeepLinks(config);
|
|
initializeMockHuddle(config.mock?.huddle, config);
|
|
mockWebsocketSendMutexWedged = false;
|
|
if (config.mock?.windowLabel) {
|
|
(window as Window & { isTauri?: boolean }).isTauri = true;
|
|
}
|
|
mockWindows(config.mock?.windowLabel ?? "main");
|
|
window.__BUZZ_E2E_COMMANDS__ = [];
|
|
window.__BUZZ_E2E_COMMAND_PAYLOADS__ = [];
|
|
window.__BUZZ_E2E_COMMAND_LOG__ = [];
|
|
window.__BUZZ_E2E_OBSERVER_CONTROLS__ = [];
|
|
window.__BUZZ_E2E_RUN_MODEL_SWITCH__ = async ({
|
|
agentPubkey,
|
|
channelIds,
|
|
modelId,
|
|
requestId,
|
|
timeoutMs = 8_000,
|
|
}) => {
|
|
_testRegisterKnownAgents("e2e-model-switch", [agentPubkey]);
|
|
await ensureRelayObserverSubscription();
|
|
// The mock relay has already been exercised by the app's normal observer
|
|
// subscription. The outcome helper below is the direct browser seam for the
|
|
// control-specific behavior, while the existing request capture proves the
|
|
// outgoing payload shape.
|
|
return awaitLiveSwitchOutcome({
|
|
requestId,
|
|
channelIds,
|
|
subscribe: (listener) => subscribeControlResults(agentPubkey, listener),
|
|
sendSwitches: async () => {
|
|
await Promise.all(
|
|
channelIds.map((channelId) =>
|
|
switchManagedAgentModel(agentPubkey, channelId, modelId, requestId),
|
|
),
|
|
);
|
|
},
|
|
scheduleTimeout: (onTimeout) => {
|
|
const timeout = window.setTimeout(onTimeout, timeoutMs);
|
|
return () => window.clearTimeout(timeout);
|
|
},
|
|
});
|
|
};
|
|
window.__BUZZ_E2E_MOUNT_AGENT_SESSION_PANEL__ = async ({
|
|
agentPubkey,
|
|
channelId,
|
|
canInterruptTurn = true,
|
|
}) => {
|
|
directPanelRoot?.unmount();
|
|
directPanelContainer?.remove();
|
|
directPanelQueryClient?.clear();
|
|
|
|
directPanelContainer = document.createElement("div");
|
|
directPanelContainer.dataset.testid = "e2e-direct-agent-session-panel-root";
|
|
document.body.appendChild(directPanelContainer);
|
|
directPanelQueryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: {
|
|
retry: false,
|
|
networkMode: "always",
|
|
refetchOnWindowFocus: false,
|
|
},
|
|
mutations: { networkMode: "always" },
|
|
},
|
|
});
|
|
directPanelRoot = createRoot(directPanelContainer);
|
|
_testRegisterKnownAgents("e2e-direct-agent-session-panel", [agentPubkey]);
|
|
|
|
const agent = {
|
|
pubkey: agentPubkey,
|
|
name: mockDisplayNames.get(agentPubkey) ?? "E2E agent",
|
|
status: "running" as const,
|
|
agentSource: "managed" as const,
|
|
canInterruptTurn,
|
|
channelIds: [channelId],
|
|
};
|
|
directPanelRoot.render(
|
|
React.createElement(
|
|
QueryClientProvider,
|
|
{ client: directPanelQueryClient },
|
|
React.createElement(
|
|
CommunitiesProvider,
|
|
null,
|
|
React.createElement(AgentSessionThreadPanel, {
|
|
agent,
|
|
canInterruptTurn,
|
|
channel: null,
|
|
channelId,
|
|
isSinglePanelView: true,
|
|
layout: "standalone",
|
|
onClose: () => {},
|
|
widthPx: 420,
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
|
|
};
|
|
mockMediaProxyPort = config.mock?.mediaProxyInitiallyUnavailable
|
|
? 0
|
|
: MOCK_MEDIA_PROXY_PORT;
|
|
window.__BUZZ_E2E_RELEASE_MEDIA_PROXY__ = () => {
|
|
mockMediaProxyPort = MOCK_MEDIA_PROXY_PORT;
|
|
resetMediaCaches();
|
|
ensureRelayOriginFetch();
|
|
return mockMediaProxyPort;
|
|
};
|
|
window.__BUZZ_E2E_EMIT_MOCK_HUDDLE_TTS_SPEAKER__ = (payload) =>
|
|
emit("huddle-tts-speaker-level", payload);
|
|
window.__BUZZ_E2E_SIGNED_EVENTS__ = [];
|
|
window.__BUZZ_E2E_WEBVIEW_ZOOM__ = 1;
|
|
window.__BUZZ_E2E_EMIT_MEDIA_UPLOAD_PHASE__ = async (input) => {
|
|
await emit("media-upload-phase", input);
|
|
};
|
|
window.__BUZZ_E2E_EMIT_MEDIA_UPLOAD_PROGRESS__ = async (input) => {
|
|
await emit("media-upload-progress", input);
|
|
};
|
|
window.__BUZZ_E2E_SET_MOCK_HUDDLE_SNAPSHOT__ = async ({
|
|
members,
|
|
transcriptionEnabled,
|
|
}) => {
|
|
if (!mockHuddle) {
|
|
throw new Error("No mock huddle is configured.");
|
|
}
|
|
mockHuddle.members = structuredClone(members);
|
|
mockHuddle.state.transcription_enabled = transcriptionEnabled;
|
|
refreshMockHuddleMembership(config);
|
|
persistMockHuddle();
|
|
await emitMockHuddleState();
|
|
};
|
|
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ = ({
|
|
channelName,
|
|
content,
|
|
parentEventId,
|
|
pubkey,
|
|
kind,
|
|
mentionPubkeys,
|
|
extraTags,
|
|
createdAt,
|
|
pending,
|
|
id,
|
|
}) => {
|
|
const channel = mockChannels.find(
|
|
(candidate) => candidate.name === channelName,
|
|
);
|
|
if (!channel) {
|
|
throw new Error(`Mock channel ${channelName} not found.`);
|
|
}
|
|
|
|
return emitMockChannelMessage(
|
|
channel.id,
|
|
content,
|
|
parentEventId,
|
|
pubkey,
|
|
kind,
|
|
mentionPubkeys,
|
|
extraTags,
|
|
createdAt,
|
|
pending,
|
|
id,
|
|
);
|
|
};
|
|
window.__BUZZ_E2E_SET_MOCK_USER_STATUS__ = ({
|
|
text,
|
|
emoji,
|
|
expiresAt,
|
|
createdAt,
|
|
}) => {
|
|
const tags = [["d", "general"]];
|
|
if (emoji) tags.push(["emoji", emoji]);
|
|
if (expiresAt) tags.push(["expiration", String(expiresAt)]);
|
|
const event = createMockEvent(
|
|
KIND_USER_STATUS,
|
|
text,
|
|
tags,
|
|
DEFAULT_MOCK_IDENTITY.pubkey,
|
|
createdAt,
|
|
);
|
|
recordMockUserStatus(event);
|
|
emitMockGlobalEvent(event);
|
|
return event;
|
|
};
|
|
window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__ = prependMockHistory;
|
|
window.__BUZZ_E2E_EMIT_MOCK_TYPING__ = ({
|
|
channelName,
|
|
createdAt,
|
|
pubkey,
|
|
threadHeadId,
|
|
}) => {
|
|
const channel = mockChannels.find(
|
|
(candidate) => candidate.name === channelName,
|
|
);
|
|
if (!channel) {
|
|
throw new Error(`Mock channel ${channelName} not found.`);
|
|
}
|
|
|
|
return emitMockTypingIndicator(
|
|
channel.id,
|
|
pubkey ?? CHARLIE_PUBKEY,
|
|
threadHeadId,
|
|
createdAt,
|
|
);
|
|
};
|
|
window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__ = ({
|
|
channelName,
|
|
kind,
|
|
exactChannel,
|
|
}) => {
|
|
const channel = mockChannels.find(
|
|
(candidate) => candidate.name === channelName,
|
|
);
|
|
if (!channel) {
|
|
throw new Error(`Mock channel ${channelName} not found.`);
|
|
}
|
|
|
|
return hasMockLiveSubscription(channel.id, kind, exactChannel);
|
|
};
|
|
window.__BUZZ_E2E_HAS_MOCK_OWNER_KIND_SUBSCRIPTION__ = ({
|
|
ownerPubkey,
|
|
kind,
|
|
}) => hasMockOwnerKindSubscription(ownerPubkey, kind);
|
|
window.__BUZZ_E2E_HAS_MOCK_GLOBAL_KIND_SUBSCRIPTION__ = (kind) =>
|
|
hasMockLiveSubscription(GLOBAL_MOCK_SUBSCRIPTION, kind);
|
|
window.__BUZZ_E2E_EMIT_MOCK_PRESENCE__ = ({ pubkey, status }) => {
|
|
const author = pubkey.toLowerCase();
|
|
setMockPresenceStatus(author, status);
|
|
emitMockGlobalEvent(createMockEvent(20001, status, [], author));
|
|
};
|
|
window.__BUZZ_E2E_REPLACE_MOCK_TEAM_CATALOG_HEAD__ = (event) => {
|
|
const dTag = event.tags.find((tag) => tag[0] === "d")?.[1];
|
|
const existingIndex = mockTeamCatalogEvents.findIndex(
|
|
(candidate) =>
|
|
candidate.pubkey.toLowerCase() === event.pubkey.toLowerCase() &&
|
|
candidate.tags.some((tag) => tag[0] === "d" && tag[1] === dTag),
|
|
);
|
|
if (existingIndex >= 0) {
|
|
mockTeamCatalogEvents.splice(existingIndex, 1);
|
|
}
|
|
mockTeamCatalogEvents.push(event);
|
|
};
|
|
window.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ = (item) => {
|
|
const category = item.category === "mention" ? "mentions" : item.category;
|
|
mockFeedOverrides[category].unshift(item);
|
|
window.dispatchEvent(new CustomEvent("buzz:e2e-home-feed-updated"));
|
|
return item;
|
|
};
|
|
window.__BUZZ_E2E_REPLACE_MOCK_FEED_ITEM__ = (oldId, item) => {
|
|
const category = item.category === "mention" ? "mentions" : item.category;
|
|
// Remove the old item from every category bucket (it may have been in a
|
|
// different bucket or the same one).
|
|
for (const bucket of Object.values(mockFeedOverrides)) {
|
|
const idx = (bucket as RawFeedItem[]).findIndex((r) => r.id === oldId);
|
|
if (idx !== -1) {
|
|
(bucket as RawFeedItem[]).splice(idx, 1);
|
|
break;
|
|
}
|
|
}
|
|
// Insert the replacement at the front of the correct bucket.
|
|
mockFeedOverrides[category].unshift(item);
|
|
window.dispatchEvent(new CustomEvent("buzz:e2e-home-feed-updated"));
|
|
return item;
|
|
};
|
|
window.__BUZZ_E2E_MD_PARSE_COUNT__ = getMarkdownParseCount;
|
|
window.__BUZZ_E2E_ACTIVATE_TIMEOUT__ = (expiresAtMs: number) => {
|
|
const expiresAtSec = expiresAtMs > 0 ? Math.floor(expiresAtMs / 1000) : 0;
|
|
const msg =
|
|
expiresAtSec > 0
|
|
? `restricted: you are timed out until ${expiresAtSec}`
|
|
: "restricted: you are timed out until 0";
|
|
recordTimeoutFromRejection(msg);
|
|
};
|
|
window.__BUZZ_E2E_INVALIDATE_CHANNELS__ = async () => {
|
|
await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({
|
|
queryKey: ["channels"],
|
|
});
|
|
};
|
|
window.__BUZZ_E2E_MUTATE_CHANNEL__ = ({
|
|
channelId,
|
|
channelType,
|
|
description,
|
|
removeMemberPubkey,
|
|
}) => {
|
|
const channel = mockChannels.find((ch) => ch.id === channelId);
|
|
if (!channel) return;
|
|
if (channelType !== undefined) {
|
|
channel.channel_type = channelType;
|
|
}
|
|
if (description !== undefined) {
|
|
channel.description = description;
|
|
}
|
|
if (removeMemberPubkey !== undefined) {
|
|
channel.members = channel.members.filter(
|
|
(m) => m.pubkey !== removeMemberPubkey,
|
|
);
|
|
syncMockChannel(channel);
|
|
}
|
|
touchMockChannel(channel);
|
|
};
|
|
// get_event defer/release seam — reset counter and queue on each install.
|
|
window.__BUZZ_E2E_GET_EVENT_CALL_COUNT__ = 0;
|
|
window.__BUZZ_E2E_DEFER_GET_EVENT__ = null;
|
|
deferredGetEventQueue = [];
|
|
heldManagedAgentStartReleases = [];
|
|
window.__BUZZ_E2E_RELEASE_MANAGED_AGENT_STARTS__ = () => {
|
|
const held = heldManagedAgentStartReleases.splice(0);
|
|
for (const release of held) release();
|
|
return held.length;
|
|
};
|
|
heldHuddleAgentSyncReleases = [];
|
|
window.__BUZZ_E2E_RELEASE_HUDDLE_AGENT_SYNCS__ = () => {
|
|
const held = heldHuddleAgentSyncReleases.splice(0);
|
|
for (const release of held) release();
|
|
return held.length;
|
|
};
|
|
deferNextChannelsRead = false;
|
|
deferredChannelsReadResolve = null;
|
|
window.__BUZZ_E2E_CHANNELS_READ_PENDING__ = 0;
|
|
window.__BUZZ_E2E_DEFER_NEXT_CHANNELS_READ__ = () => {
|
|
if (deferredChannelsReadResolve) {
|
|
throw new Error("a channel read is already deferred");
|
|
}
|
|
deferNextChannelsRead = true;
|
|
};
|
|
window.__BUZZ_E2E_RELEASE_CHANNELS_READ__ = () => {
|
|
deferNextChannelsRead = false;
|
|
const resolve = deferredChannelsReadResolve;
|
|
deferredChannelsReadResolve = null;
|
|
window.__BUZZ_E2E_CHANNELS_READ_PENDING__ = 0;
|
|
resolve?.();
|
|
return resolve ? 1 : 0;
|
|
};
|
|
window.__BUZZ_E2E_RELEASE_GET_EVENT__ = () => {
|
|
const queued = deferredGetEventQueue.splice(0);
|
|
for (const entry of queued) {
|
|
entry.run().then(entry.resolve, entry.reject);
|
|
}
|
|
// Disable deferral and reset counter after release so the seam is inert
|
|
// for the remainder of the test (no stray defers from context loads).
|
|
window.__BUZZ_E2E_DEFER_GET_EVENT__ = null;
|
|
window.__BUZZ_E2E_GET_EVENT_CALL_COUNT__ = 0;
|
|
return queued.length;
|
|
};
|
|
window.__BUZZ_E2E_EMIT_MOCK_READ_STATE__ = ({
|
|
clientId,
|
|
contexts,
|
|
createdAt,
|
|
slotId,
|
|
}) => {
|
|
const blob = JSON.stringify({
|
|
v: 1,
|
|
client_id: clientId,
|
|
contexts,
|
|
});
|
|
const event = createMockEvent(
|
|
30078,
|
|
blob,
|
|
[
|
|
["d", `read-state:${slotId}`],
|
|
["t", "read-state"],
|
|
],
|
|
getMockMemberPubkey(config),
|
|
createdAt,
|
|
);
|
|
emitMockLiveEvent(GLOBAL_MOCK_SUBSCRIPTION, event);
|
|
return event;
|
|
};
|
|
window.__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__ = (state) => {
|
|
// Directly emit a connection state change on the relay client singleton,
|
|
// for tests that need to drive degraded relay UI without waiting for the
|
|
// real auth-timeout + reconnect-debounce cycle (~10 s). Reaches the
|
|
// TS-private emitter via a cast so the production class carries no
|
|
// test-only seam.
|
|
(
|
|
relayClient as unknown as {
|
|
connectionStateEmitter: { set: (s: ConnectionState) => void };
|
|
}
|
|
).connectionStateEmitter.set(state);
|
|
};
|
|
window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__ = () =>
|
|
relayClient.getConnectionState();
|
|
window.__BUZZ_E2E_QUEUE_AUTH_RESPONSES__ = (responses) => {
|
|
mockAuthResponses.push(...responses);
|
|
};
|
|
window.__BUZZ_E2E_QUEUE_CHANNEL_HISTORY_CLOSES__ = (reasons) => {
|
|
mockChannelHistoryCloses.push(...reasons);
|
|
};
|
|
window.__BUZZ_E2E_CLOSE_LIVE_SUBSCRIPTIONS__ = (reason) => {
|
|
let closed = 0;
|
|
for (const socket of mockSockets.values()) {
|
|
for (const subId of [...socket.subscriptions.keys()]) {
|
|
sendWsText(socket.handler, ["CLOSED", subId, reason]);
|
|
socket.subscriptions.delete(subId);
|
|
closed += 1;
|
|
}
|
|
}
|
|
return closed;
|
|
};
|
|
|
|
window.__BUZZ_E2E_SEED_MOCK_REMINDERS__ = (reminders) => {
|
|
mockReminderEvents.length = 0;
|
|
for (const r of reminders) {
|
|
mockReminderEvents.push(r);
|
|
}
|
|
};
|
|
|
|
window.__BUZZ_E2E_SET_STALL_WEBSOCKET_SENDS__ = (stall) => {
|
|
const config = getConfig();
|
|
if (!config?.mock) return;
|
|
config.mock.stallWebsocketSends = stall;
|
|
if (!stall) mockWebsocketSendMutexWedged = false;
|
|
};
|
|
window.__BUZZ_E2E_DISCONNECT_MOCK_WEBSOCKETS__ = () => {
|
|
const socketIds = [...mockSockets.keys()];
|
|
for (const socketId of socketIds) disconnectMockSocket(socketId);
|
|
return socketIds.length;
|
|
};
|
|
window.__BUZZ_E2E_RESTART_MOCK_WEBSOCKETS__ = () => {
|
|
const sockets = [...mockSockets.values()];
|
|
mockSockets.clear();
|
|
for (const socket of sockets) {
|
|
sendWsClose(socket.handler, 1012, "relay restarting");
|
|
}
|
|
return sockets.length;
|
|
};
|
|
window.__BUZZ_E2E_SET_MOCK_WEBSOCKET_UNAVAILABLE__ = (unavailable) => {
|
|
mockWebsocketUnavailable = unavailable;
|
|
if (unavailable) relayWebsocketConnectAttemptStarts.length = 0;
|
|
};
|
|
window.__BUZZ_E2E_GET_WEBSOCKET_CONNECT_ATTEMPTS__ = () => [
|
|
...relayWebsocketConnectAttemptStarts,
|
|
];
|
|
window.__BUZZ_E2E_ACTIVATE_RELAY_RATE_LIMIT__ = (seconds) => {
|
|
activateRateLimit(seconds);
|
|
};
|
|
window.__BUZZ_E2E_RESET_WEBSOCKET_CONNECT_ATTEMPTS__ = () => {
|
|
relayWebsocketConnectAttemptStarts.length = 0;
|
|
};
|
|
window.__BUZZ_E2E_RELEASE_SEND_MESSAGE_LIVE_ECHO__ = () => {
|
|
const queued = deferredSendMessageLiveEchoes.splice(0);
|
|
for (const { channelId, event } of queued) {
|
|
emitMockLiveEvent(channelId, event);
|
|
}
|
|
return queued.length;
|
|
};
|
|
// Tests vary mesh admission and models to exercise provider discovery and
|
|
// the managed-agent start preflight.
|
|
window.__BUZZ_E2E_SET_MESH__ = (mesh) => {
|
|
if (mesh.admitted !== undefined) mockMeshState.admitted = mesh.admitted;
|
|
if (mesh.models !== undefined) mockMeshState.models = mesh.models;
|
|
if (mesh.denyReason !== undefined)
|
|
mockMeshState.denyReason = mesh.denyReason;
|
|
if (mesh.nodeState !== undefined) mockMeshState.nodeState = mesh.nodeState;
|
|
if (mesh.nodeMode !== undefined) mockMeshState.nodeMode = mesh.nodeMode;
|
|
if (mesh.servingUsage !== undefined)
|
|
mockMeshState.servingUsage = {
|
|
...mockMeshState.servingUsage,
|
|
...mesh.servingUsage,
|
|
};
|
|
};
|
|
let seedTurnSeq = Date.now();
|
|
window.__BUZZ_E2E_SEED_ACTIVE_TURNS__ = ({
|
|
agentPubkey,
|
|
channelId,
|
|
turnId,
|
|
kind = "turn_started",
|
|
}) => {
|
|
seedTurnSeq += 1;
|
|
const event = {
|
|
seq: seedTurnSeq,
|
|
timestamp: new Date().toISOString(),
|
|
kind,
|
|
agentIndex: 0,
|
|
channelId,
|
|
sessionId: null,
|
|
turnId,
|
|
payload: null,
|
|
};
|
|
syncAgentTurnsFromEvents(agentPubkey, [event]);
|
|
syncAgentObserverEvents(agentPubkey, [event]);
|
|
};
|
|
window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__ = ({ agentPubkey, events }) => {
|
|
injectObserverEventsForE2E(agentPubkey, events);
|
|
};
|
|
const meshModelName = (modelId: string) => {
|
|
const basename = modelId.split("/").at(-1) ?? modelId;
|
|
return basename
|
|
.replace(/-(?:Instruct|GGUF)(?:-|:|$).*/, "")
|
|
.replace(/:.*/, "")
|
|
.replaceAll("-", " ");
|
|
};
|
|
const meshNodeStatus = (
|
|
state: "off" | "running",
|
|
mode: "serve" | "client" | null,
|
|
) => {
|
|
const model = mockMeshState.activeModel ?? mockMeshState.models[0] ?? null;
|
|
return {
|
|
state,
|
|
mode,
|
|
health: { status: "ok" as const, reason: null },
|
|
apiBaseUrl: state === "running" ? "http://127.0.0.1:9337/v1" : null,
|
|
consoleUrl: null,
|
|
modelId: model?.id ?? null,
|
|
modelName: model?.name ?? null,
|
|
inviteToken: state === "running" ? "mock-endpoint-addr" : null,
|
|
endpointId: state === "running" ? "mock-endpoint-id" : null,
|
|
deviceId: state === "running" ? "mock-endpoint-id" : null,
|
|
deviceName: state === "running" ? "Mock desktop" : null,
|
|
};
|
|
};
|
|
let mockImportedVoices: Array<{
|
|
key: string;
|
|
displayName: string;
|
|
backend: string;
|
|
backendName: string;
|
|
availability: "installed";
|
|
fallbackKey: string;
|
|
referenceFile: string;
|
|
provenance: {
|
|
source: string;
|
|
contentHash: string;
|
|
license: null;
|
|
sourceUrl: null;
|
|
};
|
|
}> = [];
|
|
const handleMockCommand = async (
|
|
command: string,
|
|
payload: unknown,
|
|
): Promise<unknown> => {
|
|
const activeConfig = getConfig();
|
|
const identity = getActiveIdentity(activeConfig);
|
|
window.__BUZZ_E2E_COMMANDS__?.push(command);
|
|
const loggedPayload = (() => {
|
|
if (payload instanceof Uint8Array) {
|
|
return { rawByteLength: payload.byteLength };
|
|
}
|
|
try {
|
|
return JSON.parse(JSON.stringify(payload ?? null));
|
|
} catch {
|
|
return null;
|
|
}
|
|
})();
|
|
window.__BUZZ_E2E_COMMAND_PAYLOADS__?.push({
|
|
command,
|
|
payload: loggedPayload,
|
|
});
|
|
window.__BUZZ_E2E_COMMAND_LOG__?.push({ command, payload });
|
|
|
|
switch (command) {
|
|
case "get_huddle_state": {
|
|
const snapshot = mockHuddle ? structuredClone(mockHuddle.state) : null;
|
|
const delayMs = activeConfig?.mock?.huddleStateReadDelayMs ?? 0;
|
|
if (delayMs > 0) {
|
|
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
|
|
}
|
|
return snapshot;
|
|
}
|
|
case "get_voice_input_mode":
|
|
return mockHuddle?.state.voice_input_mode ?? "push_to_talk";
|
|
case "set_voice_input_mode": {
|
|
if (!mockHuddle) throw new Error("No active mock huddle.");
|
|
const mode = (payload as { mode?: unknown }).mode;
|
|
if (mode !== "push_to_talk" && mode !== "voice_activity") {
|
|
throw new Error("Missing voice input mode.");
|
|
}
|
|
mockHuddle.state.voice_input_mode = mode;
|
|
persistMockHuddle();
|
|
await emitMockHuddleState();
|
|
return null;
|
|
}
|
|
case "set_huddle_manual_mic_unmuted":
|
|
return null;
|
|
case "get_huddle_agent_pubkeys":
|
|
if (!mockHuddle) return [];
|
|
return [...mockHuddle.state.agent_pubkeys];
|
|
case "start_huddle": {
|
|
const request = payload as {
|
|
parentChannelId: string;
|
|
memberPubkeys?: string[];
|
|
channelName?: string;
|
|
};
|
|
const ephemeralChannelId = crypto.randomUUID();
|
|
const selfPubkey = identity?.pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey;
|
|
const owner = createCurrentMember(activeConfig, "owner");
|
|
mockHuddle = {
|
|
members: [
|
|
{ pubkey: selfPubkey, role: "owner" },
|
|
...(request.memberPubkeys ?? []).map((pubkey) => ({
|
|
pubkey,
|
|
role: "bot" as const,
|
|
})),
|
|
],
|
|
state: {
|
|
phase: "creating",
|
|
parent_channel_id: request.parentChannelId,
|
|
ephemeral_channel_id: ephemeralChannelId,
|
|
huddle_thread_event_id: null,
|
|
participants: [],
|
|
agent_pubkeys: [],
|
|
agent_voice_settings: {},
|
|
tts_enabled: false,
|
|
transcription_enabled: false,
|
|
is_creator: true,
|
|
voice_input_mode: "push_to_talk",
|
|
},
|
|
};
|
|
refreshMockHuddleMembership(activeConfig);
|
|
persistMockHuddle();
|
|
await emitMockHuddleState();
|
|
mockChannels.push(
|
|
createMockChannel({
|
|
id: ephemeralChannelId,
|
|
name: request.channelName ?? "huddle",
|
|
channel_type: "stream",
|
|
visibility: "private",
|
|
description: "",
|
|
topic: null,
|
|
purpose: null,
|
|
last_message_at: null,
|
|
archived_at: null,
|
|
created_by: owner.pubkey,
|
|
topic_set_by: null,
|
|
topic_set_at: null,
|
|
purpose_set_by: null,
|
|
purpose_set_at: null,
|
|
topic_required: false,
|
|
max_members: null,
|
|
nip29_group_id: null,
|
|
ttl_seconds: 3_600,
|
|
created_minutes_ago: 0,
|
|
members: [owner],
|
|
}),
|
|
);
|
|
emitMockGlobalEvent(
|
|
createMockEvent(
|
|
KIND_MEMBER_ADDED_NOTIFICATION,
|
|
"",
|
|
[
|
|
["h", ephemeralChannelId],
|
|
["p", selfPubkey],
|
|
],
|
|
selfPubkey,
|
|
),
|
|
);
|
|
if ((activeConfig?.mock?.startHuddleReturnDelayMs ?? 0) > 0) {
|
|
await new Promise((resolve) =>
|
|
window.setTimeout(
|
|
resolve,
|
|
activeConfig?.mock?.startHuddleReturnDelayMs ?? 0,
|
|
),
|
|
);
|
|
}
|
|
if (mockHuddle) {
|
|
mockHuddle.state.phase = "connected";
|
|
persistMockHuddle();
|
|
await emitMockHuddleState();
|
|
}
|
|
return { ephemeral_channel_id: ephemeralChannelId };
|
|
}
|
|
case "confirm_huddle_active":
|
|
if (mockHuddle) {
|
|
mockHuddle.state.phase = "active";
|
|
persistMockHuddle();
|
|
await emitMockHuddleState();
|
|
}
|
|
return null;
|
|
case "open_huddle_window":
|
|
if ((activeConfig?.mock?.openHuddleWindowDelayMs ?? 0) > 0) {
|
|
await new Promise((resolve) =>
|
|
window.setTimeout(
|
|
resolve,
|
|
activeConfig?.mock?.openHuddleWindowDelayMs ?? 0,
|
|
),
|
|
);
|
|
}
|
|
return null;
|
|
case "close_huddle_companion":
|
|
await emit("huddle-companion-returned", null);
|
|
return null;
|
|
case "leave_huddle":
|
|
case "end_huddle":
|
|
mockHuddle = null;
|
|
window.sessionStorage.removeItem(MOCK_HUDDLE_STORAGE_KEY);
|
|
// Deliberately leave the unarchived backing channel in the mock list.
|
|
// This models relay/cache propagation lag and proves the UI removes it
|
|
// at the local huddle lifecycle boundary rather than waiting for data.
|
|
await emit("huddle-state-changed", {
|
|
phase: "idle",
|
|
parent_channel_id: null,
|
|
ephemeral_channel_id: null,
|
|
huddle_thread_event_id: null,
|
|
participants: [],
|
|
agent_pubkeys: [],
|
|
agent_voice_settings: {},
|
|
tts_enabled: false,
|
|
transcription_enabled: false,
|
|
is_creator: false,
|
|
voice_input_mode: "push_to_talk",
|
|
});
|
|
return null;
|
|
case "set_huddle_transcription_enabled":
|
|
if (!mockHuddle) {
|
|
throw new Error("No active mock huddle.");
|
|
}
|
|
mockHuddle.state.transcription_enabled = Boolean(
|
|
(payload as { enabled?: boolean }).enabled,
|
|
);
|
|
persistMockHuddle();
|
|
await emitMockHuddleState();
|
|
return null;
|
|
case "ensure_huddle_agent_voice_settings":
|
|
refreshMockHuddleMembership(activeConfig);
|
|
persistMockHuddle();
|
|
return structuredClone(mockHuddle?.state.agent_voice_settings ?? {});
|
|
case "set_huddle_agent_tts_enabled": {
|
|
if (!mockHuddle) throw new Error("No active mock huddle.");
|
|
const request = payload as { agentPubkey?: string; enabled?: boolean };
|
|
if (!request.agentPubkey || typeof request.enabled !== "boolean") {
|
|
throw new Error("Missing agent text-to-speech setting.");
|
|
}
|
|
refreshMockHuddleMembership(activeConfig);
|
|
const settings =
|
|
mockHuddle.state.agent_voice_settings[request.agentPubkey];
|
|
if (!settings) throw new Error("Agent is not in the active huddle.");
|
|
settings.enabled = request.enabled;
|
|
persistMockHuddle();
|
|
await emitMockHuddleState();
|
|
return structuredClone(settings);
|
|
}
|
|
case "set_huddle_agent_voice": {
|
|
if (!mockHuddle) throw new Error("No active mock huddle.");
|
|
const request = payload as { agentPubkey?: string; voiceKey?: string };
|
|
if (!request.agentPubkey || !request.voiceKey) {
|
|
throw new Error("Missing agent voice setting.");
|
|
}
|
|
refreshMockHuddleMembership(activeConfig);
|
|
const settings =
|
|
mockHuddle.state.agent_voice_settings[request.agentPubkey];
|
|
if (!settings) throw new Error("Agent is not in the active huddle.");
|
|
settings.voice_key = request.voiceKey;
|
|
persistMockHuddle();
|
|
await emitMockHuddleState();
|
|
return structuredClone(settings);
|
|
}
|
|
case "sync_agents_to_active_huddle": {
|
|
const request = payload as {
|
|
channelId?: string;
|
|
agentPubkeys?: string[];
|
|
};
|
|
const syncDelayMs =
|
|
activeConfig?.mock?.syncAgentsToActiveHuddleDelayMs ?? 0;
|
|
if (syncDelayMs > 0) {
|
|
await new Promise<void>((resolve) => {
|
|
let settled = false;
|
|
const release = () => {
|
|
if (settled) return;
|
|
settled = true;
|
|
window.clearTimeout(timer);
|
|
heldHuddleAgentSyncReleases = heldHuddleAgentSyncReleases.filter(
|
|
(held) => held !== release,
|
|
);
|
|
resolve();
|
|
};
|
|
const timer = window.setTimeout(release, syncDelayMs);
|
|
heldHuddleAgentSyncReleases.push(release);
|
|
});
|
|
}
|
|
if (
|
|
!mockHuddle ||
|
|
!request.channelId ||
|
|
(request.channelId !== mockHuddle.state.parent_channel_id &&
|
|
request.channelId !== mockHuddle.state.ephemeral_channel_id)
|
|
) {
|
|
return { matched_active_huddle: false, added: [] };
|
|
}
|
|
|
|
const existingAgents = new Set(
|
|
mockHuddle.members
|
|
.filter((member) => member.role === "bot")
|
|
.map((member) => member.pubkey),
|
|
);
|
|
const added: string[] = [];
|
|
for (const pubkey of request.agentPubkeys ?? []) {
|
|
if (!pubkey || existingAgents.has(pubkey)) continue;
|
|
existingAgents.add(pubkey);
|
|
added.push(pubkey);
|
|
mockHuddle.members.push({ pubkey, role: "bot" });
|
|
}
|
|
refreshMockHuddleMembership(activeConfig);
|
|
persistMockHuddle();
|
|
if (added.length > 0) {
|
|
await emitMockHuddleState();
|
|
}
|
|
return { matched_active_huddle: true, added };
|
|
}
|
|
case "add_agent_to_huddle": {
|
|
const error = activeConfig?.mock?.addAgentToHuddleError;
|
|
if (error) {
|
|
throw new Error(error);
|
|
}
|
|
const result = activeConfig?.mock?.addAgentToHuddleResult;
|
|
if (!result) {
|
|
throw new Error("No mock add-agent result configured.");
|
|
}
|
|
return structuredClone(result);
|
|
}
|
|
case "remove_agent_from_huddle": {
|
|
const agentPubkey = (payload as { agentPubkey?: string })?.agentPubkey;
|
|
if (!mockHuddle || !agentPubkey) {
|
|
throw new Error("No active huddle agent to remove.");
|
|
}
|
|
const initialCount = mockHuddle.members.length;
|
|
mockHuddle.members = mockHuddle.members.filter(
|
|
(member) => member.role !== "bot" || member.pubkey !== agentPubkey,
|
|
);
|
|
if (mockHuddle.members.length === initialCount) {
|
|
throw new Error("Agent is not in this huddle.");
|
|
}
|
|
refreshMockHuddleMembership(activeConfig);
|
|
persistMockHuddle();
|
|
await emitMockHuddleState();
|
|
return;
|
|
}
|
|
case "get_model_status":
|
|
return { stt: "ready", tts: "ready" };
|
|
case "get_tts_settings":
|
|
return (
|
|
activeConfig?.mock?.ttsSettings ?? {
|
|
version: 1,
|
|
agentTextToSpeech: true,
|
|
voicePreferences: ["pocket:mary"],
|
|
}
|
|
);
|
|
case "list_voice_registry":
|
|
return [
|
|
...[
|
|
[
|
|
"anna",
|
|
"Anna",
|
|
"anna.wav",
|
|
"p228_023_enhanced.wav",
|
|
"0a6de25cf12bf1540beb85979f306a92be81fecc051c547c5395e7e5237a3856",
|
|
],
|
|
[
|
|
"vera",
|
|
"Vera",
|
|
"vera.wav",
|
|
"p229_023_enhanced.wav",
|
|
"309cf91a895830f15842b398f69a4962cb1f7e0bfab10e25dd27838e826c204b",
|
|
],
|
|
[
|
|
"fantine",
|
|
"Fantine",
|
|
"fantine.wav",
|
|
"p244_023_enhanced.wav",
|
|
"5f07d4e2a3f20a15572aae885156b43ef3fc12ef3812996fd135680d9956448b",
|
|
],
|
|
[
|
|
"charles",
|
|
"Charles",
|
|
"charles.wav",
|
|
"p254_023_enhanced.wav",
|
|
"6b681a429198f16e378d53bccb08d06939da7b00144a7696111d4f8f76be7756",
|
|
],
|
|
[
|
|
"paul",
|
|
"Paul",
|
|
"paul.wav",
|
|
"p259_023_enhanced.wav",
|
|
"7aba504fe0b3b16478b69ed27ce6007e3cb42b0c1915b5f1c6a6024ae37d679b",
|
|
],
|
|
[
|
|
"eponine",
|
|
"Eponine",
|
|
"eponine.wav",
|
|
"p262_023_enhanced.wav",
|
|
"a13c27fb47627b05223691a0ef2974358a18c886e6c2f9d2762ff1d02c20926b",
|
|
],
|
|
[
|
|
"azelma",
|
|
"Azelma",
|
|
"azelma.wav",
|
|
"p303_023_enhanced.wav",
|
|
"60e3d26cdf2efdec5df712152c839928f4d5522821e6554ae11fd96c57ab1026",
|
|
],
|
|
[
|
|
"george",
|
|
"George",
|
|
"george.wav",
|
|
"p315_023_enhanced.wav",
|
|
"29a41f93bf5236e5b21501091d7774c255d5f3d4e62fa4f9fdf0a92a793c84ae",
|
|
],
|
|
[
|
|
"mary",
|
|
"Mary",
|
|
"reference_sample.wav",
|
|
"p333_023_enhanced.wav",
|
|
"a35b0468382218e9f37a9a7494d1e4b74deaf18d7ced22265b4e325bb55c183f",
|
|
],
|
|
[
|
|
"jane",
|
|
"Jane",
|
|
"jane.wav",
|
|
"p339_023_enhanced.wav",
|
|
"2f12e7f155eb3118f55425394f1b049e5b1b67bdc9b3932c8ba4521420aeb84a",
|
|
],
|
|
[
|
|
"michael",
|
|
"Michael",
|
|
"michael.wav",
|
|
"p360_023_enhanced.wav",
|
|
"b6743e9195e5e3fd34fe9d1633ae93f7ffab787b249e45f6467d7d6f7a6ee6ad",
|
|
],
|
|
[
|
|
"eve",
|
|
"Eve",
|
|
"eve.wav",
|
|
"p361_023_enhanced.wav",
|
|
"396e7cbd066b0f3fb6d67fa26e7904076958239d736d4390f15b5fe88feb14cd",
|
|
],
|
|
].map(
|
|
([id, displayName, referenceFile, upstreamFile, contentHash]) => ({
|
|
key: `pocket:${id}`,
|
|
displayName,
|
|
backend: "pocket",
|
|
backendName: "Pocket TTS",
|
|
availability: "bundled",
|
|
fallbackKey: id === "mary" ? null : "pocket:mary",
|
|
referenceFile,
|
|
provenance: {
|
|
source: "bundled",
|
|
contentHash,
|
|
license: "CC-BY-4.0",
|
|
sourceUrl: `https://huggingface.co/kyutai/tts-voices/blob/323332d33f997de8394f24a193e1a76df720e01a/vctk/${upstreamFile}`,
|
|
},
|
|
}),
|
|
),
|
|
...mockImportedVoices,
|
|
];
|
|
case "set_tts_enabled": {
|
|
const enabled = (payload as { enabled?: boolean })?.enabled;
|
|
if (typeof enabled !== "boolean")
|
|
throw new Error("Missing text-to-speech enabled state");
|
|
const settings = {
|
|
version: 1,
|
|
agentTextToSpeech: enabled,
|
|
voicePreferences: activeConfig?.mock?.ttsSettings
|
|
?.voicePreferences ?? ["pocket:mary"],
|
|
};
|
|
if (activeConfig) {
|
|
activeConfig.mock ??= {};
|
|
activeConfig.mock.ttsSettings = settings;
|
|
}
|
|
return settings;
|
|
}
|
|
case "interrupt_huddle_speech":
|
|
return;
|
|
case "set_pocket_voice": {
|
|
const voiceKey = (payload as { voiceKey?: string })?.voiceKey;
|
|
if (!voiceKey) throw new Error("Missing Pocket voice key");
|
|
const current = activeConfig?.mock?.ttsSettings ?? {
|
|
version: 1,
|
|
agentTextToSpeech: true,
|
|
voicePreferences: ["pocket:mary"],
|
|
};
|
|
const firstPocketIndex = current.voicePreferences.findIndex((key) =>
|
|
key.startsWith("pocket:"),
|
|
);
|
|
const preferences = current.voicePreferences.filter(
|
|
(key) => !key.startsWith("pocket:"),
|
|
);
|
|
preferences.splice(
|
|
firstPocketIndex < 0 ? preferences.length : firstPocketIndex,
|
|
0,
|
|
voiceKey,
|
|
);
|
|
const settings = { ...current, voicePreferences: preferences };
|
|
if (activeConfig) {
|
|
activeConfig.mock ??= {};
|
|
activeConfig.mock.ttsSettings = settings;
|
|
}
|
|
return settings;
|
|
}
|
|
case "preview_pocket_voice":
|
|
return null;
|
|
case "import_pocket_voice": {
|
|
const importResult =
|
|
activeConfig?.mock?.pocketVoiceImportResult ?? "success";
|
|
if (importResult === "cancel") return null;
|
|
if (importResult === "invalid") {
|
|
throw new Error("Voice WAV must contain PCM or 32-bit float audio");
|
|
}
|
|
const contentHash = "1".repeat(64);
|
|
const imported = {
|
|
key: `pocket:imported:${contentHash}`,
|
|
displayName: "My voice",
|
|
backend: "pocket",
|
|
backendName: "Pocket TTS",
|
|
availability: "installed" as const,
|
|
fallbackKey: "pocket:mary",
|
|
referenceFile: `${contentHash}.wav`,
|
|
provenance: {
|
|
source: "local import",
|
|
contentHash,
|
|
license: null,
|
|
sourceUrl: null,
|
|
},
|
|
};
|
|
mockImportedVoices = [imported];
|
|
const current = activeConfig?.mock?.ttsSettings ?? {
|
|
version: 1,
|
|
agentTextToSpeech: true,
|
|
voicePreferences: ["pocket:mary"],
|
|
};
|
|
const settings = {
|
|
...current,
|
|
voicePreferences: [imported.key],
|
|
};
|
|
if (activeConfig) {
|
|
activeConfig.mock ??= {};
|
|
activeConfig.mock.ttsSettings = settings;
|
|
}
|
|
return {
|
|
settings,
|
|
registry: await handleMockCommand("list_voice_registry", null),
|
|
};
|
|
}
|
|
case "delete_pocket_voice": {
|
|
const voiceKey = (payload as { voiceKey?: string })?.voiceKey;
|
|
if (!voiceKey?.startsWith("pocket:imported:"))
|
|
throw new Error("Missing imported Pocket voice key");
|
|
mockImportedVoices = mockImportedVoices.filter(
|
|
(voice) => voice.key !== voiceKey,
|
|
);
|
|
const current = activeConfig?.mock?.ttsSettings ?? {
|
|
version: 1,
|
|
agentTextToSpeech: true,
|
|
voicePreferences: ["pocket:mary"],
|
|
};
|
|
const settings = {
|
|
...current,
|
|
voicePreferences: current.voicePreferences.includes(voiceKey)
|
|
? ["pocket:mary"]
|
|
: current.voicePreferences,
|
|
};
|
|
if (activeConfig) {
|
|
activeConfig.mock ??= {};
|
|
activeConfig.mock.ttsSettings = settings;
|
|
}
|
|
return {
|
|
settings,
|
|
registry: await handleMockCommand("list_voice_registry", null),
|
|
};
|
|
}
|
|
case "get_builderlab_auth":
|
|
return activeConfig?.mock?.builderlabAuth ?? null;
|
|
case "start_builderlab_login": {
|
|
const delayMs = activeConfig?.mock?.builderlabLoginDelayMs ?? 0;
|
|
if (delayMs > 0)
|
|
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
|
|
const nextAuth = activeConfig?.mock?.builderlabAuth ?? {
|
|
email: "owner@example.com",
|
|
expiresAt: "2099-01-01T00:00:00Z",
|
|
};
|
|
if (activeConfig?.mock) activeConfig.mock.builderlabAuth = nextAuth;
|
|
return nextAuth;
|
|
}
|
|
case "cancel_builderlab_login":
|
|
return null;
|
|
case "clear_builderlab_auth":
|
|
if (activeConfig?.mock) activeConfig.mock.builderlabAuth = null;
|
|
return null;
|
|
case "get_builderlab_nostr_identity":
|
|
return activeConfig?.mock?.builderlabIdentity
|
|
? { identity: activeConfig.mock.builderlabIdentity }
|
|
: { error: { code: "missing_mapping", setup_needed: true } };
|
|
case "bind_builderlab_nostr_identity": {
|
|
if (activeConfig?.mock?.builderlabBindError)
|
|
return { error: activeConfig.mock.builderlabBindError };
|
|
const activeIdentity = identity ?? DEFAULT_MOCK_IDENTITY;
|
|
const nextIdentity = {
|
|
pubkey_hex: activeIdentity.pubkey,
|
|
npub: `npub1${activeIdentity.pubkey}`,
|
|
};
|
|
if (activeConfig?.mock)
|
|
activeConfig.mock.builderlabIdentity = nextIdentity;
|
|
return { identity: nextIdentity };
|
|
}
|
|
case "delete_builderlab_nostr_identity":
|
|
if (activeConfig?.mock) activeConfig.mock.builderlabIdentity = null;
|
|
return {};
|
|
case "list_builderlab_communities":
|
|
return {
|
|
communities: activeConfig?.mock?.builderlabCommunities ?? [],
|
|
};
|
|
case "check_builderlab_community_name":
|
|
return {
|
|
available: true,
|
|
normalized_host: `${(payload as { name?: string })?.name ?? "community"}.communities.buzz.xyz`,
|
|
};
|
|
case "create_builderlab_community": {
|
|
const name = (payload as { name?: string })?.name ?? "community";
|
|
return {
|
|
community: activeConfig?.mock?.builderlabCreatedCommunity ?? {
|
|
id: `hosted-${name}`,
|
|
name,
|
|
normalized_host: `${name}.communities.buzz.xyz`,
|
|
},
|
|
};
|
|
}
|
|
case "mesh_installed_models":
|
|
return mockMeshState.models;
|
|
case "mesh_model_catalog":
|
|
return {
|
|
gpuName: "Mock Apple GPU",
|
|
vramDisplay: "32 GB",
|
|
vramGb: 32,
|
|
recommended: "Gemma-4-E4B-it-Q4_K_M",
|
|
entries: [
|
|
{
|
|
name: "Gemma-4-E4B-it-Q4_K_M",
|
|
size: "3.5GB",
|
|
sizeGb: 3.5,
|
|
description: "Buzz-curated local agent model",
|
|
fit: "comfortable",
|
|
installed: true,
|
|
recommended: true,
|
|
curated: true,
|
|
},
|
|
],
|
|
};
|
|
case "mesh_node_status":
|
|
return meshNodeStatus(mockMeshState.nodeState, mockMeshState.nodeMode);
|
|
case "mesh_serving_usage":
|
|
return mockMeshState.servingUsage;
|
|
case "mesh_start_node": {
|
|
const req = (
|
|
payload as {
|
|
request?: { mode?: "serve" | "client"; modelId?: string };
|
|
} | null
|
|
)?.request;
|
|
mockMeshState.nodeState = "running";
|
|
mockMeshState.nodeMode = req?.mode ?? "serve";
|
|
mockMeshState.activeModel = req?.modelId
|
|
? { id: req.modelId, name: meshModelName(req.modelId) }
|
|
: (mockMeshState.models[0] ?? null);
|
|
return meshNodeStatus(mockMeshState.nodeState, mockMeshState.nodeMode);
|
|
}
|
|
case "mesh_stop_node":
|
|
// Mirror the backend contract: "stop sharing" must never tear down a
|
|
// client (consume) node occupying the single slot. Leave it running.
|
|
if (mockMeshState.nodeMode === "client") {
|
|
return meshNodeStatus(
|
|
mockMeshState.nodeState,
|
|
mockMeshState.nodeMode,
|
|
);
|
|
}
|
|
mockMeshState.nodeState = "off";
|
|
mockMeshState.nodeMode = null;
|
|
mockMeshState.activeModel = null;
|
|
return meshNodeStatus("off", null);
|
|
/** Build a test-only signed-looking observer control event. */
|
|
case "build_observer_control_event": {
|
|
const input = payload as { agentPubkey: string; payload: unknown };
|
|
return JSON.stringify(
|
|
createMockEvent(
|
|
KIND_AGENT_OBSERVER_FRAME,
|
|
JSON.stringify(input.payload),
|
|
[
|
|
["p", input.agentPubkey],
|
|
["agent", input.agentPubkey],
|
|
["frame", "control"],
|
|
],
|
|
DEFAULT_MOCK_IDENTITY.pubkey,
|
|
),
|
|
);
|
|
}
|
|
case "decrypt_observer_event": {
|
|
const event = JSON.parse(
|
|
(payload as { eventJson: string }).eventJson,
|
|
) as RelayEvent;
|
|
try {
|
|
return JSON.parse(event.content);
|
|
} catch {
|
|
throw new Error("mock observer event content is not JSON");
|
|
}
|
|
}
|
|
case "get_identity": {
|
|
const identityReadError = activeConfig?.mock?.identityReadErrorAfter;
|
|
if (
|
|
identityReadError &&
|
|
!identityReadErrorConsumed &&
|
|
identityReadCount >= identityReadError.successfulReads
|
|
) {
|
|
identityReadErrorConsumed = true;
|
|
throw new Error(identityReadError.message);
|
|
}
|
|
identityReadCount += 1;
|
|
const isLost =
|
|
!mockIdentityLostCleared && activeConfig?.mock?.identityLost === true;
|
|
const isLocked =
|
|
!mockIdentityLockedCleared &&
|
|
activeConfig?.mock?.identityLocked === true;
|
|
if (identity) {
|
|
return {
|
|
pubkey: identity.pubkey,
|
|
display_name: identity.username,
|
|
lost: false,
|
|
locked: false,
|
|
};
|
|
}
|
|
|
|
return { ...DEFAULT_MOCK_IDENTITY, lost: isLost, locked: isLocked };
|
|
}
|
|
case "sign_nostr_identity_binding": {
|
|
const request = payload as {
|
|
challengeId: string;
|
|
expiresAt: string;
|
|
nonce: string;
|
|
origin: string;
|
|
verificationCode: string;
|
|
};
|
|
const signDelayMs = activeConfig?.mock?.nostrBindSignDelayMs ?? 0;
|
|
if (signDelayMs > 0) {
|
|
await new Promise((resolve) => setTimeout(resolve, signDelayMs));
|
|
}
|
|
const activeIdentity = identity ?? DEFAULT_MOCK_IDENTITY;
|
|
return JSON.stringify({
|
|
id: "e2e-signed-nostr-binding",
|
|
pubkey: activeIdentity.pubkey,
|
|
created_at: 0,
|
|
kind: 24243,
|
|
tags: [
|
|
["challenge_id", request.challengeId],
|
|
["nonce", request.nonce],
|
|
["verification_code", request.verificationCode],
|
|
["origin", request.origin],
|
|
["expires_at", request.expiresAt],
|
|
],
|
|
content: "",
|
|
sig: "e2e-signed-nostr-binding",
|
|
});
|
|
}
|
|
case "sign_out":
|
|
// Production wipes local state and restarts the app. In the browser
|
|
// harness there is nothing to wipe; resolving is enough — specs
|
|
// assert invocation via __BUZZ_E2E_COMMANDS__ and the pending UI.
|
|
return;
|
|
case "generate_backup_passphrase": {
|
|
const request = payload as {
|
|
words?: number;
|
|
separator?: string;
|
|
} | null;
|
|
const wordCount = Math.min(Math.max(request?.words ?? 3, 3), 10);
|
|
return MOCK_PASSPHRASE_WORDS.slice(0, wordCount).join(
|
|
request?.separator ?? " ",
|
|
);
|
|
}
|
|
case "create_ncryptsec_backup": {
|
|
const delayMs = activeConfig?.mock?.backupEncryptionDelayMs ?? 0;
|
|
if (delayMs > 0) {
|
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
}
|
|
return MOCK_NCRYPTSEC;
|
|
}
|
|
case "save_ncryptsec_copy": {
|
|
const paths = activeConfig?.mock?.backupSavePaths ?? [
|
|
"/tmp/buzz-identity.ncryptsec",
|
|
];
|
|
const index = Math.min(backupSaveCallCount, paths.length - 1);
|
|
backupSaveCallCount += 1;
|
|
return paths[index];
|
|
}
|
|
case "verify_ncryptsec_backup": {
|
|
const request = payload as { password?: string } | null;
|
|
const configuredErrors = activeConfig?.mock?.backupVerificationErrors;
|
|
const hasConfiguredResult = Boolean(
|
|
activeConfig?.mock?.backupVerificationPubkeys,
|
|
);
|
|
const errors = configuredErrors ?? [
|
|
hasConfiguredResult || request?.password === MOCK_BACKUP_PASSPHRASE
|
|
? null
|
|
: "wrong backup password or damaged key backup",
|
|
];
|
|
const index = Math.min(backupVerificationCallCount, errors.length - 1);
|
|
const error = errors[index];
|
|
if (error) {
|
|
backupVerificationCallCount += 1;
|
|
throw new Error(error);
|
|
}
|
|
const pubkeys = activeConfig?.mock?.backupVerificationPubkeys ?? [
|
|
identity?.pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey,
|
|
];
|
|
const pubkey = pubkeys[Math.min(index, pubkeys.length - 1)];
|
|
backupVerificationCallCount += 1;
|
|
return {
|
|
pubkey,
|
|
npub: npubEncode(pubkey),
|
|
matchesCurrentIdentity:
|
|
pubkey === (identity?.pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey),
|
|
};
|
|
}
|
|
case "get_nsec": {
|
|
const nsecSequence = activeConfig?.mock?.nsecErrors;
|
|
if (nsecSequence && nsecSequence.length > 0) {
|
|
const idx = Math.min(nsecCallCount, nsecSequence.length - 1);
|
|
nsecCallCount++;
|
|
const entry = nsecSequence[idx];
|
|
if (entry !== null) {
|
|
throw new Error(entry);
|
|
}
|
|
return "nsec1mock000000000000000000000000000000000000000000000000000000";
|
|
}
|
|
const nsecError = activeConfig?.mock?.nsecError;
|
|
if (nsecError) {
|
|
throw new Error(nsecError);
|
|
}
|
|
return "nsec1mock000000000000000000000000000000000000000000000000000000";
|
|
}
|
|
case "persist_current_identity": {
|
|
// Persist the ephemeral key: clears only the lost flag. The locked flag
|
|
// is cleared only by import_identity; production rejects
|
|
// persist_current_identity when the identity is in the locked state.
|
|
mockIdentityLostCleared = true;
|
|
const currentPubkey = identity?.pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey;
|
|
const currentDisplayName =
|
|
identity?.username ?? DEFAULT_MOCK_IDENTITY.display_name;
|
|
return {
|
|
pubkey: currentPubkey,
|
|
display_name: currentDisplayName,
|
|
lost: false,
|
|
locked: false,
|
|
};
|
|
}
|
|
case "import_identity": {
|
|
const importDelayMs = activeConfig?.mock?.identityImportDelayMs ?? 0;
|
|
if (importDelayMs > 0) {
|
|
await new Promise((resolve) =>
|
|
window.setTimeout(resolve, importDelayMs),
|
|
);
|
|
}
|
|
const request = payload as { nsec?: string; password?: string } | null;
|
|
const input = request?.nsec ?? "";
|
|
if (input.trim().startsWith("ncryptsec1")) {
|
|
if (
|
|
input.trim() !== MOCK_NCRYPTSEC ||
|
|
request?.password !== MOCK_BACKUP_PASSPHRASE
|
|
) {
|
|
throw new Error("Wrong backup password or damaged key backup.");
|
|
}
|
|
mockIdentityLostCleared = true;
|
|
mockIdentityLockedCleared = true;
|
|
return importMockIdentity(
|
|
nsecEncode(hexToBytes(DEFAULT_REAL_IDENTITY.privateKey)),
|
|
);
|
|
}
|
|
mockIdentityLostCleared = true;
|
|
mockIdentityLockedCleared = true;
|
|
return importMockIdentity(input);
|
|
}
|
|
case "validate_repos_dir":
|
|
// The browser harness has no host filesystem to validate. Treat the
|
|
// seeded empty/default path as valid so Add Community can continue to
|
|
// relay-policy discovery.
|
|
return;
|
|
case "fetch_join_policy":
|
|
return activeConfig?.mock?.joinPolicy ?? null;
|
|
case "fetch_link_preview_metadata": {
|
|
if (activeConfig?.mock?.deferLinkPreviewMetadata) {
|
|
await new Promise<void>((resolve) => {
|
|
deferredLinkPreviewMetadataQueue.push(resolve);
|
|
});
|
|
}
|
|
const startBlockMs =
|
|
activeConfig?.mock?.linkPreviewMetadataStartBlockMs ?? 0;
|
|
if (startBlockMs > 0) {
|
|
const stopAt = performance.now() + startBlockMs;
|
|
while (performance.now() < stopAt) {
|
|
// Deliberately block to model uncached native command startup.
|
|
}
|
|
}
|
|
const delayMs = activeConfig?.mock?.linkPreviewMetadataDelayMs ?? 0;
|
|
if (delayMs > 0) {
|
|
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
|
|
}
|
|
const href = (payload as { href?: string }).href;
|
|
const metadataByHref = activeConfig?.mock?.linkPreviewMetadataByHref;
|
|
if (href && metadataByHref && Object.hasOwn(metadataByHref, href)) {
|
|
return metadataByHref[href];
|
|
}
|
|
return activeConfig?.mock?.linkPreviewMetadata ?? null;
|
|
}
|
|
case "apply_workspace": {
|
|
const applyDelayMs = activeConfig?.mock?.applyCommunityDelayMs ?? 0;
|
|
if (applyDelayMs > 0) {
|
|
return new Promise((resolve) =>
|
|
window.setTimeout(resolve, applyDelayMs),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
case "update_tray_agent_activity":
|
|
case "clear_tray_agent_activity":
|
|
case "requeue_tray_actions":
|
|
return null;
|
|
case "take_tray_actions":
|
|
return [];
|
|
case "get_profile":
|
|
return handleGetProfile(activeConfig);
|
|
case "update_profile":
|
|
return handleUpdateProfile(
|
|
payload as Parameters<typeof handleUpdateProfile>[0],
|
|
activeConfig,
|
|
);
|
|
case "update_profile_at_relay":
|
|
return handleUpdateProfile(
|
|
{
|
|
avatarUrl: (payload as { avatarUrl: string }).avatarUrl,
|
|
},
|
|
activeConfig,
|
|
);
|
|
case "get_user_profile":
|
|
return handleGetUserProfile(
|
|
(payload as Parameters<typeof handleGetUserProfile>[0]) ?? {},
|
|
activeConfig,
|
|
);
|
|
case "get_users_batch":
|
|
return handleGetUsersBatch(
|
|
payload as Parameters<typeof handleGetUsersBatch>[0],
|
|
activeConfig,
|
|
);
|
|
case "get_user_notes":
|
|
return handleGetUserNotes(
|
|
payload as Parameters<typeof handleGetUserNotes>[0],
|
|
activeConfig,
|
|
);
|
|
case "get_global_notes":
|
|
return handleGetGlobalNotes(
|
|
payload as Parameters<typeof handleGetGlobalNotes>[0],
|
|
activeConfig,
|
|
);
|
|
case "get_notes_timeline":
|
|
return handleGetNotesTimeline(
|
|
payload as Parameters<typeof handleGetNotesTimeline>[0],
|
|
);
|
|
case "get_note":
|
|
return handleGetNote(payload as Parameters<typeof handleGetNote>[0]);
|
|
case "get_note_reactions":
|
|
return handleGetNoteReactions();
|
|
case "get_liked_notes":
|
|
return handleGetLikedNotes();
|
|
case "search_users":
|
|
return handleSearchUsers(
|
|
payload as Parameters<typeof handleSearchUsers>[0],
|
|
activeConfig,
|
|
);
|
|
case "get_presence":
|
|
return handleGetPresence(
|
|
(payload as Parameters<typeof handleGetPresence>[0]) ?? {
|
|
pubkeys: [],
|
|
},
|
|
activeConfig,
|
|
);
|
|
case "get_os_idle_seconds":
|
|
// e2e runs headless with no OS idle API; the presence hook falls back
|
|
// to in-app activity tracking.
|
|
return null;
|
|
case "get_git_identity":
|
|
// Matches the synthetic human author on a mock snapshot commit so the
|
|
// viewer-identity avatar attribution is exercised in e2e.
|
|
return { name: "Avery E", email: "avery@example.com" };
|
|
case "get_project_repo_snapshot":
|
|
if (activeConfig?.mock?.projectRepoSnapshotDelayMs) {
|
|
await new Promise((resolve) =>
|
|
window.setTimeout(
|
|
resolve,
|
|
activeConfig.mock?.projectRepoSnapshotDelayMs,
|
|
),
|
|
);
|
|
}
|
|
if (activeConfig?.mock?.projectRepoSnapshotError) {
|
|
throw new Error(activeConfig.mock.projectRepoSnapshotError);
|
|
}
|
|
return {
|
|
latest_commit: {
|
|
hash: "0123456789abcdef0123456789abcdef01234567",
|
|
short_hash: "0123456",
|
|
author_name: "Brain",
|
|
author_email: "brain@example.com",
|
|
timestamp: Math.floor(Date.now() / 1000) - 600,
|
|
subject: "Add Trello board workflow details",
|
|
},
|
|
commits: [
|
|
{
|
|
hash: "0123456789abcdef0123456789abcdef01234567",
|
|
short_hash: "0123456",
|
|
author_name: "Brain",
|
|
author_email: "brain@example.com",
|
|
timestamp: Math.floor(Date.now() / 1000) - 600,
|
|
subject: "Add Trello board workflow details",
|
|
},
|
|
{
|
|
hash: "123456789abcdef0123456789abcdef012345678",
|
|
short_hash: "1234567",
|
|
author_name: "Avery E",
|
|
author_email: "avery@example.com",
|
|
timestamp: Math.floor(Date.now() / 1000) - 1_800,
|
|
subject: "Point project repository details at active branch",
|
|
},
|
|
{
|
|
hash: "23456789abcdef0123456789abcdef0123456789",
|
|
short_hash: "2345678",
|
|
author_name: "Brain",
|
|
author_email: "brain@example.com",
|
|
timestamp: Math.floor(Date.now() / 1000) - 3_600,
|
|
subject: "Make project repository-first",
|
|
},
|
|
{
|
|
hash: "3456789abcdef0123456789abcdef0123456789a",
|
|
short_hash: "3456789",
|
|
author_name: "Git Importer",
|
|
author_email: "git-importer@example.com",
|
|
timestamp: Math.floor(Date.now() / 1000) - 7_200,
|
|
subject: "Merge remote project history into local community",
|
|
},
|
|
],
|
|
contributors: [
|
|
{
|
|
name: "Brain",
|
|
email: "brain@example.com",
|
|
commit_count: 8,
|
|
last_commit_at: Math.floor(Date.now() / 1000) - 600,
|
|
},
|
|
{
|
|
name: "Avery E",
|
|
email: "avery@example.com",
|
|
commit_count: 3,
|
|
last_commit_at: Math.floor(Date.now() / 1000) - 1_800,
|
|
},
|
|
{
|
|
name: "Git Importer",
|
|
email: "git-importer@example.com",
|
|
commit_count: 1,
|
|
last_commit_at: Math.floor(Date.now() / 1000) - 7_200,
|
|
},
|
|
],
|
|
files: [
|
|
{
|
|
path: "desktop/src/features/projects/ui/ProjectDetailScreen.tsx",
|
|
kind: "blob",
|
|
size: 18420,
|
|
preview_content:
|
|
'export function ProjectDetailScreen() {\n return <CommunityTabs defaultValue="files" />;\n}\n',
|
|
},
|
|
{
|
|
path: "desktop/src/features/projects/ui/ProjectsView.tsx",
|
|
kind: "blob",
|
|
size: 16412,
|
|
preview_content:
|
|
"export function ProjectsView() {\n return <ProjectsToolbar />;\n}\n",
|
|
},
|
|
{
|
|
path: "desktop/src/features/projects/hooks.ts",
|
|
kind: "blob",
|
|
size: 9520,
|
|
preview_content:
|
|
"export function useProjectRepoSnapshotQuery(project) {\n return useQuery({ queryKey: [project.id, 'repo-snapshot'] });\n}\n",
|
|
},
|
|
{
|
|
path: "crates/buzz-relay/src/api/git/transport.rs",
|
|
kind: "blob",
|
|
size: 33120,
|
|
preview_content:
|
|
"// Smart HTTP git transport\n// Handles upload-pack and receive-pack for Buzz git repos.\n",
|
|
},
|
|
],
|
|
};
|
|
case "get_project_local_repo_snapshot":
|
|
return window.__BUZZ_E2E_PROJECT_LOCAL_REPO_SNAPSHOT__ ?? null;
|
|
case "get_project_repo_file_content":
|
|
case "get_project_local_repo_file_content": {
|
|
const path = (payload as { path?: string } | undefined)?.path;
|
|
return path
|
|
? (window.__BUZZ_E2E_PROJECT_REPO_FILE_CONTENTS__?.[path] ?? null)
|
|
: null;
|
|
}
|
|
case "get_project_repo_diff":
|
|
return {
|
|
additions: 27,
|
|
deletions: 4,
|
|
commit_body: [
|
|
"See the [project guide](https://example.com/project-guide).",
|
|
"",
|
|
"",
|
|
"",
|
|
"",
|
|
].join("\n"),
|
|
files: [
|
|
{
|
|
path: "desktop/src/features/projects/ui/ProjectDetailScreen.tsx",
|
|
additions: 18,
|
|
deletions: 3,
|
|
patch: [
|
|
"@@ -1,6 +1,8 @@",
|
|
' import { Tabs } from "@/shared/ui/tabs";',
|
|
"",
|
|
"-function CommunityTabs() {",
|
|
"+function CommunityTabs({ selectedCommitHash }) {",
|
|
'+ const [selectedTab, setSelectedTab] = useState("overview");',
|
|
"+",
|
|
" return (",
|
|
' <Tabs value="overview">',
|
|
" <ProjectTabsList />",
|
|
].join("\n"),
|
|
truncated: false,
|
|
},
|
|
{
|
|
path: "desktop/src/features/projects/hooks.ts",
|
|
additions: 9,
|
|
deletions: 1,
|
|
patch: [
|
|
"@@ -10,4 +10,12 @@",
|
|
" export function useProjectQuery(projectId) {",
|
|
" return useQuery({ queryKey: [projectId] });",
|
|
" }",
|
|
"+",
|
|
"+export function useProjectCommitDiffQuery(project, hash) {",
|
|
'+ return useQuery({ queryKey: [project?.id, "commit-diff", hash] });',
|
|
"+}",
|
|
].join("\n"),
|
|
truncated: false,
|
|
},
|
|
],
|
|
};
|
|
case "get_project_local_repo_diff":
|
|
return null;
|
|
case "get_project_repo_sync_status":
|
|
return (
|
|
window.__BUZZ_E2E_PROJECT_REPO_SYNC_STATUS__ ?? {
|
|
local_path: null,
|
|
local_branch: null,
|
|
local_branches: [],
|
|
local_head: null,
|
|
local_short_head: null,
|
|
remote_branch: "main",
|
|
remote_head: "0123456789abcdef0123456789abcdef01234567",
|
|
remote_short_head: "0123456",
|
|
merge_base: "0123456789abcdef0123456789abcdef01234567",
|
|
ahead_count: 0,
|
|
behind_count: 0,
|
|
has_uncommitted_changes: false,
|
|
has_untracked_files: false,
|
|
can_push: false,
|
|
push_block_reason: "No local checkout found.",
|
|
can_pull: false,
|
|
pull_block_reason: "No local checkout found.",
|
|
}
|
|
);
|
|
case "list_project_local_repositories":
|
|
return [];
|
|
case "open_project_repository_folder":
|
|
return null;
|
|
case "push_project_local_repository": {
|
|
const input = payload as { branchName?: string | null };
|
|
const status = window.__BUZZ_E2E_PROJECT_REPO_SYNC_STATUS__;
|
|
const branch = input.branchName ?? status?.remote_branch ?? "main";
|
|
const commit =
|
|
status?.local_head ?? "0123456789abcdef0123456789abcdef01234567";
|
|
if (status) {
|
|
status.remote_branch = branch;
|
|
status.remote_head = commit;
|
|
status.remote_short_head = commit.slice(0, 7);
|
|
status.ahead_count = 0;
|
|
status.can_push = false;
|
|
status.push_block_reason = "Local branch is already pushed.";
|
|
}
|
|
return {
|
|
pushed: true,
|
|
message: `Pushed ${branch} to remote.`,
|
|
branch,
|
|
commit,
|
|
merge_base:
|
|
status?.merge_base ?? "0123456789abcdef0123456789abcdef01234567",
|
|
};
|
|
}
|
|
case "pull_project_local_repository":
|
|
return {
|
|
pulled: true,
|
|
message: "Pulled main from remote.",
|
|
};
|
|
case "clone_project_repository": {
|
|
// Clones land in reposDir/<repo-name>, matching the terminal mocks.
|
|
const path = "/tmp/buzz/REPOS/buzz";
|
|
const commit = "0123456789abcdef0123456789abcdef01234567";
|
|
window.__BUZZ_E2E_PROJECT_REPO_SYNC_STATUS__ = {
|
|
local_path: path,
|
|
local_branch: "main",
|
|
local_branches: ["main"],
|
|
local_head: commit,
|
|
local_short_head: commit.slice(0, 7),
|
|
remote_branch: "main",
|
|
remote_head: commit,
|
|
remote_short_head: commit.slice(0, 7),
|
|
merge_base: commit,
|
|
ahead_count: 0,
|
|
behind_count: 0,
|
|
has_uncommitted_changes: false,
|
|
has_untracked_files: false,
|
|
can_push: false,
|
|
push_block_reason: "Local branch is already pushed.",
|
|
can_pull: false,
|
|
pull_block_reason: "Local branch is up to date.",
|
|
};
|
|
return {
|
|
path,
|
|
cloned: true,
|
|
message: "Cloned repository.",
|
|
};
|
|
}
|
|
case "create_project_remote_branch": {
|
|
const input = payload as {
|
|
cloneUrl: string;
|
|
expectedCommit: string;
|
|
newBranch: string;
|
|
sourceBranch: string;
|
|
};
|
|
const dtag = new URL(input.cloneUrl).pathname
|
|
.split("/")
|
|
.filter(Boolean)
|
|
.at(-1)
|
|
?.replace(/\.git$/, "");
|
|
const repoState = getMockProjectEventStore().find(
|
|
(event) =>
|
|
event.kind === KIND_REPO_STATE &&
|
|
event.tags.some((tag) => tag[0] === "d" && tag[1] === dtag),
|
|
);
|
|
if (repoState) {
|
|
repoState.tags = repoState.tags.filter(
|
|
(tag) => tag[0] !== `refs/heads/${input.newBranch}`,
|
|
);
|
|
repoState.tags.push([
|
|
`refs/heads/${input.newBranch}`,
|
|
input.expectedCommit,
|
|
]);
|
|
repoState.created_at = Math.floor(Date.now() / 1000);
|
|
}
|
|
if (dtag) {
|
|
writeMockProjectBranch(dtag, input.newBranch, input.expectedCommit);
|
|
}
|
|
return {
|
|
branch: input.newBranch,
|
|
commit: input.expectedCommit,
|
|
message: `Created branch ${input.newBranch} from ${input.sourceBranch}.`,
|
|
};
|
|
}
|
|
case "delete_project_remote_branch": {
|
|
const input = payload as {
|
|
branch: string;
|
|
cloneUrl: string;
|
|
expectedCommit: string;
|
|
};
|
|
const dtag = new URL(input.cloneUrl).pathname
|
|
.split("/")
|
|
.filter(Boolean)
|
|
.at(-1)
|
|
?.replace(/\.git$/, "");
|
|
const repoState = getMockProjectEventStore().find(
|
|
(event) =>
|
|
event.kind === KIND_REPO_STATE &&
|
|
event.tags.some((tag) => tag[0] === "d" && tag[1] === dtag),
|
|
);
|
|
if (repoState) {
|
|
repoState.tags = repoState.tags.filter(
|
|
(tag) => tag[0] !== `refs/heads/${input.branch}`,
|
|
);
|
|
repoState.created_at = Math.floor(Date.now() / 1000);
|
|
}
|
|
if (dtag) {
|
|
writeMockProjectBranch(dtag, input.branch, null);
|
|
}
|
|
return {
|
|
branch: input.branch,
|
|
commit: input.expectedCommit,
|
|
message: `Deleted branch ${input.branch}.`,
|
|
};
|
|
}
|
|
case "publish_project_owner_announcement": {
|
|
const { input } = payload as {
|
|
input: {
|
|
content: string;
|
|
createdAt?: number;
|
|
kind: number;
|
|
tags: string[][];
|
|
targetOwner: string;
|
|
};
|
|
};
|
|
const normalizedTargetOwner = input.targetOwner.toLowerCase();
|
|
const canSignAsOwner =
|
|
(identity?.pubkey ?? MOCK_IDENTITY_PUBKEY).toLowerCase() ===
|
|
normalizedTargetOwner ||
|
|
mockManagedAgents.some(
|
|
(agent) => agent.pubkey.toLowerCase() === normalizedTargetOwner,
|
|
);
|
|
if (!canSignAsOwner) {
|
|
throw new Error(
|
|
"Only the owner identity or the owner of its managed agent can perform this action.",
|
|
);
|
|
}
|
|
const event = createMockEvent(
|
|
input.kind,
|
|
input.content,
|
|
input.tags,
|
|
normalizedTargetOwner,
|
|
input.createdAt,
|
|
);
|
|
window.__BUZZ_E2E_SIGNED_EVENTS__?.push({
|
|
content: event.content,
|
|
createdAt: event.created_at,
|
|
kind: event.kind,
|
|
tags: event.tags,
|
|
});
|
|
|
|
let publicationError: string | null = null;
|
|
if (
|
|
event.kind === KIND_PROJECT_ANNOUNCEMENT &&
|
|
window.__BUZZ_E2E_UNSUPPORTED_PROJECT_ANNOUNCEMENTS__
|
|
) {
|
|
publicationError = "restricted: unknown event kind";
|
|
} else {
|
|
const rejectionIndex =
|
|
window.__BUZZ_E2E_REJECT_PROJECT_EVENT_KINDS__?.indexOf(
|
|
event.kind,
|
|
) ?? -1;
|
|
if (rejectionIndex >= 0) {
|
|
window.__BUZZ_E2E_REJECT_PROJECT_EVENT_KINDS__?.splice(
|
|
rejectionIndex,
|
|
1,
|
|
);
|
|
publicationError = "mock project event rejection";
|
|
} else {
|
|
getMockProjectEventStore().push(event);
|
|
const acceptedProjectEvents =
|
|
window.__BUZZ_E2E_ACCEPTED_PROJECT_EVENTS__ ?? [];
|
|
acceptedProjectEvents.push({
|
|
content: event.content,
|
|
kind: event.kind,
|
|
tags: event.tags,
|
|
});
|
|
window.__BUZZ_E2E_ACCEPTED_PROJECT_EVENTS__ = acceptedProjectEvents;
|
|
const failedAckIndex =
|
|
window.__BUZZ_E2E_FAIL_PROJECT_EVENT_ACK_KINDS__?.indexOf(
|
|
event.kind,
|
|
) ?? -1;
|
|
if (failedAckIndex >= 0) {
|
|
window.__BUZZ_E2E_FAIL_PROJECT_EVENT_ACK_KINDS__?.splice(
|
|
failedAckIndex,
|
|
1,
|
|
);
|
|
publicationError = "mock lost project acknowledgement";
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
event: JSON.stringify(event),
|
|
publication_error: publicationError,
|
|
};
|
|
}
|
|
case "sign_project_pull_request_status": {
|
|
const { input } = payload as {
|
|
input: {
|
|
createdAt: number;
|
|
pullRequestAuthor: string;
|
|
pullRequestId: string;
|
|
repoAddress: string;
|
|
status: "open" | "draft" | "closed";
|
|
targetOwner: string;
|
|
};
|
|
};
|
|
const kind = {
|
|
open: KIND_GIT_STATUS_OPEN,
|
|
draft: KIND_GIT_STATUS_DRAFT,
|
|
closed: KIND_GIT_STATUS_CLOSED,
|
|
}[input.status];
|
|
const recipientPubkeys = Array.from(
|
|
new Set(
|
|
[input.targetOwner, input.pullRequestAuthor].map((pubkey) =>
|
|
pubkey.trim().toLowerCase(),
|
|
),
|
|
),
|
|
);
|
|
const event = createMockEvent(
|
|
kind,
|
|
"",
|
|
[
|
|
["e", input.pullRequestId, "", "root"],
|
|
["a", input.repoAddress],
|
|
...recipientPubkeys.map((pubkey) => ["p", pubkey]),
|
|
],
|
|
input.targetOwner,
|
|
input.createdAt,
|
|
);
|
|
window.__BUZZ_E2E_SIGNED_EVENTS__?.push(event);
|
|
getMockProjectEventStore().push(event);
|
|
return null;
|
|
}
|
|
case "sign_project_pull_request_review_request": {
|
|
const { input } = payload as {
|
|
input: {
|
|
pullRequestId: string;
|
|
repoAddress: string;
|
|
reviewerLabel: string;
|
|
reviewers: string[];
|
|
targetOwner: string;
|
|
};
|
|
};
|
|
const event = createMockEvent(
|
|
KIND_TEXT_NOTE,
|
|
`Requested a review from ${input.reviewerLabel}`,
|
|
[
|
|
["e", input.pullRequestId, "", "root"],
|
|
["a", input.repoAddress],
|
|
...input.reviewers.map((reviewer) => ["p", reviewer]),
|
|
["t", "review-request"],
|
|
],
|
|
input.targetOwner,
|
|
);
|
|
window.__BUZZ_E2E_SIGNED_EVENTS__?.push({
|
|
content: event.content,
|
|
kind: event.kind,
|
|
tags: event.tags,
|
|
});
|
|
getMockProjectEventStore().push(event);
|
|
return null;
|
|
}
|
|
case "publish_project_pull_request_merged_status": {
|
|
const { input } = payload as {
|
|
input: { statusEvent: string; targetOwner: string };
|
|
};
|
|
const event = JSON.parse(input.statusEvent) as RelayEvent;
|
|
if (event.pubkey !== input.targetOwner) {
|
|
throw new Error("mock merged status owner mismatch");
|
|
}
|
|
getMockProjectEventStore().push(event);
|
|
return null;
|
|
}
|
|
case "merge_project_pull_request": {
|
|
const { input } = payload as {
|
|
input: {
|
|
expectedCommit: string;
|
|
pullRequestAuthor: string;
|
|
pullRequestId: string;
|
|
repoAddress: string;
|
|
sourceBranch: string;
|
|
statusCreatedAt: number;
|
|
targetBranch: string;
|
|
targetOwner: string;
|
|
};
|
|
};
|
|
const normalizedTargetOwner = input.targetOwner.toLowerCase();
|
|
const canSignAsOwner =
|
|
(identity?.pubkey ?? MOCK_IDENTITY_PUBKEY).toLowerCase() ===
|
|
normalizedTargetOwner ||
|
|
mockManagedAgents.some(
|
|
(agent) => agent.pubkey.toLowerCase() === normalizedTargetOwner,
|
|
);
|
|
if (!canSignAsOwner) {
|
|
throw new Error(
|
|
"Only the repository owner or the owner of its managed agent can merge pull requests.",
|
|
);
|
|
}
|
|
if (window.__BUZZ_E2E_PROJECT_MERGE_ERROR__) {
|
|
throw window.__BUZZ_E2E_PROJECT_MERGE_ERROR__;
|
|
}
|
|
const mergeCommit = "abcdef0123456789abcdef0123456789abcdef01";
|
|
const statusEvent = createMockEvent(
|
|
KIND_GIT_STATUS_MERGED,
|
|
"",
|
|
[
|
|
["e", input.pullRequestId, "", "root"],
|
|
["a", input.repoAddress],
|
|
["p", input.targetOwner],
|
|
["p", input.pullRequestAuthor],
|
|
["merge-commit", mergeCommit],
|
|
["r", mergeCommit],
|
|
],
|
|
input.targetOwner,
|
|
input.statusCreatedAt,
|
|
);
|
|
window.__BUZZ_E2E_SIGNED_EVENTS__?.push({
|
|
content: statusEvent.content,
|
|
kind: statusEvent.kind,
|
|
tags: statusEvent.tags,
|
|
});
|
|
const rejectionIndex =
|
|
window.__BUZZ_E2E_REJECT_PROJECT_EVENT_KINDS__?.indexOf(
|
|
statusEvent.kind,
|
|
) ?? -1;
|
|
let statusPublicationError: string | null = null;
|
|
if (rejectionIndex >= 0) {
|
|
window.__BUZZ_E2E_REJECT_PROJECT_EVENT_KINDS__?.splice(
|
|
rejectionIndex,
|
|
1,
|
|
);
|
|
statusPublicationError = "mock project event rejection";
|
|
} else {
|
|
getMockProjectEventStore().push(statusEvent);
|
|
}
|
|
return {
|
|
message: "Merged feature into main.",
|
|
merge_commit: mergeCommit,
|
|
status_event: JSON.stringify(statusEvent),
|
|
status_publication_error: statusPublicationError,
|
|
};
|
|
}
|
|
case "open_project_merge_recovery_terminal": {
|
|
const { input } = payload as {
|
|
input: { expectedCommit: string };
|
|
};
|
|
return {
|
|
path: "/tmp/buzz/REPOS/buzz",
|
|
cloned: false,
|
|
recoveryRef: `refs/buzz/merge-recovery/${input.expectedCommit}`,
|
|
targetRef: `refs/buzz/merge-recovery-target/${"f".repeat(40)}`,
|
|
};
|
|
}
|
|
case "open_project_terminal":
|
|
return {
|
|
path: "/tmp/buzz/REPOS/buzz",
|
|
cloned: false,
|
|
};
|
|
case "get_relay_ws_url":
|
|
return getRelayWsUrl(activeConfig);
|
|
case "get_default_relay_url":
|
|
return getRelayWsUrl(activeConfig);
|
|
case "auto_connect_default_relay_enabled":
|
|
return activeConfig?.autoConnectDefaultRelay ?? false;
|
|
case "get_legacy_workspace_storage":
|
|
return {
|
|
workspaces: null,
|
|
activeWorkspaceId: null,
|
|
onboardingCompletions: [],
|
|
};
|
|
case "take_pending_community_deep_link":
|
|
// Mirrors the Rust queue: peek the head; acknowledge removes it.
|
|
return mockPendingCommunityDeepLinks[0] ?? null;
|
|
case "acknowledge_pending_community_deep_link": {
|
|
const { id } = payload as { id: string };
|
|
const index = mockPendingCommunityDeepLinks.findIndex(
|
|
(pending) => pending.id === id,
|
|
);
|
|
if (index === -1) {
|
|
return false;
|
|
}
|
|
mockPendingCommunityDeepLinks.splice(index, 1);
|
|
return true;
|
|
}
|
|
case "clear_pending_navigation_deep_links":
|
|
if (activeConfig?.mock?.clearPendingNavigationDeepLinksError) {
|
|
throw new Error(
|
|
activeConfig.mock.clearPendingNavigationDeepLinksError,
|
|
);
|
|
}
|
|
mockPendingNavigationDeepLinks.length = 0;
|
|
return;
|
|
case "take_pending_navigation_deep_link":
|
|
return mockPendingNavigationDeepLinks[0] ?? null;
|
|
case "acknowledge_pending_navigation_deep_link": {
|
|
const { id } = payload as { id: string };
|
|
if (mockPendingNavigationDeepLinks[0]?.id !== id) return false;
|
|
mockPendingNavigationDeepLinks.shift();
|
|
return true;
|
|
}
|
|
case "take_pending_entity_deep_link":
|
|
return mockPendingEntityDeepLinks[0] ?? null;
|
|
case "acknowledge_pending_entity_deep_link": {
|
|
const { id } = payload as { id: string };
|
|
if (mockPendingEntityDeepLinks[0]?.id !== id) {
|
|
return false;
|
|
}
|
|
mockPendingEntityDeepLinks.shift();
|
|
return true;
|
|
}
|
|
case "get_relay_http_url":
|
|
return getRelayHttpUrl(activeConfig);
|
|
case "relay_requires_membership":
|
|
return activeConfig?.mock?.relayRequiresMembership ?? false;
|
|
case "discover_acp_providers":
|
|
return handleDiscoverAcpRuntimes(activeConfig);
|
|
case "save_custom_harness":
|
|
return handleSaveCustomHarness(
|
|
payload as Parameters<typeof handleSaveCustomHarness>[0],
|
|
);
|
|
case "delete_custom_harness":
|
|
return handleDeleteCustomHarness(
|
|
payload as Parameters<typeof handleDeleteCustomHarness>[0],
|
|
activeConfig,
|
|
);
|
|
case "discover_acp_auth_methods":
|
|
return handleDiscoverAcpAuthMethods(
|
|
payload as { runtimeId?: string },
|
|
activeConfig,
|
|
);
|
|
case "connect_acp_runtime":
|
|
return handleConnectAcpRuntime(
|
|
payload as { request?: { runtimeId?: string; methodId?: string } },
|
|
activeConfig,
|
|
);
|
|
case "install_acp_runtime":
|
|
return handleInstallAcpRuntime(
|
|
payload as { runtimeId?: string },
|
|
activeConfig,
|
|
);
|
|
case "discover_backend_providers":
|
|
return activeConfig?.mock?.backendProviders ?? [];
|
|
case "probe_backend_provider": {
|
|
const probeDelayMs =
|
|
activeConfig?.mock?.backendProviderProbeDelayMs ?? 0;
|
|
if (probeDelayMs > 0) {
|
|
await new Promise((resolve) =>
|
|
window.setTimeout(resolve, probeDelayMs),
|
|
);
|
|
}
|
|
return (
|
|
activeConfig?.mock?.backendProviderProbeResult ?? {
|
|
ok: false,
|
|
error: "mock: no providers available",
|
|
}
|
|
);
|
|
}
|
|
case "discover_managed_agent_prereqs":
|
|
return handleDiscoverManagedAgentPrereqs(
|
|
payload as Parameters<typeof handleDiscoverManagedAgentPrereqs>[0],
|
|
activeConfig,
|
|
);
|
|
case "get_channels": {
|
|
// Claim the one-shot before starting the read, then hold only that
|
|
// invocation's completion. Later reads pass through and cannot join it.
|
|
const deferred = deferNextChannelsRead;
|
|
deferNextChannelsRead = false;
|
|
const channels = handleGetChannels(payload, activeConfig);
|
|
if (deferred) {
|
|
await new Promise<void>((resolve) => {
|
|
deferredChannelsReadResolve = resolve;
|
|
window.__BUZZ_E2E_CHANNELS_READ_PENDING__ = 1;
|
|
});
|
|
}
|
|
return channels;
|
|
}
|
|
case "get_open_channel_directory": {
|
|
// The real command returns a bare Channel[] (the discovery superset),
|
|
// not the hash-wrapped payload `get_channels` uses.
|
|
const directory = await handleGetChannels(
|
|
payload,
|
|
activeConfig,
|
|
"open-directory",
|
|
);
|
|
return directory.channels ?? [];
|
|
}
|
|
case "get_feed":
|
|
return handleGetFeed(
|
|
(payload as Parameters<typeof handleGetFeed>[0]) ?? {},
|
|
activeConfig,
|
|
);
|
|
case "list_relay_agents":
|
|
return handleListRelayAgents(activeConfig);
|
|
case "revalidate_relay_agents": {
|
|
const agents = await handleListRelayAgents(activeConfig);
|
|
const { pubkeys, channelId } = payload as {
|
|
pubkeys: string[];
|
|
channelId?: string;
|
|
};
|
|
const requested = new Set(
|
|
pubkeys.map((pubkey) => pubkey.toLowerCase()),
|
|
);
|
|
const revoked = new Set(
|
|
(activeConfig?.mock?.relayAgentRevalidationRevokedPubkeys ?? []).map(
|
|
(pubkey) => pubkey.toLowerCase(),
|
|
),
|
|
);
|
|
return agents.filter(
|
|
(agent) =>
|
|
requested.has(agent.pubkey.toLowerCase()) &&
|
|
!revoked.has(agent.pubkey.toLowerCase()) &&
|
|
(!channelId ||
|
|
agent.channel_ids.includes(channelId) ||
|
|
agent.owner_pubkey === MOCK_IDENTITY_PUBKEY),
|
|
);
|
|
}
|
|
case "list_personas":
|
|
return handleListPersonas();
|
|
case "create_persona":
|
|
return handleCreatePersona(
|
|
payload as Parameters<typeof handleCreatePersona>[0],
|
|
);
|
|
case "update_persona":
|
|
return handleUpdatePersona(
|
|
payload as Parameters<typeof handleUpdatePersona>[0],
|
|
);
|
|
case "update_persona_and_publish":
|
|
return handleUpdatePersonaAndPublish(
|
|
payload as Parameters<typeof handleUpdatePersonaAndPublish>[0],
|
|
activeConfig,
|
|
);
|
|
case "delete_persona":
|
|
return handleDeletePersona(
|
|
payload as Parameters<typeof handleDeletePersona>[0],
|
|
);
|
|
case "reconcile_inbound_persona_event": {
|
|
const nostrEvent = JSON.parse(
|
|
(payload as { eventJson: string }).eventJson,
|
|
) as {
|
|
kind: number;
|
|
tags: string[][];
|
|
content: string;
|
|
created_at: number;
|
|
};
|
|
if (nostrEvent.kind === 30175) {
|
|
// Persona upsert — parse content and upsert into mockPersonas by d-tag
|
|
const dTag = nostrEvent.tags.find((t) => t[0] === "d")?.[1];
|
|
if (dTag) {
|
|
const content = JSON.parse(nostrEvent.content) as {
|
|
display_name?: string;
|
|
system_prompt?: string;
|
|
};
|
|
const now = new Date().toISOString();
|
|
const existing = mockPersonas.find((p) => p.id === dTag);
|
|
const shared = nostrEvent.tags.some(
|
|
(tag) =>
|
|
tag.length === 2 && tag[0] === "shared" && tag[1] === "true",
|
|
);
|
|
if (existing) {
|
|
existing.display_name =
|
|
content.display_name ?? existing.display_name;
|
|
existing.system_prompt =
|
|
content.system_prompt ?? existing.system_prompt;
|
|
existing.shared = shared;
|
|
existing.updated_at = now;
|
|
} else {
|
|
mockPersonas.push({
|
|
id: dTag,
|
|
display_name: content.display_name ?? dTag,
|
|
avatar_url: null,
|
|
system_prompt: content.system_prompt ?? "",
|
|
is_builtin: false,
|
|
is_active: true,
|
|
shared,
|
|
env_vars: {},
|
|
created_at: now,
|
|
updated_at: now,
|
|
});
|
|
}
|
|
}
|
|
} else if (nostrEvent.kind === 5) {
|
|
// Tombstone — extract d-tag from a-tag "30175:<pubkey>:<d_tag>" and remove
|
|
const aTagValue = nostrEvent.tags.find((t) => t[0] === "a")?.[1];
|
|
if (aTagValue) {
|
|
const dTag = aTagValue.split(":")[2];
|
|
if (dTag) {
|
|
mockPersonas = mockPersonas.filter((p) => p.id !== dTag);
|
|
}
|
|
}
|
|
}
|
|
// Mirror the real Rust backend: emit "agents-data-changed" after reconcile.
|
|
await emit("agents-data-changed");
|
|
return undefined;
|
|
}
|
|
case "set_persona_active":
|
|
return handleSetPersonaActive(
|
|
payload as Parameters<typeof handleSetPersonaActive>[0],
|
|
);
|
|
case "set_persona_shared":
|
|
return handleSetPersonaShared(
|
|
payload as Parameters<typeof handleSetPersonaShared>[0],
|
|
activeConfig,
|
|
);
|
|
case "list_teams":
|
|
return handleListTeams();
|
|
case "set_team_shared":
|
|
return handleSetTeamShared(
|
|
payload as Parameters<typeof handleSetTeamShared>[0],
|
|
activeConfig,
|
|
);
|
|
case "add_team_from_catalog":
|
|
return handleAddTeamFromCatalog(
|
|
payload as Parameters<typeof handleAddTeamFromCatalog>[0],
|
|
);
|
|
case "list_channel_templates":
|
|
return (activeConfig?.mock?.channelTemplates ?? []).map((template) => ({
|
|
id: template.id,
|
|
name: template.name,
|
|
description: template.description,
|
|
channel_type: template.channelType,
|
|
visibility: template.visibility,
|
|
canvas_template: template.canvasTemplate,
|
|
agents: template.agents,
|
|
is_builtin: template.isBuiltin,
|
|
created_at: template.createdAt,
|
|
updated_at: template.updatedAt,
|
|
}));
|
|
case "create_channel_template": {
|
|
const { input } = payload as {
|
|
input: {
|
|
name: string;
|
|
description?: string;
|
|
channelType?: "stream" | "forum";
|
|
visibility?: "open" | "private";
|
|
canvasTemplate?: string;
|
|
agents?: ChannelTemplate["agents"];
|
|
};
|
|
};
|
|
const timestamp = new Date().toISOString();
|
|
const created: ChannelTemplate = {
|
|
id: `template-${Date.now()}`,
|
|
name: input.name,
|
|
description: input.description ?? null,
|
|
channelType: input.channelType ?? "stream",
|
|
visibility: input.visibility ?? "open",
|
|
canvasTemplate: input.canvasTemplate ?? null,
|
|
agents: input.agents ?? { personas: [], teams: [] },
|
|
isBuiltin: false,
|
|
createdAt: timestamp,
|
|
updatedAt: timestamp,
|
|
};
|
|
if (activeConfig) {
|
|
activeConfig.mock ??= {};
|
|
activeConfig.mock.channelTemplates = [
|
|
...(activeConfig.mock.channelTemplates ?? []),
|
|
created,
|
|
];
|
|
}
|
|
return {
|
|
id: created.id,
|
|
name: created.name,
|
|
description: created.description,
|
|
channel_type: created.channelType,
|
|
visibility: created.visibility,
|
|
canvas_template: created.canvasTemplate,
|
|
agents: created.agents,
|
|
is_builtin: created.isBuiltin,
|
|
created_at: created.createdAt,
|
|
updated_at: created.updatedAt,
|
|
};
|
|
}
|
|
case "create_team":
|
|
return handleCreateTeam(
|
|
payload as Parameters<typeof handleCreateTeam>[0],
|
|
);
|
|
case "update_team":
|
|
return handleUpdateTeam(
|
|
payload as Parameters<typeof handleUpdateTeam>[0],
|
|
);
|
|
case "delete_team":
|
|
return handleDeleteTeam(
|
|
payload as Parameters<typeof handleDeleteTeam>[0],
|
|
);
|
|
case "export_team_to_json":
|
|
return handleExportTeamToJson(payload as { id: string });
|
|
case "pick_team_directory":
|
|
return handlePickTeamDirectory();
|
|
case "install_team_from_directory":
|
|
return handleInstallTeamFromDirectory(
|
|
payload as Parameters<typeof handleInstallTeamFromDirectory>[0],
|
|
);
|
|
case "sync_team_directory":
|
|
return handleSyncTeamDirectory(
|
|
payload as Parameters<typeof handleSyncTeamDirectory>[0],
|
|
);
|
|
case "parse_team_file":
|
|
return handleParseTeamFile();
|
|
case "export_agent_snapshot":
|
|
// Mimics the save-to-disk path: report success without a real dialog.
|
|
// Specs assert invocation via __BUZZ_E2E_COMMANDS__.
|
|
return true;
|
|
case "encode_agent_snapshot_for_send": {
|
|
// Return the requested wire format so both message sharing (PNG) and
|
|
// community catalog publication (JSON) exercise their real branches.
|
|
// Optional encodeDelayMs lets specs observe the "preparing" phase before
|
|
// the upload begins.
|
|
const encodeDelayMs = activeConfig?.mock?.encodeDelayMs ?? 0;
|
|
if (encodeDelayMs > 0) {
|
|
await new Promise((resolve) =>
|
|
window.setTimeout(resolve, encodeDelayMs),
|
|
);
|
|
}
|
|
const input = payload as {
|
|
id: string;
|
|
memoryLevel: "none" | "core" | "everything";
|
|
format: "json" | "png";
|
|
};
|
|
if (input.format === "json") {
|
|
const persona = mockPersonas.find(
|
|
(candidate) => candidate.id === input.id,
|
|
);
|
|
const snapshot = {
|
|
format: "buzz-agent-snapshot",
|
|
version: 1,
|
|
definition: {
|
|
name: persona?.display_name ?? "E2E Agent",
|
|
sourceIsBuiltIn: persona?.is_builtin ?? false,
|
|
systemPrompt: persona?.system_prompt ?? "",
|
|
runtime: persona?.runtime ?? null,
|
|
model: persona?.model ?? null,
|
|
provider: persona?.provider ?? null,
|
|
respondTo: persona?.respond_to ?? null,
|
|
respondToAllowlist: persona?.respond_to_allowlist ?? [],
|
|
namePool: persona?.name_pool ?? [],
|
|
},
|
|
profile: {
|
|
displayName: persona?.display_name ?? "E2E Agent",
|
|
avatarUrl: persona?.avatar_url ?? null,
|
|
},
|
|
memory: {
|
|
level: input.memoryLevel,
|
|
entries: [],
|
|
},
|
|
};
|
|
const fileBytes = Array.from(
|
|
new TextEncoder().encode(JSON.stringify(snapshot)),
|
|
);
|
|
return {
|
|
fileBytes,
|
|
fileName: "e2e-agent.agent.json",
|
|
};
|
|
}
|
|
return {
|
|
fileBytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
|
|
fileName: "e2e-agent.agent.png",
|
|
};
|
|
}
|
|
case "preview_agent_snapshot_import": {
|
|
// Return a minimal preview — no writes performed.
|
|
return {
|
|
displayName: "Imported Agent",
|
|
isBuiltIn: true,
|
|
model: "claude-opus-4-5",
|
|
runtime: "goose",
|
|
systemPrompt: null,
|
|
avatarUrl: null,
|
|
memoryLevel: "none",
|
|
memoryEntryCount: 0,
|
|
hasSourceAllowlist: false,
|
|
sourceAllowlistCount: 0,
|
|
sourceAllowlist: [],
|
|
manifestJson: "{}",
|
|
locked: false,
|
|
};
|
|
}
|
|
case "confirm_agent_snapshot_import": {
|
|
// Return a successful import result with fresh synthetic keys.
|
|
const importResult = {
|
|
displayName: "Imported Agent",
|
|
newPubkey:
|
|
"e2e000000000000000000000000000000000000000000000000000000000000ff",
|
|
personaId: `e2e-persona-${Date.now()}`,
|
|
memoryWritten: 0,
|
|
memoryTotal: 0,
|
|
memoryErrors: [],
|
|
profileSyncError: null,
|
|
};
|
|
return importResult;
|
|
}
|
|
case "export_team_snapshot":
|
|
// Mimics the save-to-disk path: report success without a real dialog.
|
|
return true;
|
|
case "encode_team_snapshot_for_send": {
|
|
// Return a minimal PNG-shaped payload so the send flow can proceed
|
|
// through upload_media_bytes without a real Rust encode step.
|
|
const encodeDelayMs = activeConfig?.mock?.encodeDelayMs ?? 0;
|
|
if (encodeDelayMs > 0) {
|
|
await new Promise((resolve) =>
|
|
window.setTimeout(resolve, encodeDelayMs),
|
|
);
|
|
}
|
|
return {
|
|
fileBytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
|
|
fileName: "e2e-team.team.png",
|
|
};
|
|
}
|
|
case "preview_team_snapshot_import": {
|
|
// Return a minimal preview — no writes performed.
|
|
const previewHasAllowlist =
|
|
activeConfig?.mock?.teamSnapshotPreviewHasSourceAllowlist ?? false;
|
|
return {
|
|
name: "Imported Team",
|
|
description: null,
|
|
instructions: null,
|
|
members: [
|
|
{
|
|
displayName: "Team Member",
|
|
systemPrompt: null,
|
|
avatarUrl: null,
|
|
hasSourceAllowlist: previewHasAllowlist,
|
|
sourceAllowlistCount: previewHasAllowlist ? 3 : 0,
|
|
},
|
|
],
|
|
hasSourceAllowlist: previewHasAllowlist,
|
|
};
|
|
}
|
|
case "confirm_team_snapshot_import": {
|
|
// Sequenced failure injection: fail once, then succeed (retry test).
|
|
const confirmSequence = activeConfig?.mock?.teamSnapshotConfirmErrors;
|
|
if (confirmSequence && confirmSequence.length > 0) {
|
|
const idx = Math.min(
|
|
teamSnapshotConfirmCallCount,
|
|
confirmSequence.length - 1,
|
|
);
|
|
teamSnapshotConfirmCallCount++;
|
|
const entry = confirmSequence[idx];
|
|
if (entry !== null) {
|
|
throw new Error(entry);
|
|
}
|
|
}
|
|
// Return a successful import result with fresh synthetic keys.
|
|
// The nested `team` uses snake_case (Rust TeamRecord has no rename_all);
|
|
// the outer struct and members use camelCase (their Rust types do).
|
|
const importTs = Date.now();
|
|
return {
|
|
team: {
|
|
id: `e2e-team-${importTs}`,
|
|
name: "Imported Team",
|
|
description: null,
|
|
persona_ids: [`e2e-persona-${importTs}`],
|
|
instructions: null,
|
|
is_builtin: false,
|
|
source_dir: null,
|
|
is_symlink: false,
|
|
symlink_target: null,
|
|
version: null,
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
},
|
|
personaIds: [`e2e-persona-${importTs}`],
|
|
members: [
|
|
{
|
|
displayName: "Team Member",
|
|
pubkey:
|
|
"e2e000000000000000000000000000000000000000000000000000000000000ee",
|
|
personaId: `e2e-persona-${importTs}`,
|
|
memoryWritten: 0,
|
|
memoryTotal: 0,
|
|
memoryErrors: [],
|
|
profileSyncError: null,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
case "list_managed_agents":
|
|
return handleListManagedAgents(activeConfig);
|
|
case "get_agent_memory":
|
|
return handleGetAgentMemory(
|
|
(payload as Parameters<typeof handleGetAgentMemory>[0]) ?? {},
|
|
activeConfig,
|
|
);
|
|
case "create_managed_agent":
|
|
return handleCreateManagedAgent(
|
|
payload as Parameters<typeof handleCreateManagedAgent>[0],
|
|
activeConfig,
|
|
);
|
|
case "start_managed_agent":
|
|
// The settled marker (distinct from the invocation entry, so exact-
|
|
// match commandCount("start_managed_agent") is unaffected) lets a
|
|
// spec wait deterministically for a delayed start to resolve or
|
|
// reject — required to assert the *absence* of the failure toast,
|
|
// which is only falsifiable once the rejection is known to have
|
|
// landed.
|
|
return handleStartManagedAgent(
|
|
payload as Parameters<typeof handleStartManagedAgent>[0],
|
|
activeConfig,
|
|
).finally(() => {
|
|
window.__BUZZ_E2E_COMMANDS__?.push("start_managed_agent:settled");
|
|
});
|
|
case "stop_managed_agent":
|
|
return handleStopManagedAgent(
|
|
payload as Parameters<typeof handleStopManagedAgent>[0],
|
|
);
|
|
case "list_managed_agent_runtimes":
|
|
return mockManagedAgentRuntimes.map((row) => ({ ...row }));
|
|
case "get_bestie_assignment":
|
|
return mockBestieAssignment ? { ...mockBestieAssignment } : null;
|
|
case "assign_bestie": {
|
|
const agentPubkey = (payload as { agentPubkey: string }).agentPubkey;
|
|
const agent = getMockManagedAgent(agentPubkey);
|
|
if (agent.backend.type !== "local") {
|
|
throw new Error("only a local managed agent can be your Bestie");
|
|
}
|
|
mockBestieAssignment = {
|
|
agent_pubkey: agent.pubkey,
|
|
};
|
|
return { ...mockBestieAssignment };
|
|
}
|
|
case "clear_bestie_assignment":
|
|
mockBestieAssignment = null;
|
|
return undefined;
|
|
case "resolve_bestie_conversation": {
|
|
if (!mockBestieAssignment) {
|
|
throw new Error("choose an agent before opening Bestie");
|
|
}
|
|
const channel = await handleOpenDm(
|
|
{ pubkeys: [mockBestieAssignment.agent_pubkey] },
|
|
activeConfig,
|
|
);
|
|
return channel;
|
|
}
|
|
case "start_managed_agent_runtime":
|
|
return handleManagedAgentRuntimeAction(
|
|
"start",
|
|
payload as { pubkey: string; relayUrl: string },
|
|
);
|
|
case "stop_managed_agent_runtime":
|
|
return handleManagedAgentRuntimeAction(
|
|
"stop",
|
|
payload as { pubkey: string; relayUrl: string },
|
|
);
|
|
case "restart_managed_agent_runtime":
|
|
return handleManagedAgentRuntimeAction(
|
|
"restart",
|
|
payload as { pubkey: string; relayUrl: string },
|
|
);
|
|
case "reconcile_managed_agent_runtimes":
|
|
// Post-create bootstrap reconcile: no new pairs in the mock world.
|
|
return [];
|
|
case "set_agent_managed_profiles":
|
|
case "set_agent_avatar_communities":
|
|
return undefined;
|
|
case "set_managed_agent_auto_restart":
|
|
return handleSetManagedAgentAutoRestart(
|
|
payload as Parameters<typeof handleSetManagedAgentAutoRestart>[0],
|
|
);
|
|
case "set_managed_agent_start_on_app_launch":
|
|
return handleSetManagedAgentStartOnAppLaunch(
|
|
payload as Parameters<
|
|
typeof handleSetManagedAgentStartOnAppLaunch
|
|
>[0],
|
|
);
|
|
case "delete_managed_agent":
|
|
return handleDeleteManagedAgent(
|
|
payload as Parameters<typeof handleDeleteManagedAgent>[0],
|
|
);
|
|
case "get_managed_agent_log":
|
|
return handleGetManagedAgentLog(
|
|
payload as Parameters<typeof handleGetManagedAgentLog>[0],
|
|
);
|
|
case "get_agent_models":
|
|
return {
|
|
agentName: "mock-agent",
|
|
agentVersion: "0.0.0",
|
|
models: [],
|
|
agentDefaultModel: null,
|
|
selectedModel: null,
|
|
supportsSwitching: false,
|
|
};
|
|
case "discover_agent_models": {
|
|
const discoverError = activeConfig?.mock?.discoverAgentModelsError;
|
|
if (discoverError) {
|
|
throw new Error(discoverError);
|
|
}
|
|
const discoverOverride = activeConfig?.mock?.discoverAgentModels;
|
|
if (discoverOverride) {
|
|
return {
|
|
agentName: "mock-agent",
|
|
agentVersion: "0.0.0",
|
|
models: discoverOverride.models.map((model) => ({
|
|
id: model.id,
|
|
name: model.name,
|
|
description: model.description ?? null,
|
|
})),
|
|
agentDefaultModel: discoverOverride.agentDefaultModel ?? null,
|
|
selectedModel: discoverOverride.selectedModel ?? null,
|
|
supportsSwitching: discoverOverride.supportsSwitching,
|
|
};
|
|
}
|
|
const input = (
|
|
payload as {
|
|
input?: { agentCommand?: string; provider?: string };
|
|
} | null
|
|
)?.input;
|
|
const agentCommand = input?.agentCommand?.trim() ?? "";
|
|
const provider = input?.provider?.trim() ?? "";
|
|
const openAiModels = [
|
|
{ id: "gpt-5.5", name: "GPT-5.5", description: null },
|
|
{ id: "gpt-5.4", name: "GPT-5.4", description: null },
|
|
{ id: "gpt-5.4-mini", name: "GPT-5.4 mini", description: null },
|
|
{ id: "gpt-5.4-nano", name: "GPT-5.4 nano", description: null },
|
|
];
|
|
const anthropicModels = [
|
|
{
|
|
id: "goose-claude-4-6-opus",
|
|
name: "Claude Opus 4.6",
|
|
description: null,
|
|
},
|
|
{
|
|
id: "goose-claude-4-6-sonnet",
|
|
name: "Claude Sonnet 4.6",
|
|
description: null,
|
|
},
|
|
];
|
|
const claudeRuntimeModels = [
|
|
{
|
|
id: "claude-sonnet-4-20250514",
|
|
name: "Claude Sonnet 4",
|
|
description: null,
|
|
},
|
|
{
|
|
id: "claude-opus-4-20250514",
|
|
name: "Claude Opus 4",
|
|
description: null,
|
|
},
|
|
];
|
|
const codexRuntimeModels = [
|
|
{ id: "gpt-5.5", name: "GPT-5.5", description: null },
|
|
{ id: "gpt-5.5[low]", name: "GPT-5.5 (low)", description: null },
|
|
{
|
|
id: "gpt-5.5[medium]",
|
|
name: "GPT-5.5 (medium)",
|
|
description: null,
|
|
},
|
|
{ id: "gpt-5.5[high]", name: "GPT-5.5 (high)", description: null },
|
|
];
|
|
if (provider === "relay-mesh") {
|
|
if (!mockMeshState.admitted) {
|
|
throw new Error(mockMeshState.denyReason);
|
|
}
|
|
if (mockMeshState.models.length === 0) {
|
|
throw new Error(
|
|
"no Buzz shared compute serving members are available",
|
|
);
|
|
}
|
|
}
|
|
const models =
|
|
provider === "relay-mesh"
|
|
? mockMeshState.models.map((model) => ({
|
|
id: model.id,
|
|
name: model.name,
|
|
description: null,
|
|
}))
|
|
: provider === "openai"
|
|
? openAiModels
|
|
: provider === "anthropic"
|
|
? anthropicModels
|
|
: agentCommand.includes("claude")
|
|
? claudeRuntimeModels
|
|
: agentCommand.includes("codex")
|
|
? codexRuntimeModels
|
|
: [...anthropicModels, ...openAiModels];
|
|
return {
|
|
agentName: "mock-agent",
|
|
agentVersion: "0.0.0",
|
|
models,
|
|
agentDefaultModel: agentCommand.includes("codex")
|
|
? "gpt-5.5[high]"
|
|
: null,
|
|
selectedModel: null,
|
|
supportsSwitching: true,
|
|
};
|
|
}
|
|
case "get_agent_config_surface": {
|
|
const configArgs = payload as { pubkey: string };
|
|
return buildMockConfigSurface(configArgs.pubkey);
|
|
}
|
|
case "get_runtime_file_config": {
|
|
const runtimeId = (payload as { runtimeId?: string } | null | undefined)
|
|
?.runtimeId;
|
|
if (!runtimeId) return null;
|
|
return config.mock?.runtimeFileConfigs?.[runtimeId] ?? null;
|
|
}
|
|
case "get_global_agent_config": {
|
|
// Return the mutable persisted mock value, seeded from the test config.
|
|
return (
|
|
mockGlobalAgentConfig ?? {
|
|
env_vars: {},
|
|
provider: null,
|
|
model: null,
|
|
preferred_runtime: null,
|
|
}
|
|
);
|
|
}
|
|
case "get_global_agent_config_set_call_count":
|
|
return setGlobalAgentConfigCallCount;
|
|
case "set_global_agent_config": {
|
|
setGlobalAgentConfigCallCount += 1;
|
|
const saveErrors = activeConfig?.mock?.setGlobalAgentConfigErrors;
|
|
const saveError =
|
|
saveErrors?.[
|
|
Math.min(setGlobalAgentConfigCallCount - 1, saveErrors.length - 1)
|
|
];
|
|
if (saveError) throw new Error(saveError);
|
|
// Echo back the submitted config as the saved value (mirrors the
|
|
// backend's strip-on-write pass in tests where all values are already
|
|
// non-empty). The invoke payload wraps it as { config }.
|
|
const savedConfig = (
|
|
payload as {
|
|
config: {
|
|
env_vars: Record<string, string>;
|
|
provider: string | null;
|
|
model: string | null;
|
|
preferred_runtime: string | null;
|
|
};
|
|
}
|
|
).config;
|
|
// Optional configurable delay so specs can hold a save in flight and
|
|
// interleave edits (mid-save race + autosave-coalescing coverage).
|
|
// Two aliases: onboarding specs use setGlobalAgentConfigDelayMs,
|
|
// settings-card specs use globalConfigSaveDelayMs.
|
|
const saveDelayMs =
|
|
config?.mock?.globalConfigSaveDelayMs ??
|
|
activeConfig?.mock?.setGlobalAgentConfigDelayMs ??
|
|
0;
|
|
if (saveDelayMs > 0) {
|
|
await new Promise((resolve) => setTimeout(resolve, saveDelayMs));
|
|
}
|
|
mockGlobalAgentConfig = savedConfig;
|
|
// In the E2E environment there are no running agents to restart, so
|
|
// the counts default to 0 unless a spec drives them explicitly.
|
|
return {
|
|
config: savedConfig,
|
|
restarted_count: config?.mock?.globalConfigRestartedCount ?? 0,
|
|
failed_restart_count:
|
|
config?.mock?.globalConfigFailedRestartCount ?? 0,
|
|
};
|
|
}
|
|
case "get_baked_build_env": {
|
|
const bakedEnvDelayMs = config?.mock?.bakedBuildEnvDelayMs ?? 0;
|
|
if (bakedEnvDelayMs > 0) {
|
|
await new Promise((resolve) =>
|
|
window.setTimeout(resolve, bakedEnvDelayMs),
|
|
);
|
|
}
|
|
return config?.mock?.bakedBuildEnv ?? [];
|
|
}
|
|
case "get_baked_build_env_keys":
|
|
return (config?.mock?.bakedBuildEnv ?? []).map((entry) => entry.key);
|
|
case "agent_access_owner_only":
|
|
return config?.mock?.ownerOnlyAccessBuild ?? false;
|
|
case "update_managed_agent":
|
|
return handleUpdateManagedAgent(
|
|
payload as Parameters<typeof handleUpdateManagedAgent>[0],
|
|
);
|
|
case "create_channel":
|
|
return handleCreateChannel(
|
|
payload as Parameters<typeof handleCreateChannel>[0],
|
|
activeConfig,
|
|
);
|
|
case "ensure_starter_channels":
|
|
return handleEnsureStarterChannels(activeConfig);
|
|
case "open_dm":
|
|
return handleOpenDm(
|
|
payload as Parameters<typeof handleOpenDm>[0],
|
|
activeConfig,
|
|
);
|
|
case "hide_dm":
|
|
return handleHideDm(
|
|
payload as Parameters<typeof handleHideDm>[0],
|
|
activeConfig,
|
|
);
|
|
case "get_channel_details":
|
|
return handleGetChannelDetails(
|
|
payload as Parameters<typeof handleGetChannelDetails>[0],
|
|
activeConfig,
|
|
);
|
|
case "get_channel_members":
|
|
return handleGetChannelMembers(
|
|
payload as Parameters<typeof handleGetChannelMembers>[0],
|
|
activeConfig,
|
|
);
|
|
case "update_channel":
|
|
return handleUpdateChannel(
|
|
(payload as { input: Parameters<typeof handleUpdateChannel>[0] })
|
|
.input,
|
|
activeConfig,
|
|
);
|
|
case "set_channel_topic":
|
|
return handleSetChannelTopic(
|
|
payload as Parameters<typeof handleSetChannelTopic>[0],
|
|
activeConfig,
|
|
);
|
|
case "set_channel_purpose":
|
|
return handleSetChannelPurpose(
|
|
payload as Parameters<typeof handleSetChannelPurpose>[0],
|
|
activeConfig,
|
|
);
|
|
case "archive_channel":
|
|
return handleArchiveChannel(
|
|
payload as Parameters<typeof handleArchiveChannel>[0],
|
|
activeConfig,
|
|
);
|
|
case "unarchive_channel":
|
|
return handleUnarchiveChannel(
|
|
payload as Parameters<typeof handleUnarchiveChannel>[0],
|
|
activeConfig,
|
|
);
|
|
case "delete_channel":
|
|
return handleDeleteChannel(
|
|
payload as Parameters<typeof handleDeleteChannel>[0],
|
|
activeConfig,
|
|
);
|
|
case "add_channel_members":
|
|
return handleAddChannelMembers(
|
|
payload as Parameters<typeof handleAddChannelMembers>[0],
|
|
activeConfig,
|
|
);
|
|
case "remove_channel_member":
|
|
return handleRemoveChannelMember(
|
|
payload as Parameters<typeof handleRemoveChannelMember>[0],
|
|
activeConfig,
|
|
);
|
|
case "join_channel":
|
|
return handleJoinChannel(
|
|
payload as Parameters<typeof handleJoinChannel>[0],
|
|
activeConfig,
|
|
);
|
|
case "leave_channel":
|
|
return handleLeaveChannel(
|
|
payload as Parameters<typeof handleLeaveChannel>[0],
|
|
activeConfig,
|
|
);
|
|
case "search_messages":
|
|
return handleSearchMessages(
|
|
payload as Parameters<typeof handleSearchMessages>[0],
|
|
activeConfig,
|
|
);
|
|
case "get_forum_posts":
|
|
return handleGetForumPosts(
|
|
payload as Parameters<typeof handleGetForumPosts>[0],
|
|
);
|
|
case "get_forum_thread":
|
|
return handleGetForumThread(
|
|
payload as Parameters<typeof handleGetForumThread>[0],
|
|
);
|
|
case "get_thread_replies":
|
|
return handleGetThreadReplies(
|
|
payload as Parameters<typeof handleGetThreadReplies>[0],
|
|
activeConfig,
|
|
);
|
|
case "get_channel_messages_before":
|
|
return handleGetChannelMessagesBefore(
|
|
payload as Parameters<typeof handleGetChannelMessagesBefore>[0],
|
|
activeConfig,
|
|
);
|
|
case "get_channel_reconnect_repair":
|
|
return handleGetChannelReconnectRepair(
|
|
payload as Parameters<typeof handleGetChannelReconnectRepair>[0],
|
|
activeConfig,
|
|
);
|
|
case "get_channel_window":
|
|
return handleGetChannelWindow(
|
|
payload as Parameters<typeof handleGetChannelWindow>[0],
|
|
activeConfig,
|
|
);
|
|
case "send_channel_message":
|
|
return handleSendChannelMessage(
|
|
payload as Parameters<typeof handleSendChannelMessage>[0],
|
|
activeConfig,
|
|
);
|
|
case "has_managed_agent_channel_message_marker": {
|
|
const args = payload as {
|
|
channelId: string;
|
|
marker: string;
|
|
agentPubkey?: string | null;
|
|
markerScope?: "agent" | "channel" | null;
|
|
};
|
|
return getMockMessageStore(args.channelId).some(
|
|
(event) =>
|
|
(args.markerScope === "channel" ||
|
|
event.pubkey === args.agentPubkey) &&
|
|
event.tags.some(
|
|
(tag) => tag[0] === "client" && tag[1] === args.marker,
|
|
),
|
|
);
|
|
}
|
|
case "send_managed_agent_channel_message":
|
|
return handleSendManagedAgentChannelMessage(
|
|
payload as Parameters<typeof handleSendManagedAgentChannelMessage>[0],
|
|
activeConfig,
|
|
);
|
|
case "delete_message":
|
|
handleDeleteMessage(
|
|
payload as Parameters<typeof handleDeleteMessage>[0],
|
|
activeConfig,
|
|
);
|
|
return null;
|
|
case "edit_message":
|
|
return handleEditMessage(
|
|
(payload as { input: Parameters<typeof handleEditMessage>[0] }).input,
|
|
activeConfig,
|
|
);
|
|
case "add_reaction":
|
|
return handleAddReaction(
|
|
payload as Parameters<typeof handleAddReaction>[0],
|
|
activeConfig,
|
|
);
|
|
case "remove_reaction":
|
|
return handleRemoveReaction(
|
|
payload as Parameters<typeof handleRemoveReaction>[0],
|
|
activeConfig,
|
|
);
|
|
case "get_media_proxy_port":
|
|
return mockMediaProxyPort;
|
|
case "pick_and_upload_media":
|
|
return await resolveMockUploadDescriptors(activeConfig);
|
|
case "pick_and_upload_image":
|
|
return (await resolveMockUploadDescriptors(activeConfig))[0] ?? null;
|
|
case "upload_media_bytes": {
|
|
const input = payload as {
|
|
data: number[];
|
|
filename?: string | null;
|
|
progressId?: string | null;
|
|
};
|
|
if (
|
|
activeConfig?.mock?.deferLinkPreviewUploadRegistration &&
|
|
input.filename?.startsWith("link-preview-")
|
|
) {
|
|
await new Promise<void>((resolve) => {
|
|
deferredLinkPreviewUploadQueue.push(resolve);
|
|
});
|
|
}
|
|
if (input.progressId && cancelledMediaUploadIds.has(input.progressId)) {
|
|
throw new Error("upload cancelled");
|
|
}
|
|
if (input.filename?.startsWith("link-preview-")) {
|
|
window.__BUZZ_E2E_LINK_PREVIEW_UPLOAD_STARTS__ =
|
|
(window.__BUZZ_E2E_LINK_PREVIEW_UPLOAD_STARTS__ ?? 0) + 1;
|
|
}
|
|
return resolveMockUploadDescriptorForBytes(input, activeConfig);
|
|
}
|
|
case "cancel_media_upload": {
|
|
const progressId = (payload as { progressId?: string }).progressId;
|
|
if (progressId) cancelledMediaUploadIds.add(progressId);
|
|
return null;
|
|
}
|
|
case "release_media_upload": {
|
|
const progressId = (payload as { progressId?: string }).progressId;
|
|
if (progressId) cancelledMediaUploadIds.delete(progressId);
|
|
return null;
|
|
}
|
|
case "upload_media_bytes_raw":
|
|
return resolveMockUploadDescriptorForBytes(
|
|
{
|
|
data: payload as Uint8Array,
|
|
},
|
|
activeConfig,
|
|
);
|
|
case "fetch_media_bytes": {
|
|
// The real command fetches relay media through Rust reqwest and
|
|
// replies with raw bytes (`tauri::ipc::Response` → ArrayBuffer). In
|
|
// E2E the browser fetch suffices — specs serve the URL via page.route.
|
|
const input = payload as { requestId?: string; url: string };
|
|
const requestId = input.requestId ?? crypto.randomUUID();
|
|
const controller = new AbortController();
|
|
mockMediaFetchControllers.set(requestId, controller);
|
|
if (cancelledMediaFetchIds.has(requestId)) controller.abort();
|
|
if (!window.__BUZZ_E2E_MEDIA_FETCH_STATE__) {
|
|
window.__BUZZ_E2E_MEDIA_FETCH_STATE__ = { active: 0, peak: 0 };
|
|
}
|
|
const stats = window.__BUZZ_E2E_MEDIA_FETCH_STATE__;
|
|
stats.active += 1;
|
|
stats.peak = Math.max(stats.peak, stats.active);
|
|
try {
|
|
if (window.__BUZZ_E2E_HOLD_MEDIA_FETCHES__) {
|
|
await new Promise<never>((_resolve, reject) => {
|
|
const rejectCancelled = () =>
|
|
reject(new DOMException("fetch cancelled", "AbortError"));
|
|
if (controller.signal.aborted) {
|
|
rejectCancelled();
|
|
return;
|
|
}
|
|
controller.signal.addEventListener("abort", rejectCancelled, {
|
|
once: true,
|
|
});
|
|
});
|
|
}
|
|
const response = await fetch(input.url, {
|
|
signal: controller.signal,
|
|
});
|
|
if (!response.ok) throw new Error(`fetch failed: ${response.status}`);
|
|
return await response.arrayBuffer();
|
|
} finally {
|
|
stats.active -= 1;
|
|
mockMediaFetchControllers.delete(requestId);
|
|
}
|
|
}
|
|
case "cancel_media_fetch": {
|
|
const requestId = (payload as { requestId?: string }).requestId;
|
|
if (requestId) {
|
|
cancelledMediaFetchIds.add(requestId);
|
|
mockMediaFetchControllers.get(requestId)?.abort();
|
|
}
|
|
return null;
|
|
}
|
|
case "release_media_fetch": {
|
|
const requestId = (payload as { requestId?: string }).requestId;
|
|
if (requestId) {
|
|
cancelledMediaFetchIds.delete(requestId);
|
|
mockMediaFetchControllers.delete(requestId);
|
|
}
|
|
return null;
|
|
}
|
|
case "fetch_snapshot_bytes": {
|
|
// The real command fetches + validates a snapshot attachment in memory
|
|
// (size cap, SHA-256, decode). In E2E the bridge returns a minimal
|
|
// valid .agent.json payload so the import flow can proceed without a
|
|
// real relay. A non-null snapshotFetchError config forces a rejection.
|
|
const err = activeConfig?.mock?.snapshotFetchError;
|
|
if (err) throw new Error(err);
|
|
const jsonBytes = Array.from(
|
|
new TextEncoder().encode(
|
|
JSON.stringify({
|
|
format: "buzz-agent-snapshot",
|
|
version: 1,
|
|
definition: { system_prompt: "E2E imported agent prompt." },
|
|
profile: { display_name: "Imported Agent" },
|
|
memory: { level: "none", entries: [] },
|
|
}),
|
|
),
|
|
);
|
|
// Return as ArrayBuffer to mirror the real Tauri ipc::Response.
|
|
const buf = new ArrayBuffer(jsonBytes.length);
|
|
new Uint8Array(buf).set(jsonBytes);
|
|
return buf;
|
|
}
|
|
case "download_image":
|
|
case "save_png_data_url":
|
|
case "download_file":
|
|
case "save_agent_card":
|
|
// The save dialog can't run headlessly; report a successful save so the
|
|
// FileCard / image-menu click handlers resolve. Specs assert the
|
|
// command was invoked via `__BUZZ_E2E_COMMANDS__`, not the dialog.
|
|
return true;
|
|
case "card_mint_key_status":
|
|
// Cards: pretend a key is configured in global defaults so the mint
|
|
// form renders and the key-status row is shown.
|
|
return "global";
|
|
case "list_agent_cards":
|
|
// Cards archive starts empty in E2E; specs exercising the gallery
|
|
// can extend this with a seeded config knob when needed.
|
|
return [];
|
|
case "copy_image_to_clipboard":
|
|
return;
|
|
case "copy_text_to_clipboard":
|
|
await writeClipboardFlavors(payload as { html?: string; text: string });
|
|
return;
|
|
case "read_clipboard_text":
|
|
return navigator.clipboard.readText();
|
|
case "get_event":
|
|
return handleGetEvent(
|
|
payload as Parameters<typeof handleGetEvent>[0],
|
|
activeConfig,
|
|
);
|
|
case "get_events":
|
|
return Promise.all(
|
|
(payload as { eventIds: string[] }).eventIds.map(
|
|
async (eventId) =>
|
|
JSON.parse(
|
|
await handleGetEvent({ eventId }, activeConfig),
|
|
) as RelayEvent,
|
|
),
|
|
);
|
|
case "sign_event":
|
|
window.__BUZZ_E2E_SIGNED_EVENTS__?.push({
|
|
content: (payload as { content: string }).content,
|
|
createdAt: (payload as { createdAt?: number }).createdAt,
|
|
kind: (payload as { kind: number }).kind,
|
|
tags: (payload as { tags: string[][] }).tags,
|
|
});
|
|
if (identity) {
|
|
return JSON.stringify(
|
|
await signWithIdentity(identity, {
|
|
kind: (payload as { kind: number }).kind,
|
|
content: (payload as { content: string }).content,
|
|
createdAt: (payload as { createdAt?: number }).createdAt,
|
|
tags: (payload as { tags: string[][] }).tags,
|
|
}),
|
|
);
|
|
}
|
|
|
|
return JSON.stringify(
|
|
createMockEvent(
|
|
(payload as { kind: number }).kind,
|
|
(payload as { content: string }).content,
|
|
(payload as { tags: string[][] }).tags,
|
|
DEFAULT_MOCK_IDENTITY.pubkey,
|
|
(payload as { createdAt?: number }).createdAt,
|
|
),
|
|
);
|
|
case "nip44_encrypt_to_self":
|
|
return (payload as { plaintext: string }).plaintext;
|
|
case "nip44_decrypt_from_self":
|
|
return (payload as { ciphertext: string }).ciphertext;
|
|
case "create_auth_event":
|
|
mockAuthSigningAttempts++;
|
|
if (
|
|
getConfig()?.mock?.stallFirstAuthSigning &&
|
|
mockAuthSigningAttempts === 1
|
|
) {
|
|
return new Promise<string>(() => {});
|
|
}
|
|
if (identity) {
|
|
return JSON.stringify(
|
|
await signWithIdentity(identity, {
|
|
kind: 22242,
|
|
content: "",
|
|
tags: [
|
|
["relay", (payload as { relayUrl: string }).relayUrl],
|
|
["challenge", (payload as { challenge: string }).challenge],
|
|
],
|
|
}),
|
|
);
|
|
}
|
|
|
|
return JSON.stringify(
|
|
createMockEvent(22242, "", [
|
|
["relay", (payload as { relayUrl: string }).relayUrl],
|
|
["challenge", (payload as { challenge: string }).challenge],
|
|
]),
|
|
);
|
|
case "plugin:websocket|connect":
|
|
if (isRelayMode(activeConfig)) {
|
|
return connectRealSocket(
|
|
payload as Parameters<typeof connectRealSocket>[0],
|
|
);
|
|
}
|
|
|
|
return connectMockSocket(
|
|
payload as Parameters<typeof connectMockSocket>[0],
|
|
);
|
|
case "plugin:websocket|disconnect": {
|
|
const { id } = payload as { id: number };
|
|
if (isRelayMode(activeConfig)) {
|
|
realSockets.get(id)?.close();
|
|
realSockets.delete(id);
|
|
} else {
|
|
const socket = mockSockets.get(id);
|
|
mockSockets.delete(id);
|
|
if (socket) sendWsClose(socket.handler);
|
|
}
|
|
return null;
|
|
}
|
|
case "plugin:websocket|disconnect_all":
|
|
for (const socket of realSockets.values()) socket.close();
|
|
realSockets.clear();
|
|
for (const socket of mockSockets.values()) sendWsClose(socket.handler);
|
|
mockSockets.clear();
|
|
mockWebsocketSendMutexWedged = false;
|
|
return null;
|
|
case "plugin:websocket|send":
|
|
if (isRelayMode(activeConfig)) {
|
|
return sendToRealSocket(
|
|
payload as Parameters<typeof sendToRealSocket>[0],
|
|
);
|
|
}
|
|
|
|
return sendToMockSocket(
|
|
payload as Parameters<typeof sendToMockSocket>[0],
|
|
);
|
|
case "plugin:opener|open_url":
|
|
if (activeConfig?.mock?.openerError) {
|
|
throw new Error(activeConfig.mock.openerError);
|
|
}
|
|
openedExternalUrls.push(String((payload as { url: string | URL }).url));
|
|
return null;
|
|
case "get_e2e_opened_external_urls":
|
|
return [...openedExternalUrls];
|
|
case "clear_e2e_opened_external_urls":
|
|
openedExternalUrls.length = 0;
|
|
return null;
|
|
case "plugin:window|show":
|
|
case "plugin:window|unminimize":
|
|
case "plugin:window|set_focus":
|
|
case "plugin:window|set_badge_count":
|
|
case "plugin:window|set_badge_label":
|
|
return null;
|
|
case "plugin:updater|check":
|
|
return handleUpdaterCheck(activeConfig);
|
|
case "plugin:updater|download":
|
|
return handleUpdaterDownload(payload, activeConfig);
|
|
case "plugin:updater|install":
|
|
return handleUpdaterInstall();
|
|
case "is_auto_update_supported":
|
|
// Default true so all existing tests continue to use the auto-update
|
|
// path. Set mock.autoUpdateSupported: false to simulate a .deb install.
|
|
return activeConfig?.mock?.autoUpdateSupported !== false;
|
|
case "relay_reconnect_hook":
|
|
return null;
|
|
case "relay_reconnect_hook_configured":
|
|
return false;
|
|
case "plugin:resources|close":
|
|
return null;
|
|
case "plugin:process|restart":
|
|
return handleRestart(activeConfig);
|
|
case "get_channel_workflows":
|
|
return handleGetChannelWorkflows(
|
|
payload as Parameters<typeof handleGetChannelWorkflows>[0],
|
|
);
|
|
case "get_channels_workflows":
|
|
return handleGetChannelsWorkflows(
|
|
payload as Parameters<typeof handleGetChannelsWorkflows>[0],
|
|
);
|
|
case "get_workflow":
|
|
return handleGetWorkflow(
|
|
payload as Parameters<typeof handleGetWorkflow>[0],
|
|
);
|
|
case "create_workflow":
|
|
return handleCreateWorkflow(
|
|
payload as Parameters<typeof handleCreateWorkflow>[0],
|
|
);
|
|
case "update_workflow":
|
|
return handleUpdateWorkflow(
|
|
payload as Parameters<typeof handleUpdateWorkflow>[0],
|
|
);
|
|
case "delete_workflow":
|
|
return handleDeleteWorkflow(
|
|
payload as Parameters<typeof handleDeleteWorkflow>[0],
|
|
);
|
|
case "trigger_workflow":
|
|
return handleTriggerWorkflow(
|
|
payload as Parameters<typeof handleTriggerWorkflow>[0],
|
|
);
|
|
case "get_workflow_runs":
|
|
return handleGetWorkflowRuns(
|
|
payload as Parameters<typeof handleGetWorkflowRuns>[0],
|
|
);
|
|
case "get_run_approvals":
|
|
return handleGetRunApprovals(
|
|
payload as Parameters<typeof handleGetRunApprovals>[0],
|
|
);
|
|
case "plugin:webview|set_webview_zoom":
|
|
window.__BUZZ_E2E_WEBVIEW_ZOOM__ = (payload as { value: number }).value;
|
|
return;
|
|
case "start_pairing": {
|
|
const delayMs = activeConfig?.mock?.pairingStartDelayMs ?? 0;
|
|
if (delayMs > 0) {
|
|
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
|
|
}
|
|
return "nostrpair://8f4b8db31967ce14fef970a1ff1e8eecf19a430aa1c83875e2f5be68dcac0f1a?relay=wss%3A%2F%2Frelay.example.com&secret=87d5a8cfd5807a0cb44f728b67d88d6dcb8daf99be137c158f21a50c1e913c0a&v=1";
|
|
}
|
|
case "start_identity_recovery_pairing": {
|
|
const delayMs = activeConfig?.mock?.pairingStartDelayMs ?? 0;
|
|
if (delayMs > 0) {
|
|
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
|
|
}
|
|
return `nostrpair://8f4b8db31967ce14fef970a1ff1e8eecf19a430aa1c83875e2f5be68dcac0f1a?relay=wss%3A%2F%2Frelay.example.com&secret=87d5a8cfd5807a0cb44f728b67d88d6dcb8daf99be137c158f21a50c1e913c0a&v=1&mode=recover`;
|
|
}
|
|
case "cancel_pairing":
|
|
case "confirm_pairing_sas":
|
|
return null;
|
|
case "complete_identity_recovery_pairing":
|
|
mockIdentityLostCleared = true;
|
|
await emit("pairing-complete", {});
|
|
return null;
|
|
// ── NIP-IA identity archival ────────────────────────────────────────
|
|
// These mocks drive the archive-button gate matrix in
|
|
// tests/e2e/identity-archive.spec.ts. Defaults keep the button hidden
|
|
// for non-self viewees so the negative case is the unsurprising one.
|
|
case "resolve_oa_owner": {
|
|
const isMe = activeConfig?.mock?.oaOwnerIsMe ?? false;
|
|
const owner = isMe
|
|
? (identity?.pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey)
|
|
: "ff".repeat(32);
|
|
return { owner, is_me: isMe };
|
|
}
|
|
case "list_archived_identities": {
|
|
const archived = activeConfig?.mock?.archivedIdentities ?? [];
|
|
return { archived };
|
|
}
|
|
case "get_relay_self":
|
|
if ((activeConfig?.mock?.relaySelfDelayMs ?? 0) > 0) {
|
|
await new Promise((resolve) =>
|
|
window.setTimeout(
|
|
resolve,
|
|
activeConfig?.mock?.relaySelfDelayMs ?? 0,
|
|
),
|
|
);
|
|
}
|
|
return activeConfig?.mock?.relaySelf ?? null;
|
|
case "archive_identity":
|
|
case "unarchive_identity":
|
|
// The spec only verifies UI state, not the submitted request shape;
|
|
// returning null mirrors the Rust submit_event success path.
|
|
return null;
|
|
case "set_canvas":
|
|
return { ok: true, event_id: mockEventId() };
|
|
case "get_canvas": {
|
|
const canvasReadError = activeConfig?.mock?.canvasReadError;
|
|
if (canvasReadError) {
|
|
throw new Error(canvasReadError);
|
|
}
|
|
// Return the no-canvas success shape — content null means no canvas set.
|
|
return { content: null, updated_at: null, author: null };
|
|
}
|
|
// ── Local-save archive ──────────────────────────────────────────────
|
|
// These stubs drive the LocalArchiveSettingsCard in screenshot / UI tests
|
|
// without requiring a real SQLite backend. `mockSaveSubscriptions` is a
|
|
// mutable clone of `activeConfig.mock.saveSubscriptions` (reset on
|
|
// install); create/merge/delete/remove mutate it with the same
|
|
// union / delete-row-when-empty semantics as the real Rust commands
|
|
// (see `archive/store.rs::merge_owner_p_kinds` / `remove_owner_p_kind`)
|
|
// so specs can drive default-on seeding and toggle ON/OFF flows.
|
|
case "list_save_subscriptions": {
|
|
const win = window as unknown as Record<string, unknown>;
|
|
if (!win.__BUZZ_E2E_IPC_COUNTERS__) {
|
|
win.__BUZZ_E2E_IPC_COUNTERS__ = {};
|
|
}
|
|
const ipcCounters = win.__BUZZ_E2E_IPC_COUNTERS__ as Record<
|
|
string,
|
|
number
|
|
>;
|
|
ipcCounters.list_save_subscriptions =
|
|
(ipcCounters.list_save_subscriptions ?? 0) + 1;
|
|
const ident = activeConfig?.identity ?? DEFAULT_MOCK_IDENTITY;
|
|
return mockSaveSubscriptions.map((s) => ({
|
|
identity_pubkey: ident.pubkey,
|
|
relay_url: DEFAULT_RELAY_WS_URL,
|
|
scope_type: s.scope_type,
|
|
scope_value: s.scope_value,
|
|
kinds: s.kinds,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
}));
|
|
}
|
|
case "create_save_subscription": {
|
|
const req = payload as {
|
|
scopeType: string;
|
|
scopeValue: string;
|
|
kinds: number[];
|
|
};
|
|
const kindsJson = JSON.stringify(req.kinds);
|
|
const existing = mockSaveSubscriptions.find(
|
|
(s) =>
|
|
s.scope_type === req.scopeType && s.scope_value === req.scopeValue,
|
|
);
|
|
if (existing) {
|
|
existing.kinds = kindsJson;
|
|
} else {
|
|
mockSaveSubscriptions.push({
|
|
scope_type: req.scopeType,
|
|
scope_value: req.scopeValue,
|
|
kinds: kindsJson,
|
|
});
|
|
}
|
|
return null;
|
|
}
|
|
case "delete_save_subscription": {
|
|
const req = payload as { scopeType: string; scopeValue: string };
|
|
const before = mockSaveSubscriptions.length;
|
|
mockSaveSubscriptions = mockSaveSubscriptions.filter(
|
|
(s) =>
|
|
!(
|
|
s.scope_type === req.scopeType && s.scope_value === req.scopeValue
|
|
),
|
|
);
|
|
return mockSaveSubscriptions.length < before;
|
|
}
|
|
case "archive_events":
|
|
// Returns the ArchiveBatchResult shape the UI expects.
|
|
return { persisted: 0, dropped: 0 };
|
|
// Archive sync runs natively; the bridge has no relay-backed backend to
|
|
// drive, so these are accepted no-ops. Without them every AppShell mount
|
|
// logs an unknown-command warning once the gate opens.
|
|
//
|
|
// The epoch must return a number rather than null, because it stands in
|
|
// for a value production guarantees is numeric. Nothing here validates
|
|
// it at runtime — the typed wrapper is erased — so a null would not
|
|
// fail loudly; it would flow into `start_archive_sync` as
|
|
// `{ epoch: null }` and this no-op would accept it, leaving the suite
|
|
// exercising a wire shape the real backend never sends. Returning a
|
|
// number keeps the production contract, and the reconciliation-gate
|
|
// spec asserts it: announce precedes start, and start carries a numeric
|
|
// epoch. Mutating this to null fails that spec.
|
|
case "announce_archive_sync_epoch":
|
|
return 1;
|
|
case "start_archive_sync":
|
|
case "stop_archive_sync":
|
|
return null;
|
|
case "fetch_persona_catalog":
|
|
return mockPersonaCatalogPublications();
|
|
case "fetch_team_catalog":
|
|
return mockTeamCatalogPublications();
|
|
case "channel_head_cache_load": {
|
|
const args = payload as {
|
|
scope: { pubkey: string; relayUrl: string };
|
|
limit: number;
|
|
};
|
|
const key = `buzz-e2e-channel-head:${args.scope.pubkey.toLowerCase()}:${args.scope.relayUrl.toLowerCase().replace(/\/$/, "")}`;
|
|
const entries = JSON.parse(
|
|
window.localStorage.getItem(key) ?? "[]",
|
|
) as Array<{
|
|
channelId: string;
|
|
events: RelayEvent[];
|
|
savedAt: number;
|
|
lastVisitedAt: number;
|
|
}>;
|
|
return entries
|
|
.sort((left, right) => right.lastVisitedAt - left.lastVisitedAt)
|
|
.slice(0, args.limit);
|
|
}
|
|
case "channel_head_cache_store": {
|
|
const args = payload as {
|
|
scope: { pubkey: string; relayUrl: string };
|
|
channelId: string;
|
|
events: RelayEvent[];
|
|
};
|
|
const key = `buzz-e2e-channel-head:${args.scope.pubkey.toLowerCase()}:${args.scope.relayUrl.toLowerCase().replace(/\/$/, "")}`;
|
|
const entries = JSON.parse(
|
|
window.localStorage.getItem(key) ?? "[]",
|
|
) as Array<{
|
|
channelId: string;
|
|
events: RelayEvent[];
|
|
savedAt: number;
|
|
lastVisitedAt: number;
|
|
}>;
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const next = entries
|
|
.filter((entry) => entry.channelId !== args.channelId)
|
|
.concat({
|
|
channelId: args.channelId,
|
|
events: args.events,
|
|
savedAt: now,
|
|
lastVisitedAt: now,
|
|
})
|
|
.sort((left, right) => right.lastVisitedAt - left.lastVisitedAt)
|
|
.slice(0, 32);
|
|
window.localStorage.setItem(key, JSON.stringify(next));
|
|
return null;
|
|
}
|
|
case "channel_head_cache_clear": {
|
|
const args = payload as { scope: { pubkey: string; relayUrl: string } };
|
|
const key = `buzz-e2e-channel-head:${args.scope.pubkey.toLowerCase()}:${args.scope.relayUrl.toLowerCase().replace(/\/$/, "")}`;
|
|
window.localStorage.removeItem(key);
|
|
return null;
|
|
}
|
|
case "observed_unread_open_scope": {
|
|
const request = payload as {
|
|
request: {
|
|
scope: { pubkey: string; relayUrl: string };
|
|
legacyPayload?: {
|
|
eventsByChannel?: Record<
|
|
string,
|
|
Array<{
|
|
id: string;
|
|
createdAt: number;
|
|
rootId?: string | null;
|
|
highPriority: boolean;
|
|
countsTowardBadge: boolean;
|
|
countsTowardAppBadge: boolean;
|
|
}>
|
|
>;
|
|
};
|
|
};
|
|
};
|
|
const scope = getMockObservedUnreadScope(request.request.scope);
|
|
if (!scope.migrationComplete) {
|
|
for (const [channelId, events] of Object.entries(
|
|
request.request.legacyPayload?.eventsByChannel ?? {},
|
|
)) {
|
|
for (const event of events) {
|
|
if (!scope.events.has(event.id)) {
|
|
scope.events.set(event.id, {
|
|
channelId,
|
|
...event,
|
|
rootId: event.rootId ?? null,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
scope.migrationComplete = true;
|
|
}
|
|
return {
|
|
kind: "snapshot",
|
|
scope: request.request.scope,
|
|
generation: scope.generation,
|
|
revision: scope.revision,
|
|
lastAckedSequence: scope.lastSequence,
|
|
migrationComplete: scope.migrationComplete,
|
|
membershipSeeded: true,
|
|
channels: mockObservedUnreadProjections(scope),
|
|
} satisfies ObservedUnreadResponse;
|
|
}
|
|
case "observed_unread_ingest": {
|
|
const { request } = payload as {
|
|
request: {
|
|
scope: { pubkey: string; relayUrl: string };
|
|
sequence: number;
|
|
baseRevision: number;
|
|
events: Array<{
|
|
channelId: string;
|
|
id: string;
|
|
createdAt: number;
|
|
rootId: string | null;
|
|
highPriority: boolean;
|
|
countsTowardBadge: boolean;
|
|
countsTowardAppBadge: boolean;
|
|
}>;
|
|
channelLatest: Array<{ channelId: string; createdAt: number }>;
|
|
markers: Array<{ contextId: string; readAt: number | null }>;
|
|
membership: Array<{
|
|
kind: string;
|
|
value: string;
|
|
present: boolean;
|
|
}>;
|
|
clearChannels: string[];
|
|
clearAll: boolean;
|
|
};
|
|
};
|
|
const scope = getMockObservedUnreadScope(request.scope);
|
|
const before = new Map(
|
|
mockObservedUnreadProjections(scope).map((item) => [
|
|
item.channelId,
|
|
item,
|
|
]),
|
|
);
|
|
if (request.clearAll) {
|
|
scope.events.clear();
|
|
scope.channelLatest.clear();
|
|
}
|
|
for (const channelId of request.clearChannels) {
|
|
scope.channelLatest.delete(channelId);
|
|
for (const [id, event] of scope.events) {
|
|
if (event.channelId === channelId) scope.events.delete(id);
|
|
}
|
|
}
|
|
for (const latest of request.channelLatest ?? []) {
|
|
scope.channelLatest.set(
|
|
latest.channelId,
|
|
Math.max(
|
|
scope.channelLatest.get(latest.channelId) ?? 0,
|
|
latest.createdAt,
|
|
),
|
|
);
|
|
}
|
|
for (const event of request.events ?? []) {
|
|
if (!scope.events.has(event.id)) scope.events.set(event.id, event);
|
|
}
|
|
for (const marker of request.markers ?? []) {
|
|
if (marker.readAt === null) scope.markers.delete(marker.contextId);
|
|
else
|
|
scope.markers.set(
|
|
marker.contextId,
|
|
Math.max(scope.markers.get(marker.contextId) ?? 0, marker.readAt),
|
|
);
|
|
}
|
|
const after = mockObservedUnreadProjections(scope);
|
|
const afterIds = new Set(after.map((item) => item.channelId));
|
|
const baseRevision = scope.revision;
|
|
scope.revision += 1;
|
|
scope.lastSequence = request.sequence;
|
|
return {
|
|
kind: "delta",
|
|
scope: request.scope,
|
|
generation: scope.generation,
|
|
baseRevision,
|
|
revision: scope.revision,
|
|
ackedSequence: request.sequence,
|
|
upserts: after.filter(
|
|
(item) =>
|
|
JSON.stringify(before.get(item.channelId)) !==
|
|
JSON.stringify(item),
|
|
),
|
|
removed: [...before.keys()].filter((id) => !afterIds.has(id)),
|
|
} satisfies ObservedUnreadResponse;
|
|
}
|
|
case "unread_catch_up": {
|
|
const request = payload as {
|
|
request: {
|
|
channels: Array<{ id: string }>;
|
|
selfPubkey: string;
|
|
};
|
|
};
|
|
const results: UnreadCatchUpChannelResult[] =
|
|
request.request.channels.map((channel) => ({
|
|
status: "success",
|
|
channelId: channel.id,
|
|
observedEvents: [],
|
|
maxTrigger: 0,
|
|
activityRows: [],
|
|
discovered: {
|
|
participated: [],
|
|
authored: getMockMessageStore(channel.id)
|
|
.filter(
|
|
(event) =>
|
|
event.pubkey === request.request.selfPubkey &&
|
|
getThreadReferenceFromTags(event.tags).parentEventId ===
|
|
null,
|
|
)
|
|
.map((event) => event.id),
|
|
mentioned: [],
|
|
},
|
|
}));
|
|
// Keep this mock aligned with the complete Rust serde shape pinned by
|
|
// `serialized_response_matches_the_typescript_contract`.
|
|
return { channels: results };
|
|
}
|
|
case "agent_metric_archive_default_enabled":
|
|
return activeConfig?.mock?.agentMetricArchiveDefaultEnabled ?? true;
|
|
case "set_prevent_sleep_active":
|
|
return null;
|
|
case "set_window_vibrancy":
|
|
return null;
|
|
case "plugin:window|is_fullscreen":
|
|
return false;
|
|
// Settings reads the app version through the app plugin. Without this the
|
|
// bridge throws an unhandled page error on every Settings render, which
|
|
// shows up as noise in unrelated specs.
|
|
case "plugin:app|version":
|
|
return MOCK_APP_VERSION;
|
|
case "merge_save_subscription_kinds": {
|
|
// Mirrors `merge_owner_p_kinds`: union `kind` into the owner_p row's
|
|
// kinds, creating the row if it doesn't exist yet.
|
|
const { kind } = payload as { kind: number };
|
|
const ident = activeConfig?.identity ?? DEFAULT_MOCK_IDENTITY;
|
|
const row = mockSaveSubscriptions.find(
|
|
(s) => s.scope_type === "owner_p" && s.scope_value === ident.pubkey,
|
|
);
|
|
if (row) {
|
|
const kinds: number[] = JSON.parse(row.kinds);
|
|
if (!kinds.includes(kind)) {
|
|
row.kinds = JSON.stringify([...kinds, kind]);
|
|
}
|
|
} else {
|
|
mockSaveSubscriptions.push({
|
|
scope_type: "owner_p",
|
|
scope_value: ident.pubkey,
|
|
kinds: JSON.stringify([kind]),
|
|
});
|
|
}
|
|
return null;
|
|
}
|
|
case "remove_save_subscription_kind": {
|
|
// Mirrors `remove_owner_p_kind`: remove `kind` from the owner_p row's
|
|
// kinds, deleting the row entirely once its kinds list is empty.
|
|
const { kind } = payload as { kind: number };
|
|
const ident = activeConfig?.identity ?? DEFAULT_MOCK_IDENTITY;
|
|
const row = mockSaveSubscriptions.find(
|
|
(s) => s.scope_type === "owner_p" && s.scope_value === ident.pubkey,
|
|
);
|
|
if (row) {
|
|
const kinds: number[] = JSON.parse(row.kinds).filter(
|
|
(k: number) => k !== kind,
|
|
);
|
|
if (kinds.length === 0) {
|
|
mockSaveSubscriptions = mockSaveSubscriptions.filter(
|
|
(s) => s !== row,
|
|
);
|
|
} else {
|
|
row.kinds = JSON.stringify(kinds);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
default:
|
|
throw new Error(`Unsupported mocked Tauri command: ${command}`);
|
|
}
|
|
};
|
|
window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ = (command, payload) =>
|
|
handleMockCommand(command, payload ?? null);
|
|
window.__BUZZ_E2E_EMIT_TAURI_EVENT__ = (event, payload) =>
|
|
emit(event, payload);
|
|
mockIPC(handleMockCommand, { shouldMockEvents: true });
|
|
const tauriInternals = (
|
|
window as typeof window & {
|
|
__TAURI_INTERNALS__: {
|
|
listen?: (
|
|
event: string,
|
|
callback: () => void,
|
|
) => Promise<() => Promise<void>>;
|
|
};
|
|
}
|
|
).__TAURI_INTERNALS__;
|
|
// Page-evaluated E2E specs use this surface; delegate to Tauri's mocked channel
|
|
// so their listeners observe the same events emitted by application test seams.
|
|
tauriInternals.listen = async (event, callback) => {
|
|
const unlisten = await listen(event, () => callback());
|
|
return async () => unlisten();
|
|
};
|
|
|
|
installed = true;
|
|
}
|