Archived
Merge branch 'test-suite-audit-followup'
# Conflicts: # src/server/sessions/piSessionService.test.ts
This commit is contained in:
@@ -100,6 +100,43 @@ describe("workspace upload helpers", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("forwards createDirs through batch upload requests", async () => {
|
||||
const xhrs = new FakeXhrQueue();
|
||||
const file = new File(["hello"], "nested.txt", { type: "text/plain" });
|
||||
|
||||
const task = uploadWorkspaceFiles("p1", "w1", [file], {
|
||||
destinationFolder: "uploads",
|
||||
createDirs: false,
|
||||
xhrFactory: xhrs.factory,
|
||||
});
|
||||
|
||||
const xhr = xhrs.only();
|
||||
expect(xhr.url).toBe("/api/machines/local/projects/p1/workspaces/w1/file?path=uploads%2Fnested.txt&createDirs=false");
|
||||
xhr.respondJson(200, { path: "uploads/nested.txt", size: 5, modifiedAt: "2026-06-25T00:00:00.000Z", created: true });
|
||||
|
||||
await expect(task.promise).resolves.toEqual([
|
||||
{ path: "uploads/nested.txt", size: 5, modifiedAt: "2026-06-25T00:00:00.000Z", created: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("cancels an in-flight batch upload without starting remaining files", async () => {
|
||||
const xhrs = new FakeXhrQueue();
|
||||
const files = [new File(["ab"], "a.txt"), new File(["cde"], "b.txt")];
|
||||
|
||||
const task = uploadWorkspaceFiles("p1", "w1", files, {
|
||||
destinationFolder: "uploads",
|
||||
xhrFactory: xhrs.factory,
|
||||
});
|
||||
const first = xhrs.only();
|
||||
const cancellation = expect(task.promise).rejects.toBeInstanceOf(WorkspaceUploadCancelledError);
|
||||
|
||||
task.cancel();
|
||||
|
||||
await cancellation;
|
||||
expect(first.aborted).toBe(true);
|
||||
expect(xhrs.count()).toBe(1);
|
||||
});
|
||||
|
||||
it("continues batch uploads after per-file failures and reports the failed file only", async () => {
|
||||
const xhrs = new FakeXhrQueue();
|
||||
const progress: WorkspaceUploadBatchProgress[] = [];
|
||||
@@ -146,6 +183,10 @@ class FakeXhrQueue {
|
||||
at(index: number): FakeXMLHttpRequest {
|
||||
return this.instances[index] ?? failTest(`missing XHR instance ${String(index)}`);
|
||||
}
|
||||
|
||||
count(): number {
|
||||
return this.instances.length;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeXMLHttpRequest implements WorkspaceUploadXhr {
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { configApi, type PiWebConfigResponse } from "../api";
|
||||
import { SettingsDialog } from "./SettingsDialog";
|
||||
import { callDialogPromise, callDialogUpdated, collectTemplateStrings, configResponse, deferred, getDialogProperty, remoteMachine, secondRemoteMachine, setDialogProperty, stubWindowTimers } from "./SettingsDialog.testSupport";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("settings-dialog general settings machine targeting", () => {
|
||||
it("renders the active settings panel without the old global scope note", () => {
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.section = "general";
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
const strings = collectTemplateStrings(dialog.render()).join("");
|
||||
|
||||
expect(strings).toContain("<settings-general-panel");
|
||||
expect(strings).not.toContain("scope-note");
|
||||
expect(strings).not.toContain("This tab edits:");
|
||||
});
|
||||
|
||||
it("keeps gateway server config saves on the gateway config endpoint", async () => {
|
||||
stubWindowTimers();
|
||||
const savedConfig = configResponse({ host: "0.0.0.0", port: 9000, allowedHosts: true });
|
||||
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
|
||||
const onConfigSaved = vi.fn();
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.onConfigSaved = onConfigSaved;
|
||||
|
||||
await callDialogPromise(dialog, "saveConfig", { host: "0.0.0.0", port: 9000, allowedHosts: true });
|
||||
|
||||
expect(saveSpy.mock.calls).toEqual([[{ host: "0.0.0.0", port: 9000, allowedHosts: true }]]);
|
||||
expect(getDialogProperty(dialog, "configResponse")).toBe(savedConfig);
|
||||
expect(onConfigSaved).toHaveBeenCalledWith({ host: "0.0.0.0", port: 9000, allowedHosts: true });
|
||||
expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved.");
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
|
||||
it("loads file access and upload config from the selected machine", async () => {
|
||||
const config = configResponse({ pathAccess: { allowedPaths: ["/mnt/share"] }, uploads: { defaultFolder: "manual/uploads" } });
|
||||
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
await callDialogPromise(dialog, "loadAccessConfigForTarget");
|
||||
|
||||
expect(configSpy.mock.calls).toEqual([["remote-a"]]);
|
||||
expect(getDialogProperty(dialog, "accessConfigResponse")).toBe(config);
|
||||
expect(getDialogProperty(dialog, "accessError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "accessLoading")).toBe(false);
|
||||
});
|
||||
|
||||
it("saves selected-machine file access and upload config through the selected-machine endpoint", async () => {
|
||||
stubWindowTimers();
|
||||
const patch = { pathAccess: { allowedPaths: ["/mnt/share", "~/SDKs"] }, uploads: { defaultFolder: "manual/uploads" } };
|
||||
const savedConfig = configResponse(patch);
|
||||
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
await callDialogPromise(dialog, "saveMachineAccessConfig", patch);
|
||||
|
||||
expect(saveSpy.mock.calls).toEqual([[patch, "remote-a"]]);
|
||||
expect(getDialogProperty(dialog, "accessConfigResponse")).toBe(savedConfig);
|
||||
expect(getDialogProperty(dialog, "configResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved.");
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
|
||||
it("merges local selected-machine access saves into gateway config without dropping gateway-only values", async () => {
|
||||
stubWindowTimers();
|
||||
const gatewayConfig = configResponse({
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: true } },
|
||||
spawnSessions: false,
|
||||
pathAccess: { allowedPaths: ["/old"] },
|
||||
uploads: { defaultFolder: "old/uploads" },
|
||||
maxUploadBytes: 1234,
|
||||
});
|
||||
const patch = { pathAccess: { allowedPaths: ["~/SDKs"] }, uploads: {} };
|
||||
const savedConfig = configResponse({ pathAccess: { allowedPaths: ["~/SDKs"] }, uploads: {}, maxUploadBytes: 5678 });
|
||||
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
|
||||
const onConfigSaved = vi.fn();
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.onConfigSaved = onConfigSaved;
|
||||
setDialogProperty(dialog, "configResponse", gatewayConfig);
|
||||
|
||||
await callDialogPromise(dialog, "saveMachineAccessConfig", patch);
|
||||
|
||||
expect(saveSpy.mock.calls).toEqual([[patch, "local"]]);
|
||||
expect(getDialogProperty(dialog, "accessConfigResponse")).toBe(savedConfig);
|
||||
expect(getDialogProperty(dialog, "configResponse")).toMatchObject({
|
||||
config: {
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: true } },
|
||||
spawnSessions: false,
|
||||
pathAccess: { allowedPaths: ["~/SDKs"] },
|
||||
uploads: {},
|
||||
maxUploadBytes: 5678,
|
||||
},
|
||||
effectiveConfig: {
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: true } },
|
||||
spawnSessions: false,
|
||||
pathAccess: { allowedPaths: ["~/SDKs"] },
|
||||
uploads: {},
|
||||
maxUploadBytes: 5678,
|
||||
},
|
||||
});
|
||||
expect(onConfigSaved).toHaveBeenCalledWith({
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: true } },
|
||||
spawnSessions: false,
|
||||
pathAccess: { allowedPaths: ["~/SDKs"] },
|
||||
uploads: {},
|
||||
maxUploadBytes: 5678,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores stale file access load responses after the selected machine changes", async () => {
|
||||
const load = deferred<PiWebConfigResponse>();
|
||||
vi.spyOn(configApi, "config").mockReturnValue(load.promise);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
const loadPromise = callDialogPromise(dialog, "loadAccessConfigForTarget");
|
||||
expect(getDialogProperty(dialog, "accessLoading")).toBe(true);
|
||||
|
||||
dialog.machine = secondRemoteMachine;
|
||||
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
|
||||
load.resolve(configResponse({ pathAccess: { allowedPaths: ["/stale"] } }));
|
||||
await loadPromise;
|
||||
|
||||
expect(getDialogProperty(dialog, "accessConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "accessError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "accessLoading")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores stale file access save responses after the selected machine changes", async () => {
|
||||
const save = deferred<PiWebConfigResponse>();
|
||||
vi.spyOn(configApi, "saveConfig").mockReturnValue(save.promise);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
const savePromise = callDialogPromise(dialog, "saveMachineAccessConfig", { pathAccess: { allowedPaths: ["/mnt/share"] }, uploads: { defaultFolder: "manual" } });
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(true);
|
||||
|
||||
dialog.machine = secondRemoteMachine;
|
||||
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
|
||||
save.resolve(configResponse({ pathAccess: { allowedPaths: ["/mnt/share"] }, uploads: { defaultFolder: "manual" } }));
|
||||
await savePromise;
|
||||
|
||||
expect(getDialogProperty(dialog, "accessConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "savedMessage")).toBe("");
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
|
||||
it("shows selected-machine file access errors with the selected target name", async () => {
|
||||
vi.spyOn(configApi, "config").mockRejectedValue(new Error("Remote machine unavailable"));
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
await callDialogPromise(dialog, "loadAccessConfigForTarget");
|
||||
|
||||
expect(getDialogProperty(dialog, "accessError")).toBe("Failed to load file access/upload config from Lab Mac (remote machine): Could not reach Lab Mac for selected-machine settings. Check the machine connection and try again.");
|
||||
expect(getDialogProperty(dialog, "accessLoading")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { piPackagesApi, pluginsApi, type PiPackageMutationResponse } from "../api";
|
||||
import { SettingsDialog } from "./SettingsDialog";
|
||||
import { callDialogPromise, callDialogUpdated, deferred, getDialogProperty, packageInfo, packageMutationResponse, pluginInfo, pluginsResponse, remoteMachine, runtimeWithPackageManagement, secondRemoteMachine } from "./SettingsDialog.testSupport";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("settings-dialog Pi package orchestration", () => {
|
||||
it("loads package data from the selected machine and ignores stale target responses", async () => {
|
||||
const remotePackages = { packages: [packageInfo("npm:@acme/tools")] };
|
||||
const staleLoad = deferred<typeof remotePackages>();
|
||||
const packagesSpy = vi.spyOn(piPackagesApi, "packages").mockReturnValue(staleLoad.promise);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
dialog.machineRuntime = runtimeWithPackageManagement;
|
||||
|
||||
const loadPromise = callDialogPromise(dialog, "loadPackagesForTarget");
|
||||
expect(packagesSpy.mock.calls).toEqual([["remote-a"]]);
|
||||
expect(getDialogProperty(dialog, "packageLoading")).toBe(true);
|
||||
|
||||
dialog.machine = secondRemoteMachine;
|
||||
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
|
||||
staleLoad.resolve(remotePackages);
|
||||
await loadPromise;
|
||||
|
||||
expect(getDialogProperty(dialog, "packagesResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "packageError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "packageMessage")).toBe("");
|
||||
expect(getDialogProperty(dialog, "packageLoading")).toBe(false);
|
||||
});
|
||||
|
||||
it("runs remote package mutations against the selected machine without refreshing gateway plugins", async () => {
|
||||
const installedPackages = [packageInfo("npm:@acme/new-tools")];
|
||||
const install = deferred<PiPackageMutationResponse>();
|
||||
const installSpy = vi.spyOn(piPackagesApi, "install").mockReturnValue(install.promise);
|
||||
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(pluginsResponse([pluginInfo("gateway", true)]));
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
dialog.machineRuntime = runtimeWithPackageManagement;
|
||||
|
||||
const installPromise = callDialogPromise(dialog, "installPiPackage", "npm:@acme/new-tools");
|
||||
|
||||
expect(installSpy.mock.calls).toEqual([["npm:@acme/new-tools", "remote-a"]]);
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(true);
|
||||
expect(getDialogProperty(dialog, "packageOperation")).toEqual({ kind: "install", source: "npm:@acme/new-tools" });
|
||||
|
||||
install.resolve(packageMutationResponse("install", installedPackages, "npm:@acme/new-tools"));
|
||||
await installPromise;
|
||||
|
||||
expect(pluginsSpy).not.toHaveBeenCalled();
|
||||
expect(getDialogProperty(dialog, "packagesResponse")).toEqual({ packages: installedPackages });
|
||||
expect(getDialogProperty(dialog, "packageMessage")).toContain("Pi package installed on Lab Mac");
|
||||
expect(getDialogProperty(dialog, "packageMessage")).toContain("each idle PI WEB session on Lab Mac");
|
||||
expect(getDialogProperty(dialog, "packageError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "packageOperation")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
|
||||
it("refreshes gateway plugins after a local package mutation", async () => {
|
||||
const updatedPackages = [packageInfo("npm:@acme/tools")];
|
||||
const refreshedPlugins = pluginsResponse([pluginInfo("browser-helper", true)]);
|
||||
const updateSpy = vi.spyOn(piPackagesApi, "update").mockResolvedValue(packageMutationResponse("update", updatedPackages));
|
||||
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(refreshedPlugins);
|
||||
const dialog = new SettingsDialog();
|
||||
|
||||
await callDialogPromise(dialog, "updatePiPackage");
|
||||
|
||||
expect(updateSpy.mock.calls).toEqual([[undefined, "local"]]);
|
||||
expect(pluginsSpy.mock.calls).toEqual([[]]);
|
||||
expect(getDialogProperty(dialog, "packagesResponse")).toEqual({ packages: updatedPackages });
|
||||
expect(getDialogProperty(dialog, "pluginsResponse")).toBe(refreshedPlugins);
|
||||
expect(getDialogProperty(dialog, "packageMessage")).toContain("Reload the browser page separately for PI WEB browser plugin changes");
|
||||
expect(getDialogProperty(dialog, "packageError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { configApi, pluginsApi, type PiWebConfigResponse, type PiWebPluginsResponse } from "../api";
|
||||
import { SettingsDialog } from "./SettingsDialog";
|
||||
import { callDialogPromise, callDialogUpdated, configResponse, deferred, getDialogProperty, pluginInfo, pluginsResponse, remoteMachine, secondRemoteMachine, setDialogProperty, stubWindowTimers } from "./SettingsDialog.testSupport";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("settings-dialog plugin settings machine targeting", () => {
|
||||
it("loads plugin config and plugin list from the selected machine", async () => {
|
||||
const config = configResponse({ plugins: { info: { enabled: true } } });
|
||||
const plugins = pluginsResponse([pluginInfo("info", true)]);
|
||||
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
|
||||
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(plugins);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
await callDialogPromise(dialog, "loadPluginsForTarget");
|
||||
|
||||
expect(configSpy.mock.calls).toEqual([["remote-a"]]);
|
||||
expect(pluginsSpy.mock.calls).toEqual([["remote-a"]]);
|
||||
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(config);
|
||||
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBe(plugins);
|
||||
expect(getDialogProperty(dialog, "pluginError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "pluginLoading")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps fulfilled plugin config when the selected machine plugin list is unsupported", async () => {
|
||||
const config = configResponse({ plugins: { info: { enabled: true } } });
|
||||
vi.spyOn(configApi, "config").mockResolvedValue(config);
|
||||
vi.spyOn(pluginsApi, "plugins").mockRejectedValue(new Error("route GET:/api/plugins not found"));
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
await callDialogPromise(dialog, "loadPluginsForTarget");
|
||||
|
||||
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(config);
|
||||
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "pluginError")).toBe("Failed to load PI WEB plugin settings from Lab Mac (remote machine): PI WEB plugins: Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
|
||||
expect(getDialogProperty(dialog, "pluginLoading")).toBe(false);
|
||||
});
|
||||
|
||||
it("saves selected-machine plugin toggles as plugin-only patches and refreshes the selected machine plugin list", async () => {
|
||||
stubWindowTimers();
|
||||
const baseConfig = configResponse({
|
||||
plugins: {
|
||||
keep: { enabled: true, settings: { level: 1 } },
|
||||
info: { settings: { color: "blue" } },
|
||||
},
|
||||
});
|
||||
const savedConfig = configResponse({
|
||||
plugins: {
|
||||
keep: { enabled: true, settings: { level: 1 } },
|
||||
info: { enabled: false, settings: { color: "blue" } },
|
||||
},
|
||||
});
|
||||
const refreshedPlugins = pluginsResponse([pluginInfo("info", false), pluginInfo("keep", true)]);
|
||||
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
|
||||
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(refreshedPlugins);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
setDialogProperty(dialog, "selectedPluginConfigResponse", baseConfig);
|
||||
|
||||
await callDialogPromise(dialog, "togglePlugin", "info", false);
|
||||
|
||||
expect(saveSpy.mock.calls).toEqual([[
|
||||
{
|
||||
plugins: {
|
||||
keep: { enabled: true, settings: { level: 1 } },
|
||||
info: { enabled: false, settings: { color: "blue" } },
|
||||
},
|
||||
},
|
||||
"remote-a",
|
||||
]]);
|
||||
expect(pluginsSpy.mock.calls).toEqual([["remote-a"]]);
|
||||
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(savedConfig);
|
||||
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBe(refreshedPlugins);
|
||||
expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved.");
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
|
||||
it("merges local selected-machine plugin saves into gateway config without dropping gateway-only values", async () => {
|
||||
stubWindowTimers();
|
||||
const gatewayConfig = configResponse({
|
||||
host: "127.0.0.1",
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
spawnSessions: false,
|
||||
plugins: { info: { enabled: false }, gateway: { settings: { theme: "dark" } } },
|
||||
});
|
||||
const savedConfig = configResponse({ plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } } });
|
||||
const refreshedPlugins = pluginsResponse([pluginInfo("info", true)]);
|
||||
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
|
||||
vi.spyOn(pluginsApi, "plugins").mockResolvedValue(refreshedPlugins);
|
||||
const onConfigSaved = vi.fn();
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.onConfigSaved = onConfigSaved;
|
||||
setDialogProperty(dialog, "configResponse", gatewayConfig);
|
||||
setDialogProperty(dialog, "selectedPluginConfigResponse", configResponse({ plugins: { info: { enabled: false } } }));
|
||||
|
||||
await callDialogPromise(dialog, "togglePlugin", "info", true);
|
||||
|
||||
expect(saveSpy.mock.calls).toEqual([[{ plugins: { info: { enabled: true } } }, "local"]]);
|
||||
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(savedConfig);
|
||||
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBe(refreshedPlugins);
|
||||
expect(getDialogProperty(dialog, "configResponse")).toMatchObject({
|
||||
config: {
|
||||
host: "127.0.0.1",
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
spawnSessions: false,
|
||||
plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } },
|
||||
},
|
||||
effectiveConfig: {
|
||||
host: "127.0.0.1",
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
spawnSessions: false,
|
||||
plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } },
|
||||
},
|
||||
});
|
||||
expect(onConfigSaved).toHaveBeenCalledWith({
|
||||
host: "127.0.0.1",
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
spawnSessions: false,
|
||||
plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } },
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores stale plugin load responses after the selected machine changes", async () => {
|
||||
const configLoad = deferred<PiWebConfigResponse>();
|
||||
const pluginsLoad = deferred<PiWebPluginsResponse>();
|
||||
vi.spyOn(configApi, "config").mockReturnValue(configLoad.promise);
|
||||
vi.spyOn(pluginsApi, "plugins").mockReturnValue(pluginsLoad.promise);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
const loadPromise = callDialogPromise(dialog, "loadPluginsForTarget");
|
||||
expect(getDialogProperty(dialog, "pluginLoading")).toBe(true);
|
||||
|
||||
dialog.machine = secondRemoteMachine;
|
||||
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
|
||||
configLoad.resolve(configResponse({ plugins: { info: { enabled: true } } }));
|
||||
pluginsLoad.resolve(pluginsResponse([pluginInfo("info", true)]));
|
||||
await loadPromise;
|
||||
|
||||
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "pluginError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "pluginLoading")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores stale plugin save responses after the selected machine changes", async () => {
|
||||
const save = deferred<PiWebConfigResponse>();
|
||||
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(pluginsResponse([pluginInfo("info", false)]));
|
||||
vi.spyOn(configApi, "saveConfig").mockReturnValue(save.promise);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
setDialogProperty(dialog, "selectedPluginConfigResponse", configResponse({ plugins: { info: { enabled: true } } }));
|
||||
|
||||
const savePromise = callDialogPromise(dialog, "togglePlugin", "info", false);
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(true);
|
||||
|
||||
dialog.machine = secondRemoteMachine;
|
||||
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
|
||||
save.resolve(configResponse({ plugins: { info: { enabled: false } } }));
|
||||
await savePromise;
|
||||
|
||||
expect(pluginsSpy).not.toHaveBeenCalled();
|
||||
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "savedMessage")).toBe("");
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { configApi, pluginsApi, type PiWebConfigResponse, type PiWebPluginsResponse } from "../api";
|
||||
import { SettingsDialog } from "./SettingsDialog";
|
||||
import { callDialogPromise, callDialogUpdated, configResponse, deferred, getDialogProperty, pluginInfo, pluginsResponse, remoteMachine, runtimeWithPackageManagement as runtimeWithoutSelectedMachineSettings, secondRemoteMachine, setDialogProperty, stubWindowTimers } from "./SettingsDialog.testSupport";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("settings-dialog session daemon machine targeting", () => {
|
||||
it("keeps gateway settings loads on the gateway config/plugin endpoints", async () => {
|
||||
const config = configResponse({ host: "127.0.0.1" });
|
||||
const plugins: PiWebPluginsResponse = { plugins: [] };
|
||||
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
|
||||
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(plugins);
|
||||
const dialog = new SettingsDialog();
|
||||
|
||||
await callDialogPromise(dialog, "loadConfig");
|
||||
|
||||
expect(configSpy.mock.calls).toEqual([[]]);
|
||||
expect(pluginsSpy.mock.calls).toEqual([[]]);
|
||||
expect(getDialogProperty(dialog, "configResponse")).toBe(config);
|
||||
expect(getDialogProperty(dialog, "pluginsResponse")).toBe(plugins);
|
||||
expect(getDialogProperty(dialog, "error")).toBe("");
|
||||
expect(getDialogProperty(dialog, "loading")).toBe(false);
|
||||
});
|
||||
|
||||
it("loads session-daemon config from the selected machine", async () => {
|
||||
const config = configResponse({ spawnSessions: false, subsessions: true });
|
||||
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
await callDialogPromise(dialog, "loadSessiondConfigForTarget");
|
||||
|
||||
expect(configSpy.mock.calls).toEqual([["remote-a"]]);
|
||||
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBe(config);
|
||||
expect(getDialogProperty(dialog, "sessiondError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false);
|
||||
});
|
||||
|
||||
it("saves local session-daemon config through the local machine alias and updates local daemon state", async () => {
|
||||
stubWindowTimers();
|
||||
const gatewayConfig = configResponse({ host: "127.0.0.1", spawnSessions: false, subsessions: false });
|
||||
const savedConfig = configResponse({ spawnSessions: true });
|
||||
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
|
||||
const dialog = new SettingsDialog();
|
||||
setDialogProperty(dialog, "configResponse", gatewayConfig);
|
||||
|
||||
await callDialogPromise(dialog, "saveSessiondConfig", { spawnSessions: true });
|
||||
|
||||
expect(saveSpy.mock.calls).toEqual([[{ spawnSessions: true }, "local"]]);
|
||||
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBe(savedConfig);
|
||||
expect(getDialogProperty(dialog, "configResponse")).toMatchObject({ config: { host: "127.0.0.1", spawnSessions: true, subsessions: false } });
|
||||
expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved.");
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores stale session-daemon load responses after the selected machine changes", async () => {
|
||||
const load = deferred<PiWebConfigResponse>();
|
||||
vi.spyOn(configApi, "config").mockReturnValue(load.promise);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
const loadPromise = callDialogPromise(dialog, "loadSessiondConfigForTarget");
|
||||
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(true);
|
||||
|
||||
dialog.machine = secondRemoteMachine;
|
||||
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
|
||||
load.resolve(configResponse({ spawnSessions: false }));
|
||||
await loadPromise;
|
||||
|
||||
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "sessiondError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores stale session-daemon save responses after the selected machine changes", async () => {
|
||||
stubWindowTimers();
|
||||
const save = deferred<PiWebConfigResponse>();
|
||||
vi.spyOn(configApi, "saveConfig").mockReturnValue(save.promise);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
const savePromise = callDialogPromise(dialog, "saveSessiondConfig", { subsessions: true });
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(true);
|
||||
|
||||
dialog.machine = secondRemoteMachine;
|
||||
save.resolve(configResponse({ subsessions: true }));
|
||||
await savePromise;
|
||||
|
||||
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "savedMessage")).toBe("");
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
|
||||
it("skips selected-machine settings loads when the remote runtime does not advertise support", async () => {
|
||||
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(configResponse({ spawnSessions: true }));
|
||||
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(pluginsResponse([pluginInfo("info", true)]));
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
dialog.machineRuntime = runtimeWithoutSelectedMachineSettings;
|
||||
|
||||
await callDialogPromise(dialog, "loadSessiondConfigForTarget");
|
||||
await callDialogPromise(dialog, "loadAccessConfigForTarget");
|
||||
await callDialogPromise(dialog, "loadPluginsForTarget");
|
||||
|
||||
expect(configSpy).not.toHaveBeenCalled();
|
||||
expect(pluginsSpy).not.toHaveBeenCalled();
|
||||
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "accessConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "sessiondError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
|
||||
expect(getDialogProperty(dialog, "accessError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
|
||||
expect(getDialogProperty(dialog, "pluginError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
|
||||
});
|
||||
|
||||
it("does not save remote selected-machine settings when runtime support is missing", async () => {
|
||||
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(configResponse({ spawnSessions: true }));
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
dialog.machineRuntime = runtimeWithoutSelectedMachineSettings;
|
||||
setDialogProperty(dialog, "selectedPluginConfigResponse", configResponse({ plugins: { info: { enabled: true } } }));
|
||||
|
||||
await callDialogPromise(dialog, "saveSessiondConfig", { spawnSessions: true });
|
||||
await callDialogPromise(dialog, "saveMachineAccessConfig", { pathAccess: { allowedPaths: ["/mnt/share"] } });
|
||||
await callDialogPromise(dialog, "togglePlugin", "info", false);
|
||||
|
||||
expect(saveSpy).not.toHaveBeenCalled();
|
||||
expect(getDialogProperty(dialog, "sessiondError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
|
||||
expect(getDialogProperty(dialog, "accessError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
|
||||
expect(getDialogProperty(dialog, "pluginError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
|
||||
});
|
||||
|
||||
it("shows selected-machine settings errors with the selected target name", async () => {
|
||||
vi.spyOn(configApi, "config").mockRejectedValue(new Error("Remote machine unavailable"));
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
await callDialogPromise(dialog, "loadSessiondConfigForTarget");
|
||||
|
||||
expect(getDialogProperty(dialog, "sessiondError")).toBe("Failed to load session-daemon config from Lab Mac (remote machine): Could not reach Lab Mac for selected-machine settings. Check the machine connection and try again.");
|
||||
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,705 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
||||
import { configApi, piPackagesApi, pluginsApi, type Machine, type MachineRuntime, type PiPackageInfo, type PiPackageMutationResponse, type PiWebConfigResponse, type PiWebConfigValues, type PiWebPluginInfo, type PiWebPluginsResponse } from "../api";
|
||||
import { SettingsDialog } from "./SettingsDialog";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("settings-dialog session daemon machine targeting", () => {
|
||||
it("keeps gateway settings loads on the gateway config/plugin endpoints", async () => {
|
||||
const config = configResponse({ host: "127.0.0.1" });
|
||||
const plugins: PiWebPluginsResponse = { plugins: [] };
|
||||
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
|
||||
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(plugins);
|
||||
const dialog = new SettingsDialog();
|
||||
|
||||
await callDialogPromise(dialog, "loadConfig");
|
||||
|
||||
expect(configSpy.mock.calls).toEqual([[]]);
|
||||
expect(pluginsSpy.mock.calls).toEqual([[]]);
|
||||
expect(getDialogProperty(dialog, "configResponse")).toBe(config);
|
||||
expect(getDialogProperty(dialog, "pluginsResponse")).toBe(plugins);
|
||||
expect(getDialogProperty(dialog, "error")).toBe("");
|
||||
expect(getDialogProperty(dialog, "loading")).toBe(false);
|
||||
});
|
||||
|
||||
it("loads session-daemon config from the selected machine", async () => {
|
||||
const config = configResponse({ spawnSessions: false, subsessions: true });
|
||||
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
await callDialogPromise(dialog, "loadSessiondConfigForTarget");
|
||||
|
||||
expect(configSpy.mock.calls).toEqual([["remote-a"]]);
|
||||
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBe(config);
|
||||
expect(getDialogProperty(dialog, "sessiondError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false);
|
||||
});
|
||||
|
||||
it("saves local session-daemon config through the local machine alias and updates local daemon state", async () => {
|
||||
stubWindowTimers();
|
||||
const gatewayConfig = configResponse({ host: "127.0.0.1", spawnSessions: false, subsessions: false });
|
||||
const savedConfig = configResponse({ spawnSessions: true });
|
||||
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
|
||||
const dialog = new SettingsDialog();
|
||||
setDialogProperty(dialog, "configResponse", gatewayConfig);
|
||||
|
||||
await callDialogPromise(dialog, "saveSessiondConfig", { spawnSessions: true });
|
||||
|
||||
expect(saveSpy.mock.calls).toEqual([[{ spawnSessions: true }, "local"]]);
|
||||
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBe(savedConfig);
|
||||
expect(getDialogProperty(dialog, "configResponse")).toMatchObject({ config: { host: "127.0.0.1", spawnSessions: true, subsessions: false } });
|
||||
expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved.");
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores stale session-daemon load responses after the selected machine changes", async () => {
|
||||
const load = deferred<PiWebConfigResponse>();
|
||||
vi.spyOn(configApi, "config").mockReturnValue(load.promise);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
const loadPromise = callDialogPromise(dialog, "loadSessiondConfigForTarget");
|
||||
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(true);
|
||||
|
||||
dialog.machine = secondRemoteMachine;
|
||||
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
|
||||
load.resolve(configResponse({ spawnSessions: false }));
|
||||
await loadPromise;
|
||||
|
||||
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "sessiondError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores stale session-daemon save responses after the selected machine changes", async () => {
|
||||
stubWindowTimers();
|
||||
const save = deferred<PiWebConfigResponse>();
|
||||
vi.spyOn(configApi, "saveConfig").mockReturnValue(save.promise);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
const savePromise = callDialogPromise(dialog, "saveSessiondConfig", { subsessions: true });
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(true);
|
||||
|
||||
dialog.machine = secondRemoteMachine;
|
||||
save.resolve(configResponse({ subsessions: true }));
|
||||
await savePromise;
|
||||
|
||||
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "savedMessage")).toBe("");
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
|
||||
it("skips selected-machine settings loads when the remote runtime does not advertise support", async () => {
|
||||
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(configResponse({ spawnSessions: true }));
|
||||
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(pluginsResponse([pluginInfo("info", true)]));
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
dialog.machineRuntime = runtimeWithoutSelectedMachineSettings;
|
||||
|
||||
await callDialogPromise(dialog, "loadSessiondConfigForTarget");
|
||||
await callDialogPromise(dialog, "loadAccessConfigForTarget");
|
||||
await callDialogPromise(dialog, "loadPluginsForTarget");
|
||||
|
||||
expect(configSpy).not.toHaveBeenCalled();
|
||||
expect(pluginsSpy).not.toHaveBeenCalled();
|
||||
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "accessConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "sessiondError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
|
||||
expect(getDialogProperty(dialog, "accessError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
|
||||
expect(getDialogProperty(dialog, "pluginError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
|
||||
});
|
||||
|
||||
it("does not save remote selected-machine settings when runtime support is missing", async () => {
|
||||
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(configResponse({ spawnSessions: true }));
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
dialog.machineRuntime = runtimeWithoutSelectedMachineSettings;
|
||||
setDialogProperty(dialog, "selectedPluginConfigResponse", configResponse({ plugins: { info: { enabled: true } } }));
|
||||
|
||||
await callDialogPromise(dialog, "saveSessiondConfig", { spawnSessions: true });
|
||||
await callDialogPromise(dialog, "saveMachineAccessConfig", { pathAccess: { allowedPaths: ["/mnt/share"] } });
|
||||
await callDialogPromise(dialog, "togglePlugin", "info", false);
|
||||
|
||||
expect(saveSpy).not.toHaveBeenCalled();
|
||||
expect(getDialogProperty(dialog, "sessiondError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
|
||||
expect(getDialogProperty(dialog, "accessError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
|
||||
expect(getDialogProperty(dialog, "pluginError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
|
||||
});
|
||||
|
||||
it("shows selected-machine settings errors with the selected target name", async () => {
|
||||
vi.spyOn(configApi, "config").mockRejectedValue(new Error("Remote machine unavailable"));
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
await callDialogPromise(dialog, "loadSessiondConfigForTarget");
|
||||
|
||||
expect(getDialogProperty(dialog, "sessiondError")).toBe("Failed to load session-daemon config from Lab Mac (remote machine): Could not reach Lab Mac for selected-machine settings. Check the machine connection and try again.");
|
||||
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings-dialog general settings machine targeting", () => {
|
||||
it("renders the active settings panel without the old global scope note", () => {
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.section = "general";
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
const strings = collectTemplateStrings(dialog.render()).join("");
|
||||
|
||||
expect(strings).toContain("<settings-general-panel");
|
||||
expect(strings).not.toContain("scope-note");
|
||||
expect(strings).not.toContain("This tab edits:");
|
||||
});
|
||||
|
||||
it("keeps gateway server config saves on the gateway config endpoint", async () => {
|
||||
stubWindowTimers();
|
||||
const savedConfig = configResponse({ host: "0.0.0.0", port: 9000, allowedHosts: true });
|
||||
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
|
||||
const onConfigSaved = vi.fn();
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.onConfigSaved = onConfigSaved;
|
||||
|
||||
await callDialogPromise(dialog, "saveConfig", { host: "0.0.0.0", port: 9000, allowedHosts: true });
|
||||
|
||||
expect(saveSpy.mock.calls).toEqual([[{ host: "0.0.0.0", port: 9000, allowedHosts: true }]]);
|
||||
expect(getDialogProperty(dialog, "configResponse")).toBe(savedConfig);
|
||||
expect(onConfigSaved).toHaveBeenCalledWith({ host: "0.0.0.0", port: 9000, allowedHosts: true });
|
||||
expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved.");
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
|
||||
it("loads file access and upload config from the selected machine", async () => {
|
||||
const config = configResponse({ pathAccess: { allowedPaths: ["/mnt/share"] }, uploads: { defaultFolder: "manual/uploads" } });
|
||||
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
await callDialogPromise(dialog, "loadAccessConfigForTarget");
|
||||
|
||||
expect(configSpy.mock.calls).toEqual([["remote-a"]]);
|
||||
expect(getDialogProperty(dialog, "accessConfigResponse")).toBe(config);
|
||||
expect(getDialogProperty(dialog, "accessError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "accessLoading")).toBe(false);
|
||||
});
|
||||
|
||||
it("saves selected-machine file access and upload config through the selected-machine endpoint", async () => {
|
||||
stubWindowTimers();
|
||||
const patch = { pathAccess: { allowedPaths: ["/mnt/share", "~/SDKs"] }, uploads: { defaultFolder: "manual/uploads" } };
|
||||
const savedConfig = configResponse(patch);
|
||||
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
await callDialogPromise(dialog, "saveMachineAccessConfig", patch);
|
||||
|
||||
expect(saveSpy.mock.calls).toEqual([[patch, "remote-a"]]);
|
||||
expect(getDialogProperty(dialog, "accessConfigResponse")).toBe(savedConfig);
|
||||
expect(getDialogProperty(dialog, "configResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved.");
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
|
||||
it("merges local selected-machine access saves into gateway config without dropping gateway-only values", async () => {
|
||||
stubWindowTimers();
|
||||
const gatewayConfig = configResponse({
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: true } },
|
||||
spawnSessions: false,
|
||||
pathAccess: { allowedPaths: ["/old"] },
|
||||
uploads: { defaultFolder: "old/uploads" },
|
||||
maxUploadBytes: 1234,
|
||||
});
|
||||
const patch = { pathAccess: { allowedPaths: ["~/SDKs"] }, uploads: {} };
|
||||
const savedConfig = configResponse({ pathAccess: { allowedPaths: ["~/SDKs"] }, uploads: {}, maxUploadBytes: 5678 });
|
||||
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
|
||||
const onConfigSaved = vi.fn();
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.onConfigSaved = onConfigSaved;
|
||||
setDialogProperty(dialog, "configResponse", gatewayConfig);
|
||||
|
||||
await callDialogPromise(dialog, "saveMachineAccessConfig", patch);
|
||||
|
||||
expect(saveSpy.mock.calls).toEqual([[patch, "local"]]);
|
||||
expect(getDialogProperty(dialog, "accessConfigResponse")).toBe(savedConfig);
|
||||
expect(getDialogProperty(dialog, "configResponse")).toMatchObject({
|
||||
config: {
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: true } },
|
||||
spawnSessions: false,
|
||||
pathAccess: { allowedPaths: ["~/SDKs"] },
|
||||
uploads: {},
|
||||
maxUploadBytes: 5678,
|
||||
},
|
||||
effectiveConfig: {
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: true } },
|
||||
spawnSessions: false,
|
||||
pathAccess: { allowedPaths: ["~/SDKs"] },
|
||||
uploads: {},
|
||||
maxUploadBytes: 5678,
|
||||
},
|
||||
});
|
||||
expect(onConfigSaved).toHaveBeenCalledWith({
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: true } },
|
||||
spawnSessions: false,
|
||||
pathAccess: { allowedPaths: ["~/SDKs"] },
|
||||
uploads: {},
|
||||
maxUploadBytes: 5678,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores stale file access load responses after the selected machine changes", async () => {
|
||||
const load = deferred<PiWebConfigResponse>();
|
||||
vi.spyOn(configApi, "config").mockReturnValue(load.promise);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
const loadPromise = callDialogPromise(dialog, "loadAccessConfigForTarget");
|
||||
expect(getDialogProperty(dialog, "accessLoading")).toBe(true);
|
||||
|
||||
dialog.machine = secondRemoteMachine;
|
||||
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
|
||||
load.resolve(configResponse({ pathAccess: { allowedPaths: ["/stale"] } }));
|
||||
await loadPromise;
|
||||
|
||||
expect(getDialogProperty(dialog, "accessConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "accessError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "accessLoading")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores stale file access save responses after the selected machine changes", async () => {
|
||||
const save = deferred<PiWebConfigResponse>();
|
||||
vi.spyOn(configApi, "saveConfig").mockReturnValue(save.promise);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
const savePromise = callDialogPromise(dialog, "saveMachineAccessConfig", { pathAccess: { allowedPaths: ["/mnt/share"] }, uploads: { defaultFolder: "manual" } });
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(true);
|
||||
|
||||
dialog.machine = secondRemoteMachine;
|
||||
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
|
||||
save.resolve(configResponse({ pathAccess: { allowedPaths: ["/mnt/share"] }, uploads: { defaultFolder: "manual" } }));
|
||||
await savePromise;
|
||||
|
||||
expect(getDialogProperty(dialog, "accessConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "savedMessage")).toBe("");
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
|
||||
it("shows selected-machine file access errors with the selected target name", async () => {
|
||||
vi.spyOn(configApi, "config").mockRejectedValue(new Error("Remote machine unavailable"));
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
await callDialogPromise(dialog, "loadAccessConfigForTarget");
|
||||
|
||||
expect(getDialogProperty(dialog, "accessError")).toBe("Failed to load file access/upload config from Lab Mac (remote machine): Could not reach Lab Mac for selected-machine settings. Check the machine connection and try again.");
|
||||
expect(getDialogProperty(dialog, "accessLoading")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings-dialog Pi package orchestration", () => {
|
||||
it("loads package data from the selected machine and ignores stale target responses", async () => {
|
||||
const remotePackages = { packages: [packageInfo("npm:@acme/tools")] };
|
||||
const staleLoad = deferred<typeof remotePackages>();
|
||||
const packagesSpy = vi.spyOn(piPackagesApi, "packages").mockReturnValue(staleLoad.promise);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
dialog.machineRuntime = runtimeWithPackageManagement;
|
||||
|
||||
const loadPromise = callDialogPromise(dialog, "loadPackagesForTarget");
|
||||
expect(packagesSpy.mock.calls).toEqual([["remote-a"]]);
|
||||
expect(getDialogProperty(dialog, "packageLoading")).toBe(true);
|
||||
|
||||
dialog.machine = secondRemoteMachine;
|
||||
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
|
||||
staleLoad.resolve(remotePackages);
|
||||
await loadPromise;
|
||||
|
||||
expect(getDialogProperty(dialog, "packagesResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "packageError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "packageMessage")).toBe("");
|
||||
expect(getDialogProperty(dialog, "packageLoading")).toBe(false);
|
||||
});
|
||||
|
||||
it("runs remote package mutations against the selected machine without refreshing gateway plugins", async () => {
|
||||
const installedPackages = [packageInfo("npm:@acme/new-tools")];
|
||||
const install = deferred<PiPackageMutationResponse>();
|
||||
const installSpy = vi.spyOn(piPackagesApi, "install").mockReturnValue(install.promise);
|
||||
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(pluginsResponse([pluginInfo("gateway", true)]));
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
dialog.machineRuntime = runtimeWithPackageManagement;
|
||||
|
||||
const installPromise = callDialogPromise(dialog, "installPiPackage", "npm:@acme/new-tools");
|
||||
|
||||
expect(installSpy.mock.calls).toEqual([["npm:@acme/new-tools", "remote-a"]]);
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(true);
|
||||
expect(getDialogProperty(dialog, "packageOperation")).toEqual({ kind: "install", source: "npm:@acme/new-tools" });
|
||||
|
||||
install.resolve(packageMutationResponse("install", installedPackages, "npm:@acme/new-tools"));
|
||||
await installPromise;
|
||||
|
||||
expect(pluginsSpy).not.toHaveBeenCalled();
|
||||
expect(getDialogProperty(dialog, "packagesResponse")).toEqual({ packages: installedPackages });
|
||||
expect(getDialogProperty(dialog, "packageMessage")).toContain("Pi package installed on Lab Mac");
|
||||
expect(getDialogProperty(dialog, "packageMessage")).toContain("each idle PI WEB session on Lab Mac");
|
||||
expect(getDialogProperty(dialog, "packageError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "packageOperation")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
|
||||
it("refreshes gateway plugins after a local package mutation", async () => {
|
||||
const updatedPackages = [packageInfo("npm:@acme/tools")];
|
||||
const refreshedPlugins = pluginsResponse([pluginInfo("browser-helper", true)]);
|
||||
const updateSpy = vi.spyOn(piPackagesApi, "update").mockResolvedValue(packageMutationResponse("update", updatedPackages));
|
||||
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(refreshedPlugins);
|
||||
const dialog = new SettingsDialog();
|
||||
|
||||
await callDialogPromise(dialog, "updatePiPackage");
|
||||
|
||||
expect(updateSpy.mock.calls).toEqual([[undefined, "local"]]);
|
||||
expect(pluginsSpy.mock.calls).toEqual([[]]);
|
||||
expect(getDialogProperty(dialog, "packagesResponse")).toEqual({ packages: updatedPackages });
|
||||
expect(getDialogProperty(dialog, "pluginsResponse")).toBe(refreshedPlugins);
|
||||
expect(getDialogProperty(dialog, "packageMessage")).toContain("Reload the browser page separately for PI WEB browser plugin changes");
|
||||
expect(getDialogProperty(dialog, "packageError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings-dialog plugin settings machine targeting", () => {
|
||||
it("loads plugin config and plugin list from the selected machine", async () => {
|
||||
const config = configResponse({ plugins: { info: { enabled: true } } });
|
||||
const plugins = pluginsResponse([pluginInfo("info", true)]);
|
||||
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
|
||||
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(plugins);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
await callDialogPromise(dialog, "loadPluginsForTarget");
|
||||
|
||||
expect(configSpy.mock.calls).toEqual([["remote-a"]]);
|
||||
expect(pluginsSpy.mock.calls).toEqual([["remote-a"]]);
|
||||
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(config);
|
||||
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBe(plugins);
|
||||
expect(getDialogProperty(dialog, "pluginError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "pluginLoading")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps fulfilled plugin config when the selected machine plugin list is unsupported", async () => {
|
||||
const config = configResponse({ plugins: { info: { enabled: true } } });
|
||||
vi.spyOn(configApi, "config").mockResolvedValue(config);
|
||||
vi.spyOn(pluginsApi, "plugins").mockRejectedValue(new Error("route GET:/api/plugins not found"));
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
await callDialogPromise(dialog, "loadPluginsForTarget");
|
||||
|
||||
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(config);
|
||||
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "pluginError")).toBe("Failed to load PI WEB plugin settings from Lab Mac (remote machine): PI WEB plugins: Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
|
||||
expect(getDialogProperty(dialog, "pluginLoading")).toBe(false);
|
||||
});
|
||||
|
||||
it("saves selected-machine plugin toggles as plugin-only patches and refreshes the selected machine plugin list", async () => {
|
||||
stubWindowTimers();
|
||||
const baseConfig = configResponse({
|
||||
plugins: {
|
||||
keep: { enabled: true, settings: { level: 1 } },
|
||||
info: { settings: { color: "blue" } },
|
||||
},
|
||||
});
|
||||
const savedConfig = configResponse({
|
||||
plugins: {
|
||||
keep: { enabled: true, settings: { level: 1 } },
|
||||
info: { enabled: false, settings: { color: "blue" } },
|
||||
},
|
||||
});
|
||||
const refreshedPlugins = pluginsResponse([pluginInfo("info", false), pluginInfo("keep", true)]);
|
||||
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
|
||||
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(refreshedPlugins);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
setDialogProperty(dialog, "selectedPluginConfigResponse", baseConfig);
|
||||
|
||||
await callDialogPromise(dialog, "togglePlugin", "info", false);
|
||||
|
||||
expect(saveSpy.mock.calls).toEqual([[
|
||||
{
|
||||
plugins: {
|
||||
keep: { enabled: true, settings: { level: 1 } },
|
||||
info: { enabled: false, settings: { color: "blue" } },
|
||||
},
|
||||
},
|
||||
"remote-a",
|
||||
]]);
|
||||
expect(pluginsSpy.mock.calls).toEqual([["remote-a"]]);
|
||||
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(savedConfig);
|
||||
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBe(refreshedPlugins);
|
||||
expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved.");
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
|
||||
it("merges local selected-machine plugin saves into gateway config without dropping gateway-only values", async () => {
|
||||
stubWindowTimers();
|
||||
const gatewayConfig = configResponse({
|
||||
host: "127.0.0.1",
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
spawnSessions: false,
|
||||
plugins: { info: { enabled: false }, gateway: { settings: { theme: "dark" } } },
|
||||
});
|
||||
const savedConfig = configResponse({ plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } } });
|
||||
const refreshedPlugins = pluginsResponse([pluginInfo("info", true)]);
|
||||
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
|
||||
vi.spyOn(pluginsApi, "plugins").mockResolvedValue(refreshedPlugins);
|
||||
const onConfigSaved = vi.fn();
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.onConfigSaved = onConfigSaved;
|
||||
setDialogProperty(dialog, "configResponse", gatewayConfig);
|
||||
setDialogProperty(dialog, "selectedPluginConfigResponse", configResponse({ plugins: { info: { enabled: false } } }));
|
||||
|
||||
await callDialogPromise(dialog, "togglePlugin", "info", true);
|
||||
|
||||
expect(saveSpy.mock.calls).toEqual([[{ plugins: { info: { enabled: true } } }, "local"]]);
|
||||
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(savedConfig);
|
||||
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBe(refreshedPlugins);
|
||||
expect(getDialogProperty(dialog, "configResponse")).toMatchObject({
|
||||
config: {
|
||||
host: "127.0.0.1",
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
spawnSessions: false,
|
||||
plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } },
|
||||
},
|
||||
effectiveConfig: {
|
||||
host: "127.0.0.1",
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
spawnSessions: false,
|
||||
plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } },
|
||||
},
|
||||
});
|
||||
expect(onConfigSaved).toHaveBeenCalledWith({
|
||||
host: "127.0.0.1",
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
spawnSessions: false,
|
||||
plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } },
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores stale plugin load responses after the selected machine changes", async () => {
|
||||
const configLoad = deferred<PiWebConfigResponse>();
|
||||
const pluginsLoad = deferred<PiWebPluginsResponse>();
|
||||
vi.spyOn(configApi, "config").mockReturnValue(configLoad.promise);
|
||||
vi.spyOn(pluginsApi, "plugins").mockReturnValue(pluginsLoad.promise);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
const loadPromise = callDialogPromise(dialog, "loadPluginsForTarget");
|
||||
expect(getDialogProperty(dialog, "pluginLoading")).toBe(true);
|
||||
|
||||
dialog.machine = secondRemoteMachine;
|
||||
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
|
||||
configLoad.resolve(configResponse({ plugins: { info: { enabled: true } } }));
|
||||
pluginsLoad.resolve(pluginsResponse([pluginInfo("info", true)]));
|
||||
await loadPromise;
|
||||
|
||||
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "pluginError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "pluginLoading")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores stale plugin save responses after the selected machine changes", async () => {
|
||||
const save = deferred<PiWebConfigResponse>();
|
||||
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(pluginsResponse([pluginInfo("info", false)]));
|
||||
vi.spyOn(configApi, "saveConfig").mockReturnValue(save.promise);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
setDialogProperty(dialog, "selectedPluginConfigResponse", configResponse({ plugins: { info: { enabled: true } } }));
|
||||
|
||||
const savePromise = callDialogPromise(dialog, "togglePlugin", "info", false);
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(true);
|
||||
|
||||
dialog.machine = secondRemoteMachine;
|
||||
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
|
||||
save.resolve(configResponse({ plugins: { info: { enabled: false } } }));
|
||||
await savePromise;
|
||||
|
||||
expect(pluginsSpy).not.toHaveBeenCalled();
|
||||
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "savedMessage")).toBe("");
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
const remoteMachine: Machine = {
|
||||
id: "remote-a",
|
||||
name: "Lab Mac",
|
||||
kind: "remote",
|
||||
baseUrl: "https://lab.example.test",
|
||||
createdAt: "2026-07-01T00:00:00.000Z",
|
||||
updatedAt: "2026-07-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const secondRemoteMachine: Machine = {
|
||||
id: "remote-b",
|
||||
name: "Build Box",
|
||||
kind: "remote",
|
||||
baseUrl: "https://build.example.test",
|
||||
createdAt: "2026-07-01T00:00:00.000Z",
|
||||
updatedAt: "2026-07-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const runtimeWithPackageManagement: MachineRuntime = {
|
||||
machineId: "remote-a",
|
||||
ok: true,
|
||||
checkedAt: "2026-07-01T00:00:00.000Z",
|
||||
capabilities: [PI_WEB_CAPABILITIES.piPackagesManage],
|
||||
};
|
||||
|
||||
const runtimeWithoutSelectedMachineSettings: MachineRuntime = runtimeWithPackageManagement;
|
||||
|
||||
function getDialogProperty(dialog: SettingsDialog, property: string): unknown {
|
||||
return Reflect.get(dialog, property);
|
||||
}
|
||||
|
||||
function setDialogProperty(dialog: SettingsDialog, property: string, value: unknown): void {
|
||||
if (!Reflect.set(dialog, property, value)) throw new Error(`Failed to set SettingsDialog property ${property}`);
|
||||
}
|
||||
|
||||
async function callDialogPromise(dialog: SettingsDialog, methodName: string, ...args: readonly unknown[]): Promise<void> {
|
||||
const result = callDialogMethod(dialog, methodName, ...args);
|
||||
if (!(result instanceof Promise)) throw new Error(`SettingsDialog.${methodName} did not return a promise`);
|
||||
await result;
|
||||
}
|
||||
|
||||
function callDialogUpdated(dialog: SettingsDialog, changed: Map<string, unknown>): void {
|
||||
const result = callDialogMethod(dialog, "updated", changed);
|
||||
if (result !== undefined) throw new Error("SettingsDialog.updated returned an unexpected value");
|
||||
}
|
||||
|
||||
function callDialogMethod(dialog: SettingsDialog, methodName: string, ...args: readonly unknown[]): unknown {
|
||||
const method: unknown = Reflect.get(dialog, methodName);
|
||||
if (!isDialogMethod(method)) throw new Error(`SettingsDialog.${methodName} is not callable`);
|
||||
return method.call(dialog, ...args);
|
||||
}
|
||||
|
||||
function isDialogMethod(value: unknown): value is (this: SettingsDialog, ...args: readonly unknown[]) => unknown {
|
||||
return typeof value === "function";
|
||||
}
|
||||
|
||||
function collectTemplateStrings(template: TemplateResult): string[] {
|
||||
const strings: string[] = [];
|
||||
visitTemplate(template);
|
||||
return strings;
|
||||
|
||||
function visitTemplate(current: TemplateResult): void {
|
||||
strings.push(...templateStrings(current));
|
||||
for (const value of templateValues(current)) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) if (isTemplateResult(item)) visitTemplate(item);
|
||||
} else if (isTemplateResult(value)) {
|
||||
visitTemplate(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function templateStrings(template: TemplateResult): readonly string[] {
|
||||
const strings = Reflect.get(template, "strings");
|
||||
if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable");
|
||||
return strings;
|
||||
}
|
||||
|
||||
function templateValues(template: TemplateResult): readonly unknown[] {
|
||||
const values = Reflect.get(template, "values");
|
||||
if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable");
|
||||
return values.map((value: unknown) => value);
|
||||
}
|
||||
|
||||
function isTemplateResult(value: unknown): value is TemplateResult {
|
||||
return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values"));
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
|
||||
}
|
||||
|
||||
function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
||||
return {
|
||||
path: "/tmp/pi-web/config.json",
|
||||
exists: true,
|
||||
config,
|
||||
effectiveConfig: config,
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
|
||||
};
|
||||
}
|
||||
|
||||
function pluginsResponse(plugins: PiWebPluginInfo[]): PiWebPluginsResponse {
|
||||
return { plugins };
|
||||
}
|
||||
|
||||
function pluginInfo(id: string, enabled: boolean): PiWebPluginInfo {
|
||||
return {
|
||||
id,
|
||||
module: `/pi-web-plugins/${id}/plugin.js`,
|
||||
source: "test",
|
||||
scope: "local",
|
||||
machineSpecific: false,
|
||||
enabled,
|
||||
};
|
||||
}
|
||||
|
||||
function packageInfo(source: string): PiPackageInfo {
|
||||
return { source, scope: "user", filtered: false, installedPath: `/pi/packages/${source}` };
|
||||
}
|
||||
|
||||
function packageMutationResponse(action: PiPackageMutationResponse["action"], packages: PiPackageInfo[], source?: string): PiPackageMutationResponse {
|
||||
return source === undefined ? { action, packages } : { action, source, packages };
|
||||
}
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (error: unknown) => void;
|
||||
}
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolveDeferred: ((value: T) => void) | undefined;
|
||||
let rejectDeferred: ((error: unknown) => void) | undefined;
|
||||
const promise = new Promise<T>((resolve, reject) => {
|
||||
resolveDeferred = resolve;
|
||||
rejectDeferred = reject;
|
||||
});
|
||||
if (resolveDeferred === undefined || rejectDeferred === undefined) throw new Error("Deferred promise was not initialized");
|
||||
return { promise, resolve: resolveDeferred, reject: rejectDeferred };
|
||||
}
|
||||
|
||||
function stubWindowTimers(): void {
|
||||
vi.stubGlobal("window", {
|
||||
clearTimeout: vi.fn(),
|
||||
setTimeout: vi.fn(() => 1),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { vi } from "vitest";
|
||||
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
||||
import type { Machine, MachineRuntime, PiPackageInfo, PiPackageMutationResponse, PiWebConfigResponse, PiWebConfigValues, PiWebPluginInfo, PiWebPluginsResponse } from "../api";
|
||||
import { SettingsDialog } from "./SettingsDialog";
|
||||
|
||||
export const remoteMachine: Machine = {
|
||||
id: "remote-a",
|
||||
name: "Lab Mac",
|
||||
kind: "remote",
|
||||
baseUrl: "https://lab.example.test",
|
||||
createdAt: "2026-07-01T00:00:00.000Z",
|
||||
updatedAt: "2026-07-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
export const secondRemoteMachine: Machine = {
|
||||
id: "remote-b",
|
||||
name: "Build Box",
|
||||
kind: "remote",
|
||||
baseUrl: "https://build.example.test",
|
||||
createdAt: "2026-07-01T00:00:00.000Z",
|
||||
updatedAt: "2026-07-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
export const runtimeWithPackageManagement: MachineRuntime = {
|
||||
machineId: "remote-a",
|
||||
ok: true,
|
||||
checkedAt: "2026-07-01T00:00:00.000Z",
|
||||
capabilities: [PI_WEB_CAPABILITIES.piPackagesManage],
|
||||
};
|
||||
|
||||
export function getDialogProperty(dialog: SettingsDialog, property: string): unknown {
|
||||
return Reflect.get(dialog, property);
|
||||
}
|
||||
|
||||
export function setDialogProperty(dialog: SettingsDialog, property: string, value: unknown): void {
|
||||
if (!Reflect.set(dialog, property, value)) throw new Error(`Failed to set SettingsDialog property ${property}`);
|
||||
}
|
||||
|
||||
export async function callDialogPromise(dialog: SettingsDialog, methodName: string, ...args: readonly unknown[]): Promise<void> {
|
||||
const result = callDialogMethod(dialog, methodName, ...args);
|
||||
if (!(result instanceof Promise)) throw new Error(`SettingsDialog.${methodName} did not return a promise`);
|
||||
await result;
|
||||
}
|
||||
|
||||
export function callDialogUpdated(dialog: SettingsDialog, changed: Map<string, unknown>): void {
|
||||
const result = callDialogMethod(dialog, "updated", changed);
|
||||
if (result !== undefined) throw new Error("SettingsDialog.updated returned an unexpected value");
|
||||
}
|
||||
|
||||
function callDialogMethod(dialog: SettingsDialog, methodName: string, ...args: readonly unknown[]): unknown {
|
||||
const method: unknown = Reflect.get(dialog, methodName);
|
||||
if (!isDialogMethod(method)) throw new Error(`SettingsDialog.${methodName} is not callable`);
|
||||
return method.call(dialog, ...args);
|
||||
}
|
||||
|
||||
function isDialogMethod(value: unknown): value is (this: SettingsDialog, ...args: readonly unknown[]) => unknown {
|
||||
return typeof value === "function";
|
||||
}
|
||||
|
||||
export function collectTemplateStrings(template: TemplateResult): string[] {
|
||||
const strings: string[] = [];
|
||||
visitTemplate(template);
|
||||
return strings;
|
||||
|
||||
function visitTemplate(current: TemplateResult): void {
|
||||
strings.push(...templateStrings(current));
|
||||
for (const value of templateValues(current)) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) if (isTemplateResult(item)) visitTemplate(item);
|
||||
} else if (isTemplateResult(value)) {
|
||||
visitTemplate(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function templateStrings(template: TemplateResult): readonly string[] {
|
||||
const strings = Reflect.get(template, "strings");
|
||||
if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable");
|
||||
return strings;
|
||||
}
|
||||
|
||||
function templateValues(template: TemplateResult): readonly unknown[] {
|
||||
const values = Reflect.get(template, "values");
|
||||
if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable");
|
||||
return values.map((value: unknown) => value);
|
||||
}
|
||||
|
||||
function isTemplateResult(value: unknown): value is TemplateResult {
|
||||
return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values"));
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
|
||||
}
|
||||
|
||||
export function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
||||
return {
|
||||
path: "/tmp/pi-web/config.json",
|
||||
exists: true,
|
||||
config,
|
||||
effectiveConfig: config,
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
|
||||
};
|
||||
}
|
||||
|
||||
export function pluginsResponse(plugins: PiWebPluginInfo[]): PiWebPluginsResponse {
|
||||
return { plugins };
|
||||
}
|
||||
|
||||
export function pluginInfo(id: string, enabled: boolean): PiWebPluginInfo {
|
||||
return {
|
||||
id,
|
||||
module: `/pi-web-plugins/${id}/plugin.js`,
|
||||
source: "test",
|
||||
scope: "local",
|
||||
machineSpecific: false,
|
||||
enabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function packageInfo(source: string): PiPackageInfo {
|
||||
return { source, scope: "user", filtered: false, installedPath: `/pi/packages/${source}` };
|
||||
}
|
||||
|
||||
export function packageMutationResponse(action: PiPackageMutationResponse["action"], packages: PiPackageInfo[], source?: string): PiPackageMutationResponse {
|
||||
return source === undefined ? { action, packages } : { action, source, packages };
|
||||
}
|
||||
|
||||
export interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export function deferred<T>(): Deferred<T> {
|
||||
let resolveDeferred: ((value: T) => void) | undefined;
|
||||
let rejectDeferred: ((error: unknown) => void) | undefined;
|
||||
const promise = new Promise<T>((resolve, reject) => {
|
||||
resolveDeferred = resolve;
|
||||
rejectDeferred = reject;
|
||||
});
|
||||
if (resolveDeferred === undefined || rejectDeferred === undefined) throw new Error("Deferred promise was not initialized");
|
||||
return { promise, resolve: resolveDeferred, reject: rejectDeferred };
|
||||
}
|
||||
|
||||
export function stubWindowTimers(): void {
|
||||
vi.stubGlobal("window", {
|
||||
clearTimeout: vi.fn(),
|
||||
setTimeout: vi.fn(() => 1),
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { FileContentResponse, FileTreeEntry } from "../api";
|
||||
import { initialAppState } from "../appState";
|
||||
import type { WorkspacePanelContext } from "../plugins/types";
|
||||
import type { WorkspaceUploadBatchState } from "../workspaceUploadState";
|
||||
@@ -39,6 +40,38 @@ describe("workspace-files-panel upload review", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace-files-panel file tree boundary", () => {
|
||||
it("renders expanded tree and selected-file state while wiring row clicks", () => {
|
||||
const onExpandDir = vi.fn<WorkspacePanelContext["onExpandDir"]>();
|
||||
const onSelectFile = vi.fn<WorkspacePanelContext["onSelectFile"]>();
|
||||
const panel = new WorkspaceFilesPanel();
|
||||
panel.context = workspacePanelContext({
|
||||
fileTree: [directoryEntry("src"), fileEntry("README.md", 4096)],
|
||||
expandedDirs: { src: [fileEntry("src/main.ts")] },
|
||||
selectedFilePath: "README.md",
|
||||
selectedFileContent: binaryFileContent("README.md", 4096),
|
||||
onExpandDir,
|
||||
onSelectFile,
|
||||
});
|
||||
|
||||
const rendered = panel.render();
|
||||
const text = collectTemplateText(rendered);
|
||||
|
||||
expect(text).toContain("▾");
|
||||
expect(text).toContain("src");
|
||||
expect(text).toContain("main.ts");
|
||||
expect(text).toContain("README.md");
|
||||
expect(text).toContain("Binary file: README.md · 4.0 KB");
|
||||
expect(text).not.toContain("Select a file.");
|
||||
|
||||
findTemplateClickHandlerForText<Event>(rendered, "src")(new Event("click"));
|
||||
findTemplateClickHandlerForText<Event>(rendered, "README.md")(new Event("click"));
|
||||
|
||||
expect(onExpandDir).toHaveBeenCalledWith("src");
|
||||
expect(onSelectFile).toHaveBeenCalledWith("README.md");
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspaceUploadBatchesForScope", () => {
|
||||
it("filters upload batches to the selected project, workspace, and machine", () => {
|
||||
const matchingOlder = uploadBatch({ id: "older", startedAt: "2026-06-25T00:00:00.000Z" });
|
||||
@@ -154,6 +187,50 @@ function findOptionalTemplateEventHandler<E extends Event>(template: TemplateRes
|
||||
}
|
||||
}
|
||||
|
||||
// Node-based Lit tests cannot click shadow DOM here; keep direct handler extraction
|
||||
// anchored to rendered file labels and assert the observable context callbacks.
|
||||
function findTemplateClickHandlerForText<E extends Event>(template: TemplateResult, text: string): TemplateEventHandler<E> {
|
||||
const handler = findOptionalTemplateClickHandlerForText<E>(template, text);
|
||||
if (handler === undefined) throw new Error(`Expected click handler near ${text}`);
|
||||
return handler;
|
||||
}
|
||||
|
||||
function findOptionalTemplateClickHandlerForText<E extends Event>(value: unknown, text: string): TemplateEventHandler<E> | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const nestedHandler = findOptionalTemplateClickHandlerForText<E>(item, text);
|
||||
if (nestedHandler !== undefined) return nestedHandler;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (!isTemplateResult(value)) return undefined;
|
||||
|
||||
for (const item of templateValues(value)) {
|
||||
const nestedHandler = findOptionalTemplateClickHandlerForText<E>(item, text);
|
||||
if (nestedHandler !== undefined) return nestedHandler;
|
||||
}
|
||||
if (!collectTemplateText(value).includes(text)) return undefined;
|
||||
|
||||
const strings = templateStrings(value);
|
||||
const values = templateValues(value);
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
const staticChunk = strings[index];
|
||||
const candidate = values[index];
|
||||
if (staticChunk !== undefined && staticChunk.includes("@click") && isTemplateEventHandler<E>(candidate)) return candidate;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function collectTemplateText(value: unknown): string {
|
||||
if (Array.isArray(value)) return value.map((item) => collectTemplateText(item)).join("");
|
||||
if (isTemplateResult(value)) {
|
||||
const strings = templateStrings(value);
|
||||
const values = templateValues(value);
|
||||
return strings.map((part, index) => `${part}${index < values.length ? collectTemplateText(values[index]) : ""}`).join("");
|
||||
}
|
||||
return typeof value === "string" || typeof value === "number" ? String(value) : "";
|
||||
}
|
||||
|
||||
function templateStrings(template: TemplateResult): readonly string[] {
|
||||
const strings = Reflect.get(template, "strings");
|
||||
if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable");
|
||||
@@ -222,44 +299,64 @@ class FakeSubmitEvent extends Event implements SubmitEvent {
|
||||
readonly submitter: HTMLElement | null = null;
|
||||
}
|
||||
|
||||
function workspacePanelContext(patch: Partial<Pick<WorkspacePanelContext, "onStartWorkspaceUpload" | "workspaceUploadDefaultFolder">> = {}): WorkspacePanelContext {
|
||||
const workspace = { id: "workspace-1", projectId: "project-1", path: "/tmp/project", label: "main", isMain: true, isGitRepo: true, isGitWorktree: false };
|
||||
function fileEntry(path: string, size = 2): FileTreeEntry {
|
||||
return { name: path.split("/").at(-1) ?? path, path, type: "file", size };
|
||||
}
|
||||
|
||||
function directoryEntry(path: string): FileTreeEntry {
|
||||
return { name: path.split("/").at(-1) ?? path, path, type: "directory" };
|
||||
}
|
||||
|
||||
function binaryFileContent(path: string, size: number): FileContentResponse {
|
||||
return {
|
||||
machine: { id: "local", name: "Local", kind: "local" },
|
||||
path,
|
||||
encoding: "utf8",
|
||||
size,
|
||||
modifiedAt: "2026-06-25T00:00:00.000Z",
|
||||
content: "",
|
||||
truncated: false,
|
||||
binary: true,
|
||||
};
|
||||
}
|
||||
|
||||
function workspacePanelContext(patch: Partial<WorkspacePanelContext> = {}): WorkspacePanelContext {
|
||||
const workspace = patch.workspace ?? { id: "workspace-1", projectId: "project-1", path: "/tmp/project", label: "main", isMain: true, isGitRepo: true, isGitWorktree: false };
|
||||
return {
|
||||
machine: patch.machine ?? { id: "local", name: "Local", kind: "local" },
|
||||
workspace,
|
||||
state: { ...initialAppState(), workspaceUploadBatches: {} },
|
||||
files: {
|
||||
state: patch.state ?? { ...initialAppState(), workspaceUploadBatches: {} },
|
||||
files: patch.files ?? {
|
||||
readFile: vi.fn<WorkspacePanelContext["files"]["readFile"]>(() => Promise.reject(new Error("not implemented"))),
|
||||
writeFile: vi.fn<WorkspacePanelContext["files"]["writeFile"]>(() => Promise.reject(new Error("not implemented"))),
|
||||
deleteFile: vi.fn<WorkspacePanelContext["files"]["deleteFile"]>(() => Promise.reject(new Error("not implemented"))),
|
||||
moveFile: vi.fn<WorkspacePanelContext["files"]["moveFile"]>(() => Promise.reject(new Error("not implemented"))),
|
||||
},
|
||||
prompt: { insertText: vi.fn<WorkspacePanelContext["prompt"]["insertText"]>(), getText: vi.fn<WorkspacePanelContext["prompt"]["getText"]>(() => ""), getSelection: vi.fn<WorkspacePanelContext["prompt"]["getSelection"]>(() => null) },
|
||||
terminal: { open: vi.fn<WorkspacePanelContext["terminal"]["open"]>(), runCommand: vi.fn<WorkspacePanelContext["terminal"]["runCommand"]>(() => Promise.reject(new Error("not implemented"))) },
|
||||
host: { requestRender: vi.fn<WorkspacePanelContext["host"]["requestRender"]>() },
|
||||
fileTree: [],
|
||||
expandedDirs: {},
|
||||
selectedFilePath: undefined,
|
||||
selectedFileContent: undefined,
|
||||
fileTreeStale: false,
|
||||
gitStatus: undefined,
|
||||
selectedDiffPath: undefined,
|
||||
selectedDiff: undefined,
|
||||
selectedStagedDiff: undefined,
|
||||
gitStale: false,
|
||||
activeTerminalCount: 0,
|
||||
selectedTerminalId: undefined,
|
||||
terminalAutoStart: false,
|
||||
prompt: patch.prompt ?? { insertText: vi.fn<WorkspacePanelContext["prompt"]["insertText"]>(), getText: vi.fn<WorkspacePanelContext["prompt"]["getText"]>(() => ""), getSelection: vi.fn<WorkspacePanelContext["prompt"]["getSelection"]>(() => null) },
|
||||
terminal: patch.terminal ?? { open: vi.fn<WorkspacePanelContext["terminal"]["open"]>(), runCommand: vi.fn<WorkspacePanelContext["terminal"]["runCommand"]>(() => Promise.reject(new Error("not implemented"))) },
|
||||
host: patch.host ?? { requestRender: vi.fn<WorkspacePanelContext["host"]["requestRender"]>() },
|
||||
fileTree: patch.fileTree ?? [],
|
||||
expandedDirs: patch.expandedDirs ?? {},
|
||||
selectedFilePath: patch.selectedFilePath,
|
||||
selectedFileContent: patch.selectedFileContent,
|
||||
fileTreeStale: patch.fileTreeStale ?? false,
|
||||
gitStatus: patch.gitStatus,
|
||||
selectedDiffPath: patch.selectedDiffPath,
|
||||
selectedDiff: patch.selectedDiff,
|
||||
selectedStagedDiff: patch.selectedStagedDiff,
|
||||
gitStale: patch.gitStale ?? false,
|
||||
activeTerminalCount: patch.activeTerminalCount ?? 0,
|
||||
selectedTerminalId: patch.selectedTerminalId,
|
||||
terminalAutoStart: patch.terminalAutoStart ?? false,
|
||||
workspaceUploadDefaultFolder: patch.workspaceUploadDefaultFolder ?? ".pi-web/uploads",
|
||||
onRefreshFiles: vi.fn<WorkspacePanelContext["onRefreshFiles"]>(),
|
||||
onExpandDir: vi.fn<WorkspacePanelContext["onExpandDir"]>(),
|
||||
onSelectFile: vi.fn<WorkspacePanelContext["onSelectFile"]>(),
|
||||
onRefreshFiles: patch.onRefreshFiles ?? vi.fn<WorkspacePanelContext["onRefreshFiles"]>(),
|
||||
onExpandDir: patch.onExpandDir ?? vi.fn<WorkspacePanelContext["onExpandDir"]>(),
|
||||
onSelectFile: patch.onSelectFile ?? vi.fn<WorkspacePanelContext["onSelectFile"]>(),
|
||||
onStartWorkspaceUpload: patch.onStartWorkspaceUpload ?? vi.fn<WorkspacePanelContext["onStartWorkspaceUpload"]>(() => undefined),
|
||||
onCancelWorkspaceUpload: vi.fn<WorkspacePanelContext["onCancelWorkspaceUpload"]>(),
|
||||
onClearWorkspaceUpload: vi.fn<WorkspacePanelContext["onClearWorkspaceUpload"]>(),
|
||||
onRefreshGit: vi.fn<WorkspacePanelContext["onRefreshGit"]>(),
|
||||
onSelectDiff: vi.fn<WorkspacePanelContext["onSelectDiff"]>(),
|
||||
onSelectTerminal: vi.fn<WorkspacePanelContext["onSelectTerminal"]>(),
|
||||
onCancelWorkspaceUpload: patch.onCancelWorkspaceUpload ?? vi.fn<WorkspacePanelContext["onCancelWorkspaceUpload"]>(),
|
||||
onClearWorkspaceUpload: patch.onClearWorkspaceUpload ?? vi.fn<WorkspacePanelContext["onClearWorkspaceUpload"]>(),
|
||||
onRefreshGit: patch.onRefreshGit ?? vi.fn<WorkspacePanelContext["onRefreshGit"]>(),
|
||||
onSelectDiff: patch.onSelectDiff ?? vi.fn<WorkspacePanelContext["onSelectDiff"]>(),
|
||||
onSelectTerminal: patch.onSelectTerminal ?? vi.fn<WorkspacePanelContext["onSelectTerminal"]>(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
WorkspaceUploadBatchError,
|
||||
WorkspaceUploadCancelledError,
|
||||
type FileContentResponse,
|
||||
type FileTreeEntry,
|
||||
type FileTreeResponse,
|
||||
type Machine,
|
||||
type Project,
|
||||
@@ -13,6 +14,9 @@ import {
|
||||
} from "../api";
|
||||
import { FileExplorerController, type FileExplorerControllerDependencies } from "./fileExplorerController";
|
||||
|
||||
type FileExplorerApi = NonNullable<FileExplorerControllerDependencies["api"]>;
|
||||
type WorkspaceTree = FileExplorerApi["workspaceTree"];
|
||||
type WorkspaceFile = FileExplorerApi["workspaceFile"];
|
||||
type UploadWorkspaceFiles = NonNullable<FileExplorerControllerDependencies["uploadWorkspaceFiles"]>;
|
||||
type UploadWorkspaceFilesOptions = NonNullable<Parameters<UploadWorkspaceFiles>[3]>;
|
||||
|
||||
@@ -48,6 +52,56 @@ const workspace: Workspace = {
|
||||
isGitWorktree: false,
|
||||
};
|
||||
|
||||
describe("FileExplorerController file tree workflows", () => {
|
||||
it("refreshes the root and already-expanded directories for the selected machine", async () => {
|
||||
const rootEntries = [directoryEntry("src"), fileEntry("README.md")];
|
||||
const refreshedSrcEntries = [fileEntry("src/index.ts")];
|
||||
const refreshedDocsEntries = [fileEntry("docs/guide.md")];
|
||||
const workspaceTree = vi.fn<WorkspaceTree>((_projectId, _workspaceId, path = "") => Promise.resolve(treeResponse(path, {
|
||||
"": rootEntries,
|
||||
src: refreshedSrcEntries,
|
||||
docs: refreshedDocsEntries,
|
||||
}[path] ?? [])));
|
||||
const harness = createHarness({ api: createApi({ workspaceTree }) }, {
|
||||
expandedDirs: {
|
||||
src: [fileEntry("src/stale.ts")],
|
||||
docs: [fileEntry("docs/stale.md")],
|
||||
},
|
||||
fileTreeStale: true,
|
||||
error: "stale failure",
|
||||
});
|
||||
|
||||
await harness.controller.refreshFiles();
|
||||
|
||||
expect(workspaceTree).toHaveBeenCalledTimes(3);
|
||||
expect(workspaceTree).toHaveBeenCalledWith("project-1", "workspace-1", "", "remote-1");
|
||||
expect(workspaceTree).toHaveBeenCalledWith("project-1", "workspace-1", "src", "remote-1");
|
||||
expect(workspaceTree).toHaveBeenCalledWith("project-1", "workspace-1", "docs", "remote-1");
|
||||
expect(harness.state.fileTree).toEqual(rootEntries);
|
||||
expect(harness.state.expandedDirs).toEqual({ src: refreshedSrcEntries, docs: refreshedDocsEntries });
|
||||
expect(harness.state.fileTreeStale).toBe(false);
|
||||
expect(harness.state.error).toBe("");
|
||||
});
|
||||
|
||||
it("expands a directory then collapses it locally without refetching", async () => {
|
||||
const srcEntries = [fileEntry("src/index.ts")];
|
||||
const workspaceTree = vi.fn<WorkspaceTree>((_projectId, _workspaceId, path = "") => Promise.resolve(treeResponse(path, srcEntries)));
|
||||
const harness = createHarness({ api: createApi({ workspaceTree }) });
|
||||
|
||||
await harness.controller.expandDir("src");
|
||||
|
||||
expect(workspaceTree).toHaveBeenCalledWith("project-1", "workspace-1", "src", "remote-1");
|
||||
expect(harness.state.expandedDirs).toEqual({ src: srcEntries });
|
||||
expect(harness.state.error).toBe("");
|
||||
|
||||
workspaceTree.mockClear();
|
||||
await harness.controller.expandDir("src");
|
||||
|
||||
expect(workspaceTree).not.toHaveBeenCalled();
|
||||
expect(harness.state.expandedDirs).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileExplorerController workspace uploads", () => {
|
||||
it("tracks upload progress, completes from final responses, refreshes files, and selects the first uploaded file", async () => {
|
||||
const upload = controllableUpload();
|
||||
@@ -227,18 +281,16 @@ describe("FileExplorerController workspace uploads", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function createHarness(deps: FileExplorerControllerDependencies = {}) {
|
||||
function createHarness(deps: FileExplorerControllerDependencies = {}, statePatch: Partial<AppState> = {}) {
|
||||
installWindow("http://localhost/app");
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedMachine: machine,
|
||||
selectedProject: project,
|
||||
selectedWorkspace: workspace,
|
||||
...statePatch,
|
||||
};
|
||||
const api: NonNullable<FileExplorerControllerDependencies["api"]> = deps.api ?? {
|
||||
workspaceTree: vi.fn<NonNullable<FileExplorerControllerDependencies["api"]>["workspaceTree"]>((_projectId, _workspaceId, path = "") => Promise.resolve(treeResponse(path))),
|
||||
workspaceFile: vi.fn<NonNullable<FileExplorerControllerDependencies["api"]>["workspaceFile"]>((_projectId, _workspaceId, path) => Promise.resolve(fileResponse(path))),
|
||||
};
|
||||
const api = deps.api ?? createApi();
|
||||
const updateUrl = vi.fn();
|
||||
let batchSequence = 0;
|
||||
const controller = new FileExplorerController(
|
||||
@@ -308,8 +360,24 @@ function sequenceNow(...values: string[]): () => string {
|
||||
return () => values[index++] ?? values.at(-1) ?? "now";
|
||||
}
|
||||
|
||||
function treeResponse(path: string): FileTreeResponse {
|
||||
return { path, entries: [], scannedAt: "2026-06-25T00:00:00.000Z", truncated: false };
|
||||
function createApi(overrides: Partial<FileExplorerApi> = {}): FileExplorerApi {
|
||||
return {
|
||||
workspaceTree: vi.fn<WorkspaceTree>((_projectId, _workspaceId, path = "") => Promise.resolve(treeResponse(path))),
|
||||
workspaceFile: vi.fn<WorkspaceFile>((_projectId, _workspaceId, path) => Promise.resolve(fileResponse(path))),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function treeResponse(path: string, entries: FileTreeEntry[] = []): FileTreeResponse {
|
||||
return { path, entries, scannedAt: "2026-06-25T00:00:00.000Z", truncated: false };
|
||||
}
|
||||
|
||||
function directoryEntry(path: string): FileTreeEntry {
|
||||
return { name: path.split("/").at(-1) ?? path, path, type: "directory" };
|
||||
}
|
||||
|
||||
function fileEntry(path: string): FileTreeEntry {
|
||||
return { name: path.split("/").at(-1) ?? path, path, type: "file", size: 2 };
|
||||
}
|
||||
|
||||
function fileResponse(path: string): FileContentResponse {
|
||||
|
||||
@@ -20,6 +20,15 @@ const remoteMachine: Machine = {
|
||||
updatedAt: "2026-05-26T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const addedMachine: Machine = {
|
||||
id: "remote-2",
|
||||
name: "New Remote",
|
||||
kind: "remote",
|
||||
baseUrl: "https://new-remote.example.test",
|
||||
createdAt: "2026-05-27T00:00:00.000Z",
|
||||
updatedAt: "2026-05-27T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const offlineHealth: MachineHealth = {
|
||||
machineId: remoteMachine.id,
|
||||
ok: false,
|
||||
@@ -33,6 +42,85 @@ describe("MachineController", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("selects a newly added machine and clears stale workspace state", async () => {
|
||||
const project = { id: "p1", name: "Project", path: "/repo", createdAt: "now" };
|
||||
const workspace = { id: "w1", projectId: project.id, path: "/repo", label: "main", isMain: true, isGitRepo: true, isGitWorktree: false };
|
||||
const session = { id: "s1", cwd: "/repo", path: "/repo/.pi/sessions/s1.json", created: "now", modified: "now", messageCount: 1, firstMessage: "hello" };
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
machines: [localMachine, remoteMachine],
|
||||
selectedMachine: localMachine,
|
||||
projects: [project],
|
||||
workspaces: [workspace],
|
||||
sessions: [session],
|
||||
selectedProject: project,
|
||||
selectedWorkspace: workspace,
|
||||
selectedSession: session,
|
||||
fileTree: [{ name: "index.ts", path: "src/index.ts", type: "file" }],
|
||||
selectedFilePath: "src/index.ts",
|
||||
gitStatus: { isGitRepo: true, hash: "abc123", branch: "main", files: [{ path: "src/index.ts", index: "modified", workingTree: "modified" }] },
|
||||
activeTerminalCount: 2,
|
||||
error: "stale error",
|
||||
};
|
||||
const setState = (patch: Partial<AppState>) => { state = { ...state, ...patch }; };
|
||||
const updateUrl = vi.fn();
|
||||
const projects = { loadProjects: vi.fn() };
|
||||
const input = { name: "New Remote", baseUrl: "https://new-remote.example.test", token: "secret-token" };
|
||||
|
||||
const addMachine = vi.spyOn(api, "addMachine").mockResolvedValue(addedMachine);
|
||||
const health = vi.spyOn(api, "health").mockResolvedValue({ machineId: addedMachine.id, ok: true, checkedAt: "2026-05-27T00:00:01.000Z", status: "online" });
|
||||
const runtime = vi.spyOn(api, "runtime").mockResolvedValue({ machineId: addedMachine.id, ok: true, checkedAt: "2026-05-27T00:00:02.000Z" });
|
||||
|
||||
const controller = new MachineController(() => state, setState, updateUrl, projects);
|
||||
|
||||
const machine = await controller.addMachine(input);
|
||||
|
||||
expect(machine).toEqual(addedMachine);
|
||||
expect(addMachine).toHaveBeenCalledWith(input);
|
||||
expect(state.machines).toEqual([localMachine, remoteMachine, addedMachine]);
|
||||
expect(state.selectedMachine).toEqual(addedMachine);
|
||||
expect(state.projects).toEqual([]);
|
||||
expect(state.workspaces).toEqual([]);
|
||||
expect(state.sessions).toEqual([]);
|
||||
expect(state.selectedProject).toBeUndefined();
|
||||
expect(state.selectedWorkspace).toBeUndefined();
|
||||
expect(state.selectedSession).toBeUndefined();
|
||||
expect(state.fileTree).toEqual([]);
|
||||
expect(state.selectedFilePath).toBeUndefined();
|
||||
expect(state.gitStatus).toBeUndefined();
|
||||
expect(state.activeTerminalCount).toBe(0);
|
||||
expect(state.error).toBe("");
|
||||
expect(projects.loadProjects).toHaveBeenCalledOnce();
|
||||
expect(updateUrl).toHaveBeenCalledOnce();
|
||||
expect(health).toHaveBeenCalledWith(addedMachine.id);
|
||||
expect(runtime).toHaveBeenCalledWith(addedMachine.id);
|
||||
});
|
||||
|
||||
it("preserves the current machine state when adding a machine fails", async () => {
|
||||
let state: AppState = { ...initialAppState(), machines: [localMachine], selectedMachine: localMachine };
|
||||
const setState = (patch: Partial<AppState>) => { state = { ...state, ...patch }; };
|
||||
const updateUrl = vi.fn();
|
||||
const projects = { loadProjects: vi.fn() };
|
||||
const input = { name: "New Remote", baseUrl: "https://new-remote.example.test" };
|
||||
|
||||
vi.spyOn(api, "addMachine").mockRejectedValue(new Error("Remote rejected"));
|
||||
const health = vi.spyOn(api, "health");
|
||||
const runtime = vi.spyOn(api, "runtime");
|
||||
|
||||
const controller = new MachineController(() => state, setState, updateUrl, projects);
|
||||
|
||||
const machine = await controller.addMachine(input);
|
||||
|
||||
expect(machine).toBeUndefined();
|
||||
expect(state.machines).toEqual([localMachine]);
|
||||
expect(state.selectedMachine).toEqual(localMachine);
|
||||
expect(state.error).toBe("Error: Remote rejected");
|
||||
expect(projects.loadProjects).not.toHaveBeenCalled();
|
||||
expect(updateUrl).not.toHaveBeenCalled();
|
||||
expect(health).not.toHaveBeenCalled();
|
||||
expect(runtime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the routed remote machine selected while its health is offline", async () => {
|
||||
let state: AppState = initialAppState();
|
||||
const setState = (patch: Partial<AppState>) => { state = { ...state, ...patch }; };
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
||||
import { initialAppState } from "../appState";
|
||||
import { SessionController } from "./sessionController";
|
||||
import { InMemorySessionSelectionMemory } from "./sessionSelection";
|
||||
import { defaultApi, emptyPage, FakeSocket, oldSession, sessionLookupId, status, workspace, type AppState } from "./sessionController.testSupport";
|
||||
|
||||
describe("SessionController archive and cleanup", () => {
|
||||
it("forgets the selected active session when archiving leaves only archived sessions", async () => {
|
||||
const persistedSession = { ...oldSession, persisted: true };
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [persistedSession] };
|
||||
const urlUpdates: ({ replace?: boolean | undefined } | undefined)[] = [];
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
archive: () => Promise.resolve({ archived: true }),
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
(options) => { urlUpdates.push(options); },
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.selectSession(persistedSession, { updateUrl: false });
|
||||
await controller.archiveSession();
|
||||
|
||||
expect(state.selectedSession).toBeUndefined();
|
||||
expect(state.sessions).toHaveLength(1);
|
||||
expect(state.sessions[0]).toMatchObject({ ...oldSession, archived: true });
|
||||
expect(typeof state.sessions[0]?.archivedAt).toBe("string");
|
||||
expect(controller.preferredSession(workspace.path, state.sessions, undefined)).toBeUndefined();
|
||||
expect(urlUpdates).toEqual([undefined]);
|
||||
});
|
||||
|
||||
it("archives legacy sessions when persistence support is not advertised", async () => {
|
||||
const legacySession = { ...oldSession };
|
||||
const archivedIds: string[] = [];
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: legacySession, sessions: [legacySession] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
archive: (session) => {
|
||||
archivedIds.push(sessionLookupId(session));
|
||||
return Promise.resolve({ archived: true });
|
||||
},
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.archiveSession(legacySession);
|
||||
|
||||
expect(archivedIds).toEqual([legacySession.id]);
|
||||
expect(state.sessions[0]).toMatchObject({ id: legacySession.id, archived: true });
|
||||
});
|
||||
|
||||
it("archives selected session descendants and selects the next active session", async () => {
|
||||
const persistedSession = { ...oldSession, persisted: true };
|
||||
const childSession = { ...oldSession, id: "child-session", path: "/tmp/child-session.jsonl", parentSessionPath: persistedSession.path, persisted: true };
|
||||
const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl", persisted: true };
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [persistedSession, childSession, nextSession] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
archiveWithDescendants: () => Promise.resolve({ archived: true, sessionIds: [persistedSession.id, childSession.id], archivedCount: 2, skippedAlreadyArchivedCount: 0 }),
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.selectSession(persistedSession, { updateUrl: false });
|
||||
await controller.archiveSessionWithDescendants(persistedSession);
|
||||
|
||||
expect(state.sessions.find((session) => session.id === oldSession.id)).toMatchObject({ archived: true });
|
||||
expect(state.sessions.find((session) => session.id === childSession.id)).toMatchObject({ archived: true });
|
||||
expect(state.selectedSession?.id).toBe(nextSession.id);
|
||||
});
|
||||
|
||||
it("archives selected sessions in bulk", async () => {
|
||||
const persistedSession = { ...oldSession, persisted: true };
|
||||
const secondSession = { ...oldSession, id: "second-session", path: "/tmp/second-session.jsonl", persisted: true };
|
||||
const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl", persisted: true };
|
||||
const archivedIds: string[] = [];
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [persistedSession, secondSession, nextSession] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
archive: (session) => {
|
||||
archivedIds.push(sessionLookupId(session));
|
||||
return Promise.resolve({ archived: true });
|
||||
},
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.selectSession(persistedSession, { updateUrl: false });
|
||||
await controller.archiveSessions([persistedSession, secondSession]);
|
||||
|
||||
expect(archivedIds).toEqual([oldSession.id, secondSession.id]);
|
||||
expect(state.sessions.find((session) => session.id === oldSession.id)).toMatchObject({ archived: true });
|
||||
expect(state.sessions.find((session) => session.id === secondSession.id)).toMatchObject({ archived: true });
|
||||
expect(state.selectedSession?.id).toBe(nextSession.id);
|
||||
});
|
||||
|
||||
it("uses true bulk archive when the selected runtime supports it and applies partial failures", async () => {
|
||||
const persistedSession = { ...oldSession, persisted: true };
|
||||
const failedSession = { ...oldSession, id: "failed-session", path: "/tmp/failed-session.jsonl", persisted: true };
|
||||
const archiveCalls: { ids: string[]; machineId: string }[] = [];
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedWorkspace: workspace,
|
||||
sessions: [persistedSession, failedSession],
|
||||
machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsBulkMutations] } },
|
||||
};
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
archiveMany: (sessions, machineId) => {
|
||||
archiveCalls.push({ ids: sessions.map(sessionLookupId), machineId: machineId ?? "local" });
|
||||
return Promise.resolve({ archived: true, archivedSessionIds: [persistedSession.id], failures: [{ sessionId: failedSession.id, error: "busy" }], generatedAt: "now" });
|
||||
},
|
||||
archive: () => { throw new Error("single archive should not be used"); },
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.selectSession(persistedSession, { updateUrl: false });
|
||||
await controller.archiveSessions([persistedSession, failedSession]);
|
||||
|
||||
expect(archiveCalls).toEqual([{ ids: [oldSession.id, failedSession.id], machineId: "local" }]);
|
||||
expect(state.sessions.find((session) => session.id === oldSession.id)).toMatchObject({ archived: true });
|
||||
expect(state.sessions.find((session) => session.id === failedSession.id)?.archived).toBeUndefined();
|
||||
expect(state.selectedSession?.id).toBe(failedSession.id);
|
||||
expect(state.error).toBe("Archive failed for 1 session: failed-session: busy");
|
||||
});
|
||||
|
||||
it("throttles per-session archive fallback when bulk mutations are unsupported", async () => {
|
||||
const sessions = Array.from({ length: 6 }, (_value, index) => ({ ...oldSession, id: `session-${String(index)}`, path: `/tmp/session-${String(index)}.jsonl`, persisted: true }));
|
||||
const resolvers: (() => void)[] = [];
|
||||
const startedIds: string[] = [];
|
||||
let activeCount = 0;
|
||||
let maxActiveCount = 0;
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
archive: (session) => new Promise((resolve) => {
|
||||
activeCount += 1;
|
||||
maxActiveCount = Math.max(maxActiveCount, activeCount);
|
||||
startedIds.push(sessionLookupId(session));
|
||||
resolvers.push(() => {
|
||||
activeCount -= 1;
|
||||
resolve({ archived: true });
|
||||
});
|
||||
}),
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const archive = controller.archiveSessions(sessions);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(startedIds).toHaveLength(4);
|
||||
resolvers.shift()?.();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(startedIds).toHaveLength(5);
|
||||
for (const resolve of resolvers.splice(0)) resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
for (const resolve of resolvers.splice(0)) resolve();
|
||||
await archive;
|
||||
|
||||
expect(maxActiveCount).toBe(4);
|
||||
expect(state.sessions.every((session) => session.archived === true)).toBe(true);
|
||||
});
|
||||
|
||||
it("deletes selected archived sessions in bulk and selects the next current session", async () => {
|
||||
const archivedSession = { ...oldSession, archived: true, archivedAt: "later" };
|
||||
const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl" };
|
||||
const deletedIds: string[] = [];
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedWorkspace: workspace,
|
||||
selectedSession: archivedSession,
|
||||
sessions: [archivedSession, nextSession],
|
||||
machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] } },
|
||||
};
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
deleteArchived: (session) => {
|
||||
deletedIds.push(sessionLookupId(session));
|
||||
return Promise.resolve({ deleted: true });
|
||||
},
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.deleteArchivedSessions([archivedSession]);
|
||||
|
||||
expect(deletedIds).toEqual([archivedSession.id]);
|
||||
expect(state.sessions.map((session) => session.id)).toEqual([nextSession.id]);
|
||||
expect(state.selectedSession?.id).toBe(nextSession.id);
|
||||
});
|
||||
|
||||
it("uses true bulk delete when supported and keeps partial failures visible", async () => {
|
||||
const deletedSession = { ...oldSession, archived: true, archivedAt: "later" };
|
||||
const failedSession = { ...oldSession, id: "failed-archived", path: "/tmp/failed-archived.jsonl", archived: true, archivedAt: "later" };
|
||||
const deleteCalls: { ids: string[]; machineId: string }[] = [];
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedWorkspace: workspace,
|
||||
selectedSession: deletedSession,
|
||||
sessions: [deletedSession, failedSession],
|
||||
machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.sessionsBulkMutations] } },
|
||||
};
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
deleteArchivedMany: (sessions, machineId) => {
|
||||
deleteCalls.push({ ids: sessions.map(sessionLookupId), machineId: machineId ?? "local" });
|
||||
return Promise.resolve({ deleted: true, deletedSessionIds: [deletedSession.id], failures: [{ sessionId: failedSession.id, error: "busy" }], generatedAt: "now" });
|
||||
},
|
||||
deleteArchived: () => { throw new Error("single delete should not be used"); },
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.deleteArchivedSessions([deletedSession, failedSession]);
|
||||
|
||||
expect(deleteCalls).toEqual([{ ids: [deletedSession.id, failedSession.id], machineId: "local" }]);
|
||||
expect(state.sessions.map((session) => session.id)).toEqual([failedSession.id]);
|
||||
expect(state.selectedSession?.id).toBe(failedSession.id);
|
||||
expect(state.error).toBe("Delete failed for 1 session: failed-archived: busy");
|
||||
});
|
||||
|
||||
it("applies cleanup execution results and refreshes the current workspace sessions", async () => {
|
||||
const archivedAt = "2026-06-25T12:00:00.000Z";
|
||||
const deletedArchived = { ...oldSession, id: "deleted-archived", path: "/tmp/deleted-archived.jsonl", archived: true, archivedAt: "2026-05-01T00:00:00.000Z" };
|
||||
const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl" };
|
||||
const refreshedArchived = { ...oldSession, archived: true, archivedAt };
|
||||
const sessionsCalls: { cwd: string; machineId: string }[] = [];
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedWorkspace: workspace,
|
||||
selectedSession: oldSession,
|
||||
sessions: [oldSession, deletedArchived, nextSession],
|
||||
sessionStatuses: { [oldSession.id]: status(oldSession.id), [deletedArchived.id]: status(deletedArchived.id), [nextSession.id]: status(nextSession.id) },
|
||||
sessionActivities: { [oldSession.id]: { sessionId: oldSession.id, phase: "idle", label: "idle", at: archivedAt } },
|
||||
};
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
sessions: (cwd, machineId) => {
|
||||
sessionsCalls.push({ cwd, machineId: machineId ?? "local" });
|
||||
return Promise.resolve([refreshedArchived, nextSession]);
|
||||
},
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.applySessionCleanupResult({
|
||||
generatedAt: archivedAt,
|
||||
thresholds: { archiveIdleDays: 30, deleteArchivedDays: 60 },
|
||||
projects: [{ cwd: workspace.path, archiveCount: 1, deleteCount: 1 }],
|
||||
totals: { archiveCount: 1, deleteCount: 1 },
|
||||
archivedSessionIds: [oldSession.id],
|
||||
deletedSessionIds: [deletedArchived.id],
|
||||
});
|
||||
|
||||
expect(sessionsCalls).toEqual([{ cwd: workspace.path, machineId: "local" }]);
|
||||
expect(state.sessions.map((session) => session.id)).toEqual([oldSession.id, nextSession.id]);
|
||||
expect(state.sessions[0]).toMatchObject({ id: oldSession.id, archived: true, archivedAt });
|
||||
expect(state.selectedSession?.id).toBe(nextSession.id);
|
||||
expect(state.sessionStatuses[oldSession.id]).toBeUndefined();
|
||||
expect(state.sessionStatuses[deletedArchived.id]).toBeUndefined();
|
||||
expect(state.sessionActivities[oldSession.id]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not delete archived sessions when the selected machine runtime reports no support", async () => {
|
||||
const archivedSession = { ...oldSession, archived: true, archivedAt: "later" };
|
||||
const deletedIds: string[] = [];
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [archivedSession], machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [] } } };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
deleteArchived: (session) => {
|
||||
deletedIds.push(sessionLookupId(session));
|
||||
return Promise.resolve({ deleted: true });
|
||||
},
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.deleteArchivedSessions([archivedSession]);
|
||||
|
||||
expect(deletedIds).toEqual([]);
|
||||
expect(state.sessions).toEqual([archivedSession]);
|
||||
expect(state.error).toContain("requires an updated Pi-Web runtime");
|
||||
});
|
||||
|
||||
it("allows legacy archived-session deletion when runtime support is unknown", async () => {
|
||||
const archivedSession = { ...oldSession, archived: true, archivedAt: "later" };
|
||||
const deletedIds: string[] = [];
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: archivedSession, sessions: [archivedSession] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
deleteArchived: (session) => {
|
||||
deletedIds.push(sessionLookupId(session));
|
||||
return Promise.resolve({ deleted: true });
|
||||
},
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.deleteArchivedSessions([archivedSession]);
|
||||
|
||||
expect(deletedIds).toEqual([archivedSession.id]);
|
||||
expect(state.sessions).toEqual([]);
|
||||
expect(state.error).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { initialAppState } from "../appState";
|
||||
import { isCachedNewSessionInfo, loadCachedNewSessions, markCachedNewSessionInfo, rememberCachedNewSession } from "../cachedNewSessions";
|
||||
import { loadDraft, saveDraft } from "../promptDraftStorage";
|
||||
import { SessionController } from "./sessionController";
|
||||
import { defaultApi, emptyPage, FakeSocket, MemoryStorage, oldSession, replacementSession, sessionKey, sessionLookupId, status, workspace, type AppState } from "./sessionController.testSupport";
|
||||
|
||||
describe("SessionController cached-new sessions", () => {
|
||||
it("keeps live message count updates when a cached new session becomes persisted", async () => {
|
||||
const cachedSession = markCachedNewSessionInfo(oldSession);
|
||||
let resolvePrompt: (() => void) | undefined;
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: cachedSession, sessions: [cachedSession] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
prompt: () => new Promise<{ accepted: true }>((resolve) => { resolvePrompt = () => { resolve({ accepted: true }); }; }),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const send = controller.send("hello");
|
||||
controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), messageCount: 1 } });
|
||||
controller.flushPendingUpdates();
|
||||
resolvePrompt?.();
|
||||
await send;
|
||||
|
||||
expect(state.sessions[0]?.messageCount).toBe(1);
|
||||
expect(isCachedNewSessionInfo(state.sessions[0])).toBe(false);
|
||||
expect(state.selectedSession?.messageCount).toBe(1);
|
||||
});
|
||||
|
||||
it("deletes transient server-reported new sessions and clears local state", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true });
|
||||
const transientSession = { ...oldSession, persisted: false };
|
||||
const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl", persisted: true };
|
||||
const stoppedIds: string[] = [];
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedWorkspace: workspace,
|
||||
selectedSession: transientSession,
|
||||
sessions: [transientSession, nextSession],
|
||||
sessionStatuses: { [transientSession.id]: { ...status(transientSession.id), persisted: false } },
|
||||
sessionActivities: { [transientSession.id]: { sessionId: transientSession.id, phase: "active", label: "Starting", at: "2026-05-20T00:00:00.000Z" } },
|
||||
sendingPrompts: { [transientSession.id]: true },
|
||||
};
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
stop: (session) => { stoppedIds.push(sessionLookupId(session)); return Promise.resolve({ stopped: true }); },
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
saveDraft(sessionKey(transientSession.id), "discard me");
|
||||
|
||||
await controller.deleteCachedNewSession(transientSession);
|
||||
|
||||
expect(stoppedIds).toEqual([transientSession.id]);
|
||||
expect(state.sessions.map((session) => session.id)).toEqual([nextSession.id]);
|
||||
expect(state.sessionStatuses[transientSession.id]).toBeUndefined();
|
||||
expect(state.sessionActivities[transientSession.id]).toBeUndefined();
|
||||
expect(state.sendingPrompts[transientSession.id]).toBeUndefined();
|
||||
expect(loadDraft(sessionKey(transientSession.id))).toBe("");
|
||||
expect(state.selectedSession?.id).toBe(nextSession.id);
|
||||
});
|
||||
|
||||
it("recreates missing browser-cached new sessions and moves their draft", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true });
|
||||
rememberCachedNewSession(oldSession);
|
||||
saveDraft(sessionKey(oldSession.id), "draft text");
|
||||
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [markCachedNewSessionInfo(oldSession)] };
|
||||
const urlUpdates: ({ replace?: boolean | undefined } | undefined)[] = [];
|
||||
const socket = new FakeSocket();
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
startSession: () => Promise.resolve(replacementSession),
|
||||
messages: (session) => {
|
||||
if (sessionLookupId(session) === oldSession.id) return Promise.reject(new Error("Session not found"));
|
||||
return Promise.resolve(emptyPage);
|
||||
},
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
(options) => { urlUpdates.push(options); },
|
||||
undefined,
|
||||
{ api, socket },
|
||||
);
|
||||
|
||||
await controller.selectSession(markCachedNewSessionInfo(oldSession), { updateUrl: false });
|
||||
|
||||
expect(state.selectedSession?.id).toBe(replacementSession.id);
|
||||
expect(state.sessions.map((session) => session.id)).toEqual([replacementSession.id]);
|
||||
expect(socket.connectedSessionIds).toEqual([oldSession.id, replacementSession.id]);
|
||||
expect(loadDraft(sessionKey(oldSession.id))).toBe("");
|
||||
expect(loadDraft(sessionKey(replacementSession.id))).toBe("draft text");
|
||||
expect(loadCachedNewSessions().map((session) => session.id)).toEqual([replacementSession.id]);
|
||||
expect(urlUpdates).toEqual([{ replace: true }]);
|
||||
});
|
||||
|
||||
it("stores command prompt drafts for replacement sessions before selecting them", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true });
|
||||
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedWorkspace: workspace,
|
||||
selectedSession: oldSession,
|
||||
sessions: [oldSession],
|
||||
commandDialog: { type: "select", requestId: "r1", title: "Fork from message", options: [{ value: "m1", label: "fork me" }] },
|
||||
};
|
||||
const urlUpdates: unknown[] = [];
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
respondToCommand: () => Promise.resolve({ type: "done", message: "Session forked", session: replacementSession, promptDraft: "fork me" }),
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
(options) => { urlUpdates.push(options); },
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.respondToCommand("r1", "m1");
|
||||
|
||||
expect(state.commandDialog).toBeUndefined();
|
||||
expect(loadDraft(sessionKey(replacementSession.id))).toBe("fork me");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { initialAppState } from "../appState";
|
||||
import { SessionController } from "./sessionController";
|
||||
import { defaultApi, EmitSocket, emptyPage, FakeSocket, oldSession, runPendingAnimationFrames, status, workspace, type AppState, type SessionActivity, type SessionInfo } from "./sessionController.testSupport";
|
||||
|
||||
describe("SessionController live events", () => {
|
||||
it("coalesces rapid status updates into a single state write per frame", () => {
|
||||
const setStateCalls: Partial<AppState>[] = [];
|
||||
let state: AppState = { ...initialAppState(), selectedSession: oldSession, sessions: [oldSession] };
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { setStateCalls.push(patch); state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 1 } });
|
||||
controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 2 } });
|
||||
controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 3 } });
|
||||
|
||||
// Nothing applies until the frame is flushed; last-write-wins per session.
|
||||
expect(setStateCalls).toHaveLength(0);
|
||||
expect(state.sessionStatuses[oldSession.id]).toBeUndefined();
|
||||
|
||||
runPendingAnimationFrames();
|
||||
|
||||
expect(setStateCalls).toHaveLength(1);
|
||||
expect(state.sessionStatuses[oldSession.id]).toMatchObject({ sessionId: oldSession.id, messageCount: 3 });
|
||||
expect(state.status?.messageCount).toBe(3);
|
||||
});
|
||||
|
||||
it("applies the latest activity per session on flush", () => {
|
||||
const setStateCalls: Partial<AppState>[] = [];
|
||||
let state: AppState = { ...initialAppState(), selectedSession: oldSession, sessions: [oldSession] };
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { setStateCalls.push(patch); state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
controller.applyGlobalEvent({ type: "activity.update", activity: { sessionId: oldSession.id, phase: "active", label: "running tool", at: "t1" } });
|
||||
controller.applyGlobalEvent({ type: "activity.update", activity: { sessionId: oldSession.id, phase: "idle", label: "idle", at: "t2" } });
|
||||
|
||||
expect(setStateCalls).toHaveLength(0);
|
||||
|
||||
controller.flushPendingUpdates();
|
||||
|
||||
expect(state.sessionActivities[oldSession.id]).toMatchObject({ phase: "idle", label: "idle" });
|
||||
expect(state.activity?.phase).toBe("idle");
|
||||
});
|
||||
|
||||
it("coalesces status updates delivered over the per-session socket until the frame is flushed", async () => {
|
||||
const socket = new EmitSocket();
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: () => Promise.resolve(status(oldSession.id)),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket },
|
||||
);
|
||||
await controller.selectSession(oldSession, { updateUrl: false });
|
||||
|
||||
socket.emit({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 7 } });
|
||||
socket.emit({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 8 } });
|
||||
|
||||
// Buffered, not applied synchronously.
|
||||
expect(state.sessionStatuses[oldSession.id]?.messageCount).toBeUndefined();
|
||||
|
||||
controller.flushPendingUpdates();
|
||||
|
||||
expect(state.sessionStatuses[oldSession.id]?.messageCount).toBe(8);
|
||||
expect(state.status?.messageCount).toBe(8);
|
||||
});
|
||||
|
||||
it("clears stale active activity when an idle status arrives", () => {
|
||||
const activeActivity: SessionActivity = { sessionId: oldSession.id, phase: "active", label: "running tool", at: "2026-05-15T00:00:00.000Z" };
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedSession: oldSession,
|
||||
sessions: [oldSession],
|
||||
activity: activeActivity,
|
||||
sessionActivities: { [oldSession.id]: activeActivity },
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
controller.applyGlobalEvent({ type: "status.update", status: status(oldSession.id) });
|
||||
controller.flushPendingUpdates();
|
||||
|
||||
expect(state.activity).toBeUndefined();
|
||||
expect(state.sessionActivities[oldSession.id]).toBeUndefined();
|
||||
expect(state.sessionStatuses[oldSession.id]).toMatchObject({ sessionId: oldSession.id, isStreaming: false });
|
||||
});
|
||||
|
||||
it("updates visible session message counts from live status events", () => {
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedSession: oldSession,
|
||||
sessions: [oldSession],
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), messageCount: 3 } });
|
||||
controller.flushPendingUpdates();
|
||||
|
||||
expect(state.sessions[0]?.messageCount).toBe(3);
|
||||
expect(state.selectedSession?.messageCount).toBe(3);
|
||||
});
|
||||
|
||||
it("adds a newly created session to the list when it belongs to the selected workspace", () => {
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [oldSession] };
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ socket: new FakeSocket() },
|
||||
);
|
||||
const spawned: SessionInfo = { ...oldSession, id: "spawned-session", path: "/tmp/spawned-session.jsonl" };
|
||||
|
||||
controller.applyGlobalEvent({ type: "session.created", session: spawned });
|
||||
|
||||
expect(state.sessions.map((session) => session.id)).toEqual(["spawned-session", "old-session"]);
|
||||
});
|
||||
|
||||
it("ignores a created session for a different workspace or a duplicate id", () => {
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [oldSession] };
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
controller.applyGlobalEvent({ type: "session.created", session: { ...oldSession, id: "other", cwd: "/other-repo" } });
|
||||
controller.applyGlobalEvent({ type: "session.created", session: { ...oldSession } });
|
||||
|
||||
expect(state.sessions.map((session) => session.id)).toEqual(["old-session"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { initialAppState } from "../appState";
|
||||
import { isCachedNewSessionInfo, loadCachedNewSessions } from "../cachedNewSessions";
|
||||
import { loadDraft, saveDraft } from "../promptDraftStorage";
|
||||
import { SessionController } from "./sessionController";
|
||||
import { defaultApi, deferred, emptyPage, FakeSocket, MemoryStorage, oldSession, sessionKey, sessionLookupId, status, workspace, type AppState, type SessionInfo } from "./sessionController.testSupport";
|
||||
|
||||
describe("SessionController pending starts", () => {
|
||||
it("creates and selects a temporary editable session before backend start resolves", async () => {
|
||||
const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" };
|
||||
const startRequest = deferred<SessionInfo>();
|
||||
const messageCalls: string[] = [];
|
||||
const statusCalls: string[] = [];
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
startSession: () => startRequest.promise,
|
||||
messages: (session) => { messageCalls.push(sessionLookupId(session)); return Promise.resolve(emptyPage); },
|
||||
status: (session) => { statusCalls.push(sessionLookupId(session)); return Promise.resolve(status(sessionLookupId(session))); },
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const start = controller.startSession();
|
||||
const temporarySession = state.selectedSession;
|
||||
|
||||
expect(temporarySession?.id).toMatch(/^pending-session-/);
|
||||
expect(temporarySession?.persisted).toBe(false);
|
||||
expect(state.sessions.map((session) => session.id)).toEqual([temporarySession?.id]);
|
||||
expect(state.activity).toMatchObject({ sessionId: temporarySession?.id, phase: "active", label: "Creating session" });
|
||||
expect(messageCalls).toEqual([]);
|
||||
expect(statusCalls).toEqual([]);
|
||||
|
||||
startRequest.resolve(started);
|
||||
await start;
|
||||
|
||||
expect(state.sessions.map((session) => session.id)).toEqual(["started-session"]);
|
||||
expect(state.selectedSession?.id).toBe("started-session");
|
||||
expect(messageCalls).toEqual(["started-session"]);
|
||||
expect(statusCalls).toEqual(["started-session"]);
|
||||
});
|
||||
|
||||
it("does not duplicate a started session when its session.created broadcast races the HTTP response", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true });
|
||||
const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" };
|
||||
const startRequest = deferred<SessionInfo>();
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
|
||||
const socket = new FakeSocket();
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
startSession: () => {
|
||||
// Simulate the broadcast arriving before the HTTP response resolves.
|
||||
controller.applyGlobalEvent({ type: "session.created", session: started });
|
||||
return startRequest.promise;
|
||||
},
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket },
|
||||
);
|
||||
|
||||
const start = controller.startSession();
|
||||
const temporaryId = state.selectedSession?.id;
|
||||
|
||||
expect(state.sessions.map((session) => session.id)).toEqual([temporaryId]);
|
||||
|
||||
startRequest.resolve(started);
|
||||
await start;
|
||||
|
||||
expect(state.sessions.map((session) => session.id)).toEqual(["started-session"]);
|
||||
expect(isCachedNewSessionInfo(state.sessions[0])).toBe(true);
|
||||
});
|
||||
|
||||
it("releases unrelated created-session broadcasts after pending starts settle", async () => {
|
||||
const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" };
|
||||
const otherClientSession: SessionInfo = { ...oldSession, id: "other-client-session", path: "/tmp/other-client-session.jsonl" };
|
||||
const startRequest = deferred<SessionInfo>();
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
startSession: () => startRequest.promise,
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const start = controller.startSession();
|
||||
const temporaryId = state.selectedSession?.id;
|
||||
controller.applyGlobalEvent({ type: "session.created", session: started });
|
||||
controller.applyGlobalEvent({ type: "session.created", session: otherClientSession });
|
||||
|
||||
expect(state.sessions.map((session) => session.id)).toEqual([temporaryId]);
|
||||
|
||||
startRequest.resolve(started);
|
||||
await start;
|
||||
|
||||
const sessionIds = state.sessions.map((session) => session.id);
|
||||
expect(sessionIds).not.toContain(temporaryId);
|
||||
expect(sessionIds.filter((id) => id === started.id)).toHaveLength(1);
|
||||
expect(sessionIds.filter((id) => id === otherClientSession.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("preserves temporary start rows across session-list refreshes before backend resolution", async () => {
|
||||
const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" };
|
||||
const startRequest = deferred<SessionInfo>();
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
startSession: () => startRequest.promise,
|
||||
sessions: () => Promise.resolve([oldSession]),
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const start = controller.startSession();
|
||||
const temporaryId = state.selectedSession?.id;
|
||||
await controller.refreshCurrentWorkspaceSessions();
|
||||
|
||||
expect(state.sessions.map((session) => session.id)).toEqual([temporaryId, oldSession.id]);
|
||||
expect(state.selectedSession?.id).toBe(temporaryId);
|
||||
|
||||
startRequest.resolve(started);
|
||||
await start;
|
||||
|
||||
expect(state.sessions.map((session) => session.id)).toEqual([started.id, oldSession.id]);
|
||||
expect(state.selectedSession?.id).toBe(started.id);
|
||||
});
|
||||
|
||||
it("tracks multiple pending session starts without blocking another start", async () => {
|
||||
const firstStarted: SessionInfo = { ...oldSession, id: "started-session-1", path: "/tmp/started-session-1.jsonl" };
|
||||
const secondStarted: SessionInfo = { ...oldSession, id: "started-session-2", path: "/tmp/started-session-2.jsonl" };
|
||||
const startResolvers: ((session: SessionInfo) => void)[] = [];
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
startSession: () => new Promise<SessionInfo>((resolve) => { startResolvers.push(resolve); }),
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const firstStart = controller.startSession();
|
||||
const firstTemporaryId = state.selectedSession?.id;
|
||||
const secondStart = controller.startSession();
|
||||
const secondTemporaryId = state.selectedSession?.id;
|
||||
|
||||
expect(startResolvers).toHaveLength(2);
|
||||
expect(state.startingSessionCount).toBe(0);
|
||||
expect(state.sessions.map((session) => session.id)).toEqual([secondTemporaryId, firstTemporaryId]);
|
||||
expect(state.selectedSession?.id).toBe(secondTemporaryId);
|
||||
expect(state.sessions.every((session) => session.persisted === false)).toBe(true);
|
||||
|
||||
startResolvers[0]?.(firstStarted);
|
||||
await firstStart;
|
||||
|
||||
expect(state.sessions.map((session) => session.id)).toEqual([secondTemporaryId, "started-session-1"]);
|
||||
expect(state.selectedSession?.id).toBe(secondTemporaryId);
|
||||
|
||||
startResolvers[1]?.(secondStarted);
|
||||
await secondStart;
|
||||
|
||||
expect(state.startingSessionCount).toBe(0);
|
||||
expect(state.sessions.map((session) => session.id)).toEqual(["started-session-2", "started-session-1"]);
|
||||
expect(state.selectedSession?.id).toBe("started-session-2");
|
||||
});
|
||||
|
||||
it("moves a temporary session draft and cached-new marker to the resolved session", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true });
|
||||
const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" };
|
||||
const startRequest = deferred<SessionInfo>();
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
startSession: () => startRequest.promise,
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const start = controller.startSession();
|
||||
const temporaryId = state.selectedSession?.id;
|
||||
if (temporaryId === undefined) throw new Error("Expected temporary session id");
|
||||
saveDraft(sessionKey(temporaryId), "draft text");
|
||||
|
||||
startRequest.resolve(started);
|
||||
await start;
|
||||
|
||||
expect(loadDraft(sessionKey(temporaryId))).toBe("");
|
||||
expect(loadDraft(sessionKey(started.id))).toBe("draft text");
|
||||
expect(loadCachedNewSessions().map((session) => session.id)).toEqual([started.id]);
|
||||
expect(isCachedNewSessionInfo(state.sessions[0])).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a failed temporary start selected with a discardable transient row", async () => {
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
startSession: () => Promise.reject(new Error("backend unavailable")),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.startSession();
|
||||
const temporaryId = state.selectedSession?.id;
|
||||
|
||||
expect(temporaryId).toMatch(/^pending-session-/);
|
||||
expect(state.sessions.map((session) => session.id)).toEqual([temporaryId]);
|
||||
expect(state.sessions[0]?.persisted).toBe(false);
|
||||
expect(state.activity).toMatchObject({ sessionId: temporaryId, phase: "error", label: "Session creation failed" });
|
||||
expect(state.error).toContain("backend unavailable");
|
||||
|
||||
await controller.deleteCachedNewSession(state.sessions[0]);
|
||||
|
||||
expect(state.sessions).toEqual([]);
|
||||
expect(state.selectedSession).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stops the backend session if a discarded pending start resolves later", async () => {
|
||||
const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" };
|
||||
const startRequest = deferred<SessionInfo>();
|
||||
const stoppedIds: string[] = [];
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
startSession: () => startRequest.promise,
|
||||
stop: (session) => { stoppedIds.push(sessionLookupId(session)); return Promise.resolve({ stopped: true }); },
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const start = controller.startSession();
|
||||
const temporaryId = state.selectedSession?.id;
|
||||
if (temporaryId === undefined) throw new Error("Expected temporary session id");
|
||||
await controller.send("queued before discard");
|
||||
expect(state.clientQueuedSessionMessages[temporaryId]).toEqual([{ kind: "followUp", text: "queued before discard" }]);
|
||||
|
||||
await controller.deleteCachedNewSession(state.selectedSession);
|
||||
expect(state.sessions).toEqual([]);
|
||||
expect(state.selectedSession).toBeUndefined();
|
||||
expect(state.clientQueuedSessionMessages[temporaryId]).toBeUndefined();
|
||||
|
||||
startRequest.resolve(started);
|
||||
await start;
|
||||
|
||||
expect(stoppedIds).toEqual([started.id]);
|
||||
expect(state.sessions).toEqual([]);
|
||||
expect(state.selectedSession).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
||||
import { initialAppState } from "../appState";
|
||||
import { ChatTranscriptStore } from "../chatTranscriptStore";
|
||||
import { SessionController } from "./sessionController";
|
||||
import { InMemorySessionSelectionMemory } from "./sessionSelection";
|
||||
import { defaultApi, emptyPage, FakeSocket, oldSession, sessionKey, sessionLookupId, status, workspace, type AppState, type MessagePage } from "./sessionController.testSupport";
|
||||
|
||||
describe("SessionController reload and selection", () => {
|
||||
it("reloads the selected session from disk, discards the cached transcript, and re-fetches history", async () => {
|
||||
const persistedSession = { ...oldSession, persisted: true };
|
||||
const cacheKey = sessionKey(oldSession.id);
|
||||
const freshPage: MessagePage = { messages: [{ role: "assistant", content: "fresh from disk" }], start: 1, total: 2 };
|
||||
const cachedPages = new Map<string, MessagePage>([[cacheKey, { messages: [{ role: "user", content: "stale cached transcript" }], start: 0, total: 2 }]]);
|
||||
const reloadCalls: string[] = [];
|
||||
const messageCalls: string[] = [];
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedWorkspace: workspace,
|
||||
selectedSession: persistedSession,
|
||||
sessions: [persistedSession],
|
||||
machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] } },
|
||||
};
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
reloadSession: (session) => {
|
||||
reloadCalls.push(sessionLookupId(session));
|
||||
return Promise.resolve({ reloaded: true });
|
||||
},
|
||||
messages: (session) => {
|
||||
messageCalls.push(sessionLookupId(session));
|
||||
return Promise.resolve(freshPage);
|
||||
},
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{
|
||||
api,
|
||||
socket: new FakeSocket(),
|
||||
transcripts: new ChatTranscriptStore({
|
||||
read: (sessionId) => cachedPages.get(sessionId),
|
||||
write: (sessionId, page) => { cachedPages.set(sessionId, page); },
|
||||
remove: (sessionId) => { cachedPages.delete(sessionId); },
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await controller.reloadSession(persistedSession);
|
||||
|
||||
expect(reloadCalls).toEqual([oldSession.id]);
|
||||
expect(messageCalls).toEqual([oldSession.id]);
|
||||
expect(cachedPages.get(cacheKey)).toEqual(freshPage);
|
||||
expect(state.messages).toEqual([{ role: "assistant", parts: [{ type: "text", text: "fresh from disk" }] }]);
|
||||
expect(state.messagePageStart).toBe(1);
|
||||
expect(state.error).toBe("");
|
||||
});
|
||||
|
||||
it("does not reload sessions from disk when the selected machine runtime does not support it", async () => {
|
||||
const persistedSession = { ...oldSession, persisted: true };
|
||||
const reloadCalls: string[] = [];
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedWorkspace: workspace,
|
||||
selectedSession: persistedSession,
|
||||
sessions: [persistedSession],
|
||||
};
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
reloadSession: (session) => {
|
||||
reloadCalls.push(sessionLookupId(session));
|
||||
return Promise.resolve({ reloaded: true });
|
||||
},
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.reloadSession(persistedSession);
|
||||
|
||||
expect(reloadCalls).toEqual([]);
|
||||
expect(state.error).toContain("Reloading sessions from disk requires an updated Pi-Web runtime");
|
||||
});
|
||||
|
||||
it("does not reload sessions from disk without a persisted server signal when persistence is authoritative", async () => {
|
||||
const reloadCalls: string[] = [];
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedWorkspace: workspace,
|
||||
selectedSession: oldSession,
|
||||
sessions: [oldSession],
|
||||
machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.sessionsPersistedState] } },
|
||||
};
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
reloadSession: (session) => {
|
||||
reloadCalls.push(sessionLookupId(session));
|
||||
return Promise.resolve({ reloaded: true });
|
||||
},
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.reloadSession(oldSession);
|
||||
await controller.reloadSession({ ...oldSession, persisted: false });
|
||||
|
||||
expect(reloadCalls).toEqual([]);
|
||||
expect(state.error).toBe("");
|
||||
});
|
||||
|
||||
it("forgets archived selections when the archived section collapse clears selection", async () => {
|
||||
const archivedSession = { ...oldSession, archived: true, archivedAt: "later" };
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [archivedSession] };
|
||||
const urlUpdates: ({ replace?: boolean | undefined } | undefined)[] = [];
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
(options) => { urlUpdates.push(options); },
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.selectSession(archivedSession, { updateUrl: false });
|
||||
expect(controller.preferredSession(workspace.path, state.sessions, undefined)).toBe(archivedSession);
|
||||
|
||||
controller.clearSelectionAfterArchivedCollapse();
|
||||
|
||||
expect(state.selectedSession).toBeUndefined();
|
||||
expect(controller.preferredSession(workspace.path, state.sessions, undefined)).toBeUndefined();
|
||||
expect(urlUpdates).toEqual([undefined]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,354 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { initialAppState } from "../appState";
|
||||
import { SessionController } from "./sessionController";
|
||||
import { defaultApi, deferred, emptyPage, FakeSocket, oldSession, replacementSession, sessionLookupId, status, workspace, type AppState, type Deferred, type PromptAttachment, type SessionInfo } from "./sessionController.testSupport";
|
||||
|
||||
describe("SessionController send queue", () => {
|
||||
it("toggles the per-session sending state around an inline attachment send and forwards attachments", async () => {
|
||||
let resolvePrompt: (() => void) | undefined;
|
||||
let promptArgs: { attachments?: PromptAttachment[] } | undefined;
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] };
|
||||
const attachments: PromptAttachment[] = [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }];
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
prompt: (_session, _text, _behavior, _machineId, sentAttachments) => new Promise<{ accepted: true }>((resolve) => {
|
||||
promptArgs = { ...(sentAttachments === undefined ? {} : { attachments: sentAttachments }) };
|
||||
resolvePrompt = () => { resolve({ accepted: true }); };
|
||||
}),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const send = controller.send("look", undefined, attachments, "inline");
|
||||
const sendingDuringPrompt = state.sendingPrompts;
|
||||
resolvePrompt?.();
|
||||
await send;
|
||||
|
||||
expect(sendingDuringPrompt).toEqual({ [oldSession.id]: true });
|
||||
expect(state.sendingPrompts).toEqual({});
|
||||
expect(promptArgs).toEqual({ attachments });
|
||||
});
|
||||
|
||||
it("keeps the sending state scoped to the originating session when the user switches away", async () => {
|
||||
let resolvePrompt: (() => void) | undefined;
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession, replacementSession] };
|
||||
const attachments: PromptAttachment[] = [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }];
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
prompt: () => new Promise<{ accepted: true }>((resolve) => { resolvePrompt = () => { resolve({ accepted: true }); }; }),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const send = controller.send("look", undefined, attachments, "inline");
|
||||
// While the upload is in flight, deselecting must not clear the originating
|
||||
// session's sending entry, and it must stay keyed to that session only.
|
||||
controller.deselectSession();
|
||||
expect(state.sendingPrompts).toEqual({ [oldSession.id]: true });
|
||||
expect(state.sendingPrompts[replacementSession.id]).toBeUndefined();
|
||||
resolvePrompt?.();
|
||||
await send;
|
||||
expect(state.sendingPrompts).toEqual({});
|
||||
});
|
||||
|
||||
it("uploads to the workspace folder and rewrites the prompt for folder delivery", async () => {
|
||||
let savedCalledWith: PromptAttachment[] | undefined;
|
||||
let promptText: string | undefined;
|
||||
let promptAttachments: PromptAttachment[] | undefined;
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] };
|
||||
const attachments: PromptAttachment[] = [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }];
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
saveAttachments: (_session, sent) => { savedCalledWith = sent; return Promise.resolve([{ path: ".pi-web/attachments/shot.png", mimeType: "image/png", size: 3 }]); },
|
||||
prompt: (_session, text, _behavior, _machineId, sentAttachments) => { promptText = text; promptAttachments = sentAttachments; return Promise.resolve({ accepted: true }); },
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.send("check this", undefined, attachments, "folder");
|
||||
|
||||
expect(savedCalledWith).toEqual(attachments);
|
||||
expect(promptText).toBe("check this\n\[email protected]/attachments/shot.png");
|
||||
expect(promptAttachments).toBeUndefined();
|
||||
expect(state.sendingPrompts).toEqual({});
|
||||
});
|
||||
|
||||
it("does not set the sending state for plain text messages", async () => {
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] };
|
||||
const seen: Record<string, true>[] = [];
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
prompt: () => { seen.push({ ...state.sendingPrompts }); return Promise.resolve({ accepted: true }); },
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.send("hello");
|
||||
expect(seen).toEqual([{}]);
|
||||
expect(state.sendingPrompts).toEqual({});
|
||||
});
|
||||
|
||||
it("sends slash commands without inserting an optimistic transcript line and toggles the sending state", async () => {
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] };
|
||||
let resolveCommand: (() => void) | undefined;
|
||||
const seenDuringCommand: Record<string, true>[] = [];
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
runCommand: (_session, text) => new Promise((resolve) => {
|
||||
seenDuringCommand.push({ ...state.sendingPrompts });
|
||||
resolveCommand = () => { resolve(text.startsWith("/skill") ? { type: "done" } : { type: "done", message: "stats" }); };
|
||||
}),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const run = controller.send("/skill:skill-creator");
|
||||
expect(seenDuringCommand).toEqual([{ [oldSession.id]: true }]);
|
||||
// No raw command text is added to the transcript; the agent streams the
|
||||
// canonical expanded message back instead.
|
||||
expect(state.messages).toEqual([]);
|
||||
resolveCommand?.();
|
||||
await run;
|
||||
expect(state.messages).toEqual([]);
|
||||
expect(state.sendingPrompts).toEqual({});
|
||||
});
|
||||
|
||||
it("queues prompt sends for a pending session start and flushes them after resolution", async () => {
|
||||
const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" };
|
||||
const startRequest = deferred<SessionInfo>();
|
||||
const promptCalls: { sessionId: string; text: string; behavior?: "steer" | "followUp" }[] = [];
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
startSession: () => startRequest.promise,
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
prompt: (session, text, behavior) => {
|
||||
promptCalls.push({ sessionId: sessionLookupId(session), text, ...(behavior === undefined ? {} : { behavior }) });
|
||||
return Promise.resolve({ accepted: true });
|
||||
},
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const start = controller.startSession();
|
||||
const temporaryId = state.selectedSession?.id;
|
||||
if (temporaryId === undefined) throw new Error("Expected temporary session id");
|
||||
|
||||
await controller.send("first");
|
||||
await controller.send("second", "steer");
|
||||
|
||||
expect(promptCalls).toEqual([]);
|
||||
expect(state.clientQueuedSessionMessages[temporaryId]).toEqual([
|
||||
{ kind: "followUp", text: "first" },
|
||||
{ kind: "steer", text: "second" },
|
||||
]);
|
||||
expect(state.activity?.detail).toContain("2 queued messages");
|
||||
|
||||
startRequest.resolve(started);
|
||||
await start;
|
||||
|
||||
expect(promptCalls).toEqual([
|
||||
{ sessionId: started.id, text: "first" },
|
||||
{ sessionId: started.id, text: "second", behavior: "steer" },
|
||||
]);
|
||||
expect(state.clientQueuedSessionMessages[temporaryId]).toBeUndefined();
|
||||
expect(state.clientQueuedSessionMessages[started.id]).toBeUndefined();
|
||||
expect(state.sendingPrompts).toEqual({});
|
||||
expect(state.selectedSession?.id).toBe(started.id);
|
||||
});
|
||||
|
||||
it("queues slash commands, shell input, and attachments for a pending session start", async () => {
|
||||
const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" };
|
||||
const startRequest = deferred<SessionInfo>();
|
||||
const calls: string[] = [];
|
||||
const promptCalls: { text: string; attachments?: PromptAttachment[] }[] = [];
|
||||
const attachments: PromptAttachment[] = [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }];
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
startSession: () => startRequest.promise,
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
runCommand: (session, text) => {
|
||||
calls.push(`command:${sessionLookupId(session)}:${text}`);
|
||||
return Promise.resolve({ type: "done" });
|
||||
},
|
||||
shell: (session, text) => {
|
||||
calls.push(`shell:${sessionLookupId(session)}:${text}`);
|
||||
return Promise.resolve({ accepted: true });
|
||||
},
|
||||
saveAttachments: (session, sentAttachments) => {
|
||||
calls.push(`save:${sessionLookupId(session)}:${sentAttachments[0]?.name ?? ""}`);
|
||||
return Promise.resolve([{ path: ".pi-web/attachments/shot.png", mimeType: "image/png", size: 3 }]);
|
||||
},
|
||||
prompt: (session, text, _behavior, _machineId, sentAttachments) => {
|
||||
calls.push(`prompt:${sessionLookupId(session)}:${text}`);
|
||||
promptCalls.push({ text, ...(sentAttachments === undefined ? {} : { attachments: sentAttachments }) });
|
||||
return Promise.resolve({ accepted: true });
|
||||
},
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const start = controller.startSession();
|
||||
const temporaryId = state.selectedSession?.id;
|
||||
if (temporaryId === undefined) throw new Error("Expected temporary session id");
|
||||
|
||||
await controller.send("/help");
|
||||
await controller.send("!pwd");
|
||||
await controller.send("look", undefined, attachments, "inline");
|
||||
await controller.send("save", undefined, attachments, "folder");
|
||||
|
||||
expect(calls).toEqual([]);
|
||||
expect(state.clientQueuedSessionMessages[temporaryId]).toEqual([
|
||||
{ kind: "followUp", text: "/help" },
|
||||
{ kind: "followUp", text: "!pwd" },
|
||||
{ kind: "followUp", text: "look\n\n[1 attachment queued: shot.png]" },
|
||||
{ kind: "followUp", text: "save\n\n[1 attachment queued: shot.png]" },
|
||||
]);
|
||||
|
||||
startRequest.resolve(started);
|
||||
await start;
|
||||
|
||||
expect(calls).toEqual([
|
||||
`command:${started.id}:/help`,
|
||||
`shell:${started.id}:!pwd`,
|
||||
`prompt:${started.id}:look`,
|
||||
`save:${started.id}:shot.png`,
|
||||
`prompt:${started.id}:save\n\[email protected]/attachments/shot.png`,
|
||||
]);
|
||||
expect(promptCalls).toEqual([
|
||||
{ text: "look", attachments },
|
||||
{ text: "save\n\[email protected]/attachments/shot.png" },
|
||||
]);
|
||||
expect(state.clientQueuedSessionMessages[started.id]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps queued sends visible when backend session creation fails", async () => {
|
||||
const startRequest = deferred<SessionInfo>();
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
startSession: () => startRequest.promise,
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const start = controller.startSession();
|
||||
const temporaryId = state.selectedSession?.id;
|
||||
if (temporaryId === undefined) throw new Error("Expected temporary session id");
|
||||
await controller.send("recover me");
|
||||
|
||||
startRequest.reject(new Error("backend unavailable"));
|
||||
await start;
|
||||
|
||||
expect(state.selectedSession?.id).toBe(temporaryId);
|
||||
expect(state.clientQueuedSessionMessages[temporaryId]).toEqual([{ kind: "followUp", text: "recover me" }]);
|
||||
expect(state.activity).toMatchObject({ sessionId: temporaryId, phase: "error", label: "Session creation failed" });
|
||||
expect(state.activity?.detail).toContain("1 queued message kept below");
|
||||
|
||||
await controller.deleteCachedNewSession(state.selectedSession);
|
||||
|
||||
expect(state.clientQueuedSessionMessages[temporaryId]).toBeUndefined();
|
||||
expect(state.selectedSession).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps queued sends scoped to their originating pending start", async () => {
|
||||
const firstStarted: SessionInfo = { ...oldSession, id: "started-session-1", path: "/tmp/started-session-1.jsonl" };
|
||||
const secondStarted: SessionInfo = { ...oldSession, id: "started-session-2", path: "/tmp/started-session-2.jsonl" };
|
||||
const startRequests: Deferred<SessionInfo>[] = [];
|
||||
const promptCalls: { sessionId: string; text: string }[] = [];
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
startSession: () => {
|
||||
const request = deferred<SessionInfo>();
|
||||
startRequests.push(request);
|
||||
return request.promise;
|
||||
},
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
prompt: (session, text) => {
|
||||
promptCalls.push({ sessionId: sessionLookupId(session), text });
|
||||
return Promise.resolve({ accepted: true });
|
||||
},
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const firstStart = controller.startSession();
|
||||
const firstTemporary = state.selectedSession;
|
||||
if (firstTemporary === undefined) throw new Error("Expected first temporary session");
|
||||
const secondStart = controller.startSession();
|
||||
const secondTemporary = state.selectedSession;
|
||||
if (secondTemporary === undefined) throw new Error("Expected second temporary session");
|
||||
|
||||
await controller.send("second prompt");
|
||||
await controller.selectSession(firstTemporary, { updateUrl: false });
|
||||
await controller.send("first prompt");
|
||||
|
||||
startRequests[1]?.resolve(secondStarted);
|
||||
await secondStart;
|
||||
|
||||
expect(promptCalls).toEqual([{ sessionId: secondStarted.id, text: "second prompt" }]);
|
||||
expect(state.selectedSession?.id).toBe(firstTemporary.id);
|
||||
expect(state.clientQueuedSessionMessages[secondStarted.id]).toBeUndefined();
|
||||
expect(state.clientQueuedSessionMessages[firstTemporary.id]).toEqual([{ kind: "followUp", text: "first prompt" }]);
|
||||
|
||||
startRequests[0]?.resolve(firstStarted);
|
||||
await firstStart;
|
||||
|
||||
expect(promptCalls).toEqual([
|
||||
{ sessionId: secondStarted.id, text: "second prompt" },
|
||||
{ sessionId: firstStarted.id, text: "first prompt" },
|
||||
]);
|
||||
expect(state.selectedSession?.id).toBe(firstStarted.id);
|
||||
expect(state.clientQueuedSessionMessages[firstStarted.id]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,170 @@
|
||||
import { afterEach, beforeEach, vi } from "vitest";
|
||||
import type { MessagePage, SessionInfo, SessionRef, SessionStatus, Workspace } from "../api";
|
||||
import { machineSessionKey } from "../machineKeys";
|
||||
import type { SessionUiEvent } from "../sessionSocket";
|
||||
import type { SessionEventSocket } from "./sessionController";
|
||||
|
||||
export { api as defaultApi } from "../api";
|
||||
export type { MessagePage, PromptAttachment, SessionActivity, SessionInfo, SessionRef, SessionStatus, Workspace } from "../api";
|
||||
export type { AppState } from "../appState";
|
||||
|
||||
export class MemoryStorage implements Storage {
|
||||
private readonly values = new Map<string, string>();
|
||||
|
||||
get length(): number {
|
||||
return this.values.size;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.values.clear();
|
||||
}
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.values.get(key) ?? null;
|
||||
}
|
||||
|
||||
key(index: number): string | null {
|
||||
return Array.from(this.values.keys())[index] ?? null;
|
||||
}
|
||||
|
||||
removeItem(key: string): void {
|
||||
this.values.delete(key);
|
||||
}
|
||||
|
||||
setItem(key: string, value: string): void {
|
||||
this.values.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeSocket implements SessionEventSocket {
|
||||
readonly connectedSessionIds: string[] = [];
|
||||
|
||||
connect(session: SessionRef): void {
|
||||
this.connectedSessionIds.push(session.id);
|
||||
}
|
||||
|
||||
setHandler(): void {
|
||||
// Test socket does not emit events.
|
||||
}
|
||||
|
||||
close(): void {
|
||||
// No-op.
|
||||
}
|
||||
}
|
||||
|
||||
export class EmitSocket implements SessionEventSocket {
|
||||
readonly connectedSessionIds: string[] = [];
|
||||
private handler: ((event: SessionUiEvent) => void) | undefined;
|
||||
|
||||
connect(session: SessionRef, onEvent: (event: SessionUiEvent) => void): void {
|
||||
this.connectedSessionIds.push(session.id);
|
||||
this.handler = onEvent;
|
||||
}
|
||||
|
||||
setHandler(onEvent: (event: SessionUiEvent) => void): void {
|
||||
this.handler = onEvent;
|
||||
}
|
||||
|
||||
emit(event: SessionUiEvent): void {
|
||||
this.handler?.(event);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.handler = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export const workspace: Workspace = {
|
||||
id: "workspace-1",
|
||||
projectId: "project-1",
|
||||
path: "/repo",
|
||||
label: "repo",
|
||||
isMain: true,
|
||||
isGitRepo: true,
|
||||
isGitWorktree: false,
|
||||
};
|
||||
|
||||
export const oldSession: SessionInfo = {
|
||||
id: "old-session",
|
||||
path: "/tmp/old-session.jsonl",
|
||||
cwd: "/repo",
|
||||
created: "2026-05-15T00:00:00.000Z",
|
||||
modified: "2026-05-15T00:00:00.000Z",
|
||||
messageCount: 0,
|
||||
firstMessage: "",
|
||||
};
|
||||
|
||||
export const replacementSession: SessionInfo = {
|
||||
...oldSession,
|
||||
id: "new-session",
|
||||
path: "/tmp/new-session.jsonl",
|
||||
};
|
||||
|
||||
export const emptyPage: MessagePage = { messages: [], start: 0, total: 0 };
|
||||
|
||||
export interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export function deferred<T>(): Deferred<T> {
|
||||
let resolveDeferred: ((value: T) => void) | undefined;
|
||||
let rejectDeferred: ((error: unknown) => void) | undefined;
|
||||
const promise = new Promise<T>((resolve, reject) => {
|
||||
resolveDeferred = resolve;
|
||||
rejectDeferred = reject;
|
||||
});
|
||||
if (resolveDeferred === undefined || rejectDeferred === undefined) throw new Error("Deferred promise was not initialized");
|
||||
return { promise, resolve: resolveDeferred, reject: rejectDeferred };
|
||||
}
|
||||
|
||||
export function status(sessionId: string): SessionStatus {
|
||||
return {
|
||||
sessionId,
|
||||
isStreaming: false,
|
||||
isCompacting: false,
|
||||
isBashRunning: false,
|
||||
pendingMessageCount: 0,
|
||||
queuedMessages: [],
|
||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
cost: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const framesById = new Map<number, () => void>();
|
||||
let nextFrameId = 1;
|
||||
|
||||
// The controller coalesces status/activity/transcript updates behind
|
||||
// requestAnimationFrame. The node test environment has no rAF, so install a
|
||||
// controllable one: callbacks are queued and only run when a test drives a
|
||||
// frame, mirroring how the browser defers them until paint.
|
||||
beforeEach(() => {
|
||||
framesById.clear();
|
||||
nextFrameId = 1;
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: () => void) => {
|
||||
const id = nextFrameId++;
|
||||
framesById.set(id, callback);
|
||||
return id;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", (id: number) => { framesById.delete(id); });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
Object.defineProperty(globalThis, "localStorage", { value: undefined, configurable: true });
|
||||
});
|
||||
|
||||
export function runPendingAnimationFrames(): void {
|
||||
const frames = Array.from(framesById.values());
|
||||
framesById.clear();
|
||||
for (const frame of frames) frame();
|
||||
}
|
||||
|
||||
export function sessionKey(sessionId: string): string {
|
||||
return machineSessionKey("local", sessionId);
|
||||
}
|
||||
|
||||
export function sessionLookupId(session: string | SessionRef): string {
|
||||
return typeof session === "string" ? session : session.id;
|
||||
}
|
||||
@@ -82,7 +82,46 @@ describe("effectivePromptAttachmentDelivery", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PromptEditor attachment chips", () => {
|
||||
describe("PromptEditor attachment wiring", () => {
|
||||
// Direct TemplateResult handler extraction keeps these node-environment tests focused on
|
||||
// PromptEditor wiring without introducing a DOM/FileReader harness for the whole component.
|
||||
it("captures pasted files, strips data URL prefixes, and surfaces read failures", async () => {
|
||||
const editor = new PromptEditor();
|
||||
const onSend = vi.fn<NonNullable<PromptEditor["onSend"]>>();
|
||||
editor.onSend = onSend;
|
||||
setPromptEditorPrivate(editor, "draft", "inspect attachments");
|
||||
const restoreFileReader = installFileReaderStub([
|
||||
{ kind: "load", result: "data:image/png;base64,UE5H" },
|
||||
{ kind: "error", error: new DOMException("File unavailable", "NotReadableError") },
|
||||
]);
|
||||
|
||||
try {
|
||||
const paste = findTemplateEventHandlerAfterMarker<Event>(editor.render(), "@paste=");
|
||||
const pasteEvent = pasteEventWithFiles([
|
||||
new File(["png"], "shot.png", { type: "image/png" }),
|
||||
new File(["pdf"], "report.pdf", { type: "application/pdf" }),
|
||||
]);
|
||||
const preventDefault = vi.spyOn(pasteEvent, "preventDefault");
|
||||
|
||||
paste(pasteEvent);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce();
|
||||
expect(templateContainsValue(editor.render(), "Remove shot.png")).toBe(true);
|
||||
expect(templateContainsValue(editor.render(), READ_FAILURE_MESSAGE)).toBe(true);
|
||||
|
||||
const send = findTemplateEventHandlerAfterMarker<Event>(editor.render(), "send-button");
|
||||
send(new Event("click"));
|
||||
|
||||
expect(onSend).toHaveBeenCalledTimes(1);
|
||||
expect(onSend).toHaveBeenCalledWith("inspect attachments", undefined, [
|
||||
{ kind: "image", mimeType: "image/png", data: "UE5H", name: "shot.png" },
|
||||
], "inline");
|
||||
} finally {
|
||||
restoreFileReader();
|
||||
}
|
||||
});
|
||||
|
||||
it("removes a pending attachment chip before sending the remaining attachments", () => {
|
||||
const editor = new PromptEditor();
|
||||
const onSend = vi.fn<NonNullable<PromptEditor["onSend"]>>();
|
||||
@@ -111,10 +150,57 @@ describe("PromptEditor attachment chips", () => {
|
||||
|
||||
type TemplateEventHandler<E extends Event> = (event: E) => void;
|
||||
|
||||
type StubFileReaderOutcome =
|
||||
| { kind: "load"; result: string }
|
||||
| { kind: "error"; error: DOMException };
|
||||
|
||||
function setPromptEditorPrivate(editor: PromptEditor, property: string, value: unknown): void {
|
||||
if (!Reflect.set(editor, property, value)) throw new Error(`Failed to set PromptEditor ${property}`);
|
||||
}
|
||||
|
||||
function installFileReaderStub(outcomes: StubFileReaderOutcome[]): () => void {
|
||||
const hadFileReader = Reflect.has(globalThis, "FileReader");
|
||||
const previousFileReader = Reflect.get(globalThis, "FileReader");
|
||||
|
||||
class StubFileReader {
|
||||
onerror: (() => void) | null = null;
|
||||
onload: (() => void) | null = null;
|
||||
error: DOMException | null = null;
|
||||
result: string | ArrayBuffer | null = null;
|
||||
|
||||
readAsDataURL(): void {
|
||||
const outcome = outcomes.shift();
|
||||
if (outcome === undefined) throw new Error("Unexpected FileReader.readAsDataURL call");
|
||||
if (outcome.kind === "error") {
|
||||
this.error = outcome.error;
|
||||
this.onerror?.();
|
||||
return;
|
||||
}
|
||||
this.result = outcome.result;
|
||||
this.onload?.();
|
||||
}
|
||||
}
|
||||
|
||||
Reflect.set(globalThis, "FileReader", StubFileReader);
|
||||
return () => {
|
||||
if (hadFileReader) {
|
||||
Reflect.set(globalThis, "FileReader", previousFileReader);
|
||||
return;
|
||||
}
|
||||
Reflect.deleteProperty(globalThis, "FileReader");
|
||||
};
|
||||
}
|
||||
|
||||
function pasteEventWithFiles(files: readonly File[]): Event {
|
||||
const event = new Event("paste", { cancelable: true });
|
||||
Object.defineProperty(event, "clipboardData", { value: { files } });
|
||||
return event;
|
||||
}
|
||||
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
for (let remaining = 0; remaining < 10; remaining += 1) await Promise.resolve();
|
||||
}
|
||||
|
||||
function findTemplateEventHandlerAfterMarker<E extends Event>(template: TemplateResult, marker: string): TemplateEventHandler<E> {
|
||||
const handler = findOptionalTemplateEventHandlerAfterMarker<E>(template, marker);
|
||||
if (handler === undefined) throw new Error(`Expected template event handler after marker ${marker}`);
|
||||
|
||||
Reference in New Issue
Block a user