test(tools): split tool registry coverage

- Move tools.test.ts into focused registry, create, modify, read, and structure files
- Add shared tool test helpers for graph setup and tool lookup
- Preserve all 38 tool tests after the split
This commit is contained in:
Danila Poyarkov 2026-05-06 01:51:19 +03:00
parent 75e62507a5
commit 2aecb2df85
7 changed files with 656 additions and 628 deletions

View file

@ -1,628 +0,0 @@
import { describe, expect, test } from 'bun:test'
import { ALL_TOOLS, FigmaAPI, SceneGraph, computeAllLayouts } from '@open-pencil/core'
interface ToolResult {
id: string
name?: string
type?: string
error?: string
count?: number
nodes?: Array<{ id?: string; name: string; type: string }>
[key: string]: unknown
}
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 ToolResult
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 ToolResult
const child = tool.execute(figma, {
type: 'RECTANGLE',
x: 10,
y: 10,
width: 50,
height: 50,
parent_id: parent.id
}) as ToolResult
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 ToolResult
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 ToolResult
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)
})
test('defaults to HUG sizing when enabling auto-layout', () => {
const { graph, figma } = setup()
const frame = figma.createFrame()
frame.resize(300, 200)
const rawBefore = graph.getNode(frame.id)!
expect(rawBefore.layoutMode).toBe('NONE')
expect(rawBefore.primaryAxisSizing).toBe('FIXED')
expect(rawBefore.counterAxisSizing).toBe('FIXED')
const tool = ALL_TOOLS.find((t) => t.name === 'set_layout')!
tool.execute(figma, { id: frame.id, direction: 'VERTICAL' })
const rawAfter = graph.getNode(frame.id)!
expect(rawAfter.layoutMode).toBe('VERTICAL')
expect(rawAfter.primaryAxisSizing).toBe('HUG')
expect(rawAfter.counterAxisSizing).toBe('HUG')
})
test('preserves sizing modes when updating existing auto-layout', () => {
const { graph, 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: 'HORIZONTAL' })
// Manually set primary axis back to FIXED (user wants fixed width)
graph.updateNode(frame.id, { primaryAxisSizing: 'FIXED' })
expect(graph.getNode(frame.id)!.primaryAxisSizing).toBe('FIXED')
// Updating spacing should NOT reset sizing modes
tool.execute(figma, { id: frame.id, spacing: 24 })
const raw = graph.getNode(frame.id)!
expect(raw.primaryAxisSizing).toBe('FIXED')
expect(raw.counterAxisSizing).toBe('HUG')
})
test('HUG default enables hug-to-fit with 5 children', () => {
const { graph, figma } = setup()
const frame = figma.createFrame()
frame.resize(320, 100)
const tool = ALL_TOOLS.find((t) => t.name === 'set_layout')!
tool.execute(figma, { id: frame.id, direction: 'VERTICAL', spacing: 8, padding: 16 })
for (let i = 0; i < 5; i++) {
const child = figma.createRectangle()
child.resize(280, 50)
frame.appendChild(child)
}
computeAllLayouts(graph)
const node = graph.getNode(frame.id)!
expect(node.primaryAxisSizing).toBe('HUG')
// 16 (top pad) + 5*50 (children) + 4*8 (gaps) + 16 (bottom pad) = 314
expect(node.height).toBe(314)
})
})
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 ToolResult
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 ToolResult
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 ToolResult
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 ToolResult
expect(result.count).toBe(2)
})
})
describe('query_nodes', () => {
test('finds all frames with //FRAME', async () => {
const { figma } = setup()
const f1 = figma.createFrame()
f1.resize(200, 200)
f1.name = 'Frame A'
const f2 = figma.createFrame()
f2.resize(300, 300)
f2.name = 'Frame B'
figma.createRectangle()
const tool = ALL_TOOLS.find((t) => t.name === 'query_nodes')!
const result = (await tool.execute(figma, { selector: '//FRAME' })) as ToolResult
expect(result.count).toBe(2)
expect(result.nodes?.every((n) => n.type === 'FRAME')).toBe(true)
})
test('finds by attribute //RECTANGLE[@width < 200]', async () => {
const { figma } = setup()
const small = figma.createRectangle()
small.resize(100, 50)
small.name = 'Small'
const big = figma.createRectangle()
big.resize(400, 400)
big.name = 'Big'
const tool = ALL_TOOLS.find((t) => t.name === 'query_nodes')!
const result = (await tool.execute(figma, {
selector: '//RECTANGLE[@width < 200]'
})) as ToolResult
expect(result.count).toBe(1)
expect(result.nodes[0].name).toBe('Small')
})
test('finds by name with contains', async () => {
const { figma } = setup()
const t1 = figma.createText()
t1.name = 'Label Primary'
const t2 = figma.createText()
t2.name = 'Title'
const t3 = figma.createText()
t3.name = 'Label Secondary'
const tool = ALL_TOOLS.find((t) => t.name === 'query_nodes')!
const result = (await tool.execute(figma, {
selector: '//TEXT[contains(@name, "Label")]'
})) as ToolResult
expect(result.count).toBe(2)
expect(result.nodes?.every((n) => n.name.includes('Label'))).toBe(true)
})
test('returns error for invalid xpath', async () => {
const { figma } = setup()
const tool = ALL_TOOLS.find((t) => t.name === 'query_nodes')!
const result = (await tool.execute(figma, { selector: '///invalid[[[[' })) as ToolResult
expect(result.error).toBeTruthy()
expect(result.error).toContain('XPath error')
})
test('respects limit param', async () => {
const { figma } = setup()
for (let i = 0; i < 10; i++) {
const r = figma.createRectangle()
r.name = `Rect ${i}`
}
const tool = ALL_TOOLS.find((t) => t.name === 'query_nodes')!
const result = (await tool.execute(figma, { selector: '//RECTANGLE', limit: 3 })) as ToolResult
expect(result.count).toBe(3)
})
test('returns empty array when nothing matches', async () => {
const { figma } = setup()
figma.createRectangle()
const tool = ALL_TOOLS.find((t) => t.name === 'query_nodes')!
const result = (await tool.execute(figma, { selector: '//ELLIPSE' })) as ToolResult
expect(result.count).toBe(0)
expect(result.nodes).toEqual([])
})
})
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 ToolResult
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 ToolResult
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')
})
test('switch_page persists across separate FigmaAPI instances (RPC simulation)', () => {
const { graph } = setup()
const switchPage = ALL_TOOLS.find((t) => t.name === 'switch_page')!
const getCurrentPage = ALL_TOOLS.find((t) => t.name === 'get_current_page')!
const createPage = ALL_TOOLS.find((t) => t.name === 'create_page')!
let currentPageId = graph.getPages()[0].id
function rpcCall(tool: (typeof ALL_TOOLS)[number], args: Record<string, unknown>) {
const figma = new FigmaAPI(graph)
figma.currentPage = figma.wrapNode(currentPageId)
const result = tool.execute(figma, args)
if (figma.currentPageId !== currentPageId) {
currentPageId = figma.currentPageId
}
return result
}
rpcCall(createPage, { name: 'Second' })
rpcCall(switchPage, { page: 'Second' })
const result = rpcCall(getCurrentPage, {}) as { id: string; name: string }
expect(result.name).toBe('Second')
})
})
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('set_font_range', () => {
test('applies font style to text range and survives serialization', () => {
const { figma, graph } = setup()
const createText = ALL_TOOLS.find((t) => t.name === 'create_shape')!
const setText = ALL_TOOLS.find((t) => t.name === 'set_text')!
const setFontRange = ALL_TOOLS.find((t) => t.name === 'set_font_range')!
const created = createText.execute(figma, {
type: 'TEXT',
x: 0,
y: 0,
width: 200,
height: 20
}) as ToolResult
setText.execute(figma, { id: created.id, text: 'Hello World' })
setFontRange.execute(figma, {
id: created.id,
start: 0,
end: 5,
family: 'Inter',
size: 18,
style: 'Bold'
})
const node = graph.getNode(created.id)!
expect(node.styleRuns.length).toBeGreaterThan(0)
for (const run of node.styleRuns) {
expect(run.style).toBeDefined()
expect(typeof run.start).toBe('number')
expect(typeof run.length).toBe('number')
}
const boldRun = node.styleRuns.find((r) => r.style.fontWeight === 700)
expect(boldRun).toBeDefined()
expect(boldRun!.start).toBe(0)
expect(boldRun!.length).toBe(5)
})
test('applies color to text range', () => {
const { figma, graph } = setup()
const createText = ALL_TOOLS.find((t) => t.name === 'create_shape')!
const setText = ALL_TOOLS.find((t) => t.name === 'set_text')!
const setFontRange = ALL_TOOLS.find((t) => t.name === 'set_font_range')!
const created = createText.execute(figma, {
type: 'TEXT',
x: 0,
y: 0,
width: 200,
height: 20
}) as ToolResult
setText.execute(figma, { id: created.id, text: 'Red text' })
setFontRange.execute(figma, { id: created.id, start: 0, end: 3, color: '#ff0000' })
const node = graph.getNode(created.id)!
const colorRun = node.styleRuns.find((r) => r.style.fills?.length)
expect(colorRun).toBeDefined()
expect(colorRun!.style.fills![0].color.r).toBeCloseTo(1)
})
})
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: '<Frame name="Card" w={200} h={100} bg="#FFF"><Text>Hello</Text></Frame>'
})) as ToolResult
expect(result.name).toBe('Card')
expect(result.type).toBe('FRAME')
expect(result.children.length).toBeGreaterThan(0)
})
})

