Merge branch 'test-suite-audit-followup'

# Conflicts:
#	src/server/sessions/piSessionService.test.ts
This commit is contained in:
Federico Jaramillo Martinez
2026-07-10 22:44:13 +02:00
48 changed files with 6936 additions and 6023 deletions
@@ -100,6 +100,43 @@ describe("workspace upload helpers", () => {
]);
});
it("forwards createDirs through batch upload requests", async () => {
const xhrs = new FakeXhrQueue();
const file = new File(["hello"], "nested.txt", { type: "text/plain" });
const task = uploadWorkspaceFiles("p1", "w1", [file], {
destinationFolder: "uploads",
createDirs: false,
xhrFactory: xhrs.factory,
});
const xhr = xhrs.only();
expect(xhr.url).toBe("/api/machines/local/projects/p1/workspaces/w1/file?path=uploads%2Fnested.txt&createDirs=false");
xhr.respondJson(200, { path: "uploads/nested.txt", size: 5, modifiedAt: "2026-06-25T00:00:00.000Z", created: true });
await expect(task.promise).resolves.toEqual([
{ path: "uploads/nested.txt", size: 5, modifiedAt: "2026-06-25T00:00:00.000Z", created: true },
]);
});
it("cancels an in-flight batch upload without starting remaining files", async () => {
const xhrs = new FakeXhrQueue();
const files = [new File(["ab"], "a.txt"), new File(["cde"], "b.txt")];
const task = uploadWorkspaceFiles("p1", "w1", files, {
destinationFolder: "uploads",
xhrFactory: xhrs.factory,
});
const first = xhrs.only();
const cancellation = expect(task.promise).rejects.toBeInstanceOf(WorkspaceUploadCancelledError);
task.cancel();
await cancellation;
expect(first.aborted).toBe(true);
expect(xhrs.count()).toBe(1);
});
it("continues batch uploads after per-file failures and reports the failed file only", async () => {
const xhrs = new FakeXhrQueue();
const progress: WorkspaceUploadBatchProgress[] = [];
@@ -146,6 +183,10 @@ class FakeXhrQueue {
at(index: number): FakeXMLHttpRequest {
return this.instances[index] ?? failTest(`missing XHR instance ${String(index)}`);
}
count(): number {
return this.instances.length;
}
}
class FakeXMLHttpRequest implements WorkspaceUploadXhr {
@@ -0,0 +1,182 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { configApi, type PiWebConfigResponse } from "../api";
import { SettingsDialog } from "./SettingsDialog";
import { callDialogPromise, callDialogUpdated, collectTemplateStrings, configResponse, deferred, getDialogProperty, remoteMachine, secondRemoteMachine, setDialogProperty, stubWindowTimers } from "./SettingsDialog.testSupport";
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("settings-dialog general settings machine targeting", () => {
it("renders the active settings panel without the old global scope note", () => {
const dialog = new SettingsDialog();
dialog.section = "general";
dialog.machine = remoteMachine;
const strings = collectTemplateStrings(dialog.render()).join("");
expect(strings).toContain("<settings-general-panel");
expect(strings).not.toContain("scope-note");
expect(strings).not.toContain("This tab edits:");
});
it("keeps gateway server config saves on the gateway config endpoint", async () => {
stubWindowTimers();
const savedConfig = configResponse({ host: "0.0.0.0", port: 9000, allowedHosts: true });
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
const onConfigSaved = vi.fn();
const dialog = new SettingsDialog();
dialog.onConfigSaved = onConfigSaved;
await callDialogPromise(dialog, "saveConfig", { host: "0.0.0.0", port: 9000, allowedHosts: true });
expect(saveSpy.mock.calls).toEqual([[{ host: "0.0.0.0", port: 9000, allowedHosts: true }]]);
expect(getDialogProperty(dialog, "configResponse")).toBe(savedConfig);
expect(onConfigSaved).toHaveBeenCalledWith({ host: "0.0.0.0", port: 9000, allowedHosts: true });
expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved.");
expect(getDialogProperty(dialog, "saving")).toBe(false);
});
it("loads file access and upload config from the selected machine", async () => {
const config = configResponse({ pathAccess: { allowedPaths: ["/mnt/share"] }, uploads: { defaultFolder: "manual/uploads" } });
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
await callDialogPromise(dialog, "loadAccessConfigForTarget");
expect(configSpy.mock.calls).toEqual([["remote-a"]]);
expect(getDialogProperty(dialog, "accessConfigResponse")).toBe(config);
expect(getDialogProperty(dialog, "accessError")).toBe("");
expect(getDialogProperty(dialog, "accessLoading")).toBe(false);
});
it("saves selected-machine file access and upload config through the selected-machine endpoint", async () => {
stubWindowTimers();
const patch = { pathAccess: { allowedPaths: ["/mnt/share", "~/SDKs"] }, uploads: { defaultFolder: "manual/uploads" } };
const savedConfig = configResponse(patch);
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
await callDialogPromise(dialog, "saveMachineAccessConfig", patch);
expect(saveSpy.mock.calls).toEqual([[patch, "remote-a"]]);
expect(getDialogProperty(dialog, "accessConfigResponse")).toBe(savedConfig);
expect(getDialogProperty(dialog, "configResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved.");
expect(getDialogProperty(dialog, "saving")).toBe(false);
});
it("merges local selected-machine access saves into gateway config without dropping gateway-only values", async () => {
stubWindowTimers();
const gatewayConfig = configResponse({
host: "127.0.0.1",
port: 8504,
allowedHosts: ["gateway.local"],
shortcuts: { "core:view.chat": "mod+1" },
plugins: { info: { enabled: true } },
spawnSessions: false,
pathAccess: { allowedPaths: ["/old"] },
uploads: { defaultFolder: "old/uploads" },
maxUploadBytes: 1234,
});
const patch = { pathAccess: { allowedPaths: ["~/SDKs"] }, uploads: {} };
const savedConfig = configResponse({ pathAccess: { allowedPaths: ["~/SDKs"] }, uploads: {}, maxUploadBytes: 5678 });
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
const onConfigSaved = vi.fn();
const dialog = new SettingsDialog();
dialog.onConfigSaved = onConfigSaved;
setDialogProperty(dialog, "configResponse", gatewayConfig);
await callDialogPromise(dialog, "saveMachineAccessConfig", patch);
expect(saveSpy.mock.calls).toEqual([[patch, "local"]]);
expect(getDialogProperty(dialog, "accessConfigResponse")).toBe(savedConfig);
expect(getDialogProperty(dialog, "configResponse")).toMatchObject({
config: {
host: "127.0.0.1",
port: 8504,
allowedHosts: ["gateway.local"],
shortcuts: { "core:view.chat": "mod+1" },
plugins: { info: { enabled: true } },
spawnSessions: false,
pathAccess: { allowedPaths: ["~/SDKs"] },
uploads: {},
maxUploadBytes: 5678,
},
effectiveConfig: {
host: "127.0.0.1",
port: 8504,
allowedHosts: ["gateway.local"],
shortcuts: { "core:view.chat": "mod+1" },
plugins: { info: { enabled: true } },
spawnSessions: false,
pathAccess: { allowedPaths: ["~/SDKs"] },
uploads: {},
maxUploadBytes: 5678,
},
});
expect(onConfigSaved).toHaveBeenCalledWith({
host: "127.0.0.1",
port: 8504,
allowedHosts: ["gateway.local"],
shortcuts: { "core:view.chat": "mod+1" },
plugins: { info: { enabled: true } },
spawnSessions: false,
pathAccess: { allowedPaths: ["~/SDKs"] },
uploads: {},
maxUploadBytes: 5678,
});
});
it("ignores stale file access load responses after the selected machine changes", async () => {
const load = deferred<PiWebConfigResponse>();
vi.spyOn(configApi, "config").mockReturnValue(load.promise);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
const loadPromise = callDialogPromise(dialog, "loadAccessConfigForTarget");
expect(getDialogProperty(dialog, "accessLoading")).toBe(true);
dialog.machine = secondRemoteMachine;
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
load.resolve(configResponse({ pathAccess: { allowedPaths: ["/stale"] } }));
await loadPromise;
expect(getDialogProperty(dialog, "accessConfigResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "accessError")).toBe("");
expect(getDialogProperty(dialog, "accessLoading")).toBe(false);
});
it("ignores stale file access save responses after the selected machine changes", async () => {
const save = deferred<PiWebConfigResponse>();
vi.spyOn(configApi, "saveConfig").mockReturnValue(save.promise);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
const savePromise = callDialogPromise(dialog, "saveMachineAccessConfig", { pathAccess: { allowedPaths: ["/mnt/share"] }, uploads: { defaultFolder: "manual" } });
expect(getDialogProperty(dialog, "saving")).toBe(true);
dialog.machine = secondRemoteMachine;
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
save.resolve(configResponse({ pathAccess: { allowedPaths: ["/mnt/share"] }, uploads: { defaultFolder: "manual" } }));
await savePromise;
expect(getDialogProperty(dialog, "accessConfigResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "savedMessage")).toBe("");
expect(getDialogProperty(dialog, "saving")).toBe(false);
});
it("shows selected-machine file access errors with the selected target name", async () => {
vi.spyOn(configApi, "config").mockRejectedValue(new Error("Remote machine unavailable"));
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
await callDialogPromise(dialog, "loadAccessConfigForTarget");
expect(getDialogProperty(dialog, "accessError")).toBe("Failed to load file access/upload config from Lab Mac (remote machine): Could not reach Lab Mac for selected-machine settings. Check the machine connection and try again.");
expect(getDialogProperty(dialog, "accessLoading")).toBe(false);
});
});
@@ -0,0 +1,79 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { piPackagesApi, pluginsApi, type PiPackageMutationResponse } from "../api";
import { SettingsDialog } from "./SettingsDialog";
import { callDialogPromise, callDialogUpdated, deferred, getDialogProperty, packageInfo, packageMutationResponse, pluginInfo, pluginsResponse, remoteMachine, runtimeWithPackageManagement, secondRemoteMachine } from "./SettingsDialog.testSupport";
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
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<typeof remotePackages>();
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<PiPackageMutationResponse>();
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);
});
});
@@ -0,0 +1,174 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { configApi, pluginsApi, type PiWebConfigResponse, type PiWebPluginsResponse } from "../api";
import { SettingsDialog } from "./SettingsDialog";
import { callDialogPromise, callDialogUpdated, configResponse, deferred, getDialogProperty, pluginInfo, pluginsResponse, remoteMachine, secondRemoteMachine, setDialogProperty, stubWindowTimers } from "./SettingsDialog.testSupport";
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
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 } } });
const plugins = pluginsResponse([pluginInfo("info", true)]);
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(plugins);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
await callDialogPromise(dialog, "loadPluginsForTarget");
expect(configSpy.mock.calls).toEqual([["remote-a"]]);
expect(pluginsSpy.mock.calls).toEqual([["remote-a"]]);
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(config);
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBe(plugins);
expect(getDialogProperty(dialog, "pluginError")).toBe("");
expect(getDialogProperty(dialog, "pluginLoading")).toBe(false);
});
it("keeps fulfilled plugin config when the selected machine plugin list is unsupported", async () => {
const config = configResponse({ plugins: { info: { enabled: true } } });
vi.spyOn(configApi, "config").mockResolvedValue(config);
vi.spyOn(pluginsApi, "plugins").mockRejectedValue(new Error("route GET:/api/plugins not found"));
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
await callDialogPromise(dialog, "loadPluginsForTarget");
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(config);
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "pluginError")).toBe("Failed to load PI WEB plugin settings from Lab Mac (remote machine): PI WEB plugins: Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
expect(getDialogProperty(dialog, "pluginLoading")).toBe(false);
});
it("saves selected-machine plugin toggles as plugin-only patches and refreshes the selected machine plugin list", async () => {
stubWindowTimers();
const baseConfig = configResponse({
plugins: {
keep: { enabled: true, settings: { level: 1 } },
info: { settings: { color: "blue" } },
},
});
const savedConfig = configResponse({
plugins: {
keep: { enabled: true, settings: { level: 1 } },
info: { enabled: false, settings: { color: "blue" } },
},
});
const refreshedPlugins = pluginsResponse([pluginInfo("info", false), pluginInfo("keep", true)]);
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(refreshedPlugins);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
setDialogProperty(dialog, "selectedPluginConfigResponse", baseConfig);
await callDialogPromise(dialog, "togglePlugin", "info", false);
expect(saveSpy.mock.calls).toEqual([[
{
plugins: {
keep: { enabled: true, settings: { level: 1 } },
info: { enabled: false, settings: { color: "blue" } },
},
},
"remote-a",
]]);
expect(pluginsSpy.mock.calls).toEqual([["remote-a"]]);
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(savedConfig);
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBe(refreshedPlugins);
expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved.");
expect(getDialogProperty(dialog, "saving")).toBe(false);
});
it("merges local selected-machine plugin saves into gateway config without dropping gateway-only values", async () => {
stubWindowTimers();
const gatewayConfig = configResponse({
host: "127.0.0.1",
shortcuts: { "core:view.chat": "mod+1" },
spawnSessions: false,
plugins: { info: { enabled: false }, gateway: { settings: { theme: "dark" } } },
});
const savedConfig = configResponse({ plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } } });
const refreshedPlugins = pluginsResponse([pluginInfo("info", true)]);
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
vi.spyOn(pluginsApi, "plugins").mockResolvedValue(refreshedPlugins);
const onConfigSaved = vi.fn();
const dialog = new SettingsDialog();
dialog.onConfigSaved = onConfigSaved;
setDialogProperty(dialog, "configResponse", gatewayConfig);
setDialogProperty(dialog, "selectedPluginConfigResponse", configResponse({ plugins: { info: { enabled: false } } }));
await callDialogPromise(dialog, "togglePlugin", "info", true);
expect(saveSpy.mock.calls).toEqual([[{ plugins: { info: { enabled: true } } }, "local"]]);
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(savedConfig);
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBe(refreshedPlugins);
expect(getDialogProperty(dialog, "configResponse")).toMatchObject({
config: {
host: "127.0.0.1",
shortcuts: { "core:view.chat": "mod+1" },
spawnSessions: false,
plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } },
},
effectiveConfig: {
host: "127.0.0.1",
shortcuts: { "core:view.chat": "mod+1" },
spawnSessions: false,
plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } },
},
});
expect(onConfigSaved).toHaveBeenCalledWith({
host: "127.0.0.1",
shortcuts: { "core:view.chat": "mod+1" },
spawnSessions: false,
plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } },
});
});
it("ignores stale plugin load responses after the selected machine changes", async () => {
const configLoad = deferred<PiWebConfigResponse>();
const pluginsLoad = deferred<PiWebPluginsResponse>();
vi.spyOn(configApi, "config").mockReturnValue(configLoad.promise);
vi.spyOn(pluginsApi, "plugins").mockReturnValue(pluginsLoad.promise);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
const loadPromise = callDialogPromise(dialog, "loadPluginsForTarget");
expect(getDialogProperty(dialog, "pluginLoading")).toBe(true);
dialog.machine = secondRemoteMachine;
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
configLoad.resolve(configResponse({ plugins: { info: { enabled: true } } }));
pluginsLoad.resolve(pluginsResponse([pluginInfo("info", true)]));
await loadPromise;
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "pluginError")).toBe("");
expect(getDialogProperty(dialog, "pluginLoading")).toBe(false);
});
it("ignores stale plugin save responses after the selected machine changes", async () => {
const save = deferred<PiWebConfigResponse>();
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(pluginsResponse([pluginInfo("info", false)]));
vi.spyOn(configApi, "saveConfig").mockReturnValue(save.promise);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
setDialogProperty(dialog, "selectedPluginConfigResponse", configResponse({ plugins: { info: { enabled: true } } }));
const savePromise = callDialogPromise(dialog, "togglePlugin", "info", false);
expect(getDialogProperty(dialog, "saving")).toBe(true);
dialog.machine = secondRemoteMachine;
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
save.resolve(configResponse({ plugins: { info: { enabled: false } } }));
await savePromise;
expect(pluginsSpy).not.toHaveBeenCalled();
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "savedMessage")).toBe("");
expect(getDialogProperty(dialog, "saving")).toBe(false);
});
});
@@ -0,0 +1,146 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { configApi, pluginsApi, type PiWebConfigResponse, type PiWebPluginsResponse } from "../api";
import { SettingsDialog } from "./SettingsDialog";
import { callDialogPromise, callDialogUpdated, configResponse, deferred, getDialogProperty, pluginInfo, pluginsResponse, remoteMachine, runtimeWithPackageManagement as runtimeWithoutSelectedMachineSettings, secondRemoteMachine, setDialogProperty, stubWindowTimers } from "./SettingsDialog.testSupport";
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("settings-dialog session daemon machine targeting", () => {
it("keeps gateway settings loads on the gateway config/plugin endpoints", async () => {
const config = configResponse({ host: "127.0.0.1" });
const plugins: PiWebPluginsResponse = { plugins: [] };
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(plugins);
const dialog = new SettingsDialog();
await callDialogPromise(dialog, "loadConfig");
expect(configSpy.mock.calls).toEqual([[]]);
expect(pluginsSpy.mock.calls).toEqual([[]]);
expect(getDialogProperty(dialog, "configResponse")).toBe(config);
expect(getDialogProperty(dialog, "pluginsResponse")).toBe(plugins);
expect(getDialogProperty(dialog, "error")).toBe("");
expect(getDialogProperty(dialog, "loading")).toBe(false);
});
it("loads session-daemon config from the selected machine", async () => {
const config = configResponse({ spawnSessions: false, subsessions: true });
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
await callDialogPromise(dialog, "loadSessiondConfigForTarget");
expect(configSpy.mock.calls).toEqual([["remote-a"]]);
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBe(config);
expect(getDialogProperty(dialog, "sessiondError")).toBe("");
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false);
});
it("saves local session-daemon config through the local machine alias and updates local daemon state", async () => {
stubWindowTimers();
const gatewayConfig = configResponse({ host: "127.0.0.1", spawnSessions: false, subsessions: false });
const savedConfig = configResponse({ spawnSessions: true });
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
const dialog = new SettingsDialog();
setDialogProperty(dialog, "configResponse", gatewayConfig);
await callDialogPromise(dialog, "saveSessiondConfig", { spawnSessions: true });
expect(saveSpy.mock.calls).toEqual([[{ spawnSessions: true }, "local"]]);
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBe(savedConfig);
expect(getDialogProperty(dialog, "configResponse")).toMatchObject({ config: { host: "127.0.0.1", spawnSessions: true, subsessions: false } });
expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved.");
expect(getDialogProperty(dialog, "saving")).toBe(false);
});
it("ignores stale session-daemon load responses after the selected machine changes", async () => {
const load = deferred<PiWebConfigResponse>();
vi.spyOn(configApi, "config").mockReturnValue(load.promise);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
const loadPromise = callDialogPromise(dialog, "loadSessiondConfigForTarget");
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(true);
dialog.machine = secondRemoteMachine;
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
load.resolve(configResponse({ spawnSessions: false }));
await loadPromise;
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "sessiondError")).toBe("");
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false);
});
it("ignores stale session-daemon save responses after the selected machine changes", async () => {
stubWindowTimers();
const save = deferred<PiWebConfigResponse>();
vi.spyOn(configApi, "saveConfig").mockReturnValue(save.promise);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
const savePromise = callDialogPromise(dialog, "saveSessiondConfig", { subsessions: true });
expect(getDialogProperty(dialog, "saving")).toBe(true);
dialog.machine = secondRemoteMachine;
save.resolve(configResponse({ subsessions: true }));
await savePromise;
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "savedMessage")).toBe("");
expect(getDialogProperty(dialog, "saving")).toBe(false);
});
it("skips selected-machine settings loads when the remote runtime does not advertise support", async () => {
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(configResponse({ spawnSessions: true }));
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(pluginsResponse([pluginInfo("info", true)]));
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
dialog.machineRuntime = runtimeWithoutSelectedMachineSettings;
await callDialogPromise(dialog, "loadSessiondConfigForTarget");
await callDialogPromise(dialog, "loadAccessConfigForTarget");
await callDialogPromise(dialog, "loadPluginsForTarget");
expect(configSpy).not.toHaveBeenCalled();
expect(pluginsSpy).not.toHaveBeenCalled();
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "accessConfigResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "sessiondError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
expect(getDialogProperty(dialog, "accessError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
expect(getDialogProperty(dialog, "pluginError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
});
it("does not save remote selected-machine settings when runtime support is missing", async () => {
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(configResponse({ spawnSessions: true }));
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
dialog.machineRuntime = runtimeWithoutSelectedMachineSettings;
setDialogProperty(dialog, "selectedPluginConfigResponse", configResponse({ plugins: { info: { enabled: true } } }));
await callDialogPromise(dialog, "saveSessiondConfig", { spawnSessions: true });
await callDialogPromise(dialog, "saveMachineAccessConfig", { pathAccess: { allowedPaths: ["/mnt/share"] } });
await callDialogPromise(dialog, "togglePlugin", "info", false);
expect(saveSpy).not.toHaveBeenCalled();
expect(getDialogProperty(dialog, "sessiondError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
expect(getDialogProperty(dialog, "accessError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
expect(getDialogProperty(dialog, "pluginError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
});
it("shows selected-machine settings errors with the selected target name", async () => {
vi.spyOn(configApi, "config").mockRejectedValue(new Error("Remote machine unavailable"));
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
await callDialogPromise(dialog, "loadSessiondConfigForTarget");
expect(getDialogProperty(dialog, "sessiondError")).toBe("Failed to load session-daemon config from Lab Mac (remote machine): Could not reach Lab Mac for selected-machine settings. Check the machine connection and try again.");
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false);
});
});
@@ -1,705 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { TemplateResult } from "lit";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
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(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("settings-dialog session daemon machine targeting", () => {
it("keeps gateway settings loads on the gateway config/plugin endpoints", async () => {
const config = configResponse({ host: "127.0.0.1" });
const plugins: PiWebPluginsResponse = { plugins: [] };
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(plugins);
const dialog = new SettingsDialog();
await callDialogPromise(dialog, "loadConfig");
expect(configSpy.mock.calls).toEqual([[]]);
expect(pluginsSpy.mock.calls).toEqual([[]]);
expect(getDialogProperty(dialog, "configResponse")).toBe(config);
expect(getDialogProperty(dialog, "pluginsResponse")).toBe(plugins);
expect(getDialogProperty(dialog, "error")).toBe("");
expect(getDialogProperty(dialog, "loading")).toBe(false);
});
it("loads session-daemon config from the selected machine", async () => {
const config = configResponse({ spawnSessions: false, subsessions: true });
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
await callDialogPromise(dialog, "loadSessiondConfigForTarget");
expect(configSpy.mock.calls).toEqual([["remote-a"]]);
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBe(config);
expect(getDialogProperty(dialog, "sessiondError")).toBe("");
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false);
});
it("saves local session-daemon config through the local machine alias and updates local daemon state", async () => {
stubWindowTimers();
const gatewayConfig = configResponse({ host: "127.0.0.1", spawnSessions: false, subsessions: false });
const savedConfig = configResponse({ spawnSessions: true });
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
const dialog = new SettingsDialog();
setDialogProperty(dialog, "configResponse", gatewayConfig);
await callDialogPromise(dialog, "saveSessiondConfig", { spawnSessions: true });
expect(saveSpy.mock.calls).toEqual([[{ spawnSessions: true }, "local"]]);
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBe(savedConfig);
expect(getDialogProperty(dialog, "configResponse")).toMatchObject({ config: { host: "127.0.0.1", spawnSessions: true, subsessions: false } });
expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved.");
expect(getDialogProperty(dialog, "saving")).toBe(false);
});
it("ignores stale session-daemon load responses after the selected machine changes", async () => {
const load = deferred<PiWebConfigResponse>();
vi.spyOn(configApi, "config").mockReturnValue(load.promise);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
const loadPromise = callDialogPromise(dialog, "loadSessiondConfigForTarget");
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(true);
dialog.machine = secondRemoteMachine;
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
load.resolve(configResponse({ spawnSessions: false }));
await loadPromise;
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "sessiondError")).toBe("");
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false);
});
it("ignores stale session-daemon save responses after the selected machine changes", async () => {
stubWindowTimers();
const save = deferred<PiWebConfigResponse>();
vi.spyOn(configApi, "saveConfig").mockReturnValue(save.promise);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
const savePromise = callDialogPromise(dialog, "saveSessiondConfig", { subsessions: true });
expect(getDialogProperty(dialog, "saving")).toBe(true);
dialog.machine = secondRemoteMachine;
save.resolve(configResponse({ subsessions: true }));
await savePromise;
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "savedMessage")).toBe("");
expect(getDialogProperty(dialog, "saving")).toBe(false);
});
it("skips selected-machine settings loads when the remote runtime does not advertise support", async () => {
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(configResponse({ spawnSessions: true }));
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(pluginsResponse([pluginInfo("info", true)]));
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
dialog.machineRuntime = runtimeWithoutSelectedMachineSettings;
await callDialogPromise(dialog, "loadSessiondConfigForTarget");
await callDialogPromise(dialog, "loadAccessConfigForTarget");
await callDialogPromise(dialog, "loadPluginsForTarget");
expect(configSpy).not.toHaveBeenCalled();
expect(pluginsSpy).not.toHaveBeenCalled();
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "accessConfigResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "sessiondError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
expect(getDialogProperty(dialog, "accessError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
expect(getDialogProperty(dialog, "pluginError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
});
it("does not save remote selected-machine settings when runtime support is missing", async () => {
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(configResponse({ spawnSessions: true }));
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
dialog.machineRuntime = runtimeWithoutSelectedMachineSettings;
setDialogProperty(dialog, "selectedPluginConfigResponse", configResponse({ plugins: { info: { enabled: true } } }));
await callDialogPromise(dialog, "saveSessiondConfig", { spawnSessions: true });
await callDialogPromise(dialog, "saveMachineAccessConfig", { pathAccess: { allowedPaths: ["/mnt/share"] } });
await callDialogPromise(dialog, "togglePlugin", "info", false);
expect(saveSpy).not.toHaveBeenCalled();
expect(getDialogProperty(dialog, "sessiondError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
expect(getDialogProperty(dialog, "accessError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
expect(getDialogProperty(dialog, "pluginError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
});
it("shows selected-machine settings errors with the selected target name", async () => {
vi.spyOn(configApi, "config").mockRejectedValue(new Error("Remote machine unavailable"));
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
await callDialogPromise(dialog, "loadSessiondConfigForTarget");
expect(getDialogProperty(dialog, "sessiondError")).toBe("Failed to load session-daemon config from Lab Mac (remote machine): Could not reach Lab Mac for selected-machine settings. Check the machine connection and try again.");
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false);
});
});
describe("settings-dialog general settings machine targeting", () => {
it("renders the active settings panel without the old global scope note", () => {
const dialog = new SettingsDialog();
dialog.section = "general";
dialog.machine = remoteMachine;
const strings = collectTemplateStrings(dialog.render()).join("");
expect(strings).toContain("<settings-general-panel");
expect(strings).not.toContain("scope-note");
expect(strings).not.toContain("This tab edits:");
});
it("keeps gateway server config saves on the gateway config endpoint", async () => {
stubWindowTimers();
const savedConfig = configResponse({ host: "0.0.0.0", port: 9000, allowedHosts: true });
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
const onConfigSaved = vi.fn();
const dialog = new SettingsDialog();
dialog.onConfigSaved = onConfigSaved;
await callDialogPromise(dialog, "saveConfig", { host: "0.0.0.0", port: 9000, allowedHosts: true });
expect(saveSpy.mock.calls).toEqual([[{ host: "0.0.0.0", port: 9000, allowedHosts: true }]]);
expect(getDialogProperty(dialog, "configResponse")).toBe(savedConfig);
expect(onConfigSaved).toHaveBeenCalledWith({ host: "0.0.0.0", port: 9000, allowedHosts: true });
expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved.");
expect(getDialogProperty(dialog, "saving")).toBe(false);
});
it("loads file access and upload config from the selected machine", async () => {
const config = configResponse({ pathAccess: { allowedPaths: ["/mnt/share"] }, uploads: { defaultFolder: "manual/uploads" } });
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
await callDialogPromise(dialog, "loadAccessConfigForTarget");
expect(configSpy.mock.calls).toEqual([["remote-a"]]);
expect(getDialogProperty(dialog, "accessConfigResponse")).toBe(config);
expect(getDialogProperty(dialog, "accessError")).toBe("");
expect(getDialogProperty(dialog, "accessLoading")).toBe(false);
});
it("saves selected-machine file access and upload config through the selected-machine endpoint", async () => {
stubWindowTimers();
const patch = { pathAccess: { allowedPaths: ["/mnt/share", "~/SDKs"] }, uploads: { defaultFolder: "manual/uploads" } };
const savedConfig = configResponse(patch);
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
await callDialogPromise(dialog, "saveMachineAccessConfig", patch);
expect(saveSpy.mock.calls).toEqual([[patch, "remote-a"]]);
expect(getDialogProperty(dialog, "accessConfigResponse")).toBe(savedConfig);
expect(getDialogProperty(dialog, "configResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved.");
expect(getDialogProperty(dialog, "saving")).toBe(false);
});
it("merges local selected-machine access saves into gateway config without dropping gateway-only values", async () => {
stubWindowTimers();
const gatewayConfig = configResponse({
host: "127.0.0.1",
port: 8504,
allowedHosts: ["gateway.local"],
shortcuts: { "core:view.chat": "mod+1" },
plugins: { info: { enabled: true } },
spawnSessions: false,
pathAccess: { allowedPaths: ["/old"] },
uploads: { defaultFolder: "old/uploads" },
maxUploadBytes: 1234,
});
const patch = { pathAccess: { allowedPaths: ["~/SDKs"] }, uploads: {} };
const savedConfig = configResponse({ pathAccess: { allowedPaths: ["~/SDKs"] }, uploads: {}, maxUploadBytes: 5678 });
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
const onConfigSaved = vi.fn();
const dialog = new SettingsDialog();
dialog.onConfigSaved = onConfigSaved;
setDialogProperty(dialog, "configResponse", gatewayConfig);
await callDialogPromise(dialog, "saveMachineAccessConfig", patch);
expect(saveSpy.mock.calls).toEqual([[patch, "local"]]);
expect(getDialogProperty(dialog, "accessConfigResponse")).toBe(savedConfig);
expect(getDialogProperty(dialog, "configResponse")).toMatchObject({
config: {
host: "127.0.0.1",
port: 8504,
allowedHosts: ["gateway.local"],
shortcuts: { "core:view.chat": "mod+1" },
plugins: { info: { enabled: true } },
spawnSessions: false,
pathAccess: { allowedPaths: ["~/SDKs"] },
uploads: {},
maxUploadBytes: 5678,
},
effectiveConfig: {
host: "127.0.0.1",
port: 8504,
allowedHosts: ["gateway.local"],
shortcuts: { "core:view.chat": "mod+1" },
plugins: { info: { enabled: true } },
spawnSessions: false,
pathAccess: { allowedPaths: ["~/SDKs"] },
uploads: {},
maxUploadBytes: 5678,
},
});
expect(onConfigSaved).toHaveBeenCalledWith({
host: "127.0.0.1",
port: 8504,
allowedHosts: ["gateway.local"],
shortcuts: { "core:view.chat": "mod+1" },
plugins: { info: { enabled: true } },
spawnSessions: false,
pathAccess: { allowedPaths: ["~/SDKs"] },
uploads: {},
maxUploadBytes: 5678,
});
});
it("ignores stale file access load responses after the selected machine changes", async () => {
const load = deferred<PiWebConfigResponse>();
vi.spyOn(configApi, "config").mockReturnValue(load.promise);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
const loadPromise = callDialogPromise(dialog, "loadAccessConfigForTarget");
expect(getDialogProperty(dialog, "accessLoading")).toBe(true);
dialog.machine = secondRemoteMachine;
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
load.resolve(configResponse({ pathAccess: { allowedPaths: ["/stale"] } }));
await loadPromise;
expect(getDialogProperty(dialog, "accessConfigResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "accessError")).toBe("");
expect(getDialogProperty(dialog, "accessLoading")).toBe(false);
});
it("ignores stale file access save responses after the selected machine changes", async () => {
const save = deferred<PiWebConfigResponse>();
vi.spyOn(configApi, "saveConfig").mockReturnValue(save.promise);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
const savePromise = callDialogPromise(dialog, "saveMachineAccessConfig", { pathAccess: { allowedPaths: ["/mnt/share"] }, uploads: { defaultFolder: "manual" } });
expect(getDialogProperty(dialog, "saving")).toBe(true);
dialog.machine = secondRemoteMachine;
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
save.resolve(configResponse({ pathAccess: { allowedPaths: ["/mnt/share"] }, uploads: { defaultFolder: "manual" } }));
await savePromise;
expect(getDialogProperty(dialog, "accessConfigResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "savedMessage")).toBe("");
expect(getDialogProperty(dialog, "saving")).toBe(false);
});
it("shows selected-machine file access errors with the selected target name", async () => {
vi.spyOn(configApi, "config").mockRejectedValue(new Error("Remote machine unavailable"));
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
await callDialogPromise(dialog, "loadAccessConfigForTarget");
expect(getDialogProperty(dialog, "accessError")).toBe("Failed to load file access/upload config from Lab Mac (remote machine): Could not reach Lab Mac for selected-machine settings. Check the machine connection and try again.");
expect(getDialogProperty(dialog, "accessLoading")).toBe(false);
});
});
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<typeof remotePackages>();
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<PiPackageMutationResponse>();
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 } } });
const plugins = pluginsResponse([pluginInfo("info", true)]);
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(plugins);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
await callDialogPromise(dialog, "loadPluginsForTarget");
expect(configSpy.mock.calls).toEqual([["remote-a"]]);
expect(pluginsSpy.mock.calls).toEqual([["remote-a"]]);
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(config);
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBe(plugins);
expect(getDialogProperty(dialog, "pluginError")).toBe("");
expect(getDialogProperty(dialog, "pluginLoading")).toBe(false);
});
it("keeps fulfilled plugin config when the selected machine plugin list is unsupported", async () => {
const config = configResponse({ plugins: { info: { enabled: true } } });
vi.spyOn(configApi, "config").mockResolvedValue(config);
vi.spyOn(pluginsApi, "plugins").mockRejectedValue(new Error("route GET:/api/plugins not found"));
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
await callDialogPromise(dialog, "loadPluginsForTarget");
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(config);
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "pluginError")).toBe("Failed to load PI WEB plugin settings from Lab Mac (remote machine): PI WEB plugins: Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
expect(getDialogProperty(dialog, "pluginLoading")).toBe(false);
});
it("saves selected-machine plugin toggles as plugin-only patches and refreshes the selected machine plugin list", async () => {
stubWindowTimers();
const baseConfig = configResponse({
plugins: {
keep: { enabled: true, settings: { level: 1 } },
info: { settings: { color: "blue" } },
},
});
const savedConfig = configResponse({
plugins: {
keep: { enabled: true, settings: { level: 1 } },
info: { enabled: false, settings: { color: "blue" } },
},
});
const refreshedPlugins = pluginsResponse([pluginInfo("info", false), pluginInfo("keep", true)]);
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(refreshedPlugins);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
setDialogProperty(dialog, "selectedPluginConfigResponse", baseConfig);
await callDialogPromise(dialog, "togglePlugin", "info", false);
expect(saveSpy.mock.calls).toEqual([[
{
plugins: {
keep: { enabled: true, settings: { level: 1 } },
info: { enabled: false, settings: { color: "blue" } },
},
},
"remote-a",
]]);
expect(pluginsSpy.mock.calls).toEqual([["remote-a"]]);
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(savedConfig);
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBe(refreshedPlugins);
expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved.");
expect(getDialogProperty(dialog, "saving")).toBe(false);
});
it("merges local selected-machine plugin saves into gateway config without dropping gateway-only values", async () => {
stubWindowTimers();
const gatewayConfig = configResponse({
host: "127.0.0.1",
shortcuts: { "core:view.chat": "mod+1" },
spawnSessions: false,
plugins: { info: { enabled: false }, gateway: { settings: { theme: "dark" } } },
});
const savedConfig = configResponse({ plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } } });
const refreshedPlugins = pluginsResponse([pluginInfo("info", true)]);
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
vi.spyOn(pluginsApi, "plugins").mockResolvedValue(refreshedPlugins);
const onConfigSaved = vi.fn();
const dialog = new SettingsDialog();
dialog.onConfigSaved = onConfigSaved;
setDialogProperty(dialog, "configResponse", gatewayConfig);
setDialogProperty(dialog, "selectedPluginConfigResponse", configResponse({ plugins: { info: { enabled: false } } }));
await callDialogPromise(dialog, "togglePlugin", "info", true);
expect(saveSpy.mock.calls).toEqual([[{ plugins: { info: { enabled: true } } }, "local"]]);
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(savedConfig);
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBe(refreshedPlugins);
expect(getDialogProperty(dialog, "configResponse")).toMatchObject({
config: {
host: "127.0.0.1",
shortcuts: { "core:view.chat": "mod+1" },
spawnSessions: false,
plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } },
},
effectiveConfig: {
host: "127.0.0.1",
shortcuts: { "core:view.chat": "mod+1" },
spawnSessions: false,
plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } },
},
});
expect(onConfigSaved).toHaveBeenCalledWith({
host: "127.0.0.1",
shortcuts: { "core:view.chat": "mod+1" },
spawnSessions: false,
plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } },
});
});
it("ignores stale plugin load responses after the selected machine changes", async () => {
const configLoad = deferred<PiWebConfigResponse>();
const pluginsLoad = deferred<PiWebPluginsResponse>();
vi.spyOn(configApi, "config").mockReturnValue(configLoad.promise);
vi.spyOn(pluginsApi, "plugins").mockReturnValue(pluginsLoad.promise);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
const loadPromise = callDialogPromise(dialog, "loadPluginsForTarget");
expect(getDialogProperty(dialog, "pluginLoading")).toBe(true);
dialog.machine = secondRemoteMachine;
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
configLoad.resolve(configResponse({ plugins: { info: { enabled: true } } }));
pluginsLoad.resolve(pluginsResponse([pluginInfo("info", true)]));
await loadPromise;
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "pluginError")).toBe("");
expect(getDialogProperty(dialog, "pluginLoading")).toBe(false);
});
it("ignores stale plugin save responses after the selected machine changes", async () => {
const save = deferred<PiWebConfigResponse>();
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(pluginsResponse([pluginInfo("info", false)]));
vi.spyOn(configApi, "saveConfig").mockReturnValue(save.promise);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
setDialogProperty(dialog, "selectedPluginConfigResponse", configResponse({ plugins: { info: { enabled: true } } }));
const savePromise = callDialogPromise(dialog, "togglePlugin", "info", false);
expect(getDialogProperty(dialog, "saving")).toBe(true);
dialog.machine = secondRemoteMachine;
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
save.resolve(configResponse({ plugins: { info: { enabled: false } } }));
await savePromise;
expect(pluginsSpy).not.toHaveBeenCalled();
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "savedMessage")).toBe("");
expect(getDialogProperty(dialog, "saving")).toBe(false);
});
});
const remoteMachine: Machine = {
id: "remote-a",
name: "Lab Mac",
kind: "remote",
baseUrl: "https://lab.example.test",
createdAt: "2026-07-01T00:00:00.000Z",
updatedAt: "2026-07-01T00:00:00.000Z",
};
const secondRemoteMachine: Machine = {
id: "remote-b",
name: "Build Box",
kind: "remote",
baseUrl: "https://build.example.test",
createdAt: "2026-07-01T00:00:00.000Z",
updatedAt: "2026-07-01T00:00:00.000Z",
};
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);
}
function setDialogProperty(dialog: SettingsDialog, property: string, value: unknown): void {
if (!Reflect.set(dialog, property, value)) throw new Error(`Failed to set SettingsDialog property ${property}`);
}
async function callDialogPromise(dialog: SettingsDialog, methodName: string, ...args: readonly unknown[]): Promise<void> {
const result = callDialogMethod(dialog, methodName, ...args);
if (!(result instanceof Promise)) throw new Error(`SettingsDialog.${methodName} did not return a promise`);
await result;
}
function callDialogUpdated(dialog: SettingsDialog, changed: Map<string, unknown>): void {
const result = callDialogMethod(dialog, "updated", changed);
if (result !== undefined) throw new Error("SettingsDialog.updated returned an unexpected value");
}
function callDialogMethod(dialog: SettingsDialog, methodName: string, ...args: readonly unknown[]): unknown {
const method: unknown = Reflect.get(dialog, methodName);
if (!isDialogMethod(method)) throw new Error(`SettingsDialog.${methodName} is not callable`);
return method.call(dialog, ...args);
}
function isDialogMethod(value: unknown): value is (this: SettingsDialog, ...args: readonly unknown[]) => unknown {
return typeof value === "function";
}
function collectTemplateStrings(template: TemplateResult): string[] {
const strings: string[] = [];
visitTemplate(template);
return strings;
function visitTemplate(current: TemplateResult): void {
strings.push(...templateStrings(current));
for (const value of templateValues(current)) {
if (Array.isArray(value)) {
for (const item of value) if (isTemplateResult(item)) visitTemplate(item);
} else if (isTemplateResult(value)) {
visitTemplate(value);
}
}
}
}
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 isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
}
function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
return {
path: "/tmp/pi-web/config.json",
exists: true,
config,
effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
};
}
function pluginsResponse(plugins: PiWebPluginInfo[]): PiWebPluginsResponse {
return { plugins };
}
function pluginInfo(id: string, enabled: boolean): PiWebPluginInfo {
return {
id,
module: `/pi-web-plugins/${id}/plugin.js`,
source: "test",
scope: "local",
machineSpecific: false,
enabled,
};
}
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<T> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
}
function deferred<T>(): Deferred<T> {
let resolveDeferred: ((value: T) => void) | undefined;
let rejectDeferred: ((error: unknown) => void) | undefined;
const promise = new Promise<T>((resolve, reject) => {
resolveDeferred = resolve;
rejectDeferred = reject;
});
if (resolveDeferred === undefined || rejectDeferred === undefined) throw new Error("Deferred promise was not initialized");
return { promise, resolve: resolveDeferred, reject: rejectDeferred };
}
function stubWindowTimers(): void {
vi.stubGlobal("window", {
clearTimeout: vi.fn(),
setTimeout: vi.fn(() => 1),
});
}
@@ -0,0 +1,153 @@
import type { TemplateResult } from "lit";
import { vi } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import type { Machine, MachineRuntime, PiPackageInfo, PiPackageMutationResponse, PiWebConfigResponse, PiWebConfigValues, PiWebPluginInfo, PiWebPluginsResponse } from "../api";
import { SettingsDialog } from "./SettingsDialog";
export const remoteMachine: Machine = {
id: "remote-a",
name: "Lab Mac",
kind: "remote",
baseUrl: "https://lab.example.test",
createdAt: "2026-07-01T00:00:00.000Z",
updatedAt: "2026-07-01T00:00:00.000Z",
};
export const secondRemoteMachine: Machine = {
id: "remote-b",
name: "Build Box",
kind: "remote",
baseUrl: "https://build.example.test",
createdAt: "2026-07-01T00:00:00.000Z",
updatedAt: "2026-07-01T00:00:00.000Z",
};
export const runtimeWithPackageManagement: MachineRuntime = {
machineId: "remote-a",
ok: true,
checkedAt: "2026-07-01T00:00:00.000Z",
capabilities: [PI_WEB_CAPABILITIES.piPackagesManage],
};
export function getDialogProperty(dialog: SettingsDialog, property: string): unknown {
return Reflect.get(dialog, property);
}
export function setDialogProperty(dialog: SettingsDialog, property: string, value: unknown): void {
if (!Reflect.set(dialog, property, value)) throw new Error(`Failed to set SettingsDialog property ${property}`);
}
export async function callDialogPromise(dialog: SettingsDialog, methodName: string, ...args: readonly unknown[]): Promise<void> {
const result = callDialogMethod(dialog, methodName, ...args);
if (!(result instanceof Promise)) throw new Error(`SettingsDialog.${methodName} did not return a promise`);
await result;
}
export function callDialogUpdated(dialog: SettingsDialog, changed: Map<string, unknown>): void {
const result = callDialogMethod(dialog, "updated", changed);
if (result !== undefined) throw new Error("SettingsDialog.updated returned an unexpected value");
}
function callDialogMethod(dialog: SettingsDialog, methodName: string, ...args: readonly unknown[]): unknown {
const method: unknown = Reflect.get(dialog, methodName);
if (!isDialogMethod(method)) throw new Error(`SettingsDialog.${methodName} is not callable`);
return method.call(dialog, ...args);
}
function isDialogMethod(value: unknown): value is (this: SettingsDialog, ...args: readonly unknown[]) => unknown {
return typeof value === "function";
}
export function collectTemplateStrings(template: TemplateResult): string[] {
const strings: string[] = [];
visitTemplate(template);
return strings;
function visitTemplate(current: TemplateResult): void {
strings.push(...templateStrings(current));
for (const value of templateValues(current)) {
if (Array.isArray(value)) {
for (const item of value) if (isTemplateResult(item)) visitTemplate(item);
} else if (isTemplateResult(value)) {
visitTemplate(value);
}
}
}
}
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 isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
}
export function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
return {
path: "/tmp/pi-web/config.json",
exists: true,
config,
effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
};
}
export function pluginsResponse(plugins: PiWebPluginInfo[]): PiWebPluginsResponse {
return { plugins };
}
export function pluginInfo(id: string, enabled: boolean): PiWebPluginInfo {
return {
id,
module: `/pi-web-plugins/${id}/plugin.js`,
source: "test",
scope: "local",
machineSpecific: false,
enabled,
};
}
export function packageInfo(source: string): PiPackageInfo {
return { source, scope: "user", filtered: false, installedPath: `/pi/packages/${source}` };
}
export function packageMutationResponse(action: PiPackageMutationResponse["action"], packages: PiPackageInfo[], source?: string): PiPackageMutationResponse {
return source === undefined ? { action, packages } : { action, source, packages };
}
export interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
}
export function deferred<T>(): Deferred<T> {
let resolveDeferred: ((value: T) => void) | undefined;
let rejectDeferred: ((error: unknown) => void) | undefined;
const promise = new Promise<T>((resolve, reject) => {
resolveDeferred = resolve;
rejectDeferred = reject;
});
if (resolveDeferred === undefined || rejectDeferred === undefined) throw new Error("Deferred promise was not initialized");
return { promise, resolve: resolveDeferred, reject: rejectDeferred };
}
export function stubWindowTimers(): void {
vi.stubGlobal("window", {
clearTimeout: vi.fn(),
setTimeout: vi.fn(() => 1),
});
}
@@ -1,5 +1,6 @@
import type { TemplateResult } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { FileContentResponse, FileTreeEntry } from "../api";
import { initialAppState } from "../appState";
import type { WorkspacePanelContext } from "../plugins/types";
import type { WorkspaceUploadBatchState } from "../workspaceUploadState";
@@ -39,6 +40,38 @@ describe("workspace-files-panel upload review", () => {
});
});
describe("workspace-files-panel file tree boundary", () => {
it("renders expanded tree and selected-file state while wiring row clicks", () => {
const onExpandDir = vi.fn<WorkspacePanelContext["onExpandDir"]>();
const onSelectFile = vi.fn<WorkspacePanelContext["onSelectFile"]>();
const panel = new WorkspaceFilesPanel();
panel.context = workspacePanelContext({
fileTree: [directoryEntry("src"), fileEntry("README.md", 4096)],
expandedDirs: { src: [fileEntry("src/main.ts")] },
selectedFilePath: "README.md",
selectedFileContent: binaryFileContent("README.md", 4096),
onExpandDir,
onSelectFile,
});
const rendered = panel.render();
const text = collectTemplateText(rendered);
expect(text).toContain("▾");
expect(text).toContain("src");
expect(text).toContain("main.ts");
expect(text).toContain("README.md");
expect(text).toContain("Binary file: README.md · 4.0 KB");
expect(text).not.toContain("Select a file.");
findTemplateClickHandlerForText<Event>(rendered, "src")(new Event("click"));
findTemplateClickHandlerForText<Event>(rendered, "README.md")(new Event("click"));
expect(onExpandDir).toHaveBeenCalledWith("src");
expect(onSelectFile).toHaveBeenCalledWith("README.md");
});
});
describe("workspaceUploadBatchesForScope", () => {
it("filters upload batches to the selected project, workspace, and machine", () => {
const matchingOlder = uploadBatch({ id: "older", startedAt: "2026-06-25T00:00:00.000Z" });
@@ -154,6 +187,50 @@ function findOptionalTemplateEventHandler<E extends Event>(template: TemplateRes
}
}
// Node-based Lit tests cannot click shadow DOM here; keep direct handler extraction
// anchored to rendered file labels and assert the observable context callbacks.
function findTemplateClickHandlerForText<E extends Event>(template: TemplateResult, text: string): TemplateEventHandler<E> {
const handler = findOptionalTemplateClickHandlerForText<E>(template, text);
if (handler === undefined) throw new Error(`Expected click handler near ${text}`);
return handler;
}
function findOptionalTemplateClickHandlerForText<E extends Event>(value: unknown, text: string): TemplateEventHandler<E> | undefined {
if (Array.isArray(value)) {
for (const item of value) {
const nestedHandler = findOptionalTemplateClickHandlerForText<E>(item, text);
if (nestedHandler !== undefined) return nestedHandler;
}
return undefined;
}
if (!isTemplateResult(value)) return undefined;
for (const item of templateValues(value)) {
const nestedHandler = findOptionalTemplateClickHandlerForText<E>(item, text);
if (nestedHandler !== undefined) return nestedHandler;
}
if (!collectTemplateText(value).includes(text)) return undefined;
const strings = templateStrings(value);
const values = templateValues(value);
for (let index = 0; index < values.length; index += 1) {
const staticChunk = strings[index];
const candidate = values[index];
if (staticChunk !== undefined && staticChunk.includes("@click") && isTemplateEventHandler<E>(candidate)) return candidate;
}
return undefined;
}
function collectTemplateText(value: unknown): string {
if (Array.isArray(value)) return value.map((item) => collectTemplateText(item)).join("");
if (isTemplateResult(value)) {
const strings = templateStrings(value);
const values = templateValues(value);
return strings.map((part, index) => `${part}${index < values.length ? collectTemplateText(values[index]) : ""}`).join("");
}
return typeof value === "string" || typeof value === "number" ? String(value) : "";
}
function templateStrings(template: TemplateResult): readonly string[] {
const strings = Reflect.get(template, "strings");
if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable");
@@ -222,44 +299,64 @@ class FakeSubmitEvent extends Event implements SubmitEvent {
readonly submitter: HTMLElement | null = null;
}
function workspacePanelContext(patch: Partial<Pick<WorkspacePanelContext, "onStartWorkspaceUpload" | "workspaceUploadDefaultFolder">> = {}): WorkspacePanelContext {
const workspace = { id: "workspace-1", projectId: "project-1", path: "/tmp/project", label: "main", isMain: true, isGitRepo: true, isGitWorktree: false };
function fileEntry(path: string, size = 2): FileTreeEntry {
return { name: path.split("/").at(-1) ?? path, path, type: "file", size };
}
function directoryEntry(path: string): FileTreeEntry {
return { name: path.split("/").at(-1) ?? path, path, type: "directory" };
}
function binaryFileContent(path: string, size: number): FileContentResponse {
return {
machine: { id: "local", name: "Local", kind: "local" },
path,
encoding: "utf8",
size,
modifiedAt: "2026-06-25T00:00:00.000Z",
content: "",
truncated: false,
binary: true,
};
}
function workspacePanelContext(patch: Partial<WorkspacePanelContext> = {}): WorkspacePanelContext {
const workspace = patch.workspace ?? { id: "workspace-1", projectId: "project-1", path: "/tmp/project", label: "main", isMain: true, isGitRepo: true, isGitWorktree: false };
return {
machine: patch.machine ?? { id: "local", name: "Local", kind: "local" },
workspace,
state: { ...initialAppState(), workspaceUploadBatches: {} },
files: {
state: patch.state ?? { ...initialAppState(), workspaceUploadBatches: {} },
files: patch.files ?? {
readFile: vi.fn<WorkspacePanelContext["files"]["readFile"]>(() => Promise.reject(new Error("not implemented"))),
writeFile: vi.fn<WorkspacePanelContext["files"]["writeFile"]>(() => Promise.reject(new Error("not implemented"))),
deleteFile: vi.fn<WorkspacePanelContext["files"]["deleteFile"]>(() => Promise.reject(new Error("not implemented"))),
moveFile: vi.fn<WorkspacePanelContext["files"]["moveFile"]>(() => Promise.reject(new Error("not implemented"))),
},
prompt: { insertText: vi.fn<WorkspacePanelContext["prompt"]["insertText"]>(), getText: vi.fn<WorkspacePanelContext["prompt"]["getText"]>(() => ""), getSelection: vi.fn<WorkspacePanelContext["prompt"]["getSelection"]>(() => null) },
terminal: { open: vi.fn<WorkspacePanelContext["terminal"]["open"]>(), runCommand: vi.fn<WorkspacePanelContext["terminal"]["runCommand"]>(() => Promise.reject(new Error("not implemented"))) },
host: { requestRender: vi.fn<WorkspacePanelContext["host"]["requestRender"]>() },
fileTree: [],
expandedDirs: {},
selectedFilePath: undefined,
selectedFileContent: undefined,
fileTreeStale: false,
gitStatus: undefined,
selectedDiffPath: undefined,
selectedDiff: undefined,
selectedStagedDiff: undefined,
gitStale: false,
activeTerminalCount: 0,
selectedTerminalId: undefined,
terminalAutoStart: false,
prompt: patch.prompt ?? { insertText: vi.fn<WorkspacePanelContext["prompt"]["insertText"]>(), getText: vi.fn<WorkspacePanelContext["prompt"]["getText"]>(() => ""), getSelection: vi.fn<WorkspacePanelContext["prompt"]["getSelection"]>(() => null) },
terminal: patch.terminal ?? { open: vi.fn<WorkspacePanelContext["terminal"]["open"]>(), runCommand: vi.fn<WorkspacePanelContext["terminal"]["runCommand"]>(() => Promise.reject(new Error("not implemented"))) },
host: patch.host ?? { requestRender: vi.fn<WorkspacePanelContext["host"]["requestRender"]>() },
fileTree: patch.fileTree ?? [],
expandedDirs: patch.expandedDirs ?? {},
selectedFilePath: patch.selectedFilePath,
selectedFileContent: patch.selectedFileContent,
fileTreeStale: patch.fileTreeStale ?? false,
gitStatus: patch.gitStatus,
selectedDiffPath: patch.selectedDiffPath,
selectedDiff: patch.selectedDiff,
selectedStagedDiff: patch.selectedStagedDiff,
gitStale: patch.gitStale ?? false,
activeTerminalCount: patch.activeTerminalCount ?? 0,
selectedTerminalId: patch.selectedTerminalId,
terminalAutoStart: patch.terminalAutoStart ?? false,
workspaceUploadDefaultFolder: patch.workspaceUploadDefaultFolder ?? ".pi-web/uploads",
onRefreshFiles: vi.fn<WorkspacePanelContext["onRefreshFiles"]>(),
onExpandDir: vi.fn<WorkspacePanelContext["onExpandDir"]>(),
onSelectFile: vi.fn<WorkspacePanelContext["onSelectFile"]>(),
onRefreshFiles: patch.onRefreshFiles ?? vi.fn<WorkspacePanelContext["onRefreshFiles"]>(),
onExpandDir: patch.onExpandDir ?? vi.fn<WorkspacePanelContext["onExpandDir"]>(),
onSelectFile: patch.onSelectFile ?? vi.fn<WorkspacePanelContext["onSelectFile"]>(),
onStartWorkspaceUpload: patch.onStartWorkspaceUpload ?? vi.fn<WorkspacePanelContext["onStartWorkspaceUpload"]>(() => undefined),
onCancelWorkspaceUpload: vi.fn<WorkspacePanelContext["onCancelWorkspaceUpload"]>(),
onClearWorkspaceUpload: vi.fn<WorkspacePanelContext["onClearWorkspaceUpload"]>(),
onRefreshGit: vi.fn<WorkspacePanelContext["onRefreshGit"]>(),
onSelectDiff: vi.fn<WorkspacePanelContext["onSelectDiff"]>(),
onSelectTerminal: vi.fn<WorkspacePanelContext["onSelectTerminal"]>(),
onCancelWorkspaceUpload: patch.onCancelWorkspaceUpload ?? vi.fn<WorkspacePanelContext["onCancelWorkspaceUpload"]>(),
onClearWorkspaceUpload: patch.onClearWorkspaceUpload ?? vi.fn<WorkspacePanelContext["onClearWorkspaceUpload"]>(),
onRefreshGit: patch.onRefreshGit ?? vi.fn<WorkspacePanelContext["onRefreshGit"]>(),
onSelectDiff: patch.onSelectDiff ?? vi.fn<WorkspacePanelContext["onSelectDiff"]>(),
onSelectTerminal: patch.onSelectTerminal ?? vi.fn<WorkspacePanelContext["onSelectTerminal"]>(),
};
}
@@ -4,6 +4,7 @@ import {
WorkspaceUploadBatchError,
WorkspaceUploadCancelledError,
type FileContentResponse,
type FileTreeEntry,
type FileTreeResponse,
type Machine,
type Project,
@@ -13,6 +14,9 @@ import {
} from "../api";
import { FileExplorerController, type FileExplorerControllerDependencies } from "./fileExplorerController";
type FileExplorerApi = NonNullable<FileExplorerControllerDependencies["api"]>;
type WorkspaceTree = FileExplorerApi["workspaceTree"];
type WorkspaceFile = FileExplorerApi["workspaceFile"];
type UploadWorkspaceFiles = NonNullable<FileExplorerControllerDependencies["uploadWorkspaceFiles"]>;
type UploadWorkspaceFilesOptions = NonNullable<Parameters<UploadWorkspaceFiles>[3]>;
@@ -48,6 +52,56 @@ const workspace: Workspace = {
isGitWorktree: false,
};
describe("FileExplorerController file tree workflows", () => {
it("refreshes the root and already-expanded directories for the selected machine", async () => {
const rootEntries = [directoryEntry("src"), fileEntry("README.md")];
const refreshedSrcEntries = [fileEntry("src/index.ts")];
const refreshedDocsEntries = [fileEntry("docs/guide.md")];
const workspaceTree = vi.fn<WorkspaceTree>((_projectId, _workspaceId, path = "") => Promise.resolve(treeResponse(path, {
"": rootEntries,
src: refreshedSrcEntries,
docs: refreshedDocsEntries,
}[path] ?? [])));
const harness = createHarness({ api: createApi({ workspaceTree }) }, {
expandedDirs: {
src: [fileEntry("src/stale.ts")],
docs: [fileEntry("docs/stale.md")],
},
fileTreeStale: true,
error: "stale failure",
});
await harness.controller.refreshFiles();
expect(workspaceTree).toHaveBeenCalledTimes(3);
expect(workspaceTree).toHaveBeenCalledWith("project-1", "workspace-1", "", "remote-1");
expect(workspaceTree).toHaveBeenCalledWith("project-1", "workspace-1", "src", "remote-1");
expect(workspaceTree).toHaveBeenCalledWith("project-1", "workspace-1", "docs", "remote-1");
expect(harness.state.fileTree).toEqual(rootEntries);
expect(harness.state.expandedDirs).toEqual({ src: refreshedSrcEntries, docs: refreshedDocsEntries });
expect(harness.state.fileTreeStale).toBe(false);
expect(harness.state.error).toBe("");
});
it("expands a directory then collapses it locally without refetching", async () => {
const srcEntries = [fileEntry("src/index.ts")];
const workspaceTree = vi.fn<WorkspaceTree>((_projectId, _workspaceId, path = "") => Promise.resolve(treeResponse(path, srcEntries)));
const harness = createHarness({ api: createApi({ workspaceTree }) });
await harness.controller.expandDir("src");
expect(workspaceTree).toHaveBeenCalledWith("project-1", "workspace-1", "src", "remote-1");
expect(harness.state.expandedDirs).toEqual({ src: srcEntries });
expect(harness.state.error).toBe("");
workspaceTree.mockClear();
await harness.controller.expandDir("src");
expect(workspaceTree).not.toHaveBeenCalled();
expect(harness.state.expandedDirs).toEqual({});
});
});
describe("FileExplorerController workspace uploads", () => {
it("tracks upload progress, completes from final responses, refreshes files, and selects the first uploaded file", async () => {
const upload = controllableUpload();
@@ -227,18 +281,16 @@ describe("FileExplorerController workspace uploads", () => {
});
});
function createHarness(deps: FileExplorerControllerDependencies = {}) {
function createHarness(deps: FileExplorerControllerDependencies = {}, statePatch: Partial<AppState> = {}) {
installWindow("http://localhost/app");
let state: AppState = {
...initialAppState(),
selectedMachine: machine,
selectedProject: project,
selectedWorkspace: workspace,
...statePatch,
};
const api: NonNullable<FileExplorerControllerDependencies["api"]> = deps.api ?? {
workspaceTree: vi.fn<NonNullable<FileExplorerControllerDependencies["api"]>["workspaceTree"]>((_projectId, _workspaceId, path = "") => Promise.resolve(treeResponse(path))),
workspaceFile: vi.fn<NonNullable<FileExplorerControllerDependencies["api"]>["workspaceFile"]>((_projectId, _workspaceId, path) => Promise.resolve(fileResponse(path))),
};
const api = deps.api ?? createApi();
const updateUrl = vi.fn();
let batchSequence = 0;
const controller = new FileExplorerController(
@@ -308,8 +360,24 @@ function sequenceNow(...values: string[]): () => string {
return () => values[index++] ?? values.at(-1) ?? "now";
}
function treeResponse(path: string): FileTreeResponse {
return { path, entries: [], scannedAt: "2026-06-25T00:00:00.000Z", truncated: false };
function createApi(overrides: Partial<FileExplorerApi> = {}): FileExplorerApi {
return {
workspaceTree: vi.fn<WorkspaceTree>((_projectId, _workspaceId, path = "") => Promise.resolve(treeResponse(path))),
workspaceFile: vi.fn<WorkspaceFile>((_projectId, _workspaceId, path) => Promise.resolve(fileResponse(path))),
...overrides,
};
}
function treeResponse(path: string, entries: FileTreeEntry[] = []): FileTreeResponse {
return { path, entries, scannedAt: "2026-06-25T00:00:00.000Z", truncated: false };
}
function directoryEntry(path: string): FileTreeEntry {
return { name: path.split("/").at(-1) ?? path, path, type: "directory" };
}
function fileEntry(path: string): FileTreeEntry {
return { name: path.split("/").at(-1) ?? path, path, type: "file", size: 2 };
}
function fileResponse(path: string): FileContentResponse {
@@ -20,6 +20,15 @@ const remoteMachine: Machine = {
updatedAt: "2026-05-26T00:00:00.000Z",
};
const addedMachine: Machine = {
id: "remote-2",
name: "New Remote",
kind: "remote",
baseUrl: "https://new-remote.example.test",
createdAt: "2026-05-27T00:00:00.000Z",
updatedAt: "2026-05-27T00:00:00.000Z",
};
const offlineHealth: MachineHealth = {
machineId: remoteMachine.id,
ok: false,
@@ -33,6 +42,85 @@ describe("MachineController", () => {
vi.restoreAllMocks();
});
it("selects a newly added machine and clears stale workspace state", async () => {
const project = { id: "p1", name: "Project", path: "/repo", createdAt: "now" };
const workspace = { id: "w1", projectId: project.id, path: "/repo", label: "main", isMain: true, isGitRepo: true, isGitWorktree: false };
const session = { id: "s1", cwd: "/repo", path: "/repo/.pi/sessions/s1.json", created: "now", modified: "now", messageCount: 1, firstMessage: "hello" };
let state: AppState = {
...initialAppState(),
machines: [localMachine, remoteMachine],
selectedMachine: localMachine,
projects: [project],
workspaces: [workspace],
sessions: [session],
selectedProject: project,
selectedWorkspace: workspace,
selectedSession: session,
fileTree: [{ name: "index.ts", path: "src/index.ts", type: "file" }],
selectedFilePath: "src/index.ts",
gitStatus: { isGitRepo: true, hash: "abc123", branch: "main", files: [{ path: "src/index.ts", index: "modified", workingTree: "modified" }] },
activeTerminalCount: 2,
error: "stale error",
};
const setState = (patch: Partial<AppState>) => { state = { ...state, ...patch }; };
const updateUrl = vi.fn();
const projects = { loadProjects: vi.fn() };
const input = { name: "New Remote", baseUrl: "https://new-remote.example.test", token: "secret-token" };
const addMachine = vi.spyOn(api, "addMachine").mockResolvedValue(addedMachine);
const health = vi.spyOn(api, "health").mockResolvedValue({ machineId: addedMachine.id, ok: true, checkedAt: "2026-05-27T00:00:01.000Z", status: "online" });
const runtime = vi.spyOn(api, "runtime").mockResolvedValue({ machineId: addedMachine.id, ok: true, checkedAt: "2026-05-27T00:00:02.000Z" });
const controller = new MachineController(() => state, setState, updateUrl, projects);
const machine = await controller.addMachine(input);
expect(machine).toEqual(addedMachine);
expect(addMachine).toHaveBeenCalledWith(input);
expect(state.machines).toEqual([localMachine, remoteMachine, addedMachine]);
expect(state.selectedMachine).toEqual(addedMachine);
expect(state.projects).toEqual([]);
expect(state.workspaces).toEqual([]);
expect(state.sessions).toEqual([]);
expect(state.selectedProject).toBeUndefined();
expect(state.selectedWorkspace).toBeUndefined();
expect(state.selectedSession).toBeUndefined();
expect(state.fileTree).toEqual([]);
expect(state.selectedFilePath).toBeUndefined();
expect(state.gitStatus).toBeUndefined();
expect(state.activeTerminalCount).toBe(0);
expect(state.error).toBe("");
expect(projects.loadProjects).toHaveBeenCalledOnce();
expect(updateUrl).toHaveBeenCalledOnce();
expect(health).toHaveBeenCalledWith(addedMachine.id);
expect(runtime).toHaveBeenCalledWith(addedMachine.id);
});
it("preserves the current machine state when adding a machine fails", async () => {
let state: AppState = { ...initialAppState(), machines: [localMachine], selectedMachine: localMachine };
const setState = (patch: Partial<AppState>) => { state = { ...state, ...patch }; };
const updateUrl = vi.fn();
const projects = { loadProjects: vi.fn() };
const input = { name: "New Remote", baseUrl: "https://new-remote.example.test" };
vi.spyOn(api, "addMachine").mockRejectedValue(new Error("Remote rejected"));
const health = vi.spyOn(api, "health");
const runtime = vi.spyOn(api, "runtime");
const controller = new MachineController(() => state, setState, updateUrl, projects);
const machine = await controller.addMachine(input);
expect(machine).toBeUndefined();
expect(state.machines).toEqual([localMachine]);
expect(state.selectedMachine).toEqual(localMachine);
expect(state.error).toBe("Error: Remote rejected");
expect(projects.loadProjects).not.toHaveBeenCalled();
expect(updateUrl).not.toHaveBeenCalled();
expect(health).not.toHaveBeenCalled();
expect(runtime).not.toHaveBeenCalled();
});
it("keeps the routed remote machine selected while its health is offline", async () => {
let state: AppState = initialAppState();
const setState = (patch: Partial<AppState>) => { state = { ...state, ...patch }; };
@@ -0,0 +1,378 @@
import { describe, expect, it } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import { initialAppState } from "../appState";
import { SessionController } from "./sessionController";
import { InMemorySessionSelectionMemory } from "./sessionSelection";
import { defaultApi, emptyPage, FakeSocket, oldSession, sessionLookupId, status, workspace, type AppState } from "./sessionController.testSupport";
describe("SessionController archive and cleanup", () => {
it("forgets the selected active session when archiving leaves only archived sessions", async () => {
const persistedSession = { ...oldSession, persisted: true };
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [persistedSession] };
const urlUpdates: ({ replace?: boolean | undefined } | undefined)[] = [];
const api: typeof defaultApi = {
...defaultApi,
archive: () => Promise.resolve({ archived: true }),
messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
(options) => { urlUpdates.push(options); },
new InMemorySessionSelectionMemory(),
{ api, socket: new FakeSocket() },
);
await controller.selectSession(persistedSession, { updateUrl: false });
await controller.archiveSession();
expect(state.selectedSession).toBeUndefined();
expect(state.sessions).toHaveLength(1);
expect(state.sessions[0]).toMatchObject({ ...oldSession, archived: true });
expect(typeof state.sessions[0]?.archivedAt).toBe("string");
expect(controller.preferredSession(workspace.path, state.sessions, undefined)).toBeUndefined();
expect(urlUpdates).toEqual([undefined]);
});
it("archives legacy sessions when persistence support is not advertised", async () => {
const legacySession = { ...oldSession };
const archivedIds: string[] = [];
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: legacySession, sessions: [legacySession] };
const api: typeof defaultApi = {
...defaultApi,
archive: (session) => {
archivedIds.push(sessionLookupId(session));
return Promise.resolve({ archived: true });
},
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
new InMemorySessionSelectionMemory(),
{ api, socket: new FakeSocket() },
);
await controller.archiveSession(legacySession);
expect(archivedIds).toEqual([legacySession.id]);
expect(state.sessions[0]).toMatchObject({ id: legacySession.id, archived: true });
});
it("archives selected session descendants and selects the next active session", async () => {
const persistedSession = { ...oldSession, persisted: true };
const childSession = { ...oldSession, id: "child-session", path: "/tmp/child-session.jsonl", parentSessionPath: persistedSession.path, persisted: true };
const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl", persisted: true };
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [persistedSession, childSession, nextSession] };
const api: typeof defaultApi = {
...defaultApi,
archiveWithDescendants: () => Promise.resolve({ archived: true, sessionIds: [persistedSession.id, childSession.id], archivedCount: 2, skippedAlreadyArchivedCount: 0 }),
messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
new InMemorySessionSelectionMemory(),
{ api, socket: new FakeSocket() },
);
await controller.selectSession(persistedSession, { updateUrl: false });
await controller.archiveSessionWithDescendants(persistedSession);
expect(state.sessions.find((session) => session.id === oldSession.id)).toMatchObject({ archived: true });
expect(state.sessions.find((session) => session.id === childSession.id)).toMatchObject({ archived: true });
expect(state.selectedSession?.id).toBe(nextSession.id);
});
it("archives selected sessions in bulk", async () => {
const persistedSession = { ...oldSession, persisted: true };
const secondSession = { ...oldSession, id: "second-session", path: "/tmp/second-session.jsonl", persisted: true };
const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl", persisted: true };
const archivedIds: string[] = [];
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [persistedSession, secondSession, nextSession] };
const api: typeof defaultApi = {
...defaultApi,
archive: (session) => {
archivedIds.push(sessionLookupId(session));
return Promise.resolve({ archived: true });
},
messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
new InMemorySessionSelectionMemory(),
{ api, socket: new FakeSocket() },
);
await controller.selectSession(persistedSession, { updateUrl: false });
await controller.archiveSessions([persistedSession, secondSession]);
expect(archivedIds).toEqual([oldSession.id, secondSession.id]);
expect(state.sessions.find((session) => session.id === oldSession.id)).toMatchObject({ archived: true });
expect(state.sessions.find((session) => session.id === secondSession.id)).toMatchObject({ archived: true });
expect(state.selectedSession?.id).toBe(nextSession.id);
});
it("uses true bulk archive when the selected runtime supports it and applies partial failures", async () => {
const persistedSession = { ...oldSession, persisted: true };
const failedSession = { ...oldSession, id: "failed-session", path: "/tmp/failed-session.jsonl", persisted: true };
const archiveCalls: { ids: string[]; machineId: string }[] = [];
let state: AppState = {
...initialAppState(),
selectedWorkspace: workspace,
sessions: [persistedSession, failedSession],
machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsBulkMutations] } },
};
const api: typeof defaultApi = {
...defaultApi,
archiveMany: (sessions, machineId) => {
archiveCalls.push({ ids: sessions.map(sessionLookupId), machineId: machineId ?? "local" });
return Promise.resolve({ archived: true, archivedSessionIds: [persistedSession.id], failures: [{ sessionId: failedSession.id, error: "busy" }], generatedAt: "now" });
},
archive: () => { throw new Error("single archive should not be used"); },
messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
new InMemorySessionSelectionMemory(),
{ api, socket: new FakeSocket() },
);
await controller.selectSession(persistedSession, { updateUrl: false });
await controller.archiveSessions([persistedSession, failedSession]);
expect(archiveCalls).toEqual([{ ids: [oldSession.id, failedSession.id], machineId: "local" }]);
expect(state.sessions.find((session) => session.id === oldSession.id)).toMatchObject({ archived: true });
expect(state.sessions.find((session) => session.id === failedSession.id)?.archived).toBeUndefined();
expect(state.selectedSession?.id).toBe(failedSession.id);
expect(state.error).toBe("Archive failed for 1 session: failed-session: busy");
});
it("throttles per-session archive fallback when bulk mutations are unsupported", async () => {
const sessions = Array.from({ length: 6 }, (_value, index) => ({ ...oldSession, id: `session-${String(index)}`, path: `/tmp/session-${String(index)}.jsonl`, persisted: true }));
const resolvers: (() => void)[] = [];
const startedIds: string[] = [];
let activeCount = 0;
let maxActiveCount = 0;
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions };
const api: typeof defaultApi = {
...defaultApi,
archive: (session) => new Promise((resolve) => {
activeCount += 1;
maxActiveCount = Math.max(maxActiveCount, activeCount);
startedIds.push(sessionLookupId(session));
resolvers.push(() => {
activeCount -= 1;
resolve({ archived: true });
});
}),
messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
new InMemorySessionSelectionMemory(),
{ api, socket: new FakeSocket() },
);
const archive = controller.archiveSessions(sessions);
await Promise.resolve();
expect(startedIds).toHaveLength(4);
resolvers.shift()?.();
await Promise.resolve();
await Promise.resolve();
expect(startedIds).toHaveLength(5);
for (const resolve of resolvers.splice(0)) resolve();
await Promise.resolve();
await Promise.resolve();
for (const resolve of resolvers.splice(0)) resolve();
await archive;
expect(maxActiveCount).toBe(4);
expect(state.sessions.every((session) => session.archived === true)).toBe(true);
});
it("deletes selected archived sessions in bulk and selects the next current session", async () => {
const archivedSession = { ...oldSession, archived: true, archivedAt: "later" };
const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl" };
const deletedIds: string[] = [];
let state: AppState = {
...initialAppState(),
selectedWorkspace: workspace,
selectedSession: archivedSession,
sessions: [archivedSession, nextSession],
machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] } },
};
const api: typeof defaultApi = {
...defaultApi,
deleteArchived: (session) => {
deletedIds.push(sessionLookupId(session));
return Promise.resolve({ deleted: true });
},
messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
new InMemorySessionSelectionMemory(),
{ api, socket: new FakeSocket() },
);
await controller.deleteArchivedSessions([archivedSession]);
expect(deletedIds).toEqual([archivedSession.id]);
expect(state.sessions.map((session) => session.id)).toEqual([nextSession.id]);
expect(state.selectedSession?.id).toBe(nextSession.id);
});
it("uses true bulk delete when supported and keeps partial failures visible", async () => {
const deletedSession = { ...oldSession, archived: true, archivedAt: "later" };
const failedSession = { ...oldSession, id: "failed-archived", path: "/tmp/failed-archived.jsonl", archived: true, archivedAt: "later" };
const deleteCalls: { ids: string[]; machineId: string }[] = [];
let state: AppState = {
...initialAppState(),
selectedWorkspace: workspace,
selectedSession: deletedSession,
sessions: [deletedSession, failedSession],
machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsBulkMutations] } },
};
const api: typeof defaultApi = {
...defaultApi,
deleteArchivedMany: (sessions, machineId) => {
deleteCalls.push({ ids: sessions.map(sessionLookupId), machineId: machineId ?? "local" });
return Promise.resolve({ deleted: true, deletedSessionIds: [deletedSession.id], failures: [{ sessionId: failedSession.id, error: "busy" }], generatedAt: "now" });
},
deleteArchived: () => { throw new Error("single delete should not be used"); },
messages: () => Promise.resolve(emptyPage),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
new InMemorySessionSelectionMemory(),
{ api, socket: new FakeSocket() },
);
await controller.deleteArchivedSessions([deletedSession, failedSession]);
expect(deleteCalls).toEqual([{ ids: [deletedSession.id, failedSession.id], machineId: "local" }]);
expect(state.sessions.map((session) => session.id)).toEqual([failedSession.id]);
expect(state.selectedSession?.id).toBe(failedSession.id);
expect(state.error).toBe("Delete failed for 1 session: failed-archived: busy");
});
it("applies cleanup execution results and refreshes the current workspace sessions", async () => {
const archivedAt = "2026-06-25T12:00:00.000Z";
const deletedArchived = { ...oldSession, id: "deleted-archived", path: "/tmp/deleted-archived.jsonl", archived: true, archivedAt: "2026-05-01T00:00:00.000Z" };
const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl" };
const refreshedArchived = { ...oldSession, archived: true, archivedAt };
const sessionsCalls: { cwd: string; machineId: string }[] = [];
let state: AppState = {
...initialAppState(),
selectedWorkspace: workspace,
selectedSession: oldSession,
sessions: [oldSession, deletedArchived, nextSession],
sessionStatuses: { [oldSession.id]: status(oldSession.id), [deletedArchived.id]: status(deletedArchived.id), [nextSession.id]: status(nextSession.id) },
sessionActivities: { [oldSession.id]: { sessionId: oldSession.id, phase: "idle", label: "idle", at: archivedAt } },
};
const api: typeof defaultApi = {
...defaultApi,
sessions: (cwd, machineId) => {
sessionsCalls.push({ cwd, machineId: machineId ?? "local" });
return Promise.resolve([refreshedArchived, nextSession]);
},
messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
new InMemorySessionSelectionMemory(),
{ api, socket: new FakeSocket() },
);
await controller.applySessionCleanupResult({
generatedAt: archivedAt,
thresholds: { archiveIdleDays: 30, deleteArchivedDays: 60 },
projects: [{ cwd: workspace.path, archiveCount: 1, deleteCount: 1 }],
totals: { archiveCount: 1, deleteCount: 1 },
archivedSessionIds: [oldSession.id],
deletedSessionIds: [deletedArchived.id],
});
expect(sessionsCalls).toEqual([{ cwd: workspace.path, machineId: "local" }]);
expect(state.sessions.map((session) => session.id)).toEqual([oldSession.id, nextSession.id]);
expect(state.sessions[0]).toMatchObject({ id: oldSession.id, archived: true, archivedAt });
expect(state.selectedSession?.id).toBe(nextSession.id);
expect(state.sessionStatuses[oldSession.id]).toBeUndefined();
expect(state.sessionStatuses[deletedArchived.id]).toBeUndefined();
expect(state.sessionActivities[oldSession.id]).toBeUndefined();
});
it("does not delete archived sessions when the selected machine runtime reports no support", async () => {
const archivedSession = { ...oldSession, archived: true, archivedAt: "later" };
const deletedIds: string[] = [];
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [archivedSession], machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [] } } };
const api: typeof defaultApi = {
...defaultApi,
deleteArchived: (session) => {
deletedIds.push(sessionLookupId(session));
return Promise.resolve({ deleted: true });
},
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
new InMemorySessionSelectionMemory(),
{ api, socket: new FakeSocket() },
);
await controller.deleteArchivedSessions([archivedSession]);
expect(deletedIds).toEqual([]);
expect(state.sessions).toEqual([archivedSession]);
expect(state.error).toContain("requires an updated Pi-Web runtime");
});
it("allows legacy archived-session deletion when runtime support is unknown", async () => {
const archivedSession = { ...oldSession, archived: true, archivedAt: "later" };
const deletedIds: string[] = [];
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: archivedSession, sessions: [archivedSession] };
const api: typeof defaultApi = {
...defaultApi,
deleteArchived: (session) => {
deletedIds.push(sessionLookupId(session));
return Promise.resolve({ deleted: true });
},
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
new InMemorySessionSelectionMemory(),
{ api, socket: new FakeSocket() },
);
await controller.deleteArchivedSessions([archivedSession]);
expect(deletedIds).toEqual([archivedSession.id]);
expect(state.sessions).toEqual([]);
expect(state.error).toBe("");
});
});
@@ -0,0 +1,145 @@
import { describe, expect, it } from "vitest";
import { initialAppState } from "../appState";
import { isCachedNewSessionInfo, loadCachedNewSessions, markCachedNewSessionInfo, rememberCachedNewSession } from "../cachedNewSessions";
import { loadDraft, saveDraft } from "../promptDraftStorage";
import { SessionController } from "./sessionController";
import { defaultApi, emptyPage, FakeSocket, MemoryStorage, oldSession, replacementSession, sessionKey, sessionLookupId, status, workspace, type AppState } from "./sessionController.testSupport";
describe("SessionController cached-new sessions", () => {
it("keeps live message count updates when a cached new session becomes persisted", async () => {
const cachedSession = markCachedNewSessionInfo(oldSession);
let resolvePrompt: (() => void) | undefined;
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: cachedSession, sessions: [cachedSession] };
const api: typeof defaultApi = {
...defaultApi,
prompt: () => new Promise<{ accepted: true }>((resolve) => { resolvePrompt = () => { resolve({ accepted: true }); }; }),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
const send = controller.send("hello");
controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), messageCount: 1 } });
controller.flushPendingUpdates();
resolvePrompt?.();
await send;
expect(state.sessions[0]?.messageCount).toBe(1);
expect(isCachedNewSessionInfo(state.sessions[0])).toBe(false);
expect(state.selectedSession?.messageCount).toBe(1);
});
it("deletes transient server-reported new sessions and clears local state", async () => {
const storage = new MemoryStorage();
Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true });
const transientSession = { ...oldSession, persisted: false };
const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl", persisted: true };
const stoppedIds: string[] = [];
let state: AppState = {
...initialAppState(),
selectedWorkspace: workspace,
selectedSession: transientSession,
sessions: [transientSession, nextSession],
sessionStatuses: { [transientSession.id]: { ...status(transientSession.id), persisted: false } },
sessionActivities: { [transientSession.id]: { sessionId: transientSession.id, phase: "active", label: "Starting", at: "2026-05-20T00:00:00.000Z" } },
sendingPrompts: { [transientSession.id]: true },
};
const api: typeof defaultApi = {
...defaultApi,
stop: (session) => { stoppedIds.push(sessionLookupId(session)); return Promise.resolve({ stopped: true }); },
messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
saveDraft(sessionKey(transientSession.id), "discard me");
await controller.deleteCachedNewSession(transientSession);
expect(stoppedIds).toEqual([transientSession.id]);
expect(state.sessions.map((session) => session.id)).toEqual([nextSession.id]);
expect(state.sessionStatuses[transientSession.id]).toBeUndefined();
expect(state.sessionActivities[transientSession.id]).toBeUndefined();
expect(state.sendingPrompts[transientSession.id]).toBeUndefined();
expect(loadDraft(sessionKey(transientSession.id))).toBe("");
expect(state.selectedSession?.id).toBe(nextSession.id);
});
it("recreates missing browser-cached new sessions and moves their draft", async () => {
const storage = new MemoryStorage();
Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true });
rememberCachedNewSession(oldSession);
saveDraft(sessionKey(oldSession.id), "draft text");
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [markCachedNewSessionInfo(oldSession)] };
const urlUpdates: ({ replace?: boolean | undefined } | undefined)[] = [];
const socket = new FakeSocket();
const api: typeof defaultApi = {
...defaultApi,
startSession: () => Promise.resolve(replacementSession),
messages: (session) => {
if (sessionLookupId(session) === oldSession.id) return Promise.reject(new Error("Session not found"));
return Promise.resolve(emptyPage);
},
status: (session) => Promise.resolve(status(sessionLookupId(session))),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
(options) => { urlUpdates.push(options); },
undefined,
{ api, socket },
);
await controller.selectSession(markCachedNewSessionInfo(oldSession), { updateUrl: false });
expect(state.selectedSession?.id).toBe(replacementSession.id);
expect(state.sessions.map((session) => session.id)).toEqual([replacementSession.id]);
expect(socket.connectedSessionIds).toEqual([oldSession.id, replacementSession.id]);
expect(loadDraft(sessionKey(oldSession.id))).toBe("");
expect(loadDraft(sessionKey(replacementSession.id))).toBe("draft text");
expect(loadCachedNewSessions().map((session) => session.id)).toEqual([replacementSession.id]);
expect(urlUpdates).toEqual([{ replace: true }]);
});
it("stores command prompt drafts for replacement sessions before selecting them", async () => {
const storage = new MemoryStorage();
Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true });
let state: AppState = {
...initialAppState(),
selectedWorkspace: workspace,
selectedSession: oldSession,
sessions: [oldSession],
commandDialog: { type: "select", requestId: "r1", title: "Fork from message", options: [{ value: "m1", label: "fork me" }] },
};
const urlUpdates: unknown[] = [];
const api: typeof defaultApi = {
...defaultApi,
respondToCommand: () => Promise.resolve({ type: "done", message: "Session forked", session: replacementSession, promptDraft: "fork me" }),
messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
(options) => { urlUpdates.push(options); },
undefined,
{ api, socket: new FakeSocket() },
);
await controller.respondToCommand("r1", "m1");
expect(state.commandDialog).toBeUndefined();
expect(loadDraft(sessionKey(replacementSession.id))).toBe("fork me");
});
});
@@ -0,0 +1,161 @@
import { describe, expect, it } from "vitest";
import { initialAppState } from "../appState";
import { SessionController } from "./sessionController";
import { defaultApi, EmitSocket, emptyPage, FakeSocket, oldSession, runPendingAnimationFrames, status, workspace, type AppState, type SessionActivity, type SessionInfo } from "./sessionController.testSupport";
describe("SessionController live events", () => {
it("coalesces rapid status updates into a single state write per frame", () => {
const setStateCalls: Partial<AppState>[] = [];
let state: AppState = { ...initialAppState(), selectedSession: oldSession, sessions: [oldSession] };
const controller = new SessionController(
() => state,
(patch) => { setStateCalls.push(patch); state = { ...state, ...patch }; },
() => undefined,
undefined,
{ socket: new FakeSocket() },
);
controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 1 } });
controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 2 } });
controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 3 } });
// Nothing applies until the frame is flushed; last-write-wins per session.
expect(setStateCalls).toHaveLength(0);
expect(state.sessionStatuses[oldSession.id]).toBeUndefined();
runPendingAnimationFrames();
expect(setStateCalls).toHaveLength(1);
expect(state.sessionStatuses[oldSession.id]).toMatchObject({ sessionId: oldSession.id, messageCount: 3 });
expect(state.status?.messageCount).toBe(3);
});
it("applies the latest activity per session on flush", () => {
const setStateCalls: Partial<AppState>[] = [];
let state: AppState = { ...initialAppState(), selectedSession: oldSession, sessions: [oldSession] };
const controller = new SessionController(
() => state,
(patch) => { setStateCalls.push(patch); state = { ...state, ...patch }; },
() => undefined,
undefined,
{ socket: new FakeSocket() },
);
controller.applyGlobalEvent({ type: "activity.update", activity: { sessionId: oldSession.id, phase: "active", label: "running tool", at: "t1" } });
controller.applyGlobalEvent({ type: "activity.update", activity: { sessionId: oldSession.id, phase: "idle", label: "idle", at: "t2" } });
expect(setStateCalls).toHaveLength(0);
controller.flushPendingUpdates();
expect(state.sessionActivities[oldSession.id]).toMatchObject({ phase: "idle", label: "idle" });
expect(state.activity?.phase).toBe("idle");
});
it("coalesces status updates delivered over the per-session socket until the frame is flushed", async () => {
const socket = new EmitSocket();
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] };
const api: typeof defaultApi = {
...defaultApi,
messages: () => Promise.resolve(emptyPage),
status: () => Promise.resolve(status(oldSession.id)),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket },
);
await controller.selectSession(oldSession, { updateUrl: false });
socket.emit({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 7 } });
socket.emit({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 8 } });
// Buffered, not applied synchronously.
expect(state.sessionStatuses[oldSession.id]?.messageCount).toBeUndefined();
controller.flushPendingUpdates();
expect(state.sessionStatuses[oldSession.id]?.messageCount).toBe(8);
expect(state.status?.messageCount).toBe(8);
});
it("clears stale active activity when an idle status arrives", () => {
const activeActivity: SessionActivity = { sessionId: oldSession.id, phase: "active", label: "running tool", at: "2026-05-15T00:00:00.000Z" };
let state: AppState = {
...initialAppState(),
selectedSession: oldSession,
sessions: [oldSession],
activity: activeActivity,
sessionActivities: { [oldSession.id]: activeActivity },
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ socket: new FakeSocket() },
);
controller.applyGlobalEvent({ type: "status.update", status: status(oldSession.id) });
controller.flushPendingUpdates();
expect(state.activity).toBeUndefined();
expect(state.sessionActivities[oldSession.id]).toBeUndefined();
expect(state.sessionStatuses[oldSession.id]).toMatchObject({ sessionId: oldSession.id, isStreaming: false });
});
it("updates visible session message counts from live status events", () => {
let state: AppState = {
...initialAppState(),
selectedSession: oldSession,
sessions: [oldSession],
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ socket: new FakeSocket() },
);
controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), messageCount: 3 } });
controller.flushPendingUpdates();
expect(state.sessions[0]?.messageCount).toBe(3);
expect(state.selectedSession?.messageCount).toBe(3);
});
it("adds a newly created session to the list when it belongs to the selected workspace", () => {
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [oldSession] };
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ socket: new FakeSocket() },
);
const spawned: SessionInfo = { ...oldSession, id: "spawned-session", path: "/tmp/spawned-session.jsonl" };
controller.applyGlobalEvent({ type: "session.created", session: spawned });
expect(state.sessions.map((session) => session.id)).toEqual(["spawned-session", "old-session"]);
});
it("ignores a created session for a different workspace or a duplicate id", () => {
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [oldSession] };
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ socket: new FakeSocket() },
);
controller.applyGlobalEvent({ type: "session.created", session: { ...oldSession, id: "other", cwd: "/other-repo" } });
controller.applyGlobalEvent({ type: "session.created", session: { ...oldSession } });
expect(state.sessions.map((session) => session.id)).toEqual(["old-session"]);
});
});
@@ -0,0 +1,296 @@
import { describe, expect, it } from "vitest";
import { initialAppState } from "../appState";
import { isCachedNewSessionInfo, loadCachedNewSessions } from "../cachedNewSessions";
import { loadDraft, saveDraft } from "../promptDraftStorage";
import { SessionController } from "./sessionController";
import { defaultApi, deferred, emptyPage, FakeSocket, MemoryStorage, oldSession, sessionKey, sessionLookupId, status, workspace, type AppState, type SessionInfo } from "./sessionController.testSupport";
describe("SessionController pending starts", () => {
it("creates and selects a temporary editable session before backend start resolves", async () => {
const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" };
const startRequest = deferred<SessionInfo>();
const messageCalls: string[] = [];
const statusCalls: string[] = [];
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
const api: typeof defaultApi = {
...defaultApi,
startSession: () => startRequest.promise,
messages: (session) => { messageCalls.push(sessionLookupId(session)); return Promise.resolve(emptyPage); },
status: (session) => { statusCalls.push(sessionLookupId(session)); return Promise.resolve(status(sessionLookupId(session))); },
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
const start = controller.startSession();
const temporarySession = state.selectedSession;
expect(temporarySession?.id).toMatch(/^pending-session-/);
expect(temporarySession?.persisted).toBe(false);
expect(state.sessions.map((session) => session.id)).toEqual([temporarySession?.id]);
expect(state.activity).toMatchObject({ sessionId: temporarySession?.id, phase: "active", label: "Creating session" });
expect(messageCalls).toEqual([]);
expect(statusCalls).toEqual([]);
startRequest.resolve(started);
await start;
expect(state.sessions.map((session) => session.id)).toEqual(["started-session"]);
expect(state.selectedSession?.id).toBe("started-session");
expect(messageCalls).toEqual(["started-session"]);
expect(statusCalls).toEqual(["started-session"]);
});
it("does not duplicate a started session when its session.created broadcast races the HTTP response", async () => {
const storage = new MemoryStorage();
Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true });
const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" };
const startRequest = deferred<SessionInfo>();
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
const socket = new FakeSocket();
const api: typeof defaultApi = {
...defaultApi,
startSession: () => {
// Simulate the broadcast arriving before the HTTP response resolves.
controller.applyGlobalEvent({ type: "session.created", session: started });
return startRequest.promise;
},
messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket },
);
const start = controller.startSession();
const temporaryId = state.selectedSession?.id;
expect(state.sessions.map((session) => session.id)).toEqual([temporaryId]);
startRequest.resolve(started);
await start;
expect(state.sessions.map((session) => session.id)).toEqual(["started-session"]);
expect(isCachedNewSessionInfo(state.sessions[0])).toBe(true);
});
it("releases unrelated created-session broadcasts after pending starts settle", async () => {
const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" };
const otherClientSession: SessionInfo = { ...oldSession, id: "other-client-session", path: "/tmp/other-client-session.jsonl" };
const startRequest = deferred<SessionInfo>();
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
const api: typeof defaultApi = {
...defaultApi,
startSession: () => startRequest.promise,
messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
const start = controller.startSession();
const temporaryId = state.selectedSession?.id;
controller.applyGlobalEvent({ type: "session.created", session: started });
controller.applyGlobalEvent({ type: "session.created", session: otherClientSession });
expect(state.sessions.map((session) => session.id)).toEqual([temporaryId]);
startRequest.resolve(started);
await start;
const sessionIds = state.sessions.map((session) => session.id);
expect(sessionIds).not.toContain(temporaryId);
expect(sessionIds.filter((id) => id === started.id)).toHaveLength(1);
expect(sessionIds.filter((id) => id === otherClientSession.id)).toHaveLength(1);
});
it("preserves temporary start rows across session-list refreshes before backend resolution", async () => {
const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" };
const startRequest = deferred<SessionInfo>();
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
const api: typeof defaultApi = {
...defaultApi,
startSession: () => startRequest.promise,
sessions: () => Promise.resolve([oldSession]),
messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
const start = controller.startSession();
const temporaryId = state.selectedSession?.id;
await controller.refreshCurrentWorkspaceSessions();
expect(state.sessions.map((session) => session.id)).toEqual([temporaryId, oldSession.id]);
expect(state.selectedSession?.id).toBe(temporaryId);
startRequest.resolve(started);
await start;
expect(state.sessions.map((session) => session.id)).toEqual([started.id, oldSession.id]);
expect(state.selectedSession?.id).toBe(started.id);
});
it("tracks multiple pending session starts without blocking another start", async () => {
const firstStarted: SessionInfo = { ...oldSession, id: "started-session-1", path: "/tmp/started-session-1.jsonl" };
const secondStarted: SessionInfo = { ...oldSession, id: "started-session-2", path: "/tmp/started-session-2.jsonl" };
const startResolvers: ((session: SessionInfo) => void)[] = [];
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
const api: typeof defaultApi = {
...defaultApi,
startSession: () => new Promise<SessionInfo>((resolve) => { startResolvers.push(resolve); }),
messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
const firstStart = controller.startSession();
const firstTemporaryId = state.selectedSession?.id;
const secondStart = controller.startSession();
const secondTemporaryId = state.selectedSession?.id;
expect(startResolvers).toHaveLength(2);
expect(state.startingSessionCount).toBe(0);
expect(state.sessions.map((session) => session.id)).toEqual([secondTemporaryId, firstTemporaryId]);
expect(state.selectedSession?.id).toBe(secondTemporaryId);
expect(state.sessions.every((session) => session.persisted === false)).toBe(true);
startResolvers[0]?.(firstStarted);
await firstStart;
expect(state.sessions.map((session) => session.id)).toEqual([secondTemporaryId, "started-session-1"]);
expect(state.selectedSession?.id).toBe(secondTemporaryId);
startResolvers[1]?.(secondStarted);
await secondStart;
expect(state.startingSessionCount).toBe(0);
expect(state.sessions.map((session) => session.id)).toEqual(["started-session-2", "started-session-1"]);
expect(state.selectedSession?.id).toBe("started-session-2");
});
it("moves a temporary session draft and cached-new marker to the resolved session", async () => {
const storage = new MemoryStorage();
Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true });
const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" };
const startRequest = deferred<SessionInfo>();
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
const api: typeof defaultApi = {
...defaultApi,
startSession: () => startRequest.promise,
messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
const start = controller.startSession();
const temporaryId = state.selectedSession?.id;
if (temporaryId === undefined) throw new Error("Expected temporary session id");
saveDraft(sessionKey(temporaryId), "draft text");
startRequest.resolve(started);
await start;
expect(loadDraft(sessionKey(temporaryId))).toBe("");
expect(loadDraft(sessionKey(started.id))).toBe("draft text");
expect(loadCachedNewSessions().map((session) => session.id)).toEqual([started.id]);
expect(isCachedNewSessionInfo(state.sessions[0])).toBe(true);
});
it("keeps a failed temporary start selected with a discardable transient row", async () => {
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
const api: typeof defaultApi = {
...defaultApi,
startSession: () => Promise.reject(new Error("backend unavailable")),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
await controller.startSession();
const temporaryId = state.selectedSession?.id;
expect(temporaryId).toMatch(/^pending-session-/);
expect(state.sessions.map((session) => session.id)).toEqual([temporaryId]);
expect(state.sessions[0]?.persisted).toBe(false);
expect(state.activity).toMatchObject({ sessionId: temporaryId, phase: "error", label: "Session creation failed" });
expect(state.error).toContain("backend unavailable");
await controller.deleteCachedNewSession(state.sessions[0]);
expect(state.sessions).toEqual([]);
expect(state.selectedSession).toBeUndefined();
});
it("stops the backend session if a discarded pending start resolves later", async () => {
const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" };
const startRequest = deferred<SessionInfo>();
const stoppedIds: string[] = [];
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
const api: typeof defaultApi = {
...defaultApi,
startSession: () => startRequest.promise,
stop: (session) => { stoppedIds.push(sessionLookupId(session)); return Promise.resolve({ stopped: true }); },
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
const start = controller.startSession();
const temporaryId = state.selectedSession?.id;
if (temporaryId === undefined) throw new Error("Expected temporary session id");
await controller.send("queued before discard");
expect(state.clientQueuedSessionMessages[temporaryId]).toEqual([{ kind: "followUp", text: "queued before discard" }]);
await controller.deleteCachedNewSession(state.selectedSession);
expect(state.sessions).toEqual([]);
expect(state.selectedSession).toBeUndefined();
expect(state.clientQueuedSessionMessages[temporaryId]).toBeUndefined();
startRequest.resolve(started);
await start;
expect(stoppedIds).toEqual([started.id]);
expect(state.sessions).toEqual([]);
expect(state.selectedSession).toBeUndefined();
});
});
@@ -0,0 +1,148 @@
import { describe, expect, it } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import { initialAppState } from "../appState";
import { ChatTranscriptStore } from "../chatTranscriptStore";
import { SessionController } from "./sessionController";
import { InMemorySessionSelectionMemory } from "./sessionSelection";
import { defaultApi, emptyPage, FakeSocket, oldSession, sessionKey, sessionLookupId, status, workspace, type AppState, type MessagePage } from "./sessionController.testSupport";
describe("SessionController reload and selection", () => {
it("reloads the selected session from disk, discards the cached transcript, and re-fetches history", async () => {
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<string, MessagePage>([[cacheKey, { messages: [{ role: "user", content: "stale cached transcript" }], start: 0, total: 2 }]]);
const reloadCalls: string[] = [];
const messageCalls: string[] = [];
let state: AppState = {
...initialAppState(),
selectedWorkspace: workspace,
selectedSession: persistedSession,
sessions: [persistedSession],
machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] } },
};
const api: typeof defaultApi = {
...defaultApi,
reloadSession: (session) => {
reloadCalls.push(sessionLookupId(session));
return Promise.resolve({ reloaded: true });
},
messages: (session) => {
messageCalls.push(sessionLookupId(session));
return Promise.resolve(freshPage);
},
status: (session) => Promise.resolve(status(sessionLookupId(session))),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
new InMemorySessionSelectionMemory(),
{
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).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("");
});
it("does not reload sessions from disk when the selected machine runtime does not support it", async () => {
const persistedSession = { ...oldSession, persisted: true };
const reloadCalls: string[] = [];
let state: AppState = {
...initialAppState(),
selectedWorkspace: workspace,
selectedSession: persistedSession,
sessions: [persistedSession],
};
const api: typeof defaultApi = {
...defaultApi,
reloadSession: (session) => {
reloadCalls.push(sessionLookupId(session));
return Promise.resolve({ reloaded: true });
},
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
new InMemorySessionSelectionMemory(),
{ api, socket: new FakeSocket() },
);
await controller.reloadSession(persistedSession);
expect(reloadCalls).toEqual([]);
expect(state.error).toContain("Reloading sessions from disk requires an updated Pi-Web runtime");
});
it("does not reload sessions from disk without a persisted server signal when persistence is authoritative", async () => {
const reloadCalls: string[] = [];
let state: AppState = {
...initialAppState(),
selectedWorkspace: workspace,
selectedSession: oldSession,
sessions: [oldSession],
machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.sessionsPersistedState] } },
};
const api: typeof defaultApi = {
...defaultApi,
reloadSession: (session) => {
reloadCalls.push(sessionLookupId(session));
return Promise.resolve({ reloaded: true });
},
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
new InMemorySessionSelectionMemory(),
{ api, socket: new FakeSocket() },
);
await controller.reloadSession(oldSession);
await controller.reloadSession({ ...oldSession, persisted: false });
expect(reloadCalls).toEqual([]);
expect(state.error).toBe("");
});
it("forgets archived selections when the archived section collapse clears selection", async () => {
const archivedSession = { ...oldSession, archived: true, archivedAt: "later" };
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [archivedSession] };
const urlUpdates: ({ replace?: boolean | undefined } | undefined)[] = [];
const api: typeof defaultApi = {
...defaultApi,
messages: () => Promise.resolve(emptyPage),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
(options) => { urlUpdates.push(options); },
new InMemorySessionSelectionMemory(),
{ api, socket: new FakeSocket() },
);
await controller.selectSession(archivedSession, { updateUrl: false });
expect(controller.preferredSession(workspace.path, state.sessions, undefined)).toBe(archivedSession);
controller.clearSelectionAfterArchivedCollapse();
expect(state.selectedSession).toBeUndefined();
expect(controller.preferredSession(workspace.path, state.sessions, undefined)).toBeUndefined();
expect(urlUpdates).toEqual([undefined]);
});
});
@@ -0,0 +1,354 @@
import { describe, expect, it } from "vitest";
import { initialAppState } from "../appState";
import { SessionController } from "./sessionController";
import { defaultApi, deferred, emptyPage, FakeSocket, oldSession, replacementSession, sessionLookupId, status, workspace, type AppState, type Deferred, type PromptAttachment, type SessionInfo } from "./sessionController.testSupport";
describe("SessionController send queue", () => {
it("toggles the per-session sending state around an inline attachment send and forwards attachments", async () => {
let resolvePrompt: (() => void) | undefined;
let promptArgs: { attachments?: PromptAttachment[] } | undefined;
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] };
const attachments: PromptAttachment[] = [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }];
const api: typeof defaultApi = {
...defaultApi,
prompt: (_session, _text, _behavior, _machineId, sentAttachments) => new Promise<{ accepted: true }>((resolve) => {
promptArgs = { ...(sentAttachments === undefined ? {} : { attachments: sentAttachments }) };
resolvePrompt = () => { resolve({ accepted: true }); };
}),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
const send = controller.send("look", undefined, attachments, "inline");
const sendingDuringPrompt = state.sendingPrompts;
resolvePrompt?.();
await send;
expect(sendingDuringPrompt).toEqual({ [oldSession.id]: true });
expect(state.sendingPrompts).toEqual({});
expect(promptArgs).toEqual({ attachments });
});
it("keeps the sending state scoped to the originating session when the user switches away", async () => {
let resolvePrompt: (() => void) | undefined;
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession, replacementSession] };
const attachments: PromptAttachment[] = [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }];
const api: typeof defaultApi = {
...defaultApi,
prompt: () => new Promise<{ accepted: true }>((resolve) => { resolvePrompt = () => { resolve({ accepted: true }); }; }),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
const send = controller.send("look", undefined, attachments, "inline");
// While the upload is in flight, deselecting must not clear the originating
// session's sending entry, and it must stay keyed to that session only.
controller.deselectSession();
expect(state.sendingPrompts).toEqual({ [oldSession.id]: true });
expect(state.sendingPrompts[replacementSession.id]).toBeUndefined();
resolvePrompt?.();
await send;
expect(state.sendingPrompts).toEqual({});
});
it("uploads to the workspace folder and rewrites the prompt for folder delivery", async () => {
let savedCalledWith: PromptAttachment[] | undefined;
let promptText: string | undefined;
let promptAttachments: PromptAttachment[] | undefined;
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] };
const attachments: PromptAttachment[] = [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }];
const api: typeof defaultApi = {
...defaultApi,
saveAttachments: (_session, sent) => { savedCalledWith = sent; return Promise.resolve([{ path: ".pi-web/attachments/shot.png", mimeType: "image/png", size: 3 }]); },
prompt: (_session, text, _behavior, _machineId, sentAttachments) => { promptText = text; promptAttachments = sentAttachments; return Promise.resolve({ accepted: true }); },
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
await controller.send("check this", undefined, attachments, "folder");
expect(savedCalledWith).toEqual(attachments);
expect(promptText).toBe("check this\n\[email protected]/attachments/shot.png");
expect(promptAttachments).toBeUndefined();
expect(state.sendingPrompts).toEqual({});
});
it("does not set the sending state for plain text messages", async () => {
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] };
const seen: Record<string, true>[] = [];
const api: typeof defaultApi = {
...defaultApi,
prompt: () => { seen.push({ ...state.sendingPrompts }); return Promise.resolve({ accepted: true }); },
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
await controller.send("hello");
expect(seen).toEqual([{}]);
expect(state.sendingPrompts).toEqual({});
});
it("sends slash commands without inserting an optimistic transcript line and toggles the sending state", async () => {
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] };
let resolveCommand: (() => void) | undefined;
const seenDuringCommand: Record<string, true>[] = [];
const api: typeof defaultApi = {
...defaultApi,
runCommand: (_session, text) => new Promise((resolve) => {
seenDuringCommand.push({ ...state.sendingPrompts });
resolveCommand = () => { resolve(text.startsWith("/skill") ? { type: "done" } : { type: "done", message: "stats" }); };
}),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
const run = controller.send("/skill:skill-creator");
expect(seenDuringCommand).toEqual([{ [oldSession.id]: true }]);
// No raw command text is added to the transcript; the agent streams the
// canonical expanded message back instead.
expect(state.messages).toEqual([]);
resolveCommand?.();
await run;
expect(state.messages).toEqual([]);
expect(state.sendingPrompts).toEqual({});
});
it("queues prompt sends for a pending session start and flushes them after resolution", async () => {
const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" };
const startRequest = deferred<SessionInfo>();
const promptCalls: { sessionId: string; text: string; behavior?: "steer" | "followUp" }[] = [];
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
const api: typeof defaultApi = {
...defaultApi,
startSession: () => startRequest.promise,
messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
prompt: (session, text, behavior) => {
promptCalls.push({ sessionId: sessionLookupId(session), text, ...(behavior === undefined ? {} : { behavior }) });
return Promise.resolve({ accepted: true });
},
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
const start = controller.startSession();
const temporaryId = state.selectedSession?.id;
if (temporaryId === undefined) throw new Error("Expected temporary session id");
await controller.send("first");
await controller.send("second", "steer");
expect(promptCalls).toEqual([]);
expect(state.clientQueuedSessionMessages[temporaryId]).toEqual([
{ kind: "followUp", text: "first" },
{ kind: "steer", text: "second" },
]);
expect(state.activity?.detail).toContain("2 queued messages");
startRequest.resolve(started);
await start;
expect(promptCalls).toEqual([
{ sessionId: started.id, text: "first" },
{ sessionId: started.id, text: "second", behavior: "steer" },
]);
expect(state.clientQueuedSessionMessages[temporaryId]).toBeUndefined();
expect(state.clientQueuedSessionMessages[started.id]).toBeUndefined();
expect(state.sendingPrompts).toEqual({});
expect(state.selectedSession?.id).toBe(started.id);
});
it("queues slash commands, shell input, and attachments for a pending session start", async () => {
const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" };
const startRequest = deferred<SessionInfo>();
const calls: string[] = [];
const promptCalls: { text: string; attachments?: PromptAttachment[] }[] = [];
const attachments: PromptAttachment[] = [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }];
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
const api: typeof defaultApi = {
...defaultApi,
startSession: () => startRequest.promise,
messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
runCommand: (session, text) => {
calls.push(`command:${sessionLookupId(session)}:${text}`);
return Promise.resolve({ type: "done" });
},
shell: (session, text) => {
calls.push(`shell:${sessionLookupId(session)}:${text}`);
return Promise.resolve({ accepted: true });
},
saveAttachments: (session, sentAttachments) => {
calls.push(`save:${sessionLookupId(session)}:${sentAttachments[0]?.name ?? ""}`);
return Promise.resolve([{ path: ".pi-web/attachments/shot.png", mimeType: "image/png", size: 3 }]);
},
prompt: (session, text, _behavior, _machineId, sentAttachments) => {
calls.push(`prompt:${sessionLookupId(session)}:${text}`);
promptCalls.push({ text, ...(sentAttachments === undefined ? {} : { attachments: sentAttachments }) });
return Promise.resolve({ accepted: true });
},
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
const start = controller.startSession();
const temporaryId = state.selectedSession?.id;
if (temporaryId === undefined) throw new Error("Expected temporary session id");
await controller.send("/help");
await controller.send("!pwd");
await controller.send("look", undefined, attachments, "inline");
await controller.send("save", undefined, attachments, "folder");
expect(calls).toEqual([]);
expect(state.clientQueuedSessionMessages[temporaryId]).toEqual([
{ kind: "followUp", text: "/help" },
{ kind: "followUp", text: "!pwd" },
{ kind: "followUp", text: "look\n\n[1 attachment queued: shot.png]" },
{ kind: "followUp", text: "save\n\n[1 attachment queued: shot.png]" },
]);
startRequest.resolve(started);
await start;
expect(calls).toEqual([
`command:${started.id}:/help`,
`shell:${started.id}:!pwd`,
`prompt:${started.id}:look`,
`save:${started.id}:shot.png`,
`prompt:${started.id}:save\n\[email protected]/attachments/shot.png`,
]);
expect(promptCalls).toEqual([
{ text: "look", attachments },
{ text: "save\n\[email protected]/attachments/shot.png" },
]);
expect(state.clientQueuedSessionMessages[started.id]).toBeUndefined();
});
it("keeps queued sends visible when backend session creation fails", async () => {
const startRequest = deferred<SessionInfo>();
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
const api: typeof defaultApi = {
...defaultApi,
startSession: () => startRequest.promise,
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
const start = controller.startSession();
const temporaryId = state.selectedSession?.id;
if (temporaryId === undefined) throw new Error("Expected temporary session id");
await controller.send("recover me");
startRequest.reject(new Error("backend unavailable"));
await start;
expect(state.selectedSession?.id).toBe(temporaryId);
expect(state.clientQueuedSessionMessages[temporaryId]).toEqual([{ kind: "followUp", text: "recover me" }]);
expect(state.activity).toMatchObject({ sessionId: temporaryId, phase: "error", label: "Session creation failed" });
expect(state.activity?.detail).toContain("1 queued message kept below");
await controller.deleteCachedNewSession(state.selectedSession);
expect(state.clientQueuedSessionMessages[temporaryId]).toBeUndefined();
expect(state.selectedSession).toBeUndefined();
});
it("keeps queued sends scoped to their originating pending start", async () => {
const firstStarted: SessionInfo = { ...oldSession, id: "started-session-1", path: "/tmp/started-session-1.jsonl" };
const secondStarted: SessionInfo = { ...oldSession, id: "started-session-2", path: "/tmp/started-session-2.jsonl" };
const startRequests: Deferred<SessionInfo>[] = [];
const promptCalls: { sessionId: string; text: string }[] = [];
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
const api: typeof defaultApi = {
...defaultApi,
startSession: () => {
const request = deferred<SessionInfo>();
startRequests.push(request);
return request.promise;
},
messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
prompt: (session, text) => {
promptCalls.push({ sessionId: sessionLookupId(session), text });
return Promise.resolve({ accepted: true });
},
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
const firstStart = controller.startSession();
const firstTemporary = state.selectedSession;
if (firstTemporary === undefined) throw new Error("Expected first temporary session");
const secondStart = controller.startSession();
const secondTemporary = state.selectedSession;
if (secondTemporary === undefined) throw new Error("Expected second temporary session");
await controller.send("second prompt");
await controller.selectSession(firstTemporary, { updateUrl: false });
await controller.send("first prompt");
startRequests[1]?.resolve(secondStarted);
await secondStart;
expect(promptCalls).toEqual([{ sessionId: secondStarted.id, text: "second prompt" }]);
expect(state.selectedSession?.id).toBe(firstTemporary.id);
expect(state.clientQueuedSessionMessages[secondStarted.id]).toBeUndefined();
expect(state.clientQueuedSessionMessages[firstTemporary.id]).toEqual([{ kind: "followUp", text: "first prompt" }]);
startRequests[0]?.resolve(firstStarted);
await firstStart;
expect(promptCalls).toEqual([
{ sessionId: secondStarted.id, text: "second prompt" },
{ sessionId: firstStarted.id, text: "first prompt" },
]);
expect(state.selectedSession?.id).toBe(firstStarted.id);
expect(state.clientQueuedSessionMessages[firstStarted.id]).toBeUndefined();
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,170 @@
import { afterEach, beforeEach, vi } from "vitest";
import type { MessagePage, SessionInfo, SessionRef, SessionStatus, Workspace } from "../api";
import { machineSessionKey } from "../machineKeys";
import type { SessionUiEvent } from "../sessionSocket";
import type { SessionEventSocket } from "./sessionController";
export { api as defaultApi } from "../api";
export type { MessagePage, PromptAttachment, SessionActivity, SessionInfo, SessionRef, SessionStatus, Workspace } from "../api";
export type { AppState } from "../appState";
export class MemoryStorage implements Storage {
private readonly values = new Map<string, string>();
get length(): number {
return this.values.size;
}
clear(): void {
this.values.clear();
}
getItem(key: string): string | null {
return this.values.get(key) ?? null;
}
key(index: number): string | null {
return Array.from(this.values.keys())[index] ?? null;
}
removeItem(key: string): void {
this.values.delete(key);
}
setItem(key: string, value: string): void {
this.values.set(key, value);
}
}
export class FakeSocket implements SessionEventSocket {
readonly connectedSessionIds: string[] = [];
connect(session: SessionRef): void {
this.connectedSessionIds.push(session.id);
}
setHandler(): void {
// Test socket does not emit events.
}
close(): void {
// No-op.
}
}
export class EmitSocket implements SessionEventSocket {
readonly connectedSessionIds: string[] = [];
private handler: ((event: SessionUiEvent) => void) | undefined;
connect(session: SessionRef, onEvent: (event: SessionUiEvent) => void): void {
this.connectedSessionIds.push(session.id);
this.handler = onEvent;
}
setHandler(onEvent: (event: SessionUiEvent) => void): void {
this.handler = onEvent;
}
emit(event: SessionUiEvent): void {
this.handler?.(event);
}
close(): void {
this.handler = undefined;
}
}
export const workspace: Workspace = {
id: "workspace-1",
projectId: "project-1",
path: "/repo",
label: "repo",
isMain: true,
isGitRepo: true,
isGitWorktree: false,
};
export const oldSession: SessionInfo = {
id: "old-session",
path: "/tmp/old-session.jsonl",
cwd: "/repo",
created: "2026-05-15T00:00:00.000Z",
modified: "2026-05-15T00:00:00.000Z",
messageCount: 0,
firstMessage: "",
};
export const replacementSession: SessionInfo = {
...oldSession,
id: "new-session",
path: "/tmp/new-session.jsonl",
};
export const emptyPage: MessagePage = { messages: [], start: 0, total: 0 };
export interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
}
export function deferred<T>(): Deferred<T> {
let resolveDeferred: ((value: T) => void) | undefined;
let rejectDeferred: ((error: unknown) => void) | undefined;
const promise = new Promise<T>((resolve, reject) => {
resolveDeferred = resolve;
rejectDeferred = reject;
});
if (resolveDeferred === undefined || rejectDeferred === undefined) throw new Error("Deferred promise was not initialized");
return { promise, resolve: resolveDeferred, reject: rejectDeferred };
}
export function status(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,
};
}
const framesById = new Map<number, () => void>();
let nextFrameId = 1;
// The controller coalesces status/activity/transcript updates behind
// requestAnimationFrame. The node test environment has no rAF, so install a
// controllable one: callbacks are queued and only run when a test drives a
// frame, mirroring how the browser defers them until paint.
beforeEach(() => {
framesById.clear();
nextFrameId = 1;
vi.stubGlobal("requestAnimationFrame", (callback: () => void) => {
const id = nextFrameId++;
framesById.set(id, callback);
return id;
});
vi.stubGlobal("cancelAnimationFrame", (id: number) => { framesById.delete(id); });
});
afterEach(() => {
vi.unstubAllGlobals();
Object.defineProperty(globalThis, "localStorage", { value: undefined, configurable: true });
});
export function runPendingAnimationFrames(): void {
const frames = Array.from(framesById.values());
framesById.clear();
for (const frame of frames) frame();
}
export function sessionKey(sessionId: string): string {
return machineSessionKey("local", sessionId);
}
export function sessionLookupId(session: string | SessionRef): string {
return typeof session === "string" ? session : session.id;
}
+87 -1
View File
@@ -82,7 +82,46 @@ describe("effectivePromptAttachmentDelivery", () => {
});
});
describe("PromptEditor attachment chips", () => {
describe("PromptEditor attachment wiring", () => {
// Direct TemplateResult handler extraction keeps these node-environment tests focused on
// PromptEditor wiring without introducing a DOM/FileReader harness for the whole component.
it("captures pasted files, strips data URL prefixes, and surfaces read failures", async () => {
const editor = new PromptEditor();
const onSend = vi.fn<NonNullable<PromptEditor["onSend"]>>();
editor.onSend = onSend;
setPromptEditorPrivate(editor, "draft", "inspect attachments");
const restoreFileReader = installFileReaderStub([
{ kind: "load", result: "data:image/png;base64,UE5H" },
{ kind: "error", error: new DOMException("File unavailable", "NotReadableError") },
]);
try {
const paste = findTemplateEventHandlerAfterMarker<Event>(editor.render(), "@paste=");
const pasteEvent = pasteEventWithFiles([
new File(["png"], "shot.png", { type: "image/png" }),
new File(["pdf"], "report.pdf", { type: "application/pdf" }),
]);
const preventDefault = vi.spyOn(pasteEvent, "preventDefault");
paste(pasteEvent);
await flushMicrotasks();
expect(preventDefault).toHaveBeenCalledOnce();
expect(templateContainsValue(editor.render(), "Remove shot.png")).toBe(true);
expect(templateContainsValue(editor.render(), READ_FAILURE_MESSAGE)).toBe(true);
const send = findTemplateEventHandlerAfterMarker<Event>(editor.render(), "send-button");
send(new Event("click"));
expect(onSend).toHaveBeenCalledTimes(1);
expect(onSend).toHaveBeenCalledWith("inspect attachments", undefined, [
{ kind: "image", mimeType: "image/png", data: "UE5H", name: "shot.png" },
], "inline");
} finally {
restoreFileReader();
}
});
it("removes a pending attachment chip before sending the remaining attachments", () => {
const editor = new PromptEditor();
const onSend = vi.fn<NonNullable<PromptEditor["onSend"]>>();
@@ -111,10 +150,57 @@ describe("PromptEditor attachment chips", () => {
type TemplateEventHandler<E extends Event> = (event: E) => void;
type StubFileReaderOutcome =
| { kind: "load"; result: string }
| { kind: "error"; error: DOMException };
function setPromptEditorPrivate(editor: PromptEditor, property: string, value: unknown): void {
if (!Reflect.set(editor, property, value)) throw new Error(`Failed to set PromptEditor ${property}`);
}
function installFileReaderStub(outcomes: StubFileReaderOutcome[]): () => void {
const hadFileReader = Reflect.has(globalThis, "FileReader");
const previousFileReader = Reflect.get(globalThis, "FileReader");
class StubFileReader {
onerror: (() => void) | null = null;
onload: (() => void) | null = null;
error: DOMException | null = null;
result: string | ArrayBuffer | null = null;
readAsDataURL(): void {
const outcome = outcomes.shift();
if (outcome === undefined) throw new Error("Unexpected FileReader.readAsDataURL call");
if (outcome.kind === "error") {
this.error = outcome.error;
this.onerror?.();
return;
}
this.result = outcome.result;
this.onload?.();
}
}
Reflect.set(globalThis, "FileReader", StubFileReader);
return () => {
if (hadFileReader) {
Reflect.set(globalThis, "FileReader", previousFileReader);
return;
}
Reflect.deleteProperty(globalThis, "FileReader");
};
}
function pasteEventWithFiles(files: readonly File[]): Event {
const event = new Event("paste", { cancelable: true });
Object.defineProperty(event, "clipboardData", { value: { files } });
return event;
}
async function flushMicrotasks(): Promise<void> {
for (let remaining = 0; remaining < 10; remaining += 1) await Promise.resolve();
}
function findTemplateEventHandlerAfterMarker<E extends Event>(template: TemplateResult, marker: string): TemplateEventHandler<E> {
const handler = findOptionalTemplateEventHandlerAfterMarker<E>(template, marker);
if (handler === undefined) throw new Error(`Expected template event handler after marker ${marker}`);
+82
View File
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import type { Project, Workspace } from "./types.js";
import { appTestContext, registerAppTestHooks } from "./app.testSupport.js";
registerAppTestHooks();
describe("buildApp local machine aliases", () => {
it("serves local session and terminal proxy routes through machine-scoped aliases", async () => {
const sessionsResponse = await appTestContext.app.inject({ method: "GET", url: `/api/machines/local/sessions?cwd=${encodeURIComponent(appTestContext.projectDir)}` });
expect(sessionsResponse.statusCode).toBe(200);
expect(sessionsResponse.json()).toEqual({ method: "GET", path: `/sessions?cwd=${encodeURIComponent(appTestContext.projectDir)}` });
expect(appTestContext.sessionDaemonRequests).toEqual([{ method: "GET", path: `/sessions?cwd=${encodeURIComponent(appTestContext.projectDir)}` }]);
const addResponse = await appTestContext.app.inject({
method: "POST",
url: "/api/machines/local/projects",
payload: { name: "Machine Local", path: appTestContext.projectDir, create: true },
});
const project = addResponse.json<Project>();
const workspacesResponse = await appTestContext.app.inject({ method: "GET", url: `/api/machines/local/projects/${project.id}/workspaces` });
const workspace = workspacesResponse.json<Workspace[]>()[0];
if (workspace === undefined) throw new Error("Expected workspace");
const terminalResponse = await appTestContext.app.inject({
method: "POST",
url: `/api/machines/local/projects/${project.id}/workspaces/${workspace.id}/terminal-command-runs`,
payload: { origin: "core", title: "Build", command: "npm test", metadata: { "pi.operation": "test" } },
});
const closeTerminalsResponse = await appTestContext.app.inject({ method: "DELETE", url: `/api/machines/local/projects/${project.id}/workspaces/${workspace.id}/terminals` });
expect(terminalResponse.statusCode).toBe(200);
expect(terminalResponse.json()).toEqual({
method: "POST",
path: "/terminal-command-runs",
body: {
origin: "core",
projectId: project.id,
workspaceId: workspace.id,
cwd: appTestContext.projectDir,
title: "Build",
command: "npm test",
metadata: { "pi.operation": "test" },
},
});
expect(closeTerminalsResponse.statusCode).toBe(200);
expect(closeTerminalsResponse.json()).toEqual({ method: "DELETE", path: `/terminals?cwd=${encodeURIComponent(appTestContext.projectDir)}` });
expect(appTestContext.sessionDaemonRequests[1]).toEqual({
method: "POST",
path: "/terminal-command-runs",
body: {
origin: "core",
projectId: project.id,
workspaceId: workspace.id,
cwd: appTestContext.projectDir,
title: "Build",
command: "npm test",
metadata: { "pi.operation": "test" },
},
});
expect(appTestContext.sessionDaemonRequests[2]).toEqual({ method: "DELETE", path: `/terminals?cwd=${encodeURIComponent(appTestContext.projectDir)}` });
});
it("serves local projects and workspaces through machine-scoped aliases", async () => {
const addResponse = await appTestContext.app.inject({
method: "POST",
url: "/api/machines/local/projects",
payload: { name: "Machine Local", path: appTestContext.projectDir, create: true },
});
expect(addResponse.statusCode).toBe(200);
const project = addResponse.json<Project>();
const listResponse = await appTestContext.app.inject({ method: "GET", url: "/api/machines/local/projects" });
expect(listResponse.statusCode).toBe(200);
expect(listResponse.json<Project[]>()).toEqual([project]);
const workspacesResponse = await appTestContext.app.inject({ method: "GET", url: `/api/machines/local/projects/${project.id}/workspaces` });
expect(workspacesResponse.statusCode).toBe(200);
expect(workspacesResponse.json<Workspace[]>()).toEqual([expect.objectContaining({ projectId: project.id, path: appTestContext.projectDir })]);
});
});
+153
View File
@@ -0,0 +1,153 @@
import { describe, expect, it, vi } from "vitest";
import type { MachineClient } from "./machines/machineClient.js";
import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js";
import type { PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
import { appTestContext, configFromMachineConfigWriteBody, fakeRemoteClient, fullPiWebConfig, piWebConfigResponse, registerAppTestHooks, selectedMachinePiWebConfig } from "./app.testSupport.js";
registerAppTestHooks();
describe("buildApp machine routes", () => {
it("lists synthesized local machine through the HTTP contract", async () => {
const response = await appTestContext.app.inject({ method: "GET", url: "/api/machines" });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ machines: [{ id: "local", name: "Local", kind: "local", createdAt: "1970-01-01T00:00:00.000Z", updatedAt: "1970-01-01T00:00:00.000Z" }] });
});
it("adds remote machines without exposing tokens", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/", token: "secret" } });
expect(addResponse.statusCode).toBe(200);
expect(addResponse.json()).toMatchObject({ name: "Remote", kind: "remote", baseUrl: "https://remote.example.test" });
expect(addResponse.json()).not.toHaveProperty("token");
});
it("reports machine health for local and remote machines", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const requestJson: MachineClient["requestJson"] = () => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: {
packageName: "@jmfederico/pi-web",
generatedAt: "2026-05-25T00:00:00.000Z",
components: {
web: { component: "web", label: "Remote Web", stale: false, available: true },
sessiond: { component: "sessiond", label: "Remote Sessiond", stale: false, available: true },
},
release: { packageName: "@jmfederico/pi-web", updateAvailable: false },
commands: { update: "", restart: "", restartSystemd: "", restartDev: "" },
messages: [],
},
});
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
const localHealth = await appTestContext.app.inject({ method: "GET", url: "/api/machines/local/health" });
const remoteHealth = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/health` });
expect(localHealth.statusCode).toBe(200);
expect(localHealth.json()).toMatchObject({ machineId: "local", ok: true, status: "online" });
expect(remoteHealth.statusCode).toBe(200);
expect(remoteHealth.json()).toMatchObject({ machineId: remote.id, ok: true, status: "online" });
});
it("reports effective machine runtime capabilities for remote machines", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const requestJson = vi.fn<MachineClient["requestJson"]>(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: {
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, "future.capability"] },
sessiond: { component: "sessiond", label: "Remote Sessiond", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
},
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"],
},
}));
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
const runtime = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/runtime` });
expect(runtime.statusCode).toBe(200);
expect(runtime.json()).toMatchObject({ machineId: remote.id, ok: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage] });
expect(requestJson).toHaveBeenCalledWith("GET", "/api/pi-web/runtime", undefined, { timeoutMs: 3000 });
});
it("filters remote selected-machine config reads to machine-safe keys", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const requestJson = vi.fn<MachineClient["requestJson"]>(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json", "set-cookie": "secret=1" },
body: piWebConfigResponse(fullPiWebConfig()),
}));
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
const response = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/config` });
expect(response.statusCode).toBe(200);
expect(response.headers["set-cookie"]).toBeUndefined();
expect(response.json<PiWebConfigResponse>()).toEqual({
...piWebConfigResponse(fullPiWebConfig()),
config: selectedMachinePiWebConfig(),
effectiveConfig: selectedMachinePiWebConfig(),
});
expect(requestJson).toHaveBeenCalledWith("GET", "/api/config");
});
it("merges remote selected-machine config updates into the target machine config", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const requestJson = vi.fn<MachineClient["requestJson"]>((method, _path, body) => {
if (method === "GET") return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(fullPiWebConfig()) });
return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(configFromMachineConfigWriteBody(body)) });
});
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
const response = await appTestContext.app.inject({
method: "PUT",
url: `/api/machines/${remote.id}/config`,
payload: { config: { plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/srv/remote"] }, uploads: { defaultFolder: "remote\\uploads" }, maxUploadBytes: 4096, spawnSessions: true } },
});
const expectedMerged: PiWebConfigValues = {
...fullPiWebConfig(),
plugins: { info: { enabled: false } },
pathAccess: { allowedPaths: ["/srv/remote"] },
uploads: { defaultFolder: "remote/uploads" },
maxUploadBytes: 4096,
spawnSessions: true,
};
expect(response.statusCode).toBe(200);
expect(requestJson).toHaveBeenNthCalledWith(1, "GET", "/api/config");
expect(requestJson).toHaveBeenNthCalledWith(2, "PUT", "/api/config", { config: expectedMerged });
expect(response.json<PiWebConfigResponse>().config).toEqual({
plugins: { info: { enabled: false } },
pathAccess: { allowedPaths: ["/srv/remote"] },
uploads: { defaultFolder: "remote/uploads" },
maxUploadBytes: 4096,
spawnSessions: true,
subsessions: false,
});
});
it("rejects unsafe remote selected-machine config keys before proxying", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const requestJson = vi.fn<MachineClient["requestJson"]>();
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
const response = await appTestContext.app.inject({
method: "PUT",
url: `/api/machines/${remote.id}/config`,
payload: { config: { host: "0.0.0.0", allowedHosts: true, shortcuts: { "core:view.chat": "mod+1" }, spawnSessions: true } },
});
expect(response.statusCode).toBe(400);
expect(response.json<{ error: string }>().error).toContain("PI WEB selected-machine config key is not allowed: host");
expect(requestJson).not.toHaveBeenCalled();
});
});
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { appTestContext, registerAppTestHooks } from "./app.testSupport.js";
registerAppTestHooks();
describe("buildApp Pi package routes", () => {
it("serves Pi package management routes through the app wiring", async () => {
const listResponse = await appTestContext.app.inject({ method: "GET", url: "/api/pi-packages" });
expect(listResponse.statusCode).toBe(200);
expect(listResponse.json()).toEqual({ packages: [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/tmp/pi-tools" }] });
const installResponse = await appTestContext.app.inject({ method: "POST", url: "/api/pi-packages/install", payload: { source: "npm:@acme/new-tools" } });
expect(installResponse.statusCode).toBe(200);
expect(installResponse.json()).toMatchObject({ action: "install", source: "npm:@acme/new-tools" });
const localAliasResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines/local/pi-packages/remove", payload: { source: "npm:@acme/tools", scope: "user" } });
expect(localAliasResponse.statusCode).toBe(200);
expect(localAliasResponse.json()).toMatchObject({ action: "remove", source: "npm:@acme/tools", scope: "user" });
expect(appTestContext.piPackageRequests).toEqual([
{ action: "list" },
{ action: "install", source: "npm:@acme/new-tools" },
{ action: "remove", source: "npm:@acme/tools", scope: "user" },
]);
});
});
+118
View File
@@ -0,0 +1,118 @@
import { Readable } from "node:stream";
import { describe, expect, it, vi } from "vitest";
import { machineScopedPluginId } from "../shared/machinePluginIds.js";
import { appTestContext, fakeRemoteClient, registerAppTestHooks } from "./app.testSupport.js";
registerAppTestHooks();
describe("buildApp PI WEB plugin routes", () => {
it("serves the PI WEB plugin manifest and plugin assets", async () => {
const manifestResponse = await appTestContext.app.inject({ method: "GET", url: "/pi-web-plugins/manifest.json" });
expect(manifestResponse.statusCode).toBe(200);
expect(manifestResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] });
const pluginsResponse = await appTestContext.app.inject({ method: "GET", url: "/api/plugins" });
expect(pluginsResponse.statusCode).toBe(200);
expect(pluginsResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] });
const localMachinePluginsResponse = await appTestContext.app.inject({ method: "GET", url: "/api/machines/local/plugins" });
expect(localMachinePluginsResponse.statusCode).toBe(200);
expect(localMachinePluginsResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] });
const assetResponse = await appTestContext.app.inject({ method: "GET", url: "/pi-web-plugins/fake/plugin.js?v=1" });
expect(assetResponse.statusCode).toBe(200);
expect(assetResponse.headers["content-type"]).toContain("application/javascript");
expect(assetResponse.body).toBe("export default {};");
const missingResponse = await appTestContext.app.inject({ method: "GET", url: "/pi-web-plugins/fake/missing.js" });
expect(missingResponse.statusCode).toBe(404);
});
it("proxies remote machine plugin lists for settings", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const request = vi.fn(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json", "set-cookie": "secret=1" },
body: Readable.from([JSON.stringify({ plugins: [{ id: "remote-tools", module: "/pi-web-plugins/remote-tools/plugin.js", source: "local", scope: "local", machineSpecific: false, enabled: false }] })]),
}));
appTestContext.remoteClient = fakeRemoteClient({ request });
const response = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/plugins` });
expect(response.statusCode).toBe(200);
expect(response.headers["set-cookie"]).toBeUndefined();
expect(response.json()).toEqual({ plugins: [{ id: "remote-tools", module: "/pi-web-plugins/remote-tools/plugin.js", source: "local", scope: "local", machineSpecific: false, enabled: false }] });
expect(request).toHaveBeenCalledWith("GET", "/api/plugins", undefined);
});
it("rewrites and proxies remote machine plugin manifests and assets", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const requestJson = vi.fn(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: { plugins: [{ id: "remote-tools", module: "/pi-web-plugins/remote-tools/pi-web-plugin.js?v=123", source: "local", scope: "local", machineSpecific: true }] },
}));
const request = vi.fn(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/javascript", "set-cookie": "secret=1" },
body: Readable.from(["export default {};"]),
}));
appTestContext.remoteClient = fakeRemoteClient({ requestJson, request });
const manifestResponse = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/pi-web-plugins/manifest.json` });
const scopedPluginId = machineScopedPluginId(remote.id, "remote-tools");
expect(manifestResponse.statusCode).toBe(200);
expect(manifestResponse.json()).toEqual({
plugins: [{ id: "remote-tools", module: `/pi-web-plugins/${scopedPluginId}/pi-web-plugin.js?v=123`, source: "local", scope: "local", machineSpecific: true }],
});
expect(requestJson).toHaveBeenCalledWith("GET", "/pi-web-plugins/manifest.json", undefined, { timeoutMs: 10000 });
const assetResponse = await appTestContext.app.inject({ method: "GET", url: `/pi-web-plugins/${scopedPluginId}/pi-web-plugin.js?v=123` });
expect(assetResponse.statusCode).toBe(200);
expect(assetResponse.headers["content-type"]).toContain("application/javascript");
expect(assetResponse.headers["set-cookie"]).toBeUndefined();
expect(assetResponse.body).toBe("export default {};");
expect(request).toHaveBeenCalledWith("GET", "/pi-web-plugins/remote-tools/pi-web-plugin.js?v=123");
});
it("drops unsafe remote machine plugin manifest modules", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
appTestContext.remoteClient = fakeRemoteClient({
requestJson: vi.fn(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: {
plugins: [
{ id: "safe-tools", module: "nested/pi-web-plugin.js?v=1", source: "local", scope: "local" },
{ id: "traversal-tools", module: "..%2F..%2Fapi%2Fconfig", source: "local", scope: "local" },
{ id: "wrong-root", module: "/pi-web-plugins/other/pi-web-plugin.js", source: "local", scope: "local" },
],
},
})),
});
const manifestResponse = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/pi-web-plugins/manifest.json` });
expect(manifestResponse.statusCode).toBe(200);
expect(manifestResponse.json()).toEqual({
plugins: [{ id: "safe-tools", module: `/pi-web-plugins/${machineScopedPluginId(remote.id, "safe-tools")}/nested/pi-web-plugin.js?v=1`, source: "local", scope: "local" }],
});
});
it("rejects remote machine plugin asset traversal before proxying", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const request = vi.fn(() => Promise.resolve({ statusCode: 200, headers: {}, body: Readable.from([]) }));
appTestContext.remoteClient = fakeRemoteClient({ request });
const scopedPluginId = machineScopedPluginId(remote.id, "remote-tools");
const response = await appTestContext.app.inject({ method: "GET", url: `/pi-web-plugins/${scopedPluginId}/..%2F..%2Fapi%2Fconfig` });
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "Invalid remote PI WEB plugin asset path" });
expect(request).not.toHaveBeenCalled();
});
});
+112
View File
@@ -0,0 +1,112 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import type { Project, Workspace } from "./types.js";
import { appTestContext, registerAppTestHooks } from "./app.testSupport.js";
registerAppTestHooks();
describe("buildApp project routes", () => {
it("adds, lists, and closes projects through the HTTP contract", async () => {
const addResponse = await appTestContext.app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "Example", path: appTestContext.projectDir, create: true },
});
expect(addResponse.statusCode).toBe(200);
const project = addResponse.json<Project>();
expect(project).toMatchObject({ name: "Example", path: appTestContext.projectDir });
expect(project.id).not.toBe("");
const listResponse = await appTestContext.app.inject({ method: "GET", url: "/api/projects" });
expect(listResponse.statusCode).toBe(200);
expect(listResponse.json<Project[]>()).toEqual([project]);
const closeResponse = await appTestContext.app.inject({ method: "DELETE", url: `/api/projects/${project.id}` });
expect(closeResponse.statusCode).toBe(200);
expect(closeResponse.json()).toEqual({ closed: true });
const emptyListResponse = await appTestContext.app.inject({ method: "GET", url: "/api/projects" });
expect(emptyListResponse.json<Project[]>()).toEqual([]);
});
it("returns stable errors for invalid project requests", async () => {
const addResponse = await appTestContext.app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "Missing", path: join(appTestContext.tempDir, "missing") },
});
expect(addResponse.statusCode).toBe(400);
expect(addResponse.json()).toHaveProperty("error");
const closeResponse = await appTestContext.app.inject({ method: "DELETE", url: "/api/projects/does-not-exist" });
expect(closeResponse.statusCode).toBe(404);
expect(closeResponse.json()).toEqual({ error: "Project not found" });
});
it("lists a non-git project as a single workspace", async () => {
const addResponse = await appTestContext.app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "Plain", path: appTestContext.projectDir, create: true },
});
const project = addResponse.json<Project>();
const workspacesResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
expect(workspacesResponse.statusCode).toBe(200);
expect(workspacesResponse.json<Workspace[]>()).toEqual([
expect.objectContaining({
projectId: project.id,
path: appTestContext.projectDir,
label: "Plain",
isMain: true,
isGitRepo: false,
isGitWorktree: false,
}),
]);
});
it("exposes the default upload config on workspace responses", async () => {
const addResponse = await appTestContext.app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "Upload Defaults", path: appTestContext.projectDir, create: true },
});
const project = addResponse.json<Project>();
const workspacesResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
expect(workspacesResponse.statusCode).toBe(200);
expect(workspacesResponse.json<Workspace[]>()).toEqual([
expect.objectContaining({
projectId: project.id,
effectiveConfig: { uploads: { defaultFolder: ".pi-web/uploads" } },
}),
]);
});
it("lets project-local upload config override global upload config on workspace responses", async () => {
appTestContext.piWebConfig = { uploads: { defaultFolder: "global-uploads" } };
const addResponse = await appTestContext.app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "Project Upload Defaults", path: appTestContext.projectDir, create: true },
});
const project = addResponse.json<Project>();
await mkdir(join(appTestContext.projectDir, ".pi-web"), { recursive: true });
await writeFile(join(appTestContext.projectDir, ".pi-web", "config.json"), `${JSON.stringify({ version: 1, uploads: { defaultFolder: "project-uploads" } }, null, 2)}\n`);
const workspacesResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
expect(workspacesResponse.statusCode).toBe(200);
expect(workspacesResponse.json<Workspace[]>()).toEqual([
expect.objectContaining({
projectId: project.id,
effectiveConfig: { uploads: { defaultFolder: "project-uploads" } },
}),
]);
});
});
+184
View File
@@ -0,0 +1,184 @@
import { Readable } from "node:stream";
import { describe, expect, it, vi } from "vitest";
import { RemoteMachineRequestError, type MachineClient } from "./machines/machineClient.js";
import { PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS } from "../shared/federatedRoutes.js";
import { appTestContext, fakeRemoteClient, registerAppTestHooks } from "./app.testSupport.js";
registerAppTestHooks();
describe("buildApp remote machine proxy routes", () => {
it("proxies allowlisted remote HTTP routes through the selected machine", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const request = vi.fn(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json", connection: "close" },
body: Readable.from([JSON.stringify([{ id: "p1", name: "Remote Project", path: "/repo", createdAt: "now" }])]),
}));
appTestContext.remoteClient = fakeRemoteClient({ request });
const response = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/projects?active=true` });
expect(response.statusCode).toBe(200);
expect(response.headers["content-type"]).toContain("application/json");
expect(response.json()).toEqual([{ id: "p1", name: "Remote Project", path: "/repo", createdAt: "now" }]);
expect(request).toHaveBeenCalledWith("GET", "/api/projects?active=true", undefined);
});
it("proxies remote Pi package routes and gives package mutations a longer timeout", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const request = vi.fn<MachineClient["request"]>((method, path, body) => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: Readable.from([JSON.stringify({ method, path, body })]),
}));
appTestContext.remoteClient = fakeRemoteClient({ request });
const listResponse = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/pi-packages` });
const installBody = { source: "npm:@acme/new-tools" };
const installResponse = await appTestContext.app.inject({ method: "POST", url: `/api/machines/${remote.id}/pi-packages/install`, payload: installBody });
expect(listResponse.statusCode).toBe(200);
expect(listResponse.json()).toEqual({ method: "GET", path: "/api/pi-packages" });
expect(installResponse.statusCode).toBe(200);
expect(installResponse.json()).toEqual({ method: "POST", path: "/api/pi-packages/install", body: installBody });
expect(request).toHaveBeenNthCalledWith(1, "GET", "/api/pi-packages", undefined);
expect(request).toHaveBeenNthCalledWith(2, "POST", "/api/pi-packages/install", installBody, { timeoutMs: PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS });
});
it("proxies remote workspace effective upload config through the existing federated workspace route", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const remoteWorkspaces = [{
id: "w1",
projectId: "p1",
path: "/repo",
label: "main",
isMain: true,
isGitRepo: false,
isGitWorktree: false,
effectiveConfig: { uploads: { defaultFolder: "remote-project-uploads" } },
}];
const request = vi.fn(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: Readable.from([JSON.stringify(remoteWorkspaces)]),
}));
appTestContext.remoteClient = fakeRemoteClient({ request });
const response = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/projects/p1/workspaces` });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual(remoteWorkspaces);
expect(request).toHaveBeenCalledWith("GET", "/api/projects/p1/workspaces", undefined);
});
it("preserves remote file preview security headers while proxying safe response metadata", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const request = vi.fn(() => Promise.resolve({
statusCode: 200,
headers: {
"content-type": "image/svg+xml",
"content-security-policy": "sandbox; default-src 'none'; img-src 'self' data: blob:; style-src 'unsafe-inline'",
"x-content-type-options": "nosniff",
"set-cookie": "session=secret",
},
body: Readable.from(["<svg xmlns=\"http://www.w3.org/2000/svg\" />"]),
}));
appTestContext.remoteClient = fakeRemoteClient({ request });
const response = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/projects/p1/workspaces/w1/file/preview?path=${encodeURIComponent("diagram.svg")}` });
expect(response.statusCode).toBe(200);
expect(response.headers["content-type"]).toContain("image/svg+xml");
expect(response.headers["content-security-policy"]).toContain("sandbox");
expect(response.headers["x-content-type-options"]).toBe("nosniff");
expect(response.headers["set-cookie"]).toBeUndefined();
expect(response.body).toBe("<svg xmlns=\"http://www.w3.org/2000/svg\" />");
expect(request).toHaveBeenCalledWith("GET", "/api/projects/p1/workspaces/w1/file/preview?path=diagram.svg", undefined);
});
it("proxies remote workspace file writes as raw request bodies", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const payload = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
const request = vi.fn(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: Readable.from([JSON.stringify({ path: "image.png", size: payload.length, modifiedAt: "now", created: true })]),
}));
appTestContext.remoteClient = fakeRemoteClient({ request });
const response = await appTestContext.app.inject({
method: "PUT",
url: `/api/machines/${remote.id}/projects/p1/workspaces/w1/file?path=${encodeURIComponent("image.png")}`,
payload,
headers: { "content-type": "application/octet-stream" },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ path: "image.png", size: payload.length, modifiedAt: "now", created: true });
expect(request).toHaveBeenCalledWith("PUT", "/api/projects/p1/workspaces/w1/file?path=image.png", payload, { contentType: "application/octet-stream" });
});
it("proxies remote terminal command-run and continue routes", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const request = vi.fn((method: string, path: string) => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: Readable.from([JSON.stringify({ method, path })]),
}));
appTestContext.remoteClient = fakeRemoteClient({ request });
const createBody = { origin: "core", title: "Build", command: "npm test", metadata: { "pi.operation": "test" } };
const deleteWorkspaceResponse = await appTestContext.app.inject({ method: "DELETE", url: `/api/machines/${remote.id}/projects/p1/workspaces/w1` });
const createResponse = await appTestContext.app.inject({ method: "POST", url: `/api/machines/${remote.id}/projects/p1/workspaces/w1/terminal-command-runs`, payload: createBody });
const listResponse = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/terminal-command-runs?projectId=p1&statuses=running` });
const getResponse = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/terminal-command-runs/run1` });
const cancelResponse = await appTestContext.app.inject({ method: "POST", url: `/api/machines/${remote.id}/terminal-command-runs/run1/cancel` });
const closeWorkspaceTerminalsResponse = await appTestContext.app.inject({ method: "DELETE", url: `/api/machines/${remote.id}/projects/p1/workspaces/w1/terminals` });
const continueResponse = await appTestContext.app.inject({ method: "POST", url: `/api/machines/${remote.id}/projects/p1/workspaces/w1/terminals/t1/continue` });
expect(deleteWorkspaceResponse.json()).toEqual({ method: "DELETE", path: "/api/projects/p1/workspaces/w1" });
expect(createResponse.json()).toEqual({ method: "POST", path: "/api/projects/p1/workspaces/w1/terminal-command-runs" });
expect(listResponse.json()).toEqual({ method: "GET", path: "/api/terminal-command-runs?projectId=p1&statuses=running" });
expect(getResponse.json()).toEqual({ method: "GET", path: "/api/terminal-command-runs/run1" });
expect(cancelResponse.json()).toEqual({ method: "POST", path: "/api/terminal-command-runs/run1/cancel" });
expect(closeWorkspaceTerminalsResponse.json()).toEqual({ method: "DELETE", path: "/api/projects/p1/workspaces/w1/terminals" });
expect(continueResponse.json()).toEqual({ method: "POST", path: "/api/projects/p1/workspaces/w1/terminals/t1/continue" });
expect(request).toHaveBeenCalledWith("POST", "/api/projects/p1/workspaces/w1/terminal-command-runs", createBody);
});
it("proxies remote session reloads through the selected machine", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const request = vi.fn(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: Readable.from([JSON.stringify({ reloaded: true })]),
}));
appTestContext.remoteClient = fakeRemoteClient({ request });
const response = await appTestContext.app.inject({ method: "POST", url: `/api/machines/${remote.id}/sessions/s1/reload`, payload: { cwd: "/repo" } });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ reloaded: true });
expect(request).toHaveBeenCalledWith("POST", "/api/sessions/s1/reload", { cwd: "/repo" });
});
it("forwards remote JSON request bodies and normalizes remote timeouts", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const request = vi.fn(() => Promise.reject(new RemoteMachineRequestError("timed out", 504)));
appTestContext.remoteClient = fakeRemoteClient({ request });
const response = await appTestContext.app.inject({ method: "POST", url: `/api/machines/${remote.id}/sessions/s1/prompt`, payload: { text: "hello" } });
expect(response.statusCode).toBe(504);
expect(response.json()).toMatchObject({ error: "Remote machine timeout", machineId: remote.id, statusCode: 504 });
expect(request).toHaveBeenCalledWith("POST", "/api/sessions/s1/prompt", { text: "hello" });
});
});
File diff suppressed because it is too large Load Diff
+246
View File
@@ -0,0 +1,246 @@
import { mkdtemp, realpath, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { Readable } from "node:stream";
import type { FastifyInstance } from "fastify";
import { afterEach, beforeEach } from "vitest";
import { buildApp } from "./app.js";
import { ProjectService } from "./projects/projectService.js";
import { ProjectStore } from "./storage/projectStore.js";
import type { MachineClient } from "./machines/machineClient.js";
import { MachineService } from "./machines/machineService.js";
import { MachineStore } from "./machines/machineStore.js";
import { WorkspaceService } from "./workspaces/workspaceService.js";
import type { PiPackageService } from "./piPackageService.js";
import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js";
import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js";
import type { PiPackageInfo, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
interface AppTestContext {
readonly app: FastifyInstance;
readonly tempDir: string;
readonly projectDir: string;
remoteClient: MachineClient | undefined;
readonly sessionDaemonRequests: CapturedSessionDaemonRequest[];
readonly piPackageRequests: CapturedPiPackageRequest[];
piWebConfig: PiWebConfigValues;
}
let app: FastifyInstance | undefined;
let tempDir: string | undefined;
let projectDir: string | undefined;
let remoteClient: MachineClient | undefined;
let sessionDaemonRequests: CapturedSessionDaemonRequest[] = [];
let piPackageRequests: CapturedPiPackageRequest[] = [];
let piWebConfig: PiWebConfigValues = {};
export const appTestContext: AppTestContext = {
get app() {
if (app === undefined) throw new Error("App test harness was not initialized");
return app;
},
get tempDir() {
if (tempDir === undefined) throw new Error("App test tempDir was not initialized");
return tempDir;
},
get projectDir() {
if (projectDir === undefined) throw new Error("App test projectDir was not initialized");
return projectDir;
},
get remoteClient() {
return remoteClient;
},
set remoteClient(client) {
remoteClient = client;
},
get sessionDaemonRequests() {
return sessionDaemonRequests;
},
get piPackageRequests() {
return piPackageRequests;
},
get piWebConfig() {
return piWebConfig;
},
set piWebConfig(config) {
piWebConfig = config;
},
};
export function registerAppTestHooks(): void {
beforeEach(async () => {
tempDir = await realpath(await mkdtemp(join(tmpdir(), "pi-web-app-test-")));
projectDir = join(tempDir, "project");
remoteClient = undefined;
sessionDaemonRequests = [];
piPackageRequests = [];
piWebConfig = {};
app = await buildApp({
projects: new ProjectService(new ProjectStore(join(tempDir, "projects.json"))),
workspaces: new WorkspaceService(),
machines: new MachineService(new MachineStore(join(tempDir, "machines.json")), {
remoteClientFactory: () => {
if (remoteClient === undefined) throw new Error("No remote machine client configured");
return remoteClient;
},
now: () => new Date("2026-05-25T00:00:00.000Z"),
localRuntime: () => Promise.resolve({
packageName: "@jmfederico/pi-web",
generatedAt: "2026-05-25T00:00:00.000Z",
components: {
web: { component: "web", label: "PI WEB", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
sessiond: { component: "sessiond", label: "PI WEB Session Daemon", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
},
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived],
}),
}),
sessionDaemon: fakeSessionDaemon(),
config: fakeConfigService(),
piPackages: fakePiPackageService(),
piWebPlugins: {
manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] }),
plugins: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }),
readAsset: (pluginId, assetPath) => Promise.resolve(pluginId === "fake" && assetPath === "plugin.js" ? { content: Buffer.from("export default {};"), contentType: "application/javascript; charset=utf-8" } : undefined),
},
clientDist: false,
logger: false,
});
});
afterEach(async () => {
const appToClose = app;
const tempDirToRemove = tempDir;
app = undefined;
tempDir = undefined;
projectDir = undefined;
remoteClient = undefined;
sessionDaemonRequests = [];
piPackageRequests = [];
piWebConfig = {};
if (appToClose !== undefined) await appToClose.close();
if (tempDirToRemove !== undefined) await rm(tempDirToRemove, { recursive: true, force: true });
});
}
export interface CapturedSessionDaemonRequest {
method: string;
path: string;
body?: unknown;
}
interface CapturedPiPackageRequest {
action: "list" | "install" | "remove" | "update";
source?: string;
scope?: "user" | "project";
}
function fakeConfigService() {
return {
read: () => piWebConfigResponse(piWebConfig),
write: (config: PiWebConfigValues) => {
piWebConfig = config;
return piWebConfigResponse(config);
},
};
}
export function fullPiWebConfig(): PiWebConfigValues {
return {
host: "127.0.0.1",
port: 8504,
allowedHosts: ["gateway.example.test"],
shortcuts: { "core:view.chat": "mod+1" },
plugins: { info: { enabled: true, settings: { note: "remote" } } },
pathAccess: { allowedPaths: ["/srv/repos"] },
uploads: { defaultFolder: "uploads" },
maxUploadBytes: 1024,
spawnSessions: false,
subsessions: false,
};
}
export function selectedMachinePiWebConfig(): PiWebConfigValues {
return {
plugins: { info: { enabled: true, settings: { note: "remote" } } },
pathAccess: { allowedPaths: ["/srv/repos"] },
uploads: { defaultFolder: "uploads" },
maxUploadBytes: 1024,
spawnSessions: false,
subsessions: false,
};
}
export function piWebConfigResponse(config: PiWebConfigValues): PiWebConfigResponse {
return {
path: join(appTestContext.tempDir, "config.json"),
exists: false,
config,
effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
};
}
interface MachineConfigWriteBody {
config: PiWebConfigValues;
}
export function configFromMachineConfigWriteBody(body: unknown): PiWebConfigValues {
if (!isMachineConfigWriteBody(body)) throw new Error("Expected machine config write body");
return body.config;
}
function isMachineConfigWriteBody(value: unknown): value is MachineConfigWriteBody {
if (!isRecord(value)) return false;
return isRecord(value["config"]);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function fakePiPackageService(): PiPackageService {
const packages: PiPackageInfo[] = [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/tmp/pi-tools" }];
return {
list: () => {
piPackageRequests.push({ action: "list" });
return Promise.resolve({ packages });
},
install: (source) => {
piPackageRequests.push({ action: "install", source });
return Promise.resolve({ action: "install", source, packages });
},
remove: (source, scope = "user") => {
piPackageRequests.push({ action: "remove", source, scope });
return Promise.resolve({ action: "remove", source, scope, removed: true, packages });
},
update: (source) => {
piPackageRequests.push({ action: "update", ...(source === undefined ? {} : { source }) });
return Promise.resolve({ action: "update", ...(source === undefined ? {} : { source }), packages });
},
};
}
function fakeSessionDaemon(): SessionProxyDaemon {
return {
request: (method, path, body) => {
const captured = { method, path, ...(body === undefined ? {} : { body }) } satisfies CapturedSessionDaemonRequest;
sessionDaemonRequests.push(captured);
return Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify(captured),
});
},
connectWebSocket: () => { throw new Error("WebSocket not configured for test"); },
};
}
export function fakeRemoteClient(overrides: Partial<MachineClient>): MachineClient {
return {
request: () => Promise.resolve({ statusCode: 200, headers: {}, body: Readable.from([]) }),
requestJson: () => Promise.resolve({ statusCode: 200, headers: {}, body: undefined }),
connectWebSocket: () => { throw new Error("WebSocket not configured for test"); },
...overrides,
};
}
+332
View File
@@ -0,0 +1,332 @@
import { mkdir, truncate, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { MAX_IMAGE_PREVIEW_BYTES } from "../shared/workspaceFiles.js";
import type { Project, Workspace } from "./types.js";
import { appTestContext, registerAppTestHooks } from "./app.testSupport.js";
registerAppTestHooks();
describe("buildApp workspace file routes", () => {
it("serves supported workspace images as previews", async () => {
const addResponse = await appTestContext.app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "Images", path: appTestContext.projectDir, create: true },
});
const project = addResponse.json<Project>();
const svg = "<svg xmlns=\"http://www.w3.org/2000/svg\"><rect width=\"1\" height=\"1\" /></svg>";
await writeFile(join(appTestContext.projectDir, "diagram.svg"), svg);
await writeFile(join(appTestContext.projectDir, "note.txt"), "hello");
await writeFile(join(appTestContext.projectDir, "huge.png"), "");
await truncate(join(appTestContext.projectDir, "huge.png"), MAX_IMAGE_PREVIEW_BYTES + 1);
const workspacesResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
const workspace = workspacesResponse.json<Workspace[]>()[0];
if (workspace === undefined) throw new Error("Expected workspace");
const previewResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/preview?path=${encodeURIComponent("diagram.svg")}` });
expect(previewResponse.statusCode).toBe(200);
expect(previewResponse.headers["content-type"]).toContain("image/svg+xml");
expect(previewResponse.headers["cache-control"]).toBe("private, max-age=3600");
expect(previewResponse.headers["content-security-policy"]).toContain("sandbox");
expect(previewResponse.headers["x-content-type-options"]).toBe("nosniff");
expect(previewResponse.body).toBe(svg);
const rejectedResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/preview?path=${encodeURIComponent("note.txt")}` });
expect(rejectedResponse.statusCode).toBe(400);
expect(rejectedResponse.json()).toEqual({ error: "Image preview is not supported for this file type" });
const tooLargeResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/preview?path=${encodeURIComponent("huge.png")}` });
expect(tooLargeResponse.statusCode).toBe(400);
expect(tooLargeResponse.json()).toEqual({ error: "Image is too large to preview (limit 10 MB)" });
});
it("keeps normal file suggestions workspace-local when path access config is invalid", async () => {
const addResponse = await appTestContext.app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "Local Suggestions", path: appTestContext.projectDir, create: true },
});
expect(addResponse.statusCode).toBe(200);
await writeFile(join(appTestContext.projectDir, "sdk.md"), "local sdk\n");
await mkdir(join(appTestContext.projectDir, ".pi-web"), { recursive: true });
await writeFile(join(appTestContext.projectDir, ".pi-web", "config.json"), `${JSON.stringify({ version: 1, pathAccess: { allowedPaths: [""] } }, null, 2)}\n`);
const response = await appTestContext.app.inject({ method: "GET", url: `/api/files?cwd=${encodeURIComponent(appTestContext.projectDir)}&q=sdk&scope=all` });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual([{ path: "sdk.md", kind: "other" }]);
});
it("serves project-configured allowed external files through the workspace explorer", async () => {
const addResponse = await appTestContext.app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "External", path: appTestContext.projectDir, create: true },
});
const project = addResponse.json<Project>();
const externalDir = join(appTestContext.tempDir, "external-docs");
const deniedFile = join(appTestContext.tempDir, "secret.md");
await mkdir(externalDir);
await writeFile(join(externalDir, "sdk.md"), "external sdk\n");
await writeFile(deniedFile, "secret\n");
await mkdir(join(appTestContext.projectDir, ".pi-web"), { recursive: true });
await writeFile(join(appTestContext.projectDir, ".pi-web", "config.json"), `${JSON.stringify({ version: 1, pathAccess: { allowedPaths: [externalDir] } }, null, 2)}\n`);
const workspacesResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
const workspace = workspacesResponse.json<Workspace[]>()[0];
if (workspace === undefined) throw new Error("Expected workspace");
const fileResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent(join(externalDir, "sdk.md"))}` });
const treeResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/tree?path=${encodeURIComponent(externalDir)}` });
const suggestionResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/files?q=${encodeURIComponent(join(externalDir, "s"))}` });
const localSuggestionResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/files?q=sdk` });
const deniedResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent(deniedFile)}` });
expect(fileResponse.statusCode).toBe(200);
expect(fileResponse.json()).toMatchObject({ path: join(externalDir, "sdk.md"), content: "external sdk\n", binary: false });
expect(treeResponse.statusCode).toBe(200);
expect(treeResponse.json()).toMatchObject({
path: externalDir,
entries: [expect.objectContaining({ name: "sdk.md", path: join(externalDir, "sdk.md"), type: "file" })],
truncated: false,
});
expect(suggestionResponse.statusCode).toBe(200);
expect(suggestionResponse.json()).toEqual([{ path: join(externalDir, "sdk.md"), kind: "other" }]);
expect(localSuggestionResponse.statusCode).toBe(200);
expect(localSuggestionResponse.json()).toEqual([]);
expect(deniedResponse.statusCode).toBe(400);
expect(deniedResponse.json()).toEqual({ error: "Path is outside allowed paths" });
});
it("writes workspace files through the HTTP contract", async () => {
const addResponse = await appTestContext.app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "WriteTest", path: appTestContext.projectDir, create: true },
});
const project = addResponse.json<Project>();
const workspacesResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
const workspace = workspacesResponse.json<Workspace[]>()[0];
if (workspace === undefined) throw new Error("Expected workspace");
const writeTextResponse = await appTestContext.app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}`,
payload: "hello world",
headers: { "content-type": "text/plain" },
});
expect(writeTextResponse.statusCode).toBe(200);
expect(writeTextResponse.json()).toMatchObject({ path: "hello.txt", created: true });
expect(typeof writeTextResponse.json<{ size: unknown }>().size).toBe("number");
const readResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}` });
expect(readResponse.json<{ content: unknown }>().content).toBe("hello world");
const writeBinaryResponse = await appTestContext.app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("image.png")}`,
payload: Buffer.from([0x89, 0x50, 0x4e, 0x47]),
headers: { "content-type": "application/octet-stream" },
});
expect(writeBinaryResponse.statusCode).toBe(200);
expect(writeBinaryResponse.json()).toMatchObject({ path: "image.png", created: true });
const writeDeepResponse = await appTestContext.app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("deep/nested/dir/file.txt")}`,
payload: "deep content",
headers: { "content-type": "text/plain" },
});
expect(writeDeepResponse.statusCode).toBe(200);
const readDeepResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("deep/nested/dir/file.txt")}` });
expect(readDeepResponse.json<{ content: unknown }>().content).toBe("deep content");
const overwriteResponse = await appTestContext.app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}`,
payload: "updated",
headers: { "content-type": "text/plain" },
});
expect(overwriteResponse.json()).toMatchObject({ path: "hello.txt", created: false });
const noOverwriteResponse = await appTestContext.app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}&overwrite=false`,
payload: "should fail",
headers: { "content-type": "text/plain" },
});
expect(noOverwriteResponse.statusCode).toBe(400);
expect(noOverwriteResponse.json<{ error: string }>().error).toContain("File already exists");
const traversalResponse = await appTestContext.app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("../../etc/passwd")}`,
payload: "evil",
headers: { "content-type": "text/plain" },
});
expect(traversalResponse.statusCode).toBe(400);
expect(traversalResponse.json<{ error: string }>().error).toContain("Path traversal");
const noPathResponse = await appTestContext.app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file`,
payload: "no path",
headers: { "content-type": "text/plain" },
});
expect(noPathResponse.statusCode).toBe(400);
expect(noPathResponse.json<{ error: string }>().error).toContain("path query parameter is required");
const noDirsResponse = await appTestContext.app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("nonexistent/parent/file.txt")}&createDirs=false`,
payload: "should fail",
headers: { "content-type": "text/plain" },
});
expect(noDirsResponse.statusCode).toBe(400);
await mkdir(join(appTestContext.projectDir, "subdir"), { recursive: true });
const dirWriteResponse = await appTestContext.app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("subdir")}`,
payload: "should fail",
headers: { "content-type": "text/plain" },
});
expect(dirWriteResponse.statusCode).toBe(400);
});
it("deletes workspace files through the HTTP contract", async () => {
const addResponse = await appTestContext.app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "DeleteTest", path: appTestContext.projectDir, create: true },
});
const project = addResponse.json<Project>();
const workspacesResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
const workspace = workspacesResponse.json<Workspace[]>()[0];
if (workspace === undefined) throw new Error("Expected workspace");
await appTestContext.app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("to-delete.txt")}`,
payload: "delete me",
headers: { "content-type": "text/plain" },
});
const deleteResponse = await appTestContext.app.inject({
method: "DELETE",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("to-delete.txt")}`,
});
expect(deleteResponse.statusCode).toBe(200);
expect(deleteResponse.json()).toMatchObject({ path: "to-delete.txt", existed: true });
const deleteMissingResponse = await appTestContext.app.inject({
method: "DELETE",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("missing.txt")}`,
});
expect(deleteMissingResponse.statusCode).toBe(200);
expect(deleteMissingResponse.json()).toMatchObject({ path: "missing.txt", existed: false });
const traversalResponse = await appTestContext.app.inject({
method: "DELETE",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("../../etc/passwd")}`,
});
expect(traversalResponse.statusCode).toBe(400);
expect(traversalResponse.json<{ error: string }>().error).toContain("Path traversal");
const noPathResponse = await appTestContext.app.inject({
method: "DELETE",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file`,
});
expect(noPathResponse.statusCode).toBe(400);
expect(noPathResponse.json<{ error: string }>().error).toContain("path query parameter is required");
});
it("moves workspace files through the HTTP contract", async () => {
const addResponse = await appTestContext.app.inject({
method: "POST",
url: "/api/projects",
payload: { name: "MoveTest", path: appTestContext.projectDir, create: true },
});
const project = addResponse.json<Project>();
const workspacesResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` });
const workspace = workspacesResponse.json<Workspace[]>()[0];
if (workspace === undefined) throw new Error("Expected workspace");
await appTestContext.app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("original.txt")}`,
payload: "move me",
headers: { "content-type": "text/plain" },
});
const moveResponse = await appTestContext.app.inject({
method: "POST",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("original.txt")}&toPath=${encodeURIComponent("moved.txt")}`,
});
expect(moveResponse.statusCode).toBe(200);
expect(moveResponse.json()).toMatchObject({ fromPath: "original.txt", toPath: "moved.txt" });
expect(typeof moveResponse.json<{ size: unknown }>().size).toBe("number");
const readSourceResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("original.txt")}` });
expect(readSourceResponse.statusCode).toBe(400);
const readTargetResponse = await appTestContext.app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("moved.txt")}` });
expect(readTargetResponse.statusCode).toBe(200);
expect(readTargetResponse.json<{ content: unknown }>().content).toBe("move me");
await appTestContext.app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("source2.txt")}`,
payload: "source",
headers: { "content-type": "text/plain" },
});
await appTestContext.app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("target2.txt")}`,
payload: "target",
headers: { "content-type": "text/plain" },
});
const overwriteResponse = await appTestContext.app.inject({
method: "POST",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("source2.txt")}&toPath=${encodeURIComponent("target2.txt")}&overwrite=true`,
});
expect(overwriteResponse.statusCode).toBe(200);
await appTestContext.app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("source3.txt")}`,
payload: "s",
headers: { "content-type": "text/plain" },
});
await appTestContext.app.inject({
method: "PUT",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("target3.txt")}`,
payload: "t",
headers: { "content-type": "text/plain" },
});
const noOverwriteResponse = await appTestContext.app.inject({
method: "POST",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("source3.txt")}&toPath=${encodeURIComponent("target3.txt")}`,
});
expect(noOverwriteResponse.statusCode).toBe(400);
expect(noOverwriteResponse.json<{ error: string }>().error).toContain("File already exists");
const traversalFromResponse = await appTestContext.app.inject({
method: "POST",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("../../etc/passwd")}&toPath=${encodeURIComponent("safe.txt")}`,
});
expect(traversalFromResponse.statusCode).toBe(400);
const noParamsResponse = await appTestContext.app.inject({
method: "POST",
url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move`,
});
expect(noParamsResponse.statusCode).toBe(400);
expect(noParamsResponse.json<{ error: string }>().error).toContain("fromPath query parameter is required");
});
});
@@ -42,6 +42,56 @@ describe("MachineService", () => {
await expectOwnerOnlyMachineStore(storePath);
});
it("gets, updates, and removes remote machines without exposing stored secrets", async () => {
const machine = await service.add({
name: "Remote",
baseUrl: "https://remote.example.test",
token: "initial-secret",
headers: { "X-Pi-Web-Test": "initial" },
});
expect(await service.get(machine.id)).toEqual(machine);
const updated = await service.update(machine.id, {
name: " Updated Remote ",
baseUrl: "https://updated.example.test/",
token: "updated-secret",
headers: { "X-Pi-Web-Test": "updated" },
});
if (updated === undefined) throw new Error("Expected remote machine update to succeed");
expect(updated).toMatchObject({
id: machine.id,
name: "Updated Remote",
kind: "remote",
baseUrl: "https://updated.example.test",
createdAt: machine.createdAt,
});
expect(updated).not.toHaveProperty("token");
expect(updated).not.toHaveProperty("headers");
expect(await service.get(machine.id)).toEqual(updated);
expect(await service.list()).toEqual([expect.objectContaining({ id: "local", kind: "local" }), updated]);
const persistedAfterUpdate: unknown = JSON.parse(await readFile(storePath, "utf8"));
expect(persistedAfterUpdate).toMatchObject({
machines: [expect.objectContaining({
id: machine.id,
name: "Updated Remote",
baseUrl: "https://updated.example.test",
token: "updated-secret",
headers: { "X-Pi-Web-Test": "updated" },
})],
});
await expect(service.remove(machine.id)).resolves.toBe(true);
await expect(service.get(machine.id)).resolves.toBeUndefined();
await expect(service.remove(machine.id)).resolves.toBe(false);
expect(await service.list()).toEqual([expect.objectContaining({ id: "local", kind: "local" })]);
const persistedAfterRemove: unknown = JSON.parse(await readFile(storePath, "utf8"));
expect(persistedAfterRemove).toEqual({ machines: [] });
});
it.skipIf(process.platform === "win32")("tightens permissions after reading an existing machine store", async () => {
await writeFile(storePath, `${JSON.stringify({
machines: [{
+22 -1
View File
@@ -1,5 +1,5 @@
import { mkdir, mkdtemp, readFile, readdir, rm, symlink } from "node:fs/promises";
import { join } from "node:path";
import { basename, join } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { formatDimensionNote, resizeImage, type ResizedImage } from "@earendil-works/pi-coding-agent";
@@ -134,6 +134,27 @@ describe("saveAttachmentsToWorkspace", () => {
expect(await readFile(join(workspace, saved[1]?.path ?? ""))).toHaveLength(0);
});
it("falls back, strips controls, and truncates unsafe attachment names", async () => {
const longStem = "a".repeat(140);
const saved = await saveAttachmentsToWorkspace(
workspace,
[
{ kind: "image", mimeType: "image/jpeg", data: pngBase64 },
{ kind: "file", mimeType: "application/octet-stream", data: "QUJD", name: "\u0000\u001f\u007f" },
{ kind: "file", mimeType: "text/plain", data: "REVG", name: "nested/bad\u0000\u007fname\n.txt" },
{ kind: "file", mimeType: "application/pdf", data: "R0hJ", name: `${longStem}.pdf` },
],
{ now: () => new Date(2026, 5, 13, 12, 5, 1, 123) },
);
expect(saved.map((attachment) => basename(attachment.path))).toEqual([
"attachment-20260613-120501-123-1-image.jpg",
"attachment-20260613-120501-123-2-file.bin",
"attachment-20260613-120501-123-3-badname.txt",
`attachment-20260613-120501-123-4-${"a".repeat(92)}.pdf`,
]);
});
it("does not overwrite an existing attachment name", async () => {
const fixedNow = () => new Date("2026-06-13T12:05:01.123Z");
const first = await saveAttachmentsToWorkspace(
@@ -0,0 +1,358 @@
import { describe, expect, it, vi } from "vitest";
import { PiSessionService } from "./piSessionService.js";
import { CapturingSessionEventHub, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef } from "./piSessionService.testSupport.js";
describe("PiSessionService archive and cleanup", () => {
it("archives a session subtree within the root workspace", async () => {
const archivedInputs: string[] = [];
const root = sessionRecord("root");
const directChild = { ...sessionRecord("direct-child"), path: "/sessions/direct-child.jsonl", parentSessionPath: root.path };
const archivedChild = { ...sessionRecord("archived-child"), path: "/sessions/archived-child.jsonl", parentSessionPath: root.path };
const grandchild = { ...sessionRecord("grandchild"), path: "/sessions/grandchild.jsonl", parentSessionPath: archivedChild.path };
const otherWorkspaceChild = { ...sessionRecord("other-child", "/other"), path: "/sessions/other-child.jsonl", parentSessionPath: root.path };
const fake = fakeRuntime("root", { sessionFile: root.path });
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(fake.runtime),
archiveStore: {
list: () => Promise.resolve([{ sessionId: "archived-child", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: archivedChild.path, archivePath: "/archive/archived-child.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 1, firstMessage: "archived", parentSessionPath: root.path }]),
get: () => Promise.resolve(undefined),
archive: (input) => {
archivedInputs.push(input.sessionId);
return Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" });
},
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
},
sessionManager: {
create: () => fakeSessionManager(),
list: (cwd) => Promise.resolve(cwd === "/workspace" ? [root, directChild, archivedChild, grandchild] : [otherWorkspaceChild]),
open: () => fakeSessionManager(),
},
heartbeatIntervalMs: 60_000,
});
await expect(service.archiveTree(sessionRef("root"))).resolves.toEqual({
archived: true,
sessionIds: ["root", "direct-child", "grandchild"],
archivedCount: 3,
skippedAlreadyArchivedCount: 1,
});
expect(archivedInputs).toEqual(["root", "direct-child", "grandchild"]);
await service.dispose();
});
it("permanently deletes archived sessions through the archive store", async () => {
const deletedSessionIds: string[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
archiveStore: {
list: () => Promise.resolve([]),
get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId)
? { sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/archived.jsonl" }
: undefined),
archive: () => { throw new Error("archive should not be called for records that already have archive files"); },
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
deleteArchived: (sessionId) => {
deletedSessionIds.push(sessionId);
return Promise.resolve();
},
},
sessionManager: sessionGateway([sessionRecord("active")]),
heartbeatIntervalMs: 60_000,
});
await expect(service.deleteArchived("arch")).resolves.toBeUndefined();
await expect(service.deleteArchived("active")).rejects.toThrow("Archived session not found");
expect(deletedSessionIds).toEqual(["archived"]);
await service.dispose();
});
it("bulk archives inactive sessions by cwd without opening runtimes", async () => {
const recordsByCwd = new Map([
["/one", [sessionRecord("a", "/one"), sessionRecord("b", "/one")]],
["/two", [sessionRecord("c", "/two")]],
]);
const listCalls: string[] = [];
const open = vi.fn(() => { throw new Error("bulk archive should not open inactive runtimes"); });
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }))));
const service = new PiSessionService(new CapturingSessionEventHub(), {
archiveStore: {
list: () => Promise.resolve([]),
get: () => Promise.resolve(undefined),
archive: (input) => Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }),
archiveMany,
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
},
sessionManager: {
create: () => fakeSessionManager(),
list: (cwd) => {
listCalls.push(cwd);
return Promise.resolve(recordsByCwd.get(cwd) ?? []);
},
open,
},
heartbeatIntervalMs: 60_000,
});
const result = await service.archiveMany([{ id: "a", cwd: "/one" }, { id: "b", cwd: "/one" }, { id: "c", cwd: "/two" }]);
expect(result).toMatchObject({ archived: true, archivedSessionIds: ["a", "b", "c"], failures: [] });
expect(listCalls).toEqual(["/one", "/two"]);
expect(open).not.toHaveBeenCalled();
expect(archiveMany).toHaveBeenCalledTimes(1);
expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["a", "b", "c"]);
await service.dispose();
});
it("bulk archive reports per-session failures without aborting other archives", async () => {
const busy = fakeRuntime("busy", { isStreaming: true });
let createCalls = 0;
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }))));
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: () => {
createCalls += 1;
return Promise.resolve(busy.runtime);
},
archiveStore: {
list: () => Promise.resolve([]),
get: () => Promise.resolve(undefined),
archive: (input) => Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }),
archiveMany,
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
},
sessionManager: {
create: () => fakeSessionManager(),
list: () => Promise.resolve([sessionRecord("busy"), sessionRecord("ok")]),
open: () => fakeSessionManager(),
},
heartbeatIntervalMs: 60_000,
});
await service.status(sessionRef("busy"));
const result = await service.archiveMany([{ id: "busy", cwd: "/workspace" }, { id: "ok", cwd: "/workspace" }, { id: "missing", cwd: "/workspace" }]);
expect(createCalls).toBe(1);
expect(busy.calls.abort).toBe(0);
expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["ok"]);
expect(result.archivedSessionIds).toEqual(["ok"]);
expect(result.failures).toEqual([
{ sessionId: "busy", error: "Stop current session activity before archiving" },
{ sessionId: "missing", error: "Session not found" },
]);
await service.dispose();
});
it("bulk deletes only archived sessions and skips busy active archived runtimes", async () => {
const busyRecord = { sessionId: "busy-archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/busy.jsonl" };
const idleRecord = { sessionId: "idle-archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/idle.jsonl" };
const busy = fakeRuntime("busy-archived", { isStreaming: true });
const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds]));
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(busy.runtime),
archiveStore: {
list: () => Promise.resolve([busyRecord, idleRecord]),
get: (sessionId) => Promise.resolve(sessionId === "busy-archived" ? busyRecord : undefined),
archive: () => { throw new Error("archive should not be called for records that already have archive files"); },
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
deleteArchived: () => Promise.resolve(),
deleteArchivedMany,
},
sessionManager: {
create: () => fakeSessionManager(),
list: () => Promise.resolve([sessionRecord("unarchived")]),
open: () => fakeSessionManager(),
},
heartbeatIntervalMs: 60_000,
});
await service.status(sessionRef("busy-archived"));
const result = await service.deleteArchivedMany([{ id: "busy-archived", cwd: "/workspace" }, { id: "idle-archived", cwd: "/workspace" }, { id: "unarchived", cwd: "/workspace" }]);
expect(busy.calls.abort).toBe(0);
expect(deleteArchivedMany).toHaveBeenCalledWith(["idle-archived"]);
expect(result.deletedSessionIds).toEqual(["idle-archived"]);
expect(result.failures).toEqual([
{ sessionId: "busy-archived", error: "Stop current session activity before deleting archived session" },
{ sessionId: "unarchived", error: "Archived session not found" },
]);
await service.dispose();
});
it("bulk delete moves legacy archived records with one workspace scan before deleting", async () => {
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z", archivePath: `/archive/${input.sessionId}.jsonl` }))));
const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds]));
const listCalls: string[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
archiveStore: {
list: () => Promise.resolve([
{ sessionId: "legacy-a", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" },
{ sessionId: "legacy-b", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" },
{ sessionId: "moved", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/moved.jsonl" },
]),
get: () => Promise.resolve(undefined),
archive: (input) => Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }),
archiveMany,
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
deleteArchived: () => Promise.resolve(),
deleteArchivedMany,
},
sessionManager: {
create: () => fakeSessionManager(),
list: (cwd) => {
listCalls.push(cwd);
return Promise.resolve([sessionRecord("legacy-a"), sessionRecord("legacy-b"), sessionRecord("unarchived")]);
},
open: () => fakeSessionManager(),
},
heartbeatIntervalMs: 60_000,
});
const result = await service.deleteArchivedMany([{ id: "legacy-a", cwd: "/workspace" }, { id: "legacy-b", cwd: "/workspace" }, { id: "moved", cwd: "/workspace" }]);
expect(listCalls).toEqual(["/workspace"]);
expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["legacy-a", "legacy-b"]);
expect(deleteArchivedMany).toHaveBeenCalledWith(["legacy-a", "legacy-b", "moved"]);
expect(result.deletedSessionIds).toEqual(["legacy-a", "legacy-b", "moved"]);
expect(result.failures).toEqual([]);
await service.dispose();
});
it("previews session cleanup without mutating and executes a recomputed plan", async () => {
const archivedInputs: string[] = [];
const deletedSessionIds: string[] = [];
let listAllCalls = 0;
const archived = { sessionId: "archived-old", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-old.jsonl" };
const otherArchived = { sessionId: "archived-other", cwd: "/other-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-other.jsonl" };
const service = new PiSessionService(new CapturingSessionEventHub(), {
now: () => new Date("2026-06-25T00:00:00.000Z"),
archiveStore: {
list: () => Promise.resolve([archived, otherArchived]),
get: () => Promise.resolve(undefined),
archive: () => Promise.reject(new Error("cleanup should use archiveMany")),
archiveMany: (inputs) => {
archivedInputs.push(...inputs.map((input) => input.sessionId));
return Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z" })));
},
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
deleteArchived: () => Promise.reject(new Error("cleanup should use deleteArchivedMany")),
deleteArchivedMany: (sessionIds) => {
deletedSessionIds.push(...sessionIds);
return Promise.resolve([...sessionIds]);
},
},
sessionManager: {
create: () => fakeSessionManager(),
list: () => Promise.resolve([]),
listAll: () => {
listAllCalls += 1;
return Promise.resolve([
listAllCalls === 1 ? sessionRecord("preview-only", "/old-project") : sessionRecord("execute-only", "/old-project"),
listAllCalls === 1 ? sessionRecord("preview-other", "/other-project") : sessionRecord("execute-other", "/other-project"),
]);
},
open: () => fakeSessionManager(),
},
heartbeatIntervalMs: 60_000,
});
const preview = await service.cleanupPreview({ thresholds: { archiveIdleDays: 30, deleteArchivedDays: 30 }, projectCwds: ["/old-project"] });
expect(preview.totals).toEqual({ archiveCount: 1, deleteCount: 1 });
expect(preview.projects).toEqual([{ cwd: "/old-project", archiveCount: 1, deleteCount: 1 }]);
expect(archivedInputs).toEqual([]);
expect(deletedSessionIds).toEqual([]);
const result = await service.cleanup({ thresholds: { archiveIdleDays: 30, deleteArchivedDays: 30 }, projectCwds: ["/old-project"] });
expect(result.archivedSessionIds).toEqual(["execute-only"]);
expect(result.deletedSessionIds).toEqual(["archived-old"]);
expect(archivedInputs).toEqual(["execute-only"]);
expect(deletedSessionIds).toEqual(["archived-old"]);
await service.dispose();
});
it("moves legacy cleanup delete records with one workspace scan before batch deleting", async () => {
const listCalls: string[] = [];
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z", archivePath: `/archive/${input.sessionId}.jsonl` }))));
const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds]));
const service = new PiSessionService(new CapturingSessionEventHub(), {
now: () => new Date("2026-06-25T00:00:00.000Z"),
archiveStore: {
list: () => Promise.resolve([
{ sessionId: "legacy-a", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z" },
{ sessionId: "legacy-b", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z" },
]),
get: () => Promise.resolve(undefined),
archive: () => Promise.reject(new Error("cleanup should use archiveMany")),
archiveMany,
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
deleteArchived: () => Promise.reject(new Error("cleanup should use deleteArchivedMany")),
deleteArchivedMany,
},
sessionManager: {
create: () => fakeSessionManager(),
list: (cwd) => {
listCalls.push(cwd);
return Promise.resolve([sessionRecord("legacy-a", cwd), sessionRecord("legacy-b", cwd)]);
},
listAll: () => Promise.resolve([]),
open: () => fakeSessionManager(),
},
heartbeatIntervalMs: 60_000,
});
const result = await service.cleanup({ thresholds: { deleteArchivedDays: 30 }, projectCwds: ["/old-project"] });
expect(listCalls).toEqual(["/old-project"]);
expect(archiveMany).toHaveBeenCalledTimes(1);
expect(archiveMany.mock.calls[0]?.[0].map((input) => input.sessionId)).toEqual(["legacy-a", "legacy-b"]);
expect(deleteArchivedMany).toHaveBeenCalledWith(["legacy-a", "legacy-b"]);
expect(result.deletedSessionIds).toEqual(["legacy-a", "legacy-b"]);
await service.dispose();
});
it("skips busy active sessions during cleanup execution", async () => {
const fake = fakeRuntime("busy-open", { isStreaming: true, sessionManager: fakeSessionManager("/old-project"), sessionFile: "/sessions/busy-open.jsonl" });
const archivedInputs: string[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
now: () => new Date("2026-06-25T00:00:00.000Z"),
createAgentRuntime: runtimeCreator(fake.runtime),
archiveStore: {
list: () => Promise.resolve([]),
get: () => Promise.resolve(undefined),
archive: (input) => {
archivedInputs.push(input.sessionId);
return Promise.resolve({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z" });
},
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
},
sessionManager: {
create: () => fakeSessionManager("/old-project"),
list: () => Promise.resolve([sessionRecord("busy-open", "/old-project")]),
listAll: () => Promise.resolve([sessionRecord("busy-open", "/old-project")]),
open: () => fakeSessionManager("/old-project"),
},
heartbeatIntervalMs: 60_000,
});
await service.status("busy-open");
const result = await service.cleanup({ thresholds: { archiveIdleDays: 1 } });
expect(result.archivedSessionIds).toEqual([]);
expect(result.skippedBusySessionIds).toEqual(["busy-open"]);
expect(archivedInputs).toEqual([]);
expect(fake.calls.abort).toBe(0);
await service.dispose();
});
});
@@ -0,0 +1,386 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { PiSessionService, type PiAgentSession } from "./piSessionService.js";
import { CapturingSessionEventHub, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, type RuntimeCreator } from "./piSessionService.testSupport.js";
describe("PiSessionService lifecycle, listing, and reload", () => {
it("starts sessions through an injected runtime creator", async () => {
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime();
let createCalls = 0;
const createAgentRuntime: RuntimeCreator = async () => {
createCalls += 1;
await Promise.resolve();
return fake.runtime;
};
const service = new PiSessionService(hub, {
createAgentRuntime,
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
});
const session = await service.start("/workspace");
expect(createCalls).toBe(1);
expect(fake.calls.bindExtensions).toHaveLength(1);
expect(session).toMatchObject({ id: "session-1", cwd: "/workspace", messageCount: 0 });
expect(service.activeCount()).toBe(1);
expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "session-1")).toBe(true);
expect(hub.globalEvents.some((event) => event.type === "session.created" && event.session.id === "session-1" && event.session.cwd === "/workspace")).toBe(true);
await service.dispose();
expect(fake.calls.abort).toBe(1);
expect(fake.calls.dispose).toBe(1);
});
it("reports persistence from actual session-file existence for fresh active sessions", async () => {
const dir = await mkdtemp(join(tmpdir(), "pi-web-persisted-"));
const sessionFile = join(dir, "new-session.jsonl");
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("new-session", { sessionFile });
let service: PiSessionService | undefined;
try {
service = new PiSessionService(hub, {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
});
const session = await service.start("/workspace");
const createdEvent = hub.globalEvents.find((event) => event.type === "session.created");
expect(session).toMatchObject({ id: "new-session", path: sessionFile, persisted: false });
expect(createdEvent).toMatchObject({ type: "session.created", session: { id: "new-session", persisted: false } });
await expect(service.status(sessionRef("new-session"))).resolves.toMatchObject({ sessionId: "new-session", persisted: false });
await writeFile(sessionFile, '{"type":"session","id":"new-session"}\n', "utf8");
await expect(service.status(sessionRef("new-session"))).resolves.toMatchObject({ sessionId: "new-session", persisted: true });
} finally {
await service?.dispose();
await rm(dir, { recursive: true, force: true });
}
});
it("opens legacy id-only lookups from the default session store gateway", async () => {
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("legacy-session");
const open = vi.fn(() => fakeSessionManager());
const service = new PiSessionService(hub, {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: {
create: () => fakeSessionManager(),
list: () => Promise.resolve([]),
listAll: () => Promise.resolve([sessionRecord("legacy-session")]),
open,
},
heartbeatIntervalMs: 60_000,
});
await expect(service.status("legacy")).resolves.toMatchObject({ sessionId: "legacy-session" });
expect(open).toHaveBeenCalledWith("/sessions/legacy-session.jsonl");
await service.dispose();
});
it("binds extensions again when the SDK runtime replaces the active session", async () => {
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("session-1");
const replacement = fakeRuntime("session-2");
let rebindSession: ((session: PiAgentSession) => Promise<void>) | undefined;
fake.runtime.setRebindSession = (callback) => { rebindSession = callback; };
const service = new PiSessionService(hub, {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
});
await service.start("/workspace");
Object.defineProperty(fake.runtime, "session", { configurable: true, value: replacement.session });
await rebindSession?.(replacement.session);
expect(fake.calls.bindExtensions).toHaveLength(1);
expect(replacement.calls.bindExtensions).toHaveLength(1);
expect(service.activeCount()).toBe(1);
expect(await service.status("session-2")).toMatchObject({ sessionId: "session-2" });
await service.dispose();
});
it("publishes extension errors reported while binding session extensions", async () => {
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("extension-session", {
bindExtensions: (bindings) => {
bindings.onError?.({ extensionPath: "pi-mcp-adapter", event: "session_start", error: "MCP failed" });
return Promise.resolve();
},
});
const service = new PiSessionService(hub, {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
});
await service.start("/workspace");
expect(hub.sessionEvents).toContainEqual({
sessionId: "extension-session",
event: { type: "session.error", message: "pi-mcp-adapter: MCP failed" },
});
const extensionErrorActivity = hub.globalEvents.find((event) => event.type === "activity.update" && event.activity.sessionId === "extension-session");
expect(extensionErrorActivity).toMatchObject({
type: "activity.update",
activity: { sessionId: "extension-session", phase: "error", label: "extension error", detail: "pi-mcp-adapter: MCP failed" },
});
await service.dispose();
});
it("clears stale active activity once a previously active session becomes idle", async () => {
vi.useFakeTimers();
let service: PiSessionService | undefined;
try {
const hub = new CapturingSessionEventHub();
let listener: ((event: unknown) => void) | undefined;
const fake = fakeRuntime("idle-session", {
isStreaming: true,
subscribe: (next) => {
listener = next;
return () => undefined;
},
});
service = new PiSessionService(hub, {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("idle-session")]),
heartbeatIntervalMs: 1_000,
});
await service.status(sessionRef("idle-session"));
hub.globalEvents.length = 0;
listener?.({ type: "agent_start" });
const activityPhases = () => hub.globalEvents
.filter((event) => event.type === "activity.update")
.map((event) => event.activity.phase);
expect(activityPhases()).toEqual(["active"]);
fake.session.isStreaming = false;
await vi.advanceTimersByTimeAsync(1_000);
await vi.advanceTimersByTimeAsync(1_000);
expect(activityPhases()).toEqual(["active", "idle"]);
} finally {
await service?.dispose();
vi.useRealTimers();
}
});
it("publishes idle activity for SDK completion events", async () => {
const hub = new CapturingSessionEventHub();
let listener: ((event: unknown) => void) | undefined;
const fake = fakeRuntime("completion-session", {
subscribe: (next) => {
listener = next;
return () => undefined;
},
});
const service = new PiSessionService(hub, {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("completion-session")]),
heartbeatIntervalMs: 60_000,
});
await service.status(sessionRef("completion-session"));
hub.globalEvents.length = 0;
listener?.({ type: "tool_execution_end", toolName: "read", isError: false });
expect(hub.globalEvents.filter((event) => event.type === "activity.update")).toMatchObject([
{ activity: { sessionId: "completion-session", phase: "idle", label: "tool complete", detail: "read" } },
]);
await service.dispose();
});
it("uses injected archive and session-manager gateways for listing", async () => {
const service = new PiSessionService(new CapturingSessionEventHub(), {
archiveStore: {
list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-01T00:00:00.000Z" }]),
get: () => Promise.resolve(undefined),
archive: () => Promise.resolve({ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-01T00:00:00.000Z" }),
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
},
sessionManager: {
create: () => fakeSessionManager(),
list: () => Promise.resolve([
{ ...sessionRecord("active"), messageCount: 1, firstMessage: "hello", allMessagesText: "hello" },
{ ...sessionRecord("archived"), messageCount: 2, firstMessage: "bye", allMessagesText: "bye" },
]),
open: () => fakeSessionManager(),
},
heartbeatIntervalMs: 60_000,
});
const sessions = await service.list("/workspace");
expect(sessions).toHaveLength(2);
expect(sessions[0]).toMatchObject({ id: "active", persisted: true });
expect(sessions[0]?.archived).toBeUndefined();
expect(sessions[1]).toMatchObject({ id: "archived", archived: true, archivedAt: "2026-01-01T00:00:00.000Z" });
await service.dispose();
});
it("lists archived records that have been moved out of the active session directory", async () => {
const service = new PiSessionService(new CapturingSessionEventHub(), {
archiveStore: {
list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: "/sessions/archived.jsonl", archivePath: "/archive/archived.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 2, firstMessage: "bye" }]),
get: () => Promise.resolve(undefined),
archive: () => { throw new Error("archive should not be called for moved records"); },
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
},
sessionManager: {
create: () => fakeSessionManager(),
list: () => Promise.resolve([{ ...sessionRecord("active"), messageCount: 1, firstMessage: "hello", allMessagesText: "hello" }]),
open: () => fakeSessionManager(),
},
heartbeatIntervalMs: 60_000,
});
const sessions = await service.list("/workspace");
expect(sessions).toHaveLength(2);
expect(sessions[0]).toMatchObject({ id: "active" });
expect(sessions[0]?.archived).toBeUndefined();
expect(sessions[1]).toMatchObject({ id: "archived", path: "/sessions/archived.jsonl", archived: true, archivedAt: "2026-01-02T00:00:00.000Z" });
await service.dispose();
});
it("runs /reload by refreshing the active runtime resources in place", async () => {
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("runtime-reload-session");
const service = new PiSessionService(hub, {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("runtime-reload-session")]),
heartbeatIntervalMs: 60_000,
});
await expect(service.runCommand(sessionRef("runtime-reload-session"), "/reload")).resolves.toEqual({
type: "done",
message: "Session runtime resources reloaded. Extensions, skills, prompt templates, themes, and context/system prompt files are refreshed for this session. Reload the browser page separately for PI WEB browser plugin changes.",
});
expect(fake.calls.reload).toBe(1);
expect(fake.calls.abort).toBe(0);
expect(fake.calls.dispose).toBe(0);
expect(hub.globalEvents.some((event) => event.type === "activity.update" && event.activity.sessionId === "runtime-reload-session" && event.activity.label === "resources reloaded")).toBe(true);
expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "runtime-reload-session")).toBe(true);
await service.dispose();
});
it("reloads a session by closing the active runtime and re-opening it from disk", async () => {
const first = fakeRuntime("reload-session");
const second = fakeRuntime("reload-session");
const runtimes = [first.runtime, second.runtime];
let createCalls = 0;
const createAgentRuntime: RuntimeCreator = async () => {
await Promise.resolve();
const runtime = runtimes[createCalls];
createCalls += 1;
if (runtime === undefined) throw new Error("unexpected runtime creation");
return runtime;
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime,
sessionManager: sessionGateway([sessionRecord("reload-session")]),
heartbeatIntervalMs: 60_000,
});
// Open once so there is an active runtime to reload.
await service.status(sessionRef("reload-session"));
expect(createCalls).toBe(1);
await expect(service.reload(sessionRef("reload-session"))).resolves.toBeUndefined();
// The original runtime was torn down and a fresh one opened from disk.
expect(first.calls.abort).toBe(1);
expect(first.calls.dispose).toBe(1);
expect(createCalls).toBe(2);
expect(service.activeCount()).toBe(1);
await service.dispose();
});
it("refuses to reload a session that has active work in progress", async () => {
const fake = fakeRuntime("busy-session", { isStreaming: true });
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("busy-session")]),
heartbeatIntervalMs: 60_000,
});
await expect(service.reload(sessionRef("busy-session"))).rejects.toThrow("Stop current session activity before reloading");
expect(fake.calls.abort).toBe(0);
expect(fake.calls.dispose).toBe(0);
await service.dispose();
});
it("refuses to reload an archived session", async () => {
const service = new PiSessionService(new CapturingSessionEventHub(), {
archiveStore: {
list: () => Promise.resolve([]),
get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId)
? { sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", archivePath: "/archive/archived.jsonl" }
: undefined),
archive: () => Promise.resolve({ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" }),
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(true),
},
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
});
await expect(service.reload(sessionRef("archived"))).rejects.toThrow("Archived sessions are read-only");
await service.dispose();
});
it("reconciles workspace activity when listing only archived sessions", async () => {
const reconciliations: { cwd: string; sessionIds: string[] }[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
archiveStore: {
list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: "/sessions/archived.jsonl", archivePath: "/archive/archived.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 2, firstMessage: "bye" }]),
get: () => Promise.resolve(undefined),
archive: () => { throw new Error("archive should not be called for moved records"); },
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
},
sessionManager: {
create: () => fakeSessionManager(),
list: () => Promise.resolve([]),
open: () => fakeSessionManager(),
},
workspaceActivity: {
applySessionStatus: () => undefined,
applySessionActivity: () => undefined,
removeSession: () => undefined,
reconcileSessionActivity: (cwd, sessionIds) => { reconciliations.push({ cwd, sessionIds: [...sessionIds] }); },
},
heartbeatIntervalMs: 60_000,
});
const sessions = await service.list("/workspace");
expect(sessions).toHaveLength(1);
expect(sessions[0]).toMatchObject({ id: "archived", archived: true });
expect(reconciliations).toEqual([{ cwd: "/workspace", sessionIds: [] }]);
await service.dispose();
});
});
@@ -0,0 +1,299 @@
import { createAssistantMessageEventStream, type AssistantMessage } from "@earendil-works/pi-ai";
import type { StreamFn } from "@earendil-works/pi-agent-core";
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
import { describe, expect, it, vi } from "vitest";
import { PiSessionService } from "./piSessionService.js";
import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, sessionRecord, sessionRef, TEST_MODEL_ID, TEST_MODEL_PROVIDER, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js";
describe("PiSessionService prompt, queue, and auth warnings", () => {
it("sends prompts to an injected runtime without touching the SDK runtime", async () => {
const fake = fakeRuntime("prompt-session");
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("prompt-session")]),
heartbeatIntervalMs: 60_000,
});
await service.prompt(sessionRef("prompt-session"), "Build the thing");
expect(fake.calls.prompt).toEqual([{ text: "Build the thing", options: undefined }]);
await service.dispose();
});
it("echoes the user message for direct prompts but not command-forwarded ones", async () => {
const fake = fakeRuntime("echo-session", {
resourceLoader: { getSkills: () => ({ skills: [{ name: "skill-creator" }] }) },
});
const hub = new CapturingSessionEventHub();
const service = new PiSessionService(hub, {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("echo-session")]),
heartbeatIntervalMs: 60_000,
});
await service.prompt(sessionRef("echo-session"), "Build the thing");
expect(hub.sessionEvents.filter(({ event }) => event.type === "message.append")).toHaveLength(1);
// The client optimistically renders command-forwarded prompts (e.g. /skill:*),
// so the server must not publish a second copy via message.append.
await service.runCommand(sessionRef("echo-session"), "/skill:skill-creator");
expect(hub.sessionEvents.filter(({ event }) => event.type === "message.append")).toHaveLength(1);
expect(fake.calls.prompt).toEqual([
{ text: "Build the thing", options: undefined },
{ text: "/skill:skill-creator", options: undefined },
]);
await service.dispose();
});
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,
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();
});
it("generates a session name for the first prompt via the session's agent.streamFn", async () => {
const model = testModel();
const streamCalls: unknown[] = [];
const streamFn: StreamFn = (streamModel, context, options) => {
streamCalls.push({ streamModel, context, options });
const stream = createAssistantMessageEventStream();
const message: AssistantMessage = {
role: "assistant",
content: [{ type: "text", text: "Fix login bug" }],
api: "anthropic-messages",
provider: "anthropic",
model: model.id,
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
stopReason: "stop",
timestamp: Date.now(),
};
stream.push({ type: "done", reason: "stop", message });
stream.end(message);
return stream;
};
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("name-session", { model, agent: { streamFn } });
const service = new PiSessionService(hub, {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("name-session")]),
heartbeatIntervalMs: 60_000,
});
await service.prompt(sessionRef("name-session"), "Please fix the login bug");
await vi.waitFor(() => { expect(fake.session.sessionName).toBe("Fix login bug"); });
expect(streamCalls).toHaveLength(1);
expect(hub.sessionEvents.some(({ event }) => event.type === "session.name" && event.name === "Fix login bug")).toBe(true);
await service.dispose();
});
it("includes queued message details in session status", async () => {
const fake = fakeRuntime("status-session", {
messages: [{ role: "user", content: "hello" }, { role: "assistant", content: "hi" }],
pendingMessageCount: 2,
getSteeringMessages: () => ["adjust this turn"],
getFollowUpMessages: () => ["then do this"],
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("status-session")]),
heartbeatIntervalMs: 60_000,
});
await expect(service.status(sessionRef("status-session"))).resolves.toMatchObject({
pendingMessageCount: 2,
queuedMessages: [{ kind: "steer", text: "adjust this turn" }, { kind: "followUp", text: "then do this" }],
messageCount: 2,
});
await service.dispose();
});
it("does not enqueue duplicate queued message text", async () => {
const fake = fakeRuntime("dedupe-session", {
isStreaming: true,
pendingMessageCount: 1,
getFollowUpMessages: () => ["already queued"],
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("dedupe-session")]),
heartbeatIntervalMs: 60_000,
});
await service.prompt(sessionRef("dedupe-session"), "already queued", "followUp");
expect(fake.calls.prompt).toEqual([]);
await service.dispose();
});
it("does not append queued prompts to the transcript before delivery", async () => {
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("queued-session", { isStreaming: true });
const service = new PiSessionService(hub, {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("queued-session")]),
heartbeatIntervalMs: 60_000,
});
await service.prompt(sessionRef("queued-session"), "Wait for the current turn", "followUp");
expect(fake.calls.prompt).toEqual([{ text: "Wait for the current turn", options: { streamingBehavior: "followUp" } }]);
expect(hub.sessionEvents.some(({ event }) => event.type === "message.append")).toBe(false);
await service.dispose();
});
it("holds prompts sent during compaction until compaction finishes", async () => {
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("compacting-session", { isCompacting: true });
let resolveFirstPrompt: (() => void) | undefined;
fake.session.prompt = (text: string, options?: { streamingBehavior?: "steer" | "followUp" }) => {
fake.calls.prompt.push({ text, options });
if (options === undefined) {
fake.session.isStreaming = true;
return new Promise<void>((resolve) => { resolveFirstPrompt = resolve; });
}
return Promise.resolve();
};
const service = new PiSessionService(hub, {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("compacting-session")]),
heartbeatIntervalMs: 60_000,
});
await service.prompt(sessionRef("compacting-session"), "Start task 1", "followUp");
await service.prompt(sessionRef("compacting-session"), "Then task 2", "followUp");
expect(fake.calls.prompt).toEqual([]);
expect(hub.sessionEvents.some(({ event }) => event.type === "message.append")).toBe(false);
await expect(service.status(sessionRef("compacting-session"))).resolves.toMatchObject({
pendingMessageCount: 2,
queuedMessages: [{ kind: "followUp", text: "Start task 1" }, { kind: "followUp", text: "Then task 2" }],
});
fake.session.isCompacting = false;
fake.emit({ type: "compaction_end" });
await new Promise((resolve) => setTimeout(resolve, 5));
expect(fake.calls.prompt).toEqual([{ text: "Start task 1", options: undefined }]);
expect(hub.sessionEvents.some(({ event }) => event.type === "message.append" && JSON.stringify(event.message).includes("Start task 1"))).toBe(true);
await expect(service.status(sessionRef("compacting-session"))).resolves.toMatchObject({
pendingMessageCount: 1,
queuedMessages: [{ kind: "followUp", text: "Then task 2" }],
});
fake.emit({ type: "agent_start" });
await new Promise((resolve) => setTimeout(resolve, 5));
expect(fake.calls.prompt).toEqual([
{ text: "Start task 1", options: undefined },
{ text: "Then task 2", options: { streamingBehavior: "followUp" } },
]);
await expect(service.status(sessionRef("compacting-session"))).resolves.toMatchObject({
pendingMessageCount: 0,
queuedMessages: [],
});
resolveFirstPrompt?.();
await service.dispose();
});
it("clears queued messages when aborting active work", async () => {
const fake = fakeRuntime("abort-session");
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("abort-session")]),
heartbeatIntervalMs: 60_000,
});
await service.status(sessionRef("abort-session"));
await service.abort(sessionRef("abort-session"));
expect(fake.calls.clearQueue).toBe(1);
expect(fake.calls.abort).toBe(1);
await service.dispose();
});
it("clears prompts queued during compaction when aborting active work", async () => {
const fake = fakeRuntime("abort-compaction-session", { isCompacting: true });
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("abort-compaction-session")]),
heartbeatIntervalMs: 60_000,
});
await service.prompt(sessionRef("abort-compaction-session"), "Do not deliver after abort", "followUp");
await expect(service.status(sessionRef("abort-compaction-session"))).resolves.toMatchObject({ pendingMessageCount: 1 });
await service.abort(sessionRef("abort-compaction-session"));
expect(fake.calls.clearQueue).toBe(1);
expect(fake.calls.prompt).toEqual([]);
await expect(service.status(sessionRef("abort-compaction-session"))).resolves.toMatchObject({ pendingMessageCount: 0, queuedMessages: [] });
await service.dispose();
});
it("refreshes auth state and dedupes warnings when logout removes the current model's credentials", async () => {
const hub = new CapturingSessionEventHub();
const authStorage = AuthStorage.inMemory({ anthropic: { type: "api_key", key: "sk-test" } });
const modelRegistry = ModelRegistry.inMemory(authStorage);
const model = modelRegistry.find(TEST_MODEL_PROVIDER, TEST_MODEL_ID);
if (model === undefined) throw new Error("Expected Anthropic model fixture");
const fake = fakeRuntime("auth-session", { model, modelRegistry });
const service = new PiSessionService(hub, {
modelRegistry,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("auth-session")]),
heartbeatIntervalMs: 60_000,
});
await service.status(sessionRef("auth-session"));
hub.sessionEvents.length = 0;
hub.globalEvents.length = 0;
authStorage.logout("anthropic");
service.applyAuthChange({ removedProviderId: "anthropic" });
service.applyAuthChange({ removedProviderId: "anthropic" });
const warningCount = () => hub.sessionEvents.filter(({ event }) => event.type === "command.output" && event.level === "error" && event.message.includes(`${TEST_MODEL_PROVIDER}/${TEST_MODEL_ID}`)).length;
expect(warningCount()).toBe(1);
expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "auth-session")).toBe(true);
authStorage.set("anthropic", { type: "api_key", key: "sk-new" });
service.applyAuthChange();
authStorage.logout("anthropic");
service.applyAuthChange({ removedProviderId: "anthropic" });
expect(warningCount()).toBe(2);
await service.dispose();
});
it("clears queued messages when stopping a session runtime", async () => {
const fake = fakeRuntime("stop-session");
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("stop-session")]),
heartbeatIntervalMs: 60_000,
});
await service.status(sessionRef("stop-session"));
service.stop(sessionRef("stop-session"));
expect(fake.calls.clearQueue).toBe(1);
await service.dispose();
});
});
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import { PiSessionService, type PiAgentSession } from "./piSessionService.js";
import type { SpawnTargetDecision } from "./spawnTargetResolver.js";
import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js";
describe("PiSessionService", () => {
describe("spawnSession", () => {
function spawnService(decision: SpawnTargetDecision) {
const fake = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl" });
const log: { details: Record<string, unknown>; message: string }[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) },
logger: { info: (details, message) => { log.push({ details, message }); } },
heartbeatIntervalMs: 60_000,
});
return { fake, service, log };
}
it("starts a session at the resolved target, delivers the prompt, and logs the spawn", async () => {
const { fake, service, log } = spawnService({ allowed: true, cwd: "/workspace-feature" });
const result = await service.spawnSession({ spawningCwd: "/workspace", prompt: "continue the plan", cwd: "/workspace-feature" });
expect(result).toEqual({ sessionId: "spawned-1", cwd: "/workspace-feature" });
expect(fake.calls.prompt).toEqual([{ text: "continue the plan", options: undefined }]);
expect(log).toEqual([{ details: { spawningCwd: "/workspace", sessionId: "spawned-1", cwd: "/workspace-feature", promptLength: 17 }, message: "spawn_session started a new session" }]);
await service.dispose();
});
it("uses the dispatching session's model as the spawned session's initial model", async () => {
const fake = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl" });
const model = testModel();
let initialModel: PiAgentSession["model"];
const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => {
await Promise.resolve();
initialModel = options.initialModel;
return fake.runtime;
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime,
sessionManager: sessionGateway([]),
spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) },
heartbeatIntervalMs: 60_000,
});
await service.spawnSession({ spawningCwd: "/workspace", prompt: "continue", cwd: "/workspace-feature", model });
expect(initialModel).toBe(model);
await service.dispose();
});
it("rejects an out-of-project target without starting a session", async () => {
const { fake, service } = spawnService({ allowed: false, reason: "out-of-project", allowedCwds: ["/workspace"] });
await expect(service.spawnSession({ spawningCwd: "/workspace", prompt: "go", cwd: "/elsewhere" }))
.rejects.toThrow("cwd must be a workspace of this project. Allowed: /workspace");
expect(fake.calls.prompt).toEqual([]);
expect(service.activeCount()).toBe(0);
await service.dispose();
});
it("rejects when the spawning session is not in a registered project", async () => {
const { service } = spawnService({ allowed: false, reason: "not-registered" });
await expect(service.spawnSession({ spawningCwd: "/workspace", prompt: "go", cwd: undefined }))
.rejects.toThrow("Spawning session is not in a registered project");
await service.dispose();
});
it("is disabled when no spawn target resolver is configured", async () => {
const fake = fakeRuntime("spawned-x");
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
});
await expect(service.spawnSession({ spawningCwd: "/workspace", prompt: "go", cwd: undefined }))
.rejects.toThrow("Spawning sessions is disabled");
await service.dispose();
});
});
});
@@ -0,0 +1,844 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { PiSessionService, type PiAgentSession } from "./piSessionService.js";
import type { SpawnTargetDecision } from "./spawnTargetResolver.js";
import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js";
describe("PiSessionService", () => {
describe("spawnSubsession", () => {
function subsessionService(decision: SpawnTargetDecision, heartbeatIntervalMs = 60_000) {
const parent = fakeRuntime("parent-1", { sessionFile: "/tmp/parent-1.jsonl" });
const child = fakeRuntime("child-1", { sessionFile: "/tmp/child-1.jsonl", sessionManager: fakeSessionManager("/workspace-feature") });
const created = [parent.runtime, child.runtime];
let index = 0;
const createAgentRuntime: RuntimeCreator = async () => {
await Promise.resolve();
const runtime = created[Math.min(index, created.length - 1)] ?? child.runtime;
index += 1;
return runtime;
};
const archived = new Map<string, { sessionId: string; cwd: string; archivedAt: string }>();
const archiveStore = {
list: () => Promise.resolve([...archived.values()]),
get: (sessionId: string) => Promise.resolve(archived.get(sessionId)),
archive: (input: { sessionId: string; cwd: string }) => {
const record = { sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-01T00:00:00.000Z" };
archived.set(input.sessionId, record);
return Promise.resolve(record);
},
restore: (sessionId: string) => { archived.delete(sessionId); return Promise.resolve(); },
isArchived: (sessionId: string) => Promise.resolve(archived.has(sessionId)),
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime,
sessionManager: sessionGateway([]),
archiveStore,
spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) },
heartbeatIntervalMs,
});
return { parent, child, service };
}
it("records the parent, delivers the prompt, and lists the tracked child", async () => {
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" });
expect(result).toEqual({ sessionId: "child-1", cwd: "/workspace-feature" });
expect(child.calls.prompt).toEqual([{ text: "do the slice", options: undefined }]);
await expect(service.listSubsessions("parent-1")).resolves.toEqual([
{ sessionId: "child-1", cwd: "/workspace-feature", status: "idle" },
]);
await service.dispose();
});
it("uses the parent session's model as the tracked child's initial model", async () => {
const parent = fakeRuntime("parent-1", { sessionFile: "/tmp/parent-1.jsonl" });
const child = fakeRuntime("child-1", { sessionFile: "/tmp/child-1.jsonl", sessionManager: fakeSessionManager("/workspace-feature") });
const model = testModel();
const initialModels: PiAgentSession["model"][] = [];
const runtimes = [parent.runtime, child.runtime];
let index = 0;
const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => {
await Promise.resolve();
initialModels.push(options.initialModel);
const runtime = runtimes[index] ?? child.runtime;
index += 1;
return runtime;
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime,
sessionManager: sessionGateway([]),
archiveStore: emptyArchiveStore(),
spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) },
heartbeatIntervalMs: 60_000,
});
await service.start("/workspace");
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature", model });
expect(initialModels).toEqual([undefined, model]);
await service.dispose();
});
it("persists tracked child links in the parent and child sessions", async () => {
const parentPersisted: { customType: string; data?: unknown }[] = [];
const childPersisted: { customType: string; data?: unknown }[] = [];
const parent = fakeRuntime("parent-1", {
sessionFile: "/tmp/parent-1.jsonl",
sessionManager: fakeSessionManager("/workspace", {
appendCustomEntry: (customType, data) => {
parentPersisted.push({ customType, data });
return "parent-entry-1";
},
}),
});
const child = fakeRuntime("child-1", {
sessionFile: "/tmp/child-1.jsonl",
sessionManager: fakeSessionManager("/workspace-feature", {
appendCustomEntry: (customType, data) => {
childPersisted.push({ customType, data });
return "child-entry-1";
},
}),
});
const runtimes = [parent.runtime, child.runtime];
let index = 0;
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: () => {
const runtime = runtimes[index] ?? child.runtime;
index += 1;
return Promise.resolve(runtime);
},
sessionManager: sessionGateway([]),
archiveStore: emptyArchiveStore(),
spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) },
heartbeatIntervalMs: 60_000,
});
await service.start("/workspace");
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature" });
expect(parentPersisted).toEqual([
{
customType: "pi-web.subsession.link",
data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: "/tmp/child-1.jsonl", cwd: "/workspace-feature" },
},
]);
expect(childPersisted).toEqual([
{
customType: "pi-web.subsession.spawned",
data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" },
},
]);
await service.dispose();
});
it("hydrates persisted child links after a service restart so the parent can inspect them", async () => {
const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-"));
const parentFile = join(tempDir, "parent.jsonl");
const childFile = join(tempDir, "child.jsonl");
await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8");
await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8");
try {
const childManager = fakeSessionManager("/workspace-feature", {
getBranch: () => [{ type: "message", message: { role: "assistant", content: "finished" } }],
});
const parent = fakeRuntime("parent-1", {
sessionFile: parentFile,
sessionManager: fakeSessionManager("/workspace", {
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }],
}),
});
const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager });
const runtimes = [parent.runtime, child.runtime];
let index = 0;
const open = vi.fn(() => childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: () => {
const runtime = runtimes[index] ?? child.runtime;
index += 1;
return Promise.resolve(runtime);
},
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open },
archiveStore: emptyArchiveStore(),
heartbeatIntervalMs: 60_000,
});
await service.start("/workspace");
await expect(service.checkSubsession("parent-1", "child-1")).resolves.toEqual({
sessionId: "child-1",
cwd: "/workspace-feature",
status: "idle",
finalText: "finished",
messageCount: 1,
});
expect(open).toHaveBeenCalledWith(childFile);
await service.dispose();
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("ignores stale persisted child links when the child no longer records the parent", async () => {
const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-stale-"));
const parentFile = join(tempDir, "parent.jsonl");
const childFile = join(tempDir, "child.jsonl");
await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8");
await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature" })}\n`, "utf8");
try {
const parent = fakeRuntime("parent-1", {
sessionFile: parentFile,
sessionManager: fakeSessionManager("/workspace", {
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }],
}),
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(parent.runtime),
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
archiveStore: emptyArchiveStore(),
heartbeatIntervalMs: 60_000,
});
await service.start("/workspace");
await expect(service.listSubsessions("parent-1")).resolves.toEqual([]);
await service.dispose();
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("does not hydrate persisted links when the exact child file is unavailable", async () => {
const parentFile = "/sessions/parent-1.jsonl";
const parent = fakeRuntime("parent-1", {
sessionFile: parentFile,
sessionManager: fakeSessionManager("/workspace", {
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: "/sessions/child-1.jsonl", cwd: "/workspace-feature" } }],
}),
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(parent.runtime),
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
archiveStore: emptyArchiveStore(),
heartbeatIntervalMs: 60_000,
});
await service.start("/workspace");
await expect(service.listSubsessions("parent-1")).resolves.toEqual([]);
await service.dispose();
});
it("does not hydrate parent links without a child file", async () => {
const parentFile = "/sessions/parent-1.jsonl";
const parent = fakeRuntime("parent-1", {
sessionFile: parentFile,
sessionManager: fakeSessionManager("/workspace", {
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child", cwd: "/workspace-feature" } }],
}),
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(parent.runtime),
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
archiveStore: emptyArchiveStore(),
heartbeatIntervalMs: 60_000,
});
await service.start("/workspace");
await expect(service.listSubsessions("parent-1")).resolves.toEqual([]);
await service.dispose();
});
it("does not invent subsession links from existing child session headers", async () => {
const parentFile = "/sessions/parent-1.jsonl";
const childRecord = { ...sessionRecord("child-1", "/workspace-feature"), path: "/sessions/child-1.jsonl", parentSessionPath: parentFile };
const parent = fakeRuntime("parent-1", {
sessionFile: parentFile,
sessionManager: fakeSessionManager("/workspace", { getEntries: () => [] }),
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(parent.runtime),
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([childRecord]), open: () => fakeSessionManager() },
archiveStore: emptyArchiveStore(),
heartbeatIntervalMs: 60_000,
});
await service.start("/workspace");
await expect(service.listSubsessions("parent-1")).resolves.toEqual([]);
await service.dispose();
});
it("does not hydrate copied parent links when the opened parent has a different id", async () => {
const forkedParent = fakeRuntime("parent-fork-1", {
sessionFile: "/sessions/parent-fork-1.jsonl",
sessionManager: fakeSessionManager("/workspace", {
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: "/sessions/child-1.jsonl", cwd: "/workspace-feature" } }],
}),
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(forkedParent.runtime),
sessionManager: { create: () => forkedParent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
archiveStore: emptyArchiveStore(),
heartbeatIntervalMs: 60_000,
});
await service.start("/workspace");
await expect(service.listSubsessions("parent-fork-1")).resolves.toEqual([]);
await service.dispose();
});
it("relinks a spawned child when the child session is opened after restart", async () => {
const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-open-child-"));
const parentFile = join(tempDir, "parent.jsonl");
const childFile = join(tempDir, "child.jsonl");
await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8");
await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8");
try {
const childManager = fakeSessionManager("/workspace-feature", {
getHeader: () => ({ parentSession: parentFile }),
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }],
});
const parentManager = fakeSessionManager("/workspace", {
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }],
});
const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager });
const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager });
const runtimes = [child.runtime, parent.runtime];
let index = 0;
const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: () => {
const runtime = runtimes[index] ?? parent.runtime;
index += 1;
return Promise.resolve(runtime);
},
sessionManager: {
create: () => childManager,
list: () => Promise.resolve([{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]),
listAll: () => Promise.resolve([]),
open,
},
archiveStore: emptyArchiveStore(),
heartbeatIntervalMs: 60_000,
});
await service.status(sessionRef("child-1", "/workspace-feature"));
child.session.isStreaming = true;
child.emit({ type: "agent_start" });
child.session.isStreaming = false;
child.emit({ type: "agent_end" });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(parent.calls.sendCustomMessage).toHaveLength(1);
expect(parent.calls.sendCustomMessage[0]?.message.content).toContain("Subsession child-1 stopped working");
expect(open).toHaveBeenCalledWith(parentFile);
await service.dispose();
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("notifies the validated parent file instead of an active prefix-matched parent id", async () => {
const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-prefix-parent-"));
const parentFile = join(tempDir, "parent.jsonl");
const forkParentFile = join(tempDir, "parent-fork.jsonl");
const childFile = join(tempDir, "child.jsonl");
await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8");
await writeFile(forkParentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1-fork", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8");
await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8");
try {
const childManager = fakeSessionManager("/workspace-feature", {
getHeader: () => ({ parentSession: parentFile }),
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }],
});
const parentManager = fakeSessionManager("/workspace", {
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }],
});
const forkManager = fakeSessionManager("/workspace");
const fork = fakeRuntime("parent-1-fork", { sessionFile: forkParentFile, sessionManager: forkManager });
const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager });
const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager });
const runtimes = [fork.runtime, child.runtime, parent.runtime];
let index = 0;
const open = vi.fn((path: string) => {
if (path === parentFile) return parentManager;
if (path === forkParentFile) return forkManager;
return childManager;
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: () => {
const runtime = runtimes[index] ?? parent.runtime;
index += 1;
return Promise.resolve(runtime);
},
sessionManager: {
create: () => forkManager,
list: (cwd: string) => Promise.resolve(cwd === "/workspace"
? [{ ...sessionRecord("parent-1-fork", "/workspace"), path: forkParentFile }]
: [{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]),
listAll: () => Promise.resolve([]),
open,
},
archiveStore: emptyArchiveStore(),
heartbeatIntervalMs: 60_000,
});
await service.status(sessionRef("parent-1-fork", "/workspace"));
await service.status(sessionRef("child-1", "/workspace-feature"));
child.session.isStreaming = true;
child.emit({ type: "agent_start" });
child.session.isStreaming = false;
child.emit({ type: "agent_end" });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(fork.calls.sendCustomMessage).toHaveLength(0);
expect(parent.calls.sendCustomMessage).toHaveLength(1);
expect(open).toHaveBeenCalledWith(parentFile);
await service.dispose();
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("does not relink a copied child with the original session id unless the parent link names the current child file", async () => {
const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-copied-child-"));
const parentFile = join(tempDir, "parent.jsonl");
const originalChildFile = join(tempDir, "original-child.jsonl");
const copiedChildFile = join(tempDir, "copied-child.jsonl");
await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8");
await writeFile(originalChildFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8");
await writeFile(copiedChildFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8");
try {
const childManager = fakeSessionManager("/workspace-feature", {
getHeader: () => ({ parentSession: parentFile }),
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }],
});
const parentManager = fakeSessionManager("/workspace", {
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: originalChildFile, cwd: "/workspace-feature" } }],
});
const child = fakeRuntime("child-1", { sessionFile: copiedChildFile, sessionManager: childManager });
const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager });
const runtimes = [child.runtime, parent.runtime];
let index = 0;
const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: () => {
const runtime = runtimes[index] ?? parent.runtime;
index += 1;
return Promise.resolve(runtime);
},
sessionManager: {
create: () => childManager,
list: () => Promise.resolve([{ ...sessionRecord("child-1", "/workspace-feature"), path: copiedChildFile, parentSessionPath: parentFile }]),
listAll: () => Promise.resolve([]),
open,
},
archiveStore: emptyArchiveStore(),
heartbeatIntervalMs: 60_000,
});
await service.status(sessionRef("child-1", "/workspace-feature"));
child.session.isStreaming = true;
child.emit({ type: "agent_start" });
child.session.isStreaming = false;
child.emit({ type: "agent_end" });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(parent.calls.sendCustomMessage).toHaveLength(0);
await service.dispose();
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("uses the verified child file instead of an active copied child with the same id", async () => {
const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-active-copy-child-"));
const parentFile = join(tempDir, "parent.jsonl");
const originalChildFile = join(tempDir, "original-child.jsonl");
const copiedChildFile = join(tempDir, "copied-child.jsonl");
await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8");
await writeFile(originalChildFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8");
await writeFile(copiedChildFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8");
try {
const copiedManager = fakeSessionManager("/workspace-feature", {
getBranch: () => [{ type: "message", message: { role: "assistant", content: "copied child result" } }],
});
const originalManager = fakeSessionManager("/workspace-feature", {
getBranch: () => [{ type: "message", message: { role: "assistant", content: "original child result" } }],
});
const parentManager = fakeSessionManager("/workspace", {
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: originalChildFile, cwd: "/workspace-feature" } }],
});
const copiedChild = fakeRuntime("child-1", { sessionFile: copiedChildFile, sessionManager: copiedManager, isStreaming: true });
const originalChild = fakeRuntime("child-1", { sessionFile: originalChildFile, sessionManager: originalManager });
const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager });
const createAgentRuntime: RuntimeCreator = (_createRuntime, options) => {
if (options.sessionManager === copiedManager) return Promise.resolve(copiedChild.runtime);
if (options.sessionManager === originalManager) return Promise.resolve(originalChild.runtime);
if (options.sessionManager === parentManager) return Promise.resolve(parent.runtime);
throw new Error("unexpected session manager");
};
const open = vi.fn((path: string) => {
if (path === copiedChildFile) return copiedManager;
if (path === originalChildFile) return originalManager;
if (path === parentFile) return parentManager;
throw new Error(`unexpected open path ${path}`);
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime,
sessionManager: {
create: () => parentManager,
list: (cwd: string) => Promise.resolve(cwd === "/workspace-feature" ? [{ ...sessionRecord("child-1", "/workspace-feature"), path: copiedChildFile, parentSessionPath: parentFile }] : []),
listAll: () => Promise.resolve([]),
open,
},
archiveStore: emptyArchiveStore(),
heartbeatIntervalMs: 60_000,
});
await service.status(sessionRef("child-1", "/workspace-feature"));
await service.start("/workspace");
await expect(service.listSubsessions("parent-1", parentFile)).resolves.toEqual([
{ sessionId: "child-1", cwd: "/workspace-feature", status: "idle" },
]);
copiedChild.session.isStreaming = true;
copiedChild.emit({ type: "agent_start" });
copiedChild.session.isStreaming = false;
copiedChild.emit({ type: "agent_end" });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(parent.calls.sendCustomMessage).toHaveLength(0);
await expect(service.checkSubsession("parent-1", "child-1", parentFile)).resolves.toMatchObject({
sessionId: "child-1",
cwd: "/workspace-feature",
status: "idle",
finalText: "original child result",
messageCount: 1,
});
const read = await service.readSubsession("parent-1", "child-1", { roles: ["assistant"] }, parentFile);
expect(read.entries[0]?.parts[0]).toMatchObject({ kind: "text", text: "original child result" });
expect(open).toHaveBeenCalledWith(originalChildFile);
await service.dispose();
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("uses the verified parent file instead of an active copied parent with the same id", async () => {
const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-active-copy-parent-"));
const parentFile = join(tempDir, "parent.jsonl");
const copiedParentFile = join(tempDir, "copied-parent.jsonl");
const childFile = join(tempDir, "child.jsonl");
await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8");
await writeFile(copiedParentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8");
await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8");
try {
const childManager = fakeSessionManager("/workspace-feature", {
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }],
getBranch: () => [{ type: "message", message: { role: "assistant", content: "child result" } }],
});
const parentManager = fakeSessionManager("/workspace", {
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }],
});
const copiedParentManager = fakeSessionManager("/workspace", { getEntries: () => [] });
const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager });
const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager });
const copiedParent = fakeRuntime("parent-1", { sessionFile: copiedParentFile, sessionManager: copiedParentManager });
const createAgentRuntime: RuntimeCreator = (_createRuntime, options) => {
if (options.sessionManager === childManager) return Promise.resolve(child.runtime);
if (options.sessionManager === parentManager) return Promise.resolve(parent.runtime);
if (options.sessionManager === copiedParentManager) return Promise.resolve(copiedParent.runtime);
throw new Error("unexpected session manager");
};
const open = vi.fn((path: string) => {
if (path === childFile) return childManager;
if (path === parentFile) return parentManager;
if (path === copiedParentFile) return copiedParentManager;
throw new Error(`unexpected open path ${path}`);
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime,
sessionManager: {
create: () => copiedParentManager,
list: (cwd: string) => Promise.resolve(cwd === "/workspace"
? [{ ...sessionRecord("parent-1", "/workspace"), path: copiedParentFile }]
: [{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]),
listAll: () => Promise.resolve([]),
open,
},
archiveStore: emptyArchiveStore(),
heartbeatIntervalMs: 60_000,
});
await service.status(sessionRef("child-1", "/workspace-feature"));
await service.status(sessionRef("parent-1", "/workspace"));
await expect(service.listSubsessions("parent-1", copiedParentFile)).resolves.toEqual([]);
await expect(service.checkSubsession("parent-1", "child-1", copiedParentFile)).rejects.toThrow("not one of your subsessions");
await expect(service.readSubsession("parent-1", "child-1", {}, copiedParentFile)).rejects.toThrow("not one of your subsessions");
child.session.isStreaming = true;
child.emit({ type: "agent_start" });
child.session.isStreaming = false;
child.emit({ type: "agent_end" });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(copiedParent.calls.sendCustomMessage).toHaveLength(0);
expect(parent.calls.sendCustomMessage).toHaveLength(1);
expect(parent.calls.sendCustomMessage[0]?.message.content).toContain("Subsession child-1 stopped working");
expect(open).toHaveBeenCalledWith(parentFile);
await service.dispose();
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("does not relink a child marker when the current child file header no longer records the parent", async () => {
const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-stale-child-header-"));
const parentFile = join(tempDir, "parent.jsonl");
const childFile = join(tempDir, "child.jsonl");
await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8");
await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature" })}\n`, "utf8");
try {
const childManager = fakeSessionManager("/workspace-feature", {
getHeader: () => ({ parentSession: parentFile }),
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }],
});
const parentManager = fakeSessionManager("/workspace", {
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }],
});
const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager });
const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager });
const runtimes = [child.runtime, parent.runtime];
let index = 0;
const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: () => {
const runtime = runtimes[index] ?? parent.runtime;
index += 1;
return Promise.resolve(runtime);
},
sessionManager: {
create: () => childManager,
list: () => Promise.resolve([{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]),
listAll: () => Promise.resolve([]),
open,
},
archiveStore: {
...emptyArchiveStore(),
get: (sessionId) => Promise.resolve(sessionId === "child-1" ? { sessionId: "child-1", cwd: "/workspace-feature", archivedAt: "2026-01-01T00:00:00.000Z", parentSessionPath: parentFile } : undefined),
},
heartbeatIntervalMs: 60_000,
});
await service.status(sessionRef("child-1", "/workspace-feature"));
child.session.isStreaming = true;
child.emit({ type: "agent_start" });
child.session.isStreaming = false;
child.emit({ type: "agent_end" });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(parent.calls.sendCustomMessage).toHaveLength(0);
expect(open).not.toHaveBeenCalledWith(parentFile);
await service.dispose();
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("does not relink a child marker when the child header points at a different parent id", async () => {
const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-wrong-parent-"));
const mismatchedParentFile = join(tempDir, "other-parent.jsonl");
const actualParentFile = join(tempDir, "parent.jsonl");
const childFile = join(tempDir, "child.jsonl");
await writeFile(mismatchedParentFile, `${JSON.stringify({ type: "session", version: 3, id: "other-parent", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8");
await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: mismatchedParentFile })}\n`, "utf8");
try {
const childManager = fakeSessionManager("/workspace-feature", {
getHeader: () => ({ parentSession: mismatchedParentFile }),
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }],
});
const parent = fakeRuntime("parent-1", { sessionFile: actualParentFile, sessionManager: fakeSessionManager("/workspace") });
const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager });
const runtimes = [child.runtime, parent.runtime];
let index = 0;
const open = vi.fn((path: string) => path === actualParentFile ? parent.session.sessionManager : childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: () => {
const runtime = runtimes[index] ?? parent.runtime;
index += 1;
return Promise.resolve(runtime);
},
sessionManager: {
create: () => childManager,
list: () => Promise.resolve([{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: mismatchedParentFile }]),
listAll: () => Promise.resolve([{ ...sessionRecord("parent-1", "/workspace"), path: actualParentFile }]),
open,
},
archiveStore: emptyArchiveStore(),
heartbeatIntervalMs: 60_000,
});
await service.status(sessionRef("child-1", "/workspace-feature"));
child.session.isStreaming = true;
child.emit({ type: "agent_start" });
child.session.isStreaming = false;
child.emit({ type: "agent_end" });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(parent.calls.sendCustomMessage).toHaveLength(0);
expect(open).not.toHaveBeenCalledWith(actualParentFile);
await service.dispose();
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("does not relink copied child markers when the opened child has a different id", async () => {
const parentFile = "/sessions/parent-1.jsonl";
const childFile = "/sessions/child-fork-1.jsonl";
const childManager = fakeSessionManager("/workspace-feature", {
getHeader: () => ({ parentSession: parentFile }),
getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }],
});
const child = fakeRuntime("child-fork-1", { sessionFile: childFile, sessionManager: childManager });
const open = vi.fn(() => childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(child.runtime),
sessionManager: {
create: () => childManager,
list: () => Promise.resolve([{ ...sessionRecord("child-fork-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]),
listAll: () => Promise.resolve([]),
open,
},
archiveStore: emptyArchiveStore(),
heartbeatIntervalMs: 60_000,
});
await service.status(sessionRef("child-fork-1", "/workspace-feature"));
child.session.isStreaming = true;
child.emit({ type: "agent_start" });
child.session.isStreaming = false;
child.emit({ type: "agent_end" });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(open).not.toHaveBeenCalledWith(parentFile);
await expect(service.listSubsessions("parent-1")).resolves.toEqual([]);
await service.dispose();
});
it("notifies the parent once when the tracked child stops working", async () => {
const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" });
await service.start("/workspace");
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" });
parent.calls.prompt.length = 0; // ignore the spawn prompt to the child; focus on the parent notification
child.session.isStreaming = true;
child.emit({ type: "agent_start" }); // arm the notification
child.session.isStreaming = false;
child.emit({ type: "agent_end" }); // fire once
child.emit({ type: "turn_end" }); // must not re-notify
await new Promise((resolve) => setTimeout(resolve, 20)); // the parent notification is delivered via the async custom-message path
expect(parent.calls.sendCustomMessage).toHaveLength(1);
expect(parent.calls.sendCustomMessage[0]?.message.content).toContain("Subsession child-1 stopped working");
expect(parent.calls.sendCustomMessage[0]?.message.customType).toBe("subsession.completion");
expect(parent.calls.sendCustomMessage[0]?.options).toEqual({ triggerTurn: true, deliverAs: "followUp" });
expect(parent.calls.prompt).toHaveLength(0); // not a user-authored message
await service.dispose();
});
it("notifies via the heartbeat when the child settles without a further event", async () => {
const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }, 10);
await service.start("/workspace");
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" });
parent.calls.prompt.length = 0;
// The child works, then settles silently: agent_end arrives while it still
// reports active work, so the event-driven latch does not fire here.
child.session.isStreaming = true;
child.emit({ type: "agent_start" });
child.emit({ type: "agent_end" });
expect(parent.calls.sendCustomMessage).toHaveLength(0);
// Once the session settles, the periodic heartbeat re-check notifies.
child.session.isStreaming = false;
await new Promise((resolve) => setTimeout(resolve, 40));
expect(parent.calls.sendCustomMessage).toHaveLength(1);
expect(parent.calls.sendCustomMessage[0]?.message.content).toContain("Subsession child-1 stopped working");
await service.dispose();
});
it("does not notify the parent when a tracked child is archived", async () => {
const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" });
await service.start("/workspace");
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" });
// Arm the notification, as a real working child would.
child.session.isStreaming = true;
child.emit({ type: "agent_start" });
child.session.isStreaming = false;
parent.calls.sendCustomMessage.length = 0;
await service.archive("child-1");
await new Promise((resolve) => setTimeout(resolve, 20));
expect(parent.calls.sendCustomMessage).toHaveLength(0);
await service.dispose();
});
it("reports a missing tracked child file as unknown in the subsession list", async () => {
const { service } = subsessionService({ allowed: true, cwd: "/workspace-feature" });
await service.start("/workspace");
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" });
await service.archive("child-1");
await expect(service.listSubsessions("parent-1")).resolves.toEqual([
{ sessionId: "child-1", cwd: "/workspace-feature", status: "unknown" },
]);
await service.dispose();
});
it("check_subsession and read_subsession refuse sessions that are not the caller's children", async () => {
const { service } = subsessionService({ allowed: true, cwd: "/workspace-feature" });
await service.start("/workspace");
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" });
await expect(service.checkSubsession("someone-else", "child-1")).rejects.toThrow("not one of your subsessions");
await expect(service.readSubsession("someone-else", "child-1", {})).rejects.toThrow("not one of your subsessions");
await service.dispose();
});
it("is disabled when no spawn target resolver is configured", async () => {
const fake = fakeRuntime("nope");
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
});
await expect(service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "p", parentSessionFile: undefined, prompt: "go", cwd: undefined }))
.rejects.toThrow("Spawning sessions is disabled");
await service.dispose();
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,165 @@
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
import type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes.js";
import { SessionEventHub } from "../realtime/sessionEventHub.js";
import type { PiAgentSession, PiSessionManager, PiSessionRuntime, PiSessionServiceDependencies } from "./piSessionService.js";
export class CapturingSessionEventHub extends SessionEventHub {
readonly sessionEvents: { sessionId: string; event: SessionUiEvent }[] = [];
readonly globalEvents: GlobalSessionEvent[] = [];
override publish(sessionId: string, event: SessionUiEvent): void {
this.sessionEvents.push({ sessionId, event });
}
override publishGlobal(event: GlobalSessionEvent): void {
this.globalEvents.push(event);
}
}
export type SessionGateway = NonNullable<PiSessionServiceDependencies["sessionManager"]>;
export type RuntimeCreator = NonNullable<PiSessionServiceDependencies["createAgentRuntime"]>;
export interface TestSession extends PiAgentSession {
sessionName: string | undefined;
model: PiAgentSession["model"];
isStreaming: boolean;
isCompacting: boolean;
isBashRunning: boolean;
pendingMessageCount: number;
getSteeringMessages: () => readonly string[];
getFollowUpMessages: () => readonly string[];
}
export function fakeSessionManager(cwd = "/workspace", patch: Partial<PiSessionManager> = {}): PiSessionManager {
return {
getCwd: () => cwd,
getBranch: () => [],
getLeafId: () => "leaf-1",
...patch,
};
}
export function sessionRecord(id: string, cwd = "/workspace") {
return { id, path: `/sessions/${id}.jsonl`, cwd, created: new Date("2026-01-01T00:00:00.000Z"), modified: new Date("2026-01-01T00:01:00.000Z"), messageCount: 0, firstMessage: "", allMessagesText: "" };
}
export function sessionRef(id: string, cwd = "/workspace") {
return { id, cwd };
}
export const TEST_MODEL_PROVIDER = "anthropic";
export const TEST_MODEL_ID = "claude-sonnet-4-5-20250929";
export function testModel(): NonNullable<PiAgentSession["model"]> {
const model = ModelRegistry.inMemory(AuthStorage.inMemory()).find(TEST_MODEL_PROVIDER, TEST_MODEL_ID);
if (model === undefined) throw new Error("test model not found");
return model;
}
export function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession> = {}) {
const promptCalls: { text: string; options: unknown }[] = [];
const customMessageCalls: { message: { customType: string; content: string; display: boolean; details?: unknown }; options: unknown }[] = [];
const bindExtensionCalls: unknown[] = [];
const listeners: ((event: unknown) => void)[] = [];
const calls = { abort: 0, bindExtensions: bindExtensionCalls, clearQueue: 0, dispose: 0, prompt: promptCalls, reload: 0, sendCustomMessage: customMessageCalls };
const session: TestSession = {
sessionId,
sessionFile: `/tmp/${sessionId}.jsonl`,
messages: [],
sessionName: undefined,
model: undefined,
thinkingLevel: "off",
isStreaming: false,
isCompacting: false,
isBashRunning: false,
pendingMessageCount: 0,
sessionManager: fakeSessionManager(),
modelRegistry: ModelRegistry.create(AuthStorage.inMemory()),
scopedModels: [],
extensionRunner: { getRegisteredCommands: () => [] },
promptTemplates: [],
resourceLoader: { getSkills: () => ({ skills: [] }) },
subscribe: (listener: (event: unknown) => void) => {
listeners.push(listener);
return () => {
const index = listeners.indexOf(listener);
if (index !== -1) listeners.splice(index, 1);
};
},
bindExtensions: (bindings: unknown) => {
calls.bindExtensions.push(bindings);
return Promise.resolve();
},
getSessionStats: () => ({ sessionId, totalMessages: 0, userMessages: 0, assistantMessages: 0, toolCalls: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, cost: 0 }),
getContextUsage: () => undefined,
reload: () => {
calls.reload += 1;
return Promise.resolve();
},
prompt: (text: string, options: unknown) => {
calls.prompt.push({ text, options });
return Promise.resolve();
},
sendCustomMessage: (message: { customType: string; content: string; display: boolean; details?: unknown }, options: unknown) => {
calls.sendCustomMessage.push({ message, options });
return Promise.resolve();
},
executeBash: () => Promise.resolve({ output: "", exitCode: 0, cancelled: false, truncated: false }),
abort: () => {
calls.abort += 1;
return Promise.resolve();
},
clearQueue: () => {
calls.clearQueue += 1;
return { steering: [], followUp: [] };
},
getSteeringMessages: () => [],
getFollowUpMessages: () => [],
setModel: () => Promise.resolve(),
cycleModel: () => Promise.resolve(undefined),
getAvailableThinkingLevels: () => [],
setThinkingLevel: () => undefined,
cycleThinkingLevel: () => undefined,
setSessionName: (name: string) => { session.sessionName = name; },
compact: () => Promise.resolve({ summary: "", tokensBefore: 0 }),
getUserMessagesForForking: () => [],
agent: { streamFn: () => { throw new Error("streamFn should not be called in this test"); } },
...patch,
};
const runtime: PiSessionRuntime = {
cwd: session.sessionManager.getCwd(),
session,
setRebindSession: () => undefined,
fork: () => Promise.resolve({ cancelled: false }),
dispose: () => {
calls.dispose += 1;
return Promise.resolve();
},
};
return { runtime, session, calls, emit: (event: unknown) => { for (const listener of [...listeners]) listener(event); } };
}
export function runtimeCreator(runtime: PiSessionRuntime): RuntimeCreator {
return async () => {
await Promise.resolve();
return runtime;
};
}
export function sessionGateway(records: ReturnType<typeof sessionRecord>[]): SessionGateway {
return {
create: () => fakeSessionManager(),
list: () => Promise.resolve(records),
open: () => fakeSessionManager(),
};
}
export function emptyArchiveStore(): NonNullable<PiSessionServiceDependencies["archiveStore"]> {
return {
list: () => Promise.resolve([]),
get: () => Promise.resolve(undefined),
archive: () => Promise.reject(new Error("archive should not be called")),
restore: () => Promise.resolve(),
isArchived: () => Promise.resolve(false),
};
}
@@ -1,6 +1,6 @@
import { constants } from "node:fs";
import { access, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, describe, expect, it } from "vitest";
import { SessionArchiveStore } from "./sessionArchiveStore.js";
@@ -118,6 +118,54 @@ describe("SessionArchiveStore", () => {
}
await expect(store.list()).resolves.toEqual([]);
});
it("prefers exact persisted session IDs over prefix matches and canonicalizes stored cwd", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-web-archive-prefix-"));
tempRoots.push(root);
const archiveFile = join(root, "archived-sessions.json");
const rawCwd = join(root, "workspace", "..", "workspace");
await writeFile(archiveFile, JSON.stringify({
sessions: [
{
sessionId: "abc123",
cwd: rawCwd,
archivedAt: "2026-01-01T00:00:00.000Z",
originalPath: "/sessions/abc123.jsonl",
archivePath: "/archive/abc123.jsonl",
messageCount: 3,
firstMessage: "prefix",
name: "Prefix match",
parentSessionPath: "/sessions/root.jsonl",
},
{
sessionId: "abc",
cwd: rawCwd,
archivedAt: "2026-01-01T00:00:00.000Z",
originalPath: "/sessions/abc.jsonl",
archivePath: "/archive/abc.jsonl",
messageCount: 1,
firstMessage: "exact",
},
],
}), "utf8");
const store = new SessionArchiveStore(archiveFile, join(root, "archived-files"));
await expect(store.get("abc")).resolves.toMatchObject({
sessionId: "abc",
cwd: resolve(rawCwd),
firstMessage: "exact",
});
await expect(store.get("abc1")).resolves.toMatchObject({
sessionId: "abc123",
cwd: resolve(rawCwd),
firstMessage: "prefix",
name: "Prefix match",
parentSessionPath: "/sessions/root.jsonl",
});
await expect(store.isArchived("abc1")).resolves.toBe(true);
await expect(store.isArchived("missing")).resolves.toBe(false);
});
});
async function exists(path: string): Promise<boolean> {
@@ -11,11 +11,11 @@ function candidate(id: string, options: Partial<SessionArchiveTreeCandidate> = {
}
describe("session archive tree planning", () => {
it("finds candidates by full id or prefix", () => {
const candidates = [candidate("abcdef"), candidate("xyz")];
it("finds candidates by exact id before falling back to a prefix", () => {
const candidates = [candidate("abcdef"), candidate("abc"), candidate("xyz")];
expect(findArchiveCandidateByIdOrPrefix(candidates, "abcdef")?.id).toBe("abcdef");
expect(findArchiveCandidateByIdOrPrefix(candidates, "abc")?.id).toBe("abcdef");
expect(findArchiveCandidateByIdOrPrefix(candidates, "abc")?.id).toBe("abc");
expect(findArchiveCandidateByIdOrPrefix(candidates, "abcd")?.id).toBe("abcdef");
expect(findArchiveCandidateByIdOrPrefix(candidates, "missing")).toBeUndefined();
});
@@ -1,4 +1,7 @@
import { describe, expect, it } from "vitest";
import type { RealtimeEvent, TerminalInfo } from "../../shared/apiTypes.js";
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
import { SessionEventHub } from "../realtime/sessionEventHub.js";
import { TerminalService } from "./terminalService";
// TerminalService spawns a POSIX shell (/bin/bash with -lc and commands like
@@ -90,8 +93,93 @@ describe.skipIf(process.platform === "win32")("TerminalService command runs", ()
service.dispose();
}
});
it("publishes terminal lifecycle events and workspace activity updates", async () => {
const events = new RecordingEventHub();
const workspaceActivity = createWorkspaceActivityRecorder();
const service = new TerminalService(events, workspaceActivity);
const cwd = process.cwd();
try {
const run = service.runCommand({
origin: "core",
projectId: "p1",
workspaceId: "w1",
cwd,
title: "Lifecycle command",
command: "true",
});
const runningTerminal = requireTerminal(service, run.terminalId);
expect(workspaceActivity.updated).toEqual([{ id: run.terminalId, cwd, exited: false }]);
expect(events.events).toEqual([{ type: "terminal.created", terminal: runningTerminal }]);
await terminalExit(service, run.terminalId);
const exitedTerminal = requireTerminal(service, run.terminalId);
expect(workspaceActivity.updated).toEqual([
{ id: run.terminalId, cwd, exited: false },
{ id: run.terminalId, cwd, exited: true },
]);
expect(events.events).toEqual([
{ type: "terminal.created", terminal: runningTerminal },
{ type: "terminal.exited", terminal: exitedTerminal },
]);
service.close(run.terminalId);
expect(workspaceActivity.removed).toEqual([{ terminalId: run.terminalId, cwd }]);
expect(events.events).toEqual([
{ type: "terminal.created", terminal: runningTerminal },
{ type: "terminal.exited", terminal: exitedTerminal },
{ type: "terminal.closed", terminalId: run.terminalId, cwd },
]);
} finally {
service.dispose();
}
});
});
class RecordingEventHub extends SessionEventHub {
readonly events: RealtimeEvent[] = [];
override publishRealtime(event: RealtimeEvent): void {
this.events.push(event);
}
}
interface WorkspaceActivityRecorder extends Pick<WorkspaceActivityService, "updateTerminal" | "removeTerminal"> {
readonly updated: TerminalActivityUpdate[];
readonly removed: TerminalActivityRemoval[];
}
type TerminalActivityUpdate = Pick<TerminalInfo, "id" | "cwd" | "exited">;
interface TerminalActivityRemoval {
terminalId: string;
cwd: string | undefined;
}
function createWorkspaceActivityRecorder(): WorkspaceActivityRecorder {
const updated: TerminalActivityUpdate[] = [];
const removed: TerminalActivityRemoval[] = [];
return {
updated,
removed,
updateTerminal: (terminal) => {
updated.push({ id: terminal.id, cwd: terminal.cwd, exited: terminal.exited });
},
removeTerminal: (terminalId, cwd) => {
removed.push({ terminalId, cwd });
},
};
}
function requireTerminal(service: TerminalService, terminalId: string): TerminalInfo {
const terminal = service.get(terminalId);
if (terminal === undefined) throw new Error(`Expected terminal ${terminalId} to exist`);
return terminal;
}
function terminalReplay(service: TerminalService, terminalId: string): Promise<string> {
let output = "";
const detach = service.attach(terminalId, {
+107 -12
View File
@@ -1,38 +1,115 @@
import { afterEach, describe, expect, it } from "vitest";
import { WebSocket, WebSocketServer, type RawData } from "ws";
import { createBufferedSender } from "./webSocketBridge.js";
import { bridgeSockets, createBufferedSender } from "./webSocketBridge.js";
let server: WebSocketServer | undefined;
const servers = new Set<WebSocketServer>();
const sockets = new Set<WebSocket>();
afterEach(async () => {
const socketServer = server;
if (socketServer === undefined) return;
await new Promise<void>((resolve) => {
socketServer.close(() => { resolve(); });
for (const socket of sockets) closeSocket(socket);
await Promise.all(Array.from(servers, closeSocketServer));
sockets.clear();
servers.clear();
});
describe("bridgeSockets", () => {
it("forwards messages in both directions while sockets are open", async () => {
const clientSide = await createSocketPair();
const upstreamSide = await createSocketPair();
bridgeSockets(clientSide.bridgeSocket, upstreamSide.bridgeSocket);
const forwardedToUpstream = nextMessage(upstreamSide.peerSocket);
clientSide.peerSocket.send("to-upstream");
await expect(forwardedToUpstream).resolves.toBe("to-upstream");
const forwardedToClient = nextMessage(clientSide.peerSocket);
upstreamSide.peerSocket.send("to-client");
await expect(forwardedToClient).resolves.toBe("to-client");
});
it("propagates close and error events to the opposite socket", async () => {
const closeCaseClientSide = await createSocketPair();
const closeCaseUpstreamSide = await createSocketPair();
bridgeSockets(closeCaseClientSide.bridgeSocket, closeCaseUpstreamSide.bridgeSocket);
const upstreamClosed = nextClose(closeCaseUpstreamSide.peerSocket);
closeCaseClientSide.peerSocket.close();
await upstreamClosed;
const errorCaseClientSide = await createSocketPair();
const errorCaseUpstreamSide = await createSocketPair();
bridgeSockets(errorCaseClientSide.bridgeSocket, errorCaseUpstreamSide.bridgeSocket);
const clientClosed = nextClose(errorCaseClientSide.peerSocket);
errorCaseUpstreamSide.bridgeSocket.emit("error", new Error("upstream failed"));
await clientClosed;
});
server = undefined;
});
describe("createBufferedSender", () => {
it("queues messages while a WebSocket is still connecting", async () => {
const socketServer = new WebSocketServer({ host: "127.0.0.1", port: 0 });
server = socketServer;
const socketServer = createServer();
const connected = new Promise<WebSocket>((resolve) => {
socketServer.once("connection", resolve);
socketServer.once("connection", (socket) => {
sockets.add(socket);
resolve(socket);
});
});
await waitForListening(socketServer);
const client = new WebSocket(serverUrl(socketServer));
sockets.add(client);
const send = createBufferedSender(client);
send("queued-before-open");
const serverSocket = await connected;
await expect(nextMessage(serverSocket)).resolves.toBe("queued-before-open");
client.close();
serverSocket.close();
closeSocket(client);
closeSocket(serverSocket);
});
});
interface SocketPair {
bridgeSocket: WebSocket;
peerSocket: WebSocket;
}
async function createSocketPair(): Promise<SocketPair> {
const socketServer = createServer();
const connected = new Promise<WebSocket>((resolve) => {
socketServer.once("connection", (socket) => {
sockets.add(socket);
resolve(socket);
});
});
await waitForListening(socketServer);
const peerSocket = new WebSocket(serverUrl(socketServer));
sockets.add(peerSocket);
const opened = nextOpen(peerSocket);
const bridgeSocket = await connected;
await opened;
return { bridgeSocket, peerSocket };
}
function createServer(): WebSocketServer {
const socketServer = new WebSocketServer({ host: "127.0.0.1", port: 0 });
servers.add(socketServer);
return socketServer;
}
function closeSocket(socket: WebSocket): void {
if (socket.readyState !== WebSocket.CONNECTING && socket.readyState !== WebSocket.OPEN) return;
socket.close();
}
function closeSocketServer(socketServer: WebSocketServer): Promise<void> {
return new Promise<void>((resolve) => {
socketServer.close(() => { resolve(); });
});
}
function waitForListening(socketServer: WebSocketServer): Promise<void> {
if (socketServer.address() !== null) return Promise.resolve();
return new Promise((resolve, reject) => {
@@ -50,6 +127,24 @@ function serverUrl(socketServer: WebSocketServer): string {
return `ws://127.0.0.1:${String(address.port)}`;
}
function nextOpen(socket: WebSocket): Promise<void> {
if (socket.readyState === WebSocket.OPEN) return Promise.resolve();
return new Promise((resolve, reject) => {
socket.once("error", reject);
socket.once("open", () => {
socket.off("error", reject);
resolve();
});
});
}
function nextClose(socket: WebSocket): Promise<void> {
if (socket.readyState === WebSocket.CLOSED) return Promise.resolve();
return new Promise((resolve) => {
socket.once("close", () => { resolve(); });
});
}
function nextMessage(socket: WebSocket): Promise<string> {
return new Promise((resolve) => {
socket.once("message", (data) => {
@@ -0,0 +1,81 @@
import { mkdir, readFile, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { deleteWorkspaceFile, readWorkspaceFile } from "./fileContentService.js";
import { cleanupTempWorkspaces, createTempWorkspace } from "./fileContentService.testSupport.js";
afterEach(async () => {
await cleanupTempWorkspaces();
});
describe("deleteWorkspaceFile", () => {
it("deletes an existing file and returns existed: true", async () => {
const root = await createTempWorkspace();
await writeFile(join(root, "notes.txt"), "hello");
const result = await deleteWorkspaceFile(root, "notes.txt");
expect(result).toMatchObject({ path: "notes.txt", existed: true });
await expect(readWorkspaceFile(root, "notes.txt")).rejects.toThrow("Path does not exist");
});
it("returns existed: false when deleting a non-existent file", async () => {
const root = await createTempWorkspace();
const result = await deleteWorkspaceFile(root, "missing.txt");
expect(result).toMatchObject({ path: "missing.txt", existed: false });
});
it("rejects deleting a directory", async () => {
const root = await createTempWorkspace();
await mkdir(join(root, "mydir"), { recursive: true });
await expect(deleteWorkspaceFile(root, "mydir")).rejects.toThrow("Path is a directory");
});
it("rejects traversal and absolute paths", async () => {
const root = await createTempWorkspace();
await expect(deleteWorkspaceFile(root, "../secret.txt")).rejects.toThrow("Path traversal is not allowed");
await expect(deleteWorkspaceFile(root, "/etc/passwd")).rejects.toThrow("Absolute paths are not allowed");
});
it("rejects missing path", async () => {
const root = await createTempWorkspace();
await expect(deleteWorkspaceFile(root, undefined)).rejects.toThrow("path query parameter is required");
await expect(deleteWorkspaceFile(root, "")).rejects.toThrow("path query parameter is required");
});
it("deletes a symlink itself, not its target", async () => {
const root = await createTempWorkspace();
const outsideDir = await createTempWorkspace("pi-web-outside-delete-");
await writeFile(join(outsideDir, "real.txt"), "real content");
// Create a symlink inside the workspace pointing outside
await symlink(join(outsideDir, "real.txt"), join(root, "link.txt"));
const result = await deleteWorkspaceFile(root, "link.txt");
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("Path does not exist");
const realContent = await readFile(join(outsideDir, "real.txt"), "utf8");
expect(realContent).toBe("real content");
});
it("prevents deleting through a symlinked parent directory that escapes the workspace", async () => {
const root = await createTempWorkspace();
await mkdir(join(root, "subdir"), { recursive: true });
// A real file living outside the workspace that must not be deletable.
const outsideDir = await createTempWorkspace("pi-web-outside-delete-parent-");
await writeFile(join(outsideDir, "victim.txt"), "important");
// A symlinked parent directory inside the workspace pointing outside.
await symlink(outsideDir, join(root, "subdir", "escape"), "junction");
await expect(deleteWorkspaceFile(root, "subdir/escape/victim.txt")).rejects.toThrow("Path escapes workspace");
// The outside file must survive.
const realContent = await readFile(join(outsideDir, "victim.txt"), "utf8");
expect(realContent).toBe("important");
});
});
@@ -0,0 +1,127 @@
import { mkdir, readFile, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { moveWorkspaceFile, readWorkspaceFile } from "./fileContentService.js";
import { cleanupTempWorkspaces, createTempWorkspace } from "./fileContentService.testSupport.js";
afterEach(async () => {
await cleanupTempWorkspaces();
});
describe("moveWorkspaceFile", () => {
it("moves a file to a new path", async () => {
const root = await createTempWorkspace();
await writeFile(join(root, "original.txt"), "content");
const result = await moveWorkspaceFile(root, "original.txt", "moved.txt");
expect(result).toMatchObject({ fromPath: "original.txt", toPath: "moved.txt" });
expect(result.size).toBe(7);
expect(Date.parse(result.modifiedAt)).not.toBeNaN();
// Source should no longer exist
await expect(readWorkspaceFile(root, "original.txt")).rejects.toThrow("Path does not exist");
// Target should exist
const target = await readWorkspaceFile(root, "moved.txt");
expect(target.content).toBe("content");
});
it("creates intermediate directories by default", async () => {
const root = await createTempWorkspace();
await writeFile(join(root, "file.txt"), "data");
await moveWorkspaceFile(root, "file.txt", "deep/nested/dir/file.txt");
const target = await readWorkspaceFile(root, "deep/nested/dir/file.txt");
expect(target.content).toBe("data");
});
it("fails when createDirs is false and parent directory does not exist", async () => {
const root = await createTempWorkspace();
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 () => {
const root = await createTempWorkspace();
await writeFile(join(root, "source.txt"), "source content");
await writeFile(join(root, "target.txt"), "target content");
const result = await moveWorkspaceFile(root, "source.txt", "target.txt", { overwrite: true });
expect(result.toPath).toBe("target.txt");
const target = await readWorkspaceFile(root, "target.txt");
expect(target.content).toBe("source content");
});
it("throws when target exists and overwrite is false (default)", async () => {
const root = await createTempWorkspace();
await writeFile(join(root, "source.txt"), "source");
await writeFile(join(root, "target.txt"), "target");
await expect(moveWorkspaceFile(root, "source.txt", "target.txt")).rejects.toThrow("File already exists");
// 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 () => {
const root = await createTempWorkspace();
await expect(moveWorkspaceFile(root, "../secret.txt", "target.txt")).rejects.toThrow("Path traversal is not allowed");
});
it("rejects target path traversal", async () => {
const root = await createTempWorkspace();
await writeFile(join(root, "source.txt"), "data");
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 () => {
const root = await createTempWorkspace();
await mkdir(join(root, "mydir"), { recursive: true });
await expect(moveWorkspaceFile(root, "mydir", "newdir")).rejects.toThrow("Source path is not a file");
});
it("rejects missing fromPath or toPath", async () => {
const root = await createTempWorkspace();
await expect(moveWorkspaceFile(root, undefined, "target.txt")).rejects.toThrow("fromPath query parameter is required");
await expect(moveWorkspaceFile(root, "source.txt", undefined)).rejects.toThrow("toPath query parameter is required");
await expect(moveWorkspaceFile(root, "", "target.txt")).rejects.toThrow("fromPath query parameter is required");
await expect(moveWorkspaceFile(root, "source.txt", "")).rejects.toThrow("toPath query parameter is required");
});
it("prevents moving through symlinks that escape the workspace", async () => {
const root = await createTempWorkspace();
await mkdir(join(root, "subdir"), { recursive: true });
await writeFile(join(root, "subdir", "file.txt"), "data");
// Create a symlink inside the workspace that points outside
const outsideDir = await createTempWorkspace("pi-web-move-outside-");
await symlink(outsideDir, join(root, "subdir", "escape"), "junction");
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" });
});
it("prevents moving a source symlink that escapes the workspace", async () => {
const root = await createTempWorkspace();
const outsideDir = await createTempWorkspace("pi-web-move-source-outside-");
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");
});
});
@@ -0,0 +1,107 @@
import { mkdir, truncate, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { MAX_IMAGE_PREVIEW_BYTES } from "../../shared/workspaceFiles.js";
import { readWorkspaceFile } from "./fileContentService.js";
import { cleanupTempWorkspaces, createTempWorkspace } from "./fileContentService.testSupport.js";
import { readWorkspaceImagePreview } from "./imagePreviewService.js";
afterEach(async () => {
await cleanupTempWorkspaces();
});
describe("readWorkspaceFile", () => {
it("reads text files with normalized paths and language metadata", async () => {
const root = await createTempWorkspace();
await mkdir(join(root, "src"));
await writeFile(join(root, "src", "main.ts"), "const answer = 42;\n");
const file = await readWorkspaceFile(root, "./src//main.ts");
expect(file).toMatchObject({
path: "src/main.ts",
language: "typescript",
encoding: "utf8",
content: "const answer = 42;\n",
truncated: false,
binary: false,
});
expect(file.size).toBe(19);
expect(Date.parse(file.modifiedAt)).not.toBeNaN();
});
it("rejects missing paths, directories, traversal, and absolute paths", async () => {
const root = await createTempWorkspace();
await mkdir(join(root, "dir"));
await expect(readWorkspaceFile(root, undefined)).rejects.toThrow("path query parameter is required");
await expect(readWorkspaceFile(root, "dir")).rejects.toThrow("Path is not a file");
await expect(readWorkspaceFile(root, "missing.txt")).rejects.toThrow("Path does not exist");
await expect(readWorkspaceFile(root, "../secret.txt")).rejects.toThrow("Path traversal is not allowed");
await expect(readWorkspaceFile(root, "/etc/passwd")).rejects.toThrow("Absolute paths are not allowed");
});
it("reads allowed absolute files outside the workspace", async () => {
const root = await createTempWorkspace();
const external = await createTempWorkspace();
await writeFile(join(external, "README.md"), "external docs\n");
const file = await readWorkspaceFile(root, join(external, "README.md"), { allowedPaths: [external] });
expect(file).toMatchObject({
path: join(external, "README.md"),
language: "markdown",
content: "external docs\n",
truncated: false,
binary: false,
});
await expect(readWorkspaceFile(root, join(external, "README.md"))).rejects.toThrow("Absolute paths are not allowed");
});
it("detects binary files and omits binary content", async () => {
const root = await createTempWorkspace();
await writeFile(join(root, "image.bin"), Buffer.from([0x66, 0x6f, 0x00, 0x6f]));
const file = await readWorkspaceFile(root, "image.bin");
expect(file).toMatchObject({ content: "", binary: true, truncated: false });
expect(file.size).toBe(4);
});
it("marks supported images as previewable", async () => {
const root = await createTempWorkspace();
await writeFile(join(root, "logo.PNG"), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]));
const file = await readWorkspaceFile(root, "logo.PNG");
expect(file).toMatchObject({ mediaType: "image", mimeType: "image/png", content: "", binary: true, truncated: false });
expect(file.size).toBe(9);
});
it("opens image preview streams only for supported images within the preview size limit", async () => {
const root = await createTempWorkspace();
await writeFile(join(root, "diagram.svg"), "<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>");
await writeFile(join(root, "note.txt"), "hello");
await writeFile(join(root, "huge.png"), "");
await truncate(join(root, "huge.png"), MAX_IMAGE_PREVIEW_BYTES + 1);
const preview = await readWorkspaceImagePreview(root, "diagram.svg");
preview.stream.destroy();
expect(preview).toMatchObject({ path: "diagram.svg", mimeType: "image/svg+xml", size: 46 });
await expect(readWorkspaceImagePreview(root, "note.txt")).rejects.toThrow("Image preview is not supported");
await expect(readWorkspaceImagePreview(root, "huge.png")).rejects.toThrow("Image is too large to preview");
});
it("truncates large text files", async () => {
const root = await createTempWorkspace();
await writeFile(join(root, "large.md"), "a".repeat(512 * 1024 + 7));
const file = await readWorkspaceFile(root, "large.md");
expect(file.language).toBe("markdown");
expect(file.content).toHaveLength(512 * 1024);
expect(file.truncated).toBe(true);
expect(file.binary).toBe(false);
});
});
@@ -1,395 +0,0 @@
import { mkdtemp, mkdir, readFile, rm, symlink, truncate, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { MAX_IMAGE_PREVIEW_BYTES } from "../../shared/workspaceFiles.js";
import { readWorkspaceFile, writeWorkspaceFile } from "./fileContentService.js";
import { deleteWorkspaceFile, moveWorkspaceFile } from "./fileContentService.js";
import { readWorkspaceImagePreview } from "./imagePreviewService.js";
const roots: string[] = [];
async function tempWorkspace(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "pi-web-file-content-"));
roots.push(root);
return root;
}
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
describe("readWorkspaceFile", () => {
it("reads text files with normalized paths and language metadata", async () => {
const root = await tempWorkspace();
await mkdir(join(root, "src"));
await writeFile(join(root, "src", "main.ts"), "const answer = 42;\n");
const file = await readWorkspaceFile(root, "./src//main.ts");
expect(file).toMatchObject({
path: "src/main.ts",
language: "typescript",
encoding: "utf8",
content: "const answer = 42;\n",
truncated: false,
binary: false,
});
expect(file.size).toBe(19);
expect(Date.parse(file.modifiedAt)).not.toBeNaN();
});
it("rejects missing paths, directories, traversal, and absolute paths", async () => {
const root = await tempWorkspace();
await mkdir(join(root, "dir"));
await expect(readWorkspaceFile(root, undefined)).rejects.toThrow("path query parameter is required");
await expect(readWorkspaceFile(root, "dir")).rejects.toThrow("Path is not a file");
await expect(readWorkspaceFile(root, "missing.txt")).rejects.toThrow("Path does not exist");
await expect(readWorkspaceFile(root, "../secret.txt")).rejects.toThrow("Path traversal is not allowed");
await expect(readWorkspaceFile(root, "/etc/passwd")).rejects.toThrow("Absolute paths are not allowed");
});
it("reads allowed absolute files outside the workspace", async () => {
const root = await tempWorkspace();
const external = await tempWorkspace();
await writeFile(join(external, "README.md"), "external docs\n");
const file = await readWorkspaceFile(root, join(external, "README.md"), { allowedPaths: [external] });
expect(file).toMatchObject({
path: join(external, "README.md"),
language: "markdown",
content: "external docs\n",
truncated: false,
binary: false,
});
await expect(readWorkspaceFile(root, join(external, "README.md"))).rejects.toThrow("Absolute paths are not allowed");
});
it("detects binary files and omits binary content", async () => {
const root = await tempWorkspace();
await writeFile(join(root, "image.bin"), Buffer.from([0x66, 0x6f, 0x00, 0x6f]));
const file = await readWorkspaceFile(root, "image.bin");
expect(file).toMatchObject({ content: "", binary: true, truncated: false });
expect(file.size).toBe(4);
});
it("marks supported images as previewable", async () => {
const root = await tempWorkspace();
await writeFile(join(root, "logo.PNG"), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]));
const file = await readWorkspaceFile(root, "logo.PNG");
expect(file).toMatchObject({ mediaType: "image", mimeType: "image/png", content: "", binary: true, truncated: false });
expect(file.size).toBe(9);
});
it("opens image preview streams only for supported images within the preview size limit", async () => {
const root = await tempWorkspace();
await writeFile(join(root, "diagram.svg"), "<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>");
await writeFile(join(root, "note.txt"), "hello");
await writeFile(join(root, "huge.png"), "");
await truncate(join(root, "huge.png"), MAX_IMAGE_PREVIEW_BYTES + 1);
const preview = await readWorkspaceImagePreview(root, "diagram.svg");
preview.stream.destroy();
expect(preview).toMatchObject({ path: "diagram.svg", mimeType: "image/svg+xml", size: 46 });
await expect(readWorkspaceImagePreview(root, "note.txt")).rejects.toThrow("Image preview is not supported");
await expect(readWorkspaceImagePreview(root, "huge.png")).rejects.toThrow("Image is too large to preview");
});
it("truncates large text files", async () => {
const root = await tempWorkspace();
await writeFile(join(root, "large.md"), "a".repeat(512 * 1024 + 7));
const file = await readWorkspaceFile(root, "large.md");
expect(file.language).toBe("markdown");
expect(file.content).toHaveLength(512 * 1024);
expect(file.truncated).toBe(true);
expect(file.binary).toBe(false);
});
});
describe("writeWorkspaceFile", () => {
it("writes text content to a new file with normalized paths", async () => {
const root = await tempWorkspace();
const result = await writeWorkspaceFile(root, "./src//hello.ts", Buffer.from("const greeting = 'hello';\n"));
expect(result).toMatchObject({ path: "src/hello.ts", created: true });
expect(result.size).toBe(26);
expect(Date.parse(result.modifiedAt)).not.toBeNaN();
// Verify the file was actually written
const content = await readFile(join(root, "src", "hello.ts"), "utf8");
expect(content).toBe("const greeting = 'hello';\n");
});
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 () => {
const root = await tempWorkspace();
await writeFile(join(root, "notes.txt"), "old content");
const result = await writeWorkspaceFile(root, "notes.txt", Buffer.from("new content"));
expect(result).toMatchObject({ path: "notes.txt", created: false, size: 11 });
const content = await readFile(join(root, "notes.txt"), "utf8");
expect(content).toBe("new content");
});
it("throws when overwrite is false and file exists", async () => {
const root = await tempWorkspace();
await writeFile(join(root, "existing.txt"), "data");
await expect(writeWorkspaceFile(root, "existing.txt", Buffer.from("new"), { overwrite: false })).rejects.toThrow("File already exists");
});
it("creates intermediate directories by default", async () => {
const root = await tempWorkspace();
await writeWorkspaceFile(root, "deep/nested/dir/file.txt", Buffer.from("deep content"));
const content = await readFile(join(root, "deep", "nested", "dir", "file.txt"), "utf8");
expect(content).toBe("deep content");
});
it("fails when createDirs is false and parent directory does not exist", async () => {
const root = await tempWorkspace();
await expect(writeWorkspaceFile(root, "missing/dir/file.txt", Buffer.from("x"), { createDirs: false })).rejects.toThrow();
});
it("rejects missing paths, traversal, and absolute paths", async () => {
const root = await tempWorkspace();
await expect(writeWorkspaceFile(root, undefined, Buffer.from("x"))).rejects.toThrow("path query parameter is required");
await expect(writeWorkspaceFile(root, "../secret.txt", Buffer.from("x"))).rejects.toThrow("Path traversal is not allowed");
await expect(writeWorkspaceFile(root, "/etc/passwd", Buffer.from("x"))).rejects.toThrow("Absolute paths are not allowed");
});
it("rejects writing to a directory path", async () => {
const root = await tempWorkspace();
await mkdir(join(root, "mydir"), { recursive: true });
await expect(writeWorkspaceFile(root, "mydir", Buffer.from("data"))).rejects.toThrow("Path is not a file");
});
it("prevents writing through symlinks that escape the workspace", async () => {
const root = await tempWorkspace();
await mkdir(join(root, "subdir"), { recursive: true });
const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-outside-"));
roots.push(outsideDir);
await symlink(outsideDir, join(root, "subdir", "escape"), "junction");
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" });
});
});
describe("deleteWorkspaceFile", () => {
it("deletes an existing file and returns existed: true", async () => {
const root = await tempWorkspace();
await writeFile(join(root, "notes.txt"), "hello");
const result = await deleteWorkspaceFile(root, "notes.txt");
expect(result).toMatchObject({ path: "notes.txt", existed: true });
await expect(readWorkspaceFile(root, "notes.txt")).rejects.toThrow("Path does not exist");
});
it("returns existed: false when deleting a non-existent file", async () => {
const root = await tempWorkspace();
const result = await deleteWorkspaceFile(root, "missing.txt");
expect(result).toMatchObject({ path: "missing.txt", existed: false });
});
it("rejects deleting a directory", async () => {
const root = await tempWorkspace();
await mkdir(join(root, "mydir"), { recursive: true });
await expect(deleteWorkspaceFile(root, "mydir")).rejects.toThrow("Path is a directory");
});
it("rejects traversal and absolute paths", async () => {
const root = await tempWorkspace();
await expect(deleteWorkspaceFile(root, "../secret.txt")).rejects.toThrow("Path traversal is not allowed");
await expect(deleteWorkspaceFile(root, "/etc/passwd")).rejects.toThrow("Absolute paths are not allowed");
});
it("rejects missing path", async () => {
const root = await tempWorkspace();
await expect(deleteWorkspaceFile(root, undefined)).rejects.toThrow("path query parameter is required");
await expect(deleteWorkspaceFile(root, "")).rejects.toThrow("path query parameter is required");
});
it("deletes a symlink itself, not its target", async () => {
const root = await tempWorkspace();
const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-outside-delete-"));
roots.push(outsideDir);
await writeFile(join(outsideDir, "real.txt"), "real content");
// Create a symlink inside the workspace pointing outside
await symlink(join(outsideDir, "real.txt"), join(root, "link.txt"));
const result = await deleteWorkspaceFile(root, "link.txt");
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("Path does not exist");
const realContent = await readFile(join(outsideDir, "real.txt"), "utf8");
expect(realContent).toBe("real content");
});
it("prevents deleting through a symlinked parent directory that escapes the workspace", async () => {
const root = await tempWorkspace();
await mkdir(join(root, "subdir"), { recursive: true });
// A real file living outside the workspace that must not be deletable.
const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-outside-delete-parent-"));
roots.push(outsideDir);
await writeFile(join(outsideDir, "victim.txt"), "important");
// A symlinked parent directory inside the workspace pointing outside.
await symlink(outsideDir, join(root, "subdir", "escape"), "junction");
await expect(deleteWorkspaceFile(root, "subdir/escape/victim.txt")).rejects.toThrow("Path escapes workspace");
// The outside file must survive.
const realContent = await readFile(join(outsideDir, "victim.txt"), "utf8");
expect(realContent).toBe("important");
});
});
describe("moveWorkspaceFile", () => {
it("moves a file to a new path", async () => {
const root = await tempWorkspace();
await writeFile(join(root, "original.txt"), "content");
const result = await moveWorkspaceFile(root, "original.txt", "moved.txt");
expect(result).toMatchObject({ fromPath: "original.txt", toPath: "moved.txt" });
expect(result.size).toBe(7);
expect(Date.parse(result.modifiedAt)).not.toBeNaN();
// Source should no longer exist
await expect(readWorkspaceFile(root, "original.txt")).rejects.toThrow("Path does not exist");
// Target should exist
const target = await readWorkspaceFile(root, "moved.txt");
expect(target.content).toBe("content");
});
it("creates intermediate directories by default", async () => {
const root = await tempWorkspace();
await writeFile(join(root, "file.txt"), "data");
await moveWorkspaceFile(root, "file.txt", "deep/nested/dir/file.txt");
const target = await readWorkspaceFile(root, "deep/nested/dir/file.txt");
expect(target.content).toBe("data");
});
it("fails when createDirs is false and parent directory does not exist", async () => {
const root = await tempWorkspace();
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 () => {
const root = await tempWorkspace();
await writeFile(join(root, "source.txt"), "source content");
await writeFile(join(root, "target.txt"), "target content");
const result = await moveWorkspaceFile(root, "source.txt", "target.txt", { overwrite: true });
expect(result.toPath).toBe("target.txt");
const target = await readWorkspaceFile(root, "target.txt");
expect(target.content).toBe("source content");
});
it("throws when target exists and overwrite is false (default)", async () => {
const root = await tempWorkspace();
await writeFile(join(root, "source.txt"), "source");
await writeFile(join(root, "target.txt"), "target");
await expect(moveWorkspaceFile(root, "source.txt", "target.txt")).rejects.toThrow("File already exists");
// 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 () => {
const root = await tempWorkspace();
await expect(moveWorkspaceFile(root, "../secret.txt", "target.txt")).rejects.toThrow("Path traversal is not allowed");
});
it("rejects target path traversal", async () => {
const root = await tempWorkspace();
await writeFile(join(root, "source.txt"), "data");
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 () => {
const root = await tempWorkspace();
await mkdir(join(root, "mydir"), { recursive: true });
await expect(moveWorkspaceFile(root, "mydir", "newdir")).rejects.toThrow("Source path is not a file");
});
it("rejects missing fromPath or toPath", async () => {
const root = await tempWorkspace();
await expect(moveWorkspaceFile(root, undefined, "target.txt")).rejects.toThrow("fromPath query parameter is required");
await expect(moveWorkspaceFile(root, "source.txt", undefined)).rejects.toThrow("toPath query parameter is required");
await expect(moveWorkspaceFile(root, "", "target.txt")).rejects.toThrow("fromPath query parameter is required");
await expect(moveWorkspaceFile(root, "source.txt", "")).rejects.toThrow("toPath query parameter is required");
});
it("prevents moving through symlinks that escape the workspace", async () => {
const root = await tempWorkspace();
await mkdir(join(root, "subdir"), { recursive: true });
await writeFile(join(root, "subdir", "file.txt"), "data");
// Create a symlink inside the workspace that points outside
const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-move-outside-"));
roots.push(outsideDir);
await symlink(outsideDir, join(root, "subdir", "escape"), "junction");
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" });
});
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");
});
});
@@ -0,0 +1,15 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
const tempRoots: string[] = [];
export async function createTempWorkspace(prefix = "pi-web-file-content-"): Promise<string> {
const root = await mkdtemp(join(tmpdir(), prefix));
tempRoots.push(root);
return root;
}
export async function cleanupTempWorkspaces(): Promise<void> {
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
}
@@ -0,0 +1,93 @@
import { mkdir, readFile, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { writeWorkspaceFile } from "./fileContentService.js";
import { cleanupTempWorkspaces, createTempWorkspace } from "./fileContentService.testSupport.js";
afterEach(async () => {
await cleanupTempWorkspaces();
});
describe("writeWorkspaceFile", () => {
it("writes text content to a new file with normalized paths", async () => {
const root = await createTempWorkspace();
const result = await writeWorkspaceFile(root, "./src//hello.ts", Buffer.from("const greeting = 'hello';\n"));
expect(result).toMatchObject({ path: "src/hello.ts", created: true });
expect(result.size).toBe(26);
expect(Date.parse(result.modifiedAt)).not.toBeNaN();
// Verify the file was actually written
const content = await readFile(join(root, "src", "hello.ts"), "utf8");
expect(content).toBe("const greeting = 'hello';\n");
});
it("writes binary content without text re-encoding", async () => {
const root = await createTempWorkspace();
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 () => {
const root = await createTempWorkspace();
await writeFile(join(root, "notes.txt"), "old content");
const result = await writeWorkspaceFile(root, "notes.txt", Buffer.from("new content"));
expect(result).toMatchObject({ path: "notes.txt", created: false, size: 11 });
const content = await readFile(join(root, "notes.txt"), "utf8");
expect(content).toBe("new content");
});
it("throws when overwrite is false and file exists", async () => {
const root = await createTempWorkspace();
await writeFile(join(root, "existing.txt"), "data");
await expect(writeWorkspaceFile(root, "existing.txt", Buffer.from("new"), { overwrite: false })).rejects.toThrow("File already exists");
});
it("creates intermediate directories by default", async () => {
const root = await createTempWorkspace();
await writeWorkspaceFile(root, "deep/nested/dir/file.txt", Buffer.from("deep content"));
const content = await readFile(join(root, "deep", "nested", "dir", "file.txt"), "utf8");
expect(content).toBe("deep content");
});
it("fails when createDirs is false and parent directory does not exist", async () => {
const root = await createTempWorkspace();
await expect(writeWorkspaceFile(root, "missing/dir/file.txt", Buffer.from("x"), { createDirs: false })).rejects.toThrow();
});
it("rejects missing paths, traversal, and absolute paths", async () => {
const root = await createTempWorkspace();
await expect(writeWorkspaceFile(root, undefined, Buffer.from("x"))).rejects.toThrow("path query parameter is required");
await expect(writeWorkspaceFile(root, "../secret.txt", Buffer.from("x"))).rejects.toThrow("Path traversal is not allowed");
await expect(writeWorkspaceFile(root, "/etc/passwd", Buffer.from("x"))).rejects.toThrow("Absolute paths are not allowed");
});
it("rejects writing to a directory path", async () => {
const root = await createTempWorkspace();
await mkdir(join(root, "mydir"), { recursive: true });
await expect(writeWorkspaceFile(root, "mydir", Buffer.from("data"))).rejects.toThrow("Path is not a file");
});
it("prevents writing through symlinks that escape the workspace", async () => {
const root = await createTempWorkspace();
await mkdir(join(root, "subdir"), { recursive: true });
const outsideDir = await createTempWorkspace("pi-web-outside-");
await symlink(outsideDir, join(root, "subdir", "escape"), "junction");
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" });
});
});