Files
paseo/packages/cli/src/commands/plugin/index.test.ts
T
Mohamed Boudra 047f40e623 Install npm plugins and review source updates (#4975)
* feat(plugins): unify source installation and simplify plugin settings

* feat(plugins): install npm sources and review CLI updates

Treat install selectors as one-time choices. Keep source identity separate from the installed revision, and resolve ordinary updates from the current upstream default. npm only acquires directories; plugin loading remains independent of npm.

* fix(plugins): preserve CLI diagnostics and exercise reviewed updates

Move new source capability coverage to real browser journeys. Update the requirements regression to exercise preview and apply, and preserve the existing duplicate-selector diagnostic across positional option scopes.

* test(plugins): remove unused pending helper

* test(plugins): pack registry fixtures with the Windows command runner

* test(plugins): assert failed client contribution setup

* docs(plugins): consolidate guides and document npm publishing

* docs(plugins): introduce publishing choices before npm instructions

* test(plugins): own browser journey cleanup in a fixture

* feat(plugins): prepare scaffolded packages for publishing
2026-09-17 10:22:20 +02:00

388 lines
13 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" };
const listPlugins = vi.fn(async () => [
{
id: "git-plugin",
path: "/plugins/git-plugin",
enabled: true,
status: "running" as const,
source: "git" as const,
commit: "1557a34c91e2abcdef",
ref: "main",
installation: {
identity: {
kind: "git" as const,
remote: "https://example.test/plugin.git",
pluginPath: ".",
},
currentRevision: "1557a34c91e2abcdef",
},
},
{
id: "legacy-plugin",
path: "/plugins/legacy-plugin",
enabled: true,
status: "failed" as const,
error: "This plugin was made for an older version of Paseo",
},
]);
const getPluginLogs = vi.fn(async () => [
{
sequence: 1,
timestamp: "2026-08-16T12:00:00.000Z",
stream: "stdout" as const,
message: "ready",
},
]);
const installPluginSource = vi.fn(async () => ({
id: "trusted-plugin",
path: "/plugins/trusted-plugin",
enabled: true,
status: "running" as const,
}));
const installDirectoryPlugin = vi.fn(async () => ({
id: "legacy-plugin",
path: "/plugins/legacy-plugin",
enabled: true,
status: "running" as const,
}));
const previewPluginUpdates = vi.fn(async () => []);
const close = vi.fn(async () => undefined);
const features: {
pluginManagement?: boolean;
pluginLogs?: boolean;
pluginGitManagement?: boolean;
pluginSourceInstallation?: boolean;
pluginSourceUpdates?: boolean;
} = {};
vi.mock("../../utils/client.js", () => ({
connectToDaemon: vi.fn(async () => ({
getLastServerInfoMessage: () => ({ features }),
listPlugins,
getPluginLogs,
installDirectoryPlugin,
installPluginSource,
previewPluginUpdates,
close,
})),
}));
import { render } from "../../output/index.js";
import {
createPluginCommand,
runPluginInstallCommand,
runPluginUpdateCommand,
runPluginListCommand,
runPluginLogsCommand,
} from "./index.js";
describe("plugin management commands", () => {
beforeEach(() => {
features.pluginManagement = false;
features.pluginLogs = false;
features.pluginGitManagement = false;
features.pluginSourceInstallation = false;
features.pluginSourceUpdates = false;
vi.clearAllMocks();
});
it("requires host support before attempting a management RPC", async () => {
await expect(
runPluginListCommand(undefined, { daemonTarget }, {} as never),
).rejects.toMatchObject({
code: "DAEMON_UPDATE_REQUIRED",
message: "Update the host to use plugin management.",
});
expect(listPlugins).not.toHaveBeenCalled();
expect(close).toHaveBeenCalledTimes(1);
});
it("keeps status as a hidden alias for the network-free plugin list", async () => {
features.pluginManagement = true;
const command = createPluginCommand();
await command.parseAsync(["status"], { from: "user" });
expect(listPlugins).toHaveBeenCalledTimes(1);
expect(command.helpInformation()).not.toContain("status");
});
it("lists runtime state and the installed commit without an upstream commit", async () => {
features.pluginManagement = true;
const result = await runPluginListCommand(undefined, { daemonTarget }, {} as never);
const output = render(result, { noColor: true });
expect(output).toContain("SOURCE");
expect(output).toContain("REVISION");
expect(output).not.toContain("LATEST");
expect(output).toContain("1557a34c91e2");
expect(output).toContain("This plugin was made for an older version of Paseo");
});
it("filters the shared ls and status command by plugin ID", async () => {
features.pluginManagement = true;
const result = await runPluginListCommand("legacy-plugin", { daemonTarget }, {} as never);
expect(result.data.map((plugin) => plugin.id)).toEqual(["legacy-plugin"]);
});
it("requires plugin log support before attempting the RPC", async () => {
await expect(
runPluginLogsCommand("example", { daemonTarget }, {} as never),
).rejects.toMatchObject({
code: "DAEMON_UPDATE_REQUIRED",
message: "Update the host to view plugin logs.",
});
expect(getPluginLogs).not.toHaveBeenCalled();
});
it("returns readable and JSON plugin log output", async () => {
features.pluginLogs = true;
const result = await runPluginLogsCommand("example", { daemonTarget }, {} as never);
expect(getPluginLogs).toHaveBeenCalledWith("example");
expect(render(result, { noColor: true })).toContain("ready");
expect(JSON.parse(render(result, { format: "json" }))).toEqual([
{
sequence: 1,
timestamp: "2026-08-16T12:00:00.000Z",
stream: "stdout",
message: "ready",
},
]);
});
it("makes trust explicit at the plugin add entry point", () => {
const command = createPluginCommand();
expect(
command.commands.find((subcommand) => subcommand.name() === "install")?.description(),
).toContain("Trust and install");
expect(command.helpInformation()).toContain("trusted, unsandboxed plugins");
});
it("prints the trust acknowledgement before installing", async () => {
features.pluginSourceInstallation = true;
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
const command = createPluginCommand();
await command.parseAsync(["install", "/plugins/trusted-plugin"], { from: "user" });
expect(stderr).toHaveBeenCalledWith(
expect.stringContaining("preparation commands run unsandboxed on the daemon host"),
);
expect(installPluginSource).toHaveBeenCalledWith({ source: "/plugins/trusted-plugin" });
stderr.mockRestore();
});
it("requires source support for directory installs without using the legacy RPC", async () => {
features.pluginManagement = true;
features.pluginGitManagement = true;
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
await expect(
runPluginInstallCommand("/plugins/trusted-plugin", { daemonTarget }, {} as never),
).rejects.toMatchObject({
code: "DAEMON_UPDATE_REQUIRED",
message: "Update the host to install plugin sources.",
});
expect(installPluginSource).not.toHaveBeenCalled();
expect(installDirectoryPlugin).not.toHaveBeenCalled();
stderr.mockRestore();
});
it.each([undefined, false, true])(
"installs with source support and Git support=%s",
async (gitSupport) => {
features.pluginSourceInstallation = true;
features.pluginGitManagement = gitSupport;
await runPluginInstallCommand("npm:@acme/review@^1.0.0", { daemonTarget }, {} as never);
expect(installPluginSource).toHaveBeenCalledWith({ source: "npm:@acme/review@^1.0.0" });
expect(close).toHaveBeenCalledTimes(1);
},
);
it.each([undefined, false])(
"rejects installs before an RPC when source support=%s despite Git support",
async (sourceSupport) => {
features.pluginGitManagement = true;
features.pluginSourceInstallation = sourceSupport;
await expect(
runPluginInstallCommand("owner/review", { daemonTarget }, {} as never),
).rejects.toMatchObject({
code: "DAEMON_UPDATE_REQUIRED",
message: "Update the host to install plugin sources.",
});
expect(installPluginSource).not.toHaveBeenCalled();
expect(close).toHaveBeenCalledTimes(1);
},
);
it.each([undefined, false, true])(
"reviews updates with source install support=%s",
async (sourceSupport) => {
features.pluginSourceUpdates = true;
features.pluginSourceInstallation = sourceSupport;
await runPluginUpdateCommand("review", { daemonTarget }, {} as never);
expect(previewPluginUpdates).toHaveBeenCalledWith({ pluginId: "review", target: undefined });
expect(close).toHaveBeenCalledTimes(1);
},
);
it.each([undefined, false])(
"rejects reviewed updates when support=%s despite legacy Git support",
async (gitSupport) => {
features.pluginSourceInstallation = true;
features.pluginGitManagement = true;
features.pluginSourceUpdates = gitSupport;
await expect(
runPluginUpdateCommand("review", { daemonTarget }, {} as never),
).rejects.toMatchObject({
code: "DAEMON_UPDATE_REQUIRED",
message: "Update the host to review plugin updates.",
});
expect(previewPluginUpdates).not.toHaveBeenCalled();
expect(close).toHaveBeenCalledTimes(1);
},
);
it("folds the legacy --path option into the plugin source reference", async () => {
features.pluginSourceInstallation = true;
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
const command = createPluginCommand();
await command.parseAsync(["install", "owner/monorepo", "--path", "plugins/review"], {
from: "user",
});
expect(installPluginSource).toHaveBeenCalledWith({
source: "owner/monorepo:plugins/review",
});
stderr.mockRestore();
});
it("keeps an absolute monorepo path as one plugin source reference", async () => {
features.pluginSourceInstallation = true;
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
const command = createPluginCommand();
await command.parseAsync(["install", "/plugins/monorepo:plugins/review"], { from: "user" });
expect(installPluginSource).toHaveBeenCalledWith({
source: "/plugins/monorepo:plugins/review",
});
stderr.mockRestore();
});
});
import { reviewPluginUpdates, type UpdateInteraction } from "./update.js";
import type {
PluginUpdatePreview,
PluginUpdateProposal,
PluginUpdateResult,
} from "@getpaseo/protocol/messages";
function reviewFixture(answer = true, outcome: "update" | "installed-newer" = "update") {
const proposal: PluginUpdateProposal = {
id: "review",
expected: {
identity: { kind: "npm", packageName: "review", pluginPath: "." },
installationRoot: "/owned/one",
revision: "1.0.0",
},
target: {
kind: "npm",
version: "1.1.0",
resolved: "https://registry.npmjs.org/review.tgz",
integrity: "sha512-test",
},
};
const preview: PluginUpdatePreview = {
id: "review",
outcome,
links: ["https://npmjs.com/package/review/v/1.1.0"],
current: { identity: proposal.expected.identity, currentRevision: "1.0.0" },
target: proposal.target,
...(outcome === "update" ? { proposal } : {}),
};
const applied: PluginUpdateProposal[][] = [];
const questions: string[] = [];
const output: string[] = [];
const client = {
previewPluginUpdates: async () => [preview],
applyPluginUpdates: async (
proposals: PluginUpdateProposal[],
): Promise<PluginUpdateResult[]> => {
applied.push(proposals);
return [{ id: "review", outcome: "updated" }];
},
};
const interaction: UpdateInteraction = {
interactive: true,
structured: false,
write: (text) => {
output.push(text);
},
confirm: async (question) => {
questions.push(question);
return answer;
},
};
return { client, interaction, proposal, applied, questions, output };
}
describe("reviewed plugin update workflow", () => {
it("prints target and links, then declines without applying", async () => {
const f = reviewFixture(false);
expect(await reviewPluginUpdates(f.client, { pluginId: "review" }, f.interaction)).toEqual([
{ id: "review", outcome: "declined" },
]);
expect(f.output.join("")).toContain("1.0.0 → 1.1.0");
expect(f.output.join("")).toContain("Review: https://");
expect(f.questions).toEqual(["Update review? [y/N] "]);
expect(f.applied).toEqual([]);
});
it("applies exactly the displayed proposal after approval", async () => {
const f = reviewFixture();
await reviewPluginUpdates(f.client, { pluginId: "review" }, f.interaction);
expect(f.applied).toEqual([[f.proposal]]);
});
it.each([{ yes: true }, { version: "stable" }, { ref: "v1" }])(
"skips questions for %j",
async (options) => {
const f = reviewFixture();
await reviewPluginUpdates(f.client, { pluginId: "review", ...options }, f.interaction);
expect(f.questions).toEqual([]);
expect(f.applied).toEqual([[f.proposal]]);
},
);
it("check takes precedence over yes and explicit targets", async () => {
const f = reviewFixture();
await reviewPluginUpdates(
f.client,
{ pluginId: "review", check: true, yes: true, version: "1.1.0" },
f.interaction,
);
expect(f.applied).toEqual([]);
expect(f.questions).toEqual([]);
});
it("yes never applies an ordinary downgrade", async () => {
const f = reviewFixture(true, "installed-newer");
await reviewPluginUpdates(f.client, { all: true, yes: true }, f.interaction);
expect(f.applied).toEqual([]);
expect(f.questions).toEqual([]);
});
it.each([{ interactive: false }, { structured: true }])(
"requires explicit approval for %j",
async (flags) => {
const f = reviewFixture();
await expect(
reviewPluginUpdates(f.client, { pluginId: "review" }, { ...f.interaction, ...flags }),
).rejects.toThrow("Confirmation required");
expect(f.applied).toEqual([]);
},
);
});