* build(core): import Markdown with unplugin-raw Core inlined ?raw imports with a hand-written Rolldown plugin that also turned plain .md imports into strings, which nothing in Core uses. unplugin-raw already does this for the Vue SDK and design-jsx; use it here too and drop the unused *.md module declaration. * refactor(pen): use the scene-graph color parser pen/src/color.ts duplicated parseColor from @open-pencil/scene-graph/color line for line. Import it instead, which also drops pen's direct culori dependency. * fix(figma-api): validate effects like Figma The effects setter stored whatever a script passed, so scripts that Figma rejects ran here and malformed effects reached rendering and .fig export. Validate against Figma's effect shapes with Valibot, recorded from live Figma: strict objects, required shadow fields, radius >= 0, RGBA channels within 0..1, and no shadow fields on blurs. The getter now returns Figma's shape so node.effects = node.effects keeps working. Closes #786 * fix(figma-api): reject infinite numbers in effects Figma rejects Infinity in every effect number ("Number must be finite"), but v.number() accepts it, so infinite radii, offsets, and spreads reached the scene graph. * fix(figma-api): store PASS_THROUGH shadow blend as NORMAL PASS_THROUGH is a layer blend mode. Live Figma accepts it on drop and inner shadows but reads NORMAL back, so do the same instead of storing it.
225 lines
7.2 KiB
TypeScript
225 lines
7.2 KiB
TypeScript
import { expect, setDefaultTimeout, test } from 'bun:test'
|
|
|
|
import { cliSourcePath, repoPath } from '#tests/helpers/paths'
|
|
import { heavy } from '#tests/helpers/test-utils'
|
|
|
|
setDefaultTimeout(30_000)
|
|
|
|
const CLI = cliSourcePath('index.ts')
|
|
const FIXTURE = repoPath('tests/fixtures/gold-preview.fig')
|
|
|
|
async function evalCode(
|
|
code: string
|
|
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
const proc = Bun.spawn([process.execPath, CLI, 'eval', FIXTURE, '--code', code, '--json'], {
|
|
stdout: 'pipe',
|
|
stderr: 'pipe'
|
|
})
|
|
const [stdout, stderr] = await Promise.all([
|
|
new Response(proc.stdout).text(),
|
|
new Response(proc.stderr).text()
|
|
])
|
|
const exitCode = await proc.exited
|
|
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }
|
|
}
|
|
|
|
function parseJSON<T>(stdout: string): T {
|
|
return JSON.parse(stdout) as T
|
|
}
|
|
|
|
heavy('CLI tool operations via eval', () => {
|
|
test('create and read back a node', async () => {
|
|
const { stdout, exitCode } = await evalCode(`
|
|
const r = figma.createRectangle()
|
|
r.name = 'TestRect'
|
|
r.x = 100
|
|
r.y = 200
|
|
r.resize(300, 150)
|
|
return r.toJSON()
|
|
`)
|
|
expect(exitCode).toBe(0)
|
|
const result = parseJSON<Record<string, unknown>>(stdout)
|
|
expect(result.name).toBe('TestRect')
|
|
expect(result.x).toBe(100)
|
|
expect(result.y).toBe(200)
|
|
expect(result.width).toBe(300)
|
|
expect(result.height).toBe(150)
|
|
})
|
|
|
|
test('set fill on a node', async () => {
|
|
const { stdout, exitCode } = await evalCode(`
|
|
const r = figma.createRectangle()
|
|
r.resize(50, 50)
|
|
r.fills = [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
|
|
return { fills: r.fills }
|
|
`)
|
|
expect(exitCode).toBe(0)
|
|
const result = parseJSON<Record<string, unknown>>(stdout)
|
|
expect(result.fills.length).toBe(1)
|
|
expect(result.fills[0].color.r).toBe(1)
|
|
})
|
|
|
|
test('set layout on a frame', async () => {
|
|
const { stdout, exitCode } = await evalCode(`
|
|
const f = figma.createFrame()
|
|
f.resize(300, 200)
|
|
f.layoutMode = 'VERTICAL'
|
|
f.itemSpacing = 16
|
|
f.paddingLeft = 20
|
|
f.paddingRight = 20
|
|
f.paddingTop = 20
|
|
f.paddingBottom = 20
|
|
return {
|
|
layoutMode: f.layoutMode,
|
|
itemSpacing: f.itemSpacing,
|
|
paddingLeft: f.paddingLeft
|
|
}
|
|
`)
|
|
expect(exitCode).toBe(0)
|
|
const result = parseJSON<Record<string, unknown>>(stdout)
|
|
expect(result.layoutMode).toBe('VERTICAL')
|
|
expect(result.itemSpacing).toBe(16)
|
|
expect(result.paddingLeft).toBe(20)
|
|
})
|
|
|
|
test('create component from node', async () => {
|
|
const { stdout, exitCode } = await evalCode(`
|
|
const f = figma.createFrame()
|
|
f.name = 'Button'
|
|
f.resize(200, 48)
|
|
const comp = figma.createComponentFromNode(f)
|
|
return { name: comp.name, type: comp.type }
|
|
`)
|
|
expect(exitCode).toBe(0)
|
|
const result = parseJSON<Record<string, unknown>>(stdout)
|
|
expect(result.name).toBe('Button')
|
|
expect(result.type).toBe('COMPONENT')
|
|
})
|
|
|
|
test('group and ungroup nodes', async () => {
|
|
const { stdout, exitCode } = await evalCode(`
|
|
const r1 = figma.createRectangle()
|
|
r1.resize(50, 50)
|
|
const r2 = figma.createRectangle()
|
|
r2.resize(50, 50)
|
|
const group = figma.group([r1, r2], figma.currentPage)
|
|
const groupType = group.type
|
|
const childCount = group.children.length
|
|
figma.ungroup(group)
|
|
const ungrouped = figma.getNodeById(group.id)
|
|
return { groupType, childCount, ungroupedExists: ungrouped !== null }
|
|
`)
|
|
expect(exitCode).toBe(0)
|
|
const result = parseJSON<Record<string, unknown>>(stdout)
|
|
expect(result.groupType).toBe('GROUP')
|
|
expect(result.childCount).toBe(2)
|
|
expect(result.ungroupedExists).toBe(false)
|
|
})
|
|
|
|
test('find nodes by type on fixture', async () => {
|
|
const { stdout, exitCode } = await evalCode(`
|
|
const texts = figma.currentPage.findAllWithCriteria({ types: ['TEXT'] })
|
|
return { count: texts.length, hasTexts: texts.length > 0 }
|
|
`)
|
|
expect(exitCode).toBe(0)
|
|
const result = parseJSON<Record<string, unknown>>(stdout)
|
|
expect(result.hasTexts).toBe(true)
|
|
expect(result.count).toBeGreaterThan(0)
|
|
})
|
|
|
|
test('clone a node', async () => {
|
|
const { stdout, exitCode } = await evalCode(`
|
|
const r = figma.createRectangle()
|
|
r.name = 'Original'
|
|
r.resize(100, 100)
|
|
const clone = r.clone()
|
|
return {
|
|
same: r.id === clone.id,
|
|
cloneName: clone.name,
|
|
cloneWidth: clone.width
|
|
}
|
|
`)
|
|
expect(exitCode).toBe(0)
|
|
const result = parseJSON<Record<string, unknown>>(stdout)
|
|
expect(result.same).toBe(false)
|
|
expect(result.cloneName).toBe('Original')
|
|
expect(result.cloneWidth).toBe(100)
|
|
})
|
|
|
|
test('reparent node into frame', async () => {
|
|
const { stdout, exitCode } = await evalCode(`
|
|
const frame = figma.createFrame()
|
|
frame.resize(300, 300)
|
|
const rect = figma.createRectangle()
|
|
rect.resize(50, 50)
|
|
frame.appendChild(rect)
|
|
return {
|
|
parentId: rect.parent?.id,
|
|
isChild: frame.children.some(c => c.id === rect.id)
|
|
}
|
|
`)
|
|
expect(exitCode).toBe(0)
|
|
const result = parseJSON<Record<string, unknown>>(stdout)
|
|
expect(result.isChild).toBe(true)
|
|
})
|
|
|
|
test('set constraints', async () => {
|
|
const { stdout, exitCode } = await evalCode(`
|
|
const r = figma.createRectangle()
|
|
r.resize(100, 100)
|
|
r.constraints = { horizontal: 'CENTER', vertical: 'STRETCH' }
|
|
return r.constraints
|
|
`)
|
|
expect(exitCode).toBe(0)
|
|
const result = parseJSON<Record<string, unknown>>(stdout)
|
|
expect(result.horizontal).toBe('CENTER')
|
|
expect(result.vertical).toBe('STRETCH')
|
|
})
|
|
|
|
test('set effects', async () => {
|
|
const { stdout, exitCode } = await evalCode(`
|
|
const f = figma.createFrame()
|
|
f.resize(100, 100)
|
|
f.effects = [{
|
|
type: 'DROP_SHADOW',
|
|
color: { r: 0, g: 0, b: 0, a: 0.25 },
|
|
offset: { x: 0, y: 4 },
|
|
radius: 8,
|
|
spread: 0,
|
|
visible: true,
|
|
blendMode: 'NORMAL'
|
|
}]
|
|
return { count: f.effects.length, type: f.effects[0].type }
|
|
`)
|
|
expect(exitCode).toBe(0)
|
|
const result = parseJSON<Record<string, unknown>>(stdout)
|
|
expect(result.count).toBe(1)
|
|
expect(result.type).toBe('DROP_SHADOW')
|
|
})
|
|
|
|
test('list variables from fixture', async () => {
|
|
const { stdout, exitCode } = await evalCode(`
|
|
const vars = figma.getLocalVariables()
|
|
const cols = figma.getLocalVariableCollections()
|
|
return { variables: vars.length, collections: cols.length }
|
|
`)
|
|
expect(exitCode).toBe(0)
|
|
const result = parseJSON<Record<string, unknown>>(stdout)
|
|
expect(typeof result.variables).toBe('number')
|
|
expect(typeof result.collections).toBe('number')
|
|
})
|
|
|
|
test('switch page', async () => {
|
|
const { stdout, exitCode } = await evalCode(`
|
|
const pages = figma.root.children
|
|
const first = pages[0]
|
|
figma.currentPage = first
|
|
return { page: figma.currentPage.name, pageCount: pages.length }
|
|
`)
|
|
expect(exitCode).toBe(0)
|
|
const result = parseJSON<Record<string, unknown>>(stdout)
|
|
expect(result.page).toBeTruthy()
|
|
expect(result.pageCount).toBeGreaterThanOrEqual(1)
|
|
})
|
|
})
|