View file

@ -0,0 +1,63 @@
import { describe, expect, test } from 'bun:test'
import { getTool, setupToolTest, type ToolResult } from '../../helpers/tools'
describe('create_shape', () => {
test('creates a frame', () => {
const { figma } = setupToolTest()
const tool = getTool('create_shape')
const result = tool.execute(figma, {
type: 'FRAME',
x: 100,
y: 200,
width: 300,
height: 400,
name: 'Test Frame'
}) as ToolResult
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 } = setupToolTest()
const tool = getTool('create_shape')
const parent = tool.execute(figma, {
type: 'FRAME',
x: 0,
y: 0,
width: 500,
height: 500,
name: 'Parent'
}) as ToolResult
const child = tool.execute(figma, {
type: 'RECTANGLE',
x: 10,
y: 10,
width: 50,
height: 50,
parent_id: parent.id
}) as ToolResult
const parentNode = figma.getNodeById(parent.id)!
expect(parentNode.children.some((c) => c.id === child.id)).toBe(true)
})
})
describe('render', () => {
test('renders JSX string', async () => {
const { figma } = setupToolTest()
const tool = getTool('render')
const result = (await tool.execute(figma, {
jsx: '<Frame name="Card" w={200} h={100} bg="#FFF"><Text>Hello</Text></Frame>'
})) as ToolResult
expect(result.name).toBe('Card')
expect(result.type).toBe('FRAME')
expect(result.children.length).toBeGreaterThan(0)
})
})

