* 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
161 lines
5.8 KiB
TypeScript
161 lines
5.8 KiB
TypeScript
import { resolve } from 'node:path'
|
|
import { expect, test } from 'bun:test'
|
|
|
|
import { SkiaRenderer } from '@open-pencil/core'
|
|
import { createEditor } from '@open-pencil/core/editor'
|
|
import { initCanvasKit, renderNodesToImage } from '@open-pencil/core/io'
|
|
import { materializeDocument } from '@open-pencil/fig'
|
|
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
|
|
import { rescaleNodeTree } from '@open-pencil/scene-graph'
|
|
|
|
import { expectDefined } from '../helpers/assert'
|
|
import { inheritedNestedBindingRecords } from '../helpers/fig/nested-binding'
|
|
import { FIXTURES, readFixtureObject } from '../helpers/fig/fixtures'
|
|
|
|
const fixture = readFixtureObject('nested-layout-scale.json')
|
|
|
|
const declared = readFixtureObject('nested-binding-ownership-records.json')
|
|
|
|
for (const edit of [
|
|
'direct',
|
|
'variable',
|
|
'declared',
|
|
'authored',
|
|
'component',
|
|
'expression',
|
|
'rescale',
|
|
'definition'
|
|
] as const) {
|
|
test(`${edit} nested scale edit matches the independent Figma raster`, async () => {
|
|
const captured = edit !== 'direct' && edit !== 'variable'
|
|
const records = captured
|
|
? declared.nodeChanges
|
|
: [{ guid: { sessionID: 0, localID: 0 }, type: 'DOCUMENT' }, ...fixture.before]
|
|
const { graph, sources } = materializeDocument(
|
|
['expression', 'definition'].includes(edit)
|
|
? inheritedNestedBindingRecords()
|
|
: (records as NodeChange[]),
|
|
(captured ? declared : fixture).blobs.map((value) => Uint8Array.fromBase64(value)),
|
|
{ derivedBounds: true }
|
|
)
|
|
const rootId = expectDefined(
|
|
sources.get(captured ? '293733:8' : fixture.ids.instance),
|
|
'instance'
|
|
)
|
|
const width = {
|
|
direct: 300,
|
|
variable: 300,
|
|
declared: 268,
|
|
authored: 264,
|
|
component: 264,
|
|
expression: 300,
|
|
rescale: 132,
|
|
definition: 180
|
|
}[edit]
|
|
const height = {
|
|
direct: 56,
|
|
variable: 56,
|
|
declared: 56,
|
|
authored: 56,
|
|
component: 64,
|
|
expression: 56,
|
|
rescale: 28,
|
|
definition: 56
|
|
}[edit]
|
|
const nested = expectDefined(graph.getChildren(rootId)[0], 'nested instance')
|
|
const editor = createEditor({ graph })
|
|
if (!captured) editor.updateNodeWithUndo(rootId, { paddingLeft: 13 })
|
|
if (edit === 'definition') {
|
|
rescaleNodeTree(graph, rootId, 0.5)
|
|
const outer = expectDefined(sources.get('1:4'), 'outer component')
|
|
rescaleNodeTree(graph, graph.getChildren(outer)[0].id, 2)
|
|
await Promise.resolve()
|
|
expect(nested.paddingLeft).toBe(5)
|
|
} else if (['authored', 'component', 'rescale'].includes(edit)) {
|
|
editor.bindVariable(rootId, 'paddingRight', '293742:7')
|
|
editor.bindVariable(nested.id, 'paddingRight', '293742:7')
|
|
expect(nested.paddingRight).toBe(6)
|
|
if (edit === 'rescale') rescaleNodeTree(graph, rootId, 0.5)
|
|
if (edit === 'component') {
|
|
editor.updateNodeWithUndo(expectDefined(sources.get('1:2'), 'component'), { paddingTop: 8 })
|
|
await Promise.resolve()
|
|
expect(nested.paddingTop).toBe(2)
|
|
}
|
|
} else if (edit === 'declared' || edit === 'expression') {
|
|
const values =
|
|
edit === 'expression'
|
|
? { first: 60, second: 40, afterFirst: 15, afterSecond: 10 }
|
|
: { first: 16, second: 12, afterFirst: 8, afterSecond: 6 }
|
|
const variable = expectDefined(
|
|
graph.variables.get(nested.boundVariables.paddingLeft),
|
|
'declared variable'
|
|
)
|
|
const mode = graph.getNodeVariableModeId(nested.id, variable.collectionId)
|
|
editor.updateVariableValue(variable.id, mode, values.first)
|
|
expect(nested.paddingLeft).toBe(values.afterFirst)
|
|
editor.updateVariableValue(variable.id, mode, values.second)
|
|
expect(nested.paddingLeft).toBe(values.afterSecond)
|
|
} else if (edit === 'direct') editor.updateNodeWithUndo(nested.id, { paddingLeft: 7 })
|
|
else {
|
|
const collection = graph.createCollection('Spacing')
|
|
const variable = graph.createVariable('Padding', 'FLOAT', collection.id, 10)
|
|
graph.updateNode(nested.id, {
|
|
boundVariables: { paddingLeft: variable.id },
|
|
variableBindingScales: { paddingLeft: 0.25 }
|
|
})
|
|
editor.updateVariableValue(variable.id, collection.defaultModeId, 28)
|
|
}
|
|
|
|
const ck = await initCanvasKit()
|
|
const renderer = new SkiaRenderer(ck, expectDefined(ck.MakeSurface(1, 1), 'surface'))
|
|
try {
|
|
const png = expectDefined(
|
|
renderNodesToImage(ck, renderer, graph, graph.getPages()[0].id, [rootId], {
|
|
scale: 8,
|
|
format: 'PNG',
|
|
trimTransparent: false
|
|
}),
|
|
'rendered PNG'
|
|
)
|
|
const expectedBytes = new Uint8Array(
|
|
await Bun.file(
|
|
resolve(
|
|
FIXTURES,
|
|
{
|
|
direct: 'nested-layout-scale-figma.png',
|
|
variable: 'nested-layout-scale-figma.png',
|
|
declared: 'nested-binding-ownership-figma.png',
|
|
authored: 'nested-binding-authoring-figma.png',
|
|
component: 'nested-component-scale-figma.png',
|
|
expression: 'nested-binding-expression-figma.png',
|
|
rescale: 'nested-owner-rescale-figma.png',
|
|
definition: 'nested-definition-rescale-figma.png'
|
|
}[edit]
|
|
)
|
|
).arrayBuffer()
|
|
)
|
|
const pixels = (bytes: Uint8Array) => {
|
|
const image = expectDefined(ck.MakeImageFromEncoded(bytes), 'decoded PNG')
|
|
try {
|
|
expect([image.width(), image.height()]).toEqual([width, height])
|
|
return expectDefined(
|
|
image.readPixels(0, 0, {
|
|
width,
|
|
height,
|
|
alphaType: ck.AlphaType.Unpremul,
|
|
colorType: ck.ColorType.RGBA_8888,
|
|
colorSpace: ck.ColorSpace.SRGB
|
|
}),
|
|
'decoded pixels'
|
|
)
|
|
} finally {
|
|
image.delete()
|
|
}
|
|
}
|
|
expect(pixels(png)).toEqual(pixels(expectedBytes))
|
|
} finally {
|
|
renderer.destroy()
|
|
}
|
|
})
|
|
}
|