openpencil/packages/core/tests/canvas/instance-scale.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

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()
}
})
}