openpencil/tests/engine/tools-ai-adapter.test.ts
Danila Poyarkov 3849872b32 Unify tool definitions: define once, adapt for AI/CLI/MCP
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.
2026-03-01 15:02:05 +03:00

191 lines
5.4 KiB
TypeScript

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: '<Frame name="Card" w={200} h={100}><Text>Hello</Text></Frame>'
})) 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')
})
})