From 2aecb2df85fd35517e24b53c9a27ee455e91568d Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 6 May 2026 01:51:19 +0300 Subject: [PATCH] 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 --- tests/engine/tools.test.ts | 628 --------------------------- tests/engine/tools/create.test.ts | 63 +++ tests/engine/tools/modify.test.ts | 279 ++++++++++++ tests/engine/tools/read.test.ts | 184 ++++++++ tests/engine/tools/registry.test.ts | 28 ++ tests/engine/tools/structure.test.ts | 74 ++++ tests/helpers/tools.ts | 28 ++ 7 files changed, 656 insertions(+), 628 deletions(-) delete mode 100644 tests/engine/tools.test.ts create mode 100644 tests/engine/tools/create.test.ts create mode 100644 tests/engine/tools/modify.test.ts create mode 100644 tests/engine/tools/read.test.ts create mode 100644 tests/engine/tools/registry.test.ts create mode 100644 tests/engine/tools/structure.test.ts create mode 100644 tests/helpers/tools.ts diff --git a/tests/engine/tools.test.ts b/tests/engine/tools.test.ts deleted file mode 100644 index 857df9692..000000000 --- a/tests/engine/tools.test.ts +++ /dev/null @@ -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) { - 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: 'Hello' - })) as ToolResult - expect(result.name).toBe('Card') - expect(result.type).toBe('FRAME') - expect(result.children.length).toBeGreaterThan(0) - }) -}) diff --git a/tests/engine/tools/create.test.ts b/tests/engine/tools/create.test.ts new file mode 100644 index 000000000..160b1455d --- /dev/null +++ b/tests/engine/tools/create.test.ts @@ -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: 'Hello' + })) as ToolResult + expect(result.name).toBe('Card') + expect(result.type).toBe('FRAME') + expect(result.children.length).toBeGreaterThan(0) + }) +}) diff --git a/tests/engine/tools/modify.test.ts b/tests/engine/tools/modify.test.ts new file mode 100644 index 000000000..45ac7bccd --- /dev/null +++ b/tests/engine/tools/modify.test.ts @@ -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) + }) +}) diff --git a/tests/engine/tools/read.test.ts b/tests/engine/tools/read.test.ts new file mode 100644 index 000000000..ceeddb2dc --- /dev/null +++ b/tests/engine/tools/read.test.ts @@ -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) { + 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') + }) +}) diff --git a/tests/engine/tools/registry.test.ts b/tests/engine/tools/registry.test.ts new file mode 100644 index 000000000..adc385391 --- /dev/null +++ b/tests/engine/tools/registry.test.ts @@ -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') + } + } + }) +}) diff --git a/tests/engine/tools/structure.test.ts b/tests/engine/tools/structure.test.ts new file mode 100644 index 000000000..111f27d2e --- /dev/null +++ b/tests/engine/tools/structure.test.ts @@ -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) + }) +}) diff --git a/tests/helpers/tools.ts b/tests/helpers/tools.ts new file mode 100644 index 000000000..013fcfbd5 --- /dev/null +++ b/tests/helpers/tools.ts @@ -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}` + ) +}