diff --git a/packages/app/e2e/browser/plugin-management.spec.ts b/packages/app/e2e/browser/plugin-management.spec.ts index 77f988df0f..67a7a62d3c 100644 --- a/packages/app/e2e/browser/plugin-management.spec.ts +++ b/packages/app/e2e/browser/plugin-management.spec.ts @@ -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, +): Promise { + 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 { async function createDirectoryPlugin( id: string, - description: string, + description: string | undefined, title: string, ): Promise { 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); diff --git a/packages/app/src/screens/settings/plugins-page.test.tsx b/packages/app/src/screens/settings/plugins-page.test.tsx index 9d6cb12975..6301e5916a 100644 --- a/packages/app/src/screens/settings/plugins-page.test.tsx +++ b/packages/app/src/screens/settings/plugins-page.test.tsx @@ -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()); - 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()); - 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(); diff --git a/packages/cli/src/utils/command-options.test.ts b/packages/cli/src/utils/command-options.test.ts index f491c6db03..3fdb9ede18 100644 --- a/packages/cli/src/utils/command-options.test.ts +++ b/packages/cli/src/utils/command-options.test.ts @@ -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 () => { diff --git a/packages/cli/src/utils/command-options.ts b/packages/cli/src/utils/command-options.ts index 02fcd924a0..2274785cc4 100644 --- a/packages/cli/src/utils/command-options.ts +++ b/packages/cli/src/utils/command-options.ts @@ -47,8 +47,10 @@ export function withGlobalOptions( 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; diff --git a/packages/server/src/server/plugins/requirements.posix.test.ts b/packages/server/src/server/plugins/requirements.posix.test.ts index a2b10188c8..94df4baf67 100644 --- a/packages/server/src/server/plugins/requirements.posix.test.ts +++ b/packages/server/src/server/plugins/requirements.posix.test.ts @@ -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([]);