From 77509d897987d2452d4fac628f41fad0b85e58e8 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 1 Mar 2026 14:59:19 +0300 Subject: [PATCH] Unify tool definitions: define once, adapt for AI/CLI/MCP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move tool logic to @open-pencil/core/tools/schema.ts as framework-agnostic ToolDef objects. AI adapter generates valibot schemas + Vercel AI tool() wrappers automatically. 26 tools (was 10): create_shape, render (JSX), set_fill, set_stroke, set_effects, set_layout, set_constraints, update_node, delete, clone, rename, reparent, group/ungroup, find_nodes, get_node, get_page_tree, get_selection, select, list_pages, switch_page, list_variables, list_collections, create_component, create_instance, eval. src/ai/tools.ts: 269→28 lines (adapter only). Tests for all 3 interfaces: - 26 core tool tests (FigmaAPI directly) - 11 AI adapter tests (valibot + Vercel AI SDK tool()) - 12 CLI integration tests (eval command on .fig fixture) 323 total, all passing. --- AGENTS.md | 11 + packages/core/src/index.ts | 2 + packages/core/src/tools/ai-adapter.ts | 77 +++ packages/core/src/tools/index.ts | 3 + packages/core/src/tools/schema.ts | 666 ++++++++++++++++++++++++++ src/ai/tools.ts | 273 +---------- src/components/CodePanel.vue | 17 +- tests/engine/tools-ai-adapter.test.ts | 190 ++++++++ tests/engine/tools-cli.test.ts | 219 +++++++++ tests/engine/tools.test.ts | 390 +++++++++++++++ 10 files changed, 1581 insertions(+), 267 deletions(-) create mode 100644 packages/core/src/tools/ai-adapter.ts create mode 100644 packages/core/src/tools/index.ts create mode 100644 packages/core/src/tools/schema.ts create mode 100644 tests/engine/tools-ai-adapter.test.ts create mode 100644 tests/engine/tools-cli.test.ts create mode 100644 tests/engine/tools.test.ts diff --git a/AGENTS.md b/AGENTS.md index cf9f5d8ed..55d956fc0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,17 @@ The root app (`src/`) is the Tauri/Vite desktop editor. Its `src/engine/` files - Don't hand-roll `console.log` formatting — use the helpers from `packages/cli/src/format.ts` which re-exports agentfmt with project-specific adapters (`nodeToData`, `nodeDetails`, `nodeToTreeNode`, `nodeToListItem`) - Every command supports `--json` for machine-readable output +## Tools (AI / MCP / CLI) + +- Tool operations are defined once in `packages/core/src/tools/schema.ts` as framework-agnostic `ToolDef` objects +- Each tool has: name, description, typed params, and an `execute(figma: FigmaAPI, args)` function +- `defineTool()` gives type-safe params in the execute body; the array `ALL_TOOLS` erases the generics for adapters +- AI adapter (`packages/core/src/tools/ai-adapter.ts`): `toolsToAI()` converts ToolDefs → valibot schemas + Vercel AI `tool()` wrappers +- `src/ai/tools.ts` is just a thin wire: creates FigmaAPI from editor store, calls `toolsToAI()` +- CLI commands (`packages/cli/src/commands/`) are **not** generated from ToolDefs — they have custom agentfmt formatting, tree walking, pagination. The `eval` command is the CLI's access to all ToolDef operations via FigmaAPI. +- To add a new tool: add a `defineTool()` in `schema.ts`, add to `ALL_TOOLS` array — it's instantly available in AI chat, and via `eval` in CLI +- `FigmaAPI` (`packages/core/src/figma-api.ts`) is the execution target for all tools — Figma Plugin API compatible, uses Symbols for hidden internals + ## Code conventions - `@/` import alias for app cross-directory imports, relative imports within core diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 713d7d588..0db758d8c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -50,6 +50,8 @@ export { } from './scene-graph' export { FigmaAPI, FigmaNodeProxy, type FigmaFontName } from './figma-api' +export { ALL_TOOLS, defineTool, toolsToAI } from './tools' +export type { ToolDef, ParamDef, ParamType } from './tools' export { SkiaRenderer, type RenderOverlays } from './renderer' export { computeLayout, computeAllLayouts } from './layout' export { getCanvasKit, type CanvasKitOptions } from './canvaskit' diff --git a/packages/core/src/tools/ai-adapter.ts b/packages/core/src/tools/ai-adapter.ts new file mode 100644 index 000000000..545e012b5 --- /dev/null +++ b/packages/core/src/tools/ai-adapter.ts @@ -0,0 +1,77 @@ +/** + * Adapter: tool definitions → Vercel AI SDK `tool()` objects. + * + * Converts ParamDef types to valibot schemas and wraps execute + * functions with FigmaAPI instantiation. + */ + +import type { ToolDef, ParamDef, ParamType } from './schema' +import type { FigmaAPI } from '../figma-api' + +export interface AIAdapterOptions { + getFigma: () => FigmaAPI + onBeforeExecute?: () => void + onAfterExecute?: () => void +} + +export function toolsToAI( + tools: ToolDef[], + options: AIAdapterOptions, + deps: { + v: typeof import('valibot') + valibotSchema: (schema: any) => any + tool: (opts: any) => any + } +): Record { + const { v, valibotSchema, tool } = deps + const result: Record = {} + + for (const def of tools) { + const shape: Record = {} + for (const [key, param] of Object.entries(def.params)) { + shape[key] = paramToValibot(v, param) + } + + result[def.name] = tool({ + description: def.description, + inputSchema: valibotSchema(v.object(shape as any)), + execute: async (args: Record) => { + options.onBeforeExecute?.() + try { + return await def.execute(options.getFigma(), args as any) + } finally { + options.onAfterExecute?.() + } + } + }) + } + + return result +} + +function paramToValibot(v: typeof import('valibot'), param: ParamDef): unknown { + const typeMap: Record unknown> = { + string: () => (param.enum ? v.picklist(param.enum as [string, ...string[]]) : v.string()), + number: () => { + const pipes: unknown[] = [v.number()] + if (param.min !== undefined) pipes.push(v.minValue(param.min)) + if (param.max !== undefined) pipes.push(v.maxValue(param.max)) + return pipes.length > 1 ? v.pipe(...(pipes as [any, any, ...any[]])) : v.number() + }, + boolean: () => v.boolean(), + color: () => v.pipe(v.string(), v.description('Color value (hex like #ff0000 or #ff000080)')), + 'string[]': () => v.pipe(v.array(v.string()), v.minLength(1)) + } + + let schema = typeMap[param.type]() + + if (param.description && param.type !== 'color') { + schema = v.pipe(schema as any, v.description(param.description)) + } + + if (!param.required) { + schema = v.optional(schema as any, param.default as any) + } + + return schema +} diff --git a/packages/core/src/tools/index.ts b/packages/core/src/tools/index.ts new file mode 100644 index 000000000..f016a1229 --- /dev/null +++ b/packages/core/src/tools/index.ts @@ -0,0 +1,3 @@ +export { ALL_TOOLS, defineTool } from './schema' +export type { ToolDef, ParamDef, ParamType } from './schema' +export { toolsToAI } from './ai-adapter' diff --git a/packages/core/src/tools/schema.ts b/packages/core/src/tools/schema.ts new file mode 100644 index 000000000..f90b7a8cd --- /dev/null +++ b/packages/core/src/tools/schema.ts @@ -0,0 +1,666 @@ +/** + * Tool definition schema. + * + * Each tool is defined once with typed params and an execute function + * that operates on FigmaAPI. Adapters for AI chat (valibot), CLI (citty), + * and MCP (JSON Schema) are generated from these definitions. + */ + +import { parseColor } from '../color' +import type { FigmaAPI, FigmaNodeProxy } from '../figma-api' + +export type ParamType = 'string' | 'number' | 'boolean' | 'color' | 'string[]' + +export interface ParamDef { + type: ParamType + description: string + required?: boolean + default?: unknown + enum?: string[] + min?: number + max?: number +} + +export interface ToolDef { + name: string + description: string + params: Record + execute: (figma: FigmaAPI, args: Record) => unknown +} + +type ResolvedType = T extends 'string' + ? string + : T extends 'number' + ? number + : T extends 'boolean' + ? boolean + : T extends 'color' + ? string + : T extends 'string[]' + ? string[] + : never + +type ResolvedParams

> = { + [K in keyof P as P[K]['required'] extends true ? K : never]: ResolvedType +} & { + [K in keyof P as P[K]['required'] extends true ? never : K]?: ResolvedType +} + +export function defineTool

>(def: { + name: string + description: string + params: P + execute: (figma: FigmaAPI, args: ResolvedParams

) => unknown +}): ToolDef { + return def as unknown as ToolDef +} + +function nodeToResult(node: FigmaNodeProxy): Record { + return node.toJSON() +} + +function nodeSummary(node: FigmaNodeProxy): { id: string; name: string; type: string } { + return { id: node.id, name: node.name, type: node.type } +} + +// ─── Read tools ─────────────────────────────────────────────── + +export const getSelection = defineTool({ + name: 'get_selection', + description: 'Get details about currently selected nodes.', + params: {}, + execute: (figma) => { + const sel = figma.currentPage.selection + return { selection: sel.map(nodeToResult) } + } +}) + +export const getPageTree = defineTool({ + name: 'get_page_tree', + description: + 'Get the node tree of the current page. Returns all nodes with hierarchy, types, positions, and sizes.', + params: {}, + execute: (figma) => { + const page = figma.currentPage + return { + page: page.name, + children: page.children.map(nodeToResult) + } + } +}) + +export const getNode = defineTool({ + name: 'get_node', + description: 'Get detailed properties of a node by ID.', + params: { + id: { type: 'string', description: 'Node ID', required: true } + }, + execute: (figma, { id }) => { + const node = figma.getNodeById(id) + if (!node) return { error: `Node "${id}" not found` } + return nodeToResult(node) + } +}) + +export const findNodes = defineTool({ + name: 'find_nodes', + description: 'Find nodes by name pattern and/or type.', + params: { + name: { type: 'string', description: 'Name substring to match (case-insensitive)' }, + type: { + type: 'string', + description: 'Node type filter', + enum: [ + 'FRAME', + 'RECTANGLE', + 'ELLIPSE', + 'TEXT', + 'LINE', + 'STAR', + 'POLYGON', + 'SECTION', + 'GROUP', + 'COMPONENT', + 'INSTANCE', + 'VECTOR' + ] + } + }, + execute: (figma, args) => { + const page = figma.currentPage + const matches = page.findAll((node) => { + if (args.type && node.type !== args.type) return false + if (args.name && !node.name.toLowerCase().includes(args.name.toLowerCase())) return false + return true + }) + return { count: matches.length, nodes: matches.map(nodeSummary) } + } +}) + +// ─── Create tools ───────────────────────────────────────────── + +export const createShape = defineTool({ + name: 'create_shape', + description: + 'Create a shape on the canvas. Use FRAME for containers/cards, RECTANGLE for solid blocks, ELLIPSE for circles, TEXT for labels, SECTION for page sections.', + params: { + type: { + type: 'string', + description: 'Node type', + required: true, + enum: ['FRAME', 'RECTANGLE', 'ELLIPSE', 'TEXT', 'LINE', 'STAR', 'POLYGON', 'SECTION'] + }, + x: { type: 'number', description: 'X position', required: true }, + y: { type: 'number', description: 'Y position', required: true }, + width: { type: 'number', description: 'Width in pixels', required: true, min: 1 }, + height: { type: 'number', description: 'Height in pixels', required: true, min: 1 }, + name: { type: 'string', description: 'Node name shown in layers panel' }, + parent_id: { type: 'string', description: 'Parent node ID to nest inside' } + }, + execute: (figma, args) => { + const parentId = args.parent_id + const parent = parentId ? figma.getNodeById(parentId) : null + const createMap: Record FigmaNodeProxy> = { + FRAME: () => figma.createFrame(), + RECTANGLE: () => figma.createRectangle(), + ELLIPSE: () => figma.createEllipse(), + TEXT: () => figma.createText(), + LINE: () => figma.createLine(), + STAR: () => figma.createStar(), + POLYGON: () => figma.createPolygon(), + SECTION: () => figma.createSection() + } + const node = createMap[args.type]!() + node.x = args.x + node.y = args.y + node.resize(args.width, args.height) + if (args.name) node.name = args.name + if (parent) parent.appendChild(node) + return nodeSummary(node) + } +}) + +export const render = defineTool({ + name: 'render', + description: + 'Render JSX to design nodes. Primary creation tool — creates entire component trees in one call. Example: Title', + params: { + jsx: { type: 'string', description: 'JSX string to render', required: true }, + x: { type: 'number', description: 'X position of the root node' }, + y: { type: 'number', description: 'Y position of the root node' }, + parent_id: { type: 'string', description: 'Parent node ID to render into' } + }, + execute: async (figma, args) => { + const { renderJsx } = await import('../render/render-jsx') + const result = await renderJsx(figma.graph, args.jsx, { + parentId: args.parent_id ?? figma.currentPageId, + x: args.x, + y: args.y + }) + return { id: result.id, name: result.name, type: result.type, children: result.childIds } + } +}) + +// ─── Modify tools ───────────────────────────────────────────── + +export const setFill = defineTool({ + name: 'set_fill', + description: 'Set the fill color of a node. Accepts hex (#ff0000) or named color.', + params: { + id: { type: 'string', description: 'Node ID', required: true }, + color: { type: 'color', description: 'Color value (hex like #ff0000)', required: true } + }, + execute: (figma, { id, color }) => { + const node = figma.getNodeById(id) + if (!node) return { error: `Node "${id}" not found` } + + const c = parseColor(color) + node.fills = [{ type: 'SOLID', color: c, opacity: 1, visible: true }] + return { id, color: c } + } +}) + +export const setStroke = defineTool({ + name: 'set_stroke', + description: 'Set the stroke (border) of a node.', + params: { + id: { type: 'string', description: 'Node ID', required: true }, + color: { type: 'color', description: 'Stroke color (hex)', required: true }, + weight: { type: 'number', description: 'Stroke weight', default: 1, min: 0.1 }, + align: { + type: 'string', + description: 'Stroke alignment', + default: 'INSIDE', + enum: ['INSIDE', 'CENTER', 'OUTSIDE'] + } + }, + execute: (figma, { id, color, weight, align }) => { + const node = figma.getNodeById(id) + if (!node) return { error: `Node "${id}" not found` } + + const c = parseColor(color) + node.strokes = [ + { color: c, weight: weight ?? 1, opacity: 1, visible: true, align: (align ?? 'INSIDE') as 'INSIDE' | 'CENTER' | 'OUTSIDE' } + ] + return { id, color: c, weight: weight ?? 1 } + } +}) + +export const setEffects = defineTool({ + name: 'set_effects', + description: + 'Set effects on a node (drop shadow, inner shadow, blur). Pass an array or a single effect.', + params: { + id: { type: 'string', description: 'Node ID', required: true }, + type: { + type: 'string', + description: 'Effect type', + required: true, + enum: ['DROP_SHADOW', 'INNER_SHADOW', 'FOREGROUND_BLUR', 'BACKGROUND_BLUR'] + }, + color: { type: 'color', description: 'Shadow color (hex). Ignored for blur.' }, + offset_x: { type: 'number', description: 'Shadow X offset', default: 0 }, + offset_y: { type: 'number', description: 'Shadow Y offset', default: 4 }, + radius: { type: 'number', description: 'Blur radius', default: 4, min: 0 }, + spread: { type: 'number', description: 'Shadow spread', default: 0 } + }, + execute: (figma, args) => { + const node = figma.getNodeById(args.id) + if (!node) return { error: `Node "${args.id}" not found` } + + const isBlur = args.type === 'FOREGROUND_BLUR' || args.type === 'BACKGROUND_BLUR' + const effect: Record = { + type: args.type, + visible: true, + radius: args.radius ?? 4 + } + + if (!isBlur) { + + effect.color = args.color ? parseColor(args.color) : { r: 0, g: 0, b: 0, a: 0.25 } + effect.offset = { x: args.offset_x ?? 0, y: args.offset_y ?? 4 } + effect.spread = args.spread ?? 0 + } + + node.effects = [...node.effects, effect as any] + return { id: args.id, effects: node.effects.length } + } +}) + +export const updateNode = defineTool({ + name: 'update_node', + description: + 'Update properties of an existing node: position, size, opacity, corner radius, visibility, text, font.', + params: { + id: { type: 'string', description: 'Node ID', required: true }, + x: { type: 'number', description: 'X position' }, + y: { type: 'number', description: 'Y position' }, + width: { type: 'number', description: 'Width', min: 1 }, + height: { type: 'number', description: 'Height', min: 1 }, + opacity: { type: 'number', description: 'Opacity (0-1)', min: 0, max: 1 }, + corner_radius: { type: 'number', description: 'Corner radius', min: 0 }, + visible: { type: 'boolean', description: 'Visibility' }, + text: { type: 'string', description: 'Text content (TEXT nodes)' }, + font_size: { type: 'number', description: 'Font size', min: 1 }, + font_weight: { type: 'number', description: 'Font weight (100-900)' }, + name: { type: 'string', description: 'Layer name' } + }, + execute: (figma, args) => { + const node = figma.getNodeById(args.id) + if (!node) return { error: `Node "${args.id}" not found` } + const updated: string[] = [] + if (args.x !== undefined) { node.x = args.x; updated.push('x') } + if (args.y !== undefined) { node.y = args.y; updated.push('y') } + if (args.width !== undefined || args.height !== undefined) { + node.resize(args.width ?? node.width, args.height ?? node.height) + updated.push('size') + } + if (args.opacity !== undefined) { node.opacity = args.opacity; updated.push('opacity') } + if (args.corner_radius !== undefined) { + node.cornerRadius = args.corner_radius + updated.push('cornerRadius') + } + if (args.visible !== undefined) { node.visible = args.visible; updated.push('visible') } + if (args.name !== undefined) { node.name = args.name; updated.push('name') } + if (args.text !== undefined) { + figma.graph.updateNode(node.id, { text: args.text }) + updated.push('text') + } + if (args.font_size !== undefined) { + figma.graph.updateNode(node.id, { fontSize: args.font_size }) + updated.push('fontSize') + } + if (args.font_weight !== undefined) { + figma.graph.updateNode(node.id, { fontWeight: args.font_weight }) + updated.push('fontWeight') + } + return { id: args.id, updated } + } +}) + +export const setLayout = defineTool({ + name: 'set_layout', + description: 'Set auto-layout (flexbox) on a frame. Direction, alignment, spacing, padding.', + params: { + id: { type: 'string', description: 'Frame node ID', required: true }, + direction: { + type: 'string', + description: 'Layout direction', + required: true, + enum: ['HORIZONTAL', 'VERTICAL'] + }, + spacing: { type: 'number', description: 'Gap between items', default: 0, min: 0 }, + padding: { type: 'number', description: 'Equal padding on all sides', min: 0 }, + padding_horizontal: { type: 'number', description: 'Horizontal padding', min: 0 }, + padding_vertical: { type: 'number', description: 'Vertical padding', min: 0 }, + align: { + type: 'string', + description: 'Primary axis alignment', + default: 'MIN', + enum: ['MIN', 'CENTER', 'MAX', 'SPACE_BETWEEN'] + }, + counter_align: { + type: 'string', + description: 'Cross axis alignment', + default: 'MIN', + enum: ['MIN', 'CENTER', 'MAX', 'STRETCH'] + } + }, + execute: (figma, args) => { + const node = figma.getNodeById(args.id) + if (!node) return { error: `Node "${args.id}" not found` } + + node.layoutMode = args.direction as 'HORIZONTAL' | 'VERTICAL' + node.itemSpacing = args.spacing ?? 0 + node.primaryAxisAlignItems = (args.align ?? 'MIN') as any + node.counterAxisAlignItems = (args.counter_align ?? 'MIN') as any + + const ph = args.padding_horizontal ?? args.padding ?? 0 + const pv = args.padding_vertical ?? args.padding ?? 0 + node.paddingLeft = ph + node.paddingRight = ph + node.paddingTop = pv + node.paddingBottom = pv + + return { id: args.id, direction: args.direction, spacing: args.spacing ?? 0 } + } +}) + +export const setConstraints = defineTool({ + name: 'set_constraints', + description: 'Set resize constraints for a node within its parent.', + params: { + id: { type: 'string', description: 'Node ID', required: true }, + horizontal: { + type: 'string', + description: 'Horizontal constraint', + enum: ['MIN', 'CENTER', 'MAX', 'STRETCH', 'SCALE'] + }, + vertical: { + type: 'string', + description: 'Vertical constraint', + enum: ['MIN', 'CENTER', 'MAX', 'STRETCH', 'SCALE'] + } + }, + execute: (figma, args) => { + const node = figma.getNodeById(args.id) + if (!node) return { error: `Node "${args.id}" not found` } + if (args.horizontal || args.vertical) { + node.constraints = { + horizontal: args.horizontal ?? node.constraints.horizontal, + vertical: args.vertical ?? node.constraints.vertical + } + } + return { id: args.id, constraints: node.constraints } + } +}) + +// ─── Structure tools ────────────────────────────────────────── + +export const deleteNode = defineTool({ + name: 'delete_node', + description: 'Delete a node by ID.', + params: { + id: { type: 'string', description: 'Node ID to delete', required: true } + }, + execute: (figma, { id }) => { + const node = figma.getNodeById(id) + if (!node) return { error: `Node "${id}" not found` } + node.remove() + return { deleted: id } + } +}) + +export const cloneNode = defineTool({ + name: 'clone_node', + description: 'Clone (duplicate) a node.', + params: { + id: { type: 'string', description: 'Node ID to clone', required: true } + }, + execute: (figma, { id }) => { + const node = figma.getNodeById(id) + if (!node) return { error: `Node "${id}" not found` } + const clone = node.clone() + return nodeSummary(clone) + } +}) + +export const renameNode = defineTool({ + name: 'rename_node', + description: 'Rename a node in the layers panel.', + params: { + id: { type: 'string', description: 'Node ID', required: true }, + name: { type: 'string', description: 'New name', required: true } + }, + execute: (figma, { id, name }) => { + const node = figma.getNodeById(id) + if (!node) return { error: `Node "${id}" not found` } + node.name = name + return { id, name } + } +}) + +export const reparentNode = defineTool({ + name: 'reparent_node', + description: 'Move a node into a different parent.', + params: { + id: { type: 'string', description: 'Node ID to move', required: true }, + parent_id: { type: 'string', description: 'New parent node ID', required: true } + }, + execute: (figma, { id, parent_id }) => { + const node = figma.getNodeById(id) + const parent = figma.getNodeById(parent_id) + if (!node) return { error: `Node "${id}" not found` } + if (!parent) return { error: `Parent "${parent_id}" not found` } + parent.appendChild(node) + return { id, parent_id } + } +}) + +export const selectNodes = defineTool({ + name: 'select_nodes', + description: 'Select one or more nodes by ID.', + params: { + ids: { type: 'string[]', description: 'Node IDs to select', required: true } + }, + execute: (figma, { ids }) => { + figma.currentPage.selection = ids + .map((id) => figma.getNodeById(id)) + .filter((n): n is FigmaNodeProxy => n !== null) + return { selected: ids } + } +}) + +export const groupNodes = defineTool({ + name: 'group_nodes', + description: 'Group selected nodes.', + params: { + ids: { type: 'string[]', description: 'Node IDs to group', required: true } + }, + execute: (figma, { ids }) => { + const nodes = ids + .map((id) => figma.getNodeById(id)) + .filter((n): n is FigmaNodeProxy => n !== null) + if (nodes.length < 2) return { error: 'Need at least 2 nodes to group' } + const parent = nodes[0]!.parent ?? figma.currentPage + const group = figma.group(nodes, parent) + return nodeSummary(group) + } +}) + +export const ungroupNode = defineTool({ + name: 'ungroup_node', + description: 'Ungroup a group node.', + params: { + id: { type: 'string', description: 'Group node ID', required: true } + }, + execute: (figma, { id }) => { + const node = figma.getNodeById(id) + if (!node) return { error: `Node "${id}" not found` } + figma.ungroup(node) + return { ungrouped: id } + } +}) + +// ─── Component tools ────────────────────────────────────────── + +export const createComponent = defineTool({ + name: 'create_component', + description: 'Convert a frame/group into a component.', + params: { + id: { type: 'string', description: 'Node ID to convert', required: true } + }, + execute: (figma, { id }) => { + const node = figma.getNodeById(id) + if (!node) return { error: `Node "${id}" not found` } + const comp = figma.createComponentFromNode(node) + return nodeSummary(comp) + } +}) + +export const createInstance = defineTool({ + name: 'create_instance', + description: 'Create an instance of a component.', + params: { + component_id: { type: 'string', description: 'Component node ID', required: true }, + x: { type: 'number', description: 'X position' }, + y: { type: 'number', description: 'Y position' } + }, + execute: (figma, args) => { + const comp = figma.getNodeById(args.component_id) + if (!comp) return { error: `Component "${args.component_id}" not found` } + const instance = comp.createInstance() + if (args.x !== undefined) instance.x = args.x + if (args.y !== undefined) instance.y = args.y + return nodeSummary(instance) + } +}) + +// ─── Page tools ─────────────────────────────────────────────── + +export const listPages = defineTool({ + name: 'list_pages', + description: 'List all pages in the document.', + params: {}, + execute: (figma) => { + const pages = figma.root.children + return { + current: figma.currentPage.name, + pages: pages.map((p) => ({ id: p.id, name: p.name })) + } + } +}) + +export const switchPage = defineTool({ + name: 'switch_page', + description: 'Switch to a different page by name or ID.', + params: { + page: { type: 'string', description: 'Page name or ID', required: true } + }, + execute: (figma, { page }) => { + const target = + figma.root.children.find((p) => p.name === page) ?? + figma.getNodeById(page) + if (!target) return { error: `Page "${page}" not found` } + figma.currentPage = target + return { page: target.name, id: target.id } + } +}) + +// ─── Variable tools ─────────────────────────────────────────── + +export const listVariables = defineTool({ + name: 'list_variables', + description: 'List all design variables (colors, numbers, strings, booleans).', + params: { + type: { + type: 'string', + description: 'Filter by variable type', + enum: ['COLOR', 'FLOAT', 'STRING', 'BOOLEAN'] + } + }, + execute: (figma, args) => { + const vars = figma.getLocalVariables(args.type) + return { count: vars.length, variables: vars } + } +}) + +export const listCollections = defineTool({ + name: 'list_collections', + description: 'List all variable collections.', + params: {}, + execute: (figma) => { + const cols = figma.getLocalVariableCollections() + return { count: cols.length, collections: cols } + } +}) + +// ─── Eval escape hatch ──────────────────────────────────────── + +export const evalCode = defineTool({ + name: 'eval', + description: + 'Execute JavaScript with full Figma Plugin API access. Use for operations not covered by other tools. The `figma` global is available.', + params: { + code: { type: 'string', description: 'JavaScript code to execute', required: true } + }, + execute: async (figma, { code }) => { + const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor + const wrapped = code.trim().startsWith('return') ? code : `return (async () => { ${code} })()` + const fn = new AsyncFunction('figma', wrapped) + const result = await fn(figma) + if (result && typeof result === 'object' && 'toJSON' in result) return result.toJSON() + return result ?? null + } +}) + +// ─── Registry ───────────────────────────────────────────────── + +export const ALL_TOOLS: ToolDef[] = [ + getSelection, + getPageTree, + getNode, + findNodes, + createShape, + render, + setFill, + setStroke, + setEffects, + updateNode, + setLayout, + setConstraints, + deleteNode, + cloneNode, + renameNode, + reparentNode, + selectNodes, + groupNodes, + ungroupNode, + createComponent, + createInstance, + listPages, + switchPage, + listVariables, + listCollections, + evalCode +] diff --git a/src/ai/tools.ts b/src/ai/tools.ts index 1b53311ca..b4cb13b58 100644 --- a/src/ai/tools.ts +++ b/src/ai/tools.ts @@ -1,269 +1,28 @@ import { valibotSchema } from '@ai-sdk/valibot' -import { parseColor, FigmaAPI } from '@open-pencil/core' +import { ALL_TOOLS, FigmaAPI, toolsToAI } from '@open-pencil/core' import { tool } from 'ai' import * as v from 'valibot' import type { EditorStore } from '@/stores/editor' -import type { Color } from '@open-pencil/core' - -const nodeTypeSchema = v.picklist([ - 'FRAME', - 'RECTANGLE', - 'ELLIPSE', - 'TEXT', - 'LINE', - 'STAR', - 'POLYGON', - 'SECTION' -]) - -const rgbaSchema = v.object({ - r: v.pipe(v.number(), v.minValue(0), v.maxValue(1)), - g: v.pipe(v.number(), v.minValue(0), v.maxValue(1)), - b: v.pipe(v.number(), v.minValue(0), v.maxValue(1)), - a: v.optional(v.pipe(v.number(), v.minValue(0), v.maxValue(1)), 1) -}) - -const hexSchema = v.pipe( - v.string(), - v.regex(/^#?[0-9a-fA-F]{6,8}$/), - v.description('Hex color like #ff0000 or #ff000080') -) - -const colorInputSchema = v.union([rgbaSchema, hexSchema]) - -type ColorInput = v.InferOutput - -function resolveColor(input: ColorInput): Color { - if (typeof input === 'string') return parseColor(input) - return { r: input.r, g: input.g, b: input.b, a: input.a ?? 1 } -} export function createAITools(store: EditorStore) { - return { - create_shape: tool({ - description: - 'Create a shape on the canvas. Use FRAME for containers/cards, RECTANGLE for solid blocks, ELLIPSE for circles, TEXT for labels, SECTION for page sections.', - inputSchema: valibotSchema( - v.object({ - type: nodeTypeSchema, - x: v.pipe(v.number(), v.description('X position in canvas coordinates')), - y: v.pipe(v.number(), v.description('Y position in canvas coordinates')), - width: v.pipe(v.number(), v.minValue(1), v.description('Width in pixels')), - height: v.pipe(v.number(), v.minValue(1), v.description('Height in pixels')), - name: v.optional(v.pipe(v.string(), v.description('Node name shown in layers panel'))), - parent_id: v.optional(v.pipe(v.string(), v.description('Parent node ID to nest inside'))) - }) - ), - execute: async ({ type, x, y, width, height, name, parent_id }) => { - const id = store.createShape(type, x, y, width, height, parent_id) - if (name) store.renameNode(id, name) - store.select([id]) - return { id, type, x, y, width, height, name: name ?? type.toLowerCase() } - } - }), - - set_fill: tool({ - description: 'Set the fill color of a node. Accepts hex (#ff0000) or RGBA object.', - inputSchema: valibotSchema( - v.object({ - id: v.pipe(v.string(), v.description('Node ID')), - color: colorInputSchema - }) - ), - execute: async ({ id, color }) => { - const c = resolveColor(color) - store.updateNodeWithUndo( - id, - { fills: [{ type: 'SOLID', color: c, opacity: 1, visible: true }] }, - 'Set fill' - ) - return { id, color: c } - } - }), - - set_stroke: tool({ - description: 'Set the stroke (border) of a node.', - inputSchema: valibotSchema( - v.object({ - id: v.pipe(v.string(), v.description('Node ID')), - color: colorInputSchema, - weight: v.optional(v.pipe(v.number(), v.minValue(0.1)), 1), - align: v.optional(v.picklist(['INSIDE', 'CENTER', 'OUTSIDE']), 'INSIDE') - }) - ), - execute: async ({ id, color, weight, align }) => { - const c = resolveColor(color) - store.updateNodeWithUndo( - id, - { strokes: [{ color: c, weight, opacity: 1, visible: true, align }] }, - 'Set stroke' - ) - return { id, color: c, weight } - } - }), - - update_node: tool({ - description: - 'Update properties of an existing node: position, size, opacity, corner radius, visibility, text content, font.', - inputSchema: valibotSchema( - v.object({ - id: v.pipe(v.string(), v.description('Node ID')), - x: v.optional(v.number()), - y: v.optional(v.number()), - width: v.optional(v.pipe(v.number(), v.minValue(1))), - height: v.optional(v.pipe(v.number(), v.minValue(1))), - opacity: v.optional(v.pipe(v.number(), v.minValue(0), v.maxValue(1))), - corner_radius: v.optional(v.pipe(v.number(), v.minValue(0))), - visible: v.optional(v.boolean()), - text: v.optional(v.pipe(v.string(), v.description('Text content (for TEXT nodes)'))), - font_size: v.optional(v.pipe(v.number(), v.minValue(1))), - font_weight: v.optional(v.number()), - name: v.optional(v.string()) - }) - ), - execute: async ({ id, corner_radius, font_size, font_weight, name, ...rest }) => { - const changes: Record = { ...rest } - if (corner_radius !== undefined) changes.cornerRadius = corner_radius - if (font_size !== undefined) changes.fontSize = font_size - if (font_weight !== undefined) changes.fontWeight = font_weight - store.updateNodeWithUndo(id, changes, 'Update node') - if (name !== undefined) store.renameNode(id, name) - return { id, updated: Object.keys(changes) } - } - }), - - set_layout: tool({ - description: - 'Set auto-layout (flexbox) on a frame. Direction, alignment, spacing, and padding.', - inputSchema: valibotSchema( - v.object({ - id: v.pipe(v.string(), v.description('Frame node ID')), - direction: v.picklist(['HORIZONTAL', 'VERTICAL']), - spacing: v.optional( - v.pipe(v.number(), v.minValue(0), v.description('Gap between items')), - 0 - ), - padding: v.optional( - v.pipe(v.number(), v.minValue(0), v.description('Equal padding on all sides')) - ), - padding_horizontal: v.optional(v.pipe(v.number(), v.minValue(0))), - padding_vertical: v.optional(v.pipe(v.number(), v.minValue(0))), - align: v.optional( - v.pipe( - v.picklist(['MIN', 'CENTER', 'MAX', 'SPACE_BETWEEN']), - v.description('Primary axis alignment') - ), - 'MIN' - ), - counter_align: v.optional( - v.pipe( - v.picklist(['MIN', 'CENTER', 'MAX', 'STRETCH']), - v.description('Cross axis alignment') - ), - 'MIN' - ) - }) - ), - execute: async ({ - id, - direction, - spacing, - padding, - padding_horizontal, - padding_vertical, - align, - counter_align - }) => { - store.setLayoutMode(id, direction) - const ph = padding_horizontal ?? padding ?? 0 - const pv = padding_vertical ?? padding ?? 0 - store.updateNodeWithUndo( - id, - { - itemSpacing: spacing, - primaryAxisAlign: align, - counterAxisAlign: counter_align, - paddingLeft: ph, - paddingRight: ph, - paddingTop: pv, - paddingBottom: pv - }, - 'Set layout' - ) - return { id, direction, spacing } - } - }), - - delete_node: tool({ - description: 'Delete a node by ID.', - inputSchema: valibotSchema( - v.object({ - id: v.pipe(v.string(), v.description('Node ID to delete')) - }) - ), - execute: async ({ id }) => { - store.select([id]) - store.deleteSelected() - return { deleted: id } - } - }), - - select_nodes: tool({ - description: 'Select one or more nodes by ID.', - inputSchema: valibotSchema( - v.object({ - ids: v.pipe(v.array(v.string()), v.minLength(1), v.description('Node IDs to select')) - }) - ), - execute: async ({ ids }) => { - store.select(ids) - return { selected: ids } - } - }), - - get_page_tree: tool({ - description: - 'Get the node tree of the current page. Returns all nodes with their hierarchy, types, positions, and sizes.', - inputSchema: valibotSchema(v.object({})), - execute: async () => { + return toolsToAI( + ALL_TOOLS, + { + getFigma: () => { const api = new FigmaAPI(store.graph) - const page = api.getNodeById(store.state.currentPageId) - if (!page) return { error: 'No current page' } - return { - page: page.name, - children: page.children.map((c) => c.toJSON()) - } + api.currentPage = api.wrapNode(store.state.currentPageId) + api.currentPage.selection = [...store.state.selectedIds] + .map((id) => api.getNodeById(id)) + .filter((n): n is NonNullable => n !== null) + return api + }, + onAfterExecute: () => { + store.requestRender() } - }), - - get_selection: tool({ - description: 'Get details about currently selected nodes.', - inputSchema: valibotSchema(v.object({})), - execute: async () => { - const nodes = store.selectedNodes.value - if (nodes.length === 0) return { selection: [] } - const api = new FigmaAPI(store.graph) - return { - selection: nodes.map((n) => api.wrapNode(n.id).toJSON()) - } - } - }), - - rename_node: tool({ - description: 'Rename a node in the layers panel.', - inputSchema: valibotSchema( - v.object({ - id: v.string(), - name: v.string() - }) - ), - execute: async ({ id, name }) => { - store.renameNode(id, name) - return { id, name } - } - }) - } + }, + { v, valibotSchema, tool } + ) } export type AITools = ReturnType diff --git a/src/components/CodePanel.vue b/src/components/CodePanel.vue index 0975544ec..e82ce0e49 100644 --- a/src/components/CodePanel.vue +++ b/src/components/CodePanel.vue @@ -20,9 +20,7 @@ const jsxCode = computed(() => { const highlightedLines = computed(() => { if (!jsxCode.value) return [] const grammar = Prism.languages.jsx ?? Prism.languages.javascript - return jsxCode.value - .split('\n') - .map((line) => Prism.highlight(line, grammar, 'jsx')) + return jsxCode.value.split('\n').map((line) => Prism.highlight(line, grammar, 'jsx')) }) let copyTimeout: ReturnType | undefined @@ -60,16 +58,15 @@ watch(jsxCode, () => {

-
+
{{ i + 1 }} -
+ >{{ i + 1 }} +
diff --git a/tests/engine/tools-ai-adapter.test.ts b/tests/engine/tools-ai-adapter.test.ts new file mode 100644 index 000000000..28837c285 --- /dev/null +++ b/tests/engine/tools-ai-adapter.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, test } from 'bun:test' +import { valibotSchema } from '@ai-sdk/valibot' +import { ALL_TOOLS, FigmaAPI, SceneGraph, toolsToAI } from '@open-pencil/core' +import { tool } from 'ai' +import * as v from 'valibot' + +function setup() { + const graph = new SceneGraph() + const figma = new FigmaAPI(graph) + + const tools = toolsToAI( + ALL_TOOLS, + { + getFigma: () => figma, + onAfterExecute: () => {} + }, + { v, valibotSchema, tool } + ) + + return { graph, figma, tools } +} + +describe('AI adapter', () => { + test('generates tool for every definition', () => { + const { tools } = setup() + for (const def of ALL_TOOLS) { + expect(tools[def.name]).toBeDefined() + } + expect(Object.keys(tools).length).toBe(ALL_TOOLS.length) + }) + + test('each tool has description and execute', () => { + const { tools } = setup() + for (const [name, t] of Object.entries(tools)) { + const aiTool = t as { description: string; execute: Function } + expect(aiTool.description).toBeTruthy() + expect(typeof aiTool.execute).toBe('function') + } + }) + + test('create_shape tool works through adapter', async () => { + const { tools, figma } = setup() + const createShape = tools.create_shape as { execute: Function } + const result = (await createShape.execute({ + type: 'RECTANGLE', + x: 10, + y: 20, + width: 100, + height: 50, + name: 'Test Rect' + })) as any + + expect(result.id).toBeTruthy() + expect(result.type).toBe('RECTANGLE') + expect(result.name).toBe('Test Rect') + + const node = figma.getNodeById(result.id)! + expect(node.x).toBe(10) + expect(node.y).toBe(20) + expect(node.width).toBe(100) + }) + + test('set_fill tool works through adapter', async () => { + const { tools, figma } = setup() + const rect = figma.createRectangle() + rect.resize(100, 100) + + const setFill = tools.set_fill as { execute: Function } + await setFill.execute({ id: rect.id, color: '#00ff00' }) + + const fills = figma.getNodeById(rect.id)!.fills + expect(fills.length).toBe(1) + expect(fills[0].color.g).toBeCloseTo(1) + }) + + test('get_page_tree tool returns structure', async () => { + const { tools, figma } = setup() + const frame = figma.createFrame() + frame.name = 'TestFrame' + frame.resize(200, 200) + const rect = figma.createRectangle() + rect.resize(50, 50) + frame.appendChild(rect) + + const getTree = tools.get_page_tree as { execute: Function } + const result = (await getTree.execute({})) as any + expect(result.page).toBeTruthy() + expect(result.children.length).toBeGreaterThan(0) + }) + + test('onBeforeExecute and onAfterExecute are called', async () => { + const graph = new SceneGraph() + const figma = new FigmaAPI(graph) + const calls: string[] = [] + + const tools = toolsToAI( + ALL_TOOLS, + { + getFigma: () => figma, + onBeforeExecute: () => calls.push('before'), + onAfterExecute: () => calls.push('after') + }, + { v, valibotSchema, tool } + ) + + const listPages = tools.list_pages as { execute: Function } + await listPages.execute({}) + + expect(calls).toEqual(['before', 'after']) + }) + + test('onAfterExecute called even on error', async () => { + const graph = new SceneGraph() + const figma = new FigmaAPI(graph) + let afterCalled = false + + const tools = toolsToAI( + ALL_TOOLS, + { + getFigma: () => figma, + onAfterExecute: () => { + afterCalled = true + } + }, + { v, valibotSchema, tool } + ) + + const evalTool = tools.eval as { execute: Function } + try { + await evalTool.execute({ code: 'throw new Error("test")' }) + } catch { + // expected + } + + expect(afterCalled).toBe(true) + }) + + test('find_nodes works through adapter', async () => { + const { tools, figma } = setup() + figma.createRectangle().name = 'Button' + figma.createText().name = 'Label' + figma.createRectangle().name = 'Button Secondary' + + const findNodes = tools.find_nodes as { execute: Function } + const result = (await findNodes.execute({ name: 'button' })) as any + expect(result.count).toBe(2) + }) + + test('set_layout works through adapter', async () => { + const { tools, figma } = setup() + const frame = figma.createFrame() + frame.resize(300, 200) + + const setLayout = tools.set_layout as { execute: Function } + await setLayout.execute({ + id: frame.id, + direction: 'HORIZONTAL', + spacing: 8, + padding: 16 + }) + + const node = figma.getNodeById(frame.id)! + expect(node.layoutMode).toBe('HORIZONTAL') + expect(node.itemSpacing).toBe(8) + expect(node.paddingLeft).toBe(16) + }) + + test('render JSX works through adapter', async () => { + const { tools } = setup() + const render = tools.render as { execute: Function } + const result = (await render.execute({ + jsx: 'Hello' + })) as any + expect(result.name).toBe('Card') + expect(result.type).toBe('FRAME') + }) + + test('delete + get returns error for removed node', async () => { + const { tools, figma } = setup() + const rect = figma.createRectangle() + const id = rect.id + + const deleteTool = tools.delete_node as { execute: Function } + await deleteTool.execute({ id }) + + const getNode = tools.get_node as { execute: Function } + const result = (await getNode.execute({ id })) as any + expect(result.error).toContain('not found') + }) +}) diff --git a/tests/engine/tools-cli.test.ts b/tests/engine/tools-cli.test.ts new file mode 100644 index 000000000..03168d6c1 --- /dev/null +++ b/tests/engine/tools-cli.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, test } from 'bun:test' +import { join } from 'path' + +const CLI = join(import.meta.dir, '../../packages/cli/src/index.ts') +const FIXTURE = join(import.meta.dir, '../fixtures/material3.fig') + +async function evalCode( + code: string +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const proc = Bun.spawn(['bun', CLI, 'eval', FIXTURE, '--code', code, '--json'], { + stdout: 'pipe', + stderr: 'pipe' + }) + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text() + ]) + const exitCode = await proc.exited + return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode } +} + +function parseJSON(stdout: string): unknown { + return JSON.parse(stdout) +} + +describe('CLI tool operations via eval', () => { + test('create and read back a node', async () => { + const { stdout, exitCode } = await evalCode(` + const r = figma.createRectangle() + r.name = 'TestRect' + r.x = 100 + r.y = 200 + r.resize(300, 150) + return r.toJSON() + `) + expect(exitCode).toBe(0) + const result = parseJSON(stdout) as any + expect(result.name).toBe('TestRect') + expect(result.x).toBe(100) + expect(result.y).toBe(200) + expect(result.width).toBe(300) + expect(result.height).toBe(150) + }) + + test('set fill on a node', async () => { + const { stdout, exitCode } = await evalCode(` + const r = figma.createRectangle() + r.resize(50, 50) + r.fills = [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }] + return { fills: r.fills } + `) + expect(exitCode).toBe(0) + const result = parseJSON(stdout) as any + expect(result.fills.length).toBe(1) + expect(result.fills[0].color.r).toBe(1) + }) + + test('set layout on a frame', async () => { + const { stdout, exitCode } = await evalCode(` + const f = figma.createFrame() + f.resize(300, 200) + f.layoutMode = 'VERTICAL' + f.itemSpacing = 16 + f.paddingLeft = 20 + f.paddingRight = 20 + f.paddingTop = 20 + f.paddingBottom = 20 + return { + layoutMode: f.layoutMode, + itemSpacing: f.itemSpacing, + paddingLeft: f.paddingLeft + } + `) + expect(exitCode).toBe(0) + const result = parseJSON(stdout) as any + expect(result.layoutMode).toBe('VERTICAL') + expect(result.itemSpacing).toBe(16) + expect(result.paddingLeft).toBe(20) + }) + + test('create component from node', async () => { + const { stdout, exitCode } = await evalCode(` + const f = figma.createFrame() + f.name = 'Button' + f.resize(200, 48) + const comp = figma.createComponentFromNode(f) + return { name: comp.name, type: comp.type } + `) + expect(exitCode).toBe(0) + const result = parseJSON(stdout) as any + expect(result.name).toBe('Button') + expect(result.type).toBe('COMPONENT') + }) + + test('group and ungroup nodes', async () => { + const { stdout, exitCode } = await evalCode(` + const r1 = figma.createRectangle() + r1.resize(50, 50) + const r2 = figma.createRectangle() + r2.resize(50, 50) + const group = figma.group([r1, r2], figma.currentPage) + const groupType = group.type + const childCount = group.children.length + figma.ungroup(group) + const ungrouped = figma.getNodeById(group.id) + return { groupType, childCount, ungroupedExists: ungrouped !== null } + `) + expect(exitCode).toBe(0) + const result = parseJSON(stdout) as any + expect(result.groupType).toBe('GROUP') + expect(result.childCount).toBe(2) + expect(result.ungroupedExists).toBe(false) + }) + + test('find nodes by type on fixture', async () => { + const { stdout, exitCode } = await evalCode(` + const texts = figma.currentPage.findAllWithCriteria({ types: ['TEXT'] }) + return { count: texts.length, hasTexts: texts.length > 0 } + `) + expect(exitCode).toBe(0) + const result = parseJSON(stdout) as any + expect(result.hasTexts).toBe(true) + expect(result.count).toBeGreaterThan(0) + }) + + test('clone a node', async () => { + const { stdout, exitCode } = await evalCode(` + const r = figma.createRectangle() + r.name = 'Original' + r.resize(100, 100) + const clone = r.clone() + return { + same: r.id === clone.id, + cloneName: clone.name, + cloneWidth: clone.width + } + `) + expect(exitCode).toBe(0) + const result = parseJSON(stdout) as any + expect(result.same).toBe(false) + expect(result.cloneName).toBe('Original') + expect(result.cloneWidth).toBe(100) + }) + + test('reparent node into frame', async () => { + const { stdout, exitCode } = await evalCode(` + const frame = figma.createFrame() + frame.resize(300, 300) + const rect = figma.createRectangle() + rect.resize(50, 50) + frame.appendChild(rect) + return { + parentId: rect.parent?.id, + isChild: frame.children.some(c => c.id === rect.id) + } + `) + expect(exitCode).toBe(0) + const result = parseJSON(stdout) as any + expect(result.isChild).toBe(true) + }) + + test('set constraints', async () => { + const { stdout, exitCode } = await evalCode(` + const r = figma.createRectangle() + r.resize(100, 100) + r.constraints = { horizontal: 'CENTER', vertical: 'STRETCH' } + return r.constraints + `) + expect(exitCode).toBe(0) + const result = parseJSON(stdout) as any + expect(result.horizontal).toBe('CENTER') + expect(result.vertical).toBe('STRETCH') + }) + + test('set effects', async () => { + const { stdout, exitCode } = await evalCode(` + const f = figma.createFrame() + f.resize(100, 100) + f.effects = [{ + type: 'DROP_SHADOW', + color: { r: 0, g: 0, b: 0, a: 0.25 }, + offset: { x: 0, y: 4 }, + radius: 8, + spread: 0, + visible: true + }] + return { count: f.effects.length, type: f.effects[0].type } + `) + expect(exitCode).toBe(0) + const result = parseJSON(stdout) as any + expect(result.count).toBe(1) + expect(result.type).toBe('DROP_SHADOW') + }) + + test('list variables from fixture', async () => { + const { stdout, exitCode } = await evalCode(` + const vars = figma.getLocalVariables() + const cols = figma.getLocalVariableCollections() + return { variables: vars.length, collections: cols.length } + `) + expect(exitCode).toBe(0) + const result = parseJSON(stdout) as any + expect(typeof result.variables).toBe('number') + expect(typeof result.collections).toBe('number') + }) + + test('switch page', async () => { + const { stdout, exitCode } = await evalCode(` + const pages = figma.root.children + const first = pages[0] + figma.currentPage = first + return { page: figma.currentPage.name, pageCount: pages.length } + `) + expect(exitCode).toBe(0) + const result = parseJSON(stdout) as any + expect(result.page).toBeTruthy() + expect(result.pageCount).toBeGreaterThanOrEqual(1) + }) +}) diff --git a/tests/engine/tools.test.ts b/tests/engine/tools.test.ts new file mode 100644 index 000000000..23eb2e507 --- /dev/null +++ b/tests/engine/tools.test.ts @@ -0,0 +1,390 @@ +import { describe, expect, test } from 'bun:test' + +import { ALL_TOOLS, FigmaAPI, SceneGraph } from '@open-pencil/core' + +function setup() { + const graph = new SceneGraph() + const figma = new FigmaAPI(graph) + return { graph, figma } +} + +describe('tool definitions', () => { + test('all tools have unique names', () => { + const names = ALL_TOOLS.map((t) => t.name) + expect(new Set(names).size).toBe(names.length) + }) + + test('all tools have description and params', () => { + for (const t of ALL_TOOLS) { + expect(t.name).toBeTruthy() + expect(t.description).toBeTruthy() + expect(typeof t.params).toBe('object') + expect(typeof t.execute).toBe('function') + } + }) + + test('required params are marked', () => { + for (const t of ALL_TOOLS) { + for (const [key, param] of Object.entries(t.params)) { + expect(typeof param.type).toBe('string') + expect(typeof param.description).toBe('string') + } + } + }) +}) + +describe('create_shape', () => { + test('creates a frame', () => { + const { figma } = setup() + const tool = ALL_TOOLS.find((t) => t.name === 'create_shape')! + const result = tool.execute(figma, { + type: 'FRAME', + x: 100, + y: 200, + width: 300, + height: 400, + name: 'Test Frame' + }) as any + expect(result.name).toBe('Test Frame') + expect(result.type).toBe('FRAME') + + const node = figma.getNodeById(result.id)! + expect(node.x).toBe(100) + expect(node.y).toBe(200) + expect(node.width).toBe(300) + expect(node.height).toBe(400) + }) + + test('creates nested inside parent', () => { + const { figma } = setup() + const tool = ALL_TOOLS.find((t) => t.name === 'create_shape')! + const parent = tool.execute(figma, { + type: 'FRAME', + x: 0, + y: 0, + width: 500, + height: 500, + name: 'Parent' + }) as any + const child = tool.execute(figma, { + type: 'RECTANGLE', + x: 10, + y: 10, + width: 50, + height: 50, + parent_id: parent.id + }) as any + + const parentNode = figma.getNodeById(parent.id)! + expect(parentNode.children.some((c) => c.id === child.id)).toBe(true) + }) +}) + +describe('set_fill', () => { + test('sets solid fill', () => { + const { figma } = setup() + const frame = figma.createFrame() + frame.resize(100, 100) + + const tool = ALL_TOOLS.find((t) => t.name === 'set_fill')! + tool.execute(figma, { id: frame.id, color: '#ff0000' }) + + const fills = figma.getNodeById(frame.id)!.fills + expect(fills.length).toBe(1) + expect(fills[0].color.r).toBeCloseTo(1) + expect(fills[0].color.g).toBeCloseTo(0) + expect(fills[0].color.b).toBeCloseTo(0) + }) + + test('returns error for missing node', () => { + const { figma } = setup() + const tool = ALL_TOOLS.find((t) => t.name === 'set_fill')! + const result = tool.execute(figma, { id: 'nonexistent', color: '#ff0000' }) as any + expect(result.error).toContain('not found') + }) +}) + +describe('set_stroke', () => { + test('sets stroke', () => { + const { figma } = setup() + const rect = figma.createRectangle() + rect.resize(100, 100) + + const tool = ALL_TOOLS.find((t) => t.name === 'set_stroke')! + tool.execute(figma, { id: rect.id, color: '#0000ff', weight: 2 }) + + const strokes = figma.getNodeById(rect.id)!.strokes + expect(strokes.length).toBe(1) + expect(strokes[0].color.b).toBeCloseTo(1) + expect(strokes[0].weight).toBe(2) + }) +}) + +describe('set_effects', () => { + test('adds drop shadow', () => { + const { figma } = setup() + const frame = figma.createFrame() + frame.resize(100, 100) + + const tool = ALL_TOOLS.find((t) => t.name === 'set_effects')! + tool.execute(figma, { + id: frame.id, + type: 'DROP_SHADOW', + color: '#000000', + offset_x: 0, + offset_y: 4, + radius: 8, + spread: 0 + }) + + const effects = figma.getNodeById(frame.id)!.effects + expect(effects.length).toBe(1) + expect(effects[0].type).toBe('DROP_SHADOW') + }) + + test('adds blur without color', () => { + const { figma } = setup() + const frame = figma.createFrame() + frame.resize(100, 100) + + const tool = ALL_TOOLS.find((t) => t.name === 'set_effects')! + tool.execute(figma, { id: frame.id, type: 'BACKGROUND_BLUR', radius: 10 }) + + const effects = figma.getNodeById(frame.id)!.effects + expect(effects.length).toBe(1) + expect(effects[0].type).toBe('BACKGROUND_BLUR') + }) +}) + +describe('update_node', () => { + test('updates position and size', () => { + const { figma } = setup() + const rect = figma.createRectangle() + rect.resize(100, 100) + + const tool = ALL_TOOLS.find((t) => t.name === 'update_node')! + const result = tool.execute(figma, { + id: rect.id, + x: 50, + y: 75, + width: 200, + height: 150, + opacity: 0.5 + }) as any + + expect(result.updated).toContain('x') + expect(result.updated).toContain('size') + expect(result.updated).toContain('opacity') + + const node = figma.getNodeById(rect.id)! + expect(node.x).toBe(50) + expect(node.y).toBe(75) + expect(node.width).toBe(200) + expect(node.height).toBe(150) + expect(node.opacity).toBe(0.5) + }) + + test('updates corner radius', () => { + const { figma } = setup() + const rect = figma.createRectangle() + rect.resize(100, 100) + + const tool = ALL_TOOLS.find((t) => t.name === 'update_node')! + tool.execute(figma, { id: rect.id, corner_radius: 12 }) + + expect(figma.getNodeById(rect.id)!.cornerRadius).toBe(12) + }) +}) + +describe('set_layout', () => { + test('sets auto-layout', () => { + const { figma } = setup() + const frame = figma.createFrame() + frame.resize(300, 200) + + const tool = ALL_TOOLS.find((t) => t.name === 'set_layout')! + tool.execute(figma, { + id: frame.id, + direction: 'VERTICAL', + spacing: 16, + padding: 20 + }) + + const node = figma.getNodeById(frame.id)! + expect(node.layoutMode).toBe('VERTICAL') + expect(node.itemSpacing).toBe(16) + expect(node.paddingLeft).toBe(20) + expect(node.paddingTop).toBe(20) + }) +}) + +describe('delete_node', () => { + test('removes a node', () => { + const { figma } = setup() + const rect = figma.createRectangle() + + const tool = ALL_TOOLS.find((t) => t.name === 'delete_node')! + tool.execute(figma, { id: rect.id }) + + expect(figma.getNodeById(rect.id)).toBeNull() + }) +}) + +describe('clone_node', () => { + test('duplicates a node', () => { + const { figma } = setup() + const rect = figma.createRectangle() + rect.name = 'Original' + rect.resize(100, 100) + + const tool = ALL_TOOLS.find((t) => t.name === 'clone_node')! + const result = tool.execute(figma, { id: rect.id }) as any + + expect(result.id).not.toBe(rect.id) + expect(result.name).toBe('Original') + }) +}) + +describe('rename_node', () => { + test('renames a node', () => { + const { figma } = setup() + const rect = figma.createRectangle() + + const tool = ALL_TOOLS.find((t) => t.name === 'rename_node')! + tool.execute(figma, { id: rect.id, name: 'My Rectangle' }) + + expect(figma.getNodeById(rect.id)!.name).toBe('My Rectangle') + }) +}) + +describe('reparent_node', () => { + test('moves node into frame', () => { + const { figma } = setup() + const frame = figma.createFrame() + frame.resize(300, 300) + const rect = figma.createRectangle() + rect.resize(50, 50) + + const tool = ALL_TOOLS.find((t) => t.name === 'reparent_node')! + tool.execute(figma, { id: rect.id, parent_id: frame.id }) + + expect(figma.getNodeById(frame.id)!.children.some((c) => c.id === rect.id)).toBe(true) + }) +}) + +describe('group_nodes', () => { + test('groups two nodes', () => { + const { figma } = setup() + const r1 = figma.createRectangle() + r1.resize(50, 50) + const r2 = figma.createRectangle() + r2.resize(50, 50) + + const tool = ALL_TOOLS.find((t) => t.name === 'group_nodes')! + const result = tool.execute(figma, { ids: [r1.id, r2.id] }) as any + + expect(result.type).toBe('GROUP') + const group = figma.getNodeById(result.id)! + expect(group.children.length).toBe(2) + }) +}) + +describe('find_nodes', () => { + test('finds by name', () => { + const { figma } = setup() + const rect = figma.createRectangle() + rect.name = 'Button Primary' + const text = figma.createText() + text.name = 'Label' + + const tool = ALL_TOOLS.find((t) => t.name === 'find_nodes')! + const result = tool.execute(figma, { name: 'button' }) as any + expect(result.count).toBe(1) + expect(result.nodes[0].name).toBe('Button Primary') + }) + + test('finds by type', () => { + const { figma } = setup() + figma.createRectangle() + figma.createRectangle() + figma.createText() + + const tool = ALL_TOOLS.find((t) => t.name === 'find_nodes')! + const result = tool.execute(figma, { type: 'RECTANGLE' }) as any + expect(result.count).toBe(2) + }) +}) + +describe('get_node', () => { + test('returns node details', () => { + const { figma } = setup() + const rect = figma.createRectangle() + rect.name = 'Test Rect' + rect.resize(100, 50) + + const tool = ALL_TOOLS.find((t) => t.name === 'get_node')! + const result = tool.execute(figma, { id: rect.id }) as any + expect(result.name).toBe('Test Rect') + expect(result.width).toBe(100) + expect(result.height).toBe(50) + }) +}) + +describe('page tools', () => { + test('list_pages returns pages', () => { + const { figma } = setup() + const tool = ALL_TOOLS.find((t) => t.name === 'list_pages')! + const result = tool.execute(figma, {}) as any + expect(result.pages.length).toBeGreaterThanOrEqual(1) + }) + + test('switch_page changes page', () => { + const { figma } = setup() + const page2 = figma.createPage() + page2.name = 'Page 2' + + const tool = ALL_TOOLS.find((t) => t.name === 'switch_page')! + tool.execute(figma, { page: 'Page 2' }) + + expect(figma.currentPage.name).toBe('Page 2') + }) +}) + +describe('eval', () => { + test('executes code with figma api', async () => { + const { figma } = setup() + const tool = ALL_TOOLS.find((t) => t.name === 'eval')! + const result = await tool.execute(figma, { + code: 'const r = figma.createRectangle(); r.name = "FromEval"; return r.name;' + }) + expect(result).toBe('FromEval') + }) +}) + +describe('set_constraints', () => { + test('sets constraints', () => { + const { figma } = setup() + const rect = figma.createRectangle() + rect.resize(100, 100) + + const tool = ALL_TOOLS.find((t) => t.name === 'set_constraints')! + tool.execute(figma, { id: rect.id, horizontal: 'CENTER', vertical: 'STRETCH' }) + + const node = figma.getNodeById(rect.id)! + expect(node.constraints.horizontal).toBe('CENTER') + expect(node.constraints.vertical).toBe('STRETCH') + }) +}) + +describe('render', () => { + test('renders JSX string', async () => { + const { figma } = setup() + const tool = ALL_TOOLS.find((t) => t.name === 'render')! + const result = (await tool.execute(figma, { + jsx: 'Hello' + })) as any + expect(result.name).toBe('Card') + expect(result.type).toBe('FRAME') + expect(result.children.length).toBeGreaterThan(0) + }) +})