mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 18:22:38 +00:00
* 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
360 lines
14 KiB
TypeScript
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");
|
|
});
|
|
});
|