Files
paseo/packages/cli/tests/e2e/plugin-lifecycle.test.ts
T
HelloThereEveryone 74c8172f8b Wait for a plugin reload to finish instead of failing at the request deadline (#6306)
* Wait for plugin reload completion instead of the request deadline

The daemon answers plugin.reload.request only once the plugin is running
again or has failed, and a reload queues behind other plugin lifecycle
work such as an install with a preparation command. The shared client
applied its sixty-second session RPC deadline to that request, so a slow
reload failed with "Timeout waiting for message" while the daemon kept
loading the plugin, inviting a reload the daemon was still running.

Reload now waits for the daemon's own answer. Enable and disable keep the
session deadline, and a real startup failure still reaches the caller.

* Cover a slow plugin reload end to end

The CLI fixture now holds its own git build on a gate file, starts a
reload of an installed plugin behind that queued install, observes the
reload request at the proxy, keeps the gate closed past the client's
former one-minute deadline, and then asserts the reload's real result:
CLI exit 0, the plugin running, and exactly one reactivation. Listing the
plugin during the wait stays readable and does not restart it. A plugin
that cannot start still fails its reload with the daemon's error.

The request proxy that recorded install sources becomes a small observer
that records any session request, so the wait is measured from the
request boundary rather than from process start.

Verified: npx tsx packages/cli/tests/e2e/plugin-lifecycle.test.ts exits 0
in 135 s; the same fixture without the new scenario takes 66 s.

* Document how a reload reports its result

The CLI docs now say `paseo plugin reload` waits for the daemon to stop
and start the plugin, that cancelling the command or losing the
connection does not stop the daemon's work, and that `plugin ls` shows
the previous state until the reload finishes.

The plugin reference records the lifecycle fact behind it: a reload
queues behind the daemon's other plugin work, the daemon answers when
the plugin is running again or has failed, and status reads, Settings and
the logs are not the reload's outcome.

* Cover the Settings reload wait end to end

Adds a browser scenario for the ticket's user-facing half: with a gated
git install holding the daemon's plugin lifecycle queue, the row's Reload
goes out, the test keeps the gate closed past sixty seconds, asserts the
row stays pending with no error feedback, then releases the build and
asserts the daemon's plugin.reload.response and the app's "Reloaded
e2e-plugin" result.

The scenario turns plugins on through the daemon instead of the global
switch, whose transient "Plugins enabled" confirmation is not its
subject. The wait is measured from the reload request frame, which is an
outbound frame, not from the response stream.

Verified: npx playwright test --project=browser
e2e/browser/plugin-management.spec.ts -g "keeps Reload pending past a
minute" -> 1 passed (1.5m); plugin-reload-pending.png,
plugin-reload-past-deadline.png and plugin-reload-completed.png show the
pending row, the surface past the former deadline with no error, and the
finished reload.

* Correct the status guidance for a cancelled reload

Review finding: the note said a listing keeps the plugin's previous
state until the reload finishes, but that only holds while the reload is
still queued. Once it executes, the daemon removes the plugin from the
runtime catalog before stopping it (plugins/runtime.ts:377-382) and
resolvePluginStatus maps an enabled plugin that is not running to
"failed" (plugins/index.ts:71-78), so a listing read in that window
shows failed for a healthy reload.

Both passages now separate the two windows and say that neither a
listing nor the logs report the outcome of the outstanding reload.
The interim "failed" itself is TUC-1401.

* Harden the reload coverage against failures and lost connections

Review findings folded into the tests:

The Settings scenario releases its gated build in a finally block. Before
this, a failing assertion left the build polling forever, the daemon's
plugin lifecycle queue stayed blocked, and the fixture teardown's own
removePlugin timed out behind it. It also asserts the row's actions are
enabled again once the reload settles, next to the result feedback.

The connection-close regression now reconnects and asserts nothing
resends the lost reload; the no-replay invariant already covered for
legacy requests now holds for a pending reload too.

Verified: npx vitest run packages/client/src/daemon-client.test.ts -> 161
passed. npx playwright test --project=browser
e2e/browser/plugin-management.spec.ts -g "keeps Reload pending past a
minute" -> 1 passed (1.4m).

* Let the gated build finish before its files go

Review finding on packages/cli/tests/e2e/plugin-lifecycle.test.ts:450:
the finally block released the gate and deleted the gate directory
without waiting for the install and the reload it had started. The build
polls for the gate file, so cleanup could delete the release before the
child saw it and leave the build waiting until daemon shutdown.

The fixture now keeps both promises, releases the gate, and awaits them
before deleting their files.

Verified: npx tsx packages/cli/tests/e2e/plugin-lifecycle.test.ts -> exit
0, 161 s.

* Read the reload scenario as intent, not transport code

Review finding: the new browser scenario kept frame parsing, selectors,
polling, deadline arithmetic, screenshots and gate cleanup in one body.
docs/testing.md asks for complex setup in reusable helpers.

The wire observation, the gated queue hold, the row-state assertions, the
deadline wait and the surface captures are helpers now, and the body
reads as the flow: open Settings, hold the queue, reload, still pending
past the deadline, release, read the daemon's result.

Verified: npx playwright test --project=browser
e2e/browser/plugin-management.spec.ts -g "keeps Reload pending past a
minute" -> 1 passed (1.4m).

* Keep the reload lifecycle fact in one document

Review finding: the cli.md paragraph repeated the queue and interim-state
explanation that the plugin reference already owns. CLAUDE.md: "One fact,
one doc. Every other mention is a link."

The reference keeps the full explanation; the CLI page states what the
command does and links there.

* Release the gated build when its setup fails

Review finding: holdPluginLifecycleQueue started the install, then
waited up to 30 seconds for the build's started marker. A preparation
that took longer made the helper throw before it returned its cleanup
handle and before the test entered its try block, so nothing wrote the
release file: the build polled forever, the daemon's plugin lifecycle
queue stayed blocked, and the fixture's own removePlugin timed out
behind it.

The helper now owns that failure path: it releases the gate and drains
the install before rethrowing, so a queue it hands out is always
settleable.

Verified on Linux (Chromium, Playwright):
- With the started marker suppressed in a temporary local patch, the run
  fails at the 30-second marker wait inside the helper and the teardown
  still completes (both attempts ~33 seconds, no removePlugin request
  timeout).
- With the marker in place, the scenario passes: 1 passed (1.4m).

* Keep an early install failure out of the unhandled path

Review finding: the marker wait in holdPluginLifecycleQueue ran before
the install promise had a rejection handler, so a preparation that failed
or a connection that closed before the build wrote its marker surfaced as
an unhandled rejection, which fails the run before the helper's own catch
can drain the queue.

The helper attaches a handler the moment the install starts and races the
marker wait against the install, so an early failure reaches the caller
as the daemon's own error, a build that never writes its marker still
fails as a 30-second marker timeout, and every path releases the gate and
drains the install.

Verified on Linux (Chromium, Playwright), patching the build command
locally for each failure branch:
- Early failure (build exits 1): fails in 4.2 s with the daemon's
  DaemonRpcError for plugin.source.install.request, no unhandled
  rejection, teardown completes.
- No marker (build waits for the gate): fails at the 30-second marker
  wait, no unhandled rejection, teardown completes with no removePlugin
  request timeout.
- Unpatched: 1 passed (1.5m).

* Keep a failed launch from ending the run before its cleanup
2026-10-17 10:57:10 +02:00

522 lines
22 KiB
TypeScript

#!/usr/bin/env npx tsx
import { resolveCliVersion } from "../../src/version.js";
import { readPluginManifest } from "../../../server/src/server/plugins/manifest.js";
import {
startNpmRegistry,
npmPluginPackages,
} from "../../../../scripts/test-support/npm-registry.mjs";
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { once } from "node:events";
import { WebSocket, WebSocketServer } from "ws";
import { connectToDaemon } from "../../src/utils/client.ts";
import { createE2ETestContext } from "../helpers/test-daemon.ts";
const pluginSource = `export default function contribute(plugin: unknown) {
void plugin;
return () => undefined;
}`;
const execFileAsync = promisify(execFile);
async function git(cwd: string, args: string[]): Promise<void> {
await execFileAsync("git", args, { cwd });
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitFor(
condition: () => Promise<boolean>,
description: string,
timeoutMs = 60_000,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await condition()) return;
await sleep(50);
}
throw new Error(`Timed out waiting for ${description}`);
}
interface ObservedRequest {
type: string;
message: Record<string, unknown>;
observedAt: number;
}
/**
* Observes real CLI requests while forwarding them to the isolated daemon.
*
* Requests are recorded where the CLI sends them, so a scenario can measure a wait from the
* request boundary instead of from process start.
*/
async function startRequestObserver(upstreamPort: number): Promise<{
port: number;
requests: ObservedRequest[];
waitForRequest: (type: string) => Promise<number>;
close: () => Promise<void>;
}> {
const server = new WebSocketServer({ host: "127.0.0.1", port: 0 });
const requests: ObservedRequest[] = [];
server.on("connection", (socket, request) => {
const upstream = new WebSocket(`ws://127.0.0.1:${upstreamPort}${request.url}`, {
headers: { authorization: request.headers.authorization ?? "" },
});
const ready = once(upstream, "open");
socket.on("message", async (data, isBinary) => {
const frame = JSON.parse(data.toString()) as {
type?: string;
message?: { type?: string } & Record<string, unknown>;
};
if (frame.type === "session" && typeof frame.message?.type === "string") {
requests.push({
type: frame.message.type,
message: frame.message,
observedAt: Date.now(),
});
}
await ready;
upstream.send(data, { binary: isBinary });
});
upstream.on("message", (data, isBinary) => socket.send(data, { binary: isBinary }));
socket.on("close", () => upstream.close());
upstream.on("close", () => socket.close());
});
await once(server, "listening");
const address = server.address();
assert.ok(address && typeof address !== "string");
return {
port: address.port,
requests,
waitForRequest: async (type: string) => {
await waitFor(
async () => requests.some((entry) => entry.type === type),
`${type} to reach the daemon`,
);
return requests.find((entry) => entry.type === type)!.observedAt;
},
close: async () => {
for (const socket of server.clients) socket.close();
await promisify(server.close.bind(server))();
},
};
}
/** Build command that blocks the plugin lifecycle queue until the test writes the gate file. */
const gateWaitBuild = [
'const fs = require("node:fs");',
'fs.writeFileSync(process.argv[1], "started");',
"const wait = () => {",
" if (fs.existsSync(process.argv[2])) return;",
" setTimeout(wait, 50);",
"};",
"wait();",
].join("\n");
/** Plugin whose every activation appends one line, so a test can count real reactivations. */
function activationCountingPlugin(logPath: string): string {
return [
'import { appendFileSync } from "node:fs";',
`const activationLog = ${JSON.stringify(logPath)};`,
"export default function contribute(plugin: unknown) {",
' appendFileSync(activationLog, "activate\\n");',
" void plugin;",
" return () => undefined;",
"}",
].join("\n");
}
async function countActivations(logPath: string): Promise<number> {
const content = await readFile(logPath, "utf8").catch(() => "");
return content.split("\n").filter(Boolean).length;
}
async function main(): Promise<void> {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-cli-e2e-"));
const gitDirectory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-git-cli-e2e-"));
const registry = await startNpmRegistry(npmPluginPackages());
const context = await createE2ETestContext({ timeout: 45_000, env: registry.env });
try {
const scaffold = path.join(context.workDir, "authored-plugin");
const init = await context.paseo(["plugin", "init", scaffold, "--json"]);
assert.equal(init.exitCode, 0, init.stderr);
const manifestPath = path.join(scaffold, "paseo-plugin.json");
const manifest = await readPluginManifest(scaffold);
assert.deepEqual(manifest.requirements, { paseo: `>=${resolveCliVersion()}` });
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "cli-e2e", requirements: { paseo: `>=${resolveCliVersion()}` } }),
);
await writeFile(path.join(directory, "index.server.ts"), pluginSource);
// Observe real CLI requests while forwarding them to the isolated daemon.
const observer = await startRequestObserver(context.port);
const installedSources = () =>
observer.requests
.filter((entry) => entry.type === "plugin.source.install.request")
.map((entry) => entry.message.source);
const sibling = path.join(context.workDir, "x");
const caller = path.join(context.workDir, "caller");
await mkdir(caller);
await mkdir(path.join(sibling, "sub"), { recursive: true });
for (const target of [caller, path.join(sibling, "sub"), context.paseoHome]) {
await writeFile(
path.join(target, "paseo-plugin.json"),
JSON.stringify({
id: "relative-cli",
requirements: { paseo: `>=${resolveCliVersion()}` },
}),
);
await writeFile(path.join(target, "index.server.ts"), pluginSource);
}
assert.notEqual(caller, process.cwd()); // The daemon inherits the test runner's cwd.
try {
for (const [source, expectedSource, expectedDirectory, extraArgs] of [
[".", caller, caller, []],
["../x:sub", `${sibling}:sub`, path.join(sibling, "sub"), []],
["../x", `${sibling}:sub`, path.join(sibling, "sub"), ["--path", "sub"]],
["~", context.paseoHome, context.paseoHome, []],
["~/.:.", `${context.paseoHome}:.`, context.paseoHome, []],
] as const) {
observer.requests.length = 0;
const result = await context.paseo(
["plugin", "add", source, ...extraArgs, "--host", `127.0.0.1:${observer.port}`, "--json"],
{ cwd: caller },
);
assert.deepEqual(installedSources(), [expectedSource]);
assert.equal(result.exitCode, 0, result.stderr);
assert.equal(JSON.parse(result.stdout).path, expectedDirectory);
const removed = await context.paseo(["plugin", "remove", "relative-cli", "--json"]);
assert.equal(removed.exitCode, 0, removed.stderr);
}
// An absolute path belongs to the daemon's platform, which can differ from the CLI's.
const windowsSource = `C:\\${path.basename(context.workDir)}\\missing:sub`;
observer.requests.length = 0;
const missing = await context.paseo(
["plugin", "add", windowsSource, "--host", `127.0.0.1:${observer.port}`, "--json"],
{ cwd: caller },
);
assert.deepEqual(installedSources(), [windowsSource]);
assert.equal(missing.exitCode, 1);
assert.match(missing.stderr, /Plugin directory does not exist/);
} finally {
await observer.close();
}
const install = await context.paseo(["plugin", "install", directory, "--json"]);
assert.equal(install.exitCode, 0, install.stderr);
assert.equal(JSON.parse(install.stdout).id, "cli-e2e");
const client = await connectToDaemon({
target: { kind: "endpoint", host: `127.0.0.1:${context.port}` },
});
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.close();
await writeFile(
manifestPath,
JSON.stringify({ ...manifest, requirements: { paseo: ">=999.0.0" } }),
);
const incompatibleInstall = await context.paseo(["plugin", "install", scaffold, "--json"]);
assert.equal(incompatibleInstall.exitCode, 1);
assert.match(incompatibleInstall.stderr, /requires Paseo >=999.0.0/);
const afterRejection = await context.paseo(["plugin", "ls", "--json"]);
assert.equal(afterRejection.exitCode, 0, afterRejection.stderr);
assert.deepEqual(
JSON.parse(afterRejection.stdout).map((plugin: { id: string }) => plugin.id),
["cli-e2e"],
);
await writeFile(manifestPath, JSON.stringify(manifest));
const scaffoldInstall = await context.paseo(["plugin", "install", scaffold, "--json"]);
assert.equal(scaffoldInstall.exitCode, 0, scaffoldInstall.stderr);
assert.equal(JSON.parse(scaffoldInstall.stdout).status, "running");
await git(gitDirectory, ["init", "-b", "main"]);
await git(gitDirectory, ["config", "user.name", "Paseo Tests"]);
await git(gitDirectory, ["config", "user.email", "paseo@example.test"]);
await writeFile(
path.join(gitDirectory, "paseo-plugin.json"),
JSON.stringify({ id: "git-cli-e2e", requirements: { paseo: `>=${resolveCliVersion()}` } }),
);
await writeFile(path.join(gitDirectory, "index.server.ts"), pluginSource);
await git(gitDirectory, ["add", "-A"]);
await git(gitDirectory, ["commit", "-m", "initial"]);
const gitInstall = await context.paseo([
"plugin",
"add",
`git:${pathToFileURL(gitDirectory).href}`,
"--json",
]);
assert.equal(gitInstall.exitCode, 0, gitInstall.stderr);
assert.equal(JSON.parse(gitInstall.stdout).source, "git");
await writeFile(
path.join(gitDirectory, "index.server.ts"),
`${pluginSource}\nconst updated = true;\n`,
);
await git(gitDirectory, ["add", "-A"]);
await git(gitDirectory, ["commit", "-m", "update"]);
const status = await context.paseo(["plugin", "status", "git-cli-e2e", "--json"]);
assert.equal(status.exitCode, 0, status.stderr);
assert.equal(JSON.parse(status.stdout)[0].status, "running");
assert.equal(JSON.parse(status.stdout)[0].commit, JSON.parse(gitInstall.stdout).commit);
const update = await context.paseo(["plugin", "update", "git-cli-e2e", "--yes", "--json"]);
assert.equal(update.exitCode, 0, update.stderr);
assert.equal(JSON.parse(update.stdout)[0].outcome, "updated");
const installedCommit = JSON.parse(update.stdout)[0].plugin.installation.currentRevision;
const buildMarker = path.join(context.workDir, "incompatible-build-ran");
await writeFile(
path.join(gitDirectory, "paseo-plugin.json"),
JSON.stringify({
id: "git-cli-e2e",
requirements: { paseo: ">=999.0.0" },
build: [
[
process.execPath,
"-e",
'require("node:fs").writeFileSync(process.argv[1], "ran")',
buildMarker,
],
],
}),
);
await git(gitDirectory, ["add", "-A"]);
await git(gitDirectory, ["commit", "-m", "requires a future Paseo"]);
const incompatibleUpdate = await context.paseo([
"plugin",
"update",
"git-cli-e2e",
"--yes",
"--json",
]);
assert.equal(incompatibleUpdate.exitCode, 1);
assert.match(JSON.parse(incompatibleUpdate.stdout)[0].error, /requires Paseo >=999.0.0/);
await assert.rejects(readFile(buildMarker), { code: "ENOENT" });
const retained = await context.paseo(["plugin", "ls", "git-cli-e2e", "--json"]);
assert.equal(retained.exitCode, 0, retained.stderr);
assert.equal(JSON.parse(retained.stdout)[0].commit, installedCommit);
assert.equal(JSON.parse(retained.stdout)[0].status, "running");
const incompatibleAdd = await context.paseo([
"plugin",
"add",
pathToFileURL(gitDirectory).href,
"--id",
"future-plugin",
"--json",
]);
assert.equal(incompatibleAdd.exitCode, 1);
assert.match(incompatibleAdd.stderr, /requires Paseo >=999.0.0/);
await assert.rejects(readFile(buildMarker), { code: "ENOENT" });
const reload = await context.paseo(["plugin", "reload", "cli-e2e", "--json"]);
assert.equal(reload.exitCode, 0, reload.stderr);
assert.equal(JSON.parse(reload.stdout).status, "running");
const disable = await context.paseo(["plugin", "disable", "cli-e2e", "--json"]);
assert.equal(disable.exitCode, 0, disable.stderr);
assert.equal(JSON.parse(disable.stdout).status, "disabled");
const enable = await context.paseo(["plugin", "enable", "cli-e2e", "--json"]);
assert.equal(enable.exitCode, 0, enable.stderr);
assert.equal(JSON.parse(enable.stdout).status, "running");
// A reload that finishes after the client's former one-minute deadline must still report its
// real result. The wait is the daemon's own lifecycle queue, so it runs on real wall-clock time.
const gateDirectory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-reload-e2e-"));
const buildStarted = path.join(gateDirectory, "build-started");
const buildGate = path.join(gateDirectory, "build-gate");
const activationLog = path.join(gateDirectory, "activations.log");
const targetDirectory = path.join(gateDirectory, "slow-reload-target");
const slowRepository = path.join(gateDirectory, "slow-build");
const reloadObserver = await startRequestObserver(context.port);
let slowInstall: ReturnType<typeof context.paseo> | undefined;
let slowReload: ReturnType<typeof context.paseo> | undefined;
try {
await mkdir(targetDirectory);
await writeFile(
path.join(targetDirectory, "paseo-plugin.json"),
JSON.stringify({
id: "slow-reload-target",
requirements: { paseo: `>=${resolveCliVersion()}` },
}),
);
await writeFile(
path.join(targetDirectory, "index.server.ts"),
activationCountingPlugin(activationLog),
);
const targetInstall = await context.paseo(["plugin", "install", targetDirectory, "--json"]);
assert.equal(targetInstall.exitCode, 0, targetInstall.stderr);
assert.equal(await countActivations(activationLog), 1);
await mkdir(slowRepository);
await git(slowRepository, ["init", "-b", "main"]);
await git(slowRepository, ["config", "user.name", "Paseo Tests"]);
await git(slowRepository, ["config", "user.email", "paseo@example.test"]);
await writeFile(
path.join(slowRepository, "paseo-plugin.json"),
JSON.stringify({
id: "slow-build",
requirements: { paseo: `>=${resolveCliVersion()}` },
build: [[process.execPath, "-e", gateWaitBuild, buildStarted, buildGate]],
}),
);
await writeFile(path.join(slowRepository, "index.server.ts"), pluginSource);
await git(slowRepository, ["add", "-A"]);
await git(slowRepository, ["commit", "-m", "blocking build"]);
// This install holds the plugin lifecycle queue while its build waits for the gate.
slowInstall = context.paseo(
["plugin", "add", `git:${pathToFileURL(slowRepository).href}`, "--json"],
{ timeout: 180_000 },
);
// A failed launch must not arrive as an unhandled rejection: that ends this direct Node run
// before the finally block below releases the build gate. Its result is still asserted.
void slowInstall.catch(() => undefined);
await waitFor(
async () =>
readFile(buildStarted, "utf8").then(
() => true,
() => false,
),
"the blocking build to start",
);
slowReload = context.paseo(
[
"plugin",
"reload",
"slow-reload-target",
"--host",
`127.0.0.1:${reloadObserver.port}`,
"--json",
],
{ timeout: 180_000 },
);
// Same as the install above: a launch failure must not end the run before the cleanup.
void slowReload.catch(() => undefined);
const reloadRequestedAt = await reloadObserver.waitForRequest("plugin.reload.request");
// The interim listing is not the reload's outcome: it still reports the plugin running and
// no restart has happened while the reload waits behind the queue.
const duringReload = await context.paseo(["plugin", "ls", "slow-reload-target", "--json"]);
assert.equal(duringReload.exitCode, 0, duringReload.stderr);
assert.equal(JSON.parse(duringReload.stdout)[0].status, "running");
assert.equal(await countActivations(activationLog), 1);
const heldMs = Date.now() - reloadRequestedAt;
if (heldMs < 66_000) {
await sleep(66_000 - heldMs);
}
await writeFile(buildGate, "go");
const [buildResult, reloadResult] = await Promise.all([slowInstall, slowReload]);
assert.equal(buildResult.exitCode, 0, buildResult.stderr);
assert.equal(reloadResult.exitCode, 0, reloadResult.stderr);
assert.equal(JSON.parse(reloadResult.stdout).status, "running");
assert.equal(await countActivations(activationLog), 2);
// A plugin that cannot start still fails its reload with the daemon's error.
const brokenDirectory = path.join(gateDirectory, "broken-plugin");
await mkdir(brokenDirectory);
await writeFile(
path.join(brokenDirectory, "paseo-plugin.json"),
JSON.stringify({
id: "broken-reload",
requirements: { paseo: `>=${resolveCliVersion()}` },
}),
);
await writeFile(path.join(brokenDirectory, "index.server.ts"), "export default broken !!!");
const brokenInstall = await context.paseo(["plugin", "install", brokenDirectory, "--json"]);
assert.equal(brokenInstall.exitCode, 1);
assert.match(brokenInstall.stderr, /broken-reload|broken|failed/i);
const brokenReload = await context.paseo(["plugin", "reload", "broken-reload", "--json"], {
timeout: 60_000,
});
assert.equal(brokenReload.exitCode, 1);
assert.match(brokenReload.stderr, /broken-reload|broken|failed/i);
for (const pluginId of ["slow-reload-target", "slow-build", "broken-reload"]) {
const removed = await context.paseo(["plugin", "remove", pluginId, "--json"]);
assert.equal(removed.exitCode, 0, removed.stderr);
}
} finally {
// Release the gate, then let the install and the reload settle before their files go: the
// build polls for the gate file, so cleanup must not delete it while the child still waits.
await writeFile(buildGate, "go").catch(() => undefined);
await Promise.all([slowInstall?.catch(() => undefined), slowReload?.catch(() => undefined)]);
await reloadObserver.close();
await rm(gateDirectory, { recursive: true, force: true });
}
const remove = await context.paseo(["plugin", "remove", "cli-e2e", "--json"]);
assert.equal(remove.exitCode, 0, remove.stderr);
const removeGit = await context.paseo(["plugin", "remove", "git-cli-e2e", "--json"]);
assert.equal(removeGit.exitCode, 0, removeGit.stderr);
const removeScaffold = await context.paseo(["plugin", "remove", "authored-plugin", "--json"]);
assert.equal(removeScaffold.exitCode, 0, removeScaffold.stderr);
for (const source of ["npm:paseo-fixture-plugin@^1.0.0", "npm:@paseo-fixture/review@2.0.0"]) {
const npmInstall = await context.paseo([
"plugin",
"install",
source,
"--path",
".",
"--json",
]);
assert.equal(npmInstall.exitCode, 0, npmInstall.stderr);
assert.equal(JSON.parse(npmInstall.stdout).id, "npm-review");
assert.equal(JSON.parse(npmInstall.stdout).status, "running");
assert.equal(
JSON.parse(npmInstall.stdout).installation.currentRevision,
source.includes("@paseo-fixture") ? "2.0.0" : "1.1.0",
);
const npmDisabled = await context.paseo(["plugin", "disable", "npm-review", "--json"]);
assert.equal(npmDisabled.exitCode, 0, npmDisabled.stderr);
const restart = await context.paseo(["daemon", "restart", "--timeout", "45", "--json"], {
timeout: 60_000,
});
assert.equal(restart.exitCode, 0, restart.stderr);
assert.notEqual(
JSON.parse(restart.stdout).workerPid,
JSON.parse(restart.stdout).previousWorkerPid,
);
const persisted = await context.paseo(["plugin", "ls", "npm-review", "--json"]);
assert.equal(persisted.exitCode, 0, persisted.stderr);
assert.equal(JSON.parse(persisted.stdout)[0].status, "disabled");
assert.equal(JSON.parse(persisted.stdout)[0].path, JSON.parse(npmInstall.stdout).path);
assert.deepEqual(
JSON.parse(persisted.stdout)[0].installation,
JSON.parse(npmInstall.stdout).installation,
);
const npmEnabled = await context.paseo(["plugin", "enable", "npm-review", "--json"]);
assert.equal(npmEnabled.exitCode, 0, npmEnabled.stderr);
assert.equal(JSON.parse(npmEnabled.stdout).status, "running");
const npmReload = await context.paseo(["plugin", "reload", "npm-review", "--json"]);
assert.equal(npmReload.exitCode, 0, npmReload.stderr);
const npmRemove = await context.paseo(["plugin", "remove", "npm-review", "--json"]);
assert.equal(npmRemove.exitCode, 0, npmRemove.stderr);
}
const list = await context.paseo(["plugin", "ls", "--json"]);
assert.equal(list.exitCode, 0, list.stderr);
assert.deepEqual(JSON.parse(list.stdout), []);
} finally {
await context.stop();
await registry.close();
await rm(directory, { recursive: true, force: true });
await rm(gitDirectory, { recursive: true, force: true });
}
}
await main();