mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
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:
@@ -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 () => {
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
Reference in New Issue
Block a user