From 59a13f5058ac3f8906d4ce577e2b07bf0f241a4a Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 14 Jun 2026 17:32:34 +0200 Subject: [PATCH 1/2] test(updates): extract panel logic into a tested module Move the Updates plugin's pure decision logic (recommended/additional commands, panel visibility, installation labels, version formatting) into a sibling updatesLogic.ts module so it can be unit tested directly, matching the workspace-tasks multi-file plugin layout. The plugin file is now thin rendering glue that imports those helpers. Add unit tests for the extracted logic and render smoke tests that exercise the panel through its public contribution API, including that Run actions wire to the terminal with the "pi.plugin": "updates" metadata. No behavior change; the beta label stays. --- pi-web-plugins/updates/pi-web-plugin.test.ts | 185 +++++++++++++++++ pi-web-plugins/updates/pi-web-plugin.ts | 76 +------ pi-web-plugins/updates/updatesLogic.test.ts | 203 +++++++++++++++++++ pi-web-plugins/updates/updatesLogic.ts | 74 +++++++ 4 files changed, 464 insertions(+), 74 deletions(-) create mode 100644 pi-web-plugins/updates/pi-web-plugin.test.ts create mode 100644 pi-web-plugins/updates/updatesLogic.test.ts create mode 100644 pi-web-plugins/updates/updatesLogic.ts diff --git a/pi-web-plugins/updates/pi-web-plugin.test.ts b/pi-web-plugins/updates/pi-web-plugin.test.ts new file mode 100644 index 0000000..a8c4043 --- /dev/null +++ b/pi-web-plugins/updates/pi-web-plugin.test.ts @@ -0,0 +1,185 @@ +import { html, svg, type TemplateResult } from "lit"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PiWebComponentStatus, PiWebStatusResponse, PluginRuntimeState, WorkspacePanelContext, WorkspacePanelContribution, WorkspacePanelTerminal } from "@jmfederico/pi-web/plugin-api"; +import plugin from "./pi-web-plugin"; + +function isTemplateResult(value: unknown): value is TemplateResult { + return typeof value === "object" && value !== null && "_$litType$" in value; +} + +function isFunction(value: unknown): value is () => void { + return typeof value === "function"; +} + +// The repo runs vitest in plain Node (no DOM), so instead of mounting the +// template we walk the lit TemplateResult tree to collect its rendered text +// and any event-handler functions. +function walk(node: unknown, text: string[], handlers: (() => void)[]): void { + if (isFunction(node)) { + handlers.push(node); + return; + } + if (Array.isArray(node)) { + for (const child of node) walk(child, text, handlers); + return; + } + if (isTemplateResult(node)) { + for (const piece of node.strings) text.push(piece); + for (const value of node.values) walk(value, text, handlers); + return; + } + if (typeof node === "string" || typeof node === "number" || typeof node === "boolean") { + text.push(String(node)); + } +} + +function rendered(template: TemplateResult): { text: string; handlers: (() => void)[] } { + const text: string[] = []; + const handlers: (() => void)[] = []; + walk(template, text, handlers); + return { text: text.join(" "), handlers }; +} + +function badgeText(value: string | number | TemplateResult | undefined): string { + if (value === undefined) return ""; + if (isTemplateResult(value)) return rendered(value).text; + return String(value); +} + +function updatesPanel(): WorkspacePanelContribution { + const result = plugin.activate({ apiVersion: 1, pluginId: "updates", html, svg }); + const panel = result.contributions.workspacePanels?.[0]; + if (panel === undefined) throw new Error("Updates plugin did not contribute a workspace panel"); + return panel; +} + +function component(overrides: Partial = {}): PiWebComponentStatus { + return { + component: "web", + label: "Web/UI", + runtimeVersion: "1.202605.8", + installedVersion: "1.202605.8", + stale: false, + available: true, + ...overrides, + }; +} + +function status(overrides: Partial = {}): PiWebStatusResponse { + return { + packageName: "@jmfederico/pi-web", + generatedAt: "2026-06-14T00:00:00.000Z", + components: { + web: component({ component: "web", label: "Web/UI" }), + sessiond: component({ component: "sessiond", label: "Session daemon" }), + }, + release: { packageName: "@jmfederico/pi-web", updateAvailable: false }, + commands: {}, + messages: [], + ...overrides, + }; +} + +function noopTerminal(): WorkspacePanelTerminal { + return { open: vi.fn(), runCommand: vi.fn().mockResolvedValue({}) }; +} + +function contextFor(state: PluginRuntimeState | undefined, terminal: WorkspacePanelTerminal = noopTerminal()): WorkspacePanelContext { + return { + machine: { id: "local", name: "Local", kind: "local" }, + workspace: { id: "ws", projectId: "p", path: "/tmp/ws", label: "ws", isMain: true, isGitRepo: false, isGitWorktree: false }, + ...(state === undefined ? {} : { state }), + files: { readFile: vi.fn() }, + host: { requestRender: vi.fn() }, + terminal, + }; +} + +describe("Updates plugin panel", () => { + it("contributes a single Updates workspace panel", () => { + const panel = updatesPanel(); + expect(panel.id).toBe("workspace.updates"); + expect(panel.title).toBe("Updates"); + }); + + it("shows a checking placeholder before status is available", () => { + const { text } = rendered(updatesPanel().render(contextFor(undefined))); + expect(text).toContain("Checking PI WEB update status"); + }); + + it("renders installed services and commands without throwing", () => { + const value = status({ + release: { packageName: "@jmfederico/pi-web", updateAvailable: true }, + commands: { update: "pi-web update && pi-web restart", status: "pi-web status" }, + }); + const { text } = rendered(updatesPanel().render(contextFor({ piWebStatus: value }))); + expect(text).toContain("Installed services"); + expect(text).toContain("Recommended"); + expect(text).toContain("Copy"); + }); + + describe("with a terminal", () => { + const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, "navigator"); + + beforeEach(() => { + Object.defineProperty(globalThis, "navigator", { + value: { clipboard: { writeText: vi.fn() } }, + configurable: true, + }); + }); + + afterEach(() => { + if (originalNavigator === undefined) Reflect.deleteProperty(globalThis, "navigator"); + else Object.defineProperty(globalThis, "navigator", originalNavigator); + }); + + it("renders Run actions and wires them to the terminal with plugin metadata", () => { + const runCommand = vi.fn().mockResolvedValue({}); + const terminal: WorkspacePanelTerminal = { open: vi.fn(), runCommand }; + const value = status({ + release: { packageName: "@jmfederico/pi-web", updateAvailable: true }, + commands: { update: "pi-web update && pi-web restart" }, + }); + + const { text, handlers } = rendered(updatesPanel().render(contextFor({ piWebStatus: value }, terminal))); + expect(text).toContain(">Run<"); + + for (const handler of handlers) handler(); + + expect(runCommand).toHaveBeenCalledWith(expect.objectContaining({ + command: "pi-web update && pi-web restart", + open: true, + metadata: { "pi.plugin": "updates" }, + })); + }); + }); + + describe("visibility and badge", () => { + it("is hidden for managed installs with no messages and visible for local installs", () => { + const panel = updatesPanel(); + const managed = status({ + components: { + web: component({ installation: { kind: "pi-package" } }), + sessiond: component({ component: "sessiond", label: "Session daemon", installation: { kind: "npm-global" } }), + }, + }); + const local = status({ + components: { + web: component({ installation: { kind: "local" } }), + sessiond: component({ component: "sessiond", label: "Session daemon", installation: { kind: "pi-package" } }), + }, + }); + expect(panel.visible?.(contextFor({ piWebStatus: managed }))).toBe(false); + expect(panel.visible?.(contextFor({ piWebStatus: local }))).toBe(true); + }); + + it("marks the badge beta and appends the message count", () => { + const panel = updatesPanel(); + expect(badgeText(panel.badge?.(contextFor({ piWebStatus: status() })))).toContain("beta"); + + const withMessages = badgeText(panel.badge?.(contextFor({ piWebStatus: status({ messages: [{ id: "a", severity: "warning", title: "t", body: "b" }] }) }))); + expect(withMessages).toContain("beta"); + expect(withMessages).toContain("1"); + }); + }); +}); diff --git a/pi-web-plugins/updates/pi-web-plugin.ts b/pi-web-plugins/updates/pi-web-plugin.ts index 95b8792..3266f08 100644 --- a/pi-web-plugins/updates/pi-web-plugin.ts +++ b/pi-web-plugins/updates/pi-web-plugin.ts @@ -1,10 +1,6 @@ import type { TemplateResult } from "lit"; -import type { HtmlTemplateTag, PiWebComponentStatus, PiWebInstallationInfo, PiWebPlugin, PiWebStatusMessage, PiWebStatusResponse, PluginRuntimeState, WorkspacePanelTerminal } from "@jmfederico/pi-web/plugin-api"; - -interface CommandEntry { - label: string; - command: string; -} +import type { HtmlTemplateTag, PiWebComponentStatus, PiWebPlugin, PiWebStatusResponse, PluginRuntimeState, WorkspacePanelTerminal } from "@jmfederico/pi-web/plugin-api"; +import { additionalCommands, formatVersion, installationLabel, messageCount, recommendedCommand, shouldShowUpdatesPanel, statusFor } from "./updatesLogic.js"; function runCommandInTerminal(terminal: WorkspacePanelTerminal, label: string, command: string): void { void terminal.runCommand({ @@ -17,74 +13,6 @@ function runCommandInTerminal(terminal: WorkspacePanelTerminal, label: string, c }); } -// The single command users should run when they do not want to think: if an -// update is available, `commands.update` already chains the update and a full -// restart; otherwise, when anything is stale, a full restart is enough. -function recommendedCommand(status: PiWebStatusResponse): CommandEntry | undefined { - const { commands, release, components } = status; - if (release.updateAvailable && typeof commands.update === "string" && commands.update !== "") { - return { label: "Update & restart everything", command: commands.update }; - } - const restartNeeded = components.web.stale || components.sessiond.stale || !components.sessiond.available; - if (restartNeeded && typeof commands.restart === "string" && commands.restart !== "") { - return { label: "Restart everything", command: commands.restart }; - } - return undefined; -} - -function additionalCommands(status: PiWebStatusResponse, recommended: CommandEntry | undefined): CommandEntry[] { - return [ - ["Update", status.commands.update], - ["Restart all", status.commands.restart], - ["Restart Web/UI", status.commands.restartWeb], - ["Restart session daemon", status.commands.restartSessiond], - ["Status", status.commands.status], - ] - .filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[1] !== "") - .filter(([, command]) => command !== recommended?.command) - .map(([label, command]) => ({ label, command })); -} - -function messagesFor(state: PluginRuntimeState | undefined): PiWebStatusMessage[] { - return state?.piWebStatus?.messages ?? []; -} - -function statusFor(state: PluginRuntimeState | undefined): PiWebStatusResponse | undefined { - return state?.piWebStatus; -} - -function messageCount(state: PluginRuntimeState | undefined): number { - return messagesFor(state).length; -} - -function isLocalOrUnknownInstallation(installation: PiWebInstallationInfo | undefined): boolean { - return installation === undefined || installation.kind === "local" || installation.kind === "unknown"; -} - -function shouldShowUpdatesPanel(state: PluginRuntimeState | undefined): boolean { - const status = statusFor(state); - if (messageCount(state) > 0) return true; - if (status === undefined) return false; - return isLocalOrUnknownInstallation(status.components.web.installation) - || isLocalOrUnknownInstallation(status.components.sessiond.installation); -} - -function formatVersion(version: string | undefined): string { - return version === undefined || version === "" ? "unknown" : version; -} - -function installationLabel(installation: PiWebInstallationInfo | undefined): string { - if (installation === undefined) return "installation unknown"; - if (installation.kind === "pi-package") { - const scope = installation.scope === undefined ? "" : ` · ${installation.scope}`; - const source = installation.source ?? "Pi package"; - return `${source}${scope}`; - } - if (installation.kind === "npm-global") return "global npm package"; - if (installation.kind === "local") return "local checkout"; - return "installation unknown"; -} - function renderComponent(html: HtmlTemplateTag, component: PiWebComponentStatus): TemplateResult { const status = !component.available ? "unavailable" diff --git a/pi-web-plugins/updates/updatesLogic.test.ts b/pi-web-plugins/updates/updatesLogic.test.ts new file mode 100644 index 0000000..c85946b --- /dev/null +++ b/pi-web-plugins/updates/updatesLogic.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from "vitest"; +import type { PiWebComponentStatus, PiWebStatusMessage, PiWebStatusResponse, PluginRuntimeState } from "@jmfederico/pi-web/plugin-api"; +import { additionalCommands, formatVersion, installationLabel, messageCount, recommendedCommand, shouldShowUpdatesPanel } from "./updatesLogic"; + +function component(overrides: Partial = {}): PiWebComponentStatus { + return { + component: "web", + label: "Web/UI", + runtimeVersion: "1.202605.8", + installedVersion: "1.202605.8", + stale: false, + available: true, + ...overrides, + }; +} + +function status(overrides: Partial = {}): PiWebStatusResponse { + return { + packageName: "@jmfederico/pi-web", + generatedAt: "2026-06-14T00:00:00.000Z", + components: { + web: component({ component: "web", label: "Web/UI" }), + sessiond: component({ component: "sessiond", label: "Session daemon" }), + }, + release: { packageName: "@jmfederico/pi-web", updateAvailable: false }, + commands: {}, + messages: [], + ...overrides, + }; +} + +function stateWith(value: PiWebStatusResponse | undefined): PluginRuntimeState { + return value === undefined ? {} : { piWebStatus: value }; +} + +describe("recommendedCommand", () => { + it("recommends update & restart when an update is available", () => { + const result = recommendedCommand(status({ + release: { packageName: "@jmfederico/pi-web", updateAvailable: true }, + commands: { update: "pi-web update && pi-web restart", restart: "pi-web restart" }, + })); + expect(result).toEqual({ label: "Update & restart everything", command: "pi-web update && pi-web restart" }); + }); + + it("falls through to restart when an update is available but the update command is empty", () => { + const result = recommendedCommand(status({ + release: { packageName: "@jmfederico/pi-web", updateAvailable: true }, + components: { + web: component({ stale: true }), + sessiond: component({ component: "sessiond", label: "Session daemon" }), + }, + commands: { update: "", restart: "pi-web restart" }, + })); + expect(result).toEqual({ label: "Restart everything", command: "pi-web restart" }); + }); + + it("recommends restart when the web component is stale", () => { + const result = recommendedCommand(status({ + components: { + web: component({ stale: true }), + sessiond: component({ component: "sessiond", label: "Session daemon" }), + }, + commands: { restart: "pi-web restart" }, + })); + expect(result).toEqual({ label: "Restart everything", command: "pi-web restart" }); + }); + + it("recommends restart when the session daemon is unavailable", () => { + const result = recommendedCommand(status({ + components: { + web: component(), + sessiond: component({ component: "sessiond", label: "Session daemon", available: false }), + }, + commands: { restart: "pi-web restart" }, + })); + expect(result).toEqual({ label: "Restart everything", command: "pi-web restart" }); + }); + + it("returns nothing when everything is current and available", () => { + expect(recommendedCommand(status({ commands: { restart: "pi-web restart" } }))).toBeUndefined(); + }); + + it("does not fabricate a restart command when one is not configured", () => { + const result = recommendedCommand(status({ + components: { + web: component({ stale: true }), + sessiond: component({ component: "sessiond", label: "Session daemon" }), + }, + commands: { restart: "" }, + })); + expect(result).toBeUndefined(); + }); +}); + +describe("additionalCommands", () => { + it("drops empty commands and the recommended command, preserving order", () => { + const value = status({ + commands: { + update: "pi-web update", + restart: "pi-web restart", + restartWeb: "", + restartSessiond: "pi-web restart sessiond", + status: "pi-web status", + }, + }); + const result = additionalCommands(value, { label: "Restart everything", command: "pi-web restart" }); + expect(result).toEqual([ + { label: "Update", command: "pi-web update" }, + { label: "Restart session daemon", command: "pi-web restart sessiond" }, + { label: "Status", command: "pi-web status" }, + ]); + }); + + it("keeps all commands when there is no recommended command", () => { + const value = status({ commands: { update: "pi-web update", status: "pi-web status" } }); + expect(additionalCommands(value, undefined)).toEqual([ + { label: "Update", command: "pi-web update" }, + { label: "Status", command: "pi-web status" }, + ]); + }); +}); + +describe("shouldShowUpdatesPanel", () => { + const messages: PiWebStatusMessage[] = [{ id: "x", severity: "warning", title: "t", body: "b" }]; + + it("shows the panel whenever there are messages, even on a managed install", () => { + const value = status({ + messages, + components: { + web: component({ installation: { kind: "pi-package" } }), + sessiond: component({ component: "sessiond", label: "Session daemon", installation: { kind: "pi-package" } }), + }, + }); + expect(shouldShowUpdatesPanel(stateWith(value))).toBe(true); + }); + + it("hides the panel when status is unavailable", () => { + expect(shouldShowUpdatesPanel(stateWith(undefined))).toBe(false); + expect(shouldShowUpdatesPanel(undefined)).toBe(false); + }); + + it("shows the panel for local or unknown installs", () => { + const local = status({ + components: { + web: component({ installation: { kind: "local" } }), + sessiond: component({ component: "sessiond", label: "Session daemon", installation: { kind: "pi-package" } }), + }, + }); + expect(shouldShowUpdatesPanel(stateWith(local))).toBe(true); + + const unknown = status({ + components: { + web: component({ installation: { kind: "pi-package" } }), + sessiond: component({ component: "sessiond", label: "Session daemon" }), + }, + }); + expect(shouldShowUpdatesPanel(stateWith(unknown))).toBe(true); + }); + + it("hides the panel for fully managed installs with no messages", () => { + const value = status({ + components: { + web: component({ installation: { kind: "pi-package" } }), + sessiond: component({ component: "sessiond", label: "Session daemon", installation: { kind: "npm-global" } }), + }, + }); + expect(shouldShowUpdatesPanel(stateWith(value))).toBe(false); + }); +}); + +describe("messageCount", () => { + it("counts messages and tolerates missing status", () => { + expect(messageCount(undefined)).toBe(0); + expect(messageCount(stateWith(status()))).toBe(0); + expect(messageCount(stateWith(status({ messages: [{ id: "a", severity: "info", title: "t", body: "b" }] })))).toBe(1); + }); +}); + +describe("formatVersion", () => { + it("renders unknown for missing or empty versions", () => { + expect(formatVersion(undefined)).toBe("unknown"); + expect(formatVersion("")).toBe("unknown"); + expect(formatVersion("1.202605.8")).toBe("1.202605.8"); + }); +}); + +describe("installationLabel", () => { + it("labels each installation kind", () => { + expect(installationLabel(undefined)).toBe("installation unknown"); + expect(installationLabel({ kind: "unknown" })).toBe("installation unknown"); + expect(installationLabel({ kind: "npm-global" })).toBe("global npm package"); + expect(installationLabel({ kind: "local" })).toBe("local checkout"); + }); + + it("includes source and scope for pi-package installs", () => { + expect(installationLabel({ kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user" })) + .toBe("npm:@jmfederico/pi-web · user"); + }); + + it("defaults the source and omits scope when absent", () => { + expect(installationLabel({ kind: "pi-package" })).toBe("Pi package"); + }); +}); diff --git a/pi-web-plugins/updates/updatesLogic.ts b/pi-web-plugins/updates/updatesLogic.ts new file mode 100644 index 0000000..3e59a91 --- /dev/null +++ b/pi-web-plugins/updates/updatesLogic.ts @@ -0,0 +1,74 @@ +import type { PiWebInstallationInfo, PiWebStatusMessage, PiWebStatusResponse, PluginRuntimeState } from "@jmfederico/pi-web/plugin-api"; + +export interface CommandEntry { + label: string; + command: string; +} + +// The single command users should run when they do not want to think: if an +// update is available, `commands.update` already chains the update and a full +// restart; otherwise, when anything is stale, a full restart is enough. +export function recommendedCommand(status: PiWebStatusResponse): CommandEntry | undefined { + const { commands, release, components } = status; + if (release.updateAvailable && typeof commands.update === "string" && commands.update !== "") { + return { label: "Update & restart everything", command: commands.update }; + } + const restartNeeded = components.web.stale || components.sessiond.stale || !components.sessiond.available; + if (restartNeeded && typeof commands.restart === "string" && commands.restart !== "") { + return { label: "Restart everything", command: commands.restart }; + } + return undefined; +} + +export function additionalCommands(status: PiWebStatusResponse, recommended: CommandEntry | undefined): CommandEntry[] { + return [ + ["Update", status.commands.update], + ["Restart all", status.commands.restart], + ["Restart Web/UI", status.commands.restartWeb], + ["Restart session daemon", status.commands.restartSessiond], + ["Status", status.commands.status], + ] + .filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[1] !== "") + .filter(([, command]) => command !== recommended?.command) + .map(([label, command]) => ({ label, command })); +} + +export function messagesFor(state: PluginRuntimeState | undefined): PiWebStatusMessage[] { + return state?.piWebStatus?.messages ?? []; +} + +export function statusFor(state: PluginRuntimeState | undefined): PiWebStatusResponse | undefined { + return state?.piWebStatus; +} + +export function messageCount(state: PluginRuntimeState | undefined): number { + return messagesFor(state).length; +} + +export function isLocalOrUnknownInstallation(installation: PiWebInstallationInfo | undefined): boolean { + return installation === undefined || installation.kind === "local" || installation.kind === "unknown"; +} + +export function shouldShowUpdatesPanel(state: PluginRuntimeState | undefined): boolean { + const status = statusFor(state); + if (messageCount(state) > 0) return true; + if (status === undefined) return false; + return isLocalOrUnknownInstallation(status.components.web.installation) + || isLocalOrUnknownInstallation(status.components.sessiond.installation); +} + +export function formatVersion(version: string | undefined): string { + return version === undefined || version === "" ? "unknown" : version; +} + +export function installationLabel(installation: PiWebInstallationInfo | undefined): string { + if (installation === undefined) return "installation unknown"; + if (installation.kind === "pi-package") { + const scope = installation.scope === undefined ? "" : ` · ${installation.scope}`; + const source = installation.source ?? "Pi package"; + return `${source}${scope}`; + } + if (installation.kind === "npm-global") return "global npm package"; + if (installation.kind === "local") return "local checkout"; + return "installation unknown"; +} From 53ec8c308618c49afdfae304d91049e928b27827 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 14 Jun 2026 20:39:52 +0200 Subject: [PATCH 2/2] test: remove updates plugin rendering tests Drop pi-web-plugin.test.ts, which relied on a walk() hack to assert rendered template content and click wiring. Real decision logic is already covered by updatesLogic.test.ts; the remaining runCommand glue is trivial inline code. --- pi-web-plugins/updates/pi-web-plugin.test.ts | 185 ------------------- 1 file changed, 185 deletions(-) delete mode 100644 pi-web-plugins/updates/pi-web-plugin.test.ts diff --git a/pi-web-plugins/updates/pi-web-plugin.test.ts b/pi-web-plugins/updates/pi-web-plugin.test.ts deleted file mode 100644 index a8c4043..0000000 --- a/pi-web-plugins/updates/pi-web-plugin.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { html, svg, type TemplateResult } from "lit"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { PiWebComponentStatus, PiWebStatusResponse, PluginRuntimeState, WorkspacePanelContext, WorkspacePanelContribution, WorkspacePanelTerminal } from "@jmfederico/pi-web/plugin-api"; -import plugin from "./pi-web-plugin"; - -function isTemplateResult(value: unknown): value is TemplateResult { - return typeof value === "object" && value !== null && "_$litType$" in value; -} - -function isFunction(value: unknown): value is () => void { - return typeof value === "function"; -} - -// The repo runs vitest in plain Node (no DOM), so instead of mounting the -// template we walk the lit TemplateResult tree to collect its rendered text -// and any event-handler functions. -function walk(node: unknown, text: string[], handlers: (() => void)[]): void { - if (isFunction(node)) { - handlers.push(node); - return; - } - if (Array.isArray(node)) { - for (const child of node) walk(child, text, handlers); - return; - } - if (isTemplateResult(node)) { - for (const piece of node.strings) text.push(piece); - for (const value of node.values) walk(value, text, handlers); - return; - } - if (typeof node === "string" || typeof node === "number" || typeof node === "boolean") { - text.push(String(node)); - } -} - -function rendered(template: TemplateResult): { text: string; handlers: (() => void)[] } { - const text: string[] = []; - const handlers: (() => void)[] = []; - walk(template, text, handlers); - return { text: text.join(" "), handlers }; -} - -function badgeText(value: string | number | TemplateResult | undefined): string { - if (value === undefined) return ""; - if (isTemplateResult(value)) return rendered(value).text; - return String(value); -} - -function updatesPanel(): WorkspacePanelContribution { - const result = plugin.activate({ apiVersion: 1, pluginId: "updates", html, svg }); - const panel = result.contributions.workspacePanels?.[0]; - if (panel === undefined) throw new Error("Updates plugin did not contribute a workspace panel"); - return panel; -} - -function component(overrides: Partial = {}): PiWebComponentStatus { - return { - component: "web", - label: "Web/UI", - runtimeVersion: "1.202605.8", - installedVersion: "1.202605.8", - stale: false, - available: true, - ...overrides, - }; -} - -function status(overrides: Partial = {}): PiWebStatusResponse { - return { - packageName: "@jmfederico/pi-web", - generatedAt: "2026-06-14T00:00:00.000Z", - components: { - web: component({ component: "web", label: "Web/UI" }), - sessiond: component({ component: "sessiond", label: "Session daemon" }), - }, - release: { packageName: "@jmfederico/pi-web", updateAvailable: false }, - commands: {}, - messages: [], - ...overrides, - }; -} - -function noopTerminal(): WorkspacePanelTerminal { - return { open: vi.fn(), runCommand: vi.fn().mockResolvedValue({}) }; -} - -function contextFor(state: PluginRuntimeState | undefined, terminal: WorkspacePanelTerminal = noopTerminal()): WorkspacePanelContext { - return { - machine: { id: "local", name: "Local", kind: "local" }, - workspace: { id: "ws", projectId: "p", path: "/tmp/ws", label: "ws", isMain: true, isGitRepo: false, isGitWorktree: false }, - ...(state === undefined ? {} : { state }), - files: { readFile: vi.fn() }, - host: { requestRender: vi.fn() }, - terminal, - }; -} - -describe("Updates plugin panel", () => { - it("contributes a single Updates workspace panel", () => { - const panel = updatesPanel(); - expect(panel.id).toBe("workspace.updates"); - expect(panel.title).toBe("Updates"); - }); - - it("shows a checking placeholder before status is available", () => { - const { text } = rendered(updatesPanel().render(contextFor(undefined))); - expect(text).toContain("Checking PI WEB update status"); - }); - - it("renders installed services and commands without throwing", () => { - const value = status({ - release: { packageName: "@jmfederico/pi-web", updateAvailable: true }, - commands: { update: "pi-web update && pi-web restart", status: "pi-web status" }, - }); - const { text } = rendered(updatesPanel().render(contextFor({ piWebStatus: value }))); - expect(text).toContain("Installed services"); - expect(text).toContain("Recommended"); - expect(text).toContain("Copy"); - }); - - describe("with a terminal", () => { - const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, "navigator"); - - beforeEach(() => { - Object.defineProperty(globalThis, "navigator", { - value: { clipboard: { writeText: vi.fn() } }, - configurable: true, - }); - }); - - afterEach(() => { - if (originalNavigator === undefined) Reflect.deleteProperty(globalThis, "navigator"); - else Object.defineProperty(globalThis, "navigator", originalNavigator); - }); - - it("renders Run actions and wires them to the terminal with plugin metadata", () => { - const runCommand = vi.fn().mockResolvedValue({}); - const terminal: WorkspacePanelTerminal = { open: vi.fn(), runCommand }; - const value = status({ - release: { packageName: "@jmfederico/pi-web", updateAvailable: true }, - commands: { update: "pi-web update && pi-web restart" }, - }); - - const { text, handlers } = rendered(updatesPanel().render(contextFor({ piWebStatus: value }, terminal))); - expect(text).toContain(">Run<"); - - for (const handler of handlers) handler(); - - expect(runCommand).toHaveBeenCalledWith(expect.objectContaining({ - command: "pi-web update && pi-web restart", - open: true, - metadata: { "pi.plugin": "updates" }, - })); - }); - }); - - describe("visibility and badge", () => { - it("is hidden for managed installs with no messages and visible for local installs", () => { - const panel = updatesPanel(); - const managed = status({ - components: { - web: component({ installation: { kind: "pi-package" } }), - sessiond: component({ component: "sessiond", label: "Session daemon", installation: { kind: "npm-global" } }), - }, - }); - const local = status({ - components: { - web: component({ installation: { kind: "local" } }), - sessiond: component({ component: "sessiond", label: "Session daemon", installation: { kind: "pi-package" } }), - }, - }); - expect(panel.visible?.(contextFor({ piWebStatus: managed }))).toBe(false); - expect(panel.visible?.(contextFor({ piWebStatus: local }))).toBe(true); - }); - - it("marks the badge beta and appends the message count", () => { - const panel = updatesPanel(); - expect(badgeText(panel.badge?.(contextFor({ piWebStatus: status() })))).toContain("beta"); - - const withMessages = badgeText(panel.badge?.(contextFor({ piWebStatus: status({ messages: [{ id: "a", severity: "warning", title: "t", body: "b" }] }) }))); - expect(withMessages).toContain("beta"); - expect(withMessages).toContain("1"); - }); - }); -});