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

Move new source capability coverage to real browser journeys. Update the requirements regression to exercise preview and apply, and preserve the existing duplicate-selector diagnostic across positional option scopes.
This commit is contained in:
Mohamed Boudra
2026-09-16 23:40:07 +02:00
parent c3a1e0027d
commit 1e53533235
5 changed files with 78 additions and 138 deletions
@@ -8,6 +8,7 @@ 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";
@@ -43,6 +44,30 @@ const test = base.extend<{}, { npmRegistry: Awaited<ReturnType<typeof startNpmRe
],
});
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) => {
@@ -221,7 +246,7 @@ async function createGitPluginRepository(root: string): Promise<string> {
async function createDirectoryPlugin(
id: string,
description: string,
description: string | undefined,
title: string,
): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-row-e2e-"));
@@ -442,6 +467,44 @@ test("installs a Git source after a failed source remains editable", async ({ pa
}
});
for (const sourceSupport of [undefined, false]) {
test(`keeps installed plugins manageable without source capability (${sourceSupport})`, async ({
page,
}) => {
const directory = await createDirectoryPlugin(
"legacy-source-plugin",
undefined,
"Legacy plugin",
);
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installPluginSource({ source: directory });
await advertisePluginCapabilities(page, {
pluginSourceInstallation: sourceSupport,
pluginGitManagement: true,
});
await gotoAppShell(page);
await openPluginSettings(page);
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();
} finally {
await client.removePlugin("legacy-source-plugin").catch(() => undefined);
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
await client.close();
await rm(directory, { recursive: true, force: true });
}
});
}
for (const viewport of [
{ width: 1280, height: 900 },
{ width: 390, height: 844 },
@@ -453,6 +516,7 @@ for (const viewport of [
const previous = await client.getDaemonConfig();
try {
await page.setViewportSize(viewport);
await advertisePluginCapabilities(page, { pluginGitManagement: false });
await client.patchDaemonConfig({ pluginsEnabled: true });
await gotoAppShell(page);
await openNpmPluginSettings(page, viewport.width);
@@ -16,8 +16,6 @@ void testI18n;
const runtime = vi.hoisted(() => ({
connected: true,
supported: true,
sourceSupported: true as boolean | undefined,
gitSupported: true,
logsSupported: true,
client: null as DaemonClient | null,
}));
@@ -30,8 +28,6 @@ vi.mock("@/runtime/host-runtime", () => ({
vi.mock("@/runtime/host-features", () => ({
useHostFeature: (_serverId: string, feature: string) => {
if (feature === "pluginLogs") return runtime.logsSupported;
if (feature === "pluginGitManagement") return runtime.gitSupported;
if (feature === "pluginSourceInstallation") return runtime.sourceSupported;
return runtime.supported;
},
}));
@@ -156,8 +152,6 @@ describe("HostPluginsPage", () => {
vi.stubGlobal("React", React);
runtime.connected = true;
runtime.supported = true;
runtime.sourceSupported = true;
runtime.gitSupported = true;
runtime.logsSupported = true;
runtime.client = null;
vi.stubGlobal(
@@ -205,133 +199,6 @@ describe("HostPluginsPage", () => {
await waitFor(() => expect(client[method]).toHaveBeenCalledTimes(1));
});
it("disables the plugin switch and menu while an action is pending", async () => {
const client = createClient();
client.listPlugins.mockResolvedValue([plugin()]);
client.reloadPlugin.mockImplementation(() => never<never>());
renderPage(client);
await selectPluginAction("Reload");
await waitFor(() => {
expect(
screen.getByRole("switch", { name: "example: Disable" }).getAttribute("aria-disabled"),
).toBe("true");
expect(
screen.getByRole("button", { name: "Actions for example" }).getAttribute("aria-disabled"),
).toBe("true");
});
});
it.each([
[true, "disablePlugin"],
[false, "enablePlugin"],
] as const)("toggles an enabled=%s plugin with its switch", async (enabled, method) => {
const client = createClient();
client.listPlugins.mockResolvedValue([plugin(enabled)]);
renderPage(client);
fireEvent.click(
await screen.findByRole("switch", { name: `example: ${enabled ? "Disable" : "Enable"}` }),
);
await waitFor(() => expect(client[method]).toHaveBeenCalledTimes(1));
});
it("shows the manifest description and status without exposing the source path", async () => {
const client = createClient();
client.listPlugins.mockResolvedValue([
plugin(),
{
id: "disabled-example",
description: "Available when needed",
path: "/plugins/disabled-example",
enabled: false,
status: "disabled",
},
{
id: "failed-example",
description: "Needs attention",
path: "/plugins/failed-example",
enabled: true,
status: "failed",
error: "Plugin failed to start",
},
]);
renderPage(client);
expect(await screen.findByText("Reviews changes before merge")).toBeDefined();
expect(screen.getByText("Available when needed")).toBeDefined();
expect(screen.getByText("Needs attention")).toBeDefined();
expect(screen.getByText("running")).toBeDefined();
expect(screen.getByText("disabled")).toBeDefined();
expect(screen.getByText("failed")).toBeDefined();
expect(screen.getByText("Plugin failed to start")).toBeDefined();
expect(screen.queryByText("/plugins/example")).toBeNull();
expect(screen.queryByText("/plugins/disabled-example")).toBeNull();
expect(screen.queryByText("/plugins/failed-example")).toBeNull();
});
it("shows current npm revision and a directory identity without a description", async () => {
const client = createClient();
client.listPlugins.mockResolvedValue([
{
...plugin(),
installation: {
identity: { kind: "npm", packageName: "@getpaseo/example", pluginPath: "." },
currentRevision: "1.1.0",
},
},
{
id: "local",
path: "/plugins/local",
enabled: false,
status: "disabled",
installation: { identity: { kind: "directory", path: "/plugins/local" } },
},
]);
renderPage(client);
expect(await screen.findByText(/npm:@getpaseo\/example · 1.1.0/)).toBeDefined();
expect(screen.getByText("/plugins/local")).toBeDefined();
});
it("renders install pending with source support independently of Git support", async () => {
runtime.gitSupported = false;
const client = createClient();
client.installPluginSource.mockImplementation(() => never<never>());
renderPage(client);
expect(screen.getByPlaceholderText("Directory, Git URL, or npm package")).toBeDefined();
fireEvent.change(screen.getByLabelText("Plugin source"), {
target: { value: "/plugins/example" },
});
fireEvent.click(screen.getByRole("button", { name: "Install plugin" }));
const pendingControl = await screen.findByRole("button", { name: "Installing…" });
expect(pendingControl.getAttribute("aria-disabled")).toBe("true");
expect(client.installPluginSource).toHaveBeenCalledWith({ source: "/plugins/example" });
expect(screen.queryByLabelText("Plugin installation ID")).toBeNull();
expect(screen.getByRole("link", { name: "Docs" })).toBeDefined();
});
it.each([undefined, false])(
"keeps management available on a Git-capable host with source support=%s",
async (sourceSupport) => {
runtime.sourceSupported = sourceSupport;
runtime.gitSupported = true;
const client = createClient();
client.listPlugins.mockResolvedValue([plugin()]);
renderPage(client);
expect(await screen.findByRole("button", { name: "Actions for example" })).toBeDefined();
expect(screen.getByText("Update this host to install plugins")).toBeDefined();
expect(screen.queryByLabelText("Plugin source")).toBeNull();
expect(client.installPluginSource).not.toHaveBeenCalled();
await selectPluginAction("Reload");
await waitFor(() => expect(client.reloadPlugin).toHaveBeenCalledWith("example"));
},
);
it("hides the logs action when the host does not advertise support", async () => {
runtime.logsSupported = false;
const client = createClient();
@@ -31,7 +31,10 @@ describe("global command options", () => {
it("rejects conflicting parent and command selectors", async () => {
await expect(
parseHost(["--host", "first:6767", "ls", "--host", "last:6767"]),
).rejects.toMatchObject({ code: "TARGET_AMBIGUOUS" });
).rejects.toMatchObject({
code: "TARGET_AMBIGUOUS",
message: "Conflicting duplicate --host selectors.",
});
});
it("lets local-only commands ignore a global host", async () => {
+4 -2
View File
@@ -47,8 +47,10 @@ export function withGlobalOptions<Args extends unknown[], Result>(
if (typeof value === "string") selectors[key].add(value);
}
}
if (selectors.home.size > 1 || selectors.host.size > 1)
throw { code: "TARGET_AMBIGUOUS", message: "Conflicting duplicate daemon selectors." };
for (const key of ["home", "host"] as const) {
if (selectors[key].size > 1)
throw { code: "TARGET_AMBIGUOUS", message: `Conflicting duplicate --${key} selectors.` };
}
const localOnly = localCommands.has(command);
options.daemonTarget = selectDaemonTarget(options, process.env, localOnly);
mergedArgs[mergedArgs.length - 2] = options;
@@ -120,7 +120,11 @@ it("rejects Git install and update before build commands, preserving the running
[process.execPath, "-e", 'require("node:fs").writeFileSync(process.argv[1], "ran")', marker],
]);
await commit();
await expect(service.updateSources("example")).rejects.toThrow("requires Paseo >=0.9.0");
const [preview] = await service.previewUpdates({ pluginId: "example" });
expect(preview).toMatchObject({ outcome: "update" });
await expect(service.applyUpdates([preview!.proposal!])).resolves.toMatchObject([
{ id: "example", outcome: "error", error: expect.stringContaining("requires Paseo >=0.9.0") },
]);
expect(await service.listPlugins()).toEqual([installed]);
expect(service.catalog()).toHaveLength(1);
expect(await readdir(path.join(home, "plugins", ".staging"))).toEqual([]);