diff --git a/desktop/src/testing/announcementDemoFixtures.ts b/desktop/src/testing/announcementDemoFixtures.ts index 3ba6b552f9..5c229ed26b 100644 --- a/desktop/src/testing/announcementDemoFixtures.ts +++ b/desktop/src/testing/announcementDemoFixtures.ts @@ -66,6 +66,55 @@ export const ANNOUNCEMENT_DEMO_AGENTS = [ export type AnnouncementDemoAgentName = (typeof ANNOUNCEMENT_DEMO_AGENTS)[number]["name"]; +/** + * Cross-channel cues for the short announcement film. Opening the trigger + * channel starts real observer-derived turns in the background channels, then + * delivers a live mention in the pivot channel. The mention is also inserted + * into the Home feed so the production notification path owns the native + * notification and click-through behavior. + */ +export const ANNOUNCEMENT_DEMO_STORY = { + triggerChannelName: "flight-path", + pivot: { + delayMs: 8_000, + channelName: "engineering", + author: "engineer", + content: + "@Alex Rivera — before this prototype gets any bigger, we should talk stack. We’re hand-rolling the iOS and Android views separately and it’s already drifting.", + }, + engineeringSequence: { + insight: { + delayMs: 2_500, + author: "data", + content: + "Honestly? Move it to Flutter now. One codebase, both platforms, and we stop maintaining two of everything. Cheap to switch today, expensive in a month.", + }, + retask: { + delayMs: 4_000, + content: + "@Bumble New direction, team — we’re moving the prototype to Flutter. Can you and the others re-plan the port and start scaffolding?", + }, + agentReplies: { + Bumble: + "🫡 On it — mapping the current React views to Flutter widgets now. Handing the state layer to @Fizz.", + Fizz: "Got the state layer — porting the game loop to Flutter, keeping the tuned physics. @Honey, UI shell?", + Honey: + "Building the Flutter widget tree from the existing designs — same look, one codebase. 🍯", + }, + humanClose: { + delayMs: 1_100, + author: "data", + content: + "That’s the version. One stack, both platforms — nice pivot, team. 🐝", + }, + }, + workingTurns: [ + { channelName: "design", agent: "Honey" }, + { channelName: "engineering", agent: "Bumble" }, + { channelName: "marketing", agent: "Fizz" }, + ], +} as const; + export const ANNOUNCEMENT_DEMO_LIVE_CONVERSATIONS = [ { channelName: "flight-path", diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 60a197274f..f0db0b2a70 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -58,6 +58,7 @@ import { ANNOUNCEMENT_DEMO_PEOPLE, ANNOUNCEMENT_DEMO_PROJECT_SUBJECTS, ANNOUNCEMENT_DEMO_PROJECTS, + ANNOUNCEMENT_DEMO_STORY, type AnnouncementDemoAgentName, type AnnouncementDemoPersonKey, } from "@/testing/announcementDemoFixtures"; @@ -135,6 +136,8 @@ type E2eConfig = { mode?: "mock" | "relay"; mock?: { announcementDemo?: boolean; + /** Disable the cross-channel announcement story while retaining standalone demo fixtures. */ + announcementDemoStory?: boolean; acpRuntimesCatalog?: RawAcpRuntimeCatalogEntry[]; acpAuthMethods?: Record; connectAcpRuntimeResult?: RawConnectAcpRuntimeResult; @@ -2748,6 +2751,8 @@ const realSockets = new Map(); let mockManagedAgents: MockManagedAgent[] = []; const pendingAnnouncementDemoAgentResponses = new Set(); const startedAnnouncementDemoLiveConversations = new Set(); +let startedAnnouncementDemoStory = false; +let announcementDemoStorySeq = Date.now(); let mockGlobalAgentConfig: MockGlobalAgentConfig = { env_vars: {}, provider: null, @@ -4446,6 +4451,21 @@ async function requestAnnouncementDemoAgentReply( const provider = getAnnouncementDemoProviderConfig(agent); // The local Vite proxy keeps provider credentials out of the webview while // still allowing the native demo to use the settings entered in the app. + const scriptedReply = + sourceEvent.tags.some( + (tag) => tag[0] === "announcement-demo-story" && tag[1] === "retask", + ) || + sourceEvent.tags.some( + (tag) => tag[0] === "announcement-demo-story" && tag[1] === "handoff", + ) + ? ANNOUNCEMENT_DEMO_STORY.engineeringSequence.agentReplies[ + agent.name as AnnouncementDemoAgentName + ] + : null; + if (scriptedReply) { + return scriptedReply; + } + const response = await fetch("/__announcement-demo/agent-response", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -4509,6 +4529,15 @@ function publishAnnouncementDemoAgentReply( : buildTopLevelMessageTags(channelId, mentionPubkeys, agent.pubkey); if (isAgentChainEvent && continueAgentChain) { tags.push([ANNOUNCEMENT_DEMO_AGENT_CHAIN_TAG, "1"]); + if ( + sourceEvent.tags.some( + (tag) => + tag[0] === "announcement-demo-story" && + (tag[1] === "retask" || tag[1] === "handoff"), + ) + ) { + tags.push(["announcement-demo-story", "handoff"]); + } } const event = createMockEvent( 9, @@ -4526,12 +4555,15 @@ function publishAnnouncementDemoAgentReply( ANNOUNCEMENT_DEMO_AGENT_HANDOFF_DELAY_MS, ); } else if (isAgentChainEvent && continueAgentChain) { - const humanClose = getAnnouncementDemoLiveConversation(channelId) - ?.humanClose ?? { - delayMs: 1_000, - author: "producer" as const, - content: "That’s the version. I can cut to that — nice swarm work 🐝", - }; + const humanClose = sourceEvent.tags.some( + (tag) => tag[0] === "announcement-demo-story" && tag[1] === "handoff", + ) + ? ANNOUNCEMENT_DEMO_STORY.engineeringSequence.humanClose + : (getAnnouncementDemoLiveConversation(channelId)?.humanClose ?? { + delayMs: 1_000, + author: "producer" as const, + content: "That’s the version. I can cut to that — nice swarm work 🐝", + }); const closingAuthorPubkey = getAnnouncementDemoPersonPubkey( humanClose.author, ); @@ -4694,7 +4726,196 @@ function queueAnnouncementDemoAgentResponses( } } +function startAnnouncementDemoStory() { + if ( + startedAnnouncementDemoStory || + !getConfig()?.mock?.announcementDemo || + getConfig()?.mock?.announcementDemoStory === false + ) { + return; + } + startedAnnouncementDemoStory = true; + const pivotChannel = mockChannels.find( + (channel) => channel.name === ANNOUNCEMENT_DEMO_STORY.pivot.channelName, + ); + if (pivotChannel) { + // The cross-channel story owns this channel from the moment the trigger is + // opened. Reserve it before background subscriptions can start the older + // standalone engineering conversation. + startedAnnouncementDemoLiveConversations.add(pivotChannel.id); + } + // Prime the feed notification hook with the empty baseline before the live + // mention arrives. Without this, a cold demo launch can classify the first + // alert as initial-load backlog and intentionally suppress its notification. + window.dispatchEvent(new CustomEvent("buzz:e2e-home-feed-updated")); + + for (const cue of ANNOUNCEMENT_DEMO_STORY.workingTurns) { + const channel = mockChannels.find( + (candidate) => candidate.name === cue.channelName, + ); + const agent = ANNOUNCEMENT_DEMO_AGENTS.find( + (candidate) => candidate.name === cue.agent, + ); + if (!channel || !agent) { + continue; + } + + announcementDemoStorySeq += 1; + const turnId = `announcement-demo-story-${cue.channelName}`; + const event = { + seq: announcementDemoStorySeq, + timestamp: new Date().toISOString(), + kind: "turn_started" as const, + agentIndex: 0, + channelId: channel.id, + sessionId: null, + turnId, + payload: null, + }; + // Drive the same observer-derived store as a real harness turn. Unlike a + // typing fallback this works for background channels with no ChannelScreen + // mounted, which is exactly what the sidebar overview shot needs. + syncAgentTurnsFromEvents(agent.pubkey, [event]); + syncAgentObserverEvents(agent.pubkey, [event]); + } + + const pivot = ANNOUNCEMENT_DEMO_STORY.pivot; + window.setTimeout(() => { + const channel = mockChannels.find( + (candidate) => candidate.name === pivot.channelName, + ); + if (!channel) { + return; + } + + const authorPubkey = getAnnouncementDemoPersonPubkey(pivot.author); + const viewerPubkey = getMockMemberPubkey(getConfig() ?? undefined); + const backgroundAgent = ANNOUNCEMENT_DEMO_AGENTS.find( + (candidate) => candidate.name === "Bumble", + ); + if (backgroundAgent) { + announcementDemoStorySeq += 1; + const completed = { + seq: announcementDemoStorySeq, + timestamp: new Date().toISOString(), + kind: "turn_completed" as const, + agentIndex: 0, + channelId: channel.id, + sessionId: null, + turnId: `announcement-demo-story-${channel.name}`, + payload: null, + }; + syncAgentTurnsFromEvents(backgroundAgent.pubkey, [completed]); + syncAgentObserverEvents(backgroundAgent.pubkey, [completed]); + } + // The story owns engineering; opening it after the notification must not + // also start its older standalone demo conversation. + startedAnnouncementDemoLiveConversations.add(channel.id); + const message = emitMockChannelMessage( + channel.id, + pivot.content, + null, + authorPubkey, + 9, + [viewerPubkey], + [["announcement-demo-story", "pivot"]], + undefined, + mockEventId(), + ); + mockFeedOverrides.mentions.unshift({ + id: message.id, + kind: message.kind, + pubkey: message.pubkey, + content: message.content, + created_at: message.created_at, + channel_id: channel.id, + channel_name: channel.name, + tags: message.tags, + category: "mention", + }); + // Refetching the feed exercises the production notification formatter, + // sound, native notification, and notification click-through path. + window.dispatchEvent(new CustomEvent("buzz:e2e-home-feed-updated")); + + const sequence = ANNOUNCEMENT_DEMO_STORY.engineeringSequence; + window.setTimeout(() => { + emitMockTypingIndicator( + channel.id, + getAnnouncementDemoPersonPubkey(sequence.insight.author), + ); + window.setTimeout(() => { + emitMockChannelMessage( + channel.id, + sequence.insight.content, + null, + getAnnouncementDemoPersonPubkey(sequence.insight.author), + 9, + undefined, + [["announcement-demo-story", "insight"]], + undefined, + mockEventId(), + ); + }, 700); + }, sequence.insight.delayMs); + + window.setTimeout(() => { + emitMockTypingIndicator(channel.id, viewerPubkey); + window.setTimeout(() => { + const bumble = ANNOUNCEMENT_DEMO_AGENTS.find( + (candidate) => candidate.name === "Bumble", + ); + if (!bumble) { + return; + } + emitMockChannelMessage( + channel.id, + sequence.retask.content, + null, + viewerPubkey, + 9, + [bumble.pubkey], + [ + [ANNOUNCEMENT_DEMO_AGENT_CHAIN_TAG, "1"], + ["announcement-demo-story", "retask"], + ], + undefined, + mockEventId(), + ); + }, 900); + }, sequence.retask.delayMs); + }, pivot.delayMs); +} + +function handleAnnouncementDemoStoryClick(event: MouseEvent) { + if (!(event.target instanceof Element)) { + return; + } + const triggerTestId = `channel-${ANNOUNCEMENT_DEMO_STORY.triggerChannelName}`; + if (event.target.closest(`[data-testid="${triggerTestId}"]`)) { + startAnnouncementDemoStory(); + } +} + function maybeStartAnnouncementDemoLiveConversation(channelId: string) { + const channel = mockChannels.find((candidate) => candidate.id === channelId); + if ( + channel?.name === ANNOUNCEMENT_DEMO_STORY.triggerChannelName && + getConfig()?.mock?.announcementDemoStory !== false + ) { + // A live subscription can be created for the restored/default route before + // the viewer interacts with the sidebar. Reserve the pivot immediately so + // its standalone conversation cannot race the film, but wait for the + // viewer's actual sidebar click before starting any visible story beats. + const pivotChannel = mockChannels.find( + (candidate) => + candidate.name === ANNOUNCEMENT_DEMO_STORY.pivot.channelName, + ); + if (pivotChannel) { + startedAnnouncementDemoLiveConversations.add(pivotChannel.id); + } + return; + } + if ( startedAnnouncementDemoLiveConversations.has(channelId) || !getConfig()?.mock?.announcementDemo @@ -9541,6 +9762,12 @@ export function maybeInstallE2eTauriMocks() { resetMockUserStatuses(); resetMockPendingCommunityDeepLinks(config); mockWebsocketSendMutexWedged = false; + if (config.mock?.announcementDemo) { + // Subscription creation is not proof of user intent: the app may restore + // or default to flight-path during startup. Gate the film on the actual + // sidebar click so the opening frame remains under the recorder's control. + window.addEventListener("click", handleAnnouncementDemoStoryClick); + } // The browser test harness has no Tauri globals, so install the complete // window mock there. The native announcement demo already has Tauri's // read-only `window.__TAURI_INTERNALS__` binding; attempting to replace it diff --git a/desktop/tests/e2e/announcement-demo.spec.ts b/desktop/tests/e2e/announcement-demo.spec.ts index 55b9f1372d..b42d974390 100644 --- a/desktop/tests/e2e/announcement-demo.spec.ts +++ b/desktop/tests/e2e/announcement-demo.spec.ts @@ -61,6 +61,14 @@ test("announcement demo loads its workspace, people, and projects", async ({ }); }); await page.goto("/?demo=announcement"); + await page.evaluate(() => { + const config = ( + window as Window & { + __BUZZ_E2E__?: { mock?: { announcementDemoStory?: boolean } }; + } + ).__BUZZ_E2E__; + if (config?.mock) config.mock.announcementDemoStory = false; + }); await page.waitForFunction( () => typeof ( @@ -358,3 +366,151 @@ test("announcement demo loads its workspace, people, and projects", async ({ ).toBeVisible(); } }); + +test("announcement story shows background work and pivots through a mention", async ({ + context, + page, +}) => { + test.setTimeout(30_000); + await context.grantPermissions(["notifications"]); + // The E2E bridge exposes Tauri internals, but Chromium reports Linux. Force + // the browser notification branch so its click target is inspectable here; + // the packaged Linux build uses the equivalent native backend action. + await page.addInitScript(() => { + const notificationLog: Array<{ + body: string | null; + title: string; + }> = []; + const notifications: Array<{ + body: string | null; + instance: Notification; + title: string; + }> = []; + class DemoNotification extends EventTarget { + static permission: NotificationPermission = "granted"; + static async requestPermission() { + return DemoNotification.permission; + } + body: string | null; + onclick: ((event: Event) => void) | null = null; + title: string; + constructor(title: string, options?: NotificationOptions) { + super(); + this.title = title; + this.body = options?.body ?? null; + notifications.push({ body: this.body, instance: this, title }); + notificationLog.push({ body: this.body, title }); + } + close() {} + } + Object.defineProperty(window, "Notification", { + configurable: true, + value: DemoNotification, + }); + Object.assign(window, { + __BUZZ_E2E_CLICK_NOTIFICATION__: (index: number) => { + const notification = notifications[index]?.instance; + if (!notification) return false; + const event = new Event("click"); + notification.dispatchEvent(event); + notification.onclick?.(event); + return true; + }, + __BUZZ_E2E_NOTIFICATIONS__: notificationLog, + }); + Object.defineProperty(navigator, "platform", { + configurable: true, + value: "MacIntel", + }); + }); + await page.goto("/?demo=announcement"); + expect( + await page.evaluate(() => ({ + permission: Notification.permission, + platform: navigator.platform, + })), + ).toEqual({ permission: "granted", platform: "MacIntel" }); + await expect(page.getByText("The Hive", { exact: true })).toBeVisible(); + + // Startup may restore or default to flight-path and subscribe immediately; + // that must not start the film before the recorder's explicit sidebar click. + for (const channel of ["design", "engineering", "marketing"] as const) { + await expect(page.getByTestId(`channel-working-${channel}`)).toHaveCount(0); + } + await page.waitForTimeout(1_000); + for (const channel of ["design", "engineering", "marketing"] as const) { + await expect(page.getByTestId(`channel-working-${channel}`)).toHaveCount(0); + } + + await page.getByTestId("channel-flight-path").click(); + await expect(page.getByTestId("chat-title")).toHaveText("flight-path"); + + for (const channel of ["design", "engineering", "marketing"] as const) { + const badge = page.getByTestId(`channel-working-${channel}`); + await expect(badge).toBeVisible(); + await expect(badge).toContainText(/^\d+s$/); + } + + await expect + .poll( + () => + page.evaluate(() => { + const win = window as Window & { + __BUZZ_E2E_NOTIFICATIONS__?: Array<{ + body: string | null; + title: string; + }>; + }; + return win.__BUZZ_E2E_NOTIFICATIONS__ ?? []; + }), + { timeout: 12_000 }, + ) + .toEqual([ + { + body: expect.stringContaining("before this prototype gets any bigger"), + title: expect.stringContaining("mentioned you in #engineering"), + }, + ]); + + const clickedNotification = await page.evaluate(() => { + const win = window as Window & { + __BUZZ_E2E_CLICK_NOTIFICATION__?: (index: number) => boolean; + }; + return win.__BUZZ_E2E_CLICK_NOTIFICATION__?.(0) ?? false; + }); + expect(clickedNotification).toBe(true); + + await expect(page.getByTestId("chat-title")).toHaveText("engineering"); + await expect(page.getByTestId("message-timeline")).toContainText( + "it’s already drifting", + ); + await expect(page.getByTestId("channel-working-engineering")).toHaveCount(0); + await expect(page.getByTestId("message-timeline")).toContainText( + "Move it to Flutter now", + { timeout: 5_000 }, + ); + await expect(page.getByTestId("message-timeline")).toContainText( + "we’re moving the prototype to Flutter", + { timeout: 7_000 }, + ); + await expect(page.getByTestId("message-timeline")).toContainText( + "mapping the current React views to Flutter widgets", + { timeout: 6_000 }, + ); + await expect(page.getByTestId("message-timeline")).toContainText( + "porting the game loop to Flutter", + { timeout: 6_000 }, + ); + await expect(page.getByTestId("message-timeline")).toContainText( + "Building the Flutter widget tree", + { timeout: 6_000 }, + ); + await expect(page.getByTestId("message-timeline")).not.toContainText( + "connection hiccup", + ); + await expect( + page + .getByTestId("message-row") + .filter({ hasText: "One stack, both platforms" }), + ).toHaveCount(1); +});