openpencil/tests/engine/xpath.test.ts
Danila Poyarkov 6ec18f1dfb chore(tests): remove targeted non-null assertions
- Replace low-count E2E non-null assertions with store guards and optional expectations
- Use expectDefined for tool adapter and XPath node lookups
- Validate affected engine and E2E tests
2026-05-06 03:13:58 +03:00

208 lines
6.2 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import { FigmaAPI, SceneGraph, matchByXPath, nodeToXPath, queryByXPath } from '@open-pencil/core'
import { expectDefined } from '#tests/helpers/assert'
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('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)
const xpath = expectDefined(nodeToXPath(graph, btn.id), 'button XPath')
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"]')
})
})
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 = expectDefined(graph.getNode(rect.id), 'matching rectangle')
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 = expectDefined(graph.getNode(rect.id), 'non-matching rectangle')
const result = await matchByXPath(graph, '@name = "TestRect"', sceneNode)
expect(result).toBe(false)
})
})