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.
This commit is contained in:
Federico Jaramillo Martinez
2026-06-14 17:32:34 +02:00
parent 227187c4ca
commit 59a13f5058
4 changed files with 464 additions and 74 deletions
@@ -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> = {}): 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> = {}): 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");
});
});
});
+2 -74
View File
@@ -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"
+203
View File
@@ -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> = {}): 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> = {}): 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");
});
});
+74
View File
@@ -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";
}