From 8511604e8370fadd60b15f01a73a68df7f12f873 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 3 Jul 2026 11:55:59 +0200 Subject: [PATCH 1/3] test: close audit cleanup findings --- pi-web-plugins/updates/updatesLogic.test.ts | 4 ++- .../src/components/selectableRow.test.ts | 8 +++--- src/client/src/components/selectableRow.ts | 12 +++------ src/docker/piWebDockerDocs.test.ts | 26 ++++++++++++++++--- src/server/piWebPluginService.test.ts | 5 +++- src/shared/activity.test.ts | 11 ++------ src/shared/activity.ts | 10 ------- 7 files changed, 38 insertions(+), 38 deletions(-) diff --git a/pi-web-plugins/updates/updatesLogic.test.ts b/pi-web-plugins/updates/updatesLogic.test.ts index 1a3066d..b1e5c5d 100644 --- a/pi-web-plugins/updates/updatesLogic.test.ts +++ b/pi-web-plugins/updates/updatesLogic.test.ts @@ -233,9 +233,11 @@ describe("fallbackDockerStatus", () => { const fallback = fallbackDockerStatus({ dockerMode: "dev" }, "generated"); expect(fallback?.generatedAt).toBe("generated"); expect(fallback?.components.web.installation).toEqual({ kind: "docker", dockerMode: "dev" }); - expect(fallback?.commands).toMatchObject({ + expect(fallback?.commands).toEqual({ update: "pi-web-docker --dev update", restart: "pi-web-docker --dev restart", + restartWeb: "pi-web-docker --dev restart-web", + restartSessiond: "pi-web-docker --dev restart-sessiond", status: "pi-web-docker --dev status", }); expect(fallback?.messages[0]?.id).toBe("docker-status-compatibility"); diff --git a/src/client/src/components/selectableRow.test.ts b/src/client/src/components/selectableRow.test.ts index a49255b..48d028e 100644 --- a/src/client/src/components/selectableRow.test.ts +++ b/src/client/src/components/selectableRow.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { activateSelectableRow, activateSelectableRowFromKeyboard, handleSelectableRowKeyboard } from "./selectableRow"; +import { activateSelectableRow, handleSelectableRowKeyboard } from "./selectableRow"; describe("selectable row activation", () => { it("activates rows from non-interactive click targets", () => { @@ -20,8 +20,8 @@ describe("selectable row activation", () => { const enter = keyboardEventWithPath("Enter", matchTarget(() => false)); const space = keyboardEventWithPath(" ", matchTarget(() => false)); - activateSelectableRowFromKeyboard(enter, enterAction); - activateSelectableRowFromKeyboard(space, spaceAction); + expect(handleSelectableRowKeyboard(enter, { activate: enterAction })).toBe(true); + expect(handleSelectableRowKeyboard(space, { activate: spaceAction })).toBe(true); expect(enterAction).toHaveBeenCalledOnce(); expect(spaceAction).toHaveBeenCalledOnce(); @@ -33,7 +33,7 @@ describe("selectable row activation", () => { const action = vi.fn(); const event = keyboardEventWithPath("Enter", matchTarget((selector: string) => selector.includes("button"))); - activateSelectableRowFromKeyboard(event, action); + expect(handleSelectableRowKeyboard(event, { activate: action })).toBe(false); expect(action).not.toHaveBeenCalled(); expect(event.preventDefault).not.toHaveBeenCalled(); diff --git a/src/client/src/components/selectableRow.ts b/src/client/src/components/selectableRow.ts index 1bda886..cbf7a90 100644 --- a/src/client/src/components/selectableRow.ts +++ b/src/client/src/components/selectableRow.ts @@ -11,8 +11,9 @@ const interactiveSelector = [ ].join(","); type ComposedPathEvent = Pick; -type SelectableKeyboardEvent = ComposedPathEvent & Pick; -type SelectableNavigationKeyboardEvent = SelectableKeyboardEvent & Partial>; +type SelectableNavigationKeyboardEvent = ComposedPathEvent + & Pick + & Partial>; export interface SelectableRowKeyboardOptions { activate: () => void; @@ -37,13 +38,6 @@ export function activateSelectableRow(event: ComposedPathEvent, action: () => vo action(); } -export function activateSelectableRowFromKeyboard(event: SelectableKeyboardEvent, action: () => void): void { - if (event.key !== "Enter" && event.key !== " ") return; - if (isFromInteractiveElement(event)) return; - event.preventDefault(); - action(); -} - export function handleSelectableRowKeyboard(event: SelectableNavigationKeyboardEvent, options: SelectableRowKeyboardOptions): boolean { if (isFromInteractiveElement(event)) return false; if (event.key === "Enter" || event.key === " ") { diff --git a/src/docker/piWebDockerDocs.test.ts b/src/docker/piWebDockerDocs.test.ts index 979e15e..682f112 100644 --- a/src/docker/piWebDockerDocs.test.ts +++ b/src/docker/piWebDockerDocs.test.ts @@ -37,10 +37,8 @@ describe("pi-web-docker documentation", () => { readRepoFile("docker/pi-web-docker"), ]); - for (const command of PI_WEB_DOCKER_USER_COMMANDS) { - expect(dockerReadme).toContain(`| \`${command}\` |`); - expect(dockerEntrypoint).toContain(command); - } + expect(readDockerCommandMatrix(dockerReadme)).toEqual([...PI_WEB_DOCKER_USER_COMMANDS]); + expect(readEntrypointCommandCases(dockerEntrypoint)).toEqual(new Set(PI_WEB_DOCKER_USER_COMMANDS)); expect(dockerReadme).toContain("`pi-web-docker --dev status`"); expect(dockerReadme).toContain("`./docker/pi-web-docker --dev start`"); @@ -49,6 +47,26 @@ describe("pi-web-docker documentation", () => { }); }); +function readDockerCommandMatrix(dockerReadme: string): string[] { + const commandMatrixSection = dockerReadme.split("### Command matrix\n")[1]?.split("\n### Installer options")[0] ?? ""; + return Array.from(commandMatrixSection.matchAll(/^\| `([^`]+)` \|/gm), (match) => { + const command = match[1]; + if (command === undefined) throw new Error("Docker command matrix row did not include a command"); + return command; + }); +} + +function readEntrypointCommandCases(dockerEntrypoint: string): Set { + const commandCaseBlock = dockerEntrypoint.slice(dockerEntrypoint.indexOf('case "$command_name" in')); + const commandCases = new Set(); + for (const line of commandCaseBlock.split("\n")) { + const match = /^ {2}([a-z][a-z-]*(?:\|[a-z][a-z-]*)*)(?:\|__run-detached)?\)$/.exec(line); + if (match?.[1] === undefined) continue; + for (const command of match[1].split("|")) commandCases.add(command); + } + return commandCases; +} + async function readRepoFile(relativePath: string): Promise { return await readFile(join(repoRoot, relativePath), "utf8"); } diff --git a/src/server/piWebPluginService.test.ts b/src/server/piWebPluginService.test.ts index 517973c..733cfe0 100644 --- a/src/server/piWebPluginService.test.ts +++ b/src/server/piWebPluginService.test.ts @@ -67,7 +67,10 @@ describe("PiWebPluginService", () => { const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); const manifest = await service.manifest(); - expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/updates\/pi-web-plugin\.js\?v=\d+&piWebDockerMode=dev$/u); + const moduleUrl = new URL(manifest.plugins[0]?.module ?? "", "http://pi-web.test"); + expect(moduleUrl.pathname).toBe("/pi-web-plugins/updates/pi-web-plugin.js"); + expect(moduleUrl.searchParams.get("v")).toMatch(/^\d+$/u); + expect(moduleUrl.searchParams.get("piWebDockerMode")).toBe("dev"); }); it("discovers Pi package plugins through an injected package provider", async () => { diff --git a/src/shared/activity.test.ts b/src/shared/activity.test.ts index 7135a22..badf315 100644 --- a/src/shared/activity.test.ts +++ b/src/shared/activity.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { isSessionActive, sessionActivityLabel, isWorkspaceActivityActive } from "./activity"; +import { isSessionActive, isWorkspaceActivityActive } from "./activity"; import type { SessionStatus, WorkspaceActivity } from "./apiTypes"; const idleStatus: SessionStatus = { @@ -14,17 +14,10 @@ const idleStatus: SessionStatus = { }; describe("activity helpers", () => { - it("detects and labels active session states consistently", () => { + it("detects active session states", () => { expect(isSessionActive(idleStatus)).toBe(false); - expect(sessionActivityLabel(idleStatus)).toBeUndefined(); - expect(isSessionActive({ ...idleStatus, isStreaming: true })).toBe(true); - expect(sessionActivityLabel({ ...idleStatus, isStreaming: true })).toBe("streaming"); - expect(isSessionActive({ ...idleStatus, pendingMessageCount: 2 })).toBe(true); - expect(sessionActivityLabel({ ...idleStatus, pendingMessageCount: 2 })).toBe("2 pending"); - - expect(sessionActivityLabel(idleStatus, { sessionId: "s1", phase: "active", label: "running tool", detail: "read", at: "now" })).toBe("running tool: read"); }); it("detects workspace activity presence without exposing details", () => { diff --git a/src/shared/activity.ts b/src/shared/activity.ts index dd97ae8..657b34b 100644 --- a/src/shared/activity.ts +++ b/src/shared/activity.ts @@ -8,16 +8,6 @@ export function isSessionActive(status?: SessionStatus, activity?: SessionActivi || (status?.pendingMessageCount ?? 0) > 0; } -export function sessionActivityLabel(status?: SessionStatus, activity?: SessionActivity): string | undefined { - if (activity?.phase === "active") return activity.detail !== undefined && activity.detail !== "" ? `${activity.label}: ${activity.detail}` : activity.label; - if (status === undefined) return undefined; - if (status.isCompacting) return "compacting"; - if (status.isBashRunning) return "bash"; - if (status.isStreaming) return "streaming"; - if (status.pendingMessageCount > 0) return `${String(status.pendingMessageCount)} pending`; - return undefined; -} - export function isWorkspaceActivityActive(activity: WorkspaceActivity | undefined): boolean { return activity !== undefined && (activity.hasSessionActivity || activity.hasTerminalActivity); } From 73b169a768c5c163b56dbffc095ed90ac3a27fd0 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 3 Jul 2026 21:37:48 +0200 Subject: [PATCH 2/3] test: close selected coverage gaps --- .../skills/code-quality-architecture/SKILL.md | 4 +- .agents/skills/testing-guide/SKILL.md | 87 +++++++ AGENTS.md | 6 + pi-web-plugins/updates/updatesLogic.test.ts | 23 ++ .../src/api/federatedRouteContract.test.ts | 1 + .../src/components/SettingsDialog.test.ts | 84 ++++++- .../components/WorkspaceFilesPanel.test.ts | 187 ++++++++++++++- .../settings/SettingsShortcutsPanel.test.ts | 115 +++++++++- .../src/controllers/authController.test.ts | 212 +++++++++++++++++- .../fileExplorerController.test.ts | 16 ++ .../src/promptAttachmentCapture.test.ts | 149 +++++++++++- .../src/runtime/terminalRuntime.test.ts | 61 ++++- src/server/machines/machineService.test.ts | 120 ++++++++++ src/server/piWebStatusCache.test.ts | 43 ++++ .../sessiond/sessionProxyRoutes.test.ts | 55 ++++- src/server/sessions/attachmentService.test.ts | 69 +++++- src/server/sessions/authService.test.ts | 51 ++++- .../sessions/oauthLoginFlowService.test.ts | 27 +++ src/server/terminals/terminalRoutes.test.ts | 29 ++- .../workspaces/fileContentService.test.ts | 12 + 20 files changed, 1333 insertions(+), 18 deletions(-) create mode 100644 .agents/skills/testing-guide/SKILL.md diff --git a/.agents/skills/code-quality-architecture/SKILL.md b/.agents/skills/code-quality-architecture/SKILL.md index 290e9b5..6be1e78 100644 --- a/.agents/skills/code-quality-architecture/SKILL.md +++ b/.agents/skills/code-quality-architecture/SKILL.md @@ -1,12 +1,14 @@ --- name: code-quality-architecture -description: Project code quality and architecture expectations for implementation, refactoring, planning, and code review. Use this skill whenever writing, modifying, reviewing, or planning code in this repository, especially when making architecture choices, introducing modules/services/components, managing side effects, dependencies, state, boundaries, or tests. Favor composable, contained, intention-revealing, separated, dependency-injected, testable code while respecting the idioms of the framework or library in use. +description: Project code quality and architecture expectations for implementation, refactoring, planning, and code review. Use this skill whenever writing, modifying, reviewing, or planning production code or architecture in this repository, especially when making architecture choices, introducing modules/services/components, managing side effects, dependencies, state, or boundaries. Favor composable, contained, intention-revealing, separated, dependency-injected, testable code while respecting the idioms of the framework or library in use. --- # Code quality and architecture expectations Use this skill as a design lens, not as a framework tutorial. The goal is to shape code so future agents and humans can understand it, change it safely, and test it without needing to reverse-engineer hidden coupling. +For test-specific strategy, test helper conventions, and UI test harness choices, use the `testing-guide` skill. This skill still treats testability as a production-code design concern. + Respect the project's existing conventions and the framework/library idioms already in use. If a dependency expects a particular pattern, such as inheritance, decorators, lifecycle hooks, or a registration API, use that pattern deliberately and keep the surrounding project code as simple and composable as possible. ## Values we optimize for diff --git a/.agents/skills/testing-guide/SKILL.md b/.agents/skills/testing-guide/SKILL.md new file mode 100644 index 0000000..752742d --- /dev/null +++ b/.agents/skills/testing-guide/SKILL.md @@ -0,0 +1,87 @@ +--- +name: testing-guide +description: Project testing guide and test architecture rules for this repository. Use this skill whenever writing, modifying, reviewing, or planning tests, closing coverage gaps, adding Vitest coverage, creating test helpers or fakes, testing Lit components/controllers/services/routes, triaging test failures, or deciding between unit/controller/component/integration approaches. This includes the repo rule for Lit TemplateResult event-handler extraction and when not to use it. +--- + +# Testing guide + +Use this skill for test-specific decisions in this repository. The goal is useful regression coverage without letting test helpers, mocks, or component harnesses become a second application that is harder to maintain than the code under test. + +For production-code design and testability seams, also use the `code-quality-architecture` skill. This guide owns test strategy, test helper conventions, and UI test escape hatches. + +## Core principles + +- Test behavior and contracts that matter, not branches for their own sake. +- Prefer the smallest layer that proves the behavior: pure helper, service, controller, route/API contract, component boundary, then broader integration. +- Keep tests deterministic. Fake clocks, browser globals, filesystem/process/network boundaries, and hard-to-trigger errors when needed. +- Assert observable outcomes: return values, state transitions, emitted calls/events, HTTP responses, rendered user-facing state, or durable side effects. +- Avoid asserting incidental implementation details unless the selected gap is specifically about that implementation contract. +- Keep setup readable. A small explicit fixture is better than a magical factory that hides the scenario. +- Clean up global stubs, fake timers, DOM state, and pending promises so tests do not leak into one another. + +## Choosing the test layer + +Prefer this order unless the behavior requires a higher layer: + +1. **Pure helper/service tests** for data shaping, validation, cache decisions, command construction, and conversion logic. +2. **Controller/runtime adapter tests** for state orchestration, endpoint selection, cancellation, timers, and injected collaborators. +3. **Route/API contract tests** for HTTP status mapping, path/query/body parsing, proxy allowlists, and compatibility contracts. +4. **Component-boundary tests** for UI event wiring and rendered state. Prefer real DOM/custom-element interaction when practical. +5. **Broad verification** (`npm run verify`) when a change is cross-cutting, changes shared helpers/types, or before final merge review. + +Do not jump to a broad UI or integration test just because it feels more realistic if a lower layer proves the same behavior with less noise and less flake risk. + +## Test helpers and fakes + +- Keep helpers local until reuse is clear. If a pattern appears in multiple files, consolidate deliberately rather than copy-pasting variants. +- Type helpers and fakes strictly; avoid `any` unless the test is intentionally modeling an untyped external boundary. +- Fake only the boundary needed for the scenario. Do not mock the unit under test or so many collaborators that the assertion stops proving real behavior. +- Prefer controllable promises, fake timers, and explicit injected dependencies over sleeps or timing guesses. +- Name helpers after the domain behavior they support, not the mechanics of the fake. + +## Lit component tests + +Prefer testing Lit components through public/component boundaries: + +- instantiate the component and set properties when that is the component contract; +- dispatch events against rendered DOM when a lightweight DOM harness is practical; +- assert user-visible rendered state or controller calls caused by user-like interactions. + +### TemplateResult event-handler extraction rule + +Lit `TemplateResult` event-handler extraction means calling `render()`, inspecting the returned template's `strings`/`values`, finding an event handler near a marker, and invoking that handler directly. It is an escape hatch, not the default. + +Use TemplateResult handler extraction only when all of these are true: + +1. The test is specifically verifying Lit template event wiring. +2. A DOM/custom-element render harness would add disproportionate setup, flakiness, or noise for the behavior being checked. +3. The assertion checks observable component/controller effects, not Lit internals. +4. The lookup is anchored to stable semantic markup, labels, or user-facing text rather than incidental handler order. +5. The test stays narrow; it is not trying to cover a full user flow, accessibility behavior, or visual/layout behavior. + +Do not use TemplateResult handler extraction for: + +- general content assertions; +- styling, layout, focus, keyboard navigation, or accessibility behavior; +- broad user flows where real DOM events are the point; +- scenarios with an existing public controller/service/helper seam; +- copying a new ad hoc helper variant into another file without reviewing whether a shared helper or DOM harness is now warranted. + +When using this escape hatch: + +- Add a short comment above the helper or test explaining why direct handler extraction is proportionate. +- Keep the helper small, type-guarded, and file-local unless reuse is already justified. +- Anchor searches to stable semantic markers such as accessible labels, button text, ids intentionally used by the component, or nearby form markup. +- Assert the behavior caused by the handler, such as state changes or calls to injected callbacks/controllers. +- Avoid assertions about the exact shape of Lit's private data beyond the minimum needed to find the handler; fail with clear errors if the template cannot be inspected. + +## Checks to run + +Run the narrowest meaningful check first: + +- Changed test file: `npm test -- --run `. +- Source or exported type changes: also run `npm run typecheck`. +- Non-trivial test helper, component, or lint-sensitive changes: run `npx eslint ` or `npm run lint` when broader lint coverage is needed. +- Cross-cutting changes or final merge review: prefer `npm run verify`. + +Record exact commands and results when working under relay/audit workflows or when handing work to another agent. diff --git a/AGENTS.md b/AGENTS.md index c503ccf..774ce44 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,12 @@ If you make changes that affect `src/server/sessiond.ts`, session runtime owners Changes to the web/API/UI side generally only require the `pi-web-ui-dev.service` autoreload/restart path. +## Testing guidance + +Project-specific testing rules live in `.agents/skills/testing-guide/SKILL.md`. + +Use that skill whenever writing, modifying, reviewing, or planning tests, closing coverage gaps, triaging test failures, or creating test helpers/harnesses. Keep detailed testing conventions there rather than growing this top-level orientation file. + ## Configuration conventions - `$PI_WEB_DATA_DIR` (`~/.pi-web` by default) contains PI WEB-managed state such as `projects.json` and `machines.json`; do not treat it as the user-editable config API. diff --git a/pi-web-plugins/updates/updatesLogic.test.ts b/pi-web-plugins/updates/updatesLogic.test.ts index b1e5c5d..cdb64cf 100644 --- a/pi-web-plugins/updates/updatesLogic.test.ts +++ b/pi-web-plugins/updates/updatesLogic.test.ts @@ -76,6 +76,17 @@ describe("recommendedCommand", () => { expect(result).toEqual({ label: "Restart everything", command: "pi-web restart" }); }); + it("recommends restart when the session daemon is stale", () => { + const result = recommendedCommand(status({ + components: { + web: component(), + sessiond: component({ component: "sessiond", label: "Session daemon", stale: true }), + }, + commands: { restart: "pi-web restart" }, + })); + expect(result).toEqual({ label: "Restart everything", command: "pi-web restart" }); + }); + it("returns nothing when everything is current and available", () => { expect(recommendedCommand(status({ commands: { restart: "pi-web restart" } }))).toBeUndefined(); }); @@ -243,6 +254,18 @@ describe("fallbackDockerStatus", () => { expect(fallback?.messages[0]?.id).toBe("docker-status-compatibility"); }); + it("creates Docker runtime commands without the development prefix", () => { + const fallback = fallbackDockerStatus({ dockerMode: "runtime" }); + expect(fallback?.components.sessiond.installation).toEqual({ kind: "docker", dockerMode: "runtime" }); + expect(fallback?.commands).toEqual({ + update: "pi-web-docker update", + restart: "pi-web-docker restart", + restartWeb: "pi-web-docker restart-web", + restartSessiond: "pi-web-docker restart-sessiond", + status: "pi-web-docker status", + }); + }); + it("does not create a fallback without a Docker runtime hint", () => { expect(fallbackDockerStatus({})).toBeUndefined(); }); diff --git a/src/client/src/api/federatedRouteContract.test.ts b/src/client/src/api/federatedRouteContract.test.ts index 6c044b5..ad1fd65 100644 --- a/src/client/src/api/federatedRouteContract.test.ts +++ b/src/client/src/api/federatedRouteContract.test.ts @@ -67,6 +67,7 @@ describe("federated route contract", () => { ignoreParseFailure(sessionsApi.cycleThinkingLevel(session, machineId)), ignoreParseFailure(sessionsApi.commands(session, machineId)), ignoreParseFailure(sessionsApi.prompt(session, "hello", "followUp", machineId)), + ignoreParseFailure(sessionsApi.saveAttachments(session, [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }], machineId, "uploads")), ignoreParseFailure(sessionsApi.shell(session, "ls", machineId)), ignoreParseFailure(sessionsApi.runCommand(session, "/help", machineId)), ignoreParseFailure(sessionsApi.respondToCommand(session, "req 1", "yes", machineId)), diff --git a/src/client/src/components/SettingsDialog.test.ts b/src/client/src/components/SettingsDialog.test.ts index 5fdca5b..e453492 100644 --- a/src/client/src/components/SettingsDialog.test.ts +++ b/src/client/src/components/SettingsDialog.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { TemplateResult } from "lit"; import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; -import { configApi, pluginsApi, type Machine, type MachineRuntime, type PiWebConfigResponse, type PiWebConfigValues, type PiWebPluginInfo, type PiWebPluginsResponse } from "../api"; +import { configApi, piPackagesApi, pluginsApi, type Machine, type MachineRuntime, type PiPackageInfo, type PiPackageMutationResponse, type PiWebConfigResponse, type PiWebConfigValues, type PiWebPluginInfo, type PiWebPluginsResponse } from "../api"; import { SettingsDialog } from "./SettingsDialog"; afterEach(() => { @@ -319,6 +319,76 @@ describe("settings-dialog general settings machine targeting", () => { }); }); +describe("settings-dialog Pi package orchestration", () => { + it("loads package data from the selected machine and ignores stale target responses", async () => { + const remotePackages = { packages: [packageInfo("npm:@acme/tools")] }; + const staleLoad = deferred(); + const packagesSpy = vi.spyOn(piPackagesApi, "packages").mockReturnValue(staleLoad.promise); + const dialog = new SettingsDialog(); + dialog.machine = remoteMachine; + dialog.machineRuntime = runtimeWithPackageManagement; + + const loadPromise = callDialogPromise(dialog, "loadPackagesForTarget"); + expect(packagesSpy.mock.calls).toEqual([["remote-a"]]); + expect(getDialogProperty(dialog, "packageLoading")).toBe(true); + + dialog.machine = secondRemoteMachine; + callDialogUpdated(dialog, new Map([["machine", remoteMachine]])); + staleLoad.resolve(remotePackages); + await loadPromise; + + expect(getDialogProperty(dialog, "packagesResponse")).toBeUndefined(); + expect(getDialogProperty(dialog, "packageError")).toBe(""); + expect(getDialogProperty(dialog, "packageMessage")).toBe(""); + expect(getDialogProperty(dialog, "packageLoading")).toBe(false); + }); + + it("runs remote package mutations against the selected machine without refreshing gateway plugins", async () => { + const installedPackages = [packageInfo("npm:@acme/new-tools")]; + const install = deferred(); + const installSpy = vi.spyOn(piPackagesApi, "install").mockReturnValue(install.promise); + const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(pluginsResponse([pluginInfo("gateway", true)])); + const dialog = new SettingsDialog(); + dialog.machine = remoteMachine; + dialog.machineRuntime = runtimeWithPackageManagement; + + const installPromise = callDialogPromise(dialog, "installPiPackage", "npm:@acme/new-tools"); + + expect(installSpy.mock.calls).toEqual([["npm:@acme/new-tools", "remote-a"]]); + expect(getDialogProperty(dialog, "saving")).toBe(true); + expect(getDialogProperty(dialog, "packageOperation")).toEqual({ kind: "install", source: "npm:@acme/new-tools" }); + + install.resolve(packageMutationResponse("install", installedPackages, "npm:@acme/new-tools")); + await installPromise; + + expect(pluginsSpy).not.toHaveBeenCalled(); + expect(getDialogProperty(dialog, "packagesResponse")).toEqual({ packages: installedPackages }); + expect(getDialogProperty(dialog, "packageMessage")).toContain("Pi package installed on Lab Mac"); + expect(getDialogProperty(dialog, "packageMessage")).toContain("each idle PI WEB session on Lab Mac"); + expect(getDialogProperty(dialog, "packageError")).toBe(""); + expect(getDialogProperty(dialog, "packageOperation")).toBeUndefined(); + expect(getDialogProperty(dialog, "saving")).toBe(false); + }); + + it("refreshes gateway plugins after a local package mutation", async () => { + const updatedPackages = [packageInfo("npm:@acme/tools")]; + const refreshedPlugins = pluginsResponse([pluginInfo("browser-helper", true)]); + const updateSpy = vi.spyOn(piPackagesApi, "update").mockResolvedValue(packageMutationResponse("update", updatedPackages)); + const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(refreshedPlugins); + const dialog = new SettingsDialog(); + + await callDialogPromise(dialog, "updatePiPackage"); + + expect(updateSpy.mock.calls).toEqual([[undefined, "local"]]); + expect(pluginsSpy.mock.calls).toEqual([[]]); + expect(getDialogProperty(dialog, "packagesResponse")).toEqual({ packages: updatedPackages }); + expect(getDialogProperty(dialog, "pluginsResponse")).toBe(refreshedPlugins); + expect(getDialogProperty(dialog, "packageMessage")).toContain("Reload the browser page separately for PI WEB browser plugin changes"); + expect(getDialogProperty(dialog, "packageError")).toBe(""); + expect(getDialogProperty(dialog, "saving")).toBe(false); + }); +}); + describe("settings-dialog plugin settings machine targeting", () => { it("loads plugin config and plugin list from the selected machine", async () => { const config = configResponse({ plugins: { info: { enabled: true } } }); @@ -502,13 +572,15 @@ const secondRemoteMachine: Machine = { updatedAt: "2026-07-01T00:00:00.000Z", }; -const runtimeWithoutSelectedMachineSettings: MachineRuntime = { +const runtimeWithPackageManagement: MachineRuntime = { machineId: "remote-a", ok: true, checkedAt: "2026-07-01T00:00:00.000Z", capabilities: [PI_WEB_CAPABILITIES.piPackagesManage], }; +const runtimeWithoutSelectedMachineSettings: MachineRuntime = runtimeWithPackageManagement; + function getDialogProperty(dialog: SettingsDialog, property: string): unknown { return Reflect.get(dialog, property); } @@ -600,6 +672,14 @@ function pluginInfo(id: string, enabled: boolean): PiWebPluginInfo { }; } +function packageInfo(source: string): PiPackageInfo { + return { source, scope: "user", filtered: false, installedPath: `/pi/packages/${source}` }; +} + +function packageMutationResponse(action: PiPackageMutationResponse["action"], packages: PiPackageInfo[], source?: string): PiPackageMutationResponse { + return source === undefined ? { action, packages } : { action, source, packages }; +} + interface Deferred { promise: Promise; resolve: (value: T) => void; diff --git a/src/client/src/components/WorkspaceFilesPanel.test.ts b/src/client/src/components/WorkspaceFilesPanel.test.ts index 923fd83..937989c 100644 --- a/src/client/src/components/WorkspaceFilesPanel.test.ts +++ b/src/client/src/components/WorkspaceFilesPanel.test.ts @@ -1,6 +1,43 @@ -import { describe, expect, it, vi } from "vitest"; +import type { TemplateResult } from "lit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { initialAppState } from "../appState"; +import type { WorkspacePanelContext } from "../plugins/types"; import type { WorkspaceUploadBatchState } from "../workspaceUploadState"; -import { startDirectWorkspaceUpload, uploadBatchProgressValue, uploadBatchStatusLabel, workspaceUploadBatchesForScope, workspaceUploadReviewDefaults, workspaceUploadReviewError } from "./WorkspaceFilesPanel"; +import { WorkspaceFilesPanel, startDirectWorkspaceUpload, uploadBatchProgressValue, uploadBatchStatusLabel, workspaceUploadBatchesForScope, workspaceUploadReviewDefaults, workspaceUploadReviewError } from "./WorkspaceFilesPanel"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("workspace-files-panel upload review", () => { + it("opens review from the hidden file input and submits selected files with defaults", () => { + vi.stubGlobal("HTMLInputElement", FakeHTMLInputElement); + const files = [new File(["a"], "a.txt"), new File(["b"], "b.txt")]; + const onStartWorkspaceUpload = vi.fn(() => ({ batchId: "batch-1", done: Promise.resolve() })); + const panel = new WorkspaceFilesPanel(); + panel.context = workspacePanelContext({ workspaceUploadDefaultFolder: "project/uploads", onStartWorkspaceUpload }); + + const inputChange = findTemplateEventHandler(panel.render(), `id="workspace-upload-input"`); + const input = new FakeHTMLInputElement(files); + inputChange(new EventWithCurrentTarget("change", input)); + + expect(input.value).toBe(""); + expect(onStartWorkspaceUpload).not.toHaveBeenCalled(); + + const submit = findTemplateEventHandler(panel.render(), "
(panel.render(), " { it("filters upload batches to the selected project, workspace, and machine", () => { @@ -80,6 +117,152 @@ describe("workspaceUploadReviewError", () => { }); }); +type TemplateEventHandler = (event: E) => void; + +function findTemplateEventHandler(template: TemplateResult, marker: string): TemplateEventHandler { + const handler = findOptionalTemplateEventHandler(template, marker); + if (handler === undefined) throw new Error(`Expected template event handler after ${marker}`); + return handler; +} + +function findOptionalTemplateEventHandler(template: TemplateResult, marker: string): TemplateEventHandler | undefined { + return findInTemplate(template); + + function findInTemplate(current: TemplateResult): TemplateEventHandler | undefined { + const strings = templateStrings(current); + const values = templateValues(current); + for (let index = 0; index < values.length; index += 1) { + const staticChunk = strings[index]; + const value = values[index]; + if (staticChunk !== undefined && staticChunk.includes(marker) && isTemplateEventHandler(value)) return value; + const nestedHandler = findInValue(value); + if (nestedHandler !== undefined) return nestedHandler; + } + return undefined; + } + + function findInValue(value: unknown): TemplateEventHandler | undefined { + if (Array.isArray(value)) { + for (const item of value) { + const nestedHandler = findInValue(item); + if (nestedHandler !== undefined) return nestedHandler; + } + return undefined; + } + if (isTemplateResult(value)) return findInTemplate(value); + return undefined; + } +} + +function templateStrings(template: TemplateResult): readonly string[] { + const strings = Reflect.get(template, "strings"); + if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable"); + return strings; +} + +function templateValues(template: TemplateResult): readonly unknown[] { + const values = Reflect.get(template, "values"); + if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable"); + return values.map((value: unknown) => value); +} + +function isTemplateResult(value: unknown): value is TemplateResult { + return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values")); +} + +function isTemplateEventHandler(value: unknown): value is TemplateEventHandler { + return typeof value === "function"; +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item: unknown) => typeof item === "string"); +} + +class FakeFileList implements FileList { + readonly length: number; + [index: number]: File; + + constructor(private readonly files: readonly File[]) { + this.length = files.length; + files.forEach((file, index) => { + this[index] = file; + }); + } + + item(index: number): File | null { + return this.files[index] ?? null; + } + + [Symbol.iterator](): ArrayIterator { + return this.files[Symbol.iterator](); + } +} + +class FakeHTMLInputElement extends EventTarget { + readonly files: FileList; + value = "selected-files"; + + constructor(files: readonly File[]) { + super(); + this.files = new FakeFileList(files); + } +} + +class EventWithCurrentTarget extends Event { + constructor(type: string, private readonly eventCurrentTarget: EventTarget) { + super(type); + } + + override get currentTarget(): EventTarget { + return this.eventCurrentTarget; + } +} + +class FakeSubmitEvent extends Event implements SubmitEvent { + readonly submitter: HTMLElement | null = null; +} + +function workspacePanelContext(patch: Partial> = {}): WorkspacePanelContext { + const workspace = { id: "workspace-1", projectId: "project-1", path: "/tmp/project", label: "main", isMain: true, isGitRepo: true, isGitWorktree: false }; + return { + machine: { id: "local", name: "Local", kind: "local" }, + workspace, + state: { ...initialAppState(), workspaceUploadBatches: {} }, + files: { + readFile: vi.fn(() => Promise.reject(new Error("not implemented"))), + writeFile: vi.fn(() => Promise.reject(new Error("not implemented"))), + deleteFile: vi.fn(() => Promise.reject(new Error("not implemented"))), + moveFile: vi.fn(() => Promise.reject(new Error("not implemented"))), + }, + prompt: { insertText: vi.fn(), getText: vi.fn(() => ""), getSelection: vi.fn(() => null) }, + terminal: { open: vi.fn(), runCommand: vi.fn(() => Promise.reject(new Error("not implemented"))) }, + host: { requestRender: vi.fn() }, + fileTree: [], + expandedDirs: {}, + selectedFilePath: undefined, + selectedFileContent: undefined, + fileTreeStale: false, + gitStatus: undefined, + selectedDiffPath: undefined, + selectedDiff: undefined, + selectedStagedDiff: undefined, + gitStale: false, + activeTerminalCount: 0, + selectedTerminalId: undefined, + terminalAutoStart: false, + workspaceUploadDefaultFolder: patch.workspaceUploadDefaultFolder ?? ".pi-web/uploads", + onRefreshFiles: vi.fn(), + onExpandDir: vi.fn(), + onSelectFile: vi.fn(), + onStartWorkspaceUpload: patch.onStartWorkspaceUpload ?? vi.fn(() => undefined), + onCancelWorkspaceUpload: vi.fn(), + onClearWorkspaceUpload: vi.fn(), + onRefreshGit: vi.fn(), + onSelectDiff: vi.fn(), + onSelectTerminal: vi.fn(), + }; +} + function uploadBatch(patch: Partial = {}): WorkspaceUploadBatchState { return { id: patch.id ?? "batch-1", diff --git a/src/client/src/components/settings/SettingsShortcutsPanel.test.ts b/src/client/src/components/settings/SettingsShortcutsPanel.test.ts index da082e2..7d67c4c 100644 --- a/src/client/src/components/settings/SettingsShortcutsPanel.test.ts +++ b/src/client/src/components/settings/SettingsShortcutsPanel.test.ts @@ -1,9 +1,14 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { TemplateResult } from "lit"; +import type { AppAction } from "../../actions"; import type { PiWebConfigResponse, PiWebConfigValues } from "../../api"; import { SettingsShortcutsPanel } from "./SettingsShortcutsPanel"; import type { SettingsNotice } from "./SettingsPanelFrame"; +afterEach(() => { + vi.unstubAllGlobals(); +}); + describe("settings-shortcuts-panel layout", () => { it("renders header, ordered notices, and shortcut settings through the shared frame", () => { const panel = new SettingsShortcutsPanel(); @@ -40,6 +45,34 @@ describe("settings-shortcuts-panel layout", () => { }); }); +describe("settings-shortcuts-panel shortcut row actions", () => { + it("saves edited shortcuts, disables them with None, and resets overrides", () => { + vi.stubGlobal("HTMLInputElement", FakeHTMLInputElement); + const onSave = vi.fn(); + const savePanel = panelWithShortcuts({ shortcuts: { "core:other": "mod+o" } }, onSave); + + findTemplateEventHandler(savePanel.render(), "@input=")( + new EventWithTarget("input", new FakeHTMLInputElement(" control + shift + p ")), + ); + + expectTextOrder(flattenTemplateContent(savePanel.render()), ["Open palette", "Ctrl+Shift+P", "Custom ยท Unsaved"]); + + findTemplateEventHandler(savePanel.render(), ">Save")(new Event("click")); + + const nonePanel = panelWithShortcuts({ shortcuts: { "core:open-palette": "mod+shift+p", "core:other": "mod+o" } }, onSave); + findTemplateEventHandler(nonePanel.render(), ">None")(new Event("click")); + + const resetPanel = panelWithShortcuts({ shortcuts: { "core:open-palette": null, "core:other": "mod+o" } }, onSave); + findTemplateEventHandler(resetPanel.render(), ">Reset")(new Event("click")); + + expect(onSave.mock.calls).toEqual([ + [{ shortcuts: { "core:other": "mod+o", "core:open-palette": "mod+shift+p" } }], + [{ shortcuts: { "core:open-palette": null, "core:other": "mod+o" } }], + [{ shortcuts: { "core:other": "mod+o" } }], + ]); + }); +}); + function frameNotices(template: TemplateResult): readonly SettingsNotice[] { const notices = collectTemplateValues(template).find(isSettingsNoticeArray); if (notices === undefined) throw new Error("Expected settings-panel-frame notices to be rendered"); @@ -139,6 +172,86 @@ function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((item: unknown) => typeof item === "string"); } +type SaveHandler = (config: PiWebConfigValues) => void | Promise; +type TemplateEventHandler = (event: E) => void; + +function panelWithShortcuts(config: PiWebConfigValues, onSave: SaveHandler): SettingsShortcutsPanel { + const panel = new SettingsShortcutsPanel(); + panel.actions = [shortcutAction()]; + panel.configResponse = configResponse(config); + panel.onSave = onSave; + return panel; +} + +function shortcutAction(): AppAction { + return { + id: "core:open-palette", + title: "Open palette", + description: "Open the command palette.", + shortcut: "mod+k", + group: "Navigation", + run: vi.fn(), + }; +} + +function findTemplateEventHandler(template: TemplateResult, marker: string): TemplateEventHandler { + const handler = findOptionalTemplateEventHandler(template, marker); + if (handler === undefined) throw new Error(`Expected template event handler near ${marker}`); + return handler; +} + +function findOptionalTemplateEventHandler(template: TemplateResult, marker: string): TemplateEventHandler | undefined { + return findInTemplate(template); + + function findInTemplate(current: TemplateResult): TemplateEventHandler | undefined { + const strings = templateStrings(current); + const values = templateValues(current); + for (let index = 0; index < values.length; index += 1) { + const value = values[index]; + if (isTemplateEventHandler(value) && templateEventHandlerMatches(strings, index, marker)) return value; + const nestedHandler = findInValue(value); + if (nestedHandler !== undefined) return nestedHandler; + } + return undefined; + } + + function findInValue(value: unknown): TemplateEventHandler | undefined { + if (Array.isArray(value)) { + for (const item of value) { + const nestedHandler = findInValue(item); + if (nestedHandler !== undefined) return nestedHandler; + } + return undefined; + } + if (isTemplateResult(value)) return findInTemplate(value); + return undefined; + } +} + +function templateEventHandlerMatches(strings: readonly string[], valueIndex: number, marker: string): boolean { + return (strings[valueIndex] ?? "").includes(marker) || (strings[valueIndex + 1] ?? "").includes(marker); +} + +function isTemplateEventHandler(value: unknown): value is TemplateEventHandler { + return typeof value === "function"; +} + +class FakeHTMLInputElement extends EventTarget { + constructor(readonly value: string) { + super(); + } +} + +class EventWithTarget extends Event { + constructor(type: string, private readonly eventTarget: EventTarget) { + super(type); + } + + override get target(): EventTarget { + return this.eventTarget; + } +} + function configResponse(config: PiWebConfigValues): PiWebConfigResponse { return { path: "/tmp/pi-web/config.json", diff --git a/src/client/src/controllers/authController.test.ts b/src/client/src/controllers/authController.test.ts index 5fa8c82..e344174 100644 --- a/src/client/src/controllers/authController.test.ts +++ b/src/client/src/controllers/authController.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { api as defaultApi, type AuthProviderOption, type OAuthFlowState } from "../api"; +import { api as defaultApi, type AuthProviderOption, type OAuthFlowState, type SessionInfo, type SessionStatus } from "../api"; import { initialAppState, type AppState } from "../appState"; import { AuthController, parseAuthSlashCommand } from "./authController"; @@ -42,20 +42,226 @@ describe("AuthController", () => { expect(getState().authDialog).toMatchObject({ step: "oauth", inputValue: "https://callback", responding: true }); }); + + it("resets OAuth prompt input and submit state when the request id changes", async () => { + const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } }); + const { controller, getState } = createController( + { authDialog: { step: "oauth", flow, inputValue: "https://callback", responding: true } }, + { + respondOAuthFlow: () => Promise.resolve(oauthFlow({ + select: { requestId: "request-2", message: "Choose an account", options: [{ value: "acct-1", label: "Account 1" }] }, + progress: ["Need account selection"], + })), + }, + ); + + await controller.respondOAuth(); + + expect(getState().authDialog).toMatchObject({ + step: "oauth", + flow: { select: { requestId: "request-2" } }, + inputValue: "", + responding: false, + }); + }); + + it("closes the OAuth dialog and refreshes selected session status when the flow completes", async () => { + const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } }); + const session = sessionInfo("session-1"); + const refreshedStatus = sessionStatus(session.id); + const respondCalls: { flowId: string; requestId: string; value: string; machineId: string | undefined }[] = []; + const statusCalls: { session: Parameters[0]; machineId: string | undefined }[] = []; + const appliedStatuses: SessionStatus[] = []; + const { controller, getState } = createController( + { selectedSession: session, authDialog: { step: "oauth", flow, inputValue: "https://callback" } }, + { + respondOAuthFlow: (flowId, requestId, value, machineId) => { + respondCalls.push({ flowId, requestId, value, machineId }); + return Promise.resolve(oauthFlow({ status: "complete" })); + }, + status: (sessionArg, machineId) => { + statusCalls.push({ session: sessionArg, machineId }); + return Promise.resolve(refreshedStatus); + }, + }, + (status) => { appliedStatuses.push(status); }, + ); + + await controller.respondOAuth(); + await flushMicrotasks(); + + expect(respondCalls).toEqual([{ flowId: "flow-1", requestId: "request-1", value: "https://callback", machineId: "local" }]); + expect(getState().authDialog).toBeUndefined(); + expect(statusCalls).toEqual([{ session, machineId: "local" }]); + expect(appliedStatuses).toEqual([refreshedStatus]); + }); + + it("leaves the OAuth dialog ready to retry if responding fails", async () => { + const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } }); + const { controller, getState } = createController( + { authDialog: { step: "oauth", flow, inputValue: "https://callback", responding: true } }, + { respondOAuthFlow: () => Promise.reject(new Error("Invalid callback")) }, + ); + + await controller.respondOAuth(); + + expect(getState().authDialog).toMatchObject({ + step: "oauth", + flow, + inputValue: "https://callback", + responding: false, + error: "Error: Invalid callback", + }); + }); + + it("cancels the active OAuth flow and closes the dialog even when cancellation fails", async () => { + const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } }); + const cancelCalls: { flowId: string; machineId: string | undefined }[] = []; + const { controller, getState } = createController( + { authDialog: { step: "oauth", flow } }, + { + cancelOAuthFlow: (flowId, machineId) => { + cancelCalls.push({ flowId, machineId }); + return Promise.reject(new Error("Cancel unavailable")); + }, + }, + ); + + await controller.cancelOAuth(); + + expect(cancelCalls).toEqual([{ flowId: "flow-1", machineId: "local" }]); + expect(getState().authDialog).toBeUndefined(); + }); + + it("validates API key input before saving and clears the validation error when edited", async () => { + const saveCalls: { providerId: string; key: string; machineId: string | undefined }[] = []; + const provider = authProvider("openai", "api_key"); + const { controller, getState } = createController( + { authDialog: { step: "apiKey", provider, value: " " } }, + { + saveApiKey: (providerId, key, machineId) => { + saveCalls.push({ providerId, key, machineId }); + return Promise.resolve({ accepted: true }); + }, + }, + ); + + await controller.saveApiKey(); + + expect(saveCalls).toEqual([]); + expect(getState().authDialog).toMatchObject({ step: "apiKey", error: "API key is required" }); + + controller.updateApiKey("sk-live"); + + expect(getState().authDialog).toMatchObject({ step: "apiKey", value: "sk-live" }); + expect(getState().authDialog).not.toHaveProperty("error"); + }); + + it("saves a trimmed API key on the selected machine and refreshes selected session status", async () => { + const saveCalls: { providerId: string; key: string; machineId: string | undefined }[] = []; + const statusCalls: { session: Parameters[0]; machineId: string | undefined }[] = []; + const appliedStatuses: SessionStatus[] = []; + const provider = authProvider("openai", "api_key"); + const session = sessionInfo("session-1"); + const refreshedStatus = sessionStatus(session.id); + const { controller, getState } = createController( + { + selectedMachine: remoteMachine("remote-1"), + selectedSession: session, + authDialog: { step: "apiKey", provider, value: " sk-live " }, + }, + { + saveApiKey: (providerId, key, machineId) => { + saveCalls.push({ providerId, key, machineId }); + return Promise.resolve({ accepted: true }); + }, + status: (sessionArg, machineId) => { + statusCalls.push({ session: sessionArg, machineId }); + return Promise.resolve(refreshedStatus); + }, + }, + (status) => { appliedStatuses.push(status); }, + ); + + await controller.saveApiKey(); + await flushMicrotasks(); + + expect(saveCalls).toEqual([{ providerId: "openai", key: "sk-live", machineId: "remote-1" }]); + expect(getState().authDialog).toBeUndefined(); + expect(statusCalls).toEqual([{ session, machineId: "remote-1" }]); + expect(appliedStatuses).toEqual([refreshedStatus]); + }); + + it("keeps the API key dialog open with an error if saving fails", async () => { + const provider = authProvider("openai", "api_key"); + const { controller, getState } = createController( + { authDialog: { step: "apiKey", provider, value: "sk-live" } }, + { saveApiKey: () => Promise.reject(new Error("Denied")) }, + ); + + await controller.saveApiKey(); + + expect(getState().authDialog).toMatchObject({ step: "apiKey", value: "sk-live", saving: false, error: "Error: Denied" }); + }); }); -function createController(statePatch: Partial, apiPatch: Partial = {}) { +function createController( + statePatch: Partial, + apiPatch: Partial = {}, + applyStatus: (status: SessionStatus) => void = () => undefined, +) { let state: AppState = { ...initialAppState(), ...statePatch }; const api = { ...defaultApi, ...apiPatch }; const controller = new AuthController( () => state, (patch) => { state = { ...state, ...patch }; }, - () => undefined, + applyStatus, { api }, ); return { controller, getState: () => state }; } +async function flushMicrotasks(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +function remoteMachine(id: string): NonNullable { + return { + id, + name: "Remote", + kind: "remote", + baseUrl: "https://remote.example", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; +} + +function sessionInfo(id: string): SessionInfo { + return { + id, + cwd: "/repo", + path: `/tmp/${id}.jsonl`, + created: "2026-01-01T00:00:00.000Z", + modified: "2026-01-01T00:00:00.000Z", + messageCount: 0, + firstMessage: "", + }; +} + +function sessionStatus(sessionId: string): SessionStatus { + return { + sessionId, + isStreaming: false, + isCompacting: false, + isBashRunning: false, + pendingMessageCount: 0, + queuedMessages: [], + tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + cost: 0, + }; +} + function authProvider(id: string, authType: "oauth" | "api_key"): AuthProviderOption { return { id, authType, name: `${id} ${authType}`, status: { configured: false } }; } diff --git a/src/client/src/controllers/fileExplorerController.test.ts b/src/client/src/controllers/fileExplorerController.test.ts index 4cf87e5..4217d4c 100644 --- a/src/client/src/controllers/fileExplorerController.test.ts +++ b/src/client/src/controllers/fileExplorerController.test.ts @@ -158,6 +158,22 @@ describe("FileExplorerController workspace uploads", () => { }); }); + it("clears an in-flight upload by cancelling the request and removing the batch", async () => { + const upload = controllableUpload({ rejectOnCancel: true }); + const harness = createHarness({ uploadWorkspaceFiles: upload.fn }); + const run = harness.controller.startWorkspaceUpload([new File(["aa"], "a.txt")], { destinationFolder: "uploads" }); + + expect(run?.batchId).toBe("batch-1"); + expect(harness.state.workspaceUploadBatches["batch-1"]?.status).toBe("uploading"); + + harness.controller.clearWorkspaceUpload(run?.batchId ?? "missing"); + await run?.done; + + expect(upload.cancel).toHaveBeenCalledTimes(1); + expect(harness.state.workspaceUploadBatches).toEqual({}); + expect(harness.state.error).toBe(""); + }); + it("keeps per-file errors accurate and refreshes after partial batch success", async () => { const upload = controllableUpload(); const harness = createHarness({ uploadWorkspaceFiles: upload.fn, now: sequenceNow("start", "fail") }); diff --git a/src/client/src/promptAttachmentCapture.test.ts b/src/client/src/promptAttachmentCapture.test.ts index 61dbd26..d25ee37 100644 --- a/src/client/src/promptAttachmentCapture.test.ts +++ b/src/client/src/promptAttachmentCapture.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, it } from "vitest"; +import type { TemplateResult } from "lit"; +import { describe, expect, it, vi } from "vitest"; +import { PromptEditor } from "./components/PromptEditor"; import { capturePromptAttachments, DEFAULT_FILE_MIME_TYPE, effectivePromptAttachmentDelivery, READ_FAILURE_MESSAGE, type CapturableFile } from "./promptAttachmentCapture"; function file(name: string, type: string, size = 10): CapturableFile { @@ -79,3 +81,148 @@ describe("effectivePromptAttachmentDelivery", () => { ])).toBe("folder"); }); }); + +describe("PromptEditor attachment chips", () => { + it("removes a pending attachment chip before sending the remaining attachments", () => { + const editor = new PromptEditor(); + const onSend = vi.fn>(); + editor.onSend = onSend; + setPromptEditorPrivate(editor, "draft", "please review"); + setPromptEditorPrivate(editor, "attachments", [ + { id: "attachment-1", kind: "file", name: "report.pdf", mimeType: "application/pdf", data: "UkVQT1JU", size: 6 }, + { id: "attachment-2", kind: "image", name: "shot.png", mimeType: "image/png", data: "UE5H", size: 3 }, + ]); + + const removeReport = findTemplateEventHandlerAfterValue(editor.render(), "Remove report.pdf", "@click="); + removeReport(new Event("click")); + + expect(templateContainsValue(editor.render(), "Remove report.pdf")).toBe(false); + expect(templateContainsValue(editor.render(), "Remove shot.png")).toBe(true); + + const send = findTemplateEventHandlerAfterMarker(editor.render(), "send-button"); + send(new Event("click")); + + expect(onSend).toHaveBeenCalledTimes(1); + expect(onSend).toHaveBeenCalledWith("please review", undefined, [ + { kind: "image", mimeType: "image/png", data: "UE5H", name: "shot.png" }, + ], "inline"); + }); +}); + +type TemplateEventHandler = (event: E) => void; + +function setPromptEditorPrivate(editor: PromptEditor, property: string, value: unknown): void { + if (!Reflect.set(editor, property, value)) throw new Error(`Failed to set PromptEditor ${property}`); +} + +function findTemplateEventHandlerAfterMarker(template: TemplateResult, marker: string): TemplateEventHandler { + const handler = findOptionalTemplateEventHandlerAfterMarker(template, marker); + if (handler === undefined) throw new Error(`Expected template event handler after marker ${marker}`); + return handler; +} + +function findOptionalTemplateEventHandlerAfterMarker(template: TemplateResult, marker: string): TemplateEventHandler | undefined { + const strings = templateStrings(template); + const values = templateValues(template); + for (let index = 0; index < values.length; index += 1) { + const staticChunk = strings[index]; + if (staticChunk?.includes(marker) === true) { + const handler = nextTemplateEventHandler(values, index); + if (handler !== undefined) return handler; + } + const nestedHandler = findOptionalTemplateEventHandlerAfterMarkerInValue(values[index], marker); + if (nestedHandler !== undefined) return nestedHandler; + } + return undefined; +} + +function findOptionalTemplateEventHandlerAfterMarkerInValue(value: unknown, marker: string): TemplateEventHandler | undefined { + if (Array.isArray(value)) { + for (const item of value) { + const nestedHandler = findOptionalTemplateEventHandlerAfterMarkerInValue(item, marker); + if (nestedHandler !== undefined) return nestedHandler; + } + return undefined; + } + if (isTemplateResult(value)) return findOptionalTemplateEventHandlerAfterMarker(value, marker); + return undefined; +} + +function findTemplateEventHandlerAfterValue(template: TemplateResult, expectedValue: unknown, marker: string): TemplateEventHandler { + const handler = findOptionalTemplateEventHandlerAfterValue(template, expectedValue, marker); + if (handler === undefined) throw new Error(`Expected template event handler after value ${String(expectedValue)}`); + return handler; +} + +function findOptionalTemplateEventHandlerAfterValue(template: TemplateResult, expectedValue: unknown, marker: string): TemplateEventHandler | undefined { + const strings = templateStrings(template); + const values = templateValues(template); + for (let index = 0; index < values.length; index += 1) { + const value = values[index]; + if (value === expectedValue) { + for (let handlerIndex = index + 1; handlerIndex < values.length; handlerIndex += 1) { + const staticChunk = strings[handlerIndex]; + const maybeHandler = values[handlerIndex]; + if (staticChunk?.includes(marker) === true && isTemplateEventHandler(maybeHandler)) return maybeHandler; + } + } + const nestedHandler = findOptionalTemplateEventHandlerAfterValueInValue(value, expectedValue, marker); + if (nestedHandler !== undefined) return nestedHandler; + } + return undefined; +} + +function findOptionalTemplateEventHandlerAfterValueInValue(value: unknown, expectedValue: unknown, marker: string): TemplateEventHandler | undefined { + if (Array.isArray(value)) { + for (const item of value) { + const nestedHandler = findOptionalTemplateEventHandlerAfterValueInValue(item, expectedValue, marker); + if (nestedHandler !== undefined) return nestedHandler; + } + return undefined; + } + if (isTemplateResult(value)) return findOptionalTemplateEventHandlerAfterValue(value, expectedValue, marker); + return undefined; +} + +function nextTemplateEventHandler(values: readonly unknown[], startIndex: number): TemplateEventHandler | undefined { + for (let index = startIndex; index < values.length; index += 1) { + const value = values[index]; + if (isTemplateEventHandler(value)) return value; + } + return undefined; +} + +function templateContainsValue(template: TemplateResult, expectedValue: unknown): boolean { + return templateValues(template).some((value) => templateValueContains(value, expectedValue)); +} + +function templateValueContains(value: unknown, expectedValue: unknown): boolean { + if (value === expectedValue) return true; + if (Array.isArray(value)) return value.some((item) => templateValueContains(item, expectedValue)); + if (isTemplateResult(value)) return templateContainsValue(value, expectedValue); + return false; +} + +function templateStrings(template: TemplateResult): readonly string[] { + const strings = Reflect.get(template, "strings"); + if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable"); + return strings; +} + +function templateValues(template: TemplateResult): readonly unknown[] { + const values = Reflect.get(template, "values"); + if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable"); + return values.map((value: unknown) => value); +} + +function isTemplateResult(value: unknown): value is TemplateResult { + return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values")); +} + +function isTemplateEventHandler(value: unknown): value is TemplateEventHandler { + return typeof value === "function"; +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item: unknown) => typeof item === "string"); +} diff --git a/src/client/src/runtime/terminalRuntime.test.ts b/src/client/src/runtime/terminalRuntime.test.ts index 70c1c40..ad71de8 100644 --- a/src/client/src/runtime/terminalRuntime.test.ts +++ b/src/client/src/runtime/terminalRuntime.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import type { RunTerminalCommandInput, TerminalCommandRun, Workspace } from "../api"; +import type { RunTerminalCommandInput, TerminalCommandRun, TerminalCommandRunFilter, Workspace } from "../api"; import { createTerminalCommandRunsRuntime } from "./terminalRuntime"; const workspace: Workspace = { @@ -52,6 +52,32 @@ describe("terminal runtime", () => { await expect(handle.completed).resolves.toEqual(succeededRun); }); + it("passes through command-run lookup helpers and open requests", async () => { + const filter: TerminalCommandRunFilter = { + projectId: "p1", + workspaceId: "w1", + statuses: ["running"], + metadata: { "pi.operation": "test" }, + }; + const runs = [runningRun, succeededRun]; + const openTerminal = vi.fn(); + const api = { + runTerminalCommand: vi.fn(), + listCommandRuns: vi.fn(() => Promise.resolve(runs)), + getCommandRun: vi.fn(() => Promise.resolve(succeededRun)), + }; + const runtime = createTerminalCommandRunsRuntime("core", { api, openTerminal }); + + await expect(runtime.listCommandRuns(filter)).resolves.toEqual(runs); + await expect(runtime.getCommandRun("run1")).resolves.toEqual(succeededRun); + runtime.open({ terminalId: "t2" }); + + expect(api.listCommandRuns).toHaveBeenCalledWith(filter); + expect(api.getCommandRun).toHaveBeenCalledWith("run1"); + expect(openTerminal).toHaveBeenCalledWith(undefined, { terminalId: "t2" }); + expect(api.runTerminalCommand).not.toHaveBeenCalled(); + }); + it("polls command-run records until completion", async () => { vi.useFakeTimers(); const api = { @@ -73,4 +99,37 @@ describe("terminal runtime", () => { await expect(handle.completed).resolves.toEqual(succeededRun); expect(api.getCommandRun).toHaveBeenCalledWith("run1"); }); + + it("rejects completion polling failures and clears the scheduled timer", async () => { + const pollError = new Error("poll failed"); + const timerId = globalThis.setTimeout(() => undefined, 0); + globalThis.clearTimeout(timerId); + const scheduledPolls: (() => void)[] = []; + const clearTimeout = vi.fn(); + const api = { + runTerminalCommand: vi.fn(() => Promise.resolve(runningRun)), + listCommandRuns: vi.fn(), + getCommandRun: vi.fn(() => Promise.reject(pollError)), + }; + const runtime = createTerminalCommandRunsRuntime("core", { + api, + openTerminal: vi.fn(), + pollIntervalMs: 25, + setTimeout: (handler) => { + scheduledPolls.push(handler); + return timerId; + }, + clearTimeout, + }); + + const handle = await runtime.runCommand({ workspace, title: "Build", command: "npm run build" }); + const poll = scheduledPolls[0]; + expect(poll).toBeDefined(); + poll?.(); + + await expect(handle.completed).rejects.toBe(pollError); + expect(api.getCommandRun).toHaveBeenCalledWith("run1"); + expect(scheduledPolls).toHaveLength(1); + expect(clearTimeout).toHaveBeenCalledWith(timerId); + }); }); diff --git a/src/server/machines/machineService.test.ts b/src/server/machines/machineService.test.ts index f0f8714..0dfe4c5 100644 --- a/src/server/machines/machineService.test.ts +++ b/src/server/machines/machineService.test.ts @@ -2,6 +2,9 @@ import { chmod, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises" import { join, resolve } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PiWebRuntimeResponse } from "../../shared/apiTypes.js"; +import { PI_WEB_CAPABILITIES } from "../../shared/capabilities.js"; +import type { MachineClient } from "./machineClient.js"; import { MachineService } from "./machineService.js"; import { MachineStore, machineStorePath } from "./machineStore.js"; @@ -97,6 +100,90 @@ describe("MachineService", () => { }); }); + it("fetches and caches remote runtime through the configured client", async () => { + const body = remoteRuntimeBody(); + const requestJson = vi.fn(() => Promise.resolve({ statusCode: 200, headers: {}, body })); + const factoryMachines: unknown[] = []; + const remoteService = new MachineService(new MachineStore(storePath), { + remoteClientFactory: (machine) => { + factoryMachines.push(machine); + return fakeRemoteClient({ requestJson }); + }, + now: () => new Date("2026-05-25T00:00:00.000Z"), + runtimeCacheTtlMs: 10_000, + }); + const machine = await remoteService.add({ + name: " Remote ", + baseUrl: "https://remote.example.test/", + token: "secret", + headers: { "X-Pi-Web-Test": "yes" }, + }); + + const first = await remoteService.runtime(machine.id); + const second = await remoteService.runtime(machine.id); + + expect(first).toEqual({ + machineId: machine.id, + ok: true, + checkedAt: "2026-05-25T00:00:00.000Z", + packageName: body.packageName, + generatedAt: body.generatedAt, + components: body.components, + capabilities: body.capabilities, + }); + expect(second).toEqual(first); + expect(requestJson).toHaveBeenCalledTimes(1); + expect(requestJson).toHaveBeenCalledWith("GET", "/api/pi-web/runtime", undefined, { timeoutMs: 3000 }); + expect(factoryMachines).toEqual([ + expect.objectContaining({ + id: machine.id, + name: "Remote", + baseUrl: "https://remote.example.test", + token: "secret", + headers: { "X-Pi-Web-Test": "yes" }, + }), + ]); + }); + + it("caches remote runtime errors and clears them after remote updates", async () => { + let now = new Date("2026-05-25T00:00:00.000Z"); + const body = remoteRuntimeBody(); + const requestJson = vi.fn() + .mockRejectedValueOnce(new Error("network down")) + .mockResolvedValueOnce({ statusCode: 200, headers: {}, body }); + const remoteService = new MachineService(new MachineStore(storePath), { + remoteClientFactory: () => fakeRemoteClient({ requestJson }), + now: () => now, + runtimeCacheTtlMs: 10_000, + }); + const machine = await remoteService.add({ name: "Remote", baseUrl: "https://remote.example.test" }); + + const errorRuntime = await remoteService.runtime(machine.id); + now = new Date("2026-05-25T00:00:01.000Z"); + const cachedErrorRuntime = await remoteService.runtime(machine.id); + await remoteService.update(machine.id, { name: "Remote Updated" }); + now = new Date("2026-05-25T00:00:02.000Z"); + const refreshedRuntime = await remoteService.runtime(machine.id); + + expect(errorRuntime).toEqual({ + machineId: machine.id, + ok: false, + checkedAt: "2026-05-25T00:00:00.000Z", + error: "network down", + }); + expect(cachedErrorRuntime).toEqual(errorRuntime); + expect(refreshedRuntime).toEqual({ + machineId: machine.id, + ok: true, + checkedAt: "2026-05-25T00:00:02.000Z", + packageName: body.packageName, + generatedAt: body.generatedAt, + components: body.components, + capabilities: body.capabilities, + }); + expect(requestJson).toHaveBeenCalledTimes(2); + }); + it("does not allow local machine mutation", async () => { await expect(service.update("local", { name: "Other" })).rejects.toThrow("Local machine cannot be changed"); await expect(service.remove("local")).rejects.toThrow("Local machine cannot be deleted"); @@ -112,3 +199,36 @@ async function expectOwnerOnlyMachineStore(path: string): Promise { if (process.platform === "win32") return; expect((await stat(path)).mode & 0o777).toBe(0o600); } + +function remoteRuntimeBody(): PiWebRuntimeResponse { + return { + packageName: "@jmfederico/pi-web", + generatedAt: "2026-05-25T00:00:00.000Z", + components: { + web: { + component: "web", + label: "Remote Web", + runtimeVersion: "1.0.0", + available: true, + capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage], + }, + sessiond: { + component: "sessiond", + label: "Remote Session daemon", + runtimeVersion: "1.0.0", + available: true, + capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived], + }, + }, + capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage], + }; +} + +function fakeRemoteClient(overrides: Partial): MachineClient { + return { + request: () => { throw new Error("HTTP request not configured for test"); }, + requestJson: () => { throw new Error("JSON request not configured for test"); }, + connectWebSocket: () => { throw new Error("WebSocket not configured for test"); }, + ...overrides, + }; +} diff --git a/src/server/piWebStatusCache.test.ts b/src/server/piWebStatusCache.test.ts index 44170f3..0d94142 100644 --- a/src/server/piWebStatusCache.test.ts +++ b/src/server/piWebStatusCache.test.ts @@ -31,6 +31,49 @@ describe("createPiWebStatusCache", () => { expect(load).toHaveBeenCalledTimes(2); }); + it("explicitly refreshes and replaces a fresh cached status", async () => { + let now = 1_000; + const load = vi.fn() + .mockResolvedValueOnce(status("first")) + .mockResolvedValueOnce(status("second")); + const cache = createPiWebStatusCache(load, { ttlMs: 100, now: () => now }); + + await expect(cache.get()).resolves.toMatchObject({ generatedAt: "first" }); + now = 1_050; + + await expect(cache.refresh()).resolves.toMatchObject({ generatedAt: "second" }); + await expect(cache.get()).resolves.toMatchObject({ generatedAt: "second" }); + expect(load).toHaveBeenCalledTimes(2); + }); + + it("retains stale status and reports background refresh errors", async () => { + let now = 1_000; + const refreshError = new Error("refresh failed"); + const errorReported = createDeferred(); + const onError = vi.fn((error: unknown) => { + errorReported.resolve(error); + }); + const load = vi.fn() + .mockResolvedValueOnce(status("first")) + .mockRejectedValueOnce(refreshError) + .mockResolvedValueOnce(status("second")); + const cache = createPiWebStatusCache(load, { ttlMs: 100, now: () => now, onError }); + + await expect(cache.get()).resolves.toMatchObject({ generatedAt: "first" }); + now = 1_101; + + await expect(cache.get()).resolves.toMatchObject({ generatedAt: "first" }); + await expect(errorReported.promise).resolves.toBe(refreshError); + expect(onError).toHaveBeenCalledTimes(1); + expect(load).toHaveBeenCalledTimes(2); + + await expect(cache.get()).resolves.toMatchObject({ generatedAt: "first" }); + await waitForMicrotasks(); + + await expect(cache.get()).resolves.toMatchObject({ generatedAt: "second" }); + expect(load).toHaveBeenCalledTimes(3); + }); + it("deduplicates concurrent cold loads", async () => { const deferred = createDeferred(); const load = vi.fn(() => deferred.promise); diff --git a/src/server/sessiond/sessionProxyRoutes.test.ts b/src/server/sessiond/sessionProxyRoutes.test.ts index 32e0974..f6a19c5 100644 --- a/src/server/sessiond/sessionProxyRoutes.test.ts +++ b/src/server/sessiond/sessionProxyRoutes.test.ts @@ -36,6 +36,40 @@ describe("machine-scoped session proxy routes", () => { expect(daemon.requests).toEqual([{ method: "POST", path: "/auth/api-key", body: { providerId: "p", key: "k" } }]); }); + it("forwards sessiond health and runtime aliases to daemon endpoints", async () => { + const healthResponse = await app.inject({ method: "GET", url: "/api/machines/local/sessiond/health" }); + const runtimeResponse = await app.inject({ method: "GET", url: "/api/machines/local/sessiond/runtime" }); + + expect(healthResponse.statusCode).toBe(200); + expect(healthResponse.json()).toEqual({ ok: true }); + expect(runtimeResponse.statusCode).toBe(200); + expect(runtimeResponse.json()).toEqual({ ok: true }); + expect(daemon.requests).toEqual([ + { method: "GET", path: "/health", body: undefined }, + { method: "GET", path: "/runtime", body: undefined }, + ]); + }); + + it("forwards empty upstream responses without parsing a body", async () => { + daemon.respondWith({ statusCode: 204, headers: {}, body: "" }); + + const response = await app.inject({ method: "DELETE", url: "/api/machines/local/sessions/session-1" }); + + expect(response.statusCode).toBe(204); + expect(response.body).toBe(""); + expect(daemon.requests).toEqual([{ method: "DELETE", path: "/sessions/session-1", body: undefined }]); + }); + + it("returns a 502 response when the daemon request fails", async () => { + daemon.failWith(new Error("connection refused")); + + const response = await app.inject({ method: "GET", url: "/api/machines/local/sessions" }); + + expect(response.statusCode).toBe(502); + expect(response.json()).toEqual({ error: "Session daemon unavailable: connection refused" }); + expect(daemon.requests).toEqual([{ method: "GET", path: "/sessions", body: undefined }]); + }); + it("preserves cwd query context when forwarding session event websockets", async () => { await app.listen({ host: "127.0.0.1", port: 0 }); const socket = new WebSocket(`${serverUrl(app)}/api/machines/local/sessions/session-1/events?cwd=${encodeURIComponent("/repo")}`); @@ -49,9 +83,16 @@ describe("machine-scoped session proxy routes", () => { }); }); +interface FakeSessionDaemonResponse { + statusCode: number; + headers: Record; + body: string; +} + class FakeSessionDaemon { readonly requests: { method: string; path: string; body: unknown }[] = []; readonly websocketPaths: string[] = []; + private readonly queuedResponses: (FakeSessionDaemonResponse | Error)[] = []; private readonly sockets = new Set(); private constructor(private readonly upstream: WebSocketServer) { @@ -67,9 +108,19 @@ class FakeSessionDaemon { return new FakeSessionDaemon(upstream); } - request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record; body: string }> { + respondWith(response: FakeSessionDaemonResponse): void { + this.queuedResponses.push(response); + } + + failWith(error: Error): void { + this.queuedResponses.push(error); + } + + request(method: string, path: string, body?: unknown): Promise { this.requests.push({ method, path, body }); - return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true }) }); + const queuedResponse = this.queuedResponses.shift(); + if (queuedResponse instanceof Error) return Promise.reject(queuedResponse); + return Promise.resolve(queuedResponse ?? { statusCode: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true }) }); } connectWebSocket(path: string): WebSocket { diff --git a/src/server/sessions/attachmentService.test.ts b/src/server/sessions/attachmentService.test.ts index 089c5ef..a41601d 100644 --- a/src/server/sessions/attachmentService.test.ts +++ b/src/server/sessions/attachmentService.test.ts @@ -1,13 +1,21 @@ import { mkdir, mkdtemp, readFile, readdir, rm, symlink } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { DEFAULT_ATTACHMENT_FOLDER, saveAttachmentsToWorkspace } from "./attachmentService.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { formatDimensionNote, resizeImage, type ResizedImage } from "@earendil-works/pi-coding-agent"; +import { DEFAULT_ATTACHMENT_FOLDER, attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js"; + +vi.mock("@earendil-works/pi-coding-agent", () => ({ + formatDimensionNote: vi.fn(), + resizeImage: vi.fn(), +})); let workspace: string; let externalDirectories: string[] = []; beforeEach(async () => { + vi.mocked(formatDimensionNote).mockReset(); + vi.mocked(resizeImage).mockReset(); workspace = await mkdtemp(join(tmpdir(), "pi-web-attachments-")); externalDirectories = []; }); @@ -22,6 +30,63 @@ afterEach(async () => { const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]); const pngBase64 = pngBytes.toString("base64"); +function resizedImage(overrides: Partial = {}): ResizedImage { + return { + data: "resized-data", + mimeType: "image/png", + originalWidth: 2400, + originalHeight: 1200, + width: 1200, + height: 600, + wasResized: true, + ...overrides, + }; +} + +describe("attachmentsToInlineImages", () => { + it("resizes images, drops unresizable images, and preserves dimension notes", async () => { + const firstInput = Buffer.from("first image"); + const droppedInput = Buffer.from("too large"); + const thirdInput = Buffer.from("third image"); + const firstResized = resizedImage({ data: "first-resized", mimeType: "image/webp" }); + const thirdResized = resizedImage({ + data: "third-resized", + mimeType: "image/jpeg", + originalWidth: 640, + originalHeight: 480, + width: 640, + height: 480, + wasResized: false, + }); + + vi.mocked(resizeImage) + .mockResolvedValueOnce(firstResized) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(thirdResized); + vi.mocked(formatDimensionNote) + .mockReturnValueOnce("[Image dimensions changed.]") + .mockReturnValueOnce(undefined); + + await expect(attachmentsToInlineImages([ + { kind: "image", mimeType: "image/png", data: firstInput.toString("base64"), name: "first.png" }, + { kind: "image", mimeType: "image/png", data: droppedInput.toString("base64"), name: "huge.png" }, + { kind: "image", mimeType: "image/jpeg", data: thirdInput.toString("base64"), name: "photo.jpg" }, + ])).resolves.toEqual([ + { + image: { type: "image", data: "first-resized", mimeType: "image/webp" }, + dimensionNote: "[Image dimensions changed.]", + }, + { image: { type: "image", data: "third-resized", mimeType: "image/jpeg" } }, + ]); + + expect(resizeImage).toHaveBeenNthCalledWith(1, firstInput, "image/png"); + expect(resizeImage).toHaveBeenNthCalledWith(2, droppedInput, "image/png"); + expect(resizeImage).toHaveBeenNthCalledWith(3, thirdInput, "image/jpeg"); + expect(formatDimensionNote).toHaveBeenNthCalledWith(1, firstResized); + expect(formatDimensionNote).toHaveBeenNthCalledWith(2, thirdResized); + }); +}); + describe("saveAttachmentsToWorkspace", () => { it("writes attachments into the default folder and returns relative paths", async () => { const fixedNow = () => new Date("2026-06-13T12:05:01.123Z"); diff --git a/src/server/sessions/authService.test.ts b/src/server/sessions/authService.test.ts index 3efe1b3..3218b9d 100644 --- a/src/server/sessions/authService.test.ts +++ b/src/server/sessions/authService.test.ts @@ -1,6 +1,8 @@ import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import type { OAuthFlowState } from "../../shared/apiTypes.js"; import { AuthService, type AuthChange } from "./authService.js"; +import { OAuthLoginFlowService } from "./oauthLoginFlowService.js"; describe("AuthService", () => { it("saves API keys and emits a global auth change", () => { @@ -30,6 +32,39 @@ describe("AuthService", () => { expect(changes).toEqual([]); auth.dispose(); }); + + it("refreshes auth state after OAuth login completes", () => { + const authStorage = AuthStorage.inMemory(); + const modelRegistry = ModelRegistry.create(authStorage); + const authFlows = new CapturingOAuthLoginFlowService(); + const auth = new AuthService({ modelRegistry, authFlows }); + const changes: AuthChange[] = []; + auth.subscribe((change) => { changes.push(change); }); + const reload = vi.spyOn(authStorage, "reload"); + const refresh = vi.spyOn(modelRegistry, "refresh"); + const provider = authStorage.getOAuthProviders().find((option) => option.id === "anthropic"); + if (provider === undefined) throw new Error("Expected built-in OAuth provider"); + + expect(auth.startOAuthLogin(provider.id)).toMatchObject({ providerId: provider.id, providerName: provider.name, status: "running" }); + + const startOptions = authFlows.startCalls.at(0); + if (startOptions === undefined) throw new Error("Expected OAuth flow to start"); + expect(startOptions.providerId).toBe(provider.id); + expect(startOptions.providerName).toBe(provider.name); + expect(startOptions.authStorage).toBe(authStorage); + expect(changes).toEqual([]); + + reload.mockClear(); + refresh.mockClear(); + if (startOptions.onComplete === undefined) throw new Error("Expected OAuth completion callback"); + startOptions.onComplete(); + + expect(reload).toHaveBeenCalledOnce(); + expect(refresh).toHaveBeenCalledOnce(); + expect(changes).toEqual([{}]); + auth.dispose(); + expect(authFlows.disposed).toBe(true); + }); }); function createAuthService(data: Parameters[0] = {}) { @@ -40,3 +75,17 @@ function createAuthService(data: Parameters[0] = {} auth.subscribe((change) => { changes.push(change); }); return { auth, authStorage, changes }; } + +class CapturingOAuthLoginFlowService extends OAuthLoginFlowService { + readonly startCalls: Parameters[0][] = []; + disposed = false; + + override start(options: Parameters[0]): OAuthFlowState { + this.startCalls.push(options); + return { flowId: "flow-1", providerId: options.providerId, providerName: options.providerName, status: "running", progress: [] }; + } + + override dispose(): void { + this.disposed = true; + } +} diff --git a/src/server/sessions/oauthLoginFlowService.test.ts b/src/server/sessions/oauthLoginFlowService.test.ts index 07b0fc3..24e3ab8 100644 --- a/src/server/sessions/oauthLoginFlowService.test.ts +++ b/src/server/sessions/oauthLoginFlowService.test.ts @@ -12,6 +12,7 @@ afterEach(() => { describe("OAuthLoginFlowService", () => { it("round-trips prompt responses and completes the flow", async () => { let promptValue: string | undefined; + const onComplete = vi.fn(); const service = new OAuthLoginFlowService(); const state = service.start({ providerId: "test-provider", @@ -22,6 +23,7 @@ describe("OAuthLoginFlowService", () => { promptValue = await callbacks.onPrompt({ message: "Paste code", placeholder: "code" }); callbacks.onProgress?.(`Got ${promptValue}`); }), + onComplete, }); const prompt = state.prompt; @@ -35,6 +37,7 @@ describe("OAuthLoginFlowService", () => { expect(promptValue).toBe("abc123"); expect(service.get(state.flowId)).toMatchObject({ status: "complete", progress: ["Waiting for code", "Got abc123", "Login complete"] }); + expect(onComplete).toHaveBeenCalledOnce(); service.dispose(); }); @@ -113,6 +116,30 @@ describe("OAuthLoginFlowService", () => { service.dispose(); }); + it("rejects pending prompts when disposed", async () => { + const promptRejected = deferred(); + const service = new OAuthLoginFlowService(); + const state = service.start({ + providerId: "test-provider", + providerName: "Test Provider", + authStorage: fakeAuthStorage(async (_providerId, callbacks) => { + try { + await callbacks.onPrompt({ message: "Paste code" }); + } catch (error) { + promptRejected.resolve(toError(error)); + throw error; + } + }), + }); + + expect(state.prompt).toBeDefined(); + + service.dispose(); + + await expect(promptRejected.promise).resolves.toMatchObject({ message: "Login cancelled" }); + expect(() => { service.get(state.flowId); }).toThrow("OAuth login flow not found"); + }); + it("rejects stale or duplicate responses", () => { const service = new OAuthLoginFlowService(); const state = service.start({ diff --git a/src/server/terminals/terminalRoutes.test.ts b/src/server/terminals/terminalRoutes.test.ts index 47bc2b3..c387046 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("routes command-run create, filter, cancel, and terminal continue requests", async () => { + it("routes command-run create, get, filter, cancel, and terminal continue requests", async () => { const createResponse = await app.inject({ method: "POST", url: "/terminal-command-runs", @@ -51,7 +51,16 @@ describe("terminal routes", () => { }); expect(createResponse.statusCode).toBe(200); - expect(createResponse.json()).toMatchObject({ id: "run1", terminalId: "t-run", status: "running" }); + const createdRun = createResponse.json(); + expect(createdRun).toMatchObject({ id: "run1", terminalId: "t-run", status: "running" }); + + const getResponse = await app.inject({ method: "GET", url: "/terminal-command-runs/run1" }); + expect(getResponse.statusCode).toBe(200); + expect(getResponse.json()).toEqual(createdRun); + + const missingGetResponse = await app.inject({ method: "GET", url: "/terminal-command-runs/missing" }); + expect(missingGetResponse.statusCode).toBe(404); + expect(missingGetResponse.json()).toEqual({ error: "Terminal command run not found" }); const listResponse = await app.inject({ method: "GET", url: `/terminal-command-runs?projectId=p1&statuses=running&metadata=${encodeURIComponent(JSON.stringify({ "pi.operation": "test" }))}` }); @@ -67,6 +76,22 @@ describe("terminal routes", () => { expect(continueResponse.statusCode).toBe(200); expect(terminals.events).toContain("continue:t-run"); }); + + it("rejects invalid command-run filter and metadata queries", async () => { + const invalidStatusResponse = await app.inject({ method: "GET", url: "/terminal-command-runs?statuses=running,stuck" }); + expect(invalidStatusResponse.statusCode).toBe(400); + expect(invalidStatusResponse.json()).toEqual({ error: "Invalid command run status: stuck" }); + + const arrayMetadataResponse = await app.inject({ method: "GET", url: `/terminal-command-runs?metadata=${encodeURIComponent(JSON.stringify(["not", "an", "object"]))}` }); + expect(arrayMetadataResponse.statusCode).toBe(400); + expect(arrayMetadataResponse.json()).toEqual({ error: "metadata filter must be an object" }); + + const nonStringMetadataResponse = await app.inject({ method: "GET", url: `/terminal-command-runs?metadata=${encodeURIComponent(JSON.stringify({ "pi.operation": 42 }))}` }); + expect(nonStringMetadataResponse.statusCode).toBe(400); + expect(nonStringMetadataResponse.json()).toEqual({ error: "metadata filter value must be a string: pi.operation" }); + + expect(terminals.filters).toEqual([]); + }); }); class FakeTerminals implements TerminalRouteService { diff --git a/src/server/workspaces/fileContentService.test.ts b/src/server/workspaces/fileContentService.test.ts index e2ab634..580d1a6 100644 --- a/src/server/workspaces/fileContentService.test.ts +++ b/src/server/workspaces/fileContentService.test.ts @@ -380,4 +380,16 @@ describe("moveWorkspaceFile", () => { expect(source.content).toBe("data"); await expect(readFile(join(outsideDir, "evil.txt"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); }); + + it("prevents moving a source symlink that escapes the workspace", async () => { + const root = await tempWorkspace(); + const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-move-source-outside-")); + roots.push(outsideDir); + await writeFile(join(outsideDir, "secret.txt"), "secret"); + await symlink(join(outsideDir, "secret.txt"), join(root, "source-link.txt")); + + await expect(moveWorkspaceFile(root, "source-link.txt", "moved.txt")).rejects.toThrow("Path escapes workspace"); + await expect(readWorkspaceFile(root, "moved.txt")).rejects.toThrow("Path does not exist"); + await expect(readFile(join(outsideDir, "secret.txt"), "utf8")).resolves.toBe("secret"); + }); }); From 9006db1e6dae6aa58ac41ed2c7c27cfc8fbeeabe Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 4 Jul 2026 22:36:45 +0200 Subject: [PATCH 3/3] test: tolerate Windows line endings in Docker docs check --- src/docker/piWebDockerDocs.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/docker/piWebDockerDocs.test.ts b/src/docker/piWebDockerDocs.test.ts index 682f112..3dbc3d8 100644 --- a/src/docker/piWebDockerDocs.test.ts +++ b/src/docker/piWebDockerDocs.test.ts @@ -48,7 +48,8 @@ describe("pi-web-docker documentation", () => { }); function readDockerCommandMatrix(dockerReadme: string): string[] { - const commandMatrixSection = dockerReadme.split("### Command matrix\n")[1]?.split("\n### Installer options")[0] ?? ""; + const normalizedReadme = normalizeLineEndings(dockerReadme); + const commandMatrixSection = normalizedReadme.split("### Command matrix\n")[1]?.split("\n### Installer options")[0] ?? ""; return Array.from(commandMatrixSection.matchAll(/^\| `([^`]+)` \|/gm), (match) => { const command = match[1]; if (command === undefined) throw new Error("Docker command matrix row did not include a command"); @@ -57,7 +58,8 @@ function readDockerCommandMatrix(dockerReadme: string): string[] { } function readEntrypointCommandCases(dockerEntrypoint: string): Set { - const commandCaseBlock = dockerEntrypoint.slice(dockerEntrypoint.indexOf('case "$command_name" in')); + const normalizedEntrypoint = normalizeLineEndings(dockerEntrypoint); + const commandCaseBlock = normalizedEntrypoint.slice(normalizedEntrypoint.indexOf('case "$command_name" in')); const commandCases = new Set(); for (const line of commandCaseBlock.split("\n")) { const match = /^ {2}([a-z][a-z-]*(?:\|[a-z][a-z-]*)*)(?:\|__run-detached)?\)$/.exec(line); @@ -67,6 +69,10 @@ function readEntrypointCommandCases(dockerEntrypoint: string): Set { return commandCases; } +function normalizeLineEndings(content: string): string { + return content.replace(/\r\n?/g, "\n"); +} + async function readRepoFile(relativePath: string): Promise { return await readFile(join(repoRoot, relativePath), "utf8"); }