Files
paseo/packages/cli/src/commands/plugin/scaffold.test.ts
T
Mohamed Boudra deb846eed6 Let plugins use Paseo modals, icons, and toasts (#3967)
* feat(plugins): add React Native UI primitives

Give client plugins host-owned adaptive UI without exposing app internals. Preserve plugin runtime contexts when modal content moves through the compact portal host.

* docs(plugins): clarify host UI reference

* refactor(plugins): harden host UI boundary

Replace mocked pass-through coverage with a real browser plugin journey, keep host runtime ownership inside the evaluator, preserve toast context across compact portals, and expose modal content as a compound component.

* fix(plugins): defer native UI presentation runtime

Loading the plugin evaluator initialized the modal and toast presentation stacks even when no plugin requested host UI. Keep the capability stable while delaying platform-heavy presentation code until Modal.Content renders.

* refactor(plugins): move modal metadata to root

Keep Modal.Content body-only while the top-level Modal owns the host-rendered title and leading icon. This leaves presentation metadata stable as the compound API gains future regions.
2026-08-28 11:43:30 +02:00

195 lines
6.5 KiB
TypeScript

import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { tmpdir } from "node:os";
import ts from "typescript";
import { afterEach, describe, expect, it } from "vitest";
import { scaffoldPluginDirectory } from "./scaffold.js";
const directories: string[] = [];
afterEach(async () => {
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true })));
});
describe("plugin scaffold", () => {
it("creates a standalone split-runtime project that typechecks", 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 loaded = ts.readConfigFile(configPath, ts.sys.readFile);
expect(loaded.error).toBeUndefined();
const parsed = ts.parseJsonConfigFileContent(loaded.config, ts.sys, directory);
const diagnostics = ts.getPreEmitDiagnostics(
ts.createProgram(parsed.fileNames, parsed.options),
);
expect(
diagnostics.map((diagnostic) =>
ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"),
),
).toEqual([]);
expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual({
id: "hello-plugin",
});
await expect(readFile(path.join(directory, "index.ts"), "utf8")).resolves.toContain(
'from "./main.client"',
);
await expect(readFile(path.join(directory, "main.client.tsx"), "utf8")).resolves.toContain(
"Hello from my plugin",
);
});
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, "inspect.shared.ts"),
`import { defineRpc } from "@getpaseo/plugin/server";
import { z } from "zod";
export const inspect = defineRpc({
name: "inspect",
input: z.object({}),
output: z.object({ configured: z.boolean() }),
});
`,
),
writeFile(
path.join(directory, "inspect.server.ts"),
`import type { PluginHandlerContext } from "@getpaseo/plugin/server";
import type { output as ZodOutput } from "zod";
import { inspect } from "./inspect.shared";
export async function inspectConfig(
_input: ZodOutput<typeof inspect.input>,
{ paseo }: PluginHandlerContext,
) {
return { configured: Boolean((await paseo.config.get()).config) };
}
`,
),
writeFile(
path.join(directory, "main.client.tsx"),
`import React from "react";
import { Text } from "react-native";
import { Icon, Modal, useToast } from "@getpaseo/plugin/react-native";
import {
type PluginAgentPanelProps,
type PluginClientContext,
type PluginComposerPillProps,
useAgent,
usePaseo,
useWorkspace,
} from "@getpaseo/plugin";
import { inspect } from "./inspect.shared";
export function Surface() {
const paseo = usePaseo();
const toast = useToast();
const createWorkspace = () => paseo.workspaces.create({
source: { kind: "directory", path: "/repo" },
});
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 ComposerPill({ workspaceId, agentId }: PluginComposerPillProps) {
return <Text>{workspaceId}: {agentId}</Text>;
}
export function contributeClient(client: PluginClientContext) {
return client.addComposerPill({
id: "open-review",
title: "Open review",
workspaceId: "workspace-a",
agentId: "agent-a",
Component: ComposerPill,
async onPress() {
await client.rpc(inspect, {});
client.openPanel("review", { workspaceId: "workspace-a", agentId: "agent-a" });
},
});
}
`,
),
writeFile(
path.join(directory, "index.ts"),
`import type { PluginContext } from "@getpaseo/plugin";
import { AgentPanel, contributeClient, Surface } from "./main.client";
import { inspectConfig } from "./inspect.server";
import { inspect } from "./inspect.shared";
export default function contribute(plugin: PluginContext) {
plugin.handle(inspect, inspectConfig);
plugin.addSurface("main", Surface);
plugin.addWorkspacePanel({
id: "review",
title: "Review",
icon: "Scan",
context: "agent",
Component: AgentPanel,
});
plugin.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");
},
});
plugin.addClientSide(contributeClient);
return () => {};
}
`,
),
]);
const configPath = path.join(directory, "tsconfig.json");
const loaded = ts.readConfigFile(configPath, ts.sys.readFile);
const parsed = ts.parseJsonConfigFileContent(loaded.config, ts.sys, directory);
const diagnostics = ts.getPreEmitDiagnostics(
ts.createProgram(parsed.fileNames, parsed.options),
);
expect(
diagnostics.map((diagnostic) =>
ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"),
),
).toEqual([]);
}, 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");
});
});