Archived
Merge branch 'test-suite-audit-followup'
# Conflicts: # src/server/sessions/piSessionService.test.ts
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: testing-guide
|
name: testing-guide
|
||||||
description: Project testing guide and test architecture rules for this repository. Use this skill whenever writing, modifying, reviewing, or planning tests, closing coverage gaps, adding Vitest coverage, creating test helpers or fakes, testing Lit components/controllers/services/routes, triaging test failures, or deciding between unit/controller/component/integration approaches. This includes the repo rule for Lit TemplateResult event-handler extraction and when not to use it.
|
description: Repository-specific testing guide. Use for any test work: planning coverage, writing/fixing/reviewing Vitest tests, test helpers/fakes, failure triage, choosing test layers, and Lit UI tests, including TemplateResult handler extraction rules.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Testing guide
|
# Testing guide
|
||||||
|
|||||||
@@ -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 () => {
|
it("continues batch uploads after per-file failures and reports the failed file only", async () => {
|
||||||
const xhrs = new FakeXhrQueue();
|
const xhrs = new FakeXhrQueue();
|
||||||
const progress: WorkspaceUploadBatchProgress[] = [];
|
const progress: WorkspaceUploadBatchProgress[] = [];
|
||||||
@@ -146,6 +183,10 @@ class FakeXhrQueue {
|
|||||||
at(index: number): FakeXMLHttpRequest {
|
at(index: number): FakeXMLHttpRequest {
|
||||||
return this.instances[index] ?? failTest(`missing XHR instance ${String(index)}`);
|
return this.instances[index] ?? failTest(`missing XHR instance ${String(index)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
count(): number {
|
||||||
|
return this.instances.length;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class FakeXMLHttpRequest implements WorkspaceUploadXhr {
|
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 type { TemplateResult } from "lit";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { FileContentResponse, FileTreeEntry } from "../api";
|
||||||
import { initialAppState } from "../appState";
|
import { initialAppState } from "../appState";
|
||||||
import type { WorkspacePanelContext } from "../plugins/types";
|
import type { WorkspacePanelContext } from "../plugins/types";
|
||||||
import type { WorkspaceUploadBatchState } from "../workspaceUploadState";
|
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", () => {
|
describe("workspaceUploadBatchesForScope", () => {
|
||||||
it("filters upload batches to the selected project, workspace, and machine", () => {
|
it("filters upload batches to the selected project, workspace, and machine", () => {
|
||||||
const matchingOlder = uploadBatch({ id: "older", startedAt: "2026-06-25T00:00:00.000Z" });
|
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[] {
|
function templateStrings(template: TemplateResult): readonly string[] {
|
||||||
const strings = Reflect.get(template, "strings");
|
const strings = Reflect.get(template, "strings");
|
||||||
if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable");
|
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;
|
readonly submitter: HTMLElement | null = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function workspacePanelContext(patch: Partial<Pick<WorkspacePanelContext, "onStartWorkspaceUpload" | "workspaceUploadDefaultFolder">> = {}): WorkspacePanelContext {
|
function fileEntry(path: string, size = 2): FileTreeEntry {
|
||||||
const workspace = { id: "workspace-1", projectId: "project-1", path: "/tmp/project", label: "main", isMain: true, isGitRepo: true, isGitWorktree: false };
|
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 {
|
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,
|
workspace,
|
||||||
state: { ...initialAppState(), workspaceUploadBatches: {} },
|
state: patch.state ?? { ...initialAppState(), workspaceUploadBatches: {} },
|
||||||
files: {
|
files: patch.files ?? {
|
||||||
readFile: vi.fn<WorkspacePanelContext["files"]["readFile"]>(() => Promise.reject(new Error("not implemented"))),
|
readFile: vi.fn<WorkspacePanelContext["files"]["readFile"]>(() => Promise.reject(new Error("not implemented"))),
|
||||||
writeFile: vi.fn<WorkspacePanelContext["files"]["writeFile"]>(() => 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"))),
|
deleteFile: vi.fn<WorkspacePanelContext["files"]["deleteFile"]>(() => Promise.reject(new Error("not implemented"))),
|
||||||
moveFile: vi.fn<WorkspacePanelContext["files"]["moveFile"]>(() => 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) },
|
prompt: patch.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"))) },
|
terminal: patch.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"]>() },
|
host: patch.host ?? { requestRender: vi.fn<WorkspacePanelContext["host"]["requestRender"]>() },
|
||||||
fileTree: [],
|
fileTree: patch.fileTree ?? [],
|
||||||
expandedDirs: {},
|
expandedDirs: patch.expandedDirs ?? {},
|
||||||
selectedFilePath: undefined,
|
selectedFilePath: patch.selectedFilePath,
|
||||||
selectedFileContent: undefined,
|
selectedFileContent: patch.selectedFileContent,
|
||||||
fileTreeStale: false,
|
fileTreeStale: patch.fileTreeStale ?? false,
|
||||||
gitStatus: undefined,
|
gitStatus: patch.gitStatus,
|
||||||
selectedDiffPath: undefined,
|
selectedDiffPath: patch.selectedDiffPath,
|
||||||
selectedDiff: undefined,
|
selectedDiff: patch.selectedDiff,
|
||||||
selectedStagedDiff: undefined,
|
selectedStagedDiff: patch.selectedStagedDiff,
|
||||||
gitStale: false,
|
gitStale: patch.gitStale ?? false,
|
||||||
activeTerminalCount: 0,
|
activeTerminalCount: patch.activeTerminalCount ?? 0,
|
||||||
selectedTerminalId: undefined,
|
selectedTerminalId: patch.selectedTerminalId,
|
||||||
terminalAutoStart: false,
|
terminalAutoStart: patch.terminalAutoStart ?? false,
|
||||||
workspaceUploadDefaultFolder: patch.workspaceUploadDefaultFolder ?? ".pi-web/uploads",
|
workspaceUploadDefaultFolder: patch.workspaceUploadDefaultFolder ?? ".pi-web/uploads",
|
||||||
onRefreshFiles: vi.fn<WorkspacePanelContext["onRefreshFiles"]>(),
|
onRefreshFiles: patch.onRefreshFiles ?? vi.fn<WorkspacePanelContext["onRefreshFiles"]>(),
|
||||||
onExpandDir: vi.fn<WorkspacePanelContext["onExpandDir"]>(),
|
onExpandDir: patch.onExpandDir ?? vi.fn<WorkspacePanelContext["onExpandDir"]>(),
|
||||||
onSelectFile: vi.fn<WorkspacePanelContext["onSelectFile"]>(),
|
onSelectFile: patch.onSelectFile ?? vi.fn<WorkspacePanelContext["onSelectFile"]>(),
|
||||||
onStartWorkspaceUpload: patch.onStartWorkspaceUpload ?? vi.fn<WorkspacePanelContext["onStartWorkspaceUpload"]>(() => undefined),
|
onStartWorkspaceUpload: patch.onStartWorkspaceUpload ?? vi.fn<WorkspacePanelContext["onStartWorkspaceUpload"]>(() => undefined),
|
||||||
onCancelWorkspaceUpload: vi.fn<WorkspacePanelContext["onCancelWorkspaceUpload"]>(),
|
onCancelWorkspaceUpload: patch.onCancelWorkspaceUpload ?? vi.fn<WorkspacePanelContext["onCancelWorkspaceUpload"]>(),
|
||||||
onClearWorkspaceUpload: vi.fn<WorkspacePanelContext["onClearWorkspaceUpload"]>(),
|
onClearWorkspaceUpload: patch.onClearWorkspaceUpload ?? vi.fn<WorkspacePanelContext["onClearWorkspaceUpload"]>(),
|
||||||
onRefreshGit: vi.fn<WorkspacePanelContext["onRefreshGit"]>(),
|
onRefreshGit: patch.onRefreshGit ?? vi.fn<WorkspacePanelContext["onRefreshGit"]>(),
|
||||||
onSelectDiff: vi.fn<WorkspacePanelContext["onSelectDiff"]>(),
|
onSelectDiff: patch.onSelectDiff ?? vi.fn<WorkspacePanelContext["onSelectDiff"]>(),
|
||||||
onSelectTerminal: vi.fn<WorkspacePanelContext["onSelectTerminal"]>(),
|
onSelectTerminal: patch.onSelectTerminal ?? vi.fn<WorkspacePanelContext["onSelectTerminal"]>(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
WorkspaceUploadBatchError,
|
WorkspaceUploadBatchError,
|
||||||
WorkspaceUploadCancelledError,
|
WorkspaceUploadCancelledError,
|
||||||
type FileContentResponse,
|
type FileContentResponse,
|
||||||
|
type FileTreeEntry,
|
||||||
type FileTreeResponse,
|
type FileTreeResponse,
|
||||||
type Machine,
|
type Machine,
|
||||||
type Project,
|
type Project,
|
||||||
@@ -13,6 +14,9 @@ import {
|
|||||||
} from "../api";
|
} from "../api";
|
||||||
import { FileExplorerController, type FileExplorerControllerDependencies } from "./fileExplorerController";
|
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 UploadWorkspaceFiles = NonNullable<FileExplorerControllerDependencies["uploadWorkspaceFiles"]>;
|
||||||
type UploadWorkspaceFilesOptions = NonNullable<Parameters<UploadWorkspaceFiles>[3]>;
|
type UploadWorkspaceFilesOptions = NonNullable<Parameters<UploadWorkspaceFiles>[3]>;
|
||||||
|
|
||||||
@@ -48,6 +52,56 @@ const workspace: Workspace = {
|
|||||||
isGitWorktree: false,
|
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", () => {
|
describe("FileExplorerController workspace uploads", () => {
|
||||||
it("tracks upload progress, completes from final responses, refreshes files, and selects the first uploaded file", async () => {
|
it("tracks upload progress, completes from final responses, refreshes files, and selects the first uploaded file", async () => {
|
||||||
const upload = controllableUpload();
|
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");
|
installWindow("http://localhost/app");
|
||||||
let state: AppState = {
|
let state: AppState = {
|
||||||
...initialAppState(),
|
...initialAppState(),
|
||||||
selectedMachine: machine,
|
selectedMachine: machine,
|
||||||
selectedProject: project,
|
selectedProject: project,
|
||||||
selectedWorkspace: workspace,
|
selectedWorkspace: workspace,
|
||||||
|
...statePatch,
|
||||||
};
|
};
|
||||||
const api: NonNullable<FileExplorerControllerDependencies["api"]> = deps.api ?? {
|
const api = deps.api ?? createApi();
|
||||||
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 updateUrl = vi.fn();
|
const updateUrl = vi.fn();
|
||||||
let batchSequence = 0;
|
let batchSequence = 0;
|
||||||
const controller = new FileExplorerController(
|
const controller = new FileExplorerController(
|
||||||
@@ -308,8 +360,24 @@ function sequenceNow(...values: string[]): () => string {
|
|||||||
return () => values[index++] ?? values.at(-1) ?? "now";
|
return () => values[index++] ?? values.at(-1) ?? "now";
|
||||||
}
|
}
|
||||||
|
|
||||||
function treeResponse(path: string): FileTreeResponse {
|
function createApi(overrides: Partial<FileExplorerApi> = {}): FileExplorerApi {
|
||||||
return { path, entries: [], scannedAt: "2026-06-25T00:00:00.000Z", truncated: false };
|
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 {
|
function fileResponse(path: string): FileContentResponse {
|
||||||
|
|||||||
@@ -20,6 +20,15 @@ const remoteMachine: Machine = {
|
|||||||
updatedAt: "2026-05-26T00:00:00.000Z",
|
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 = {
|
const offlineHealth: MachineHealth = {
|
||||||
machineId: remoteMachine.id,
|
machineId: remoteMachine.id,
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -33,6 +42,85 @@ describe("MachineController", () => {
|
|||||||
vi.restoreAllMocks();
|
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 () => {
|
it("keeps the routed remote machine selected while its health is offline", async () => {
|
||||||
let state: AppState = initialAppState();
|
let state: AppState = initialAppState();
|
||||||
const setState = (patch: Partial<AppState>) => { state = { ...state, ...patch }; };
|
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;
|
||||||
|
}
|
||||||
@@ -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", () => {
|
it("removes a pending attachment chip before sending the remaining attachments", () => {
|
||||||
const editor = new PromptEditor();
|
const editor = new PromptEditor();
|
||||||
const onSend = vi.fn<NonNullable<PromptEditor["onSend"]>>();
|
const onSend = vi.fn<NonNullable<PromptEditor["onSend"]>>();
|
||||||
@@ -111,10 +150,57 @@ describe("PromptEditor attachment chips", () => {
|
|||||||
|
|
||||||
type TemplateEventHandler<E extends Event> = (event: E) => void;
|
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 {
|
function setPromptEditorPrivate(editor: PromptEditor, property: string, value: unknown): void {
|
||||||
if (!Reflect.set(editor, property, value)) throw new Error(`Failed to set PromptEditor ${property}`);
|
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> {
|
function findTemplateEventHandlerAfterMarker<E extends Event>(template: TemplateResult, marker: string): TemplateEventHandler<E> {
|
||||||
const handler = findOptionalTemplateEventHandlerAfterMarker<E>(template, marker);
|
const handler = findOptionalTemplateEventHandlerAfterMarker<E>(template, marker);
|
||||||
if (handler === undefined) throw new Error(`Expected template event handler after marker ${marker}`);
|
if (handler === undefined) throw new Error(`Expected template event handler after marker ${marker}`);
|
||||||
|
|||||||
@@ -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 })]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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" } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
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 () => {
|
it.skipIf(process.platform === "win32")("tightens permissions after reading an existing machine store", async () => {
|
||||||
await writeFile(storePath, `${JSON.stringify({
|
await writeFile(storePath, `${JSON.stringify({
|
||||||
machines: [{
|
machines: [{
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { mkdir, mkdtemp, readFile, readdir, rm, symlink } from "node:fs/promises";
|
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 { tmpdir } from "node:os";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { formatDimensionNote, resizeImage, type ResizedImage } from "@earendil-works/pi-coding-agent";
|
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);
|
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 () => {
|
it("does not overwrite an existing attachment name", async () => {
|
||||||
const fixedNow = () => new Date("2026-06-13T12:05:01.123Z");
|
const fixedNow = () => new Date("2026-06-13T12:05:01.123Z");
|
||||||
const first = await saveAttachmentsToWorkspace(
|
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 { constants } from "node:fs";
|
||||||
import { access, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
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 { tmpdir } from "node:os";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { SessionArchiveStore } from "./sessionArchiveStore.js";
|
import { SessionArchiveStore } from "./sessionArchiveStore.js";
|
||||||
@@ -118,6 +118,54 @@ describe("SessionArchiveStore", () => {
|
|||||||
}
|
}
|
||||||
await expect(store.list()).resolves.toEqual([]);
|
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> {
|
async function exists(path: string): Promise<boolean> {
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ function candidate(id: string, options: Partial<SessionArchiveTreeCandidate> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("session archive tree planning", () => {
|
describe("session archive tree planning", () => {
|
||||||
it("finds candidates by full id or prefix", () => {
|
it("finds candidates by exact id before falling back to a prefix", () => {
|
||||||
const candidates = [candidate("abcdef"), candidate("xyz")];
|
const candidates = [candidate("abcdef"), candidate("abc"), candidate("xyz")];
|
||||||
|
|
||||||
expect(findArchiveCandidateByIdOrPrefix(candidates, "abcdef")?.id).toBe("abcdef");
|
expect(findArchiveCandidateByIdOrPrefix(candidates, "abc")?.id).toBe("abc");
|
||||||
expect(findArchiveCandidateByIdOrPrefix(candidates, "abc")?.id).toBe("abcdef");
|
expect(findArchiveCandidateByIdOrPrefix(candidates, "abcd")?.id).toBe("abcdef");
|
||||||
expect(findArchiveCandidateByIdOrPrefix(candidates, "missing")).toBeUndefined();
|
expect(findArchiveCandidateByIdOrPrefix(candidates, "missing")).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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";
|
import { TerminalService } from "./terminalService";
|
||||||
|
|
||||||
// TerminalService spawns a POSIX shell (/bin/bash with -lc and commands like
|
// 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();
|
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> {
|
function terminalReplay(service: TerminalService, terminalId: string): Promise<string> {
|
||||||
let output = "";
|
let output = "";
|
||||||
const detach = service.attach(terminalId, {
|
const detach = service.attach(terminalId, {
|
||||||
|
|||||||
@@ -1,38 +1,115 @@
|
|||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { WebSocket, WebSocketServer, type RawData } from "ws";
|
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 () => {
|
afterEach(async () => {
|
||||||
const socketServer = server;
|
for (const socket of sockets) closeSocket(socket);
|
||||||
if (socketServer === undefined) return;
|
await Promise.all(Array.from(servers, closeSocketServer));
|
||||||
await new Promise<void>((resolve) => {
|
sockets.clear();
|
||||||
socketServer.close(() => { resolve(); });
|
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", () => {
|
describe("createBufferedSender", () => {
|
||||||
it("queues messages while a WebSocket is still connecting", async () => {
|
it("queues messages while a WebSocket is still connecting", async () => {
|
||||||
const socketServer = new WebSocketServer({ host: "127.0.0.1", port: 0 });
|
const socketServer = createServer();
|
||||||
server = socketServer;
|
|
||||||
const connected = new Promise<WebSocket>((resolve) => {
|
const connected = new Promise<WebSocket>((resolve) => {
|
||||||
socketServer.once("connection", resolve);
|
socketServer.once("connection", (socket) => {
|
||||||
|
sockets.add(socket);
|
||||||
|
resolve(socket);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
await waitForListening(socketServer);
|
await waitForListening(socketServer);
|
||||||
|
|
||||||
const client = new WebSocket(serverUrl(socketServer));
|
const client = new WebSocket(serverUrl(socketServer));
|
||||||
|
sockets.add(client);
|
||||||
const send = createBufferedSender(client);
|
const send = createBufferedSender(client);
|
||||||
send("queued-before-open");
|
send("queued-before-open");
|
||||||
|
|
||||||
const serverSocket = await connected;
|
const serverSocket = await connected;
|
||||||
await expect(nextMessage(serverSocket)).resolves.toBe("queued-before-open");
|
await expect(nextMessage(serverSocket)).resolves.toBe("queued-before-open");
|
||||||
client.close();
|
closeSocket(client);
|
||||||
serverSocket.close();
|
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> {
|
function waitForListening(socketServer: WebSocketServer): Promise<void> {
|
||||||
if (socketServer.address() !== null) return Promise.resolve();
|
if (socketServer.address() !== null) return Promise.resolve();
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -50,6 +127,24 @@ function serverUrl(socketServer: WebSocketServer): string {
|
|||||||
return `ws://127.0.0.1:${String(address.port)}`;
|
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> {
|
function nextMessage(socket: WebSocket): Promise<string> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
socket.once("message", (data) => {
|
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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user