Files
paseo/packages/server/src/server/plugins/index.posix.test.ts
T
Dominic 919e44b319 fix(server): start configured plugins concurrently (#8427)
The daemon accepts connections only after its configured plugins have
started, and PluginService.start started them one at a time, so each
plugin's build and process launch added to daemon startup. Configured
plugins now start together. Their providers and usage sources are still
published in config order: a plugin that finishes early waits for the
plugins listed before it, so when two plugins claim the same provider or
usage-source ID, the one listed first keeps it, as before. A failing
plugin still fails on its own.

Fixes #7979
2026-11-01 12:59:34 +00:00

1808 lines
69 KiB
TypeScript

import { randomUUID } from "node:crypto";
import {
cp,
rename,
mkdir,
mkdtemp,
readFile,
readdir,
rm,
stat,
writeFile,
} from "node:fs/promises";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import pino from "pino";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { DaemonConfigStore } from "../daemon-config-store.js";
import { PluginService } from "./index.js";
import { BuiltinPluginLoader } from "./builtin/index.js";
import { ManagedPluginSources } from "./managed-source.js";
import type { PluginProviderMetadata } from "./plugin-process-protocol.js";
import {
startNpmRegistry,
npmPluginPackages,
} from "../../../../../scripts/test-support/npm-registry.mjs";
import { runGitCommand } from "../../utils/run-git-command.js";
const roots: string[] = [];
type TestPluginRuntime = NonNullable<ConstructorParameters<typeof PluginService>[3]["runtime"]>;
const emptyUsageRuntime = {
getUsageSourceRegistrations: () => [],
fetchUsage: async () => undefined,
discoverUsage: async () => [],
} satisfies Pick<TestPluginRuntime, "getUsageSourceRegistrations" | "fetchUsage" | "discoverUsage">;
async function createPlugin(id: string, source: string): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-service-"));
roots.push(directory);
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id }));
await writeFile(path.join(directory, "index.server.ts"), source);
return directory;
}
function createStore(
home: string,
plugins: Record<string, { source: "directory"; path: string; enabled?: boolean }> = {},
): DaemonConfigStore {
return new DaemonConfigStore(home, {
mcp: { injectIntoAgents: true },
browserTools: { enabled: false },
providers: {},
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
pluginsEnabled: true,
plugins,
});
}
function createService(
home: string,
plugins: Record<string, { source: "directory"; path: string; enabled?: boolean }> = {},
dependencies: ConstructorParameters<typeof PluginService>[3] = {},
): PluginService {
return bindTestSessionHost(
new PluginService(pino({ level: "silent" }), createStore(home, plugins), "0.4.0", dependencies),
);
}
function bindTestSessionHost(service: PluginService): PluginService {
service.bindPaseoSessionHost({
async attachPluginSocket(_pluginId, socket) {
const closed = new Promise<void>((resolve) => socket.once("close", resolve));
socket.on("message", (data) => {
if (typeof data !== "string") return;
const message = JSON.parse(data);
if (message.type !== "hello") return;
socket.send(
JSON.stringify({
type: "session",
message: {
type: "status",
payload: {
status: "server_info",
serverId: "plugin-service-test",
hostname: "plugin-service-test",
version: "0.4.0",
features: {},
},
},
}),
);
});
return { closed };
},
});
return service;
}
function catalogIds(service: PluginService): string[] {
return service
.catalog()
.map(({ id }) => id)
.sort();
}
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
function createPausedRuntime() {
let releaseStart = () => undefined;
let markStarted = () => undefined;
const startGate = new Promise<void>((resolve) => {
releaseStart = resolve;
});
const started = new Promise<void>((resolve) => {
markStarted = resolve;
});
const running = new Set<string>();
const runtime: TestPluginRuntime = {
...emptyUsageRuntime,
catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })),
invoke: async () => undefined,
getLogs: () => [],
clearLogs: () => undefined,
startPlugin: async (pluginId, _path, canPublish) => {
markStarted();
await startGate;
if (!canPublish()) throw new Error(`Plugin start cancelled: ${pluginId}`);
running.add(pluginId);
},
stopPluginById: async (pluginId) => running.delete(pluginId),
stopAll: async () => {
running.clear();
},
subscribe: () => () => undefined,
bindPaseoSessionHost: () => undefined,
};
return { runtime, started, releaseStart };
}
function createPluginSelectivePausedRuntime(pausedPluginId: string) {
let releaseStart = () => undefined;
let markStarted = () => undefined;
const startGate = new Promise<void>((resolve) => {
releaseStart = resolve;
});
const started = new Promise<void>((resolve) => {
markStarted = resolve;
});
const starts: string[] = [];
const running = new Set<string>();
const runtime: TestPluginRuntime = {
...emptyUsageRuntime,
catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })),
invoke: async () => undefined,
getLogs: () => [],
clearLogs: () => undefined,
startPlugin: async (pluginId, _path, canPublish) => {
starts.push(pluginId);
if (pluginId === pausedPluginId) {
markStarted();
await startGate;
}
if (!canPublish()) throw new Error(`Plugin start cancelled: ${pluginId}`);
running.add(pluginId);
},
stopPluginById: async (pluginId) => running.delete(pluginId),
stopAll: async () => {
running.clear();
},
subscribe: () => () => undefined,
bindPaseoSessionHost: () => undefined,
};
return { runtime, started, releaseStart, starts };
}
function createGatedRuntime(providers: Record<string, readonly PluginProviderMetadata[]> = {}) {
const gates = new Map<string, PromiseWithResolvers<void>>();
const gate = (pluginId: string) => {
const existing = gates.get(pluginId);
if (existing) return existing;
const created = Promise.withResolvers<void>();
gates.set(pluginId, created);
return created;
};
const begun: string[] = [];
const running = new Set<string>();
const runtime: TestPluginRuntime = {
...emptyUsageRuntime,
catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })),
invoke: async () => undefined,
getLogs: () => [],
clearLogs: () => undefined,
getProviderRegistrations: (pluginId) =>
running.has(pluginId) ? (providers[pluginId] ?? []) : [],
startPlugin: async (pluginId) => {
begun.push(pluginId);
await gate(pluginId).promise;
running.add(pluginId);
},
stopPluginById: async (pluginId) => running.delete(pluginId),
stopAll: async () => {
running.clear();
},
subscribe: () => () => undefined,
bindPaseoSessionHost: () => undefined,
};
return {
runtime,
begun,
finish: (pluginId: string) => gate(pluginId).resolve(),
fail: (pluginId: string, error: Error) => gate(pluginId).reject(error),
};
}
function nextNotification(service: PluginService, pluginId: string): Promise<void> {
return new Promise((resolve) => {
const unsubscribe = service.subscribe((notified) => {
if (notified !== pluginId) return;
unsubscribe();
resolve();
});
});
}
describe("PluginService", () => {
it.each([
{ shadow: false, runtime: "subprocess" },
{ shadow: true, runtime: "subprocess" },
{ shadow: false, runtime: "builtin" },
{ shadow: true, runtime: "builtin" },
])(
"publishes status and launch metadata through $runtime despite a configured provider (extends: $shadow)",
async ({ shadow, runtime }) => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const directory = await createPlugin(
"provider-status",
`export default function contribute(server) {
server.registerProvider({ id: "plugin-agent", label: "Plugin agent", command: ["agent", "serve"],
async status({ launch }) { return launch.env.LOGIN === "yes" ? { available: true } : { available: false, diagnostic: "Sign in" }; },
async getCatalogCacheKey({ launch }) { return launch.command + ":" + launch.env.LOGIN; },
async connect({ launch }) {
if (launch.command !== process.execPath || launch.env.LOGIN !== "no") throw new Error("Unexpected launch");
return { version: 1, capabilities: [], async send() {}, onEvent() { return () => {}; }, async close() {} };
},
});
return () => {};
}`,
);
const store = createStore(home);
await store.patch({
providers: {
"plugin-agent": shadow
? { extends: "acp", label: "Own provider", command: ["own-agent"] }
: { command: ["configured-agent"], enabled: false },
},
});
const builtinRoot = path.join(home, "builtins");
if (runtime === "builtin") {
await mkdir(builtinRoot);
await cp(directory, path.join(builtinRoot, "provider-status"), { recursive: true });
}
const builtinPlugins = new BuiltinPluginLoader(
builtinRoot,
runtime === "builtin" ? ["provider-status"] : [],
);
const service = bindTestSessionHost(
new PluginService(pino({ level: "silent" }), store, "0.4.0", { builtinPlugins }),
);
await service.start();
try {
if (runtime === "subprocess") await service.installDirectory({ path: directory });
const [registration] = service.getProviderRegistrations();
expect(registration).toMatchObject({
id: "plugin-agent",
command: ["agent", "serve"],
status: expect.any(Function),
});
const launch = { command: process.execPath, args: [], env: { LOGIN: "no" } };
expect(await registration!.getCatalogCacheKey!({ scope: "global", launch })).toBe(
process.execPath + ":no",
);
expect(await registration!.status!({ launch })).toEqual({
available: false,
diagnostic: "Sign in",
});
expect(
await registration!.status!({ launch: { ...launch, env: { LOGIN: "yes" } } }),
).toEqual({ available: true });
const connection = await registration!.connect({ versions: [1], capabilities: [], launch });
await connection.close();
} finally {
await service.stopAllPlugins();
}
},
);
it("resolves a provider icon path to sanitized inline SVG", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const directory = await createPlugin(
"provider-icon",
`export default function contribute(server) {
server.registerProvider({
id: "plugin-agent",
label: "Plugin agent",
icon: "icon.svg",
async getCatalogCacheKey(options) { return options.scope === "workspace" ? "runtime:" + options.cwd : undefined; },
async connect() { throw new Error("not opened by this test"); },
});
return () => {};
}`,
);
const iconSvg = '<svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z" /></svg>';
await writeFile(path.join(directory, "icon.svg"), iconSvg);
const service = createService(home);
await service.start();
await service.installDirectory({ path: directory });
expect(service.getProviderRegistrations()).toMatchObject([
{ id: "plugin-agent", icon: iconSvg, getCatalogCacheKey: expect.any(Function) },
]);
const provider = service.getProviderRegistrations()[0]!;
expect(await provider.getCatalogCacheKey!({ scope: "workspace", cwd: "/project-a" })).toBe(
"runtime:/project-a",
);
expect(
await provider.getCatalogCacheKey!({ scope: "workspace", cwd: "/project-b", force: true }),
).toBe("runtime:/project-b");
expect(await provider.getCatalogCacheKey!({ scope: "global" })).toBeUndefined();
});
it("publishes provider registrations only while their plugin is running", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const directory = await createPlugin(
"provider-lifecycle",
`export default function contribute(server) {
server.registerProvider({
id: "plugin-agent",
label: "Plugin agent",
async connect() { throw new Error("not opened by this test"); },
});
return () => {};
}`,
);
const service = createService(home);
await service.start();
await service.installDirectory({ path: directory });
expect(service.getProviderRegistrations()).toMatchObject([
{ id: "plugin-agent", label: "Plugin agent" },
]);
await service.disablePlugin("provider-lifecycle");
expect(service.getProviderRegistrations()).toEqual([]);
});
it("retains logs when disabled and clears them only when removed", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const entries = [
{
sequence: 1,
timestamp: "2026-08-16T12:00:00.000Z",
stream: "stdout" as const,
message: "ready",
},
];
const cleared: string[] = [];
const runtime: TestPluginRuntime = {
...emptyUsageRuntime,
catalog: () => [],
invoke: async () => undefined,
getLogs: () => entries,
clearLogs: (pluginId) => {
cleared.push(pluginId);
entries.length = 0;
},
startPlugin: async () => undefined,
stopPluginById: async () => false,
stopAll: async () => undefined,
subscribe: () => () => undefined,
bindPaseoSessionHost: () => undefined,
};
const service = createService(
home,
{ example: { source: "directory", path: "/plugins/example", enabled: false } },
{ runtime },
);
expect(service.getLogs("example")).toEqual(entries);
await service.disablePlugin("example");
expect(service.getLogs("example")).toEqual(entries);
expect(cleared).toEqual([]);
await service.removePlugin("example");
expect(cleared).toEqual(["example"]);
expect(entries).toEqual([]);
});
it("publishes each configured plugin after its startup state settles", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const first = await createPlugin(
"startup-first",
`export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`,
);
const second = await createPlugin(
"startup-second",
`export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`,
);
const service = createService(home, {
"startup-first": { source: "directory", path: first },
"startup-second": { source: "directory", path: second },
});
const notifications: { pluginId: string; running: boolean }[] = [];
service.subscribe((pluginId) =>
notifications.push({ pluginId, running: catalogIds(service).includes(pluginId) }),
);
await service.start();
expect(notifications).toEqual([
{ pluginId: "startup-first", running: true },
{ pluginId: "startup-second", running: true },
]);
expect(catalogIds(service)).toEqual(["startup-first", "startup-second"]);
await service.stopAllPlugins();
}, 20_000);
it("starts configured plugins concurrently and publishes them in config order", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const gated = createGatedRuntime();
const service = createService(
home,
{
alpha: { source: "directory", path: "/plugins/alpha" },
beta: { source: "directory", path: "/plugins/beta" },
gamma: { source: "directory", path: "/plugins/gamma" },
},
{ runtime: gated.runtime },
);
const notified: string[] = [];
service.subscribe((pluginId) => notified.push(pluginId));
let started = false;
const starting = (async () => {
await service.start();
started = true;
})();
await expect.poll(() => gated.begun).toEqual(["alpha", "beta", "gamma"]);
gated.finish("gamma");
const alphaPublished = nextNotification(service, "alpha");
gated.finish("alpha");
await alphaPublished;
expect(notified).toEqual(["alpha"]);
expect(started).toBe(false);
gated.finish("beta");
await starting;
expect(notified).toEqual(["alpha", "beta", "gamma"]);
expect(catalogIds(service)).toEqual(["alpha", "beta", "gamma"]);
await service.stopAllPlugins();
});
it("starts the other configured plugins when one fails while they are still starting", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const gated = createGatedRuntime();
const service = createService(
home,
{
broken: { source: "directory", path: "/plugins/broken" },
healthy: { source: "directory", path: "/plugins/healthy" },
},
{ runtime: gated.runtime },
);
const notified: string[] = [];
service.subscribe((pluginId) => notified.push(pluginId));
const starting = service.start();
await expect.poll(() => gated.begun).toEqual(["broken", "healthy"]);
const brokenPublished = nextNotification(service, "broken");
gated.fail("broken", new Error("startup exploded"));
await brokenPublished;
gated.finish("healthy");
await starting;
expect(notified).toEqual(["broken", "healthy"]);
expect(catalogIds(service)).toEqual(["healthy"]);
expect(await service.listPlugins()).toMatchObject([
{ id: "broken", status: "failed", error: "startup exploded" },
{ id: "healthy", status: "running" },
]);
await service.stopAllPlugins();
});
it("keeps a provider ID with the plugin listed first when both start at once", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const gated = createGatedRuntime({
first: [{ id: "shared-agent", label: "First agent" }],
second: [{ id: "shared-agent", label: "Second agent" }],
});
const service = createService(
home,
{
first: { source: "directory", path: "/plugins/first" },
second: { source: "directory", path: "/plugins/second" },
},
{ runtime: gated.runtime },
);
const starting = service.start();
await expect.poll(() => gated.begun).toEqual(["first", "second"]);
gated.finish("second");
gated.finish("first");
await starting;
expect(service.getProviderRegistrations()).toMatchObject([
{ id: "shared-agent", label: "First agent" },
]);
expect(catalogIds(service)).toEqual(["first"]);
expect(await service.listPlugins()).toMatchObject([
{ id: "first", status: "running" },
{
id: "second",
status: "failed",
error: 'Plugin second cannot register provider ID "shared-agent" twice',
},
]);
await service.stopAllPlugins();
});
it("uses an explicit config key, exposes reload failure, and retries from disk", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const directory = await createPlugin(
"manifest-default",
`export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`,
);
const store = createStore(home);
const service = bindTestSessionHost(
new PluginService(pino({ level: "silent" }), store, "0.4.0"),
);
await service.start();
await expect(
service.installDirectory({ path: directory, id: "work-plugin" }),
).resolves.toMatchObject({
id: "work-plugin",
status: "running",
});
await expect(service.installDirectory({ path: directory, id: "work-plugin" })).rejects.toThrow(
"choose another ID with --id",
);
await writeFile(path.join(directory, "index.server.ts"), "export default broken syntax !!!");
await expect(service.reloadPlugin("work-plugin")).rejects.toThrow();
expect(service.catalog()).toEqual([]);
expect(await service.listPlugins()).toMatchObject([
expect.objectContaining({ id: "work-plugin", status: "failed", error: expect.any(String) }),
]);
await writeFile(
path.join(directory, "index.server.ts"),
`export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`,
);
await expect(service.reloadPlugin("work-plugin")).resolves.toMatchObject({ status: "running" });
await service.stopAllPlugins();
}, 20_000);
it("lists manifest metadata for running and disabled plugins without hiding malformed entries", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const running = await createPlugin("running", "export default () => () => {};");
const disabled = await createPlugin("disabled", "export default () => () => {};");
const malformed = await createPlugin("malformed", "export default () => () => {};");
await writeFile(
path.join(running, "paseo-plugin.json"),
JSON.stringify({
id: "running",
description: "Runs checks",
name: "Checks",
icon: "icon.png",
media: ["screenshot.png", "https://example.com/demo.mp4"],
}),
);
await writeFile(
path.join(disabled, "paseo-plugin.json"),
JSON.stringify({ id: "disabled", description: "Waits until enabled" }),
);
await writeFile(path.join(malformed, "paseo-plugin.json"), "{");
const service = createService(home, {
running: { source: "directory", path: running },
disabled: { source: "directory", path: disabled, enabled: false },
malformed: { source: "directory", path: malformed },
});
await service.start();
expect(
(await service.listPlugins()).map(({ id, description, name, icon, media }) => ({
id,
description,
name,
icon,
media,
})),
).toEqual([
{ id: "disabled", description: "Waits until enabled" },
{ id: "malformed", description: undefined },
{
id: "running",
description: "Runs checks",
name: "Checks",
icon: "icon.png",
media: ["screenshot.png", "https://example.com/demo.mp4"],
},
]);
await service.stopAllPlugins();
});
it("resolves a bare registry id even when a matching directory exists in the daemon cwd", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
const owner = path.join(process.cwd(), `registry-shadow-${randomUUID()}`);
await mkdir(owner);
roots.push(home, owner);
const source = `${path.basename(owner)}/example`;
const repository = await createPlugin("registry-example", "export default () => () => {};\n");
await mkdir(path.join(owner, "example"));
await cp(repository, path.join(owner, "example"), { recursive: true });
await runGitCommand(["init", "-b", "main"], { cwd: repository });
await runGitCommand(["add", "-A"], { cwd: repository });
await runGitCommand(
[
"-c",
"user.name=Paseo Tests",
"-c",
"user.email=paseo@example.test",
"commit",
"-m",
"fixture",
],
{ cwd: repository },
);
const { stdout } = await runGitCommand(["rev-parse", "HEAD"], { cwd: repository });
const requests: Array<string | undefined> = [];
const server = createServer((request, response) => {
requests.push(request.url);
response.setHeader("Content-Type", "application/json");
response.end(
JSON.stringify({
id: source,
name: "Example",
description: "Test",
categories: [],
author: { github: path.basename(owner) },
repository: { url: "https://github.com/fixture/example" },
artifact: {
kind: "git",
remote: pathToFileURL(repository).href,
commit: stdout.trim(),
pluginPath: ".",
},
media: [],
submittedAt: "2026-10-03",
reviewedAt: "2026-10-03",
updatedAt: "2026-10-03",
publishedAt: "2026-10-03",
readme: "# Test",
}),
);
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing registry address");
const url = `http://127.0.0.1:${address.port}`;
const store = createStore(home);
await store.patch({ pluginsEnabled: false });
const service = bindTestSessionHost(
new PluginService(pino({ level: "silent" }), store, "0.4.0", {
managedSources: new ManagedPluginSources(home, { defaultUrl: url }),
}),
);
try {
await service.start();
await expect(service.installSource({ source })).resolves.toMatchObject({
id: "registry-example",
status: "disabled",
installation: {
identity: { kind: "git", registry: { url, id: source } },
currentRevision: stdout.trim(),
},
});
expect(requests).toEqual([`/plugins/${source}.json`]);
await expect(
service.installSource({ source: `./${source}`, id: "local-example" }),
).resolves.toMatchObject({
installation: { identity: { kind: "directory", path: path.join(owner, "example") } },
});
expect(requests).toEqual([`/plugins/${source}.json`]);
} finally {
await service.stopAllPlugins();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}, 30_000);
it("names a missing explicit directory instead of trying a managed source", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const directory = path.join(home, "missing");
const service = createService(home);
await expect(service.installSource({ source: directory })).rejects.toThrow(
`Plugin directory does not exist: ${directory}`,
);
});
it("installs an explicit directory and its selected plugin subdirectory", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const repository = await mkdtemp(path.join(tmpdir(), "owner-repository-"));
roots.push(repository);
const pluginDirectory = path.join(repository, "plugins", "review");
await mkdir(pluginDirectory, { recursive: true });
await writeFile(
path.join(pluginDirectory, "paseo-plugin.json"),
JSON.stringify({ id: "local-monorepo" }),
);
await writeFile(
path.join(pluginDirectory, "index.server.ts"),
"export default function contribute(plugin: unknown) { void plugin; return () => undefined; }",
);
const service = createService(home);
await service.start();
await expect(
service.installSource({ source: `${repository}:plugins/review` }),
).resolves.toMatchObject({ id: "local-monorepo", path: pluginDirectory, status: "running" });
await service.stopAllPlugins();
}, 20_000);
it.each([true, false])(
"updates the shipped legacy Git layout with enabled=%s",
async (enabled) => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-legacy-layout-"));
roots.push(home);
const repository = await mkdtemp(path.join(tmpdir(), "paseo-legacy-remote-"));
roots.push(repository);
const id = "legacy-review";
const pluginPath = "plugins/review";
await mkdir(path.join(repository, pluginPath), { recursive: true });
await writeFile(
path.join(repository, pluginPath, "paseo-plugin.json"),
JSON.stringify({ id, requirements: { paseo: ">=0.8.0" } }),
);
await writeFile(
path.join(repository, pluginPath, "index.server.ts"),
`import { defineSettings } from "@getpaseo/plugin";
import { z } from "zod";
import type { PluginServerContext } from "@getpaseo/plugin/server";
export default function contribute(server: PluginServerContext) {
server.registerSettings(defineSettings({ id: "preferences", scope: "host", version: 1, schema: z.object({ message: z.string().default("default") }) }));
return () => {};
}`,
);
for (const args of [
["init", "-b", "main"],
["add", "."],
["-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "initial"],
["tag", "v1"],
])
await runGitCommand(args, { cwd: repository });
const initial = (
await runGitCommand(["rev-parse", "HEAD"], { cwd: repository })
).stdout.trim();
const remote = pathToFileURL(repository).href;
const legacyRoot = path.join(home, "plugins", id, `${initial.slice(0, 12)}-${randomUUID()}`);
const checkoutRoot = path.join(legacyRoot, "checkout");
const directory = path.join(checkoutRoot, pluginPath);
await mkdir(legacyRoot, { recursive: true });
await runGitCommand(["clone", remote, checkoutRoot], { cwd: home });
await runGitCommand(["checkout", "--detach", "v1"], { cwd: checkoutRoot });
await writeFile(
path.join(home, "plugins", "sources.json"),
JSON.stringify({
[id]: {
remote,
requestedRef: "v1",
trackingBranch: null,
commit: "f".repeat(40),
pluginPath,
checkoutRoot,
},
}),
);
const settingsDirectory = path.join(home, "plugin-settings");
await mkdir(path.join(settingsDirectory, id), { recursive: true });
const settingsFile = path.join(settingsDirectory, id, "preferences.json");
const savedSettings = JSON.stringify({ version: 1, values: { message: "keep my settings" } });
await writeFile(settingsFile, savedSettings);
const store = createStore(home, { [id]: { source: "directory", path: directory, enabled } });
const open = () =>
bindTestSessionHost(
new PluginService(pino({ level: "silent" }), store, "0.8.0", {
managedSources: new ManagedPluginSources(home),
settingsDirectory,
}),
);
let service = open();
try {
await service.start();
const expected = {
identity: { kind: "git", remote, pluginPath },
currentRevision: initial,
};
await rename(repository, `${repository}-offline`);
try {
expect(await new ManagedPluginSources(home).describe(id, directory)).toEqual(expected);
expect(await service.listPlugins()).toMatchObject([
{ id, enabled, status: enabled ? "running" : "disabled", installation: expected },
]);
} finally {
await rename(`${repository}-offline`, repository);
}
await service.stopAllPlugins();
service = open();
await service.start();
await runGitCommand(["checkout", "-b", "new-default"], { cwd: repository });
await writeFile(path.join(repository, pluginPath, "revision.txt"), "new default");
for (const args of [
["add", "."],
["-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "update"],
])
await runGitCommand(args, { cwd: repository });
const latest = (
await runGitCommand(["rev-parse", "HEAD"], { cwd: repository })
).stdout.trim();
const [preview] = await service.previewUpdates({ pluginId: id });
expect(preview).toMatchObject({
outcome: "update",
current: expected,
target: { kind: "git", commit: latest },
});
const [updated] = await service.applyUpdates([preview!.proposal!]);
expect(updated).toMatchObject({
outcome: "updated",
plugin: {
id,
enabled,
status: enabled ? "running" : "disabled",
installation: { identity: expected.identity, currentRevision: latest },
},
});
await expect(stat(legacyRoot)).rejects.toMatchObject({ code: "ENOENT" });
expect(await readFile(settingsFile, "utf8")).toBe(savedSettings);
await service.disablePlugin(id);
await service.enablePlugin(id);
expect(await service.reloadPlugin(id)).toMatchObject({ status: "running" });
expect(await service.invokePluginRpc(id, "settings.preferences.read", {})).toMatchObject({
status: "ready",
values: { message: "keep my settings" },
});
await service.removePlugin(id);
await expect(stat(path.join(home, "plugins", id))).rejects.toMatchObject({
code: "ENOENT",
});
expect(await stat(repository)).toBeDefined();
const sources = new ManagedPluginSources(home);
// A record cannot confer ownership on another plugin's tree or a malformed version name.
sources.commit(id, { kind: "git", remote });
for (const outside of [
path.join(home, "plugins", "other", path.basename(legacyRoot), "checkout"),
path.join(home, "plugins", id, "f".repeat(36), "checkout"),
path.join(home, "plugins", id, `${initial.slice(0, 12)}-invalid`, "checkout"),
])
await expect(sources.describe(id, outside)).rejects.toThrow(
"outside its managed installation",
);
} finally {
await service.stopAllPlugins();
}
},
60_000,
);
it("keeps the running commit when a Git update build command fails", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const repository = await mkdtemp(path.join(tmpdir(), "paseo-plugin-repository-"));
roots.push(repository);
await runGitCommand(["init", "-b", "main"], { cwd: repository });
await runGitCommand(["config", "user.name", "Paseo Tests"], { cwd: repository });
await runGitCommand(["config", "user.email", "paseo@example.test"], { cwd: repository });
await writeFile(
path.join(repository, "paseo-plugin.json"),
JSON.stringify({ id: "git-update" }),
);
await writeFile(
path.join(repository, "index.server.ts"),
"export default function contribute(plugin: unknown) { void plugin; return () => undefined; }",
);
await runGitCommand(["add", "-A"], { cwd: repository });
await runGitCommand(["commit", "-m", "initial"], { cwd: repository });
const store = createStore(home);
const service = bindTestSessionHost(
new PluginService(pino({ level: "silent" }), store, "0.4.0", {
managedSources: new ManagedPluginSources(home),
}),
);
await service.start();
const installed = await service.installSource({ source: pathToFileURL(repository).href });
const installedPath = installed.path;
const installedCommit = installed.commit;
await writeFile(
path.join(repository, "paseo-plugin.json"),
JSON.stringify({
id: "git-update",
build: [
[process.execPath, "-e", 'process.stderr.write("build exploded") ; process.exit(1)'],
],
}),
);
await writeFile(
path.join(repository, "index.server.ts"),
'export default function contribute(plugin: unknown) { void plugin; throw new Error("broken update"); }',
);
await runGitCommand(["add", "-A"], { cwd: repository });
await runGitCommand(["commit", "-m", "broken update"], { cwd: repository });
expect(await applyReviewedUpdate(service, "git-update")).toMatchObject([
{ outcome: "error", error: expect.stringContaining("build exploded") },
]);
expect(await readdir(path.join(home, "plugins", ".staging"))).toEqual([]);
expect(service.catalog()).toEqual([
expect.objectContaining({ id: "git-update", clientBundle: expect.any(String) }),
]);
expect(await service.listPlugins()).toMatchObject([
expect.objectContaining({
id: "git-update",
path: installedPath,
commit: installedCommit,
status: "running",
}),
]);
await service.stopAllPlugins();
}, 30_000);
it("runs Git build commands in staging before validation and activation on install and update", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const repository = await mkdtemp(path.join(tmpdir(), "paseo-plugin-repository-"));
roots.push(repository);
await runGitCommand(["init", "-b", "main"], { cwd: repository });
await runGitCommand(["config", "user.name", "Paseo Tests"], { cwd: repository });
await runGitCommand(["config", "user.email", "paseo@example.test"], { cwd: repository });
await writeFile(
path.join(repository, "paseo-plugin.json"),
JSON.stringify({
id: "prepared-git-plugin",
build: [
[
process.execPath,
"-e",
'require("node:fs").writeFileSync(process.argv[1], "prepared")',
"build-marker;touch shell-injection",
],
],
}),
);
await writeFile(path.join(repository, "index.server.ts"), "export default () => () => {};\n");
await runGitCommand(["add", "-A"], { cwd: repository });
await runGitCommand(["commit", "-m", "initial"], { cwd: repository });
const events: string[] = [];
const running = new Set<string>();
const runtime: TestPluginRuntime = {
...emptyUsageRuntime,
catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })),
invoke: async () => undefined,
getLogs: () => [],
clearLogs: () => undefined,
validatePlugin: async (directory) => {
events.push(
`validate:${await readFile(path.join(directory, "build-marker;touch shell-injection"), "utf8")}`,
);
},
startPlugin: async (pluginId) => {
events.push("start");
running.add(pluginId);
},
stopPluginById: async (pluginId) => running.delete(pluginId),
stopAll: async () => running.clear(),
subscribe: () => () => undefined,
bindPaseoSessionHost: () => undefined,
};
const service = createService(
home,
{},
{
runtime,
managedSources: new ManagedPluginSources(home),
},
);
await service.start();
const installed = await service.installSource({ source: pathToFileURL(repository).href });
expect(events).toEqual(["validate:prepared", "start"]);
await expect(stat(path.join(installed.path, "shell-injection"))).rejects.toThrow();
await writeFile(
path.join(repository, "paseo-plugin.json"),
JSON.stringify({
id: "prepared-git-plugin",
build: [
[
process.execPath,
"-e",
'require("node:fs").writeFileSync("build-marker;touch shell-injection", "updated")',
],
],
}),
);
await runGitCommand(["add", "-A"], { cwd: repository });
await runGitCommand(["commit", "-m", "prepared update"], { cwd: repository });
await expect(applyReviewedUpdate(service, "prepared-git-plugin")).resolves.toEqual([
expect.objectContaining({ id: "prepared-git-plugin", outcome: "updated" }),
]);
expect(events).toEqual(["validate:prepared", "start", "validate:updated", "start"]);
await service.stopAllPlugins();
}, 30_000);
it("activates an update when the enabled plugin previously failed to start", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const repository = await mkdtemp(path.join(tmpdir(), "paseo-plugin-repository-"));
roots.push(repository);
await runGitCommand(["init", "-b", "main"], { cwd: repository });
await runGitCommand(["config", "user.name", "Paseo Tests"], { cwd: repository });
await runGitCommand(["config", "user.email", "paseo@example.test"], { cwd: repository });
await writeFile(
path.join(repository, "paseo-plugin.json"),
JSON.stringify({ id: "failed-update" }),
);
await writeFile(path.join(repository, "index.server.ts"), "export default () => () => {};\n");
await runGitCommand(["add", "-A"], { cwd: repository });
await runGitCommand(["commit", "-m", "initial"], { cwd: repository });
const managedSources = new ManagedPluginSources(home);
let initial = await managedSources.prepareInstall({
source: pathToFileURL(repository).href,
});
initial = await managedSources.place("failed-update", initial);
managedSources.commit("failed-update", initial.record);
const running = new Set<string>();
const starts: string[] = [];
let failNextStart = true;
const runtime: TestPluginRuntime = {
...emptyUsageRuntime,
catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })),
invoke: async () => undefined,
getLogs: () => [],
clearLogs: () => undefined,
validatePlugin: async () => undefined,
startPlugin: async (pluginId, sourcePath, canPublish) => {
starts.push(sourcePath);
if (failNextStart) {
failNextStart = false;
throw new Error("initial start failed");
}
if (canPublish()) running.add(pluginId);
},
stopPluginById: async (pluginId) => running.delete(pluginId),
stopAll: async () => running.clear(),
subscribe: () => () => undefined,
bindPaseoSessionHost: () => undefined,
};
const store = createStore(home, {
"failed-update": { source: "directory", path: initial.directory, enabled: true },
});
const service = new PluginService(pino({ level: "silent" }), store, "0.4.0", {
runtime,
managedSources,
});
await service.start();
expect(await service.listPlugins()).toMatchObject([
expect.objectContaining({ id: "failed-update", status: "failed" }),
]);
await writeFile(
path.join(repository, "index.server.ts"),
"export default () => () => { new Date(); };\n",
);
await runGitCommand(["add", "-A"], { cwd: repository });
await runGitCommand(["commit", "-m", "fixed"], { cwd: repository });
await expect(applyReviewedUpdate(service, "failed-update")).resolves.toEqual([
expect.objectContaining({ id: "failed-update", outcome: "updated" }),
]);
expect(starts).toHaveLength(2);
expect(starts[1]).not.toBe(initial.directory);
expect(await service.listPlugins()).toMatchObject([
expect.objectContaining({ id: "failed-update", path: starts[1], status: "running" }),
]);
await service.stopAllPlugins();
}, 30_000);
it.each([false, true])(
"cleans a failed update when restoration also fails=%s",
async (failRestore) => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-recovery-"));
roots.push(home);
const repository = await createPlugin("recovery", "export default () => () => {};\n");
for (const args of [
["init", "-b", "main"],
["add", "."],
["-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "initial"],
])
await runGitCommand(args, { cwd: repository });
const running = new Set<string>();
let starts = 0;
const runtime: TestPluginRuntime = {
...emptyUsageRuntime,
catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })),
invoke: async () => undefined,
getLogs: () => [],
clearLogs: () => undefined,
validatePlugin: async () => undefined,
startPlugin: async (id) => {
starts++;
if (starts === 2) throw new Error("candidate activation failed");
if (starts === 3 && failRestore) throw new Error("prior activation failed");
running.add(id);
},
stopPluginById: async (id) => running.delete(id),
stopAll: async () => running.clear(),
subscribe: () => () => undefined,
bindPaseoSessionHost: () => undefined,
};
const service = createService(
home,
{},
{ runtime, managedSources: new ManagedPluginSources(home) },
);
await service.start();
const installed = await service.installSource({ source: pathToFileURL(repository).href });
await writeFile(path.join(repository, "revision.txt"), "new");
for (const args of [
["add", "."],
["-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "update"],
])
await runGitCommand(args, { cwd: repository });
const result = await applyReviewedUpdate(service, "recovery");
expect(result).toMatchObject([
{
outcome: "error",
error: expect.stringContaining(
failRestore ? "restoring previous plugin also failed" : "candidate activation failed",
),
},
]);
expect(await service.listPlugins()).toMatchObject([
{
path: installed.path,
enabled: true,
status: failRestore ? "failed" : "running",
installation: installed.installation,
},
]);
expect(await readdir(path.join(home, "plugins", "recovery"))).toEqual([
path.basename(path.dirname(installed.path)),
]);
expect(await readdir(path.join(home, "plugins", ".staging"))).toEqual([]);
await service.stopAllPlugins();
},
30_000,
);
it("disables and removes a plugin without touching its source directory", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const cleanupFile = path.join(home, "cleanup.txt");
const directory = await createPlugin(
"cleanup-plugin",
`import { writeFileSync } from "node:fs";
export default function contribute(plugin: unknown) {
void plugin;
return () => writeFileSync(${JSON.stringify(cleanupFile)}, "cleaned");
}`,
);
const store = createStore(home);
const service = bindTestSessionHost(
new PluginService(pino({ level: "silent" }), store, "0.4.0"),
);
await service.start();
await service.installDirectory({ path: directory });
await expect(service.disablePlugin("cleanup-plugin")).resolves.toMatchObject({
status: "disabled",
});
expect(await readFile(cleanupFile, "utf8")).toBe("cleaned");
await service.removePlugin("cleanup-plugin");
expect(await service.listPlugins()).toMatchObject([]);
await expect(stat(directory)).resolves.toMatchObject({});
await service.stopAllPlugins();
}, 20_000);
it("detaches every plugin synchronously when the global switch turns off and recovers", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const first = await createPlugin(
"first",
`export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`,
);
const second = await createPlugin(
"second",
`export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`,
);
// Hold cleanup open so synchronous detach and queued recovery are tested
// independently of compiler/process startup speed on the runner.
let finishCleanup = () => undefined;
const cleanup = new Promise<void>((resolve) => {
finishCleanup = resolve;
});
const running = new Set<string>();
const runtime: TestPluginRuntime = {
...emptyUsageRuntime,
catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })),
invoke: async (pluginId) => {
if (!running.has(pluginId)) throw new Error(`Plugin is not available: ${pluginId}`);
return pluginId;
},
getLogs: () => [],
clearLogs: () => undefined,
startPlugin: async (pluginId, _path, canPublish) => {
if (!canPublish()) throw new Error(`Plugin start cancelled: ${pluginId}`);
running.add(pluginId);
},
stopPluginById: async (pluginId) => {
const existed = running.delete(pluginId);
await cleanup;
return existed;
},
stopAll: async () => running.clear(),
subscribe: () => () => undefined,
bindPaseoSessionHost: () => undefined,
};
const store = createStore(home);
const service = bindTestSessionHost(
new PluginService(pino({ level: "silent" }), store, "0.4.0", { runtime }),
);
try {
await service.start();
await service.installDirectory({ path: first });
await service.installDirectory({ path: second });
expect(catalogIds(service)).toEqual(["first", "second"]);
await store.patch({ pluginsEnabled: false });
expect(service.catalog()).toEqual([]);
await expect(service.invokePluginRpc("second", "anything", {})).rejects.toThrow(
"Plugin is not available",
);
await store.patch({ pluginsEnabled: true });
expect(service.catalog()).toEqual([]);
finishCleanup();
// Observe recovery itself. Reloading here restarts a healthy plugin and
// would also conceal a failure to restart it after the global enable.
await expect
.poll(() => service.listPlugins(), { timeout: 10_000 })
.toMatchObject([
{ id: "first", status: "running" },
{ id: "second", status: "running" },
]);
} finally {
finishCleanup();
await service.stopAllPlugins();
}
}, 20_000);
it("does not publish an in-flight start after a later global disable", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const store = createStore(home, {
slow: { source: "directory", path: "/plugins/slow", enabled: true },
});
await store.patch({ pluginsEnabled: false });
const paused = createPausedRuntime();
const service = new PluginService(pino({ level: "silent" }), store, "0.4.0", {
runtime: paused.runtime,
});
await service.start();
await store.patch({ pluginsEnabled: true });
await paused.started;
await store.patch({ pluginsEnabled: false });
paused.releaseStart();
await service.stopAllPlugins();
expect(service.catalog()).toEqual([]);
expect(await service.listPlugins()).toMatchObject([
{ id: "slow", path: "/plugins/slow", enabled: true, status: "disabled" },
]);
});
it("persists overlapping enable and disable without losing either source mutation", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const store = createStore(home, {
a: { source: "directory", path: "/plugins/a", enabled: false },
b: { source: "directory", path: "/plugins/b", enabled: true },
});
const paused = createPluginSelectivePausedRuntime("unused");
const service = new PluginService(pino({ level: "silent" }), store, "0.4.0", {
runtime: paused.runtime,
});
await service.start();
await Promise.all([service.enablePlugin("a"), service.disablePlugin("b")]);
expect(store.get().plugins).toEqual({
a: { source: "directory", path: "/plugins/a", enabled: true },
b: { source: "directory", path: "/plugins/b", enabled: false },
});
await service.stopAllPlugins();
});
it("clears a failed disable intent so the previous enabled state can publish", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const store = createStore(home, {
slow: { source: "directory", path: "/plugins/slow", enabled: false },
});
const paused = createPausedRuntime();
const service = new PluginService(pino({ level: "silent" }), store, "0.4.0", {
runtime: paused.runtime,
});
await service.start();
const enabling = service.enablePlugin("slow");
await paused.started;
await writeFile(path.join(home, "config.json"), "invalid JSON");
await expect(service.disablePlugin("slow")).rejects.toThrow("Invalid JSON");
paused.releaseStart();
await expect(enabling).resolves.toMatchObject({ enabled: true, status: "running" });
expect(service.catalog()).toEqual([{ id: "slow", clientBundle: "bundle" }]);
await service.stopAllPlugins();
});
it("does not publish an in-flight enable after a later plugin disable", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const store = createStore(home, {
slow: { source: "directory", path: "/plugins/slow", enabled: false },
});
const paused = createPausedRuntime();
const service = new PluginService(pino({ level: "silent" }), store, "0.4.0", {
runtime: paused.runtime,
});
await service.start();
const enabling = expect(service.enablePlugin("slow")).rejects.toThrow(
"Plugin start cancelled: slow",
);
await paused.started;
const disabling = service.disablePlugin("slow");
paused.releaseStart();
await enabling;
await expect(disabling).resolves.toMatchObject({ status: "disabled" });
expect(service.catalog()).toEqual([]);
});
it("keeps a later disable authoritative over an enable waiting behind another plugin", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const store = createStore(home, {
occupier: { source: "directory", path: "/plugins/occupier", enabled: false },
slow: { source: "directory", path: "/plugins/slow", enabled: false },
});
const paused = createPluginSelectivePausedRuntime("occupier");
const service = new PluginService(pino({ level: "silent" }), store, "0.4.0", {
runtime: paused.runtime,
});
await service.start();
const occupying = service.enablePlugin("occupier");
await paused.started;
const enabling = service.enablePlugin("slow");
const disabling = service.disablePlugin("slow");
paused.releaseStart();
await occupying;
await expect(enabling).resolves.toMatchObject({
id: "slow",
enabled: false,
status: "disabled",
});
await expect(disabling).resolves.toMatchObject({
id: "slow",
enabled: false,
status: "disabled",
});
expect(paused.starts).toEqual(["occupier"]);
expect(service.catalog()).toEqual([{ id: "occupier", clientBundle: "bundle" }]);
});
it("notifies exactly once after successful and failed configured installs", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const successful = await createPlugin(
"successful-install",
`export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`,
);
const failed = await createPlugin(
"failed-install",
`export default function contribute(plugin: unknown) { void plugin; throw new Error("startup exploded"); }`,
);
const service = createService(home);
const events: string[] = [];
service.subscribe((pluginId) => events.push(pluginId));
await service.start();
await service.installDirectory({ path: successful });
expect(events).toEqual(["successful-install"]);
events.length = 0;
await expect(service.installDirectory({ path: failed })).rejects.toThrow("startup exploded");
expect(events).toEqual(["failed-install"]);
await service.stopAllPlugins();
});
it("reports invalid manifests, missing entries, and startup failures", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const invalid = await createPlugin("valid-before-corruption", "export default () => () => {};");
await writeFile(path.join(invalid, "paseo-plugin.json"), JSON.stringify({}));
const missingEntry = await createPlugin("missing-entry", "export default () => () => {};");
await rm(path.join(missingEntry, "index.server.ts"));
const legacy = await createPlugin("legacy-plugin", "export default () => () => {};");
await rm(path.join(legacy, "index.server.ts"));
await writeFile(path.join(legacy, "index.ts"), "export default () => () => {};");
const startupFailure = await createPlugin(
"startup-failure",
`export default function contribute(plugin: unknown) { void plugin; throw new Error("startup exploded"); }`,
);
const service = createService(home);
await service.start();
await expect(service.installDirectory({ path: invalid })).rejects.toThrow();
await expect(service.installDirectory({ path: missingEntry })).rejects.toThrow(
"Plugin entry points are missing",
);
await expect(service.installDirectory({ path: legacy })).rejects.toThrow(
"This plugin was made for an older version of Paseo and cannot run on Paseo v0.8. Ask its author to update it. Plugin authors can follow the migration guide: https://paseo.sh/docs/plugins/migration",
);
await expect(service.installDirectory({ path: startupFailure })).rejects.toThrow(
"startup exploded",
);
expect(await service.listPlugins()).toMatchObject([
expect.objectContaining({
id: "legacy-plugin",
status: "failed",
error:
"This plugin was made for an older version of Paseo and cannot run on Paseo v0.8. Ask its author to update it. Plugin authors can follow the migration guide: https://paseo.sh/docs/plugins/migration",
}),
expect.objectContaining({
id: "missing-entry",
status: "failed",
error: expect.stringContaining("Plugin entry points are missing"),
}),
expect.objectContaining({
id: "startup-failure",
status: "failed",
error: "startup exploded",
}),
]);
await service.stopAllPlugins();
});
it("contains cleanup errors and invokes server cleanup once per stopped installation", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const cleanupFile = path.join(home, "cleanups.txt");
const directory = await createPlugin(
"cleanup-count",
`import { appendFileSync } from "node:fs";
export default function contribute(plugin: unknown) {
void plugin;
return () => {
appendFileSync(${JSON.stringify(cleanupFile)}, "cleanup\\n");
throw new Error("cleanup exploded");
};
}`,
);
const service = createService(home);
await service.start();
await service.installDirectory({ path: directory });
await service.reloadPlugin("cleanup-count");
await service.disablePlugin("cleanup-count");
await service.enablePlugin("cleanup-count");
await service.removePlugin("cleanup-count");
await service.installDirectory({ path: directory });
await service.stopAllPlugins();
expect((await readFile(cleanupFile, "utf8")).trim().split("\n")).toHaveLength(4);
});
});
describe("npm plugin installation", () => {
let registry: Awaited<ReturnType<typeof startNpmRegistry>>;
// Give real npm fixture construction its own setup budget so it does not consume
// the installation journey on Windows.
beforeAll(async () => {
const packages = npmPluginPackages();
const fixture = packages[1];
packages.push({ ...fixture, version: "2.0.0", tags: ["future"] });
const failures: Array<[string, Record<string, string>]> = [
["bad-manifest", { "paseo-plugin.json": "{}" }],
[
"bad-build",
{
"paseo-plugin.json": JSON.stringify({
id: "bad-build",
requirements: { paseo: ">=0.4.0" },
build: [[process.execPath, "-e", "throw new Error('preparation failed')"]],
}),
},
],
[
"bad-requirements",
{
"paseo-plugin.json": JSON.stringify({
id: "bad-requirements",
requirements: { paseo: ">=999.0.0" },
}),
},
],
["bad-code", { "index.server.ts": "invalid syntax !!!" }],
[
"bad-activation",
{ "index.server.ts": "export default () => { throw new Error('activation failed'); };" },
],
];
const failedPackages = [];
for (const [name, overrides] of failures) {
failedPackages.push({ ...fixture, name, files: { ...fixture.files, ...overrides } });
}
registry = await startNpmRegistry([
...packages,
{
name: "paseo-prepared-plugin",
version: "1.0.0",
dependencies: { "paseo-fixture-dependency": "1.0.0" },
scripts: { postinstall: "node -e \"throw new Error('npm lifecycle must not run')\"" },
files: {
"nested/paseo-plugin.json": JSON.stringify({
id: "prepared",
requirements: { paseo: ">=0.4.0" },
build: [
[
process.execPath,
"-e",
"require('node:fs').mkdirSync('server'); require('node:fs').writeFileSync('server/generated.js', 'export default 42;');",
],
],
}),
"nested/index.server.ts": `import generated from "./server/generated.js";
import dependency from "paseo-fixture-dependency";
import { z } from "zod";
export default function contribute() {
if (generated !== 42 || z.string().parse(dependency) !== "dependency loaded") throw new Error("Preparation failed");
return () => {};
}`,
},
},
...failedPackages,
]);
}, 120_000);
it("preserves dependencies and metadata, and reloads without npm", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-npm-service-"));
roots.push(home);
const previousConfig = process.env.npm_config_userconfig;
const previousPath = process.env.PATH;
process.env.npm_config_userconfig = registry.userconfig;
const store = createStore(home);
const open = () =>
bindTestSessionHost(
new PluginService(pino({ level: "silent" }), store, "0.8.0", {
managedSources: new ManagedPluginSources(home),
}),
);
let service = open();
try {
await service.start();
const installed = await service.installSource({ source: "npm:paseo-fixture-plugin@^1.0.0" });
expect(installed).toMatchObject({
id: "npm-review",
status: "running",
installation: {
identity: { kind: "npm", packageName: "paseo-fixture-plugin", pluginPath: "." },
currentRevision: "1.1.0",
},
});
expect(installed.commit).toBeUndefined();
expect(installed.remote).toBeUndefined();
expect(
service
.getLogs("npm-review")
.map((entry) => entry.message)
.join("\n"),
).toContain("dependency loaded");
const installRoot = path.dirname(path.dirname(installed.path));
expect(
JSON.parse(await readFile(path.join(home, "plugins", "sources.json"), "utf8"))[
"npm-review"
],
).toEqual({ kind: "npm" });
expect(
JSON.parse(await readFile(path.join(installRoot, "package-lock.json"), "utf8")).packages[
"node_modules/paseo-fixture-dependency"
].version,
).toBe("1.0.0");
for (const [source, id, version] of [
["npm:paseo-fixture-plugin@1.0.0", "exact", "1.0.0"],
["paseo-fixture-plugin@stable", "tag", "1.0.0"],
["npm:@paseo-fixture/review", "scoped", "2.0.0"],
]) {
expect(await service.installSource({ source, id })).toMatchObject({
id,
status: "running",
installation: { currentRevision: version },
});
}
const [preview] = await service.previewUpdates({ pluginId: "exact" });
expect(preview).toMatchObject({
outcome: "update",
current: { currentRevision: "1.0.0" },
target: { version: "1.1.0" },
});
const before = (await service.listPlugins()).find((item) => item.id === "exact")!;
await service.disablePlugin("exact");
registry.setTag("paseo-fixture-plugin", "latest", "2.0.0");
const results = await service.applyUpdates([
{ ...preview!.proposal!, id: "missing-plugin" },
preview!.proposal!,
]);
expect(results).toMatchObject([
{ outcome: "error" },
{
outcome: "updated",
plugin: { enabled: false, installation: { currentRevision: "1.1.0" } },
},
]);
expect(results[1]?.plugin?.installation?.identity).toEqual(before.installation?.identity);
registry.setTag("paseo-fixture-plugin", "latest", "1.1.0");
expect(await service.applyUpdates([preview!.proposal!])).toMatchObject([
{ outcome: "error", error: expect.stringContaining("changed since review") },
]);
await service.enablePlugin("exact");
const [future] = await service.previewUpdates({
pluginId: "exact",
target: { kind: "npm", version: "future" },
});
expect(await service.applyUpdates([future!.proposal!])).toMatchObject([
{ outcome: "updated", plugin: { installation: { currentRevision: "2.0.0" } } },
]);
const [newer] = await service.previewUpdates({ pluginId: "exact" });
expect(newer).toMatchObject({
outcome: "installed-newer",
current: { currentRevision: "2.0.0" },
target: { version: "1.1.0" },
});
expect(newer?.proposal).toBeUndefined();
const [downgrade] = await service.previewUpdates({
pluginId: "exact",
target: { kind: "npm", version: "1.0.0" },
});
const wrongArtifact = {
...downgrade!.proposal!,
target: { ...downgrade!.proposal!.target, integrity: "sha512-invalid" },
};
expect(await service.applyUpdates([wrongArtifact])).toMatchObject([{ outcome: "error" }]);
expect(await service.applyUpdates([downgrade!.proposal!])).toMatchObject([
{ outcome: "updated", plugin: { installation: { currentRevision: "1.0.0" } } },
]);
expect((await service.previewUpdates({ pluginId: "exact" }))[0]?.outcome).toBe("update");
await expect(service.updateSources("exact")).rejects.toThrow("Update the client");
const prepared = await service.installSource({
source: "npm:paseo-prepared-plugin@1.0.0:nested",
});
expect(prepared).toMatchObject({ id: "prepared", status: "running" });
expect(await readFile(path.join(prepared.path, "server/generated.js"), "utf8")).toBe(
"export default 42;",
);
expect(prepared.installation).toMatchObject({ identity: { pluginPath: "nested" } });
await service.removePlugin("prepared");
expect(registry.requests).not.toContain("/@getpaseo/plugin");
await expect(service.installSource({ source: "npm:missing-plugin" })).rejects.toThrow();
await expect(
service.installSource({ source: "npm:paseo-fixture-plugin", ref: "main" }),
).rejects.toThrow("--ref is only valid for Git");
await expect(service.installSource({ source: "npm:paseo-fixture-plugin" })).rejects.toThrow(
"already configured",
);
expect(await readdir(path.join(home, "plugins", ".staging"))).toEqual([]);
for (const name of [
"bad-manifest",
"bad-build",
"bad-requirements",
"bad-code",
"bad-activation",
]) {
await expect(service.installSource({ source: `npm:${name}`, id: name })).rejects.toThrow();
expect((await service.listPlugins()).map((plugin) => plugin.id)).toEqual([
"exact",
"npm-review",
"scoped",
"tag",
]);
expect(await readdir(path.join(home, "plugins", ".staging"))).toEqual([]);
}
await service.disablePlugin("scoped");
await service.stopAllPlugins();
const recordsPath = path.join(home, "plugins", "sources.json");
const oldRecords = JSON.parse(await readFile(recordsPath, "utf8"));
Object.assign(oldRecords["npm-review"], {
requestedSpec: "0.0.1",
version: "0.0.1",
packageName: "obsolete",
installRoot: "/obsolete",
integrity: "obsolete",
});
await writeFile(recordsPath, JSON.stringify(oldRecords));
process.env.PATH = path.join(home, "no-executables");
service = open();
await service.start();
expect(await service.listPlugins()).toMatchObject(
expect.arrayContaining([
expect.objectContaining({
id: "npm-review",
status: "running",
installation: installed.installation,
}),
expect.objectContaining({ id: "scoped", enabled: false, status: "disabled" }),
]),
);
expect(await service.reloadPlugin("npm-review")).toMatchObject({
status: "running",
installation: installed.installation,
});
await expect(
service.installSource({ source: "npm:paseo-fixture-plugin", id: "missing-npm" }),
).rejects.toThrow("npm is required on the daemon host");
expect(await readdir(path.join(home, "plugins", ".staging"))).toEqual([]);
const packagePath = path.join(installed.path, "package.json");
const packageJson = await readFile(packagePath, "utf8");
await writeFile(
packagePath,
JSON.stringify({ ...JSON.parse(packageJson), version: "99.0.0" }),
);
const corrupt = (await service.listPlugins()).find((plugin) => plugin.id === "npm-review")!;
expect(corrupt.status).toBe("running");
expect(corrupt.installation).toEqual({ identity: installed.installation!.identity });
expect((await service.previewUpdates({ pluginId: "npm-review" }))[0]).toMatchObject({
outcome: "error",
error: expect.stringContaining("Installed revision is unavailable"),
});
expect(await service.reloadPlugin("npm-review")).toMatchObject({ status: "running" });
await writeFile(packagePath, packageJson);
await service.removePlugin("npm-review");
await expect(stat(installRoot)).rejects.toMatchObject({ code: "ENOENT" });
expect(
JSON.parse(await readFile(path.join(home, "plugins", "sources.json"), "utf8"))[
"npm-review"
],
).toBeUndefined();
} finally {
process.env.PATH = previousPath;
if (previousConfig === undefined) delete process.env.npm_config_userconfig;
else process.env.npm_config_userconfig = previousConfig;
await service.stopAllPlugins();
await registry.close();
}
}, 120_000);
});
async function applyReviewedUpdate(service: PluginService, pluginId: string) {
const [preview] = await service.previewUpdates({ pluginId });
expect(preview?.outcome).toBe("update");
return service.applyUpdates([preview!.proposal!]);
}