* Add XPath query for node selection - packages/core/src/xpath.ts: XPath engine using fontoxpath with custom DOM facade over SceneGraph nodes - RPC command: 'query' with selector, page, limit params - CLI command: 'open-pencil query <file> <xpath>' with --json, --page, --limit - AI/MCP tool: 'query_nodes' with XPath examples in description - Queryable attributes: name, width, height, x, y, visible, opacity, cornerRadius, fontSize, fontFamily, fontWeight, layoutMode, itemSpacing, padding*, strokeWeight, rotation, locked, blendMode, text, lineHeight, letterSpacing * Add tests and docs for XPath query - 9 unit tests for queryByXPath/matchByXPath core functions - 6 tool tests for query_nodes AI/MCP tool - README: query examples in CLI section - VitePress docs: patterns, queryable attributes, example output - CHANGELOG: feature entry * Lazy-load fontoxpath to avoid bundling in desktop app - Dynamic import() instead of static import for fontoxpath - queryByXPath/matchByXPath now async (they were sync before) - RpcCommand.execute allows R | Promise<R> for async commands - Fix 2 lint errors (unnecessary optional chain, always-truthy) - Update all tests and CLI to await results
125 lines
3.6 KiB
TypeScript
125 lines
3.6 KiB
TypeScript
import { describe, expect, test } from 'bun:test'
|
|
|
|
import { FigmaAPI, SceneGraph, matchByXPath, queryByXPath } from '@open-pencil/core'
|
|
|
|
function setup() {
|
|
const graph = new SceneGraph()
|
|
const figma = new FigmaAPI(graph)
|
|
return { graph, figma }
|
|
}
|
|
|
|
describe('queryByXPath', () => {
|
|
test('finds nodes by type', async () => {
|
|
const { graph, figma } = setup()
|
|
figma.createRectangle()
|
|
figma.createRectangle()
|
|
figma.createText()
|
|
|
|
const results = await queryByXPath(graph, '//RECTANGLE')
|
|
expect(results.length).toBe(2)
|
|
expect(results.every((n) => n.type === 'RECTANGLE')).toBe(true)
|
|
})
|
|
|
|
test('filters by attribute comparison', async () => {
|
|
const { graph, figma } = setup()
|
|
const small = figma.createRectangle()
|
|
small.resize(50, 50)
|
|
small.name = 'Small'
|
|
|
|
const big = figma.createRectangle()
|
|
big.resize(300, 300)
|
|
big.name = 'Big'
|
|
|
|
const results = await queryByXPath(graph, '//RECTANGLE[@width < 200]')
|
|
expect(results.length).toBe(1)
|
|
expect(results[0].name).toBe('Small')
|
|
})
|
|
|
|
test('handles descendant axis //FRAME//TEXT', async () => {
|
|
const { graph, figma } = setup()
|
|
const frame = figma.createFrame()
|
|
frame.resize(400, 400)
|
|
frame.name = 'Container'
|
|
|
|
const text = figma.createText()
|
|
text.name = 'Inside'
|
|
frame.appendChild(text)
|
|
|
|
const outsideText = figma.createText()
|
|
outsideText.name = 'Outside'
|
|
|
|
const results = await queryByXPath(graph, '//FRAME//TEXT')
|
|
expect(results.length).toBe(1)
|
|
expect(results[0].name).toBe('Inside')
|
|
})
|
|
|
|
test('respects limit option', async () => {
|
|
const { graph, figma } = setup()
|
|
for (let i = 0; i < 10; i++) {
|
|
const r = figma.createRectangle()
|
|
r.name = `Rect ${i}`
|
|
}
|
|
|
|
const results = await queryByXPath(graph, '//RECTANGLE', { limit: 3 })
|
|
expect(results.length).toBe(3)
|
|
})
|
|
|
|
test('filters by page option', async () => {
|
|
const { graph, figma } = setup()
|
|
const rect1 = figma.createRectangle()
|
|
rect1.name = 'Page1Rect'
|
|
const page1Name = figma.currentPage.name
|
|
|
|
const page2 = figma.createPage()
|
|
page2.name = 'Page 2'
|
|
figma.currentPage = page2
|
|
const rect2 = figma.createRectangle()
|
|
rect2.name = 'Page2Rect'
|
|
|
|
const page1Results = await queryByXPath(graph, '//RECTANGLE', { page: page1Name })
|
|
expect(page1Results.length).toBe(1)
|
|
expect(page1Results[0].name).toBe('Page1Rect')
|
|
|
|
const page2Results = await queryByXPath(graph, '//RECTANGLE', { page: 'Page 2' })
|
|
expect(page2Results.length).toBe(1)
|
|
expect(page2Results[0].name).toBe('Page2Rect')
|
|
})
|
|
|
|
test('returns empty for no matches', async () => {
|
|
const { graph, figma } = setup()
|
|
figma.createRectangle()
|
|
|
|
const results = await queryByXPath(graph, '//ELLIPSE')
|
|
expect(results.length).toBe(0)
|
|
})
|
|
|
|
test('handles invalid selector gracefully (throws)', async () => {
|
|
const { graph } = setup()
|
|
expect(queryByXPath(graph, '///invalid[[[[')).rejects.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('matchByXPath', () => {
|
|
test('returns true for matching node', async () => {
|
|
const { graph, figma } = setup()
|
|
const rect = figma.createRectangle()
|
|
rect.resize(100, 100)
|
|
rect.name = 'TestRect'
|
|
|
|
const sceneNode = graph.getNode(rect.id)!
|
|
const result = await matchByXPath(graph, '@name = "TestRect"', sceneNode)
|
|
expect(result).toBe(true)
|
|
})
|
|
|
|
test('returns false for non-matching node', async () => {
|
|
const { graph, figma } = setup()
|
|
const rect = figma.createRectangle()
|
|
rect.resize(100, 100)
|
|
rect.name = 'Other'
|
|
|
|
const sceneNode = graph.getNode(rect.id)!
|
|
const result = await matchByXPath(graph, '@name = "TestRect"', sceneNode)
|
|
expect(result).toBe(false)
|
|
})
|
|
})
|