diff --git a/src/client/src/appShell/viewportPositionRepair.test.ts b/src/client/src/appShell/viewportPositionRepair.test.ts index 588ad69..302ed86 100644 --- a/src/client/src/appShell/viewportPositionRepair.test.ts +++ b/src/client/src/appShell/viewportPositionRepair.test.ts @@ -80,15 +80,27 @@ describe("ViewportPositionRepairer", () => { const timer = firstMapEntry(scheduler.timers); expect(timer[1].delayMs).toBe(VIEWPORT_POSITION_REPAIR_DELAY_MS); + scheduler.documentElement.scrollTop = 56; + scheduler.body.scrollTop = 78; scheduler.runAnimationFrame(firstFrame); - expect(scheduler.scrollCalls).toHaveLength(2); + expect(scheduler.scrollCalls).toEqual([[0, 0], [0, 0]]); + expect(scheduler.documentElement.scrollTop).toBe(0); + expect(scheduler.body.scrollTop).toBe(0); const secondFrame = firstMapKey(scheduler.animationFrames); + scheduler.documentElement.scrollTop = 90; + scheduler.body.scrollTop = 123; scheduler.runAnimationFrame(secondFrame); - expect(scheduler.scrollCalls).toHaveLength(3); + expect(scheduler.scrollCalls).toEqual([[0, 0], [0, 0], [0, 0]]); + expect(scheduler.documentElement.scrollTop).toBe(0); + expect(scheduler.body.scrollTop).toBe(0); + scheduler.documentElement.scrollTop = 34; + scheduler.body.scrollTop = 12; scheduler.runTimer(timer[0]); - expect(scheduler.scrollCalls).toHaveLength(4); + expect(scheduler.scrollCalls).toEqual([[0, 0], [0, 0], [0, 0], [0, 0]]); + expect(scheduler.documentElement.scrollTop).toBe(0); + expect(scheduler.body.scrollTop).toBe(0); }); it("replaces pending scheduled repairs", () => { @@ -102,6 +114,10 @@ describe("ViewportPositionRepairer", () => { expect(scheduler.canceledAnimationFrames).toEqual([firstFrame]); expect(scheduler.clearedTimers).toEqual([firstTimer]); + expect(scheduler.animationFrames.has(firstFrame)).toBe(false); + expect(scheduler.timers.has(firstTimer)).toBe(false); + expect(scheduler.animationFrames.size).toBe(1); + expect(scheduler.timers.size).toBe(1); }); it("clears pending work when repair is no longer needed", () => { @@ -115,5 +131,7 @@ describe("ViewportPositionRepairer", () => { expect(scheduler.canceledAnimationFrames).toEqual([firstFrame]); expect(scheduler.clearedTimers).toEqual([firstTimer]); + expect(scheduler.animationFrames.size).toBe(0); + expect(scheduler.timers.size).toBe(0); }); }); diff --git a/src/client/src/cachedNewSessions.test.ts b/src/client/src/cachedNewSessions.test.ts index 85e235f..bd1f976 100644 --- a/src/client/src/cachedNewSessions.test.ts +++ b/src/client/src/cachedNewSessions.test.ts @@ -57,9 +57,12 @@ describe("cached new sessions", () => { rememberCachedNewSession(baseSession, "local", storage); rememberCachedNewSession({ ...baseSession, id: "other", cwd: "/other" }, "local", storage); - expect(mergeCachedNewSessions("/repo", [], "local", storage).map((session) => session.id)).toEqual(["session-1"]); - expect(mergeCachedNewSessions("/repo", [baseSession], "local", storage).map((session) => session.id)).toEqual(["session-1"]); - expect(isCachedNewSessionInfo(mergeCachedNewSessions("/repo", [baseSession], "local", storage)[0])).toBe(false); + const cachedOnly = mergeCachedNewSessions("/repo", [], "local", storage); + const mergedWithServerSession = mergeCachedNewSessions("/repo", [baseSession], "local", storage); + + expect(cachedOnly.map((session) => session.id)).toEqual(["session-1"]); + expect(mergedWithServerSession.map((session) => session.id)).toEqual(["session-1"]); + expect(isCachedNewSessionInfo(mergedWithServerSession[0])).toBe(false); expect(loadCachedNewSessions(storage).map((session) => session.id)).toEqual(["other"]); }); diff --git a/src/client/src/chatScrollPosition.test.ts b/src/client/src/chatScrollPosition.test.ts index 01c32d2..5284432 100644 --- a/src/client/src/chatScrollPosition.test.ts +++ b/src/client/src/chatScrollPosition.test.ts @@ -135,7 +135,7 @@ describe("ChatScrollController", () => { expect(JSON.parse(storage.getItem(key) ?? "{}")).toEqual({ mode: "bottom" }); }); - it("captures the session id when scheduling a delayed save", () => { + it("cancels the previous delayed save and passes the latest session id", () => { const scheduler = new ManualScheduler(); const controller = new ChatScrollController(new MemoryScrollStorage(), scheduler); const saved: string[] = []; diff --git a/src/client/src/components/selectableRow.test.ts b/src/client/src/components/selectableRow.test.ts index ce63d26..a49255b 100644 --- a/src/client/src/components/selectableRow.test.ts +++ b/src/client/src/components/selectableRow.test.ts @@ -8,7 +8,7 @@ describe("selectable row activation", () => { expect(action).toHaveBeenCalledOnce(); }); - it("preserves contributed links and other interactive elements", () => { + it("preserves contributed links inside rows", () => { const action = vi.fn(); activateSelectableRow(eventWithPath(matchTarget((selector: string) => selector.includes("a[href]"))), action); expect(action).not.toHaveBeenCalled(); @@ -57,6 +57,8 @@ describe("selectable row activation", () => { expect(handleSelectableRowKeyboard(event, { activate: vi.fn(), cancel })).toBe(true); expect(cancel).toHaveBeenCalledOnce(); + expect(event.preventDefault).toHaveBeenCalledOnce(); + expect(event.stopPropagation).toHaveBeenCalledOnce(); }); }); diff --git a/src/client/src/components/settings/SettingsGeneralPanel.test.ts b/src/client/src/components/settings/SettingsGeneralPanel.test.ts index 9c0250a..570c182 100644 --- a/src/client/src/components/settings/SettingsGeneralPanel.test.ts +++ b/src/client/src/components/settings/SettingsGeneralPanel.test.ts @@ -123,25 +123,6 @@ describe("settings-general-panel save payloads", () => { expect(getPanelProperty(panel, "machineLocalError")).toBe(""); }); - it("clears upload defaults with a selected-machine-safe patch", async () => { - const panel = new SettingsGeneralPanel(); - const onSaveMachineConfig = vi.fn(); - panel.onSaveMachineConfig = onSaveMachineConfig; - setPanelProperty(panel, "machineDraft", { - allowedPathsText: "", - uploadDefaultFolder: "", - } satisfies MachineAccessConfigDraft); - - await callPanelPromise(panel, "saveMachineAccessConfig", new Event("submit", { cancelable: true })); - - expect(onSaveMachineConfig.mock.calls).toEqual([[ - { - pathAccess: { allowedPaths: [] }, - uploads: {}, - }, - ]]); - }); - it("keeps invalid upload folders local and does not save selected-machine config", async () => { const panel = new SettingsGeneralPanel(); const onSaveMachineConfig = vi.fn(); diff --git a/src/client/src/components/settings/SettingsPackagesPanel.test.ts b/src/client/src/components/settings/SettingsPackagesPanel.test.ts index 7e97ea9..e639549 100644 --- a/src/client/src/components/settings/SettingsPackagesPanel.test.ts +++ b/src/client/src/components/settings/SettingsPackagesPanel.test.ts @@ -59,7 +59,7 @@ describe("settings-packages-panel layout", () => { expect(rendered).not.toContain("Pi package list unavailable"); }); - it("orders package load errors before the trusted-code warning while preserving loaded data", () => { + it("orders package errors before the trusted-code warning while preserving loaded data", () => { const panel = new SettingsPackagesPanel(); panel.targetMachine = remoteTarget; panel.packagesResponse = { packages: [packageInfo("npm:@acme/tools")] }; diff --git a/src/client/src/components/settings/SettingsPanelFrame.test.ts b/src/client/src/components/settings/SettingsPanelFrame.test.ts index 46cc2d0..0eafee2 100644 --- a/src/client/src/components/settings/SettingsPanelFrame.test.ts +++ b/src/client/src/components/settings/SettingsPanelFrame.test.ts @@ -28,7 +28,7 @@ describe("settings-panel-frame", () => { expect(rendered.indexOf('class="notice-stack"')).toBeLessThan(rendered.indexOf('class="content"')); }); - it("maps notice types to consistent default tones and roles", () => { + it("maps representative notice types to consistent default tones and roles", () => { const notices: readonly SettingsNotice[] = [ { type: "availability", content: "Configuration unavailable." }, { type: "success", content: "Saved." }, @@ -41,7 +41,12 @@ describe("settings-panel-frame", () => { const values = collectTemplateValues(frame.render()); expect(notices.map(settingsNoticeTone)).toEqual(["error", "success", "warning", "info"]); - expect(values).toEqual(expect.arrayContaining(["notice error", "alert", "notice success", "status", "notice warning", "note", "notice info"])); + expect(values.filter(isNoticeClassOrRole)).toEqual([ + "notice error", "alert", + "notice success", "status", + "notice warning", "note", + "notice info", "note", + ]); }); it("wires the default header action through the frame", () => { @@ -145,3 +150,8 @@ function isStringArray(value: unknown): value is string[] { function isActionHandler(value: unknown): value is () => void { return typeof value === "function"; } + +function isNoticeClassOrRole(value: unknown): value is string { + return typeof value === "string" + && (value.startsWith("notice ") || value === "alert" || value === "status" || value === "note"); +} diff --git a/src/client/src/components/settings/settingsConfigDraft.test.ts b/src/client/src/components/settings/settingsConfigDraft.test.ts index febf6ee..6e13e75 100644 --- a/src/client/src/components/settings/settingsConfigDraft.test.ts +++ b/src/client/src/components/settings/settingsConfigDraft.test.ts @@ -1,7 +1,5 @@ import { describe, expect, it } from "vitest"; import { - configFromDraft, - draftFromConfig, gatewayServerConfigFromDraft, gatewayServerDraftFromConfig, machineAccessConfigPatchFromDraft, @@ -28,28 +26,42 @@ describe("settings config drafts", () => { allowedPathsText: "/tmp\n~/SDKs", uploadDefaultFolder: "manual/uploads", }); + expect(gatewayServerDraftFromConfig({ allowedHosts: true }).allowedHostsMode).toBe("all"); }); - it("builds gateway server saves without changing selected-machine-safe config values", () => { + it("builds gateway server saves without dropping preserved config values", () => { expect(gatewayServerConfigFromDraft({ host: " gateway.local ", port: "9000", allowedHostsMode: "all", allowedHostsText: "ignored.local", }, { + shortcuts: { "core:view.chat": "mod+1" }, + plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/old"] }, uploads: { defaultFolder: "old/uploads" }, maxUploadBytes: 1234, spawnSessions: true, + subsessions: false, })).toEqual({ host: "gateway.local", port: 9000, allowedHosts: true, + shortcuts: { "core:view.chat": "mod+1" }, + plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/old"] }, uploads: { defaultFolder: "old/uploads" }, maxUploadBytes: 1234, spawnSessions: true, + subsessions: false, }); + + expect(gatewayServerConfigFromDraft({ + host: "", + port: "", + allowedHostsMode: "list", + allowedHostsText: "example.local, 192.168.1.20\n", + })).toEqual({ allowedHosts: ["example.local", "192.168.1.20"] }); }); it("builds selected-machine access/upload patches only from selected-machine-safe fields", () => { @@ -77,64 +89,10 @@ describe("settings config drafts", () => { expect(() => machineAccessConfigPatchFromDraft({ allowedPathsText: "", uploadDefaultFolder: "../secret" })).toThrow("Upload default folder must not contain path traversal."); }); - it("converts PI WEB config values to editable general settings drafts", () => { - expect(draftFromConfig({ host: "0.0.0.0", port: 8504, allowedHosts: ["example.local", "192.168.1.20"], pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } })).toEqual({ - host: "0.0.0.0", - port: "8504", - allowedHostsMode: "list", - allowedHostsText: "example.local\n192.168.1.20", - allowedPathsText: "/tmp\n~/SDKs", - }); - expect(draftFromConfig({ allowedHosts: true }).allowedHostsMode).toBe("all"); - }); - - it("converts drafts back to config while preserving non-general preferences", () => { - expect(configFromDraft({ - host: " 127.0.0.1 ", - port: "9000", - allowedHostsMode: "list", - allowedHostsText: "example.local, 192.168.1.20\n", - allowedPathsText: "/tmp\n~/SDKs\n", - }, { shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/old"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234 })).toEqual({ - host: "127.0.0.1", - port: 9000, - allowedHosts: ["example.local", "192.168.1.20"], - shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, - plugins: { info: { enabled: false } }, - pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] }, - uploads: { defaultFolder: "manual/uploads" }, - maxUploadBytes: 1234, - }); - }); - - it("removes global path access when the allowed paths field is cleared", () => { - expect(configFromDraft({ - host: "", - port: "", - allowedHostsMode: "list", - allowedHostsText: "", - allowedPathsText: "", - }, { pathAccess: { allowedPaths: ["/old"] } })).not.toHaveProperty("pathAccess"); - }); - - it("rejects relative external paths before saving", () => { - expect(() => configFromDraft({ - host: "", - port: "", - allowedHostsMode: "list", - allowedHostsText: "", + it("rejects relative external paths before saving selected-machine access", () => { + expect(() => machineAccessConfigPatchFromDraft({ allowedPathsText: "relative/path", + uploadDefaultFolder: "", })).toThrow("Allowed external paths must be absolute paths or start with ~"); }); - - it("preserves the spawnSessions flag when saving general settings", () => { - const result = configFromDraft({ - host: "", - port: "", - allowedHostsMode: "list", - allowedHostsText: "", - allowedPathsText: "", - }, { spawnSessions: true }); - expect(result.spawnSessions).toBe(true); - }); }); diff --git a/src/client/src/components/settings/settingsConfigDraft.ts b/src/client/src/components/settings/settingsConfigDraft.ts index 90e4606..917b9d4 100644 --- a/src/client/src/components/settings/settingsConfigDraft.ts +++ b/src/client/src/components/settings/settingsConfigDraft.ts @@ -12,10 +12,6 @@ export interface MachineAccessConfigDraft { uploadDefaultFolder: string; } -export interface ConfigDraft extends GatewayServerConfigDraft { - allowedPathsText: string; -} - export function emptyGatewayServerConfigDraft(): GatewayServerConfigDraft { return { host: "", port: "", allowedHostsMode: "list", allowedHostsText: "" }; } @@ -40,10 +36,6 @@ export function machineAccessDraftFromConfig(config: PiWebConfigValues): Machine }; } -export function draftFromConfig(config: PiWebConfigValues): ConfigDraft { - return { ...gatewayServerDraftFromConfig(config), allowedPathsText: machineAccessDraftFromConfig(config).allowedPathsText }; -} - export function gatewayServerConfigFromDraft(draft: GatewayServerConfigDraft, baseConfig: PiWebConfigValues = {}): PiWebConfigValues { const config = preservedGatewayConfigRemainder(baseConfig); const host = draft.host.trim(); @@ -67,14 +59,6 @@ export function machineAccessConfigPatchFromDraft(draft: MachineAccessConfigDraf }; } -export function configFromDraft(draft: ConfigDraft, baseConfig: PiWebConfigValues = {}): PiWebConfigValues { - const config = gatewayServerConfigFromDraft(draft, baseConfig); - const allowedPaths = parseAllowedPathsText(draft.allowedPathsText); - if (allowedPaths.length > 0) config.pathAccess = { allowedPaths }; - else delete config.pathAccess; - return config; -} - function preservedGatewayConfigRemainder(baseConfig: PiWebConfigValues): PiWebConfigValues { return { ...(baseConfig.shortcuts === undefined ? {} : { shortcuts: baseConfig.shortcuts }), diff --git a/src/client/src/components/settings/settingsSessiondConfig.test.ts b/src/client/src/components/settings/settingsSessiondConfig.test.ts index b669882..f3451e3 100644 --- a/src/client/src/components/settings/settingsSessiondConfig.test.ts +++ b/src/client/src/components/settings/settingsSessiondConfig.test.ts @@ -5,9 +5,7 @@ import { mergeSelectedMachineSessiondConfig, spawnSessionsConfigPatch, subsessio describe("session daemon settings config helpers", () => { it("builds daemon-only save patches for the sessiond toggles", () => { expect(spawnSessionsConfigPatch(false)).toEqual({ spawnSessions: false }); - expect(Object.keys(spawnSessionsConfigPatch(false))).toEqual(["spawnSessions"]); expect(subsessionsConfigPatch(true)).toEqual({ subsessions: true }); - expect(Object.keys(subsessionsConfigPatch(true))).toEqual(["subsessions"]); }); it("merges local selected-machine daemon config into gateway config without dropping gateway-only values", () => { diff --git a/src/client/src/controllers/sessionController.test.ts b/src/client/src/controllers/sessionController.test.ts index 2baf0fe..385de44 100644 --- a/src/client/src/controllers/sessionController.test.ts +++ b/src/client/src/controllers/sessionController.test.ts @@ -3,6 +3,7 @@ import { api as defaultApi, type MessagePage, type PromptAttachment, type Sessio import type { SessionUiEvent } from "../sessionSocket"; import { isCachedNewSessionInfo, loadCachedNewSessions, markCachedNewSessionInfo, rememberCachedNewSession } from "../cachedNewSessions"; import { initialAppState, type AppState } from "../appState"; +import { ChatTranscriptStore } from "../chatTranscriptStore"; import { machineSessionKey } from "../machineKeys"; import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; import { loadDraft, saveDraft } from "../promptDraftStorage"; @@ -1414,8 +1415,10 @@ describe("SessionController", () => { }); it("reloads the selected session from disk, discards the cached transcript, and re-fetches history", async () => { - Object.defineProperty(globalThis, "localStorage", { value: new MemoryStorage(), configurable: true }); const persistedSession = { ...oldSession, persisted: true }; + const cacheKey = sessionKey(oldSession.id); + const freshPage: MessagePage = { messages: [{ role: "assistant", content: "fresh from disk" }], start: 1, total: 2 }; + const cachedPages = new Map([[cacheKey, { messages: [{ role: "user", content: "stale cached transcript" }], start: 0, total: 2 }]]); const reloadCalls: string[] = []; const messageCalls: string[] = []; let state: AppState = { @@ -1433,7 +1436,7 @@ describe("SessionController", () => { }, messages: (session) => { messageCalls.push(sessionLookupId(session)); - return Promise.resolve(emptyPage); + return Promise.resolve(freshPage); }, status: (session) => Promise.resolve(status(sessionLookupId(session))), }; @@ -1442,13 +1445,24 @@ describe("SessionController", () => { (patch) => { state = { ...state, ...patch }; }, () => undefined, new InMemorySessionSelectionMemory(), - { api, socket: new FakeSocket() }, + { + api, + socket: new FakeSocket(), + transcripts: new ChatTranscriptStore({ + read: (sessionId) => cachedPages.get(sessionId), + write: (sessionId, page) => { cachedPages.set(sessionId, page); }, + remove: (sessionId) => { cachedPages.delete(sessionId); }, + }), + }, ); await controller.reloadSession(persistedSession); expect(reloadCalls).toEqual([oldSession.id]); - expect(messageCalls).toContain(oldSession.id); + expect(messageCalls).toEqual([oldSession.id]); + expect(cachedPages.get(cacheKey)).toEqual(freshPage); + expect(state.messages).toEqual([{ role: "assistant", parts: [{ type: "text", text: "fresh from disk" }] }]); + expect(state.messagePageStart).toBe(1); expect(state.error).toBe(""); }); diff --git a/src/client/src/controllers/sessionSelection.test.ts b/src/client/src/controllers/sessionSelection.test.ts index 6880259..8c13774 100644 --- a/src/client/src/controllers/sessionSelection.test.ts +++ b/src/client/src/controllers/sessionSelection.test.ts @@ -28,18 +28,12 @@ describe("selectPreferredSession", () => { expect(selectPreferredSession(sessions)).toBeUndefined(); }); - it("can remember an archived selected session", () => { + it("returns a remembered archived session before falling back to active sessions", () => { const sessions = [{ ...testSession("s1"), archived: true }, testSession("s2")]; expect(selectPreferredSession(sessions, { latestSessionId: "s1" })?.id).toBe("s1"); }); - it("can remember an archived selected session when only archived sessions remain", () => { - const sessions = [{ ...testSession("s1"), archived: true }]; - - expect(selectPreferredSession(sessions, { latestSessionId: "s1" })?.id).toBe("s1"); - }); - it("falls back to the first active session when the remembered session no longer exists", () => { const sessions = [{ ...testSession("s1"), archived: true }, testSession("s2")]; diff --git a/src/client/src/controllers/workspaceSelection.test.ts b/src/client/src/controllers/workspaceSelection.test.ts index 477d3f9..a2a8af1 100644 --- a/src/client/src/controllers/workspaceSelection.test.ts +++ b/src/client/src/controllers/workspaceSelection.test.ts @@ -22,7 +22,7 @@ describe("selectPreferredWorkspace", () => { expect(selectPreferredWorkspace(workspaces, { latestWorkspaceId: "old" })?.id).toBe("main"); }); - it("preserves explicit invalid target behavior", () => { + it("does not fall back to remembered workspace when the explicit target is invalid", () => { const workspaces = [testWorkspace("main"), testWorkspace("feature")]; expect(selectPreferredWorkspace(workspaces, { targetWorkspaceId: "old", latestWorkspaceId: "feature" })).toBeUndefined(); diff --git a/src/client/src/inputModes.test.ts b/src/client/src/inputModes.test.ts index f4e8ea3..20e84df 100644 --- a/src/client/src/inputModes.test.ts +++ b/src/client/src/inputModes.test.ts @@ -1,27 +1,27 @@ import { describe, expect, it } from "vitest"; import { inputModeForDraft, inputModesEqual, isShellInput } from "./inputModes"; -describe("inputModeForDraft", () => { - it("detects shell input and context-excluded shell input after leading whitespace", () => { +describe("input mode helpers", () => { + it("detects shell mode and context-excluded shell mode after leading whitespace", () => { expect(inputModeForDraft(" ! npm test")).toEqual({ kind: "shell", excludeFromContext: false }); expect(inputModeForDraft("\n!! secret command")).toEqual({ kind: "shell", excludeFromContext: true }); expect(isShellInput(" ! pwd")).toBe(true); }); - it("detects slash commands only for the current token", () => { + it("detects slash-command mode from the current token", () => { expect(inputModeForDraft("/compact")).toEqual({ kind: "command" }); expect(inputModeForDraft("please /compact")).toEqual({ kind: "command" }); expect(inputModeForDraft("please mention/path")).toEqual({ kind: "normal" }); }); - it("treats modes as equal only when kind and shell context-exclusion match", () => { + it("compares modes by kind and shell context-exclusion", () => { expect(inputModesEqual({ kind: "normal" }, { kind: "normal" })).toBe(true); expect(inputModesEqual({ kind: "normal" }, { kind: "command" })).toBe(false); expect(inputModesEqual({ kind: "shell", excludeFromContext: false }, { kind: "shell", excludeFromContext: false })).toBe(true); expect(inputModesEqual({ kind: "shell", excludeFromContext: false }, { kind: "shell", excludeFromContext: true })).toBe(false); }); - it("detects file completion contexts", () => { + it("collapses file completion triggers to file mode", () => { expect(inputModeForDraft("open @src/main.ts")).toEqual({ kind: "file" }); expect(inputModeForDraft("open @ ")).toEqual({ kind: "file" }); expect(inputModeForDraft("open @ A FILE")).toEqual({ kind: "file" }); diff --git a/src/client/src/plugins/registry.test.ts b/src/client/src/plugins/registry.test.ts index 46161c1..ce47538 100644 --- a/src/client/src/plugins/registry.test.ts +++ b/src/client/src/plugins/registry.test.ts @@ -64,7 +64,7 @@ describe("PluginRegistry", () => { expect(registry.getWorkspacePanels().map((panel) => panel.id)).toEqual(["core:workspace.files", "core:workspace.git", "core:workspace.terminal"]); }); - it("provides html and svg helpers to plugin activation", () => { + it("provides html and svg helpers to plugin activation and callbacks", () => { const registry = new PluginRegistry(); registry.register({ id: "example", @@ -86,7 +86,10 @@ describe("PluginRegistry", () => { }, }); - expect(registry.getWorkspacePanels()[0]?.icon).toBeDefined(); + const panel = registry.getWorkspacePanels()[0]; + + expect(panel?.icon).toBeDefined(); + expect(panel?.render(createWorkspacePanelContext("local"))).toBeDefined(); }); it("exposes the prompt helper to workspace panel callbacks", () => { diff --git a/src/client/src/route.test.ts b/src/client/src/route.test.ts index c270bda..b82f7bc 100644 --- a/src/client/src/route.test.ts +++ b/src/client/src/route.test.ts @@ -8,9 +8,10 @@ afterEach(() => { Object.defineProperty(globalThis, "window", { value: originalWindow, configurable: true }); }); -function installWindow(href: string): { pushed: string[] } { +function installWindow(href: string): { pushed: string[]; replaced: string[] } { const url = new URL(href); const pushed: string[] = []; + const replaced: string[] = []; const fakeWindow = { location: { href: url.href, @@ -23,12 +24,12 @@ function installWindow(href: string): { pushed: string[] } { pushed.push(String(next)); }), replaceState: vi.fn((_state: object, _title: string, next: URL | string) => { - pushed.push(String(next)); + replaced.push(String(next)); }), }, }; Object.defineProperty(globalThis, "window", { value: fakeWindow, configurable: true }); - return { pushed }; + return { pushed, replaced }; } describe("route helpers", () => { @@ -51,8 +52,8 @@ describe("route helpers", () => { expect(readRoute()).toMatchObject({ tool: undefined, view: undefined }); }); - it("writes compact URLs and preserves path/hash", () => { - const { pushed } = installWindow("http://localhost/app?old=1#section"); + it("writes compact URLs with push history and preserves path/hash", () => { + const { pushed, replaced } = installWindow("http://localhost/app?old=1#section"); const route: AppRoute = { machineId: "remote", projectId: "project/id", @@ -65,13 +66,15 @@ describe("route helpers", () => { writeRoute(route); expect(pushed).toEqual(["http://localhost/app?old=1&machine=remote&project=project%2Fid&workspace=workspace+id&tool=core%3Aworkspace.files&view=chat#section"]); + expect(replaced).toEqual([]); }); - it("does not push history when the route is unchanged", () => { - const { pushed } = installWindow("http://localhost/app?project=p1&tool=core%3Aworkspace.git"); + it("does not write history when the route is unchanged", () => { + const { pushed, replaced } = installWindow("http://localhost/app?project=p1&tool=core%3Aworkspace.git"); writeRoute({ machineId: undefined, projectId: "p1", workspaceId: undefined, sessionId: undefined, tool: "core:workspace.git", view: undefined }); expect(pushed).toEqual([]); + expect(replaced).toEqual([]); }); }); diff --git a/src/client/src/theme.test.ts b/src/client/src/theme.test.ts index 25a70c9..a973c89 100644 --- a/src/client/src/theme.test.ts +++ b/src/client/src/theme.test.ts @@ -93,17 +93,21 @@ describe("resolveThemePreference", () => { expect(resolution.activeTheme?.id).toBe("themes:classic"); }); - it("does not overwrite a missing selected theme preference in the resolution result", () => { - const missingThemeId: QualifiedContributionId = "plugin:missing"; + it("falls back to Classic without mutating a missing selected theme preference", () => { + const preference = { + themeId: "plugin:missing", + auto: true, + } satisfies { themeId: QualifiedContributionId; auto: boolean }; const resolution = resolveThemePreference({ themes, themePairs, - preference: { themeId: missingThemeId, auto: true }, + preference, prefersLight: false, }); expect(resolution.selectedTheme?.id).toBe("themes:classic"); - expect(missingThemeId).toBe("plugin:missing"); + expect(resolution.activeTheme?.id).toBe("themes:classic"); + expect(preference).toEqual({ themeId: "plugin:missing", auto: true }); }); it("can look up a pair from either member theme", () => { diff --git a/src/client/src/workspaceActivity.test.ts b/src/client/src/workspaceActivity.test.ts index 379308c..88bb548 100644 --- a/src/client/src/workspaceActivity.test.ts +++ b/src/client/src/workspaceActivity.test.ts @@ -15,9 +15,11 @@ function activity(cwd: string, patch: Partial = {}): Workspac } describe("workspace activity aggregation", () => { - it("matches activity to workspace paths", () => { - const ws = workspace("p1", "/repo"); - expect(workspaceActivityFor(ws, { "/repo": activity("/repo") })?.hasSessionActivity).toBe(true); + it("matches activity to workspace paths rather than ids", () => { + const ws = { ...workspace("p1", "/repo"), id: "workspace-1" }; + const matched = activity("/repo"); + + expect(workspaceActivityFor(ws, { "/repo": matched, "workspace-1": activity("workspace-1") })).toEqual(matched); }); it("uses a terminal indicator only when there is no session activity", () => { diff --git a/src/client/src/workspaceDeletion.test.ts b/src/client/src/workspaceDeletion.test.ts index 5d449a0..63c4de0 100644 --- a/src/client/src/workspaceDeletion.test.ts +++ b/src/client/src/workspaceDeletion.test.ts @@ -47,10 +47,18 @@ describe("workspace deletion state", () => { }); }); - it("reports pending workspace deletions for disabling repeated actions", () => { - const state = { workspaceDeletionRuns: { w1: run("new", "w1", "2026-05-25T00:00:01.000Z", "running") } }; + it("reports only queued or running workspace deletions as pending", () => { + const state = { + workspaceDeletionRuns: { + w1: run("running", "w1", "2026-05-25T00:00:01.000Z", "running"), + w2: run("queued", "w2", "2026-05-25T00:00:02.000Z", "queued"), + w3: run("succeeded", "w3", "2026-05-25T00:00:03.000Z", "succeeded"), + w4: run("failed", "w4", "2026-05-25T00:00:04.000Z", "failed"), + }, + }; expect(isWorkspaceDeletionPending(state, workspace)).toBe(true); - expect(pendingWorkspaceDeletionIds(state.workspaceDeletionRuns)).toEqual(["w1"]); + expect(isWorkspaceDeletionPending(state, { ...workspace, id: "w3" })).toBe(false); + expect(pendingWorkspaceDeletionIds(state.workspaceDeletionRuns)).toEqual(["w1", "w2"]); }); }); diff --git a/src/config.test.ts b/src/config.test.ts index c23c0c2..617eb40 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -18,9 +18,23 @@ afterEach(async () => { describe("PI WEB config persistence", () => { it("writes and reads the configured PI WEB config path", () => { - const saved = savePiWebConfig({ host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } }, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] }, uploads: { defaultFolder: "manual\\incoming" } }, testOptions()); + const requestedConfig = { + host: "0.0.0.0", + port: 9000, + allowedHosts: ["example.local"], + shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, + plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } }, + pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] }, + uploads: { defaultFolder: "manual\\incoming" }, + }; + const normalizedConfig = { + ...requestedConfig, + uploads: { defaultFolder: "manual/incoming" }, + }; - expect(saved).toEqual({ path: configPath, exists: true, config: { host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } }, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] }, uploads: { defaultFolder: "manual/incoming" } } }); + const saved = savePiWebConfig(requestedConfig, testOptions()); + + expect(saved).toEqual({ path: configPath, exists: true, config: normalizedConfig }); expect(loadPiWebConfig(testOptions())).toEqual(saved); }); diff --git a/src/server/configRoutes.test.ts b/src/server/configRoutes.test.ts index bcca824..0bb60c1 100644 --- a/src/server/configRoutes.test.ts +++ b/src/server/configRoutes.test.ts @@ -35,15 +35,32 @@ describe("config routes", () => { }); it("updates config through the service", async () => { + const requestedConfig: PiWebConfigValues = { + host: "0.0.0.0", + port: 9000, + allowedHosts: true, + spawnSessions: true, + subsessions: true, + shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, + plugins: { info: { enabled: false, settings: { note: "hidden" } } }, + pathAccess: { allowedPaths: ["/tmp"] }, + uploads: { defaultFolder: "uploads\\manual" }, + maxUploadBytes: 1234, + }; + const expectedConfig: PiWebConfigValues = { + ...requestedConfig, + uploads: { defaultFolder: "uploads/manual" }, + }; + const response = await app.inject({ method: "PUT", url: "/api/config", - payload: { config: { host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "uploads\\manual" }, maxUploadBytes: 1234 } }, + payload: { config: requestedConfig }, }); expect(response.statusCode).toBe(200); - expect(savedConfig).toEqual({ host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "uploads/manual" }, maxUploadBytes: 1234 }); - expect(response.json().config).toEqual(savedConfig); + expect(savedConfig).toEqual(expectedConfig); + expect(response.json().config).toEqual(expectedConfig); }); it("rejects invalid config payloads before writing", async () => { @@ -109,11 +126,16 @@ describe("config routes", () => { it("merges local selected-machine config updates without dropping gateway-only keys", async () => { savedConfig = fullConfig(); + const selectedMachinePatch: PiWebConfigValues = { + plugins: { info: { enabled: false } }, + uploads: { defaultFolder: "uploads\\manual" }, + spawnSessions: true, + }; const response = await app.inject({ method: "PUT", url: "/api/machines/local/config", - payload: { config: { plugins: { info: { enabled: false } }, uploads: { defaultFolder: "uploads\\manual" }, spawnSessions: true } }, + payload: { config: selectedMachinePatch }, }); const expectedConfig: PiWebConfigValues = { diff --git a/src/server/dockerControlAssets.test.ts b/src/server/dockerControlAssets.test.ts index 00febfa..912bc18 100644 --- a/src/server/dockerControlAssets.test.ts +++ b/src/server/dockerControlAssets.test.ts @@ -536,7 +536,7 @@ async function withUnixSocket(socketPath: string, callback: () => Promise) function cleanProcessEnv(): NodeJS.ProcessEnv { const env = { ...process.env }; for (const key of Object.keys(env)) { - if (key === "COMPOSE_PROJECT_NAME" || key === "DOCKER_GID" || key === "HOSTEXEC_IMAGE" || key.startsWith("PI_WEB_")) { + if (key === "COMPOSE_PROJECT_NAME" || key === "DOCKER_GID" || key === "HOSTEXEC_IMAGE" || key === "XDG_DATA_HOME" || key.startsWith("PI_WEB_")) { Reflect.deleteProperty(env, key); } } diff --git a/src/server/machines/machineService.test.ts b/src/server/machines/machineService.test.ts index 2e255bf..f0f8714 100644 --- a/src/server/machines/machineService.test.ts +++ b/src/server/machines/machineService.test.ts @@ -24,6 +24,7 @@ describe("MachineService", () => { expect(await service.list()).toEqual([ { id: "local", name: "Local", kind: "local", createdAt: "1970-01-01T00:00:00.000Z", updatedAt: "1970-01-01T00:00:00.000Z" }, ]); + await expect(stat(storePath)).rejects.toMatchObject({ code: "ENOENT" }); }); it("adds remote machines and omits secrets from public responses", async () => { @@ -38,8 +39,7 @@ describe("MachineService", () => { await expectOwnerOnlyMachineStore(storePath); }); - it("tightens permissions after reading an existing machine store", async () => { - if (process.platform === "win32") return; + it.skipIf(process.platform === "win32")("tightens permissions after reading an existing machine store", async () => { await writeFile(storePath, `${JSON.stringify({ machines: [{ id: "remote-1", diff --git a/src/server/piPackageRoutes.test.ts b/src/server/piPackageRoutes.test.ts index a4cd553..18bbb20 100644 --- a/src/server/piPackageRoutes.test.ts +++ b/src/server/piPackageRoutes.test.ts @@ -85,9 +85,11 @@ describe("registerPiPackageRoutes", () => { expect(missingSource.statusCode).toBe(400); expect(missingSource.json()).toEqual({ error: "Pi package source must be a non-empty string" }); expect(blankSource.statusCode).toBe(400); + expect(blankSource.json()).toEqual({ error: "Pi package source must be a non-empty string" }); expect(invalidScope.statusCode).toBe(400); expect(invalidScope.json()).toEqual({ error: "Pi package scope must be \"user\" or \"project\"" }); expect(invalidUpdate.statusCode).toBe(400); + expect(invalidUpdate.json()).toEqual({ error: "Pi package source must be a non-empty string" }); expect(serviceMocks.install).not.toHaveBeenCalled(); expect(serviceMocks.remove).not.toHaveBeenCalled(); expect(serviceMocks.update).not.toHaveBeenCalled(); diff --git a/src/server/piWebPluginService.test.ts b/src/server/piWebPluginService.test.ts index 352f164..517973c 100644 --- a/src/server/piWebPluginService.test.ts +++ b/src/server/piWebPluginService.test.ts @@ -172,19 +172,30 @@ describe("PiWebPluginService", () => { }); it("skips duplicate plugin ids", async () => { - await writePlugin(join(tempDir, "plugins", "one"), { - packageJson: { piWeb: { plugins: [{ id: "duplicate", module: "pi-web-plugin.js" }] } }, - files: { "pi-web-plugin.js": "export default {};" }, + const firstRoot = join(tempDir, "first-root"); + const secondRoot = join(tempDir, "second-root"); + await writePlugin(join(firstRoot, "duplicate"), { + packageJson: { piWeb: { plugins: [{ id: "duplicate", module: "first.js" }] } }, + files: { "first.js": "export default {};" }, }); - await writePlugin(join(tempDir, "plugins", "two"), { - packageJson: { piWeb: { plugins: [{ id: "duplicate", module: "pi-web-plugin.js" }] } }, - files: { "pi-web-plugin.js": "export default {};" }, + await writePlugin(join(secondRoot, "duplicate"), { + packageJson: { piWeb: { plugins: [{ id: "duplicate", module: "second.js", machineSpecific: true }] } }, + files: { "second.js": "export default {};" }, }); - const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); + const service = new PiWebPluginService({ + roots: [ + { path: firstRoot, source: "first", scope: "local" }, + { path: secondRoot, source: "second", scope: "local" }, + ], + packageProvider: false, + }); const manifest = await service.manifest(); - expect(manifest.plugins.map((plugin) => plugin.id)).toEqual(["duplicate"]); + expect(manifest.plugins).toEqual([ + expect.objectContaining({ id: "duplicate", source: "first", machineSpecific: false }), + ]); + expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/duplicate\/first\.js\?v=\d+$/u); }); it("skips legacy metadata shortcuts and unsafe module paths", async () => { diff --git a/src/server/piWebStatus.test.ts b/src/server/piWebStatus.test.ts index e214e61..15cd1a5 100644 --- a/src/server/piWebStatus.test.ts +++ b/src/server/piWebStatus.test.ts @@ -90,8 +90,7 @@ describe("PI WEB status", () => { expect(status.messages.map((message) => message.id)).toContain("sessiond-stale"); }); - it("suggests native systemd commands for local development services", async () => { - if (process.platform !== "linux") return; + it.skipIf(process.platform !== "linux")("suggests native systemd commands for local development services", async () => { process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1"; disableDockerRuntimeEnv(); const home = await tempHome(); diff --git a/src/server/sessions/authProviderOptions.test.ts b/src/server/sessions/authProviderOptions.test.ts index 854bfab..e6fa31e 100644 --- a/src/server/sessions/authProviderOptions.test.ts +++ b/src/server/sessions/authProviderOptions.test.ts @@ -33,7 +33,7 @@ describe("auth provider options", () => { expect(isApiKeyLoginProvider("openai", new Set(["openai-codex"]))).toBe(true); }); - it("includes Anthropic in both OAuth and API key login options", () => { + it("builds login options for OAuth-only, dual-auth, and API-key providers", () => { const options = getLoginProviderOptions(registry()); expect(options).toEqual(expect.arrayContaining([ expect.objectContaining({ id: "anthropic", authType: "oauth" }), @@ -44,7 +44,7 @@ describe("auth provider options", () => { expect(options).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: "openai-codex", authType: "api_key" })])); }); - it("returns only stored credentials for logout", () => { + it("returns only currently stored credentials for logout", () => { expect(getLogoutProviderOptions(registry())).toEqual([ expect.objectContaining({ id: "openai", authType: "api_key" }), ]); diff --git a/src/server/sessions/piSessionService.test.ts b/src/server/sessions/piSessionService.test.ts index 55ddc82..f9b8f93 100644 --- a/src/server/sessions/piSessionService.test.ts +++ b/src/server/sessions/piSessionService.test.ts @@ -169,23 +169,6 @@ function emptyArchiveStore(): NonNullable { - it("exposes the session's agent.streamFn for one-off model calls", async () => { - const hub = new CapturingSessionEventHub(); - const streamFn = vi.fn(); - const fake = fakeRuntime("stream-session", { agent: { streamFn } }); - const service = new PiSessionService(hub, { - createAgentRuntime: runtimeCreator(fake.runtime), - sessionManager: sessionGateway([]), - heartbeatIntervalMs: 60_000, - }); - - await service.start("/workspace"); - - expect(fake.session.agent.streamFn).toBe(streamFn); - - await service.dispose(); - }); - it("starts sessions through an injected runtime creator", async () => { const hub = new CapturingSessionEventHub(); const fake = fakeRuntime(); @@ -957,14 +940,21 @@ describe("PiSessionService", () => { it("rejects malformed prompt text before opening the runtime", async () => { const fake = fakeRuntime("prompt-session"); + let createCalls = 0; + const createAgentRuntime: RuntimeCreator = async () => { + createCalls += 1; + await Promise.resolve(); + return fake.runtime; + }; const service = new PiSessionService(new CapturingSessionEventHub(), { - createAgentRuntime: runtimeCreator(fake.runtime), + createAgentRuntime, sessionManager: sessionGateway([sessionRecord("prompt-session")]), heartbeatIntervalMs: 60_000, }); await expect(service.prompt("prompt-session", undefined)).rejects.toThrow("Prompt text is required"); + expect(createCalls).toBe(0); expect(fake.calls.prompt).toEqual([]); await service.dispose(); }); @@ -1313,7 +1303,7 @@ describe("PiSessionService", () => { } it("records the parent, delivers the prompt, and lists the tracked child", async () => { - const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }); + const { child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }); await service.start("/workspace"); // bring the parent online so it can be notified const result = await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature" }); @@ -1323,7 +1313,6 @@ describe("PiSessionService", () => { await expect(service.listSubsessions("parent-1")).resolves.toEqual([ { sessionId: "child-1", cwd: "/workspace-feature", status: "idle" }, ]); - void parent; await service.dispose(); }); diff --git a/src/server/sessions/sessionCommandService.test.ts b/src/server/sessions/sessionCommandService.test.ts index 7ca503c..9aa3acb 100644 --- a/src/server/sessions/sessionCommandService.test.ts +++ b/src/server/sessions/sessionCommandService.test.ts @@ -66,11 +66,15 @@ describe("SessionCommandService", () => { await expect(service.run("s1", "/template arg")).resolves.toMatchObject({ type: "done" }); await expect(service.run("s1", "/skill:skill-a arg")).resolves.toMatchObject({ type: "done" }); expect(prompt).toHaveBeenCalledTimes(3); + expect(prompt).toHaveBeenNthCalledWith(1, "s1", "/ext arg"); + expect(prompt).toHaveBeenNthCalledWith(2, "s1", "/template arg"); + expect(prompt).toHaveBeenNthCalledWith(3, "s1", "/skill:skill-a arg"); }); - it("renames sessions and returns updated client session metadata", async () => { + it("renames sessions, publishes the name update, and returns updated client session metadata", async () => { const active = activeSession(); - const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher()); + const events = eventPublisher(); + const service = new SessionCommandService(() => getActive(active), vi.fn(), events); await expect(service.run("s1", "/name Useful name")).resolves.toMatchObject({ type: "done", @@ -78,6 +82,7 @@ describe("SessionCommandService", () => { session: { id: "s1", cwd: "/work", name: "Useful name", messageCount: 2 }, }); expect(active.runtime.session.setSessionName).toHaveBeenCalledWith("Useful name"); + expect(events.publish).toHaveBeenCalledWith("s1", { type: "session.name", sessionId: "s1", name: "Useful name" }); }); it("formats session stats", async () => { @@ -90,18 +95,22 @@ describe("SessionCommandService", () => { }); }); - it("starts compaction and publishes completion", async () => { + it("starts compaction, updates lifecycle hooks, and publishes completion", async () => { const active = activeSession(); const events = eventPublisher(); - const service = new SessionCommandService(() => getActive(active), vi.fn(), events); + const onCompactionStart = vi.fn(); + const onCompactionEnd = vi.fn(); + const service = new SessionCommandService(() => getActive(active), vi.fn(), events, { onCompactionStart, onCompactionEnd }); await expect(service.run("s1", "/compact focus on tests")).resolves.toEqual({ type: "done", message: "Compaction started…" }); + expect(onCompactionStart).toHaveBeenCalledWith(active.runtime.session); await vi.waitFor(() => { expect(events.publish).toHaveBeenCalledWith("s1", { type: "command.output", level: "success", message: "Compaction complete.\nTokens before: 123\n\nshort summary", }); + expect(onCompactionEnd).toHaveBeenCalledWith(active.runtime.session, "success"); }); expect(active.runtime.session.compact).toHaveBeenCalledWith("focus on tests"); }); diff --git a/src/server/sessions/spawnSessionTool.test.ts b/src/server/sessions/spawnSessionTool.test.ts index fbc8c95..ea19549 100644 --- a/src/server/sessions/spawnSessionTool.test.ts +++ b/src/server/sessions/spawnSessionTool.test.ts @@ -9,7 +9,7 @@ const dispatchModel = { provider: "anthropic", id: "claude-sonnet" }; const ctxWithModel = { model: dispatchModel } as ExtensionContext; describe("createSpawnSessionToolDefinition", () => { - it("passes the spawning cwd and params to the spawn callback and reports success", async () => { + it("passes the spawning cwd, explicit cwd, dispatching model, and prompt to spawn callback", async () => { const spawn = vi.fn(() => Promise.resolve({ sessionId: "new-1", cwd: "/repos/a-feature" })); const tool = createSpawnSessionToolDefinition("/repos/a", { spawn }); @@ -20,7 +20,7 @@ describe("createSpawnSessionToolDefinition", () => { expect(result.content[0]).toMatchObject({ type: "text", text: "Started session new-1 in /repos/a-feature." }); }); - it("defaults cwd to undefined so the service falls back to the spawning cwd", async () => { + it("forwards omitted cwd as undefined and omits a missing dispatching model", async () => { const spawn = vi.fn(() => Promise.resolve({ sessionId: "new-2", cwd: "/repos/a" })); const tool = createSpawnSessionToolDefinition("/repos/a", { spawn }); @@ -29,15 +29,6 @@ describe("createSpawnSessionToolDefinition", () => { expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", prompt: "continue", cwd: undefined }); }); - it("omits the inherited model when the dispatching session has no current model", async () => { - const spawn = vi.fn(() => Promise.resolve({ sessionId: "new-3", cwd: "/repos/a" })); - const tool = createSpawnSessionToolDefinition("/repos/a", { spawn }); - - await tool.execute("call-3", { prompt: "continue" }, undefined, undefined, ctx); - - expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", prompt: "continue", cwd: undefined }); - }); - it("propagates the spawn callback error so the agent loop reports it", async () => { const spawn = vi.fn(() => Promise.reject(new Error("cwd must be a workspace of this project. Allowed: /repos/a"))); const tool = createSpawnSessionToolDefinition("/repos/a", { spawn }); diff --git a/src/server/sessions/subsessionTranscript.test.ts b/src/server/sessions/subsessionTranscript.test.ts index 4821740..56ef8c2 100644 --- a/src/server/sessions/subsessionTranscript.test.ts +++ b/src/server/sessions/subsessionTranscript.test.ts @@ -81,12 +81,12 @@ describe("buildTranscriptView", () => { expect(callPart.args).toEqual({ command: "ls" }); }); - it("search keeps only matching entries across text and tool names", () => { - const messages = [assistant("the auth flow"), assistant("unrelated"), toolResult("error in auth.ts", "read")]; + it("search keeps only entries matching text or tool-call names", () => { + const messages = [assistant("the auth flow"), assistant("unrelated"), toolResult("error in auth.ts", "read"), toolCall("auth-search")]; const view = buildTranscriptView(messages, { search: "auth" }); - expect(view.matched).toBe(2); - expect(view.entries.map((entry) => entry.index)).toEqual([0, 2]); + expect(view.matched).toBe(3); + expect(view.entries.map((entry) => entry.index)).toEqual([0, 2, 3]); }); it("search runs against full content even when maxChars would clip the match away", () => { diff --git a/src/server/terminals/terminalRoutes.test.ts b/src/server/terminals/terminalRoutes.test.ts index 815d1ee..47bc2b3 100644 --- a/src/server/terminals/terminalRoutes.test.ts +++ b/src/server/terminals/terminalRoutes.test.ts @@ -43,7 +43,7 @@ describe("terminal routes", () => { expect(terminals.events).toEqual([`close-cwd:${requestCwd}`]); }); - it("creates and lists terminal command runs with filters", async () => { + it("routes command-run create, filter, cancel, and terminal continue requests", async () => { const createResponse = await app.inject({ method: "POST", url: "/terminal-command-runs", diff --git a/src/server/workingDirectory.test.ts b/src/server/workingDirectory.test.ts index f542d08..b55f9e9 100644 --- a/src/server/workingDirectory.test.ts +++ b/src/server/workingDirectory.test.ts @@ -13,8 +13,7 @@ describe("normalizeRequestCwd", () => { expect(normalizeRequestCwd(join(absoluteBase, ".", "nested", ".."))).toBe(absoluteBase); }); - it("treats Windows backslash and forward-slash paths as equal", () => { - if (process.platform !== "win32") return; + it.skipIf(process.platform !== "win32")("treats Windows backslash and forward-slash paths as equal", () => { expect(normalizeRequestCwd("C:/Users/dev/project")).toBe("C:\\Users\\dev\\project"); }); @@ -47,8 +46,7 @@ describe("cwdPathsEqual", () => { expect(cwdPathsEqual(absoluteBase, join(absoluteBase, "."))).toBe(true); }); - it("treats Windows backslash and forward-slash paths as equal", () => { - if (process.platform !== "win32") return; + it.skipIf(process.platform !== "win32")("treats Windows backslash and forward-slash paths as equal", () => { expect(cwdPathsEqual("C:\\Users\\dev\\project", "C:/Users/dev/project")).toBe(true); }); diff --git a/src/server/workspaces/fileContentService.test.ts b/src/server/workspaces/fileContentService.test.ts index c99a1a3..e2ab634 100644 --- a/src/server/workspaces/fileContentService.test.ts +++ b/src/server/workspaces/fileContentService.test.ts @@ -130,13 +130,14 @@ describe("writeWorkspaceFile", () => { expect(content).toBe("const greeting = 'hello';\n"); }); - it("writes binary content", async () => { + it("writes binary content without text re-encoding", async () => { const root = await tempWorkspace(); const binaryData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]); const result = await writeWorkspaceFile(root, "image.png", binaryData); expect(result).toMatchObject({ path: "image.png", created: true, size: 6 }); + await expect(readFile(join(root, "image.png"))).resolves.toEqual(binaryData); }); it("overwrites existing files by default", async () => { @@ -190,14 +191,12 @@ describe("writeWorkspaceFile", () => { it("prevents writing through symlinks that escape the workspace", async () => { const root = await tempWorkspace(); await mkdir(join(root, "subdir"), { recursive: true }); - // Create a symlink inside the workspace that points outside - const { symlink } = await import("node:fs/promises"); const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-outside-")); roots.push(outsideDir); await symlink(outsideDir, join(root, "subdir", "escape"), "junction"); - // Attempting to write through the symlink should be blocked - await expect(writeWorkspaceFile(root, "subdir/escape/evil.txt", Buffer.from("evil"))).rejects.toThrow(); + await expect(writeWorkspaceFile(root, "subdir/escape/evil.txt", Buffer.from("evil"))).rejects.toThrow("Path escapes workspace"); + await expect(readFile(join(outsideDir, "evil.txt"))).rejects.toMatchObject({ code: "ENOENT" }); }); }); @@ -227,7 +226,7 @@ describe("deleteWorkspaceFile", () => { await expect(deleteWorkspaceFile(root, "mydir")).rejects.toThrow("Path is a directory"); }); - it("rejects path traversal", async () => { + it("rejects traversal and absolute paths", async () => { const root = await tempWorkspace(); await expect(deleteWorkspaceFile(root, "../secret.txt")).rejects.toThrow("Path traversal is not allowed"); @@ -253,7 +252,7 @@ describe("deleteWorkspaceFile", () => { expect(result).toMatchObject({ path: "link.txt", existed: true }); // The symlink should be gone, but the target file should still exist - await expect(readWorkspaceFile(root, "link.txt")).rejects.toThrow(); + await expect(readWorkspaceFile(root, "link.txt")).rejects.toThrow("Path does not exist"); const realContent = await readFile(join(outsideDir, "real.txt"), "utf8"); expect(realContent).toBe("real content"); }); @@ -307,6 +306,8 @@ describe("moveWorkspaceFile", () => { await writeFile(join(root, "file.txt"), "data"); await expect(moveWorkspaceFile(root, "file.txt", "missing/dir/file.txt", { createDirs: false })).rejects.toThrow(); + const source = await readWorkspaceFile(root, "file.txt"); + expect(source.content).toBe("data"); }); it("overwrites target when overwrite is true", async () => { @@ -327,9 +328,11 @@ describe("moveWorkspaceFile", () => { await writeFile(join(root, "target.txt"), "target"); await expect(moveWorkspaceFile(root, "source.txt", "target.txt")).rejects.toThrow("File already exists"); - // Source should still exist + // Source and target should remain unchanged const source = await readWorkspaceFile(root, "source.txt"); expect(source.content).toBe("source"); + const target = await readWorkspaceFile(root, "target.txt"); + expect(target.content).toBe("target"); }); it("rejects source path traversal", async () => { @@ -342,7 +345,9 @@ describe("moveWorkspaceFile", () => { const root = await tempWorkspace(); await writeFile(join(root, "source.txt"), "data"); - await expect(moveWorkspaceFile(root, "source.txt", "../secret.txt")).rejects.toThrow(); + await expect(moveWorkspaceFile(root, "source.txt", "../secret.txt")).rejects.toThrow("Path traversal is not allowed"); + const source = await readWorkspaceFile(root, "source.txt"); + expect(source.content).toBe("data"); }); it("rejects moving a directory", async () => { @@ -370,6 +375,9 @@ describe("moveWorkspaceFile", () => { roots.push(outsideDir); await symlink(outsideDir, join(root, "subdir", "escape"), "junction"); - await expect(moveWorkspaceFile(root, "subdir/file.txt", "subdir/escape/evil.txt")).rejects.toThrow(); + await expect(moveWorkspaceFile(root, "subdir/file.txt", "subdir/escape/evil.txt")).rejects.toThrow("Path escapes workspace"); + const source = await readWorkspaceFile(root, "subdir/file.txt"); + expect(source.content).toBe("data"); + await expect(readFile(join(outsideDir, "evil.txt"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); }); }); diff --git a/src/server/workspaces/workspaceDeletionRoutes.test.ts b/src/server/workspaces/workspaceDeletionRoutes.test.ts index 4ff152d..8605c32 100644 --- a/src/server/workspaces/workspaceDeletionRoutes.test.ts +++ b/src/server/workspaces/workspaceDeletionRoutes.test.ts @@ -53,7 +53,7 @@ afterEach(async () => { }); describe("workspace deletion routes", () => { - it("closes target workspace terminals before starting the deletion terminal command", async () => { + it("closes target workspace terminals before starting deletion from the main workspace", async () => { const response = await app.inject({ method: "DELETE", url: "/api/projects/p1/workspaces/feature" }); expect(response.statusCode).toBe(200); diff --git a/src/shared/piWebStatusParsing.test.ts b/src/shared/piWebStatusParsing.test.ts index adb2c75..dbc7389 100644 --- a/src/shared/piWebStatusParsing.test.ts +++ b/src/shared/piWebStatusParsing.test.ts @@ -3,7 +3,7 @@ import { PI_WEB_CAPABILITIES } from "./capabilities"; import { parsePiWebComponentStatus, parsePiWebInstallationInfo, parsePiWebRuntimeResponse, parsePiWebVersionResponse } from "./piWebStatusParsing"; describe("PI WEB status parsing", () => { - it("parses known runtime capabilities and ignores unknown string capabilities", () => { + it("parses known top-level and component capabilities while ignoring unknown strings", () => { expect(parsePiWebRuntimeResponse({ packageName: "@jmfederico/pi-web", generatedAt: "now", @@ -21,7 +21,7 @@ describe("PI WEB status parsing", () => { }); }); - it("rejects malformed capability arrays", () => { + it("rejects runtime responses with malformed component capability arrays", () => { expect(parsePiWebRuntimeResponse({ packageName: "@jmfederico/pi-web", generatedAt: "now", diff --git a/src/shared/promptAttachments.test.ts b/src/shared/promptAttachments.test.ts index 28ae518..49265f6 100644 --- a/src/shared/promptAttachments.test.ts +++ b/src/shared/promptAttachments.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { base64ByteLength, extensionForImageMimeType, isSupportedImageMimeType, MAX_INLINE_IMAGE_BASE64_BYTES, parsePromptAttachments } from "./promptAttachments.js"; -const tinyPngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCA',".replace(/[^A-Za-z0-9+/=]/g, ""); +const validImageBase64 = "QUJD"; describe("isSupportedImageMimeType", () => { it("accepts pi-supported image types", () => { @@ -43,12 +43,12 @@ describe("parsePromptAttachments", () => { }); it("normalizes valid attachments", () => { - const result = parsePromptAttachments([{ kind: "image", mimeType: "image/png", data: tinyPngBase64, name: "shot.png" }]); - expect(result).toEqual([{ kind: "image", mimeType: "image/png", data: tinyPngBase64, name: "shot.png" }]); + const result = parsePromptAttachments([{ kind: "image", mimeType: "image/png", data: validImageBase64, name: "shot.png" }]); + expect(result).toEqual([{ kind: "image", mimeType: "image/png", data: validImageBase64, name: "shot.png" }]); }); it("drops empty names", () => { - const result = parsePromptAttachments([{ kind: "image", mimeType: "image/png", data: tinyPngBase64, name: "" }]); + const result = parsePromptAttachments([{ kind: "image", mimeType: "image/png", data: validImageBase64, name: "" }]); expect(result[0]).not.toHaveProperty("name"); }); @@ -57,9 +57,9 @@ describe("parsePromptAttachments", () => { }); it("rejects unsupported kinds and mime types", () => { - expect(() => parsePromptAttachments([{ kind: "video", mimeType: "image/png", data: tinyPngBase64 }])).toThrow(/unsupported kind/); - expect(() => parsePromptAttachments([{ kind: "file", mimeType: "application/pdf", data: tinyPngBase64 }])).toThrow(/unsupported kind/); - expect(() => parsePromptAttachments([{ kind: "image", mimeType: "image/svg+xml", data: tinyPngBase64 }])).toThrow(/unsupported image type/); + expect(() => parsePromptAttachments([{ kind: "video", mimeType: "image/png", data: validImageBase64 }])).toThrow(/unsupported kind/); + expect(() => parsePromptAttachments([{ kind: "file", mimeType: "application/pdf", data: validImageBase64 }])).toThrow(/unsupported kind/); + expect(() => parsePromptAttachments([{ kind: "image", mimeType: "image/svg+xml", data: validImageBase64 }])).toThrow(/unsupported image type/); }); it("accepts generic files only when file attachments are allowed", () => { @@ -83,7 +83,7 @@ describe("parsePromptAttachments", () => { }); it("keeps image MIME validation when file attachments are allowed", () => { - expect(() => parsePromptAttachments([{ kind: "image", mimeType: "image/svg+xml", data: tinyPngBase64 }], { allowFileAttachments: true })).toThrow(/unsupported image type/); + expect(() => parsePromptAttachments([{ kind: "image", mimeType: "image/svg+xml", data: validImageBase64 }], { allowFileAttachments: true })).toThrow(/unsupported image type/); }); it("rejects invalid base64 data", () => { @@ -97,7 +97,7 @@ describe("parsePromptAttachments", () => { }); it("enforces the attachment count limit", () => { - const many = Array.from({ length: 3 }, () => ({ kind: "image", mimeType: "image/png", data: tinyPngBase64 })); + const many = Array.from({ length: 3 }, () => ({ kind: "image", mimeType: "image/png", data: validImageBase64 })); expect(() => parsePromptAttachments(many, { maxAttachments: 2 })).toThrow(/too many attachments/); }); });