From 45bcc63544cdfef68557dc99bf5c6d888c5ad233 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 4 Jul 2026 23:35:11 +0200 Subject: [PATCH] test(settings): split settings dialog specs --- .../components/SettingsDialog.general.test.ts | 182 +++++ .../SettingsDialog.packages.test.ts | 79 ++ .../components/SettingsDialog.plugins.test.ts | 174 +++++ .../SettingsDialog.sessiond.test.ts | 146 ++++ .../src/components/SettingsDialog.test.ts | 705 ------------------ .../components/SettingsDialog.testSupport.ts | 153 ++++ 6 files changed, 734 insertions(+), 705 deletions(-) create mode 100644 src/client/src/components/SettingsDialog.general.test.ts create mode 100644 src/client/src/components/SettingsDialog.packages.test.ts create mode 100644 src/client/src/components/SettingsDialog.plugins.test.ts create mode 100644 src/client/src/components/SettingsDialog.sessiond.test.ts delete mode 100644 src/client/src/components/SettingsDialog.test.ts create mode 100644 src/client/src/components/SettingsDialog.testSupport.ts diff --git a/src/client/src/components/SettingsDialog.general.test.ts b/src/client/src/components/SettingsDialog.general.test.ts new file mode 100644 index 0000000..7ca56c5 --- /dev/null +++ b/src/client/src/components/SettingsDialog.general.test.ts @@ -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(" { + 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(); + 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(); + 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); + }); +}); diff --git a/src/client/src/components/SettingsDialog.packages.test.ts b/src/client/src/components/SettingsDialog.packages.test.ts new file mode 100644 index 0000000..29017cf --- /dev/null +++ b/src/client/src/components/SettingsDialog.packages.test.ts @@ -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(); + const packagesSpy = vi.spyOn(piPackagesApi, "packages").mockReturnValue(staleLoad.promise); + const dialog = new SettingsDialog(); + dialog.machine = remoteMachine; + dialog.machineRuntime = runtimeWithPackageManagement; + + const loadPromise = callDialogPromise(dialog, "loadPackagesForTarget"); + expect(packagesSpy.mock.calls).toEqual([["remote-a"]]); + expect(getDialogProperty(dialog, "packageLoading")).toBe(true); + + dialog.machine = secondRemoteMachine; + callDialogUpdated(dialog, new Map([["machine", remoteMachine]])); + staleLoad.resolve(remotePackages); + await loadPromise; + + expect(getDialogProperty(dialog, "packagesResponse")).toBeUndefined(); + expect(getDialogProperty(dialog, "packageError")).toBe(""); + expect(getDialogProperty(dialog, "packageMessage")).toBe(""); + expect(getDialogProperty(dialog, "packageLoading")).toBe(false); + }); + + it("runs remote package mutations against the selected machine without refreshing gateway plugins", async () => { + const installedPackages = [packageInfo("npm:@acme/new-tools")]; + const install = deferred(); + const installSpy = vi.spyOn(piPackagesApi, "install").mockReturnValue(install.promise); + const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(pluginsResponse([pluginInfo("gateway", true)])); + const dialog = new SettingsDialog(); + dialog.machine = remoteMachine; + dialog.machineRuntime = runtimeWithPackageManagement; + + const installPromise = callDialogPromise(dialog, "installPiPackage", "npm:@acme/new-tools"); + + expect(installSpy.mock.calls).toEqual([["npm:@acme/new-tools", "remote-a"]]); + expect(getDialogProperty(dialog, "saving")).toBe(true); + expect(getDialogProperty(dialog, "packageOperation")).toEqual({ kind: "install", source: "npm:@acme/new-tools" }); + + install.resolve(packageMutationResponse("install", installedPackages, "npm:@acme/new-tools")); + await installPromise; + + expect(pluginsSpy).not.toHaveBeenCalled(); + expect(getDialogProperty(dialog, "packagesResponse")).toEqual({ packages: installedPackages }); + expect(getDialogProperty(dialog, "packageMessage")).toContain("Pi package installed on Lab Mac"); + expect(getDialogProperty(dialog, "packageMessage")).toContain("each idle PI WEB session on Lab Mac"); + expect(getDialogProperty(dialog, "packageError")).toBe(""); + expect(getDialogProperty(dialog, "packageOperation")).toBeUndefined(); + expect(getDialogProperty(dialog, "saving")).toBe(false); + }); + + it("refreshes gateway plugins after a local package mutation", async () => { + const updatedPackages = [packageInfo("npm:@acme/tools")]; + const refreshedPlugins = pluginsResponse([pluginInfo("browser-helper", true)]); + const updateSpy = vi.spyOn(piPackagesApi, "update").mockResolvedValue(packageMutationResponse("update", updatedPackages)); + const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(refreshedPlugins); + const dialog = new SettingsDialog(); + + await callDialogPromise(dialog, "updatePiPackage"); + + expect(updateSpy.mock.calls).toEqual([[undefined, "local"]]); + expect(pluginsSpy.mock.calls).toEqual([[]]); + expect(getDialogProperty(dialog, "packagesResponse")).toEqual({ packages: updatedPackages }); + expect(getDialogProperty(dialog, "pluginsResponse")).toBe(refreshedPlugins); + expect(getDialogProperty(dialog, "packageMessage")).toContain("Reload the browser page separately for PI WEB browser plugin changes"); + expect(getDialogProperty(dialog, "packageError")).toBe(""); + expect(getDialogProperty(dialog, "saving")).toBe(false); + }); +}); diff --git a/src/client/src/components/SettingsDialog.plugins.test.ts b/src/client/src/components/SettingsDialog.plugins.test.ts new file mode 100644 index 0000000..8aae22f --- /dev/null +++ b/src/client/src/components/SettingsDialog.plugins.test.ts @@ -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(); + const pluginsLoad = deferred(); + 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(); + 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); + }); +}); diff --git a/src/client/src/components/SettingsDialog.sessiond.test.ts b/src/client/src/components/SettingsDialog.sessiond.test.ts new file mode 100644 index 0000000..cbd7528 --- /dev/null +++ b/src/client/src/components/SettingsDialog.sessiond.test.ts @@ -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(); + 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(); + 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); + }); +}); diff --git a/src/client/src/components/SettingsDialog.test.ts b/src/client/src/components/SettingsDialog.test.ts deleted file mode 100644 index e453492..0000000 --- a/src/client/src/components/SettingsDialog.test.ts +++ /dev/null @@ -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(); - 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(); - 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(" { - 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(); - 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(); - 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(); - const packagesSpy = vi.spyOn(piPackagesApi, "packages").mockReturnValue(staleLoad.promise); - const dialog = new SettingsDialog(); - dialog.machine = remoteMachine; - dialog.machineRuntime = runtimeWithPackageManagement; - - const loadPromise = callDialogPromise(dialog, "loadPackagesForTarget"); - expect(packagesSpy.mock.calls).toEqual([["remote-a"]]); - expect(getDialogProperty(dialog, "packageLoading")).toBe(true); - - dialog.machine = secondRemoteMachine; - callDialogUpdated(dialog, new Map([["machine", remoteMachine]])); - staleLoad.resolve(remotePackages); - await loadPromise; - - expect(getDialogProperty(dialog, "packagesResponse")).toBeUndefined(); - expect(getDialogProperty(dialog, "packageError")).toBe(""); - expect(getDialogProperty(dialog, "packageMessage")).toBe(""); - expect(getDialogProperty(dialog, "packageLoading")).toBe(false); - }); - - it("runs remote package mutations against the selected machine without refreshing gateway plugins", async () => { - const installedPackages = [packageInfo("npm:@acme/new-tools")]; - const install = deferred(); - const installSpy = vi.spyOn(piPackagesApi, "install").mockReturnValue(install.promise); - const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(pluginsResponse([pluginInfo("gateway", true)])); - const dialog = new SettingsDialog(); - dialog.machine = remoteMachine; - dialog.machineRuntime = runtimeWithPackageManagement; - - const installPromise = callDialogPromise(dialog, "installPiPackage", "npm:@acme/new-tools"); - - expect(installSpy.mock.calls).toEqual([["npm:@acme/new-tools", "remote-a"]]); - expect(getDialogProperty(dialog, "saving")).toBe(true); - expect(getDialogProperty(dialog, "packageOperation")).toEqual({ kind: "install", source: "npm:@acme/new-tools" }); - - install.resolve(packageMutationResponse("install", installedPackages, "npm:@acme/new-tools")); - await installPromise; - - expect(pluginsSpy).not.toHaveBeenCalled(); - expect(getDialogProperty(dialog, "packagesResponse")).toEqual({ packages: installedPackages }); - expect(getDialogProperty(dialog, "packageMessage")).toContain("Pi package installed on Lab Mac"); - expect(getDialogProperty(dialog, "packageMessage")).toContain("each idle PI WEB session on Lab Mac"); - expect(getDialogProperty(dialog, "packageError")).toBe(""); - expect(getDialogProperty(dialog, "packageOperation")).toBeUndefined(); - expect(getDialogProperty(dialog, "saving")).toBe(false); - }); - - it("refreshes gateway plugins after a local package mutation", async () => { - const updatedPackages = [packageInfo("npm:@acme/tools")]; - const refreshedPlugins = pluginsResponse([pluginInfo("browser-helper", true)]); - const updateSpy = vi.spyOn(piPackagesApi, "update").mockResolvedValue(packageMutationResponse("update", updatedPackages)); - const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(refreshedPlugins); - const dialog = new SettingsDialog(); - - await callDialogPromise(dialog, "updatePiPackage"); - - expect(updateSpy.mock.calls).toEqual([[undefined, "local"]]); - expect(pluginsSpy.mock.calls).toEqual([[]]); - expect(getDialogProperty(dialog, "packagesResponse")).toEqual({ packages: updatedPackages }); - expect(getDialogProperty(dialog, "pluginsResponse")).toBe(refreshedPlugins); - expect(getDialogProperty(dialog, "packageMessage")).toContain("Reload the browser page separately for PI WEB browser plugin changes"); - expect(getDialogProperty(dialog, "packageError")).toBe(""); - expect(getDialogProperty(dialog, "saving")).toBe(false); - }); -}); - -describe("settings-dialog plugin settings machine targeting", () => { - it("loads plugin config and plugin list from the selected machine", async () => { - const config = configResponse({ plugins: { info: { enabled: true } } }); - 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(); - const pluginsLoad = deferred(); - 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(); - 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 { - 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): 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 { - promise: Promise; - resolve: (value: T) => void; - reject: (error: unknown) => void; -} - -function deferred(): Deferred { - let resolveDeferred: ((value: T) => void) | undefined; - let rejectDeferred: ((error: unknown) => void) | undefined; - const promise = new Promise((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), - }); -} diff --git a/src/client/src/components/SettingsDialog.testSupport.ts b/src/client/src/components/SettingsDialog.testSupport.ts new file mode 100644 index 0000000..c4574f0 --- /dev/null +++ b/src/client/src/components/SettingsDialog.testSupport.ts @@ -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 { + 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): 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 { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +} + +export function deferred(): Deferred { + let resolveDeferred: ((value: T) => void) | undefined; + let rejectDeferred: ((error: unknown) => void) | undefined; + const promise = new Promise((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), + }); +}