View file

@ -0,0 +1,279 @@
import { describe, expect, test } from 'bun:test'
import { computeAllLayouts } from '@open-pencil/core'
import { ALL_TOOLS, getTool, setupToolTest, type ToolResult } from '../../helpers/tools'
describe('set_fill', () => {
test('sets solid fill', () => {
const { figma } = setupToolTest()
const frame = figma.createFrame()
frame.resize(100, 100)
const tool = getTool('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 } = setupToolTest()
const tool = getTool('set_fill')
const result = tool.execute(figma, { id: 'nonexistent', color: '#ff0000' }) as ToolResult
expect(result.error).toContain('not found')
})
})
describe('set_stroke', () => {
test('sets stroke', () => {
const { figma } = setupToolTest()
const rect = figma.createRectangle()
rect.resize(100, 100)
const tool = getTool('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 } = setupToolTest()
const frame = figma.createFrame()
frame.resize(100, 100)
const tool = getTool('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 } = setupToolTest()
const frame = figma.createFrame()
frame.resize(100, 100)
const tool = getTool('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 } = setupToolTest()
const rect = figma.createRectangle()
rect.resize(100, 100)
const tool = getTool('update_node')
const result = tool.execute(figma, {
id: rect.id,
x: 50,
y: 75,
width: 200,
height: 150,
opacity: 0.5
}) as ToolResult
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 } = setupToolTest()
const rect = figma.createRectangle()
rect.resize(100, 100)
const tool = getTool('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 } = setupToolTest()
const frame = figma.createFrame()
frame.resize(300, 200)
const tool = getTool('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)
})
test('defaults to HUG sizing when enabling auto-layout', () => {
const { graph, figma } = setupToolTest()
const frame = figma.createFrame()
frame.resize(300, 200)
const rawBefore = graph.getNode(frame.id)!
expect(rawBefore.layoutMode).toBe('NONE')
expect(rawBefore.primaryAxisSizing).toBe('FIXED')
expect(rawBefore.counterAxisSizing).toBe('FIXED')
const tool = getTool('set_layout')
tool.execute(figma, { id: frame.id, direction: 'VERTICAL' })
const rawAfter = graph.getNode(frame.id)!
expect(rawAfter.layoutMode).toBe('VERTICAL')
expect(rawAfter.primaryAxisSizing).toBe('HUG')
expect(rawAfter.counterAxisSizing).toBe('HUG')
})
test('preserves sizing modes when updating existing auto-layout', () => {
const { graph, figma } = setupToolTest()
const frame = figma.createFrame()
frame.resize(300, 200)
const tool = getTool('set_layout')
tool.execute(figma, { id: frame.id, direction: 'HORIZONTAL' })
// Manually set primary axis back to FIXED (user wants fixed width)
graph.updateNode(frame.id, { primaryAxisSizing: 'FIXED' })
expect(graph.getNode(frame.id)!.primaryAxisSizing).toBe('FIXED')
// Updating spacing should NOT reset sizing modes
tool.execute(figma, { id: frame.id, spacing: 24 })
const raw = graph.getNode(frame.id)!
expect(raw.primaryAxisSizing).toBe('FIXED')
expect(raw.counterAxisSizing).toBe('HUG')
})
test('HUG default enables hug-to-fit with 5 children', () => {
const { graph, figma } = setupToolTest()
const frame = figma.createFrame()
frame.resize(320, 100)
const tool = getTool('set_layout')
tool.execute(figma, { id: frame.id, direction: 'VERTICAL', spacing: 8, padding: 16 })
for (let i = 0; i < 5; i++) {
const child = figma.createRectangle()
child.resize(280, 50)
frame.appendChild(child)
}
computeAllLayouts(graph)
const node = graph.getNode(frame.id)!
expect(node.primaryAxisSizing).toBe('HUG')
// 16 (top pad) + 5*50 (children) + 4*8 (gaps) + 16 (bottom pad) = 314
expect(node.height).toBe(314)
})
})
describe('set_constraints', () => {
test('sets constraints', () => {
const { figma } = setupToolTest()
const rect = figma.createRectangle()
rect.resize(100, 100)
const tool = getTool('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('set_font_range', () => {
test('applies font style to text range and survives serialization', () => {
const { figma, graph } = setupToolTest()
const createText = getTool('create_shape')
const setText = getTool('set_text')
const setFontRange = getTool('set_font_range')
const created = createText.execute(figma, {
type: 'TEXT',
x: 0,
y: 0,
width: 200,
height: 20
}) as ToolResult
setText.execute(figma, { id: created.id, text: 'Hello World' })
setFontRange.execute(figma, {
id: created.id,
start: 0,
end: 5,
family: 'Inter',
size: 18,
style: 'Bold'
})
const node = graph.getNode(created.id)!
expect(node.styleRuns.length).toBeGreaterThan(0)
for (const run of node.styleRuns) {
expect(run.style).toBeDefined()
expect(typeof run.start).toBe('number')
expect(typeof run.length).toBe('number')
}
const boldRun = node.styleRuns.find((r) => r.style.fontWeight === 700)
expect(boldRun).toBeDefined()
expect(boldRun!.start).toBe(0)
expect(boldRun!.length).toBe(5)
})
test('applies color to text range', () => {
const { figma, graph } = setupToolTest()
const createText = getTool('create_shape')
const setText = getTool('set_text')
const setFontRange = getTool('set_font_range')
const created = createText.execute(figma, {
type: 'TEXT',
x: 0,
y: 0,
width: 200,
height: 20
}) as ToolResult
setText.execute(figma, { id: created.id, text: 'Red text' })
setFontRange.execute(figma, { id: created.id, start: 0, end: 3, color: '#ff0000' })
const node = graph.getNode(created.id)!
const colorRun = node.styleRuns.find((r) => r.style.fills?.length)
expect(colorRun).toBeDefined()
expect(colorRun!.style.fills![0].color.r).toBeCloseTo(1)
})
})

View file

@ -0,0 +1,184 @@
import { describe, expect, test } from 'bun:test'
import { FigmaAPI } from '@open-pencil/core'
import { ALL_TOOLS, getTool, setupToolTest, type ToolResult } from '../../helpers/tools'
describe('find_nodes', () => {
test('finds by name', () => {
const { figma } = setupToolTest()
const rect = figma.createRectangle()
rect.name = 'Button Primary'
const text = figma.createText()
text.name = 'Label'
const tool = getTool('find_nodes')
const result = tool.execute(figma, { name: 'button' }) as ToolResult
expect(result.count).toBe(1)
expect(result.nodes[0].name).toBe('Button Primary')
})
test('finds by type', () => {
const { figma } = setupToolTest()
figma.createRectangle()
figma.createRectangle()
figma.createText()
const tool = getTool('find_nodes')
const result = tool.execute(figma, { type: 'RECTANGLE' }) as ToolResult
expect(result.count).toBe(2)
})
})
describe('query_nodes', () => {
test('finds all frames with //FRAME', async () => {
const { figma } = setupToolTest()
const f1 = figma.createFrame()
f1.resize(200, 200)
f1.name = 'Frame A'
const f2 = figma.createFrame()
f2.resize(300, 300)
f2.name = 'Frame B'
figma.createRectangle()
const tool = getTool('query_nodes')
const result = (await tool.execute(figma, { selector: '//FRAME' })) as ToolResult
expect(result.count).toBe(2)
expect(result.nodes?.every((n) => n.type === 'FRAME')).toBe(true)
})
test('finds by attribute //RECTANGLE[@width < 200]', async () => {
const { figma } = setupToolTest()
const small = figma.createRectangle()
small.resize(100, 50)
small.name = 'Small'
const big = figma.createRectangle()
big.resize(400, 400)
big.name = 'Big'
const tool = getTool('query_nodes')
const result = (await tool.execute(figma, {
selector: '//RECTANGLE[@width < 200]'
})) as ToolResult
expect(result.count).toBe(1)
expect(result.nodes[0].name).toBe('Small')
})
test('finds by name with contains', async () => {
const { figma } = setupToolTest()
const t1 = figma.createText()
t1.name = 'Label Primary'
const t2 = figma.createText()
t2.name = 'Title'
const t3 = figma.createText()
t3.name = 'Label Secondary'
const tool = getTool('query_nodes')
const result = (await tool.execute(figma, {
selector: '//TEXT[contains(@name, "Label")]'
})) as ToolResult
expect(result.count).toBe(2)
expect(result.nodes?.every((n) => n.name.includes('Label'))).toBe(true)
})
test('returns error for invalid xpath', async () => {
const { figma } = setupToolTest()
const tool = getTool('query_nodes')
const result = (await tool.execute(figma, { selector: '///invalid[[[[' })) as ToolResult
expect(result.error).toBeTruthy()
expect(result.error).toContain('XPath error')
})
test('respects limit param', async () => {
const { figma } = setupToolTest()
for (let i = 0; i < 10; i++) {
const r = figma.createRectangle()
r.name = `Rect ${i}`
}
const tool = getTool('query_nodes')
const result = (await tool.execute(figma, { selector: '//RECTANGLE', limit: 3 })) as ToolResult
expect(result.count).toBe(3)
})
test('returns empty array when nothing matches', async () => {
const { figma } = setupToolTest()
figma.createRectangle()
const tool = getTool('query_nodes')
const result = (await tool.execute(figma, { selector: '//ELLIPSE' })) as ToolResult
expect(result.count).toBe(0)
expect(result.nodes).toEqual([])
})
})
describe('get_node', () => {
test('returns node details', () => {
const { figma } = setupToolTest()
const rect = figma.createRectangle()
rect.name = 'Test Rect'
rect.resize(100, 50)
const tool = getTool('get_node')
const result = tool.execute(figma, { id: rect.id }) as ToolResult
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 } = setupToolTest()
const tool = getTool('list_pages')
const result = tool.execute(figma, {}) as ToolResult
expect(result.pages.length).toBeGreaterThanOrEqual(1)
})
test('switch_page changes page', () => {
const { figma } = setupToolTest()
const page2 = figma.createPage()
page2.name = 'Page 2'
const tool = getTool('switch_page')
tool.execute(figma, { page: 'Page 2' })
expect(figma.currentPage.name).toBe('Page 2')
})
test('switch_page persists across separate FigmaAPI instances (RPC simulation)', () => {
const { graph } = setupToolTest()
const switchPage = getTool('switch_page')
const getCurrentPage = getTool('get_current_page')
const createPage = getTool('create_page')
let currentPageId = graph.getPages()[0].id
function rpcCall(tool: (typeof ALL_TOOLS)[number], args: Record<string, unknown>) {
const figma = new FigmaAPI(graph)
figma.currentPage = figma.wrapNode(currentPageId)
const result = tool.execute(figma, args)
if (figma.currentPageId !== currentPageId) {
currentPageId = figma.currentPageId
}
return result
}
rpcCall(createPage, { name: 'Second' })
rpcCall(switchPage, { page: 'Second' })
const result = rpcCall(getCurrentPage, {}) as { id: string; name: string }
expect(result.name).toBe('Second')
})
})
describe('eval', () => {
test('executes code with figma api', async () => {
const { figma } = setupToolTest()
const tool = getTool('eval')
const result = await tool.execute(figma, {
code: 'const r = figma.createRectangle(); r.name = "FromEval"; return r.name;'
})
expect(result).toBe('FromEval')
})
})

View file

@ -0,0 +1,28 @@
import { describe, expect, test } from 'bun:test'
import { ALL_TOOLS } from '../../helpers/tools'
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')
}
}
})
})

View file

@ -0,0 +1,74 @@
import { describe, expect, test } from 'bun:test'
import { getTool, setupToolTest, type ToolResult } from '../../helpers/tools'
describe('delete_node', () => {
test('removes a node', () => {
const { figma } = setupToolTest()
const rect = figma.createRectangle()
const tool = getTool('delete_node')
tool.execute(figma, { id: rect.id })
expect(figma.getNodeById(rect.id)).toBeNull()
})
})
describe('clone_node', () => {
test('duplicates a node', () => {
const { figma } = setupToolTest()
const rect = figma.createRectangle()
rect.name = 'Original'
rect.resize(100, 100)
const tool = getTool('clone_node')
const result = tool.execute(figma, { id: rect.id }) as ToolResult
expect(result.id).not.toBe(rect.id)
expect(result.name).toBe('Original')
})
})
describe('rename_node', () => {
test('renames a node', () => {
const { figma } = setupToolTest()
const rect = figma.createRectangle()
const tool = getTool('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 } = setupToolTest()
const frame = figma.createFrame()
frame.resize(300, 300)
const rect = figma.createRectangle()
rect.resize(50, 50)
const tool = getTool('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 } = setupToolTest()
const r1 = figma.createRectangle()
r1.resize(50, 50)
const r2 = figma.createRectangle()
r2.resize(50, 50)
const tool = getTool('group_nodes')
const result = tool.execute(figma, { ids: [r1.id, r2.id] }) as ToolResult
expect(result.type).toBe('GROUP')
const group = figma.getNodeById(result.id)!
expect(group.children.length).toBe(2)
})
})

28
tests/helpers/tools.ts Normal file
View file

@ -0,0 +1,28 @@
import { ALL_TOOLS, FigmaAPI, SceneGraph } from '@open-pencil/core'
export { ALL_TOOLS }
import { expectDefined } from './assert'
export interface ToolResult {
id: string
name?: string
type?: string
error?: string
count?: number
nodes?: Array<{ id?: string; name: string; type: string }>
[key: string]: unknown
}
export function setupToolTest() {
const graph = new SceneGraph()
const figma = new FigmaAPI(graph)
return { graph, figma }
}
export function getTool(name: string): (typeof ALL_TOOLS)[number] {
return expectDefined(
ALL_TOOLS.find((tool) => tool.name === name),
`tool ${name}`
)
}