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"; +}