Files
paseo/packages/cli/src/commands/plugin/scaffold.test.ts
T
Mohamed Boudra 2d85fe4e04 feat(plugins): declare display metadata in plugin manifests (#6263)
* feat(plugins): declare display metadata in plugin manifests

* style(website): format plugin page description

* fix(plugins): preserve default names and clarify registry follow-up

* test: fix stale plugin card selectors and terminal marker race
2026-10-06 23:18:24 +01:00

360 lines
14 KiB
TypeScript

import { exec, execFile } from "node:child_process";
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
import { tmpdir } from "node:os";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import { scaffoldPluginDirectory } from "./scaffold.js";
const directories: string[] = [];
const execFileAsync = promisify(execFile);
const execAsync = promisify(exec);
const tscPath = createRequire(import.meta.url).resolve("typescript/bin/tsc");
async function typecheckPlugin(directory: string): Promise<void> {
const configPath = path.join(directory, "tsconfig.json");
try {
await execFileAsync(process.execPath, [tscPath, "--noEmit", "-p", configPath], {
cwd: directory,
});
} catch (error) {
const failure = error as { stderr?: string; stdout?: string };
throw new Error(`${failure.stdout ?? ""}${failure.stderr ?? ""}`, { cause: error });
}
}
afterEach(async () => {
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true })));
});
describe("plugin scaffold", () => {
it("creates an author overview beside the manifest", async () => {
const parent = await mkdtemp(path.join(tmpdir(), "paseo-plugin-scaffold-"));
directories.push(parent);
const directory = path.join(parent, "hello-plugin");
await scaffoldPluginDirectory(directory);
const overview = await readFile(path.join(directory, "OVERVIEW.md"), "utf8");
const comments = overview.match(/<!--[\s\S]*?-->/g) ?? [];
expect(comments).toHaveLength(1);
expect(overview.replace(/<!--[\s\S]*?-->/g, "").trim()).toBe("");
expect(overview).toContain("required to list");
expect(overview).toContain("README");
expect(overview).toContain("choosing a plugin in Paseo");
expect(overview).toContain("Length follows complexity");
expect(overview).toContain("1. Describe what your plugin is and does");
expect(overview).toContain("2. Explain how it works only when it is not obvious");
expect(overview).toContain("3. Explain setup when needed");
expect(overview).toContain("4. Explain capabilities and settings");
expect(overview).toContain("permissions");
expect(overview).toContain("known limits");
expect(overview).not.toContain("This plugin entry was imported");
const packageJson = JSON.parse(await readFile(path.join(directory, "package.json"), "utf8"));
expect(packageJson.files).toContain("OVERVIEW.md");
expect(await readdir(directory)).toContain("paseo-plugin.json");
});
it("includes the author overview in the packed npm artifact", async () => {
const parent = await mkdtemp(path.join(tmpdir(), "paseo-plugin-scaffold-"));
directories.push(parent);
const directory = path.join(parent, "packed-plugin");
await scaffoldPluginDirectory(directory);
const { stdout } = await execAsync("npm pack --dry-run --ignore-scripts --json", {
cwd: directory,
});
const [artifact] = JSON.parse(stdout) as { files: { path: string }[] }[];
expect(artifact.files.map((file) => file.path)).toEqual(
expect.arrayContaining(["OVERVIEW.md", "paseo-plugin.json"]),
);
});
it(
"creates a standalone split-runtime project that typechecks",
{ timeout: 15_000 },
async () => {
const parent = await mkdtemp(path.join(process.cwd(), ".plugin-scaffold-"));
directories.push(parent);
const directory = path.join(parent, "hello-plugin");
await scaffoldPluginDirectory(directory);
const configPath = path.join(directory, "tsconfig.json");
const config = JSON.parse(await readFile(configPath, "utf8")) as {
compilerOptions: { lib: string[]; types: string[] };
};
expect(config.compilerOptions.lib).toEqual(["ES2023"]);
expect(config.compilerOptions.types).toEqual(["react"]);
await expect(typecheckPlugin(directory)).resolves.toBeUndefined();
const cliPackageJson = JSON.parse(
await readFile(new URL("../../../package.json", import.meta.url), "utf8"),
) as { version: string };
expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual(
{
id: "hello-plugin",
$comment: expect.stringContaining('"icon": "assets/icon.png"'),
media: [],
requirements: { paseo: `>=${cliPackageJson.version}` },
},
);
expect(JSON.parse(await readFile(path.join(directory, "package.json"), "utf8"))).toEqual({
name: "hello-plugin",
private: true,
version: "0.0.0",
files: [
"paseo-plugin.json",
"OVERVIEW.md",
"index.client.ts",
"index.client.tsx",
"index.server.ts",
"index.server.tsx",
"client/",
"server/",
"shared/",
],
scripts: { typecheck: "tsc --noEmit" },
devDependencies: {
"@getpaseo/plugin": cliPackageJson.version,
"@tanstack/react-query": "^5.90.11",
"@types/react": "~19.2.0",
react: "19.1.0",
"react-native": "0.81.5",
typescript: "^5.9.3",
zod: "^4.4.3",
},
});
expect(await readdir(directory)).not.toContain("paseo-plugin.d.ts");
await expect(readFile(path.join(directory, "index.client.tsx"), "utf8")).resolves.toContain(
'from "./client/greeting"',
);
await expect(readFile(path.join(directory, "index.server.ts"), "utf8")).resolves.toContain(
'from "./server/greeting"',
);
await expect(
readFile(path.join(directory, "client/greeting.tsx"), "utf8"),
).resolves.toContain("useRpc(greetingRpc)");
await expect(readFile(path.join(directory, "client/web.ts"), "utf8")).resolves.not.toContain(
`/// <reference lib="dom" />`,
);
await expect(readFile(path.join(directory, "client/web.ts"), "utf8")).resolves.toContain(
`declare const window: { open(url: string, target: string, features: string): unknown };`,
);
await expect(readFile(path.join(directory, "client/web.ts"), "utf8")).resolves.toContain(
`Platform.OS === "web"`,
);
await expect(
readFile(path.join(directory, "client/greeting.tsx"), "utf8"),
).resolves.toContain(`openExternal("https://paseo.sh")`);
await expect(readFile(path.join(directory, "server/greeting.ts"), "utf8")).resolves.toContain(
'"Hello, " + name + "!"',
);
await expect(readFile(path.join(directory, "shared/greeting.ts"), "utf8")).resolves.toContain(
'name: "greeting.create"',
);
await writeFile(path.join(directory, "client/greeting.tsx"), '\ndocument.title = "x";\n', {
flag: "a",
});
await expect(typecheckPlugin(directory)).rejects.toThrow("Cannot find name 'document'.");
},
);
it("typechecks client and server Paseo API access", async () => {
const parent = await mkdtemp(path.join(process.cwd(), ".plugin-scaffold-"));
directories.push(parent);
const directory = path.join(parent, "paseo-api-plugin");
await scaffoldPluginDirectory(directory);
await Promise.all([
writeFile(
path.join(directory, "shared", "inspect.ts"),
`import { defineRpc } from "@getpaseo/plugin";
import { z } from "zod";
export const inspect = defineRpc({
name: "inspect",
input: z.object({}),
output: z.object({ configured: z.boolean() }),
});
`,
),
writeFile(
path.join(directory, "server", "inspect.ts"),
`import type { PluginHandlerContext } from "@getpaseo/plugin/server";
import type { RpcInput } from "@getpaseo/plugin";
import { inspect } from "../shared/inspect";
export async function inspectConfig(
_input: RpcInput<typeof inspect>,
{ paseo }: PluginHandlerContext,
) {
return { configured: Boolean((await paseo.config.get()).config) };
}
`,
),
writeFile(
path.join(directory, "client", "main.tsx"),
`import React from "react";
import { Text } from "react-native";
import { Icon, Modal, useToast } from "@getpaseo/plugin/client/react-native";
import { type PluginAgentPanelProps, type PluginClientContext, type PluginSurfaceProps, useAgent, usePaseo, useWorkspace } from "@getpaseo/plugin/client";
import { inspect } from "../shared/inspect";
export function Surface({ navigation }: PluginSurfaceProps) {
const paseo = usePaseo();
const toast = useToast();
const createWorkspace = () => paseo.workspaces.create({
source: { kind: "directory", path: "/repo" },
});
navigation?.openAgent({ agentId: "agent-1" });
navigation?.openWorkspace({ workspaceId: "workspace-1" });
navigation?.openAgent({ serverId: "server-2", agentId: "agent-2" });
navigation?.openWorkspace({ serverId: "server-2", workspaceId: "workspace-2" });
void createWorkspace;
return <><Icon name="Settings" size={18} color="#123456" /><Text onPress={() => toast.show("Ready")}>Paseo API</Text><Modal title="Example" icon={<Icon name="Settings" />} open={false} onOpenChange={() => {}}><Modal.Content><Text>Modal</Text></Modal.Content></Modal></>;
}
export function AgentPanel({ workspaceId, agentId }: PluginAgentPanelProps) {
const workspaceName = useWorkspace(workspaceId, (workspace) => {
// @ts-expect-error Plugin snapshots are readonly.
workspace.name = "mutated";
return workspace.name;
});
const agentTitle = useAgent(agentId, (agent) => {
// @ts-expect-error Nested plugin snapshot values are readonly.
agent.labels.phase = "mutated";
return agent.title;
});
return <Text>{workspaceName}: {agentTitle}</Text>;
}
export function contributeClient(client: PluginClientContext) {
const header = client.addHeaderButton({
id: "review-tools",
workspaceId: "workspace-a",
button: {
title: "Review tools",
icon: "Scan",
label: "Review",
behavior: {
kind: "menu",
items: [{ kind: "item", id: "refresh", title: "Refresh review", behavior: {
kind: "action", async onPress() { await client.rpc(inspect, {}); },
} }],
},
},
});
const pill = client.addComposerPill({
id: "open-review",
workspaceId: "workspace-a",
agentId: "agent-a",
button: {
title: "Open review",
icon: "Scan",
behavior: {
kind: "action",
async onPress() {
await client.rpc(inspect, {});
client.openPanel("review", { workspaceId: "workspace-a", agentId: "agent-a" });
},
},
},
});
header.update({ visible: false });
pill.update({ disabled: true });
return () => { header.remove(); pill.remove(); };
}
`,
),
writeFile(
path.join(directory, "index.client.tsx"),
`import type { PluginClientContext } from "@getpaseo/plugin/client";
import { AgentPanel, contributeClient, Surface } from "./client/main";
import { inspect } from "./shared/inspect";
export default function contribute(client: PluginClientContext) {
client.addScreen({ id: "main", title: "Review", Component: Surface });
client.addWorkspacePanel({
id: "review",
title: "Review",
icon: "Scan",
context: "agent",
Component: AgentPanel,
});
client.addCommandCenterItem({
id: "open-review",
title: "Open review",
icon: "Scan",
context: "agent",
async onSelect({ paseo, rpc, workspace, openPanel }) {
await paseo.workspaces.ref(workspace.id).setTitle("Review");
await rpc(inspect, {});
openPanel("review");
},
});
return contributeClient(client);
}
`,
),
writeFile(
path.join(directory, "index.server.ts"),
`import type { PluginServerContext } from "@getpaseo/plugin/server";
import { inspectConfig } from "./server/inspect";
import { inspect } from "./shared/inspect";
export default function contribute(server: PluginServerContext) {
server.handle(inspect, inspectConfig);
return () => {};
}
`,
),
]);
await expect(typecheckPlugin(directory)).resolves.toBeUndefined();
}, 20_000);
it("reports TypeScript errors for the old composer pill shape and cleanup", async () => {
const parent = await mkdtemp(path.join(process.cwd(), ".plugin-scaffold-"));
directories.push(parent);
const directory = path.join(parent, "old-composer-pill");
await scaffoldPluginDirectory(directory);
await writeFile(
path.join(directory, "index.client.tsx"),
`
import type { PluginClientContext, PluginComposerPillProps } from "@getpaseo/plugin/client";
export default function contribute(client: PluginClientContext) {
const oldPill = {
id: "review", workspaceId: "workspace-a", agentId: "agent-a",
title: "Review", Component: () => null, onPress() {},
};
client.addComposerPill(oldPill);
const pill = client.addComposerPill({
id: "review", workspaceId: "workspace-a", agentId: "agent-a",
button: { title: "Review", icon: "Scan", behavior: { kind: "action", onPress() {} } },
});
pill();
return () => pill.remove();
}
`,
);
const check = typecheckPlugin(directory);
await expect(check).rejects.toThrow(
/has no exported member (named )?'PluginComposerPillProps'/,
);
await expect(check).rejects.toThrow("Property 'button' is missing");
await expect(check).rejects.toThrow("has no call signatures");
}, 20_000);
it("refuses to write into a non-empty directory", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-scaffold-"));
directories.push(directory);
await writeFile(path.join(directory, "notes.txt"), "keep me");
await expect(scaffoldPluginDirectory(directory, "hello-plugin")).rejects.toThrow(
"Plugin directory must be empty",
);
expect(await readFile(path.join(directory, "notes.txt"), "utf8")).toBe("keep me");
});
});