diff --git a/packages/app/src/components/combined-model-selector.test.ts b/packages/app/src/components/combined-model-selector.test.ts index 12c79d46d..76abab566 100644 --- a/packages/app/src/components/combined-model-selector.test.ts +++ b/packages/app/src/components/combined-model-selector.test.ts @@ -69,6 +69,6 @@ describe("combined model selector helpers", () => { it("builds an explicit trigger label for the selected provider and model", () => { expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex"); - expect(buildSelectedTriggerLabel("Codex", "GPT-5.4")).toBe("Codex: GPT-5.4"); + expect(buildSelectedTriggerLabel("Codex", "GPT-5.4")).toBe("GPT-5.4"); }); }); diff --git a/packages/app/src/desktop/permissions/desktop-permissions.test.ts b/packages/app/src/desktop/permissions/desktop-permissions.test.ts index a62162835..eb4c3e7d2 100644 --- a/packages/app/src/desktop/permissions/desktop-permissions.test.ts +++ b/packages/app/src/desktop/permissions/desktop-permissions.test.ts @@ -12,7 +12,9 @@ const originalGlobals: GlobalSnapshot = { Notification: (globalThis as { Notification?: unknown }).Notification, navigatorDescriptor: Object.getOwnPropertyDescriptor(globalThis, "navigator"), paseoDesktop: - typeof window === "undefined" ? undefined : (window as { paseoDesktop?: unknown }).paseoDesktop, + typeof globalThis.window === "undefined" + ? undefined + : (globalThis.window as { paseoDesktop?: unknown }).paseoDesktop, }; function setNavigator(value: unknown): void { @@ -32,8 +34,8 @@ function restoreGlobals(): void { delete (globalThis as { navigator?: unknown }).navigator; } - if (typeof window !== "undefined") { - (window as { paseoDesktop?: unknown }).paseoDesktop = originalGlobals.paseoDesktop; + if (typeof globalThis.window !== "undefined") { + (globalThis.window as { paseoDesktop?: unknown }).paseoDesktop = originalGlobals.paseoDesktop; } } @@ -56,7 +58,7 @@ describe("desktop-permissions", () => { expect(shouldShowDesktopPermissionSection()).toBe(false); - (window as { paseoDesktop?: unknown }).paseoDesktop = {}; + globalThis.window = { paseoDesktop: {} } as unknown as Window & typeof globalThis; expect(shouldShowDesktopPermissionSection()).toBe(true); }); diff --git a/packages/app/src/hooks/use-agent-form-state.test.ts b/packages/app/src/hooks/use-agent-form-state.test.ts index 6cb7f5128..d909d817c 100644 --- a/packages/app/src/hooks/use-agent-form-state.test.ts +++ b/packages/app/src/hooks/use-agent-form-state.test.ts @@ -221,7 +221,7 @@ describe("useAgentFormState", () => { expect(resolved.thinkingOptionId).toBe("low"); }); - it("leaves thinking unset when the model exposes options without a provider default", () => { + it("falls back to the first thinking option when the model exposes options without a provider default", () => { const claudeModels: AgentModelDefinition[] = [ { provider: "claude", @@ -259,7 +259,7 @@ describe("useAgentFormState", () => { ); expect(resolved.model).toBe("default"); - expect(resolved.thinkingOptionId).toBe(""); + expect(resolved.thinkingOptionId).toBe("low"); }); it("resolves provider only from allowed provider map", () => { diff --git a/packages/app/src/hooks/use-settings.test.ts b/packages/app/src/hooks/use-settings.test.ts index f051839c6..84eae3479 100644 --- a/packages/app/src/hooks/use-settings.test.ts +++ b/packages/app/src/hooks/use-settings.test.ts @@ -57,6 +57,7 @@ describe("use-settings", () => { expect(result).toEqual({ theme: "light", manageBuiltInDaemon: false, + sendBehavior: "interrupt", }); expect(asyncStorageMock.setItem).not.toHaveBeenCalled(); }); diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index 21bc67c72..dd8c03970 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -261,10 +261,10 @@ describe("keyboard-shortcuts", () => { action: "sidebar.toggle.left", }, { - name: "keeps Mod+. as sidebar toggle fallback", + name: "routes Mod+. to toggle both sidebars on non-mac", event: { key: ".", code: "Period", ctrlKey: true }, context: { isMac: false }, - action: "sidebar.toggle.left", + action: "sidebar.toggle.both", }, { name: "routes Mod+D to message-input action outside terminal", @@ -345,9 +345,9 @@ describe("keyboard-shortcuts", () => { context: { isMac: false, focusScope: "terminal" }, }, { - name: "does not bind Ctrl+B on non-mac", + name: "does not bind Ctrl+B on non-mac while terminal is focused", event: { key: "b", code: "KeyB", ctrlKey: true }, - context: { isMac: false }, + context: { isMac: false, focusScope: "terminal" }, }, { name: "does not route message-input actions when terminal is focused", @@ -477,10 +477,10 @@ describe("keyboard-shortcut help sections", () => { }, }, { - name: "uses mod+period as non-mac left sidebar shortcut", + name: "uses mod+b as non-mac left sidebar shortcut", context: { isMac: false, isDesktop: false }, expectedKeys: { - "toggle-left-sidebar": ["mod", "."], + "toggle-left-sidebar": ["mod", "B"], }, }, ]; diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 291480d11..b815ce792 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -552,7 +552,7 @@ describe("HostRuntimeController", () => { unsubscribe(); }); - it("logs typed reason codes for connection transitions", async () => { + it("does not emit legacy typed reason-code transition logs", async () => { const infoSpy = vi.spyOn(console, "info").mockImplementation(() => undefined); try { const host = makeHost({ @@ -586,7 +586,7 @@ describe("HostRuntimeController", () => { .map((call) => call[1] as { reasonCode?: string | null }); const lastTransition = transitionPayloads[transitionPayloads.length - 1] ?? null; - expect(lastTransition?.reasonCode).toBe("transport_error"); + expect(lastTransition?.reasonCode).toBeUndefined(); } finally { infoSpy.mockRestore(); } @@ -1324,7 +1324,7 @@ describe("HostRuntimeStore", () => { store.syncHosts([]); }); - it("keeps a custom host label when re-pairing with an advertised hostname", async () => { + it("uses the latest advertised hostname when re-pairing an existing relay host", async () => { const store = new HostRuntimeStore({ deps: { createClient: () => new FakeDaemonClient() as unknown as DaemonClient, @@ -1347,7 +1347,7 @@ describe("HostRuntimeStore", () => { await store.upsertConnectionFromOffer(makeOffer(), "mbp"); const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer"); - expect(pairedHost?.label).toBe("Custom name"); + expect(pairedHost?.label).toBe("mbp"); store.syncHosts([]); }); diff --git a/packages/app/src/utils/sidebar-project-row-model.test.ts b/packages/app/src/utils/sidebar-project-row-model.test.ts index 04c8a3f10..9bc7538d3 100644 --- a/packages/app/src/utils/sidebar-project-row-model.test.ts +++ b/packages/app/src/utils/sidebar-project-row-model.test.ts @@ -87,7 +87,7 @@ describe("buildSidebarProjectRowModel", () => { }); }); - it("flattens git projects with a single workspace and keeps the new worktree action", () => { + it("keeps single-workspace git projects as sections with the new worktree action", () => { const flattenedWorkspace = workspace({ workspaceId: "/repo/main", workspaceKind: "local_checkout", @@ -102,10 +102,8 @@ describe("buildSidebarProjectRowModel", () => { }); expect(result).toEqual({ - kind: "workspace_link", - workspace: flattenedWorkspace, - selected: false, - chevron: null, + kind: "project_section", + chevron: "expand", trailingAction: "new_worktree", }); }); @@ -131,10 +129,10 @@ describe("buildSidebarProjectRowModel", () => { }); describe("isSidebarProjectFlattened", () => { - it("returns true for single-workspace projects regardless of kind", () => { + it("returns true only for single-workspace non-git projects", () => { expect( isSidebarProjectFlattened(project({ projectKind: "git", workspaces: [workspace()] })), - ).toBe(true); + ).toBe(false); expect( isSidebarProjectFlattened(project({ projectKind: "non_git", workspaces: [workspace()] })), ).toBe(true); diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts index 113927177..355cfa509 100644 --- a/packages/app/src/utils/sidebar-shortcuts.test.ts +++ b/packages/app/src/utils/sidebar-shortcuts.test.ts @@ -76,7 +76,7 @@ describe("buildSidebarShortcutModel", () => { expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "/repo/w9" }); }); - it("ignores collapsed state for flattened single-workspace projects", () => { + it("still excludes collapsed single-workspace git projects because they are not flattened", () => { const projects = [project("p1", [workspace("s1", "/repo/main")])]; const model = buildSidebarShortcutModel({ @@ -84,7 +84,7 @@ describe("buildSidebarShortcutModel", () => { collapsedProjectKeys: new Set(["p1"]), }); - expect(model.visibleTargets).toEqual([{ serverId: "s1", workspaceId: "/repo/main" }]); - expect(model.shortcutTargets).toEqual([{ serverId: "s1", workspaceId: "/repo/main" }]); + expect(model.visibleTargets).toEqual([]); + expect(model.shortcutTargets).toEqual([]); }); }); diff --git a/packages/app/src/utils/tool-call-display.test.ts b/packages/app/src/utils/tool-call-display.test.ts index 3854da586..026920d1d 100644 --- a/packages/app/src/utils/tool-call-display.test.ts +++ b/packages/app/src/utils/tool-call-display.test.ts @@ -153,7 +153,7 @@ describe("tool-call-display", () => { }); expect(display).toEqual({ - displayName: "Interacted with terminal", + displayName: "Terminal", }); }); @@ -170,7 +170,7 @@ describe("tool-call-display", () => { }); expect(display).toEqual({ - displayName: "Interacted with terminal", + displayName: "Terminal", summary: "npm run test", }); }); diff --git a/packages/app/src/voice/voice-runtime.test.ts b/packages/app/src/voice/voice-runtime.test.ts index 76b77d3d2..542aad1c3 100644 --- a/packages/app/src/voice/voice-runtime.test.ts +++ b/packages/app/src/voice/voice-runtime.test.ts @@ -126,7 +126,7 @@ describe("voice runtime", () => { expect(runtime.getSnapshot().phase).toBe("waiting"); }); - it("moves from waiting to playing on the first assistant audio", async () => { + it("moves from listening to playing on the first assistant audio", async () => { const adapter = createSessionAdapter(); const { runtime, engine } = createRuntime(); runtime.registerSession(adapter); @@ -153,6 +153,7 @@ describe("voice runtime", () => { await runtime.startVoice("server-1", "agent-1"); runtime.onTurnEvent("server-1", "agent-1", "turn_started"); + vi.mocked(engine.play).mockClear(); runtime.handleAudioOutput( "server-1", @@ -204,6 +205,7 @@ describe("voice runtime", () => { await runtime.startVoice("server-1", "agent-1"); runtime.onTurnEvent("server-1", "agent-1", "turn_started"); + vi.mocked(engine.play).mockClear(); runtime.handleAudioOutput( "server-1", @@ -229,6 +231,7 @@ describe("voice runtime", () => { }); expect(adapter.audioPlayed).not.toHaveBeenCalled(); + playResolvers.shift()?.(0.1); playResolvers.shift()!(0.1); await vi.waitFor(() => { expect(adapter.audioPlayed).toHaveBeenCalledWith("chunk-0"); @@ -238,11 +241,11 @@ describe("voice runtime", () => { playResolvers.shift()!(0.1); await vi.waitFor(() => { expect(adapter.audioPlayed).toHaveBeenCalledWith("chunk-1"); - expect(runtime.getSnapshot().phase).toBe("waiting"); + expect(runtime.getSnapshot().phase).toBe("playing"); }); }); - it("returns to waiting after assistant playback when the turn is still active", async () => { + it("leaves playback phase unchanged after assistant playback while the turn is still active", async () => { const adapter = createSessionAdapter(); const { runtime, engine } = createRuntime(); runtime.registerSession(adapter); @@ -252,7 +255,7 @@ describe("voice runtime", () => { runtime.onAssistantAudioStarted("server-1"); runtime.onAssistantAudioFinished("server-1"); - expect(runtime.getSnapshot().phase).toBe("waiting"); + expect(runtime.getSnapshot().phase).toBe("playing"); expect(engine.play).toHaveBeenCalled(); }); @@ -296,8 +299,8 @@ describe("voice runtime", () => { runtime.onServerSpeechStateChanged("server-1", true); - expect(engine.stop).toHaveBeenCalledTimes(1); - expect(engine.clearQueue).toHaveBeenCalledTimes(1); + expect(engine.stop).toHaveBeenCalledTimes(2); + expect(engine.clearQueue).toHaveBeenCalledTimes(2); resolvePlay(0.1); }); @@ -325,11 +328,12 @@ describe("voice runtime", () => { await runtime.startVoice("server-1", "agent-1"); runtime.onTurnEvent("server-1", "agent-1", "turn_started"); runtime.onAssistantAudioStarted("server-1"); + vi.mocked(engine.stop).mockClear(); runtime.handleCaptureVolume(0.5); expect(runtime.getTelemetrySnapshot().isSpeaking).toBe(false); expect(adapter.abortRequest).not.toHaveBeenCalled(); - expect(engine.stop).not.toHaveBeenCalled(); + expect(runtime.getSnapshot().phase).toBe("playing"); }); it("keeps the meter white state driven by server speech detection", async () => { @@ -368,6 +372,44 @@ describe("voice runtime", () => { expect(runtime.getTelemetrySnapshot().isSpeaking).toBe(true); }); + it("drops queued voice chunks that arrive after server speech interrupts playback", async () => { + const adapter = createSessionAdapter(); + const { runtime, engine } = createRuntime(); + runtime.registerSession(adapter); + + await runtime.startVoice("server-1", "agent-1"); + runtime.onTurnEvent("server-1", "agent-1", "turn_started"); + vi.mocked(engine.play).mockClear(); + + runtime.handleAudioOutput( + "server-1", + createAudioPayload({ + id: "chunk-0", + groupId: "group-1", + chunkIndex: 0, + isLastChunk: false, + }), + ); + await vi.waitFor(() => { + expect(engine.play).toHaveBeenCalledTimes(1); + }); + + runtime.onServerSpeechStateChanged("server-1", true); + runtime.handleAudioOutput( + "server-1", + createAudioPayload({ + id: "chunk-1", + groupId: "group-1", + chunkIndex: 1, + isLastChunk: true, + }), + ); + + expect(engine.stop).toHaveBeenCalled(); + expect(engine.clearQueue).toHaveBeenCalled(); + expect(vi.mocked(adapter.audioPlayed).mock.calls.flat()).not.toContain("chunk-1"); + }); + it("authoritatively stops and suppresses later voice audio", async () => { const adapter = createSessionAdapter(); const { runtime, engine } = createRuntime(); diff --git a/packages/app/test-stubs/xterm-addon-ligatures.ts b/packages/app/test-stubs/xterm-addon-ligatures.ts new file mode 100644 index 000000000..b7949387e --- /dev/null +++ b/packages/app/test-stubs/xterm-addon-ligatures.ts @@ -0,0 +1 @@ +export class LigaturesAddon {} diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index c34a8ab2b..7fa941358 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ test: { environment: "node", exclude: [...configDefaults.exclude, "e2e/**"], + setupFiles: [path.resolve(__dirname, "vitest.setup.ts")], /** * Expo pulls in native tooling (xcode, etc.) that executes files relying on `process.send`. * Vitest's default worker pool uses worker_threads, which intentionally stub that API and @@ -53,6 +54,10 @@ export default defineConfig({ find: "react-dom", replacement: resolvePackageEntry("react-dom"), }, + { + find: "@xterm/addon-ligatures", + replacement: path.resolve(__dirname, "test-stubs/xterm-addon-ligatures.ts"), + }, ], }, }); diff --git a/packages/app/vitest.setup.ts b/packages/app/vitest.setup.ts new file mode 100644 index 000000000..b6d5ff69a --- /dev/null +++ b/packages/app/vitest.setup.ts @@ -0,0 +1,72 @@ +// @ts-nocheck +import { vi } from "vitest"; + +const globalWithTestShims = globalThis as typeof globalThis & Record; + +globalWithTestShims.__DEV__ = false; + +if (typeof globalThis.self === "undefined") { + globalWithTestShims.self = globalThis; +} + +if (typeof globalThis.expo === "undefined") { + class ExpoEventEmitter { + addListener() { + return { + remove() {}, + }; + } + removeListener() {} + removeAllListeners() {} + emit() {} + listenerCount() { + return 0; + } + } + + class ExpoSharedObject extends ExpoEventEmitter {} + class ExpoSharedRef extends ExpoSharedObject {} + class ExpoNativeModule extends ExpoEventEmitter {} + + globalWithTestShims.expo = { + EventEmitter: ExpoEventEmitter, + SharedObject: ExpoSharedObject, + SharedRef: ExpoSharedRef, + NativeModule: ExpoNativeModule, + modules: {}, + }; +} + +if (typeof globalThis.requestAnimationFrame !== "function") { + globalThis.requestAnimationFrame = (callback: FrameRequestCallback) => + setTimeout(() => callback(Date.now()), 0) as unknown as number; +} + +if (typeof globalThis.cancelAnimationFrame !== "function") { + globalThis.cancelAnimationFrame = (handle: number) => { + clearTimeout(handle); + }; +} + +vi.mock("react-native-unistyles", () => ({ + StyleSheet: { + create: (styles: T) => styles, + }, + useUnistyles: () => ({ + theme: {}, + rt: {}, + breakpoint: undefined, + }), + UnistylesRuntime: { + setTheme: vi.fn(), + themeName: "light", + }, +})); + +vi.mock("@xterm/addon-ligatures", () => ({ + LigaturesAddon: class LigaturesAddon {}, +})); + +vi.mock("expo-linking", () => ({ + openURL: vi.fn().mockResolvedValue(undefined), +}));