2026-03-04 12:02:06 +00:00
|
|
|
import { describe, expect, test, setDefaultTimeout } from 'bun:test'
|
2026-03-05 05:56:35 +00:00
|
|
|
import { join } from 'path'
|
|
|
|
|
import { heavy } from '../helpers/test-utils'
|
2026-03-04 12:02:06 +00:00
|
|
|
|
|
|
|
|
setDefaultTimeout(30_000)
|
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 11:59:19 +00:00
|
|
|
|
|
|
|
|
const CLI = join(import.meta.dir, '../../packages/cli/src/index.ts')
|
2026-03-05 05:56:35 +00:00
|
|
|
const FIXTURE = join(import.meta.dir, '../fixtures/gold-preview.fig')
|
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 11:59:19 +00:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 05:56:35 +00:00
|
|
|
heavy('CLI tool operations via eval', () => {
|
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 11:59:19 +00:00
|
|
|
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)
|
|
|
|
|
})
|
|
|
|
|
})
|