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.
191 lines
5.4 KiB
TypeScript
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')
|
|
})
|
|
})
|