openpencil/tests/engine/tools/cli.test.ts
Danila Poyarkov e2a3aa3f80
fix: validate parsed JSON at untrusted boundaries with Valibot (#855)
* fix: validate parsed JSON at untrusted boundaries with Valibot

Clipboard HTML, library revisions from shared storage, MCP and automation
WebSocket messages, the MCP discovery file, sidecar output and AI/MCP tool
arguments were JSON.parse'd and cast to their expected types, so a
malformed payload reached the document or crashed paste. They now go
through v.pipe(v.string(), v.parseJson(), Schema), which reports bad JSON
and a wrong shape as the same validation failure.

The path_set tool rejects an invalid VectorNetwork and shares its parser
with create_vector. The CLI library catalog validates its files and runs
revisions through the same size, identity and content-hash checks as the
app; reading image bytes as index-keyed records also stops them coming
back empty. Hand-rolled typeof readers for plugin data, document metadata,
caches and preferences become schemas with their behaviour preserved, and
readCacheJSON takes a schema for its payload.

open-pencil/no-unvalidated-json-parse rejects type assertions on
JSON.parse results other than `as unknown` in src and packages/*/src.

* refactor: validate parsed JSON in tests and tooling

Extend open-pencil/no-unvalidated-json-parse beyond source: tests, helpers and repo tooling now parse JSON through Valibot schemas instead of asserting a type. The shared fixture reader returns a validated object; its old array annotation never matched the fixtures.

* fix: validate clipboard geometry bytes, library images and model catalogs

Clipboard geometry blobs and library image bytes must be bytes at contiguous indexes, so out-of-range or gapped values are rejected instead of silently becoming different geometry or images; serialized library nodes must carry source metadata. The models.dev and OpenRouter responses are validated like their cached copies, and activate-tab rejects a CDP frame it cannot read instead of hanging.

* refactor: extend the JSON validation lint to .json() results

no-unvalidated-json-parse now also rejects type assertions on Response, Bun.file and shell .json() results, the same unchecked parse in another form. MCP server tests read /health through a validated readHealth helper and discovery files through parseDiscoveryInfo; the remaining tooling reads its JSON through schemas.

* test: validate the RPC request body in the CLI app export test

* test: validate CLI JSON output in the tool and app command tests

* test: compare the malformed models.dev fallback with the curated list
2026-10-04 17:01:50 +00:00

229 lines
7 KiB
TypeScript

import { expect, setDefaultTimeout, test } from 'bun:test'
import * as v from 'valibot'
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 }
}
const RecordJSON = v.pipe(v.string(), v.parseJson(), v.record(v.string(), v.unknown()))
function parseRecord(stdout: string): Record<string, unknown> {
return v.parse(RecordJSON, stdout)
}
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 = parseRecord(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 = parseRecord(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 = parseRecord(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 = parseRecord(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 = parseRecord(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 = parseRecord(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 = parseRecord(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 = parseRecord(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 = parseRecord(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 = parseRecord(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 = parseRecord(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 = parseRecord(stdout)
expect(result.page).toBeTruthy()
expect(result.pageCount).toBeGreaterThanOrEqual(1)
})
})