FigmaAPI in @open-pencil/core provides a Figma Plugin API-compatible interface backed by SceneGraph. Covers node creation, property access, tree operations, auto-layout, text, traversal, grouping, components, selection, and serialization. CLI: bun open-pencil eval <file> --code 'figma.createFrame()'
525 lines
15 KiB
TypeScript
525 lines
15 KiB
TypeScript
import { describe, expect, test } from 'bun:test'
|
|
|
|
import { FigmaAPI, SceneGraph } from '@open-pencil/core'
|
|
|
|
function createAPI(): FigmaAPI {
|
|
return new FigmaAPI(new SceneGraph())
|
|
}
|
|
|
|
describe('FigmaAPI', () => {
|
|
describe('document & pages', () => {
|
|
test('root has pages as children', () => {
|
|
const api = createAPI()
|
|
expect(api.root.children.length).toBeGreaterThan(0)
|
|
expect(api.root.children[0].type).toBe('CANVAS')
|
|
})
|
|
|
|
test('currentPage is first page', () => {
|
|
const api = createAPI()
|
|
expect(api.currentPage.type).toBe('CANVAS')
|
|
expect(api.currentPage.name).toBe('Page 1')
|
|
})
|
|
|
|
test('createPage adds a new page', () => {
|
|
const api = createAPI()
|
|
const page = api.createPage()
|
|
expect(page.type).toBe('CANVAS')
|
|
expect(api.root.children.length).toBe(2)
|
|
})
|
|
|
|
test('currentPage can be switched', () => {
|
|
const api = createAPI()
|
|
const page2 = api.createPage()
|
|
page2.name = 'Page 2'
|
|
api.currentPage = page2
|
|
expect(api.currentPage.name).toBe('Page 2')
|
|
})
|
|
|
|
test('getNodeById returns proxy', () => {
|
|
const api = createAPI()
|
|
const frame = api.createFrame()
|
|
const found = api.getNodeById(frame.id)
|
|
expect(found).not.toBeNull()
|
|
expect(found!.id).toBe(frame.id)
|
|
})
|
|
|
|
test('getNodeById returns null for unknown id', () => {
|
|
const api = createAPI()
|
|
expect(api.getNodeById('nonexistent')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('node creation', () => {
|
|
test('createFrame', () => {
|
|
const api = createAPI()
|
|
const frame = api.createFrame()
|
|
expect(frame.type).toBe('FRAME')
|
|
expect(frame.parent!.id).toBe(api.currentPage.id)
|
|
})
|
|
|
|
test('createRectangle', () => {
|
|
const api = createAPI()
|
|
const rect = api.createRectangle()
|
|
expect(rect.type).toBe('RECTANGLE')
|
|
})
|
|
|
|
test('createEllipse', () => {
|
|
const api = createAPI()
|
|
expect(api.createEllipse().type).toBe('ELLIPSE')
|
|
})
|
|
|
|
test('createText', () => {
|
|
const api = createAPI()
|
|
expect(api.createText().type).toBe('TEXT')
|
|
})
|
|
|
|
test('createLine', () => {
|
|
const api = createAPI()
|
|
expect(api.createLine().type).toBe('LINE')
|
|
})
|
|
|
|
test('createPolygon', () => {
|
|
const api = createAPI()
|
|
expect(api.createPolygon().type).toBe('POLYGON')
|
|
})
|
|
|
|
test('createStar', () => {
|
|
const api = createAPI()
|
|
expect(api.createStar().type).toBe('STAR')
|
|
})
|
|
|
|
test('createVector', () => {
|
|
const api = createAPI()
|
|
expect(api.createVector().type).toBe('VECTOR')
|
|
})
|
|
|
|
test('createComponent', () => {
|
|
const api = createAPI()
|
|
expect(api.createComponent().type).toBe('COMPONENT')
|
|
})
|
|
|
|
test('createSection', () => {
|
|
const api = createAPI()
|
|
expect(api.createSection().type).toBe('SECTION')
|
|
})
|
|
|
|
test('created node is child of current page', () => {
|
|
const api = createAPI()
|
|
const frame = api.createFrame()
|
|
expect(api.currentPage.children.some((c) => c.id === frame.id)).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('property access', () => {
|
|
test('name get/set', () => {
|
|
const api = createAPI()
|
|
const frame = api.createFrame()
|
|
frame.name = 'Card'
|
|
expect(frame.name).toBe('Card')
|
|
})
|
|
|
|
test('position get/set', () => {
|
|
const api = createAPI()
|
|
const frame = api.createFrame()
|
|
frame.x = 50
|
|
frame.y = 100
|
|
expect(frame.x).toBe(50)
|
|
expect(frame.y).toBe(100)
|
|
})
|
|
|
|
test('resize', () => {
|
|
const api = createAPI()
|
|
const frame = api.createFrame()
|
|
frame.resize(300, 200)
|
|
expect(frame.width).toBe(300)
|
|
expect(frame.height).toBe(200)
|
|
})
|
|
|
|
test('fills get/set', () => {
|
|
const api = createAPI()
|
|
const rect = api.createRectangle()
|
|
rect.fills = [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
|
|
expect(rect.fills.length).toBe(1)
|
|
expect(rect.fills[0].color.r).toBe(1)
|
|
})
|
|
|
|
test('opacity get/set', () => {
|
|
const api = createAPI()
|
|
const frame = api.createFrame()
|
|
frame.opacity = 0.5
|
|
expect(frame.opacity).toBe(0.5)
|
|
})
|
|
|
|
test('visible get/set', () => {
|
|
const api = createAPI()
|
|
const frame = api.createFrame()
|
|
frame.visible = false
|
|
expect(frame.visible).toBe(false)
|
|
})
|
|
|
|
test('locked get/set', () => {
|
|
const api = createAPI()
|
|
const frame = api.createFrame()
|
|
frame.locked = true
|
|
expect(frame.locked).toBe(true)
|
|
})
|
|
|
|
test('rotation get/set', () => {
|
|
const api = createAPI()
|
|
const frame = api.createFrame()
|
|
frame.rotation = 45
|
|
expect(frame.rotation).toBe(45)
|
|
})
|
|
|
|
test('clipsContent get/set', () => {
|
|
const api = createAPI()
|
|
const frame = api.createFrame()
|
|
frame.clipsContent = true
|
|
expect(frame.clipsContent).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('corner radius', () => {
|
|
test('uniform corner radius', () => {
|
|
const api = createAPI()
|
|
const rect = api.createRectangle()
|
|
rect.cornerRadius = 8
|
|
expect(rect.cornerRadius).toBe(8)
|
|
expect(rect.topLeftRadius).toBe(8)
|
|
expect(rect.bottomRightRadius).toBe(8)
|
|
})
|
|
|
|
test('independent corners return mixed', () => {
|
|
const api = createAPI()
|
|
const rect = api.createRectangle()
|
|
rect.topLeftRadius = 4
|
|
rect.bottomRightRadius = 12
|
|
expect(rect.cornerRadius).toBe(api.mixed)
|
|
})
|
|
})
|
|
|
|
describe('text', () => {
|
|
test('characters maps to text content', () => {
|
|
const api = createAPI()
|
|
const text = api.createText()
|
|
text.characters = 'Hello World'
|
|
expect(text.characters).toBe('Hello World')
|
|
})
|
|
|
|
test('fontSize get/set', () => {
|
|
const api = createAPI()
|
|
const text = api.createText()
|
|
text.fontSize = 24
|
|
expect(text.fontSize).toBe(24)
|
|
})
|
|
|
|
test('fontName get/set', () => {
|
|
const api = createAPI()
|
|
const text = api.createText()
|
|
text.fontName = { family: 'Roboto', style: 'Bold' }
|
|
expect(text.fontName.family).toBe('Roboto')
|
|
expect(text.fontWeight).toBe(700)
|
|
})
|
|
|
|
test('fontName italic', () => {
|
|
const api = createAPI()
|
|
const text = api.createText()
|
|
text.fontName = { family: 'Inter', style: 'Bold Italic' }
|
|
expect(text.fontName.family).toBe('Inter')
|
|
expect(text.fontWeight).toBe(700)
|
|
expect(text.fontName.style).toBe('Bold Italic')
|
|
})
|
|
|
|
test('textAlignHorizontal', () => {
|
|
const api = createAPI()
|
|
const text = api.createText()
|
|
text.textAlignHorizontal = 'CENTER'
|
|
expect(text.textAlignHorizontal).toBe('CENTER')
|
|
})
|
|
|
|
test('textAutoResize', () => {
|
|
const api = createAPI()
|
|
const text = api.createText()
|
|
text.textAutoResize = 'WIDTH_AND_HEIGHT'
|
|
expect(text.textAutoResize).toBe('WIDTH_AND_HEIGHT')
|
|
})
|
|
})
|
|
|
|
describe('auto-layout', () => {
|
|
test('layoutMode', () => {
|
|
const api = createAPI()
|
|
const frame = api.createFrame()
|
|
frame.layoutMode = 'VERTICAL'
|
|
expect(frame.layoutMode).toBe('VERTICAL')
|
|
})
|
|
|
|
test('itemSpacing', () => {
|
|
const api = createAPI()
|
|
const frame = api.createFrame()
|
|
frame.layoutMode = 'VERTICAL'
|
|
frame.itemSpacing = 12
|
|
expect(frame.itemSpacing).toBe(12)
|
|
})
|
|
|
|
test('padding', () => {
|
|
const api = createAPI()
|
|
const frame = api.createFrame()
|
|
frame.paddingTop = 16
|
|
frame.paddingRight = 24
|
|
frame.paddingBottom = 16
|
|
frame.paddingLeft = 24
|
|
expect(frame.paddingTop).toBe(16)
|
|
expect(frame.paddingRight).toBe(24)
|
|
})
|
|
|
|
test('alignment', () => {
|
|
const api = createAPI()
|
|
const frame = api.createFrame()
|
|
frame.layoutMode = 'HORIZONTAL'
|
|
frame.primaryAxisAlignItems = 'SPACE_BETWEEN'
|
|
frame.counterAxisAlignItems = 'CENTER'
|
|
expect(frame.primaryAxisAlignItems).toBe('SPACE_BETWEEN')
|
|
expect(frame.counterAxisAlignItems).toBe('CENTER')
|
|
})
|
|
|
|
test('layoutWrap', () => {
|
|
const api = createAPI()
|
|
const frame = api.createFrame()
|
|
frame.layoutWrap = 'WRAP'
|
|
expect(frame.layoutWrap).toBe('WRAP')
|
|
})
|
|
})
|
|
|
|
describe('tree operations', () => {
|
|
test('appendChild reparents', () => {
|
|
const api = createAPI()
|
|
const parent = api.createFrame()
|
|
const child = api.createRectangle()
|
|
parent.appendChild(child)
|
|
expect(parent.children.length).toBe(1)
|
|
expect(parent.children[0].id).toBe(child.id)
|
|
expect(child.parent!.id).toBe(parent.id)
|
|
})
|
|
|
|
test('insertChild at index', () => {
|
|
const api = createAPI()
|
|
const parent = api.createFrame()
|
|
const a = api.createRectangle()
|
|
const b = api.createRectangle()
|
|
const c = api.createRectangle()
|
|
parent.appendChild(a)
|
|
parent.appendChild(b)
|
|
parent.insertChild(1, c)
|
|
expect(parent.children.map((c) => c.id)).toEqual([a.id, c.id, b.id])
|
|
})
|
|
|
|
test('remove deletes node', () => {
|
|
const api = createAPI()
|
|
const frame = api.createFrame()
|
|
const id = frame.id
|
|
frame.remove()
|
|
expect(api.getNodeById(id)).toBeNull()
|
|
})
|
|
|
|
test('removed property', () => {
|
|
const api = createAPI()
|
|
const frame = api.createFrame()
|
|
expect(frame.removed).toBe(false)
|
|
frame.remove()
|
|
expect(frame.removed).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('traversal', () => {
|
|
test('findAll finds deep descendants', () => {
|
|
const api = createAPI()
|
|
const parent = api.createFrame()
|
|
parent.name = 'Container'
|
|
const child = api.createFrame()
|
|
child.name = 'Card'
|
|
parent.appendChild(child)
|
|
const text = api.createText()
|
|
text.characters = 'Title'
|
|
text.name = 'Title'
|
|
child.appendChild(text)
|
|
|
|
const found = parent.findAll((n) => n.type === 'TEXT')
|
|
expect(found.length).toBe(1)
|
|
expect(found[0].characters).toBe('Title')
|
|
})
|
|
|
|
test('findAll without callback returns all', () => {
|
|
const api = createAPI()
|
|
const parent = api.createFrame()
|
|
const a = api.createRectangle()
|
|
const b = api.createText()
|
|
parent.appendChild(a)
|
|
parent.appendChild(b)
|
|
expect(parent.findAll().length).toBe(2)
|
|
})
|
|
|
|
test('findOne returns first match', () => {
|
|
const api = createAPI()
|
|
const parent = api.createFrame()
|
|
const a = api.createRectangle()
|
|
a.name = 'First'
|
|
const b = api.createRectangle()
|
|
b.name = 'Second'
|
|
parent.appendChild(a)
|
|
parent.appendChild(b)
|
|
const found = parent.findOne((n) => n.type === 'RECTANGLE')
|
|
expect(found!.name).toBe('First')
|
|
})
|
|
|
|
test('findChild searches direct children only', () => {
|
|
const api = createAPI()
|
|
const parent = api.createFrame()
|
|
const child = api.createFrame()
|
|
const deep = api.createText()
|
|
deep.name = 'Deep'
|
|
parent.appendChild(child)
|
|
child.appendChild(deep)
|
|
|
|
expect(parent.findChild((n) => n.name === 'Deep')).toBeNull()
|
|
expect(child.findChild((n) => n.name === 'Deep')).not.toBeNull()
|
|
})
|
|
|
|
test('findChildren returns direct children matching', () => {
|
|
const api = createAPI()
|
|
const parent = api.createFrame()
|
|
const a = api.createRectangle()
|
|
const b = api.createText()
|
|
const c = api.createRectangle()
|
|
parent.appendChild(a)
|
|
parent.appendChild(b)
|
|
parent.appendChild(c)
|
|
const rects = parent.findChildren((n) => n.type === 'RECTANGLE')
|
|
expect(rects.length).toBe(2)
|
|
})
|
|
})
|
|
|
|
describe('grouping', () => {
|
|
test('group creates GROUP with children', () => {
|
|
const api = createAPI()
|
|
const a = api.createRectangle()
|
|
const b = api.createEllipse()
|
|
const group = api.group([a, b], api.currentPage)
|
|
expect(group.type).toBe('GROUP')
|
|
expect(group.children.length).toBe(2)
|
|
})
|
|
|
|
test('ungroup dissolves group', () => {
|
|
const api = createAPI()
|
|
const a = api.createRectangle()
|
|
const b = api.createEllipse()
|
|
const group = api.group([a, b], api.currentPage)
|
|
const groupId = group.id
|
|
api.ungroup(group)
|
|
expect(api.getNodeById(groupId)).toBeNull()
|
|
expect(a.parent!.id).toBe(api.currentPage.id)
|
|
})
|
|
})
|
|
|
|
describe('selection', () => {
|
|
test('selection get/set', () => {
|
|
const api = createAPI()
|
|
const frame = api.createFrame()
|
|
api.currentPage.selection = [frame]
|
|
expect(api.currentPage.selection.length).toBe(1)
|
|
expect(api.currentPage.selection[0].id).toBe(frame.id)
|
|
})
|
|
|
|
test('selection empty by default', () => {
|
|
const api = createAPI()
|
|
expect(api.currentPage.selection).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('components', () => {
|
|
test('createInstance from component', () => {
|
|
const api = createAPI()
|
|
const comp = api.createComponent()
|
|
comp.name = 'Button'
|
|
comp.resize(200, 40)
|
|
const instance = comp.createInstance()
|
|
expect(instance.type).toBe('INSTANCE')
|
|
expect(instance.mainComponent!.id).toBe(comp.id)
|
|
})
|
|
})
|
|
|
|
describe('serialization', () => {
|
|
test('toJSON returns clean object', () => {
|
|
const api = createAPI()
|
|
const frame = api.createFrame()
|
|
frame.name = 'Card'
|
|
frame.resize(300, 200)
|
|
frame.fills = [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }]
|
|
const json = frame.toJSON()
|
|
expect(json.name).toBe('Card')
|
|
expect(json.width).toBe(300)
|
|
expect(json.fills).toBeDefined()
|
|
})
|
|
|
|
test('toJSON includes children', () => {
|
|
const api = createAPI()
|
|
const frame = api.createFrame()
|
|
const rect = api.createRectangle()
|
|
frame.appendChild(rect)
|
|
const json = frame.toJSON() as { children?: unknown[] }
|
|
expect(json.children).toBeDefined()
|
|
expect(json.children!.length).toBe(1)
|
|
})
|
|
|
|
test('toString returns readable string', () => {
|
|
const api = createAPI()
|
|
const frame = api.createFrame()
|
|
frame.name = 'Card'
|
|
expect(frame.toString()).toContain('FRAME')
|
|
expect(frame.toString()).toContain('Card')
|
|
})
|
|
})
|
|
|
|
describe('stubs', () => {
|
|
test('loadFontAsync resolves', async () => {
|
|
const api = createAPI()
|
|
await api.loadFontAsync({ family: 'Inter', style: 'Regular' })
|
|
})
|
|
|
|
test('mixed is a symbol', () => {
|
|
const api = createAPI()
|
|
expect(typeof api.mixed).toBe('symbol')
|
|
})
|
|
})
|
|
|
|
describe('absolute position', () => {
|
|
test('absoluteBoundingBox accounts for nesting', () => {
|
|
const api = createAPI()
|
|
const parent = api.createFrame()
|
|
parent.x = 100
|
|
parent.y = 200
|
|
const child = api.createRectangle()
|
|
parent.appendChild(child)
|
|
child.x = 10
|
|
child.y = 20
|
|
child.resize(50, 30)
|
|
const bounds = child.absoluteBoundingBox
|
|
expect(bounds.x).toBe(110)
|
|
expect(bounds.y).toBe(220)
|
|
expect(bounds.width).toBe(50)
|
|
expect(bounds.height).toBe(30)
|
|
})
|
|
})
|
|
|
|
describe('stroke details', () => {
|
|
test('strokeWeight and strokeAlign', () => {
|
|
const api = createAPI()
|
|
const rect = api.createRectangle()
|
|
rect.strokes = [{ color: { r: 0, g: 0, b: 0, a: 1 }, weight: 2, opacity: 1, visible: true, align: 'CENTER' }]
|
|
expect(rect.strokeWeight).toBe(2)
|
|
expect(rect.strokeAlign).toBe('CENTER')
|
|
rect.strokeWeight = 4
|
|
expect(rect.strokeWeight).toBe(4)
|
|
})
|
|
})
|
|
})
|