mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 02:32:47 +00:00
The 0.13 UI entry rename dropped the reveal hook from the 0.12 runtime. Keep it on the legacy entry until that entry is removed, with deprecated SDK typing and migration guidance. Closes #7577 Closes #7722 Co-authored-by: luke <luopeihuan@gmail.com> Co-authored-by: RoundRedDot <roundreddot@gmail.com> Co-authored-by: Matt Cowger <1929548+mcowger@users.noreply.github.com>
896 lines
32 KiB
TypeScript
896 lines
32 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { useRevealedText } from "@/hooks/use-revealed-text";
|
|
import { runPluginClientBundle, type PluginClientRuntime } from "./evaluate";
|
|
|
|
const runtime = {
|
|
paseo: {},
|
|
async rpc() {},
|
|
openSettings() {},
|
|
openScreen() {},
|
|
openSurface() {},
|
|
openPanel() {},
|
|
addHeaderButton() {
|
|
return { update() {}, remove() {} };
|
|
},
|
|
addComposerPill() {
|
|
return { update() {}, remove() {} };
|
|
},
|
|
} as unknown as PluginClientRuntime;
|
|
|
|
function evaluatePluginClientBundle(id: string, source: string) {
|
|
return runPluginClientBundle(id, source, runtime);
|
|
}
|
|
|
|
function bundle(body: string): string {
|
|
return `(function(require) {
|
|
const module = { exports: {} };
|
|
module.exports.default = function(plugin) { ${body}; return function() {}; };
|
|
return module.exports;
|
|
})`;
|
|
}
|
|
|
|
describe("evaluatePluginClientBundle", () => {
|
|
it("releases button registrations when client setup throws", () => {
|
|
let active = 0;
|
|
function addButton() {
|
|
active++;
|
|
return {
|
|
update() {},
|
|
remove() {
|
|
active--;
|
|
},
|
|
};
|
|
}
|
|
expect(() =>
|
|
runPluginClientBundle(
|
|
"failed-buttons",
|
|
bundle(`
|
|
plugin.addHeaderButton({ id: "header", workspaceId: "workspace", button: { title: "Header", icon: "Scan", behavior: { kind: "action", onPress() {} } } });
|
|
plugin.addComposerPill({ id: "pill", workspaceId: "workspace", agentId: "agent", button: { title: "Pill", icon: "Scan", behavior: { kind: "action", onPress() {} } } });
|
|
throw new Error("Setup failed");
|
|
`),
|
|
{ ...runtime, addHeaderButton: addButton, addComposerPill: addButton },
|
|
),
|
|
).toThrow("Setup failed");
|
|
expect(active).toBe(0);
|
|
});
|
|
|
|
it("accepts memoized settings screens", () => {
|
|
const plugin = evaluatePluginClientBundle(
|
|
"settings",
|
|
bundle(`
|
|
const Component = require("react").memo(function Settings() { return null; });
|
|
plugin.addSettingsScreen({ id: "display", title: "Display", icon: "Settings", Component });
|
|
`),
|
|
);
|
|
expect(plugin.settingsScreens.map((screen) => screen.id)).toEqual(["display"]);
|
|
});
|
|
|
|
it("returns idempotent removers for every client registration", () => {
|
|
let pillCount = 0;
|
|
const plugin = runPluginClientBundle(
|
|
"removals",
|
|
bundle(`
|
|
function Component() { return null; }
|
|
const schema = { safeParse(value) { return { success: true, data: value }; } };
|
|
globalThis.__pluginRemovals = [
|
|
plugin.addScreen({ id: "main", title: "Main", Component }),
|
|
plugin.addSettingsScreen({ id: "display", title: "Display", icon: "Settings", Component }),
|
|
plugin.addSidebarHeaderItem({ id: "main", title: "Main", Component }),
|
|
plugin.addSidebarFooterItem({ id: "status", title: "Status", Component }),
|
|
plugin.addWorkspacePanel({ id: "panel", title: "Panel", icon: "Blocks", context: "workspace", Component }),
|
|
plugin.addCommandCenterItem({ id: "command", title: "Command", icon: "Blocks", context: "global", onSelect() {} }),
|
|
plugin.addSlashCommand({ name: "review", description: "Review", argumentHint: "", context: "workspace", onSubmit() {} }),
|
|
plugin.addComposerPill({ id: "pill", workspaceId: "workspace", agentId: "agent", button: { title: "Pill", icon: "Scan", behavior: { kind: "action", onPress() {} } } }).remove,
|
|
plugin.addAttachmentSource({ id: "issues", title: "Issues", icon: "Blocks", pickerTitle: "Attach issue", searchPlaceholder: "Search", search: { name: "issues.search", input: {}, output: {} } }),
|
|
plugin.addTheme({ id: "night", name: "Night", appearance: "dark", colors: { background: "#000", foreground: "#fff", raised: "#111", control: "#222", border: "#333", mutedForeground: "#aaa", ring: "#555" } }),
|
|
plugin.addTimelineTransformer({ id: "transformer", query: { itemType: "tool_call" }, transform() { return { items: [] }; } }),
|
|
plugin.addTimelineRenderer({ kind: "card", version: 1, schema, Component }),
|
|
];
|
|
`),
|
|
{
|
|
...runtime,
|
|
addComposerPill() {
|
|
pillCount += 1;
|
|
return {
|
|
update() {},
|
|
remove() {
|
|
pillCount -= 1;
|
|
},
|
|
};
|
|
},
|
|
},
|
|
);
|
|
const removals = Reflect.get(globalThis, "__pluginRemovals") as Array<() => void>;
|
|
|
|
expect(
|
|
[
|
|
plugin.surfaces,
|
|
plugin.settingsScreens,
|
|
plugin.sidebarItems.header,
|
|
plugin.sidebarItems.footer,
|
|
plugin.workspacePanels,
|
|
plugin.commandCenterItems,
|
|
plugin.clientSlashCommands,
|
|
plugin.attachmentSources,
|
|
plugin.themes,
|
|
plugin.timelineTransformers,
|
|
plugin.timelineRenderers,
|
|
].every((items) => items.length === 1),
|
|
).toBe(true);
|
|
expect(pillCount).toBe(1);
|
|
for (const remove of removals) {
|
|
remove();
|
|
remove();
|
|
}
|
|
expect(
|
|
[
|
|
plugin.surfaces,
|
|
plugin.settingsScreens,
|
|
plugin.sidebarItems.header,
|
|
plugin.sidebarItems.footer,
|
|
plugin.workspacePanels,
|
|
plugin.commandCenterItems,
|
|
plugin.clientSlashCommands,
|
|
plugin.attachmentSources,
|
|
plugin.themes,
|
|
plugin.timelineTransformers,
|
|
plugin.timelineRenderers,
|
|
].every((items) => items.length === 0),
|
|
).toBe(true);
|
|
expect(pillCount).toBe(0);
|
|
Reflect.deleteProperty(globalThis, "__pluginRemovals");
|
|
});
|
|
|
|
it.each(["tool_call", "notification"])("collects %s transformers and renderers", (itemType) => {
|
|
const plugin = evaluatePluginClientBundle(
|
|
"reports",
|
|
bundle(`
|
|
function Card() { return null; }
|
|
const schema = { safeParse(value) { return { success: true, data: value }; } };
|
|
plugin.addTimelineTransformer({
|
|
id: "test-report",
|
|
query: { itemType: "${itemType}" },
|
|
transform() { return { items: [] }; },
|
|
});
|
|
plugin.addTimelineRenderer({
|
|
kind: "test-report",
|
|
version: 1,
|
|
schema,
|
|
Component: Card,
|
|
});
|
|
`),
|
|
);
|
|
|
|
expect(plugin.timelineTransformers.map(({ id, query }) => ({ id, query }))).toEqual([
|
|
{ id: "test-report", query: { itemType } },
|
|
]);
|
|
expect(plugin.timelineRenderers.map(({ kind, version }) => ({ kind, version }))).toEqual([
|
|
{ kind: "test-report", version: 1 },
|
|
]);
|
|
});
|
|
|
|
it("rejects unknown timeline item types", () => {
|
|
expect(() =>
|
|
evaluatePluginClientBundle(
|
|
"reports",
|
|
bundle(`
|
|
plugin.addTimelineTransformer({
|
|
id: "bad-query",
|
|
query: { itemType: "settled" },
|
|
transform() { return { items: [] }; },
|
|
});
|
|
`),
|
|
),
|
|
).toThrow("Timeline transformer bad-query has invalid item type: settled");
|
|
});
|
|
|
|
it("collects screens and sidebar header and footer items", () => {
|
|
const plugin = evaluatePluginClientBundle(
|
|
"example",
|
|
bundle(`
|
|
function Screen() { return null; }
|
|
const Item = require("react").memo(function Item() { return null; });
|
|
plugin.addScreen({ id: "main", title: " Main ", Component: Screen });
|
|
plugin.addScreen({ id: "bot", title: (params) => params.botId, Component: Screen });
|
|
plugin.addSidebarHeaderItem({ id: "main", title: " Example ", Component: Item });
|
|
plugin.addSidebarFooterItem({ id: "main", title: "Status", Component: Item });
|
|
`),
|
|
);
|
|
|
|
expect(plugin.id).toBe("example");
|
|
expect(plugin.surfaces.map((surface) => surface.id)).toEqual(["main", "bot"]);
|
|
expect(plugin.surfaces[0]?.title).toBe("Main");
|
|
expect(plugin.surfaces[1]?.title).toBeTypeOf("function");
|
|
expect(plugin.sidebarItems.header.map(({ id, title }) => ({ id, title }))).toEqual([
|
|
{ id: "main", title: "Example" },
|
|
]);
|
|
expect(plugin.sidebarItems.footer.map(({ id, title }) => ({ id, title }))).toEqual([
|
|
{ id: "main", title: "Status" },
|
|
]);
|
|
expect(plugin.legacySidebarItems).toEqual([]);
|
|
});
|
|
|
|
it.each([
|
|
[`plugin.addScreen("main", Screen);`, "addScreen takes { id, title, Component }"],
|
|
[`plugin.addScreen({ id: "main", Component: Screen });`, "Screen main needs a title"],
|
|
[
|
|
`plugin.addScreen({ id: "main", title: " ", Component: Screen });`,
|
|
"Screen main needs a title",
|
|
],
|
|
[
|
|
`plugin.addScreen({ id: "main", title: 42, Component: Screen });`,
|
|
"Screen main needs a title",
|
|
],
|
|
[
|
|
`plugin.addScreen({ id: "main", title: "Main", Component: "Main" });`,
|
|
"Screen main is not a component",
|
|
],
|
|
])("rejects a malformed screen: %s", (call, message) => {
|
|
expect(() =>
|
|
evaluatePluginClientBundle("example", bundle(`function Screen() { return null; } ${call}`)),
|
|
).toThrow(message);
|
|
});
|
|
|
|
it("rejects duplicate and malformed sidebar items within a section", () => {
|
|
expect(() =>
|
|
evaluatePluginClientBundle(
|
|
"example",
|
|
bundle(`
|
|
function Item() { return null; }
|
|
plugin.addSidebarFooterItem({ id: "main", title: "One", Component: Item });
|
|
plugin.addSidebarFooterItem({ id: "main", title: "Two", Component: Item });
|
|
`),
|
|
),
|
|
).toThrow("Duplicate sidebar footer item: main");
|
|
expect(() =>
|
|
evaluatePluginClientBundle(
|
|
"example",
|
|
bundle(`plugin.addSidebarHeaderItem({ id: "main", title: "Main", Component: "Main" });`),
|
|
),
|
|
).toThrow("Sidebar item main is not a component");
|
|
expect(() =>
|
|
evaluatePluginClientBundle(
|
|
"example",
|
|
bundle(
|
|
`plugin.addSidebarHeaderItem({ id: "main", title: " ", Component() { return null; } });`,
|
|
),
|
|
),
|
|
).toThrow("Sidebar item main has no title");
|
|
});
|
|
|
|
it("expands the addSurface and addSidebarItem aliases to a screen and a legacy header item", () => {
|
|
const plugin = evaluatePluginClientBundle(
|
|
"example",
|
|
bundle(`
|
|
function Surface() { return null; }
|
|
plugin.addSurface("main", Surface);
|
|
plugin.addSidebarItem({ id: "entry", title: "Example", icon: "Blocks", surface: "main" });
|
|
`),
|
|
);
|
|
|
|
expect(plugin.surfaces.map(({ id, title }) => ({ id, title }))).toEqual([
|
|
{ id: "main", title: "main" },
|
|
]);
|
|
expect(plugin.sidebarItems).toEqual({ header: [], footer: [] });
|
|
expect(plugin.legacySidebarItems).toEqual([
|
|
{ id: "entry", title: "Example", icon: "Blocks", surface: "main" },
|
|
]);
|
|
});
|
|
|
|
it("releases an addSidebarItem alias's header id", () => {
|
|
const plugin = evaluatePluginClientBundle(
|
|
"example",
|
|
bundle(`
|
|
plugin.addScreen({ id: "main", title: "Main", Component: function Screen() { return null; } });
|
|
const remove = plugin.addSidebarItem({ id: "entry", title: "Example", icon: "Blocks", surface: "main" });
|
|
remove();
|
|
plugin.addSidebarHeaderItem({ id: "entry", title: "Replacement", Component() { return null; } });
|
|
`),
|
|
);
|
|
|
|
expect(plugin.sidebarItems.header.map((item) => item.title)).toEqual(["Replacement"]);
|
|
expect(plugin.legacySidebarItems).toEqual([]);
|
|
});
|
|
|
|
it("rejects a header item that reuses an addSidebarItem id", () => {
|
|
expect(() =>
|
|
evaluatePluginClientBundle(
|
|
"example",
|
|
bundle(`
|
|
plugin.addScreen({ id: "main", title: "Main", Component: function Screen() { return null; } });
|
|
plugin.addSidebarItem({ id: "entry", title: "Example", icon: "Blocks", surface: "main" });
|
|
plugin.addSidebarHeaderItem({ id: "entry", title: "Other", Component() { return null; } });
|
|
`),
|
|
),
|
|
).toThrow("Duplicate sidebar header item: entry");
|
|
});
|
|
|
|
it("collects a declarative attachment source", () => {
|
|
const plugin = evaluatePluginClientBundle(
|
|
"linear",
|
|
bundle(`
|
|
plugin.addAttachmentSource({
|
|
id: "issues",
|
|
title: "Linear issue",
|
|
icon: "CircleDot",
|
|
pickerTitle: "Attach Linear issue",
|
|
searchPlaceholder: "Search by identifier or title",
|
|
search: { name: "issues.search", input: {}, output: {} },
|
|
});
|
|
`),
|
|
);
|
|
|
|
expect(plugin.attachmentSources).toEqual([
|
|
{
|
|
id: "issues",
|
|
title: "Linear issue",
|
|
icon: "CircleDot",
|
|
pickerTitle: "Attach Linear issue",
|
|
searchPlaceholder: "Search by identifier or title",
|
|
search: { name: "issues.search", input: {}, output: {} },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("collects contextual workspace panels and Command Center items", () => {
|
|
const plugin = evaluatePluginClientBundle(
|
|
"review",
|
|
bundle(`
|
|
function ReviewPanel() { return null; }
|
|
plugin.addWorkspacePanel({
|
|
id: "review",
|
|
title: "Review",
|
|
icon: "Scan",
|
|
context: "agent",
|
|
Component: ReviewPanel,
|
|
});
|
|
plugin.addCommandCenterItem({
|
|
id: "open-review",
|
|
title: "Open review",
|
|
icon: "Scan",
|
|
context: "agent",
|
|
onSelect() {},
|
|
});
|
|
`),
|
|
);
|
|
|
|
expect(
|
|
plugin.workspacePanels.map(({ id, title, icon, context, locations }) => ({
|
|
id,
|
|
title,
|
|
icon,
|
|
context,
|
|
locations,
|
|
})),
|
|
).toEqual([
|
|
{
|
|
id: "review",
|
|
title: "Review",
|
|
icon: "Scan",
|
|
context: "agent",
|
|
locations: ["workspace"],
|
|
},
|
|
]);
|
|
expect(
|
|
plugin.commandCenterItems.map(({ id, title, icon, context }) => ({
|
|
id,
|
|
title,
|
|
icon,
|
|
context,
|
|
})),
|
|
).toEqual([{ id: "open-review", title: "Open review", icon: "Scan", context: "agent" }]);
|
|
});
|
|
|
|
it("registers workspace and agent panels with stable aliases", () => {
|
|
const plugin = evaluatePluginClientBundle(
|
|
"panels",
|
|
bundle(`
|
|
const Component = () => null;
|
|
plugin.addWorkspacePanel({ id: "workspace", title: "Workspace", icon: "File", Component });
|
|
plugin.addAgentPanel({ id: "agent", title: "Agent", icon: "File", Component });
|
|
plugin.addWorkspacePanel({ id: "legacy", title: "Legacy", icon: "File", context: "agent", Component });
|
|
`),
|
|
);
|
|
expect(plugin.workspacePanels.map(({ id, context }) => ({ id, context }))).toEqual([
|
|
{ id: "workspace", context: "workspace" },
|
|
{ id: "agent", context: "agent" },
|
|
{ id: "legacy", context: "agent" },
|
|
]);
|
|
});
|
|
|
|
it("registers inline SVG and component icons", () => {
|
|
const svg = '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M4 4h16v16H4z" /></svg>';
|
|
const plugin = evaluatePluginClientBundle(
|
|
"review",
|
|
bundle(`
|
|
function ReviewPanel() { return null; }
|
|
function BadgeIcon() { return null; }
|
|
plugin.addWorkspacePanel({
|
|
id: "review",
|
|
title: "Review",
|
|
icon: { svg: ${JSON.stringify(` ${svg}\n`)} },
|
|
context: "workspace",
|
|
Component: ReviewPanel,
|
|
});
|
|
plugin.addCommandCenterItem({
|
|
id: "open-review",
|
|
title: "Open review",
|
|
icon: BadgeIcon,
|
|
context: "global",
|
|
onSelect() {},
|
|
});
|
|
plugin.addAttachmentSource({
|
|
id: "issues",
|
|
title: "Issues",
|
|
icon: { svg: ${JSON.stringify(svg)} },
|
|
pickerTitle: "Attach issue",
|
|
searchPlaceholder: "Search",
|
|
search: { name: "issues.search", input: {}, output: {} },
|
|
});
|
|
`),
|
|
);
|
|
|
|
expect(plugin.workspacePanels[0]?.icon).toEqual({ svg });
|
|
expect(plugin.commandCenterItems[0]?.icon).toHaveProperty("name", "BadgeIcon");
|
|
expect(plugin.attachmentSources[0]?.icon).toEqual({ svg });
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
"workspace panel",
|
|
`plugin.addWorkspacePanel({ id: "review", title: "Review", icon: ICON, context: "workspace", Component() { return null; } })`,
|
|
"Workspace panel review has an invalid icon: script elements are not allowed",
|
|
],
|
|
[
|
|
"Command Center item",
|
|
`plugin.addCommandCenterItem({ id: "review", title: "Review", icon: ICON, context: "global", onSelect() {} })`,
|
|
"Command Center item review has an invalid icon: script elements are not allowed",
|
|
],
|
|
[
|
|
"settings screen",
|
|
`plugin.addSettingsScreen({ id: "review", title: "Review", icon: ICON, Component() { return null; } })`,
|
|
"Settings screen review has an invalid icon: script elements are not allowed",
|
|
],
|
|
[
|
|
"attachment source",
|
|
`plugin.addAttachmentSource({ id: "review", title: "Review", icon: ICON, pickerTitle: "Pick", searchPlaceholder: "Search", search: { name: "issues.search", input: {}, output: {} } })`,
|
|
"Attachment source review has an invalid icon: script elements are not allowed",
|
|
],
|
|
])("fails the plugin when a %s registers an unsafe SVG", (_kind, registration, message) => {
|
|
const icon = JSON.stringify({ svg: "<svg><script>run()</script></svg>" });
|
|
|
|
expect(() =>
|
|
evaluatePluginClientBundle("review", bundle(registration.replace("ICON", icon))),
|
|
).toThrow(message);
|
|
});
|
|
|
|
it("normalizes and validates workspace panel locations", () => {
|
|
const plugin = evaluatePluginClientBundle(
|
|
"review",
|
|
bundle(`
|
|
function ReviewPanel() { return null; }
|
|
plugin.addWorkspacePanel({
|
|
id: "review",
|
|
title: "Review",
|
|
icon: "Scan",
|
|
context: "agent",
|
|
locations: ["workspace", "explorer"],
|
|
Component: ReviewPanel,
|
|
});
|
|
`),
|
|
);
|
|
expect(plugin.workspacePanels[0]?.locations).toEqual(["workspace", "explorer"]);
|
|
|
|
for (const [locations, message] of [
|
|
["[]", "must support at least one location"],
|
|
['["sidebar"]', "has invalid location: sidebar"],
|
|
['["explorer", "explorer"]', "has duplicate locations"],
|
|
] as const) {
|
|
expect(() =>
|
|
evaluatePluginClientBundle(
|
|
"review",
|
|
bundle(`
|
|
function ReviewPanel() { return null; }
|
|
plugin.addWorkspacePanel({
|
|
id: "review",
|
|
title: "Review",
|
|
icon: "Scan",
|
|
context: "agent",
|
|
locations: ${locations},
|
|
Component: ReviewPanel,
|
|
});
|
|
`),
|
|
),
|
|
).toThrow(message);
|
|
}
|
|
});
|
|
|
|
it("rejects duplicate workspace panel and Command Center ids", () => {
|
|
expect(() =>
|
|
evaluatePluginClientBundle(
|
|
"review",
|
|
bundle(`
|
|
function Panel() { return null; }
|
|
const panel = { id: "review", title: "Review", icon: "Scan", context: "workspace", Component: Panel };
|
|
plugin.addWorkspacePanel(panel);
|
|
plugin.addWorkspacePanel(panel);
|
|
`),
|
|
),
|
|
).toThrow("Duplicate workspace panel: review");
|
|
|
|
expect(() =>
|
|
evaluatePluginClientBundle(
|
|
"review",
|
|
bundle(`
|
|
const item = { id: "review", title: "Review", icon: "Scan", context: "global", onSelect() {} };
|
|
plugin.addCommandCenterItem(item);
|
|
plugin.addCommandCenterItem(item);
|
|
`),
|
|
),
|
|
).toThrow("Duplicate Command Center item: review");
|
|
});
|
|
|
|
it("runs the client entry with the full runtime context", () => {
|
|
const plugin = evaluatePluginClientBundle(
|
|
"review",
|
|
bundle(`
|
|
if (!plugin.paseo || !plugin.rpc || !plugin.openSurface || !plugin.openPanel || !plugin.addComposerPill) {
|
|
throw new Error("missing client runtime");
|
|
}
|
|
`),
|
|
);
|
|
expect(plugin.id).toBe("review");
|
|
});
|
|
|
|
it("rejects duplicate attachment source ids", () => {
|
|
expect(() =>
|
|
evaluatePluginClientBundle(
|
|
"linear",
|
|
bundle(`
|
|
const source = {
|
|
id: "issues",
|
|
title: "Linear issue",
|
|
icon: "CircleDot",
|
|
pickerTitle: "Attach Linear issue",
|
|
searchPlaceholder: "Search",
|
|
search: { name: "issues.search", input: {}, output: {} },
|
|
};
|
|
plugin.addAttachmentSource(source);
|
|
plugin.addAttachmentSource(source);
|
|
`),
|
|
),
|
|
).toThrow("Duplicate attachment source: issues");
|
|
});
|
|
|
|
it("collects a contributed theme", () => {
|
|
const plugin = evaluatePluginClientBundle(
|
|
"catppuccin",
|
|
bundle(`
|
|
plugin.addTheme({
|
|
id: "mocha",
|
|
name: "Catppuccin Mocha",
|
|
appearance: "dark",
|
|
colors: {
|
|
background: "#1e1e2e",
|
|
foreground: "#cdd6f4",
|
|
raised: "#313244",
|
|
control: "#45475a",
|
|
border: "#45475a",
|
|
accent: "#cba6f7",
|
|
mutedForeground: "#a6adc8",
|
|
ring: "#6c7086",
|
|
},
|
|
});
|
|
`),
|
|
);
|
|
|
|
expect(plugin.themes.map((theme) => [theme.id, theme.name])).toEqual([
|
|
["mocha", "Catppuccin Mocha"],
|
|
]);
|
|
expect(plugin.themes[0]?.colors.accent).toBe("#cba6f7");
|
|
});
|
|
|
|
it("rejects a theme with a color that is not a hex value", () => {
|
|
expect(() =>
|
|
evaluatePluginClientBundle(
|
|
"catppuccin",
|
|
bundle(`
|
|
plugin.addTheme({
|
|
id: "mocha",
|
|
name: "Catppuccin Mocha",
|
|
appearance: "dark",
|
|
colors: {
|
|
background: "rebeccapurple",
|
|
foreground: "#cdd6f4",
|
|
raised: "#313244",
|
|
control: "#45475a",
|
|
border: "#45475a",
|
|
mutedForeground: "#a6adc8",
|
|
ring: "#6c7086",
|
|
},
|
|
});
|
|
`),
|
|
),
|
|
).toThrow("Must be a hex color");
|
|
});
|
|
|
|
it("rejects duplicate theme ids", () => {
|
|
expect(() =>
|
|
evaluatePluginClientBundle(
|
|
"catppuccin",
|
|
bundle(`
|
|
const theme = {
|
|
id: "mocha",
|
|
name: "Catppuccin Mocha",
|
|
appearance: "dark",
|
|
colors: {
|
|
background: "#1e1e2e",
|
|
foreground: "#cdd6f4",
|
|
raised: "#313244",
|
|
control: "#45475a",
|
|
border: "#45475a",
|
|
mutedForeground: "#a6adc8",
|
|
ring: "#6c7086",
|
|
},
|
|
};
|
|
plugin.addTheme(theme);
|
|
plugin.addTheme(theme);
|
|
`),
|
|
),
|
|
).toThrow("Duplicate theme: mocha");
|
|
});
|
|
|
|
it("rejects a sidebar placement whose surface does not exist", () => {
|
|
expect(() =>
|
|
evaluatePluginClientBundle(
|
|
"example",
|
|
bundle(`
|
|
plugin.addSidebarItem({ id: "main", title: "Example", icon: "Blocks", surface: "missing" });
|
|
`),
|
|
),
|
|
).toThrow("references missing surface missing");
|
|
});
|
|
|
|
it("rejects a bundle without a default contribution function", () => {
|
|
expect(() => evaluatePluginClientBundle("example", `(function() { return {}; })`)).toThrow(
|
|
"must default export a function",
|
|
);
|
|
});
|
|
|
|
it("requires a cleanup function", () => {
|
|
expect(() =>
|
|
evaluatePluginClientBundle("example", `(function() { return { default: function() {} }; })`),
|
|
).toThrow("must return a cleanup function");
|
|
});
|
|
|
|
it("provides the host Icon component through @getpaseo/plugin/client/ui", () => {
|
|
const plugin = evaluatePluginClientBundle(
|
|
"example",
|
|
`(function(require) {
|
|
const { Icon } = require("@getpaseo/plugin/client/ui");
|
|
const module = { exports: {} };
|
|
module.exports.default = function(plugin) {
|
|
plugin.addScreen({ id: "main", title: "Main", Component: function Surface() {
|
|
return Icon({ name: "Settings", size: 18, color: "#123456" });
|
|
} });
|
|
return function() {};
|
|
};
|
|
return module.exports;
|
|
})`,
|
|
);
|
|
|
|
const Component = plugin.surfaces[0]?.Component;
|
|
expect(Component).toBeTypeOf("function");
|
|
const element = (Component as (props: never) => { props: unknown })({} as never);
|
|
expect(element).toMatchObject({ props: { size: 18, color: "#123456" } });
|
|
});
|
|
|
|
it("provides Paseo UI through @getpaseo/plugin/client/ui", () => {
|
|
const plugin = evaluatePluginClientBundle(
|
|
"example",
|
|
`(function(require) {
|
|
const { Icon, Modal, useToast, SettingsSection, SidebarRow, StreamedText } = require("@getpaseo/plugin/client/ui");
|
|
const module = { exports: {} };
|
|
module.exports.default = function(plugin) {
|
|
if (typeof Icon !== "function" || typeof Modal !== "function" || typeof Modal.Content !== "function" || typeof useToast !== "function" || typeof SettingsSection !== "function" || typeof SidebarRow !== "function" || typeof StreamedText !== "function") {
|
|
throw new Error("React Native plugin UI is incomplete");
|
|
}
|
|
plugin.addSurface("main", function Surface() { return null; });
|
|
return function() {};
|
|
};
|
|
return module.exports;
|
|
})`,
|
|
);
|
|
|
|
expect(plugin.surfaces.map((surface) => surface.id)).toEqual(["main"]);
|
|
});
|
|
|
|
it("keeps the host reveal hook on the deprecated client/react-native entry", () => {
|
|
const plugin = evaluatePluginClientBundle(
|
|
"legacy-reveal",
|
|
bundle(`
|
|
const legacy = require("@getpaseo/plugin/client/react-native");
|
|
const ui = require("@getpaseo/plugin/client/ui");
|
|
if (typeof legacy.useRevealedText !== "function") throw new Error("Missing legacy reveal hook");
|
|
if (legacy.StreamedText !== ui.StreamedText) throw new Error("Missing current UI exports");
|
|
if ("useRevealedText" in ui) throw new Error("Legacy hook leaked into current UI");
|
|
plugin.addScreen({ id: "reveal", title: "Reveal", Component: legacy.useRevealedText });
|
|
`),
|
|
);
|
|
expect(plugin.surfaces[0]?.Component).toBe(useRevealedText);
|
|
});
|
|
|
|
it("keeps shared and client runtime exports separate", () => {
|
|
expect(() =>
|
|
evaluatePluginClientBundle(
|
|
"example",
|
|
`(function(require) {
|
|
const shared = require("@getpaseo/plugin");
|
|
const client = require("@getpaseo/plugin/client");
|
|
const { ExternalLink, Loading, Markdown } = require("@getpaseo/plugin/client/ui");
|
|
if (typeof Markdown !== "function") throw new Error("Markdown");
|
|
if (typeof ExternalLink !== "function") throw new Error("ExternalLink");
|
|
if (typeof Loading !== "function") throw new Error("Loading");
|
|
for (const name of ["usePaseo", "useRpc", "useSettings", "useAgent", "useWorkspace", "openExternalUrl", "useFileText", "useFileBytes", "useFileUrl", "PluginFileConflictError", "PluginFileDeletedError"]) {
|
|
if (name in shared || typeof client[name] !== "function") throw new Error(name);
|
|
}
|
|
if ("Icon" in shared || typeof shared.PluginAttachmentItemSchema.parse !== "function") throw new Error("shared exports");
|
|
return { default() { return () => {}; } };
|
|
})`,
|
|
),
|
|
).not.toThrow();
|
|
});
|
|
|
|
it.each([
|
|
"@getpaseo/plugin/server",
|
|
"@getpaseo/plugin/server/provider",
|
|
"@getpaseo/plugin/server/acp",
|
|
"@getpaseo/plugin/client/host",
|
|
"@getpaseo/plugin/react-native",
|
|
"@getpaseo/plugin/ui",
|
|
"@getpaseo/plugin/host",
|
|
"@paseo/plugin",
|
|
])("rejects %s in the client loader", (specifier) => {
|
|
expect(() =>
|
|
evaluatePluginClientBundle(
|
|
"example",
|
|
`(function(require) { require("${specifier}"); return {}; })`,
|
|
),
|
|
).toThrow("not available in plugin client code");
|
|
});
|
|
|
|
it("resolves shared RPC helpers from @getpaseo/plugin", () => {
|
|
const plugin = evaluatePluginClientBundle(
|
|
"example",
|
|
`(function(require) {
|
|
const { defineRpc, defineAttachmentSource } = require("@getpaseo/plugin");
|
|
const search = defineRpc({ name: "issues.search", input: {}, output: {} });
|
|
const module = { exports: {} };
|
|
module.exports.default = function(plugin) {
|
|
plugin.addAttachmentSource(defineAttachmentSource({
|
|
id: "issues",
|
|
title: "Issue",
|
|
icon: "CircleDot",
|
|
pickerTitle: "Attach issue",
|
|
searchPlaceholder: "Search",
|
|
search,
|
|
}));
|
|
return function() {};
|
|
};
|
|
return module.exports;
|
|
})`,
|
|
);
|
|
|
|
expect(plugin.attachmentSources.map((source) => source.search.name)).toEqual(["issues.search"]);
|
|
});
|
|
|
|
it("rejects modules that are not part of the client runtime", () => {
|
|
expect(() =>
|
|
evaluatePluginClientBundle(
|
|
"example",
|
|
`(function(require) {
|
|
require("fs");
|
|
const module = { exports: {} };
|
|
module.exports.default = function() { return function() {}; };
|
|
return module.exports;
|
|
})`,
|
|
),
|
|
).toThrow('Module "fs" is not available in plugin client code');
|
|
});
|
|
|
|
it("does not publish partial contributions when setup fails", () => {
|
|
expect(() =>
|
|
evaluatePluginClientBundle(
|
|
"example",
|
|
`(function() { return { default: function(plugin) {
|
|
plugin.addSurface("main", function() { return null; });
|
|
throw new Error("setup exploded");
|
|
} }; })`,
|
|
),
|
|
).toThrow("setup exploded");
|
|
});
|
|
});
|
|
|
|
it("binds imported getters to each originating installation across delayed callbacks", async () => {
|
|
const calls: string[] = [];
|
|
const hostRuntime = (installation: string): PluginClientRuntime => ({
|
|
...runtime,
|
|
hosts: {
|
|
getSnapshot: () => [],
|
|
subscribe: () => () => {},
|
|
getPaseoClient(serverId) {
|
|
calls.push(`${installation}/${serverId}`);
|
|
return runtime.paseo;
|
|
},
|
|
},
|
|
});
|
|
const source = bundle(`
|
|
const { getPaseoClient } = require("@getpaseo/plugin/client");
|
|
getPaseoClient("entry-host");
|
|
plugin.addCommandCenterItem({
|
|
id: "read", title: "Read", icon: "Server", context: "global",
|
|
onSelect: async () => { await Promise.resolve(); getPaseoClient("target-host"); },
|
|
});
|
|
`);
|
|
const first = runPluginClientBundle("same-id", source, hostRuntime("first"));
|
|
const second = runPluginClientBundle("same-id", source, hostRuntime("second"));
|
|
// Both callbacks run after the second bundle has evaluated.
|
|
await first.commandCenterItems[0].onSelect({} as never);
|
|
await second.commandCenterItems[0].onSelect({} as never);
|
|
expect(calls).toEqual([
|
|
"first/entry-host",
|
|
"second/entry-host",
|
|
"first/target-host",
|
|
"second/target-host",
|
|
]);
|
|
await first.cleanup();
|
|
await second.cleanup();
|
|
});
|
|
|
|
describe("file panels", () => {
|
|
const panel =
|
|
'{ id: "pdf", title: "PDF", icon: "FileText", files: { extensions: ["pdf"] }, Component() { return null; } }';
|
|
it("registers a panel and removes it on cleanup", async () => {
|
|
const plugin = evaluatePluginClientBundle("files", bundle(`plugin.addFilePanel(${panel})`));
|
|
expect(plugin.filePanels.map(({ id, title, files }) => ({ id, title, files }))).toEqual([
|
|
{ id: "pdf", title: "PDF", files: { extensions: ["pdf"] } },
|
|
]);
|
|
await plugin.cleanup();
|
|
expect(plugin.filePanels).toEqual([]);
|
|
});
|
|
it.each([
|
|
['files: { extensions: ["pdf"] }', 'files: { pattern: "*.pdf" }', "selector"],
|
|
['files: { extensions: ["pdf"] }', 'files: { extensions: [".pdf"] }', "selector"],
|
|
['files: { extensions: ["pdf"] }', 'files: { extensions: ["*"] }', "selector"],
|
|
['files: { extensions: ["pdf"] }', 'files: { extensions: ["foo/bar"] }', "selector"],
|
|
['files: { extensions: ["pdf"] }', "files: { extensions: [] }", "selector"],
|
|
['files: { extensions: ["pdf"] }', 'files: { extensions: [], filenames: ["a/b"] }', "selector"],
|
|
['id: "pdf"', 'id: "Bad"', "file panel id"],
|
|
['title: "PDF"', 'title: " "', "title"],
|
|
["Component() { return null; }", "Component: {}", "component"],
|
|
['icon: "FileText"', 'icon: ""', "icon"],
|
|
])("rejects invalid registration %s → %s", (from, to, error) => {
|
|
expect(() =>
|
|
evaluatePluginClientBundle(
|
|
"files",
|
|
bundle(`plugin.addFilePanel(${panel.replace(from, to)})`),
|
|
),
|
|
).toThrow(error);
|
|
});
|
|
it("accepts filename selectors and rejects duplicate IDs", () => {
|
|
const source = panel.replace(
|
|
'extensions: ["pdf"]',
|
|
'extensions: [], filenames: ["Dockerfile"]',
|
|
);
|
|
const plugin = evaluatePluginClientBundle("files", bundle(`plugin.addFilePanel(${source})`));
|
|
expect(plugin.filePanels[0]?.files).toEqual({ extensions: [], filenames: ["Dockerfile"] });
|
|
expect(() =>
|
|
evaluatePluginClientBundle(
|
|
"files",
|
|
bundle(`plugin.addFilePanel(${source}); plugin.addFilePanel(${source})`),
|
|
),
|
|
).toThrow("Duplicate file panel");
|
|
});
|
|
});
|