Files
paseo/packages/app/e2e/browser/plugin-management.spec.ts
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

877 lines
34 KiB
TypeScript

import { existsSync } from "node:fs";
import overviewCorpus from "../../../protocol/tests/fixtures/plugin-overview.json";
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { execFileSync } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import type { TestInfo } from "@playwright/test";
import { buildOpenProjectRoute } from "@/utils/host-routes";
import { expect, test as base, type Page } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { getServerId } from "../support/helpers/server-id";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { waitForSettledPosition } from "../support/helpers/sheet-layout";
import {
expectSettingsHeader,
openCompactSettings,
openHostSection,
openSettingsHost,
} from "../support/helpers/settings";
import {
startNpmRegistry,
npmPluginPackages,
} from "../../../../scripts/test-support/npm-registry.mjs";
interface PluginEnvironment {
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
directory: string;
}
const test = base.extend<
{ pluginEnvironment: PluginEnvironment },
{ npmRegistry: Awaited<ReturnType<typeof startNpmRegistry>> }
>({
pluginEnvironment: async ({ e2eWorker: _e2eWorker }, provide) => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugins-e2e-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
try {
await provide({ client, directory });
} finally {
try {
const current = await client.getDaemonConfig();
const existingIds = new Set(Object.keys(previous.config.plugins ?? {}));
for (const id of Object.keys(current.config.plugins ?? {})) {
if (!existingIds.has(id)) await client.removePlugin(id);
}
} finally {
try {
await client.patchDaemonConfig({
pluginsEnabled: previous.config.pluginsEnabled ?? false,
});
} finally {
try {
await client.close();
} finally {
await rm(directory, { recursive: true, force: true });
}
}
}
}
},
npmRegistry: [
async ({ browserName: _browserName }, provide) => {
const registry = await startNpmRegistry(npmPluginPackages());
try {
await provide(registry);
} finally {
await registry.close();
}
},
{ scope: "worker" },
],
e2eDaemonEnvironment: [
async ({ npmRegistry }, provide) => {
await provide(npmRegistry.env);
},
{ scope: "worker" },
],
});
async function advertisePluginCapabilities(
page: Page,
features: Record<string, boolean | undefined>,
): Promise<void> {
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((message) => server.send(message));
server.onMessage((message) => {
if (typeof message !== "string") {
browser.send(message);
return;
}
const envelope = JSON.parse(message);
if (
envelope.message?.type === "status" &&
envelope.message.payload?.status === "server_info"
) {
Object.assign(envelope.message.payload.features, features);
}
browser.send(JSON.stringify(envelope));
});
});
}
function observePluginCatalog(page: Page) {
let responses = 0;
page.on("websocket", (socket) => {
socket.on("framereceived", ({ payload }) => {
if (typeof payload !== "string") return;
try {
const envelope = JSON.parse(payload) as {
type?: unknown;
message?: { type?: unknown };
};
const message = envelope.type === "session" ? envelope.message : envelope;
if (message?.type === "plugin.catalog.get.response") responses += 1;
} catch {
return;
}
});
});
return {
waitForInitialFetch: async () => {
await expect.poll(() => responses).toBeGreaterThan(0);
},
};
}
function pluginSource(title: string, renderError?: string): string {
return `import React from "react";
import { Text } from "react-native";
export default function contribute(plugin) {
function Surface() {
${renderError ? `throw new Error(${JSON.stringify(renderError)});` : ""}
const cleanups = Number(globalThis.sessionStorage?.getItem("paseo-plugin-cleanups") || "0");
return <Text>${title} cleanup {cleanups}</Text>;
}
plugin.addSurface("main", Surface);
plugin.addSidebarItem({ id: "main", title: ${JSON.stringify(title)}, icon: "Blocks", surface: "main" });
${
renderError
? `function HealthySurface() { return <Text>Healthy contribution</Text>; }
plugin.addSurface("healthy", HealthySurface);
plugin.addSidebarItem({ id: "healthy", title: "Healthy surface", icon: "Blocks", surface: "healthy" });`
: ""
}
return () => {
const storage = globalThis.sessionStorage;
if (storage) {
const cleanups = Number(storage.getItem("paseo-plugin-cleanups") || "0");
storage.setItem("paseo-plugin-cleanups", String(cleanups + 1));
}
};
}`;
}
async function openPluginSettings(page: Page): Promise<void> {
const serverId = getServerId();
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "plugins");
await expectSettingsHeader(page, "Plugins");
}
async function openCompactPluginSettings(page: Page): Promise<void> {
const serverId = getServerId();
await openCompactSettings(page, buildOpenProjectRoute());
await openHostSection(page, serverId, "plugins");
await expect(page.getByRole("textbox", { name: "Plugin source", exact: true })).toBeVisible();
}
async function reloadPlugin(page: Page): Promise<void> {
await selectPluginAction(page, "e2e-plugin", "Reload");
await expect(page.getByText("Reloaded e2e-plugin", { exact: true })).toBeVisible();
}
async function openPluginActions(page: Page, pluginId: string): Promise<void> {
await page.getByRole("button", { name: `Actions for ${pluginId}`, exact: true }).click();
await expect(page.getByRole("menuitem").first()).toBeVisible();
}
async function selectPluginAction(page: Page, pluginId: string, action: string): Promise<void> {
await openPluginActions(page, pluginId);
await page.getByRole("menuitem", { name: action, exact: true }).click();
}
async function expectContributionBody(page: Page, expectedBody: string): Promise<void> {
const body = page.getByText(expectedBody, { exact: true }).filter({ visible: true });
await expect(body).toHaveCount(1);
await expect(body).toBeVisible();
}
async function openContribution(page: Page, title: string, expectedBody: string): Promise<void> {
await page.getByRole("button", { name: title, exact: true }).click();
await expectContributionBody(page, expectedBody);
}
async function leavePluginSettings(page: Page): Promise<void> {
await page.getByRole("button", { name: "Back", exact: true }).click();
await expect(page).not.toHaveURL(/\/settings\//);
}
async function openContributionFromSettings(
page: Page,
title: string,
expectedBody: string,
): Promise<void> {
await leavePluginSettings(page);
await openContribution(page, title, expectedBody);
}
async function reloadActiveContribution(
page: Page,
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
expectedBody: string,
): Promise<void> {
await client.reloadPlugin("e2e-plugin");
await expectContributionBody(page, expectedBody);
}
async function expectContributionRemoved(page: Page, title: string): Promise<void> {
await expect(page.getByRole("button", { name: title, exact: true })).not.toBeVisible();
}
async function capturePluginInstallForm(
page: Page,
testInfo: TestInfo,
name: "wide" | "wide-menu" | "compact-error" | "compact-success" | "compact-menu",
): Promise<void> {
await page.screenshot({
path: testInfo.outputPath(`plugin-source-install-${name}.png`),
fullPage: true,
animations: "disabled",
});
}
async function installPlugin(page: Page, source: string): Promise<void> {
await page.getByLabel("Plugin source").fill(source);
await page.getByRole("button", { name: "Install plugin" }).click();
}
async function expectPluginSourceDocsOpen(page: Page): Promise<void> {
const requestedPage = page
.context()
.waitForEvent(
"request",
(request) => request.isNavigationRequest() && request.url().startsWith("https://paseo.sh/"),
);
const docsPagePromise = page.context().waitForEvent("page");
await page.getByRole("link", { name: "Docs", exact: true }).click();
const request = await requestedPage;
const docsPage = await docsPagePromise;
try {
expect(new URL(request.url()).pathname).toBe("/docs/plugins/reference");
// The deployed site can redirect while the matching website change is still in this PR.
await docsPage.waitForURL(
(url) => url.origin === "https://paseo.sh" && url.hash === "#plugin-sources",
{ waitUntil: "commit" },
);
} finally {
await docsPage.close();
}
}
async function createGitPluginRepository(root: string): Promise<string> {
const repository = path.join(root, "repository");
await mkdir(repository);
await writeFile(
path.join(repository, "paseo-plugin.json"),
JSON.stringify({
id: "git-e2e-plugin",
description: "Adds review tools from a Git repository",
requirements: pluginRequirements,
}),
);
await writeFile(path.join(repository, "index.client.tsx"), pluginSource("Git plugin"));
execFileSync("git", ["init", "-b", "main"], { cwd: repository, stdio: "ignore" });
execFileSync("git", ["config", "user.name", "Paseo Tests"], {
cwd: repository,
stdio: "ignore",
});
execFileSync("git", ["config", "user.email", "paseo@example.test"], {
cwd: repository,
stdio: "ignore",
});
execFileSync("git", ["add", "-A"], { cwd: repository, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "initial"], { cwd: repository, stdio: "ignore" });
return repository;
}
async function createDirectoryPlugin(
root: string,
id: string,
description: string | undefined,
title: string,
): Promise<string> {
const directory = await mkdtemp(path.join(root, "plugin-row-"));
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id, description, requirements: pluginRequirements }),
);
await writeFile(
path.join(directory, "index.client.tsx"),
`import React from "react";
import { Text } from "react-native";
export default function contribute(plugin) {
function Surface() { return <Text>${title}</Text>; }
plugin.addSurface("main", Surface);
plugin.addSidebarItem({ id: "main", title: ${JSON.stringify(title)}, icon: "Blocks", surface: "main" });
return () => undefined;
}`,
);
return directory;
}
/** Build command that holds the plugin lifecycle queue until the test writes the release file. */
const gatedBuildCommand = [
'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");
async function createGatedBuildRepository(
root: string,
gate: { started: string; release: string },
): Promise<string> {
const repository = path.join(root, "gated-repository");
await mkdir(repository);
await writeFile(
path.join(repository, "paseo-plugin.json"),
JSON.stringify({
id: "gated-build-plugin",
description: "Holds the plugin lifecycle queue while its build waits",
requirements: pluginRequirements,
build: [[process.execPath, "-e", gatedBuildCommand, gate.started, gate.release]],
}),
);
await writeFile(path.join(repository, "index.client.tsx"), pluginSource("Gated build plugin"));
execFileSync("git", ["init", "-b", "main"], { cwd: repository, stdio: "ignore" });
execFileSync("git", ["config", "user.name", "Paseo Tests"], {
cwd: repository,
stdio: "ignore",
});
execFileSync("git", ["config", "user.email", "paseo@example.test"], {
cwd: repository,
stdio: "ignore",
});
execFileSync("git", ["add", "-A"], { cwd: repository, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "initial"], { cwd: repository, stdio: "ignore" });
return repository;
}
async function installFailedPlugin(
page: Page,
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
directory: string,
): Promise<void> {
await client.installPluginSource({ source: directory });
await writeFile(path.join(directory, "index.client.tsx"), "export default broken !!!");
await client.reloadPlugin("failed-preview-plugin");
await expect(page.getByLabel("failed-preview-plugin failed")).toBeVisible();
}
test("installs, reloads, recovers, disables, and removes a trusted local plugin", async ({
page,
pluginEnvironment,
}, testInfo) => {
const directory = await installLocalPluginWithStatusExamples(page, pluginEnvironment, testInfo);
await reloadAndRecoverLocalPlugin(page, pluginEnvironment.client, directory);
await toggleRemoveAndReinstallLocalPlugin(page, directory);
});
test("installs a Git source after a failed source remains editable", async ({
page,
pluginEnvironment,
}, testInfo) => {
const source = await prepareCompactGitInstall(page, pluginEnvironment);
await retryGitInstallAndInspectRows(page, source, testInfo);
});
for (const sourceSupport of [undefined, false]) {
test(`keeps installed plugins manageable without source capability (${sourceSupport})`, async ({
page,
pluginEnvironment,
}) => {
const directory = await prepareLegacyPluginHost(page, pluginEnvironment, sourceSupport);
await verifyLegacyPluginManagement(page, directory);
});
}
for (const viewport of [
{ width: 1280, height: 900 },
{ width: 390, height: 844 },
]) {
test(`installs an npm source and manages its row at ${viewport.width}px`, async ({
page,
pluginEnvironment,
}, testInfo) => {
await prepareNpmPluginHost(page, pluginEnvironment, viewport);
await retryNpmInstallAndInspectRow(page, viewport.width, testInfo);
await reloadAndRemoveNpmPlugin(page);
});
}
async function openNpmPluginSettings(page: Page, width: number) {
if (width < 600) await openCompactPluginSettings(page);
else await openPluginSettings(page);
}
async function expectSourceHierarchy(page: Page, description: string, source: string) {
const descriptionText = page.getByText(description, { exact: false });
const sourceText = page.getByText(source, { exact: false });
const descriptionSize = await descriptionText.evaluate((element) =>
Number.parseFloat(getComputedStyle(element).fontSize),
);
const sourceSize = await sourceText.evaluate((element) =>
Number.parseFloat(getComputedStyle(element).fontSize),
);
expect(sourceSize).toBeLessThan(descriptionSize);
await expect(sourceText).toHaveCSS("color", "rgb(161, 161, 170)");
await expect(descriptionText).toHaveCSS("color", "rgb(113, 113, 122)");
}
async function installLocalPluginWithStatusExamples(
page: Page,
pluginEnvironment: PluginEnvironment,
testInfo: TestInfo,
) {
const directory = await mkdtemp(path.join(pluginEnvironment.directory, "local-"));
const disabledDirectory = await createDirectoryPlugin(
pluginEnvironment.directory,
"disabled-preview-plugin-with-a-long-name",
"Keeps optional previews off until this plugin is enabled",
"Preview plugin",
);
const failedDirectory = await createDirectoryPlugin(
pluginEnvironment.directory,
"failed-preview-plugin",
"Demonstrates a plugin that needs attention",
"Failed preview plugin",
);
const { client } = pluginEnvironment;
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({
id: "e2e-plugin",
description: "Exercises the complete local plugin lifecycle",
requirements: pluginRequirements,
}),
);
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v1"));
const catalog = observePluginCatalog(page);
await gotoAppShell(page);
await openPluginSettings(page);
await catalog.waitForInitialFetch();
await expect(page.getByRole("textbox", { name: "Plugin installation ID" })).toHaveCount(0);
await expectPluginSourceDocsOpen(page);
await client.patchDaemonConfig({ pluginsEnabled: false });
await page.getByRole("switch", { name: "Enable plugins" }).click();
await expect(page.getByText("Plugins enabled", { exact: true })).toBeVisible();
await client.installPluginSource({ source: disabledDirectory });
await client.disablePlugin("disabled-preview-plugin-with-a-long-name");
await installFailedPlugin(page, client, failedDirectory);
await page.getByLabel("Plugin source").fill(directory);
await page.getByRole("button", { name: "Install plugin" }).click();
await expect(page.getByText("Installed e2e-plugin", { exact: true })).toBeVisible();
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
await expect(page.getByText("Exercises the complete local plugin lifecycle")).toBeVisible();
await expect(
page.getByText("Keeps optional previews off until this plugin is enabled"),
).toBeVisible();
await expect(page.getByText("Demonstrates a plugin that needs attention")).toBeVisible();
await expect(page.getByLabel("disabled-preview-plugin-with-a-long-name disabled")).toBeVisible();
await expect(page.getByLabel("failed-preview-plugin failed")).toBeVisible();
await capturePluginInstallForm(page, testInfo, "wide");
await openPluginActions(page, "e2e-plugin");
await capturePluginInstallForm(page, testInfo, "wide-menu");
await page.getByRole("menuitem", { name: "Logs", exact: true }).click();
await expect(page.getByRole("dialog")).toContainText("Logs: e2e-plugin");
await page.getByRole("button", { name: "Close", exact: true }).click();
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 0");
return directory;
}
async function reloadAndRecoverLocalPlugin(
page: Page,
client: PluginEnvironment["client"],
directory: string,
) {
await openPluginSettings(page);
await reloadPlugin(page);
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 1");
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v2"));
await openPluginSettings(page);
await reloadPlugin(page);
await openContributionFromSettings(page, "Plugin v2", "Plugin v2 cleanup 2");
await expect(page.getByRole("button", { name: "Plugin v1", exact: true })).not.toBeVisible();
await writeFile(
path.join(directory, "index.client.tsx"),
pluginSource("Broken surface", "render exploded"),
);
await openPluginSettings(page);
await reloadPlugin(page);
await openContributionFromSettings(page, "Broken surface", "Plugin failed: render exploded");
await openContribution(page, "Healthy surface", "Healthy contribution");
await openContribution(page, "Broken surface", "Plugin failed: render exploded");
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Recovered surface"));
await reloadActiveContribution(page, client, "Recovered surface cleanup 4");
await expect(page.getByRole("button", { name: "Broken surface", exact: true })).not.toBeVisible();
await writeFile(path.join(directory, "index.client.tsx"), "export default broken syntax !!!");
await openPluginSettings(page);
await selectPluginAction(page, "e2e-plugin", "Reload");
await expect(page.getByTestId("plugin-management-feedback")).toContainText("Request failed");
await expect(page.getByLabel("e2e-plugin failed")).toBeVisible();
await leavePluginSettings(page);
await expectContributionRemoved(page, "Recovered surface");
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v3"));
await openPluginSettings(page);
await selectPluginAction(page, "e2e-plugin", "Reload");
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
await openContributionFromSettings(page, "Plugin v3", "Plugin v3 cleanup 5");
}
async function toggleRemoveAndReinstallLocalPlugin(page: Page, directory: string) {
await openPluginSettings(page);
await page.getByRole("switch", { name: "e2e-plugin: Disable", exact: true }).click();
await expect(page.getByLabel("e2e-plugin disabled")).toBeVisible();
await leavePluginSettings(page);
await expectContributionRemoved(page, "Plugin v3");
await openPluginSettings(page);
await page.getByRole("switch", { name: "e2e-plugin: Enable", exact: true }).click();
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
await openContributionFromSettings(page, "Plugin v3", "Plugin v3 cleanup 6");
await openPluginSettings(page);
page.once("dialog", (dialog) => dialog.accept());
await selectPluginAction(page, "e2e-plugin", "Remove");
await expect(page.getByTestId("plugin-row-e2e-plugin")).toHaveCount(0);
await installPlugin(page, directory);
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
await gotoAppShell(page);
await openContribution(page, "Plugin v3", "Plugin v3 cleanup 7");
}
async function prepareCompactGitInstall(page: Page, pluginEnvironment: PluginEnvironment) {
const root = pluginEnvironment.directory;
const repository = await createGitPluginRepository(root);
const longDirectory = await createDirectoryPlugin(
pluginEnvironment.directory,
"compact-plugin-with-a-realistically-long-name",
"Shows how a realistically long plugin description wraps on a compact screen",
"Compact long plugin",
);
const disabledDirectory = await createDirectoryPlugin(
pluginEnvironment.directory,
"compact-disabled-plugin",
"Remains installed and ready to enable later",
"Compact disabled plugin",
);
const missingSource = path.join(root, "missing-plugin");
const { client } = pluginEnvironment;
await page.setViewportSize({ width: 390, height: 844 });
await client.installPluginSource({ source: longDirectory });
await client.installPluginSource({ source: disabledDirectory });
await client.disablePlugin("compact-disabled-plugin");
await gotoAppShell(page);
await openCompactPluginSettings(page);
await expect(page.getByRole("textbox", { name: "Plugin installation ID" })).toHaveCount(0);
await client.patchDaemonConfig({ pluginsEnabled: false });
await page.getByRole("switch", { name: "Enable plugins" }).click();
await expect(page.getByText("Plugins enabled", { exact: true })).toBeVisible();
return { repository, missingSource };
}
async function retryGitInstallAndInspectRows(
page: Page,
source: { repository: string; missingSource: string },
testInfo: TestInfo,
) {
const { repository, missingSource } = source;
await installPlugin(page, missingSource);
await expect(page.getByTestId("plugin-management-feedback")).toContainText(
`Plugin directory does not exist: ${missingSource}`,
);
await expect(page.getByLabel("Plugin source")).toHaveValue(missingSource);
await capturePluginInstallForm(page, testInfo, "compact-error");
await installPlugin(page, pathToFileURL(repository).href);
await expect(page.getByText("Installed git-e2e-plugin", { exact: true })).toBeVisible();
await expect(page.getByLabel("git-e2e-plugin running")).toBeVisible();
await expect(
page.getByText("Shows how a realistically long plugin description wraps on a compact screen"),
).toBeVisible();
await expect(page.getByLabel("compact-disabled-plugin disabled")).toBeVisible();
await expect(page.getByLabel("Plugin source")).toHaveValue("");
await capturePluginInstallForm(page, testInfo, "compact-success");
await openPluginActions(page, "git-e2e-plugin");
const removeAction = page.getByRole("menuitem", { name: "Remove", exact: true });
await expect(removeAction).toBeInViewport({ ratio: 1 });
await waitForSettledPosition(removeAction);
await capturePluginInstallForm(page, testInfo, "compact-menu");
await page.keyboard.press("Escape");
}
async function prepareLegacyPluginHost(
page: Page,
pluginEnvironment: PluginEnvironment,
sourceSupport: boolean | undefined,
) {
const directory = await createDirectoryPlugin(
pluginEnvironment.directory,
"legacy-source-plugin",
undefined,
"Legacy plugin",
);
const { client } = pluginEnvironment;
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installPluginSource({ source: directory });
await advertisePluginCapabilities(page, {
pluginSourceInstallation: sourceSupport,
pluginGitManagement: true,
});
await gotoAppShell(page);
await openPluginSettings(page);
return directory;
}
async function verifyLegacyPluginManagement(page: Page, directory: string) {
await expect(
page.getByText("Update this host to install plugins", { exact: true }),
).toBeVisible();
await expect(page.getByRole("textbox", { name: "Plugin source", exact: true })).toHaveCount(0);
await expect(page.getByText(directory, { exact: true })).toBeVisible();
await selectPluginAction(page, "legacy-source-plugin", "Reload");
await expect(page.getByText("Reloaded legacy-source-plugin", { exact: true })).toBeVisible();
}
async function prepareNpmPluginHost(
page: Page,
pluginEnvironment: PluginEnvironment,
viewport: { width: number; height: number },
) {
const { client } = pluginEnvironment;
await page.setViewportSize(viewport);
await advertisePluginCapabilities(page, { pluginGitManagement: false });
await client.patchDaemonConfig({ pluginsEnabled: true });
await gotoAppShell(page);
await openNpmPluginSettings(page, viewport.width);
}
async function retryNpmInstallAndInspectRow(page: Page, width: number, testInfo: TestInfo) {
await installPlugin(page, "npm:missing-plugin");
await expect(page.getByTestId("plugin-management-feedback")).toContainText("404");
await expect(page.getByLabel("Plugin source")).toHaveValue("npm:missing-plugin");
await installPlugin(page, "npm:@paseo-fixture/review@^2.0.0");
await expect(page.getByText("Installed npm-review", { exact: true })).toBeVisible();
await expect(page.getByLabel("npm-review running")).toBeVisible();
await expectSourceHierarchy(
page,
"Installed from the npm fixture registry",
"npm:@paseo-fixture/review · 2.0.0",
);
await expect(
page.getByText("Installed from the npm fixture registry", { exact: true }),
).toBeVisible();
await page.screenshot({
path: testInfo.outputPath(`npm-plugin-${width}.png`),
animations: "disabled",
});
}
async function reloadAndRemoveNpmPlugin(page: Page) {
await selectPluginAction(page, "npm-review", "Reload");
await expect(page.getByText("Reloaded npm-review", { exact: true })).toBeVisible();
page.once("dialog", (dialog) => dialog.accept());
await selectPluginAction(page, "npm-review", "Remove");
await expect(page.getByText("Removed npm-review", { exact: true })).toBeVisible();
await expect(page.getByLabel("npm-review running")).toHaveCount(0);
}
test("renders installed-plugin descriptions as plain text", async ({ page, pluginEnvironment }) => {
const description = overviewCorpus.map(({ markdown }) => markdown).join("\n\n");
await installPluginWithUntrustedDescription(pluginEnvironment, description);
await gotoAppShell(page);
await openPluginSettings(page);
const text = page.getByText(description, { exact: true });
await expect(text).toBeVisible();
// This field must remain a text node, with no Markdown links, images, or HTML children.
expect(await text.evaluate((element) => element.childElementCount)).toBe(0);
});
async function installPluginWithUntrustedDescription(
environment: PluginEnvironment,
description: string,
) {
const directory = await createDirectoryPlugin(
environment.directory,
"plain-text-plugin",
description,
"Plain text plugin",
);
await environment.client.installPluginSource({ source: directory });
}
/** Waits for the reload request and the daemon's answer on the wire, so a scenario can measure it. */
function observePluginReload(page: Page) {
const requests: number[] = [];
const responses: number[] = [];
const isSessionMessage = (payload: unknown, type: string): boolean => {
if (typeof payload !== "string") return false;
try {
const envelope = JSON.parse(payload) as { type?: unknown; message?: { type?: unknown } };
const message = envelope.type === "session" ? envelope.message : envelope;
return message?.type === type;
} catch {
return false;
}
};
page.on("websocket", (socket) => {
// The reload request travels client -> daemon (outbound); the answer comes back inbound.
socket.on("framesent", ({ payload }) => {
if (isSessionMessage(payload, "plugin.reload.request")) requests.push(Date.now());
});
socket.on("framereceived", ({ payload }) => {
if (isSessionMessage(payload, "plugin.reload.response")) responses.push(Date.now());
});
});
return {
waitForRequest: () => expect.poll(() => requests.length, { timeout: 30_000 }).toBe(1),
waitForResponse: () => expect.poll(() => responses.length, { timeout: 120_000 }).toBe(1),
requestedAt: () => requests[0]!,
requestCount: () => requests.length,
};
}
/** Starts a gated git install that holds the daemon's plugin lifecycle queue until it is released. */
async function holdPluginLifecycleQueue(
client: PluginEnvironment["client"],
root: string,
): Promise<{ release: () => Promise<void>; settle: () => Promise<void> }> {
const gate = {
started: path.join(root, "build-started"),
release: path.join(root, "build-release"),
};
const repository = await createGatedBuildRepository(root, gate);
const install = client.installPluginSource({ source: `git:${pathToFileURL(repository).href}` });
// The marker wait below can outlive an early failure: a refused preparation, a dropped
// connection, or a build command that exits before writing its marker. Handle that rejection now
// so it never surfaces as an unhandled one, and drain the same promise in finish().
const installDrained = install.catch(() => undefined);
// Every path releases the gate and drains the install: a build still polling for its gate holds
// the daemon's plugin lifecycle queue open, and the fixture's own plugin removal would stall.
const finish = async () => {
await writeFile(gate.release, "go").catch(() => undefined);
await installDrained;
};
try {
// Whichever comes first decides the scenario: the build reports that it started, or the setup
// fails with the daemon's own error instead of a bare marker timeout.
await Promise.race([
expect.poll(() => existsSync(gate.started), { timeout: 30_000 }).toBe(true),
install,
]);
} catch (error) {
// Setup that never reached the build must not hand the caller a queue it cannot settle.
await finish();
throw error;
}
return {
release: () => writeFile(gate.release, "go"),
settle: finish,
};
}
function pluginRowActions(page: Page, pluginId: string) {
return page.getByRole("button", { name: `Actions for ${pluginId}`, exact: true });
}
async function expectPluginRowPending(page: Page, pluginId: string): Promise<void> {
await expect(pluginRowActions(page, pluginId)).toBeDisabled();
}
async function expectPluginRowSettled(page: Page, pluginId: string): Promise<void> {
await expect(pluginRowActions(page, pluginId)).toBeEnabled();
await expect(page.getByLabel(`${pluginId} running`)).toBeVisible();
}
async function capturePluginSurface(page: Page, testInfo: TestInfo, name: string): Promise<void> {
await page.screenshot({
path: testInfo.outputPath(name),
fullPage: true,
animations: "disabled",
});
}
/** The wait under test is the daemon's lifecycle queue, so the scenario has to spend real time. */
async function waitPastFormerReloadDeadline(page: Page, requestedAt: number): Promise<void> {
const remaining = 66_000 - (Date.now() - requestedAt);
if (remaining > 0) await page.waitForTimeout(remaining);
}
test("keeps Reload pending past a minute while the plugin queue is busy", async ({
page,
pluginEnvironment,
}, testInfo) => {
test.setTimeout(180_000);
const { client, directory } = pluginEnvironment;
const pluginDirectory = await createDirectoryPlugin(
directory,
"e2e-plugin",
"Exercises the complete local plugin lifecycle",
"Plugin v1",
);
// Observe before the app loads: its daemon socket comes up with the page.
const reload = observePluginReload(page);
await gotoAppShell(page);
await openPluginSettings(page);
// Plugins on is a precondition here, not the subject: set it through the daemon instead of the
// switch, whose transient "Plugins enabled" confirmation this scenario must not depend on.
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installPluginSource({ source: pluginDirectory });
await expectPluginRowSettled(page, "e2e-plugin");
const queue = await holdPluginLifecycleQueue(client, directory);
try {
await selectPluginAction(page, "e2e-plugin", "Reload");
await reload.waitForRequest();
// Pending: the row's actions stay disabled, so a second reload cannot be submitted.
await expectPluginRowPending(page, "e2e-plugin");
await capturePluginSurface(page, testInfo, "plugin-reload-pending.png");
await waitPastFormerReloadDeadline(page, reload.requestedAt());
// Past the former deadline the reload is still pending, the row shows its previous state, and
// no timeout error reached the surface.
await expectPluginRowPending(page, "e2e-plugin");
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
await expect(page.getByTestId("plugin-management-feedback")).toHaveCount(0);
await capturePluginSurface(page, testInfo, "plugin-reload-past-deadline.png");
await queue.release();
// The daemon's answer only arrives once the plugin really started again; a request timeout
// would have shown up as feedback during the wait above.
await reload.waitForResponse();
expect(reload.requestCount()).toBe(1);
await expect(page.getByTestId("plugin-management-feedback")).toContainText(
"Reloaded e2e-plugin",
);
await expectPluginRowSettled(page, "e2e-plugin");
await capturePluginSurface(page, testInfo, "plugin-reload-completed.png");
} finally {
await queue.settle();
}
});