From 5a465b2e0300bd2681683165ea4970cbf1133b2e Mon Sep 17 00:00:00 2001 From: Kyle De Freitas Date: Mon, 12 Oct 2026 22:34:20 -0400 Subject: [PATCH 1/4] fix(notifications): seed window-focus state from isFocused() at mount --- .../useCompletionNotifications.test.ts | 50 +++++++++++++++++++ .../hooks/useCompletionNotifications.ts | 19 +++++-- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/src/shared/hooks/__tests__/useCompletionNotifications.test.ts b/src/shared/hooks/__tests__/useCompletionNotifications.test.ts index 4af37f1fe..04ba0dd42 100644 --- a/src/shared/hooks/__tests__/useCompletionNotifications.test.ts +++ b/src/shared/hooks/__tests__/useCompletionNotifications.test.ts @@ -186,6 +186,7 @@ describe("useCompletionNotifications", () => { }), ); mocks.getCurrentWindow.mockReturnValue({ + isFocused: vi.fn().mockResolvedValue(true), onFocusChanged: vi.fn().mockResolvedValue(vi.fn()), unminimize: vi.fn().mockResolvedValue(undefined), show: vi.fn().mockResolvedValue(undefined), @@ -214,6 +215,7 @@ describe("useCompletionNotifications", () => { | null = null; const appWindow = { + isFocused: vi.fn().mockResolvedValue(true), onFocusChanged: vi.fn((handler) => { focusChanged = handler; return Promise.resolve(vi.fn()); @@ -278,6 +280,7 @@ describe("useCompletionNotifications", () => { let focusChanged: ((event: { payload: boolean }) => void) | null = null; mocks.getCurrentWindow.mockReturnValue({ + isFocused: vi.fn().mockResolvedValue(true), onFocusChanged: vi.fn((handler) => { focusChanged = handler; return Promise.resolve(vi.fn()); @@ -357,6 +360,7 @@ describe("useCompletionNotifications", () => { ), ); mocks.getCurrentWindow.mockReturnValue({ + isFocused: vi.fn().mockResolvedValue(true), onFocusChanged: vi.fn((handler) => { focusChanged = handler; return Promise.resolve(vi.fn()); @@ -401,6 +405,7 @@ describe("useCompletionNotifications", () => { : Promise.resolve(undefined), ); mocks.getCurrentWindow.mockReturnValue({ + isFocused: vi.fn().mockResolvedValue(true), onFocusChanged: vi.fn((handler) => { focusChanged = handler; return Promise.resolve(vi.fn()); @@ -453,6 +458,7 @@ describe("useCompletionNotifications", () => { : Promise.resolve(undefined), ); mocks.getCurrentWindow.mockReturnValue({ + isFocused: vi.fn().mockResolvedValue(true), onFocusChanged: vi.fn((handler) => { focusChanged = handler; return Promise.resolve(vi.fn()); @@ -502,6 +508,7 @@ describe("useCompletionNotifications", () => { window.addEventListener("goose:open-settings", onOpenSettings); mocks.getCurrentWindow.mockReturnValue({ + isFocused: vi.fn().mockResolvedValue(true), onFocusChanged: vi.fn((handler) => { focusChanged = handler; return Promise.resolve(vi.fn()); @@ -660,6 +667,7 @@ describe("useCompletionNotifications", () => { ); mocks.getCurrentWindow.mockReturnValue({ + isFocused: vi.fn().mockResolvedValue(true), onFocusChanged: vi.fn((handler) => { focusChanged = handler; return Promise.resolve(vi.fn()); @@ -699,4 +707,46 @@ describe("useCompletionNotifications", () => { ), ); }); + + it("routes to desktop delivery when the window starts unfocused before any focus event", async () => { + const onFocusChanged = vi.fn().mockResolvedValue(vi.fn()); + mocks.getCurrentWindow.mockReturnValue({ + isFocused: vi.fn().mockResolvedValue(false), + onFocusChanged, + unminimize: vi.fn().mockResolvedValue(undefined), + show: vi.fn().mockResolvedValue(undefined), + setFocus: vi.fn().mockResolvedValue(undefined), + }); + + renderHook(() => useCompletionNotifications(vi.fn())); + + // Wait for the focus listener registration so the isFocused seed has + // resolved too; no focus-change event is ever delivered in this test. + await waitFor(() => expect(onFocusChanged).toHaveBeenCalled()); + + useChatSessionStore.getState().addSession({ + id: "session-4", + title: "Background start", + createdAt: "2026-06-05T00:00:00.000Z", + updatedAt: "2026-06-05T00:00:00.000Z", + messageCount: 1, + }); + useChatStore.getState().setMessages("session-4", [makeMsg("completed")]); + + act(() => { + useChatStore.getState().setChatState("session-4", "streaming"); + useChatStore.getState().setChatState("session-4", "idle"); + }); + + await waitFor(() => + expect(mocks.invoke).toHaveBeenCalledWith( + "show_completion_notification", + { + body: "Background start finished", + sessionId: "session-4", + sound: "berd-sounds-4.mp3", + }, + ), + ); + }); }); diff --git a/src/shared/hooks/useCompletionNotifications.ts b/src/shared/hooks/useCompletionNotifications.ts index 9f6dd4dbb..d59d5f977 100644 --- a/src/shared/hooks/useCompletionNotifications.ts +++ b/src/shared/hooks/useCompletionNotifications.ts @@ -103,11 +103,22 @@ export function useCompletionNotifications( let unlisten: (() => void) | null = null; let cancelled = false; import("@tauri-apps/api/window") - .then(({ getCurrentWindow }) => - getCurrentWindow().onFocusChanged(({ payload: focused }) => { + .then(({ getCurrentWindow }) => { + const appWindow = getCurrentWindow(); + // Seed from the real focus state: the ref defaults to true, so a + // completion that lands before any focus-change event would otherwise + // be delivered as an in-app toast in a backgrounded window instead of + // a desktop notification. + void appWindow + .isFocused() + .then((focused) => { + windowFocusedRef.current = focused; + }) + .catch(() => {}); + return appWindow.onFocusChanged(({ payload: focused }) => { windowFocusedRef.current = focused; - }), - ) + }); + }) .then((fn) => { if (cancelled) fn(); else unlisten = fn; From 12de61b397767e5012b985e4e4cf042ba097567b Mon Sep 17 00:00:00 2001 From: Kyle De Freitas Date: Mon, 12 Oct 2026 23:48:33 -0400 Subject: [PATCH 2/4] fix(notifications): buffer completion delivery until initial focus state is seeded --- .../useCompletionNotifications.test.ts | 159 ++++++++++++++++++ .../hooks/useCompletionNotifications.ts | 73 ++++++-- 2 files changed, 217 insertions(+), 15 deletions(-) diff --git a/src/shared/hooks/__tests__/useCompletionNotifications.test.ts b/src/shared/hooks/__tests__/useCompletionNotifications.test.ts index 04ba0dd42..ac17c817e 100644 --- a/src/shared/hooks/__tests__/useCompletionNotifications.test.ts +++ b/src/shared/hooks/__tests__/useCompletionNotifications.test.ts @@ -3,6 +3,7 @@ import type React from "react"; import { isValidElement } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { + FOCUS_STATE_SEED_TIMEOUT_MS, getCompletionOutcome, getNotificationBody, useCompletionNotifications, @@ -749,4 +750,162 @@ describe("useCompletionNotifications", () => { ), ); }); + + function makeAppWindow(isFocused: () => Promise) { + let focusChanged: ((event: { payload: boolean }) => void) | null = null; + const onFocusChanged = vi.fn((handler) => { + focusChanged = handler; + return Promise.resolve(vi.fn()); + }); + mocks.getCurrentWindow.mockReturnValue({ + isFocused: vi.fn(isFocused), + onFocusChanged, + unminimize: vi.fn().mockResolvedValue(undefined), + show: vi.fn().mockResolvedValue(undefined), + setFocus: vi.fn().mockResolvedValue(undefined), + }); + return { + onFocusChanged, + fireFocusChanged: (payload: boolean) => focusChanged?.({ payload }), + }; + } + + function completeSession(sessionId: string, title: string) { + useChatSessionStore.getState().addSession({ + id: sessionId, + title, + createdAt: "2026-06-05T00:00:00.000Z", + updatedAt: "2026-06-05T00:00:00.000Z", + messageCount: 1, + }); + useChatStore.getState().setMessages(sessionId, [makeMsg("completed")]); + act(() => { + useChatStore.getState().setChatState(sessionId, "streaming"); + useChatStore.getState().setChatState(sessionId, "idle"); + }); + } + + it("defers a completion finishing before the focus query settles, then delivers exactly one desktop notification", async () => { + const focusQuery = deferred(); + const { onFocusChanged } = makeAppWindow(() => focusQuery.promise); + + renderHook(() => useCompletionNotifications(vi.fn())); + await waitFor(() => expect(onFocusChanged).toHaveBeenCalled()); + + completeSession("session-defer", "Deferred start"); + + // The query is still pending: delivery must be buffered, not routed with + // the placeholder focused default. + await act(async () => { + await Promise.resolve(); + }); + expect(mocks.invoke).not.toHaveBeenCalledWith( + "show_completion_notification", + expect.anything(), + ); + expect(mocks.toast).not.toHaveBeenCalled(); + + focusQuery.resolve(false); + + await waitFor(() => + expect(mocks.invoke).toHaveBeenCalledWith( + "show_completion_notification", + { + body: "Deferred start finished", + sessionId: "session-defer", + sound: "berd-sounds-4.mp3", + }, + ), + ); + const desktopCalls = mocks.invoke.mock.calls.filter( + ([command]) => command === "show_completion_notification", + ); + expect(desktopCalls).toHaveLength(1); + expect(mocks.toast).not.toHaveBeenCalled(); + }); + + it("lets a focus event that arrives before the query settles win over the stale query result", async () => { + const focusQuery = deferred(); + const { onFocusChanged, fireFocusChanged } = makeAppWindow( + () => focusQuery.promise, + ); + + renderHook(() => useCompletionNotifications(vi.fn())); + await waitFor(() => expect(onFocusChanged).toHaveBeenCalled()); + + // Fresh event: the window lost focus after mount, before the (stale) + // query reported it as focused. + act(() => { + fireFocusChanged(false); + }); + focusQuery.resolve(true); + await act(async () => { + await Promise.resolve(); + }); + + completeSession("session-race", "Event wins"); + + await waitFor(() => + expect(mocks.invoke).toHaveBeenCalledWith( + "show_completion_notification", + { + body: "Event wins finished", + sessionId: "session-race", + sound: "berd-sounds-4.mp3", + }, + ), + ); + expect(mocks.toast).not.toHaveBeenCalled(); + }); + + it("falls back to the default focus state when the focus query rejects", async () => { + const { onFocusChanged } = makeAppWindow(() => + Promise.reject(new Error("focus query failed")), + ); + mocks.toast.mockReturnValue("toast-id"); + + renderHook(() => useCompletionNotifications(vi.fn())); + await waitFor(() => expect(onFocusChanged).toHaveBeenCalled()); + + completeSession("session-reject", "Query rejected"); + + // Rejection releases buffered completions with the focused default: the + // in-app toast path, exactly once. + await waitFor(() => expect(mocks.toast).toHaveBeenCalledTimes(1)); + expect(mocks.invoke).not.toHaveBeenCalledWith( + "show_completion_notification", + expect.anything(), + ); + }); + + it("releases buffered completions after the bounded fallback when the focus query never settles", async () => { + const { onFocusChanged } = makeAppWindow( + () => new Promise(() => {}), + ); + mocks.toast.mockReturnValue("toast-id"); + + // Fake timers must be active before mount so the bounded-fallback timer + // itself is controllable. + vi.useFakeTimers(); + try { + renderHook(() => useCompletionNotifications(vi.fn())); + // Flush the dynamic import microtasks so the listener registers. + await vi.advanceTimersByTimeAsync(0); + expect(onFocusChanged).toHaveBeenCalled(); + + completeSession("session-timeout", "Hung query"); + + await vi.advanceTimersByTimeAsync(0); + expect(mocks.toast).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(FOCUS_STATE_SEED_TIMEOUT_MS); + expect(mocks.toast).toHaveBeenCalledTimes(1); + expect(mocks.invoke).not.toHaveBeenCalledWith( + "show_completion_notification", + expect.anything(), + ); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/src/shared/hooks/useCompletionNotifications.ts b/src/shared/hooks/useCompletionNotifications.ts index d59d5f977..3aea0fa71 100644 --- a/src/shared/hooks/useCompletionNotifications.ts +++ b/src/shared/hooks/useCompletionNotifications.ts @@ -23,6 +23,9 @@ import { getPlatform } from "@/shared/lib/platform"; import type { Message } from "@/shared/types/messages"; const COMPLETION_NOTIFICATION_CLICKED_EVENT = "completion-notification-clicked"; +// Upper bound on how long completion delivery may wait for the initial +// window-focus query before proceeding with the best-known focus state. +export const FOCUS_STATE_SEED_TIMEOUT_MS = 1000; async function shouldSuppressCompletionNotification( sessionId: string, @@ -83,6 +86,11 @@ export function useCompletionNotifications( onNavigateToSession: (sessionId: string) => void, ): void { const windowFocusedRef = useRef(true); + // Resolves once the focus state has been seeded (initial isFocused() query + // settled, a real focus event arrived, or the bounded fallback elapsed). + // Completion delivery waits on this so a session finishing before the + // mount-time query settles is not misclassified by the default value. + const focusStateReadyRef = useRef>(Promise.resolve()); // Keep a stable ref so the Zustand subscriber never has a stale callback. const navigateRef = useRef(onNavigateToSession); useEffect(() => { @@ -102,22 +110,51 @@ export function useCompletionNotifications( if (!window.__TAURI_INTERNALS__) return; let unlisten: (() => void) | null = null; let cancelled = false; + let markFocusStateReady!: () => void; + focusStateReadyRef.current = new Promise((resolve) => { + markFocusStateReady = resolve; + }); + // Bounded fallback: never hold completion delivery hostage to a focus + // query that fails to settle. + const fallbackTimer = setTimeout( + markFocusStateReady, + FOCUS_STATE_SEED_TIMEOUT_MS, + ); + // A real focus-change event is always fresher than the mount-time query, + // so once one arrives the query result must not clobber it. + let focusEventSeen = false; import("@tauri-apps/api/window") .then(({ getCurrentWindow }) => { const appWindow = getCurrentWindow(); - // Seed from the real focus state: the ref defaults to true, so a - // completion that lands before any focus-change event would otherwise - // be delivered as an in-app toast in a backgrounded window instead of - // a desktop notification. - void appWindow - .isFocused() - .then((focused) => { + // Register the focus listener before sampling focus so a change that + // happens while the sample is in flight is observed (and wins). + return appWindow + .onFocusChanged(({ payload: focused }) => { + // A cleaned-up effect generation must not write into state now + // owned by a newer generation (StrictMode remounts). + if (cancelled) return; + focusEventSeen = true; windowFocusedRef.current = focused; + markFocusStateReady(); }) - .catch(() => {}); - return appWindow.onFocusChanged(({ payload: focused }) => { - windowFocusedRef.current = focused; - }); + .then((unlistenFocus) => { + // Seed from the real focus state: the ref defaults to true, so a + // completion that lands before any focus-change event would + // otherwise be delivered as an in-app toast in a backgrounded + // window instead of a desktop notification. + void appWindow + .isFocused() + .then((focused) => { + if (!cancelled && !focusEventSeen) { + windowFocusedRef.current = focused; + } + markFocusStateReady(); + }) + .catch(() => { + markFocusStateReady(); + }); + return unlistenFocus; + }); }) .then((fn) => { if (cancelled) fn(); @@ -125,6 +162,9 @@ export function useCompletionNotifications( }); return () => { cancelled = true; + clearTimeout(fallbackTimer); + // Don't leave buffered completions waiting on a torn-down listener. + markFocusStateReady(); unlisten?.(); }; }, []); @@ -242,8 +282,12 @@ export function useCompletionNotifications( ? session.title : ""; const body = getNotificationBody(outcome, title); - void shouldSuppressCompletionNotification(sessionId).then( - (suppress) => { + // Wait for the seeded focus state before evaluating suppression + // so neither the focus routing below reads the placeholder + // default nor the suppression result goes stale while buffered. + void focusStateReadyRef.current + .then(() => shouldSuppressCompletionNotification(sessionId)) + .then((suppress) => { if (suppress) return; const currentPrefs = getNotificationPrefs(); if (!currentPrefs.enabled) return; @@ -296,8 +340,7 @@ export function useCompletionNotifications( viewLabel: tRef.current("actions.view"), changeSoundLabel: tRef.current("actions.changeSound"), }); - }, - ); + }); } } }, From ca9840fa4d52cbf7eb916236fc2232a2380f7963 Mon Sep 17 00:00:00 2001 From: Kyle De Freitas Date: Tue, 13 Oct 2026 07:26:15 -0400 Subject: [PATCH 3/4] fix(notifications): defer active-view suppression until focus state is seeded The pre-buffer skip read the placeholder focused default, so a completion in the actively viewed session could be dropped while the mount-time focus query was still in flight. --- .../useCompletionNotifications.test.ts | 44 +++++++++++++++++++ .../hooks/useCompletionNotifications.ts | 12 +++-- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/shared/hooks/__tests__/useCompletionNotifications.test.ts b/src/shared/hooks/__tests__/useCompletionNotifications.test.ts index ac17c817e..7d42b65da 100644 --- a/src/shared/hooks/__tests__/useCompletionNotifications.test.ts +++ b/src/shared/hooks/__tests__/useCompletionNotifications.test.ts @@ -622,6 +622,7 @@ describe("useCompletionNotifications", () => { ); mocks.getCurrentWindow.mockReturnValue({ + isFocused: vi.fn().mockResolvedValue(true), onFocusChanged: vi.fn((handler) => { focusChanged = handler; return Promise.resolve(vi.fn()); @@ -824,6 +825,49 @@ describe("useCompletionNotifications", () => { expect(mocks.toast).not.toHaveBeenCalled(); }); + it("delivers a desktop notification for an actively viewed session completing before the focus query settles in an unfocused window", async () => { + const focusQuery = deferred(); + const { onFocusChanged } = makeAppWindow(() => focusQuery.promise); + + renderHook(() => useCompletionNotifications(vi.fn())); + await waitFor(() => expect(onFocusChanged).toHaveBeenCalled()); + + // The user is "viewing" this session, but the window is actually + // backgrounded — the hook must not drop the completion based on the + // placeholder focused default before the query settles. + useChatStore.setState({ + activeSessionId: "session-viewed", + isViewingActiveSession: true, + }); + completeSession("session-viewed", "Viewed background"); + + await act(async () => { + await Promise.resolve(); + }); + expect(mocks.invoke).not.toHaveBeenCalledWith( + "show_completion_notification", + expect.anything(), + ); + + focusQuery.resolve(false); + + await waitFor(() => + expect(mocks.invoke).toHaveBeenCalledWith( + "show_completion_notification", + { + body: "Viewed background finished", + sessionId: "session-viewed", + sound: "berd-sounds-4.mp3", + }, + ), + ); + const desktopCalls = mocks.invoke.mock.calls.filter( + ([command]) => command === "show_completion_notification", + ); + expect(desktopCalls).toHaveLength(1); + expect(mocks.toast).not.toHaveBeenCalled(); + }); + it("lets a focus event that arrives before the query settles win over the stale query result", async () => { const focusQuery = deferred(); const { onFocusChanged, fireFocusChanged } = makeAppWindow( diff --git a/src/shared/hooks/useCompletionNotifications.ts b/src/shared/hooks/useCompletionNotifications.ts index 3aea0fa71..522a8c132 100644 --- a/src/shared/hooks/useCompletionNotifications.ts +++ b/src/shared/hooks/useCompletionNotifications.ts @@ -263,13 +263,11 @@ export function useCompletionNotifications( pendingSessions.delete(sessionId); const chatStoreState = useChatStore.getState(); - const isViewingThisSession = isSessionActivelyViewed( - chatStoreState, - sessionId, - ); - // Skip if user is already watching this session in a focused window. - if (isViewingThisSession && windowFocusedRef.current) continue; - + // The "actively watching in a focused window" skip is evaluated + // only after the focus state is seeded (inside the buffered + // continuation below): checking it here would read the + // placeholder `true` default and silently drop a completion + // that lands while the mount-time focus query is in flight. const messages = chatStoreState.messagesBySession[sessionId] ?? []; const outcome = getCompletionOutcome(messages); const session = useChatSessionStore From 1c7e8a19a69f19ee48ff577fcaf4257d17f5d047 Mon Sep 17 00:00:00 2001 From: Kyle De Freitas Date: Tue, 13 Oct 2026 07:26:15 -0400 Subject: [PATCH 4/4] test(notifications): add browser e2e coverage for completion delivery routing Covers background->native, blur-event->native, focused-other-surface->toast, active-session suppression, and the disabled toggle against the Tauri IPC mock. Native delivery is asserted as the captured invoke, not a real OS notification. --- playwright.config.ts | 7 + tests/e2e/fixtures/tauri-mock.ts | 32 +++- tests/e2e/notifications.spec.ts | 244 +++++++++++++++++++++++++++++++ 3 files changed, 281 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/notifications.spec.ts diff --git a/playwright.config.ts b/playwright.config.ts index 29eab0b9a..adcc78d9b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -81,6 +81,13 @@ export default defineConfig({ ...devices["Desktop Chrome"], }, }, + { + name: "notifications", + testMatch: ["**/notifications.spec.ts"], + use: { + ...devices["Desktop Chrome"], + }, + }, { name: "voice-conversation", testMatch: [ diff --git a/tests/e2e/fixtures/tauri-mock.ts b/tests/e2e/fixtures/tauri-mock.ts index d29b2cd22..d38aeb4ae 100644 --- a/tests/e2e/fixtures/tauri-mock.ts +++ b/tests/e2e/fixtures/tauri-mock.ts @@ -293,7 +293,23 @@ export function buildInitScript(options?: { const SKILL_SOURCES = SKILLS.map(skillToSourceEntry); const POCKET_VOICE_SPOKEN_TEXTS = []; + // sessionId -> { promise, resolve } used to keep a session/prompt turn + // open until a test explicitly releases it. + const PROMPT_HOLDS = new Map(); + window.__GOOSE_E2E__ = { + holdPrompt: (sessionId) => { + let resolve; + const promise = new Promise((r) => { + resolve = r; + }); + PROMPT_HOLDS.set(sessionId, { promise, resolve }); + }, + releasePrompt: (sessionId) => { + const hold = PROMPT_HOLDS.get(sessionId); + PROMPT_HOLDS.delete(sessionId); + hold?.resolve(); + }, listAgentSources: () => clone(AGENT_SOURCES), clearAgentSources: () => { AGENT_SOURCES = PERSONAS.map(personaToSourceEntry); @@ -479,6 +495,15 @@ export function buildInitScript(options?: { }; persistAgentSources(); } + const holdRelease = + PROMPT_HOLDS.get(message.params?.sessionId) ?? + PROMPT_HOLDS.get("*"); + if (holdRelease) { + // A test asked to hold this turn open; resolve when released. + return holdRelease.promise.then(() => + jsonRpcResult(message.id, { stopReason: "end_turn" }), + ); + } return jsonRpcResult(message.id, { stopReason: "end_turn" }); } case "_goose/providers/list": @@ -649,10 +674,13 @@ export function buildInitScript(options?: { if (!response) { return; } - queueMicrotask(() => { + // Responses may be promises (e.g. a held session/prompt) so tests + // can control when an agent turn finishes. + Promise.resolve(response).then((resolved) => { + if (!resolved) return; this.dispatchEvent( new MessageEvent("message", { - data: JSON.stringify(response), + data: JSON.stringify(resolved), }), ); }); diff --git a/tests/e2e/notifications.spec.ts b/tests/e2e/notifications.spec.ts new file mode 100644 index 000000000..c9b59ca60 --- /dev/null +++ b/tests/e2e/notifications.spec.ts @@ -0,0 +1,244 @@ +/** + * Behavioral coverage for completion-notification delivery routing. + * + * These tests run against the browser Tauri IPC mock (tests/e2e/fixtures/ + * tauri-mock.ts): "native delivery" is observed as a captured + * `show_completion_notification` invoke, NOT as a real OS notification. + * Verifying the actual native notification surface requires a real Tauri + * runtime and is out of scope for this suite. + */ +import { test as base, expect, type Page } from "@playwright/test"; +import { buildInitScript } from "./fixtures/tauri-mock"; + +declare global { + interface Window { + __NOTIFY_E2E__: { + shown: Array<{ body?: string; sessionId?: string; sound?: unknown }>; + focused: boolean; + blurListeners: number; + }; + __GOOSE_E2E__: { + emitTauriEvent: (event: string, payload: unknown) => void; + holdPrompt: (sessionId: string) => void; + releasePrompt: (sessionId: string) => void; + }; + } +} + +/** + * Wraps the mocked Tauri invoke to capture notification commands and answer + * the window-focus seed query deterministically. + */ +function notificationCaptureScript(options: { initialFocused: boolean }) { + return ` + (() => { + window.__NOTIFY_E2E__ = { + shown: [], + focused: ${options.initialFocused}, + blurListeners: 0, + }; + const original = window.__TAURI_INTERNALS__.invoke.bind( + window.__TAURI_INTERNALS__, + ); + window.__TAURI_INTERNALS__.invoke = (cmd, args) => { + if (cmd === "show_completion_notification") { + window.__NOTIFY_E2E__.shown.push(args ?? {}); + return Promise.resolve(null); + } + if (cmd === "should_suppress_completion_notification") { + return Promise.resolve(false); + } + if (cmd === "plugin:window|is_focused") { + return Promise.resolve(window.__NOTIFY_E2E__.focused); + } + if (cmd === "plugin:event|listen" && args?.event === "tauri://blur") { + window.__NOTIFY_E2E__.blurListeners += 1; + } + return original(cmd, args); + }; + })(); + `; +} + +const test = base.extend<{ + notifyPage: Page; + initialFocused: boolean; + notificationPrefs: Record | null; +}>({ + initialFocused: [true, { option: true }], + notificationPrefs: [null, { option: true }], + notifyPage: async ({ page, initialFocused, notificationPrefs }, use) => { + await page.addInitScript({ + content: buildInitScript({ + // The default inventory configures only "claude" while the mocked + // _goose/defaults/read reports "openai", which trips the + // provider-setup-required home and disables the composer. Supply a + // configured openai entry so prompts can actually be sent. + providerInventory: [ + { + providerId: "openai", + providerName: "OpenAI", + description: "OpenAI provider", + defaultModel: "gpt-4.1", + configured: true, + providerType: "Preferred", + category: "model", + configKeys: [], + setupSteps: [], + supportsRefresh: true, + refreshing: false, + lastUpdatedAt: null, + lastRefreshAttemptAt: null, + lastRefreshError: null, + stale: false, + modelSelectionHint: null, + models: [ + { + id: "gpt-4.1", + name: "GPT-4.1", + family: "GPT", + recommended: true, + }, + ], + }, + ], + }), + }); + await page.addInitScript({ + content: notificationCaptureScript({ initialFocused }), + }); + if (notificationPrefs) { + await page.addInitScript( + (prefs) => + window.localStorage.setItem( + "goose:notifications", + JSON.stringify(prefs), + ), + notificationPrefs, + ); + } + await use(page); + }, +}); + +async function waitForHome(page: Page) { + await expect(page.getByText("Jump to session")).toBeVisible({ + timeout: 10_000, + }); +} + +/** Wait until the hook's focus listener is registered so a blur event lands. */ +async function waitForFocusListener(page: Page) { + await expect + .poll(() => page.evaluate(() => window.__NOTIFY_E2E__.blurListeners)) + .toBeGreaterThan(0); +} + +async function sendPromptFromHome(page: Page, text: string) { + const input = page.getByRole("textbox", { name: "Start a conversation" }); + await input.fill(text); + await input.press("Enter"); +} + +function shownNotifications(page: Page) { + return page.evaluate(() => window.__NOTIFY_E2E__.shown); +} + +test.describe("completion notification delivery (simulated Tauri)", () => { + test.describe("window starts backgrounded (seeded focus state)", () => { + test.use({ initialFocused: false }); + + test("routes a completion to native delivery exactly once with session metadata", async ({ + notifyPage: page, + }) => { + await page.goto("/"); + await waitForHome(page); + await sendPromptFromHome(page, "finish in background"); + + await expect.poll(() => shownNotifications(page)).toHaveLength(1); + const [shown] = await shownNotifications(page); + expect(shown.body).toBe("finish in background finished"); + expect(typeof shown.sessionId).toBe("string"); + expect(shown.sessionId?.length).toBeGreaterThan(0); + + // No in-app toast on the native path, and no duplicate native call. + await page.waitForTimeout(500); + await expect(page.getByText(/finished$/)).toHaveCount(0); + expect(await shownNotifications(page)).toHaveLength(1); + }); + }); + + test("blur event before completion routes to native delivery", async ({ + notifyPage: page, + }) => { + await page.goto("/"); + await waitForHome(page); + await waitForFocusListener(page); + await page.evaluate(() => { + window.__GOOSE_E2E__.holdPrompt("*"); + }); + await sendPromptFromHome(page, "background me"); + await page.evaluate(() => { + window.__GOOSE_E2E__.emitTauriEvent("tauri://blur", null); + window.__GOOSE_E2E__.releasePrompt("*"); + }); + + await expect.poll(() => shownNotifications(page)).toHaveLength(1); + await expect(page.getByText(/finished$/)).toHaveCount(0); + }); + + test("focused window viewing another surface shows the in-app toast instead", async ({ + notifyPage: page, + }) => { + await page.goto("/"); + await waitForHome(page); + await page.evaluate(() => { + window.__GOOSE_E2E__.holdPrompt("*"); + }); + await sendPromptFromHome(page, "long running turn"); + // Leave the session view while the turn is still running. + await page.getByTestId("nav-home").click(); + await waitForHome(page); + await page.evaluate(() => { + window.__GOOSE_E2E__.releasePrompt("*"); + }); + + await expect(page.getByText("long running turn finished")).toBeVisible(); + await expect( + page.getByRole("button", { name: "View", exact: true }), + ).toBeVisible(); + expect(await shownNotifications(page)).toHaveLength(0); + }); + + test("completion in the actively viewed focused session is suppressed on both channels", async ({ + notifyPage: page, + }) => { + await page.goto("/"); + await waitForHome(page); + await sendPromptFromHome(page, "watching this one"); + + // Give delivery routing time to (incorrectly) fire if it were going to. + await page.waitForTimeout(1500); + await expect(page.getByText(/finished$/)).toHaveCount(0); + expect(await shownNotifications(page)).toHaveLength(0); + }); + + test.describe("notifications disabled", () => { + test.use({ + initialFocused: false, + notificationPrefs: { enabled: false }, + }); + + test("delivers nothing even for a background completion", async ({ + notifyPage: page, + }) => { + await page.goto("/"); + await waitForHome(page); + await sendPromptFromHome(page, "disabled background"); + + await page.waitForTimeout(1500); + expect(await shownNotifications(page)).toHaveLength(0); + await expect(page.getByText(/finished$/)).toHaveCount(0); + }); + }); +});