XPath query for node selection (#82)
* 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
2026-03-09 12:32:27 +00:00
|
|
|
import { describe, expect, test } from 'bun:test'
|
|
|
|
|
|
feat: copy node ID / XPath from context menu, CLI selection command
Add nodeToXPath() to core — builds a unique XPath selector for any node
by walking up the tree and using name predicates (or positional index
when names collide among same-type siblings). Properly escapes names
containing quotes using XPath concat() or double-quote delimiters.
UI: "Copy node ID" and "Copy XPath" items in the canvas right-click
"Copy/Paste as" submenu. Also i18n the entire submenu — all labels now
use menuMessages with translations for de, es, fr, it, pl, ru, zh-CN.
CLI: `open-pencil selection` command (app-mode only) returns selected
nodes with id, name, type, size, and xpath. Connects to the running
app via the automation WebSocket.
Automation: new `selection` RPC handler that returns selected nodes
with computed XPath selectors.
2026-04-15 07:08:46 +00:00
|
|
|
import { FigmaAPI, SceneGraph, matchByXPath, nodeToXPath, queryByXPath } from '@open-pencil/core'
|
XPath query for node selection (#82)
* 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
2026-03-09 12:32:27 +00:00
|
|
|
|
2026-05-06 00:13:58 +00:00
|
|
|
import { expectDefined } from '#tests/helpers/assert'
|
|
|
|
|
|
XPath query for node selection (#82)
* 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
2026-03-09 12:32:27 +00:00
|
|
|
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()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
feat: copy node ID / XPath from context menu, CLI selection command
Add nodeToXPath() to core — builds a unique XPath selector for any node
by walking up the tree and using name predicates (or positional index
when names collide among same-type siblings). Properly escapes names
containing quotes using XPath concat() or double-quote delimiters.
UI: "Copy node ID" and "Copy XPath" items in the canvas right-click
"Copy/Paste as" submenu. Also i18n the entire submenu — all labels now
use menuMessages with translations for de, es, fr, it, pl, ru, zh-CN.
CLI: `open-pencil selection` command (app-mode only) returns selected
nodes with id, name, type, size, and xpath. Connects to the running
app via the automation WebSocket.
Automation: new `selection` RPC handler that returns selected nodes
with computed XPath selectors.
2026-04-15 07:08:46 +00:00
|
|
|
describe('nodeToXPath', () => {
|
|
|
|
|
test('returns null for missing node', () => {
|
|
|
|
|
const { graph } = setup()
|
|
|
|
|
expect(nodeToXPath(graph, 'nonexistent')).toBeNull()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('uses name predicate for unique name among siblings', () => {
|
|
|
|
|
const { graph, figma } = setup()
|
|
|
|
|
const rect = figma.createRectangle()
|
|
|
|
|
rect.name = 'Header'
|
|
|
|
|
const text = figma.createText()
|
|
|
|
|
text.name = 'Title'
|
|
|
|
|
|
|
|
|
|
const xpath = nodeToXPath(graph, rect.id)
|
|
|
|
|
expect(xpath).toBe("//RECTANGLE[@name='Header']")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('includes name even when sole child of that type', () => {
|
|
|
|
|
const { graph, figma } = setup()
|
|
|
|
|
const frame = figma.createFrame()
|
|
|
|
|
frame.name = 'Card'
|
|
|
|
|
const text = figma.createText()
|
|
|
|
|
text.name = 'Label'
|
|
|
|
|
frame.appendChild(text)
|
|
|
|
|
|
|
|
|
|
const xpath = nodeToXPath(graph, text.id)
|
|
|
|
|
expect(xpath).toBe("//FRAME[@name='Card']/TEXT[@name='Label']")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('uses positional predicate for duplicate names', () => {
|
|
|
|
|
const { graph, figma } = setup()
|
|
|
|
|
const r1 = figma.createRectangle()
|
|
|
|
|
r1.name = 'Item'
|
|
|
|
|
const r2 = figma.createRectangle()
|
|
|
|
|
r2.name = 'Item'
|
|
|
|
|
|
|
|
|
|
const xpath1 = nodeToXPath(graph, r1.id)
|
|
|
|
|
const xpath2 = nodeToXPath(graph, r2.id)
|
|
|
|
|
expect(xpath1).toBe('//RECTANGLE[1]')
|
|
|
|
|
expect(xpath2).toBe('//RECTANGLE[2]')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('builds multi-level path', () => {
|
|
|
|
|
const { graph, figma } = setup()
|
|
|
|
|
const frame = figma.createFrame()
|
|
|
|
|
frame.name = 'Container'
|
|
|
|
|
const inner = figma.createFrame()
|
|
|
|
|
inner.name = 'Row'
|
|
|
|
|
frame.appendChild(inner)
|
|
|
|
|
const text = figma.createText()
|
|
|
|
|
text.name = 'Label'
|
|
|
|
|
inner.appendChild(text)
|
|
|
|
|
|
|
|
|
|
const xpath = nodeToXPath(graph, text.id)
|
|
|
|
|
expect(xpath).toBe("//FRAME[@name='Container']/FRAME[@name='Row']/TEXT[@name='Label']")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('round-trips with queryByXPath', async () => {
|
|
|
|
|
const { graph, figma } = setup()
|
|
|
|
|
const frame = figma.createFrame()
|
|
|
|
|
frame.name = 'Sidebar'
|
|
|
|
|
const btn = figma.createRectangle()
|
|
|
|
|
btn.name = 'Button'
|
|
|
|
|
frame.appendChild(btn)
|
|
|
|
|
|
2026-05-06 00:13:58 +00:00
|
|
|
const xpath = expectDefined(nodeToXPath(graph, btn.id), 'button XPath')
|
feat: copy node ID / XPath from context menu, CLI selection command
Add nodeToXPath() to core — builds a unique XPath selector for any node
by walking up the tree and using name predicates (or positional index
when names collide among same-type siblings). Properly escapes names
containing quotes using XPath concat() or double-quote delimiters.
UI: "Copy node ID" and "Copy XPath" items in the canvas right-click
"Copy/Paste as" submenu. Also i18n the entire submenu — all labels now
use menuMessages with translations for de, es, fr, it, pl, ru, zh-CN.
CLI: `open-pencil selection` command (app-mode only) returns selected
nodes with id, name, type, size, and xpath. Connects to the running
app via the automation WebSocket.
Automation: new `selection` RPC handler that returns selected nodes
with computed XPath selectors.
2026-04-15 07:08:46 +00:00
|
|
|
const results = await queryByXPath(graph, xpath)
|
|
|
|
|
expect(results.length).toBe(1)
|
|
|
|
|
expect(results[0].id).toBe(btn.id)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('handles names with single quotes', () => {
|
|
|
|
|
const { graph, figma } = setup()
|
|
|
|
|
const rect = figma.createRectangle()
|
|
|
|
|
rect.name = "it's a test"
|
|
|
|
|
|
|
|
|
|
const xpath = nodeToXPath(graph, rect.id)
|
|
|
|
|
expect(xpath).toBe('//RECTANGLE[@name="it\'s a test"]')
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
XPath query for node selection (#82)
* 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
2026-03-09 12:32:27 +00:00
|
|
|
describe('matchByXPath', () => {
|
|
|
|
|
test('returns true for matching node', async () => {
|
|
|
|
|
const { graph, figma } = setup()
|
|
|
|
|
const rect = figma.createRectangle()
|
|
|
|
|
rect.resize(100, 100)
|
|
|
|
|
rect.name = 'TestRect'
|
|
|
|
|
|
2026-05-06 00:13:58 +00:00
|
|
|
const sceneNode = expectDefined(graph.getNode(rect.id), 'matching rectangle')
|
XPath query for node selection (#82)
* 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
2026-03-09 12:32:27 +00:00
|
|
|
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'
|
|
|
|
|
|
2026-05-06 00:13:58 +00:00
|
|
|
const sceneNode = expectDefined(graph.getNode(rect.id), 'non-matching rectangle')
|
XPath query for node selection (#82)
* 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
2026-03-09 12:32:27 +00:00
|
|
|
const result = await matchByXPath(graph, '@name = "TestRect"', sceneNode)
|
|
|
|
|
expect(result).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
})
|