* feat(plugins): add local plugin surfaces and RPCs Keep plugin execution scoped to its daemon while allowing one local TypeScript entry point to contribute validated RPCs and native client surfaces. Paseo owns host selection, navigation chrome, and per-installation query caches. * feat(plugins): add Linear attachment source * fix(nix): refresh npm dependency hash * test(server): exclude internal sessions from Hub counts * test(server): distinguish Hub execution sessions * feat(plugins): make local plugins explicitly opt-in Keep plugin loading dormant until configuration enables it, and collect unsupported examples under one directory. * refactor(plugins): establish host boundaries * fix(nix): refresh plugin dependency hash * fix(plugins): complete packaging and startup rollback * feat(plugins): add managed local lifecycle Make trusted local plugins recoverable and authorable through daemon-owned lifecycle operations, host settings, and a capability-gated CLI. Reloads fully stop the previous plugin before starting new code, while generated projects provide strict TSX checking without changing runtime dependency ownership. * fix(nix): refresh plugin dependency hash
210 lines
8.8 KiB
TypeScript
210 lines
8.8 KiB
TypeScript
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { expect, test, type Page } from "../support/fixtures";
|
|
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
|
import { getServerId } from "../support/helpers/server-id";
|
|
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
|
import {
|
|
expectSettingsHeader,
|
|
openHostSection,
|
|
openSettingsHost,
|
|
} from "../support/helpers/settings";
|
|
|
|
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 reloadPlugin(page: Page): Promise<void> {
|
|
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
|
await expect(page.getByText("Reloaded e2e-plugin", { exact: true })).toBeVisible();
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
test("installs, reloads, recovers, disables, and removes a trusted local plugin", async ({
|
|
page,
|
|
}) => {
|
|
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-e2e-"));
|
|
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
|
const previous = await client.getDaemonConfig();
|
|
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "e2e-plugin" }));
|
|
await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v1"));
|
|
|
|
try {
|
|
const catalog = observePluginCatalog(page);
|
|
await gotoAppShell(page);
|
|
await openPluginSettings(page);
|
|
await catalog.waitForInitialFetch();
|
|
await client.patchDaemonConfig({ pluginsEnabled: false });
|
|
await page.getByRole("switch", { name: "Enable plugins" }).click();
|
|
await expect(page.getByText("Plugins enabled", { exact: true })).toBeVisible();
|
|
await page.getByLabel("Plugin directory").fill(directory);
|
|
await page.getByLabel("Plugin installation ID").focus();
|
|
await expect(page.getByLabel("Plugin installation ID")).toHaveValue("e2e-plugin");
|
|
await page.getByRole("button", { name: "Install directory" }).click();
|
|
await expect(page.getByText("Installed e2e-plugin", { exact: true })).toBeVisible();
|
|
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
|
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 0");
|
|
|
|
await openPluginSettings(page);
|
|
await reloadPlugin(page);
|
|
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 1");
|
|
|
|
await writeFile(path.join(directory, "index.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.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.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.tsx"), "export default broken syntax !!!");
|
|
await openPluginSettings(page);
|
|
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
|
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.tsx"), pluginSource("Plugin v3"));
|
|
await openPluginSettings(page);
|
|
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
|
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
|
await openContributionFromSettings(page, "Plugin v3", "Plugin v3 cleanup 5");
|
|
|
|
await openPluginSettings(page);
|
|
await page.getByRole("button", { name: "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("button", { name: "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 page.getByRole("button", { name: "Remove", exact: true }).click();
|
|
await expect(page.getByText("No plugins configured", { exact: true })).toBeVisible();
|
|
|
|
await page.getByLabel("Plugin directory").fill(directory);
|
|
await page.getByRole("button", { name: "Install directory" }).click();
|
|
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
|
await gotoAppShell(page);
|
|
await openContribution(page, "Plugin v3", "Plugin v3 cleanup 7");
|
|
} finally {
|
|
await client.removePlugin("e2e-plugin").catch(() => undefined);
|
|
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
|
|
await client.close().catch(() => undefined);
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|