openpencil/tests/engine/io/fig/import/pattern-paint-oracle.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

155 lines
5.7 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import { readFileSync } from 'node:fs'
import * as v from 'valibot'
const OracleVector = v.object({ x: v.number(), y: v.number() })
const PatternOracleFill = v.object({
type: v.string(),
sourceNodeId: v.string(),
tileType: v.string(),
spacing: OracleVector,
horizontalAlignment: v.string(),
verticalAlignment: v.string()
})
const EffectOracleResult = v.object({
ok: v.boolean(),
effects: v.array(
v.object({
type: v.string(),
noiseType: v.optional(v.string()),
noiseSize: v.optional(v.number()),
noiseSizeVector: v.optional(OracleVector),
density: v.optional(v.number()),
opacity: v.optional(v.number()),
secondaryColor: v.optional(v.unknown())
})
)
})
const OracleSource = v.object({ id: v.string(), visible: v.boolean() })
const PaintOracleJSON = v.pipe(
v.string(),
v.parseJson(),
v.object({
pattern: v.object({
frame: v.object({ id: v.string() }),
source: OracleSource,
target: v.object({ fills: v.array(PatternOracleFill) })
}),
patternAlignment: v.object({
frame: v.object({ id: v.string() }),
source: OracleSource,
targets: v.array(v.object({ alignment: v.string(), fills: v.array(PatternOracleFill) })),
metrics: v.object({ rmseNormalized: v.number(), fuzzDifferentPixels: v.number() }),
analysis: v.record(
v.string(),
v.object({
pairedRowCount: v.number(),
avgDeltaY: v.number(),
avgDeltaFirstX: v.number(),
missingOpenPencilRows: v.number(),
extraOpenPencilRows: v.number()
})
)
}),
effects: v.object({
noise: v.object({ results: v.record(v.string(), EffectOracleResult) }),
textureAndGlass: v.object({ results: v.record(v.string(), EffectOracleResult) })
}),
pluginRuntimeCreation: v.record(v.string(), v.object({ ok: v.boolean(), message: v.string() })),
currentFileFillTypes: v.record(v.string(), v.number()),
localFigFixtureFillTypes: v.record(v.string(), v.record(v.string(), v.number())),
status: v.string()
})
)
function readOracle() {
return v.parse(
PaintOracleJSON,
readFileSync('tests/fixtures/figma-oracles/pattern-noise-custom-paints.json', 'utf8')
)
}
describe('Figma pattern/noise/custom paint oracle availability', () => {
test('records the live Figma pattern paint payload', () => {
const oracle = readOracle()
const patternFill = oracle.pattern.target.fills[0]
expect(oracle.pattern.source.visible).toBe(true)
expect(patternFill?.type).toBe('PATTERN')
expect(patternFill?.sourceNodeId).toBe(oracle.pattern.source.id)
expect(patternFill?.tileType).toBe('RECTANGULAR')
expect(patternFill?.spacing.x).toBe(0.25)
expect(patternFill?.spacing.y).toBeCloseTo(0.4)
expect(patternFill?.horizontalAlignment).toBe('CENTER')
expect(patternFill?.verticalAlignment).toBe('CENTER')
})
test('records Figma pattern alignment payloads and current visual metrics', () => {
const alignment = readOracle().patternAlignment
expect(alignment.source.visible).toBe(true)
expect(alignment.targets.map((target) => target.alignment)).toEqual(['START', 'CENTER', 'END'])
for (const target of alignment.targets) {
const fill = target.fills[0]
expect(fill?.type).toBe('PATTERN')
expect(fill?.sourceNodeId).toBe(alignment.source.id)
expect(fill?.horizontalAlignment).toBe(target.alignment)
expect(fill?.verticalAlignment).toBe(target.alignment)
expect(fill?.spacing.y).toBeCloseTo(0.4)
}
expect(alignment.metrics.rmseNormalized).toBeCloseTo(0.246422)
expect(alignment.metrics.fuzzDifferentPixels).toBe(22209)
expect(alignment.analysis.START).toMatchObject({ avgDeltaY: 0, avgDeltaFirstX: 0 })
expect(alignment.analysis.CENTER).toMatchObject({ avgDeltaY: 3, avgDeltaFirstX: -0.5 })
expect(alignment.analysis.END).toMatchObject({ avgDeltaY: -6.67, avgDeltaFirstX: -0.5 })
})
test('records that noise and custom paint payloads are still blocked on Figma-authored samples', () => {
const oracle = readOracle()
expect(oracle.pluginRuntimeCreation.PATTERN_DIRECT_FILLS_ASSIGNMENT?.ok).toBe(false)
for (const type of ['NOISE', 'CUSTOM']) {
expect(oracle.pluginRuntimeCreation[`${type}_ASYNC_FILLS`]?.ok).toBe(false)
expect(oracle.currentFileFillTypes[type]).toBeUndefined()
for (const counts of Object.values(oracle.localFigFixtureFillTypes)) {
expect(counts[type]).toBeUndefined()
}
}
expect(oracle.status).toContain('NOISE and CUSTOM paint payloads')
})
test('records Figma-authored noise effect payloads for follow-up effect fidelity work', () => {
const { results } = readOracle().effects.noise
expect(results.MONOTONE?.ok).toBe(true)
expect(results.DUOTONE?.ok).toBe(true)
expect(results.MULTITONE?.ok).toBe(true)
const monotone = results.MONOTONE?.effects[0]
const duotone = results.DUOTONE?.effects[0]
const multitone = results.MULTITONE?.effects[0]
expect(monotone?.type).toBe('NOISE')
expect(monotone?.noiseType).toBe('MONOTONE')
expect(monotone?.noiseSizeVector).toEqual({ x: 0.5, y: 0.5 })
expect(monotone?.density).toBeCloseTo(0.4)
expect(duotone?.secondaryColor).toEqual({ r: 1, g: 1, b: 1, a: 1 })
expect(multitone?.opacity).toBeCloseTo(0.7)
})
test('records texture and glass effect payloads separately from unavailable custom paints', () => {
const { results } = readOracle().effects.textureAndGlass
expect(results.TEXTURE?.ok).toBe(true)
expect(results.TEXTURE?.effects[0]?.type).toBe('TEXTURE')
expect(results.TEXTURE?.effects[0]?.noiseSizeVector).toEqual({ x: 0.5, y: 0.5 })
expect(results.GLASS?.ok).toBe(true)
expect(results.GLASS?.effects[0]?.type).toBe('GLASS')
})
})