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
This commit is contained in:
Danila Poyarkov 2026-10-04 17:01:50 +00:00 committed by GitHub
parent b9753aa016
commit e2a3aa3f80
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
95 changed files with 2087 additions and 784 deletions

View file

@ -57,6 +57,7 @@ Before a PR run `bun run check`, `bun run format`, `bun run test:unit`, and `bun
- Valibot for first-party runtime validation. Keep Zod only where an upstream dependency requires it; never maintain parallel first-party schemas in both.
- Use existing dependencies before writing utilities: `culori` for color conversion, `es-toolkit` for focused helpers (without replacing clear native code), VueUse for browser, event, focus, clipboard, storage, and timer behavior (one-shot rAF or service-owned timers are fine when clearer), `dedent` for multiline prompt composition and embedded examples. Keep substantial prompt prose in the owning Markdown source and compose it.
- Use `js-base64` directly for Base64: `fromUint8Array`/`toUint8Array` for bytes, `encode`/`decode` for text, and `isValid` before decoding input from outside (clipboard, imported files, tool arguments). Do not wrap it or use `atob`, `btoa`, or `Buffer` Base64 conversions; `open-pencil/no-hand-rolled-base64` enforces this.
- Parse JSON from outside the process (clipboard, files, network, storage, tool arguments) with `v.safeParse(v.pipe(v.string(), v.parseJson(), Schema), text)`, so malformed JSON and a wrong shape fail the same way. Never assert a type on a `JSON.parse` or `.json()` result other than `unknown`; `open-pencil/no-unvalidated-json-parse` enforces this in source, tests, and tooling.
- Browser-shipped code targets the supported browser baseline in `src/app/shell/support/baseline.ts`: TypeScript `lib` stays ES2023 and `compat/compat` rejects missing Web APIs; Node-only packages (`cli`, `mcp`, `harness`) are exempt. Details in `src/AGENTS.md` (Browser baseline).
- Components must not hold module-level mutable state. Name repeated or cross-feature constants; app-wide values belong in `src/constants.ts`.
- Window API augmentations belong to the owning compilation boundary: `src/global.d.ts` for the app, the package's `global.d.ts` for package DOM gaps, `tests/helpers/tauri/native-global.d.ts` for native tests. Never put `declare global` in specs or implementation modules; include canonical declarations through tsconfig. Keep app API contracts named and owned by their implementation domain; derive vendor API types from top-level type imports. Optional runtime globals stay optional and need a runtime guard.

View file

@ -107,6 +107,8 @@
- Load the Bold, Medium, and other styles of macOS system fonts packaged as font collections, such as Menlo, Helvetica Neue, and Avenir Next, instead of reporting them as substituted or drawing a different style (#746).
- Load the Medium, Semibold, Bold, and other styles of installed variable fonts such as SF Pro on macOS instead of reporting them as substituted (#752).
- Ship the MIT license text in every published npm package, and add READMEs for `@open-pencil/core`, `@open-pencil/cli`, and `@open-pencil/mcp` on npm.
- Reject malformed vector network JSON in the AI and MCP `path_set` tool with an error instead of storing it on the node, where later path tools failed on it.
- Keep image bytes when the CLI reads a component library revision back from its catalog, instead of restoring every image empty.
- Work with pages other than the one on screen through MCP, `eval`, and the CLI against the running app without switching to them: `openpencil export --page` and `--page-id` export that page instead of the selection, `export_image` and `openpencil export --node` export layers from any page instead of failing with "Raster export selection must stay on a single page", and a `.fig` page that has not been shown yet gets its layers, fonts, and layout before a command reads or changes it.
- Fill the open subpaths of filled, unstroked SVG paths as if they were closed, as SVG does, in icons from `insert_icon` and Design JSX `<Icon>`, inline Design JSX `<svg>`, SVG from `import_svg` or dropped and pasted files, and SVG clip paths. Icons that cut holes with open subpaths, such as some Font Awesome icons, no longer render with those holes filled in, and filled `<polyline>` elements render filled instead of not at all.
- Keep round and other stroke caps and joins after saving and reopening the file on icons from `insert_icon` and Design JSX `<Icon>`, and on vectors from inline Design JSX `<svg>`, `import_svg`, and dropped or pasted SVG files. They were set only on the stroke paint, which `.fig` does not store, so outline icons such as Lucide's reopened with butt caps and miter joins and showed gaps where their strokes meet.
@ -123,6 +125,10 @@
- Validate cursors, selections, and names that collaborators send before drawing them, and cap their size, so a broken or hostile peer cannot crash or flood the canvas.
- Evaluate `calc` expressions through `jsep` and an arithmetic allowlist that never compiles input into JavaScript, replacing the `expr-eval` dependency and its unpatched critical code-execution advisory (GHSA-q9v2-7m5w-4693).
- Escape layer names and other text properties in JSX and Tailwind JSX export, so text from a document can no longer add attributes or JavaScript expressions that the AI and MCP `render` and `replace` tools would execute, and names containing `&` no longer change when the JSX is rendered back.
- Validate OpenPencil and Figma clipboard data before pasting, so malformed or hostile clipboard content is ignored instead of throwing out of paste or writing mistyped layers into the document.
- Validate component library revisions from shared storage and from CLI catalogs before reading them, and run the CLI's revisions through the same size, identity, and content-hash checks as the app, so a malformed or tampered revision is rejected instead of crashing or entering the document.
- Validate MCP and automation WebSocket messages and the MCP discovery file, so a malformed message or a non-string auth token is rejected instead of being used unchecked.
- List AI models when the models.dev catalog returns a malformed entry: the curated list is shown instead of model listing failing until the app restarts.
## 0.15.1 — 2026-09-18

View file

@ -451,6 +451,12 @@
"open-pencil/no-useless-pass-through-wrappers": "error"
}
},
{
"files": ["**/*.{ts,vue}"],
"rules": {
"open-pencil/no-unvalidated-json-parse": "error"
}
},
{
"files": [
"src/**/*.{ts,vue}",

View file

@ -43,13 +43,7 @@ export async function readManifest(outputDir: string): Promise<StoryManifest> {
return { version: 1, files: {} }
throw error
}
let data: unknown
try {
data = JSON.parse(text)
} catch {
data = undefined
}
const result = v.safeParse(ManifestSchema, data)
const result = v.safeParse(v.pipe(v.string(), v.parseJson(), ManifestSchema), text)
if (!result.success)
throw new Error(
`${path} is not a valid OpenPencil stories manifest; remove it together with the stories it listed, then export again.`

View file

@ -1,18 +1,24 @@
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
import { dirname, resolve, sep } from 'node:path'
import * as v from 'valibot'
import type {
ComponentLibraryRevision,
LibraryCatalog,
LibrarySummary,
PublishLibraryInput,
SerializedComponentLibraryRevision,
StoredLibraryLatestManifest
} from '@open-pencil/core/library'
import {
createLibraryRevision,
deserializeLibraryRevision,
serializeLibraryRevision
LibrarySummarySchema,
MAX_LIBRARY_REVISION_BYTES,
SerializedLibraryRevisionSchema,
serializeLibraryRevision,
StoredLibraryLatestManifestSchema,
validateLibraryRevision
} from '@open-pencil/core/library'
const catalogPublicationQueues = new Map<string, Promise<void>>()
@ -43,7 +49,7 @@ export class FileSystemLibraryCatalog implements LibraryCatalog {
async listLibraries(): Promise<LibrarySummary[]> {
try {
return await this.#readJSON<LibrarySummary[]>('libraries.json')
return await this.#readJSON('libraries.json', v.array(LibrarySummarySchema))
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return []
throw error
@ -53,12 +59,22 @@ export class FileSystemLibraryCatalog implements LibraryCatalog {
async getRevision(libraryId: string, revisionId?: string): Promise<ComponentLibraryRevision> {
const resolvedRevisionId =
revisionId ??
(await this.#readJSON<StoredLibraryLatestManifest>(`${libraryId}/manifest.json`)).summary
.latestRevisionId
const data = await this.#readJSON<SerializedComponentLibraryRevision>(
`${libraryId}/revisions/${resolvedRevisionId}.json`
(await this.#readJSON(`${libraryId}/manifest.json`, StoredLibraryLatestManifestSchema))
.summary.latestRevisionId
const data = await this.#readJSON(
`${libraryId}/revisions/${resolvedRevisionId}.json`,
SerializedLibraryRevisionSchema,
MAX_LIBRARY_REVISION_BYTES
)
return deserializeLibraryRevision(data)
const revision = deserializeLibraryRevision(data)
if (
revision.manifest.libraryId !== libraryId ||
revision.manifest.revisionId !== resolvedRevisionId
) {
throw new Error('Library revision identity mismatch')
}
await validateLibraryRevision(revision)
return revision
}
async publishRevision(input: PublishLibraryInput): Promise<ComponentLibraryRevision> {
@ -107,8 +123,19 @@ export class FileSystemLibraryCatalog implements LibraryCatalog {
return path
}
async #readJSON<T>(relative: string): Promise<T> {
return JSON.parse(await readFile(this.#path(relative), 'utf8')) as T
async #readJSON<TSchema extends v.GenericSchema>(
relative: string,
schema: TSchema,
maxBytes = Number.POSITIVE_INFINITY
): Promise<v.InferOutput<TSchema>> {
const bytes = await readFile(this.#path(relative))
if (bytes.byteLength > maxBytes)
throw new Error(`Library catalog file exceeds size limit: ${relative}`)
const text = new TextDecoder().decode(bytes)
const result = v.safeParse(v.pipe(v.string(), v.parseJson(), schema), text)
if (!result.success)
throw new Error(`Invalid library catalog file ${relative}: ${v.summarize(result.issues)}`)
return result.output
}
async #writeJSON(relative: string, value: unknown): Promise<void> {

View file

@ -5,6 +5,8 @@ import type { AddressInfo } from 'node:net'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import * as v from 'valibot'
import { CLI_ENTRY } from '#cli-tests/helpers/paths'
setDefaultTimeout(60_000)
@ -17,7 +19,13 @@ const TARGET = {
pageName: 'Page 1'
}
type Request = { command: string; args: Record<string, unknown> }
const RequestJSON = v.pipe(
v.string(),
v.parseJson(),
v.object({ command: v.string(), args: v.record(v.string(), v.unknown()) })
)
type Request = v.InferOutput<typeof RequestJSON>
const requests: Request[] = []
const responses = new Map<string, unknown>()
@ -31,7 +39,7 @@ beforeAll(async () => {
body += chunk.toString()
})
request.on('end', () => {
const parsed = JSON.parse(body) as Request
const parsed = v.parse(RequestJSON, body)
requests.push(parsed)
response.writeHead(200, { 'Content-Type': 'application/json' })
response.end(

View file

@ -3,6 +3,8 @@ import { mkdtemp, readFile, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import * as v from 'valibot'
import { CLI_ENTRY, FIXTURES } from '#cli-tests/helpers/paths'
setDefaultTimeout(60_000)
@ -47,7 +49,10 @@ describe('diff CLI', () => {
const files = await cli(['diff', 'files', FIXTURE, outPath, '--json'])
// Like diff(1), a difference exits 1.
expect(files.exitCode).toBe(1)
const result = JSON.parse(files.stdout) as { diff: string }
const result = v.parse(
v.pipe(v.string(), v.parseJson(), v.object({ diff: v.string() })),
files.stdout
)
expect(result.diff).toContain(`/Logo #${LOGO} removed`)
expect(result.diff).toContain('opacity={0.5}')

View file

@ -6,13 +6,20 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { runCommand } from 'citty'
import * as v from 'valibot'
import type { AutomationDocumentSummary } from '@open-pencil/core/rpc'
import { removeDiscoveryFile, writeDiscoveryFile } from '@open-pencil/mcp/discovery'
import exportCommand from '#cli/commands/export'
type RPCRequest = { command: string; args: Record<string, unknown> }
const RPCRequestJSON = v.pipe(
v.string(),
v.parseJson(),
v.object({ command: v.string(), args: v.record(v.string(), v.unknown()) })
)
type RPCRequest = v.InferOutput<typeof RPCRequestJSON>
const DOCUMENT: AutomationDocumentSummary = {
id: 'tab-1',
@ -46,7 +53,7 @@ beforeAll(async () => {
const chunks: Buffer[] = []
request.on('data', (chunk: Buffer) => chunks.push(chunk))
request.on('end', () => {
const body = JSON.parse(Buffer.concat(chunks).toString('utf-8')) as RPCRequest
const body = v.parse(RPCRequestJSON, Buffer.concat(chunks).toString('utf-8'))
requests.push(body)
response.writeHead(200, { 'Content-Type': 'application/json' })
response.end(JSON.stringify({ ok: true, result: reply(body.command) }))

View file

@ -3,12 +3,18 @@ import { mkdtemp } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import * as v from 'valibot'
import { CLI_ENTRY, FIXTURES } from '#cli-tests/helpers/paths'
setDefaultTimeout(60_000)
const FIXTURE = join(FIXTURES, 'gold-preview.fig')
function parseJSON<T>(text: string, schema: v.GenericSchema<unknown, T>): T {
return v.parse(v.pipe(v.string(), v.parseJson(), schema), text)
}
async function cli(args: string[]) {
const proc = Bun.spawn([process.execPath, CLI_ENTRY, ...args], { stdout: 'pipe', stderr: 'pipe' })
const [stdout, stderr] = await Promise.all([
@ -22,13 +28,19 @@ describe('tool CLI', () => {
test('lists and describes tools with their effect and argument schema', async () => {
const listed = await cli(['tool', 'list', '--json'])
expect(listed.exitCode).toBe(0)
const tools = JSON.parse(listed.stdout) as Array<{ name: string; effect: string }>
const tools = parseJSON(
listed.stdout,
v.array(v.looseObject({ name: v.string(), effect: v.string() }))
)
expect(tools).toContainEqual(expect.objectContaining({ name: 'set_fill', effect: 'write' }))
expect(tools).toContainEqual(expect.objectContaining({ name: 'list_pages', effect: 'read' }))
const described = await cli(['tool', 'describe', 'create_page', '--json'])
expect(described.exitCode).toBe(0)
const info = JSON.parse(described.stdout) as { schema: { properties: object } }
const info = parseJSON(
described.stdout,
v.looseObject({ schema: v.looseObject({ properties: v.record(v.string(), v.unknown()) }) })
)
expect(info.schema.properties).toHaveProperty('name')
})
@ -49,7 +61,10 @@ describe('tool CLI', () => {
expect(JSON.parse(created.stdout)).toMatchObject({ name: 'From CLI' })
const pages = await cli(['tool', 'call', 'list_pages', outPath, '--json'])
const result = JSON.parse(pages.stdout) as { pages: Array<{ name: string }> }
const result = parseJSON(
pages.stdout,
v.looseObject({ pages: v.array(v.looseObject({ name: v.string() })) })
)
expect(result.pages.map((page) => page.name)).toContain('From CLI')
})

View file

@ -32,7 +32,65 @@ describe('filesystem library catalog', () => {
const catalog = new FileSystemLibraryCatalog(root)
await expect(catalog.getRevision('../outside')).rejects.toThrow('Invalid catalog path')
await Bun.write(join(root, 'libraries.json'), '{invalid')
await expect(catalog.listLibraries()).rejects.toBeInstanceOf(SyntaxError)
await expect(catalog.listLibraries()).rejects.toThrow(
'Invalid library catalog file libraries.json'
)
await Bun.write(join(root, 'libraries.json'), '[{"libraryId":1}]')
await expect(catalog.listLibraries()).rejects.toThrow(
'Invalid library catalog file libraries.json'
)
})
test('restores image bytes from stored revisions', async () => {
const root = await mkdtemp(join(tmpdir(), 'open-pencil-libraries-'))
const graph = new SceneGraph()
graph.images.set('image-hash', new Uint8Array([1, 2, 3]))
graph.createNode('COMPONENT', graph.getPages()[0].id, {
name: 'Avatar',
componentKey: 'avatar',
fills: [
{
type: 'IMAGE',
imageHash: 'image-hash',
visible: true,
opacity: 1,
color: { r: 0, g: 0, b: 0, a: 1 },
blendMode: 'NORMAL'
}
]
})
const catalog = new FileSystemLibraryCatalog(root)
await catalog.publishRevision({ libraryId: 'design-system', name: 'Design system', graph })
const restored = await catalog.getRevision('design-system')
expect(restored.graph.images.get('image-hash')).toEqual(new Uint8Array([1, 2, 3]))
})
test('rejects revisions that are malformed or fail integrity validation', async () => {
const root = await mkdtemp(join(tmpdir(), 'open-pencil-libraries-'))
const graph = new SceneGraph()
graph.createNode('COMPONENT', graph.getPages()[0].id, {
name: 'Button',
componentKey: 'button'
})
const catalog = new FileSystemLibraryCatalog(root)
const revision = await catalog.publishRevision({
libraryId: 'design-system',
name: 'Design system',
graph
})
const path = join(root, 'design-system/revisions', `${revision.manifest.revisionId}.json`)
const stored = await readFile(path, 'utf8')
const contentHash = revision.manifest.assets[0]?.contentHash ?? ''
await Bun.write(path, JSON.stringify({ manifest: revision.manifest, graph: { nodes: 'none' } }))
await expect(catalog.getRevision('design-system')).rejects.toThrow(
'Invalid library catalog file'
)
await Bun.write(path, stored.replaceAll(contentHash, 'tampered'))
await expect(catalog.getRevision('design-system')).rejects.toThrow(
'Component library content hash mismatch'
)
})
test('serializes concurrent publishers and keeps a valid index', async () => {

View file

@ -1,5 +1,6 @@
import { deflateSync, inflateSync } from 'fflate'
import { fromUint8Array, isValid, toUint8Array } from 'js-base64'
import * as v from 'valibot'
import {
createInstanceOverrideState,
@ -8,21 +9,97 @@ import {
setInstanceOverride,
type InstanceOverrideState,
type GeometryPath,
type NodeType,
type SceneGraph,
type SceneNode,
type SerializedInstanceOverrideState
type WindingRule
} from '@open-pencil/scene-graph'
import type { JSONObject } from '@open-pencil/scene-graph/primitives'
import type { ClipboardSnapshot } from '#core/editor/clipboard/copy'
interface SerializedClipboardNode extends JSONObject {
overrides?: Record<string, unknown>
instanceOverrides?: SerializedInstanceOverrideState
textPicture?: string | Uint8Array
children?: SerializedClipboardNode[]
const NODE_TYPES: Record<NodeType, true> = {
CANVAS: true,
FRAME: true,
RECTANGLE: true,
ROUNDED_RECTANGLE: true,
ELLIPSE: true,
TEXT: true,
LINE: true,
STAR: true,
POLYGON: true,
VECTOR: true,
BOOLEAN_OPERATION: true,
GROUP: true,
SECTION: true,
COMPONENT: true,
COMPONENT_SET: true,
INSTANCE: true,
CONNECTOR: true,
SHAPE_WITH_TEXT: true
}
interface SerializedGeometryPath {
windingRule: WindingRule
commandsBlob: Record<string, number>
[key: string]: unknown
}
/** The node fields paste dereferences; the remaining SceneNode fields pass through. */
interface SerializedClipboardNode {
id: string
type: NodeType
x: number
y: number
overrides?: Record<string, unknown>
instanceOverrides?: unknown
textPicture?: string | null
fillGeometry?: SerializedGeometryPath[]
strokeGeometry?: SerializedGeometryPath[]
children?: SerializedClipboardNode[]
[key: string]: unknown
}
const finiteNumber = v.pipe(v.number(), v.finite())
const byte = v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(255))
const SerializedGeometry = v.optional(
v.array(
v.looseObject({
windingRule: v.picklist(['NONZERO', 'EVENODD']),
commandsBlob: v.record(v.string(), byte)
})
)
)
const SerializedClipboardNodeSchema: v.GenericSchema<unknown, SerializedClipboardNode> =
v.looseObject({
id: v.string(),
type: v.custom<NodeType>(
(value) => typeof value === 'string' && Object.hasOwn(NODE_TYPES, value)
),
// Older clipboard payloads may omit a position; paste offsets from the origin then.
x: v.optional(finiteNumber, 0),
y: v.optional(finiteNumber, 0),
overrides: v.optional(v.record(v.string(), v.unknown())),
instanceOverrides: v.optional(v.unknown()),
textPicture: v.optional(v.nullable(v.string())),
fillGeometry: SerializedGeometry,
strokeGeometry: SerializedGeometry,
children: v.optional(v.array(v.lazy(() => SerializedClipboardNodeSchema)))
})
/** Clipboard HTML is writable by any application, so the payload is validated before paste. */
const OpenPencilClipboardJSON = v.pipe(
v.string(),
v.parseJson(),
v.object({
format: v.literal('openpencil/v1'),
nodes: v.array(SerializedClipboardNodeSchema),
images: v.optional(v.record(v.string(), v.unknown()))
})
)
type ClipboardNode = SceneNode & { children?: ClipboardNode[] }
export type OpenPencilClipboardData = Pick<ClipboardSnapshot, 'nodes' | 'images'>
@ -39,19 +116,17 @@ export function parseOpenPencilClipboard(html: string): OpenPencilClipboardData
} catch {
bytes = raw
}
const decoded = JSON.parse(new TextDecoder().decode(bytes))
if (decoded.format === 'openpencil/v1' && Array.isArray(decoded.nodes)) {
const nodes = restoreNodeData(decoded.nodes as SerializedClipboardNode[])
const images = new Map<string, Uint8Array>()
if (decoded.images && typeof decoded.images === 'object') {
for (const [hash, b64] of Object.entries(decoded.images)) {
if (typeof b64 === 'string') {
images.set(hash, clipboardBytes(b64))
}
}
}
return { nodes, images }
const decoded = v.safeParse(OpenPencilClipboardJSON, new TextDecoder().decode(bytes))
if (!decoded.success) {
console.warn('Ignoring malformed OpenPencil clipboard data:', v.summarize(decoded.issues))
return null
}
const nodes = restoreNodeData(decoded.output.nodes)
const images = new Map<string, Uint8Array>()
for (const [hash, b64] of Object.entries(decoded.output.images ?? {})) {
if (typeof b64 === 'string') images.set(hash, clipboardBytes(b64))
}
return { nodes, images }
} catch (e) {
console.warn('Failed to parse OpenPencil clipboard data:', e)
}
@ -74,17 +149,11 @@ function legacyInstanceOverrides(
return state
}
function restoreGeometry(paths: unknown): GeometryPath[] {
if (!Array.isArray(paths)) return []
return paths.map(
(path: GeometryPath & { commandsBlob: Uint8Array | Record<string, number> }) => ({
...path,
commandsBlob:
path.commandsBlob instanceof Uint8Array
? path.commandsBlob
: Uint8Array.from(Object.values(path.commandsBlob))
})
)
function restoreGeometry(paths: SerializedGeometryPath[] | undefined): GeometryPath[] {
return (paths ?? []).map((path) => ({
...path,
commandsBlob: Uint8Array.from(Object.values(path.commandsBlob))
}))
}
/** Clipboard HTML comes from other applications, so its Base64 is checked before decoding. */
@ -96,10 +165,9 @@ function clipboardBytes(value: string): Uint8Array {
function restoreNodeData(nodes: SerializedClipboardNode[]): ClipboardNode[] {
return nodes.map((node) => {
const { children, instanceOverrides, overrides, textPicture, ...rest } = node
const nodeId = typeof rest.id === 'string' ? rest.id : ''
const overrideState = instanceOverrides
? deserializeInstanceOverrideState(instanceOverrides)
: legacyInstanceOverrides(nodeId, overrides)
: legacyInstanceOverrides(rest.id, overrides)
return {
...rest,
fillGeometry: restoreGeometry(rest.fillGeometry),

View file

@ -8,6 +8,7 @@ export * from './materialize'
export * from './preview'
export * from './registry'
export * from './revision'
export * from './schema'
export * from './selective'
export * from './serialization'
export * from './source-publication'

View file

@ -0,0 +1,98 @@
import * as v from 'valibot'
import type { SceneNode, Variable, VariableCollection } from '@open-pencil/scene-graph'
import type { SerializedComponentLibraryRevision } from './serialization'
import { COMPONENT_LIBRARY_SCHEMA_VERSION } from './types'
import type { LibrarySummary, StoredLibraryLatestManifest } from './types'
const SerializedNodeShape = v.looseObject({
id: v.string(),
type: v.string(),
parentId: v.nullable(v.string()),
childIds: v.array(v.string()),
// Asset keys fall back to `source.id`, which is null for nodes that never came from a file.
source: v.looseObject({ id: v.nullable(v.string()) })
})
const SerializedVariableShape = v.looseObject({
id: v.string(),
collectionId: v.string(),
valuesByMode: v.record(v.string(), v.unknown())
})
const SerializedCollectionShape = v.looseObject({
id: v.string(),
modes: v.array(v.unknown()),
variableIds: v.array(v.string())
})
/**
* Library revisions come from shared catalogs. Nodes, variables and collections are checked for
* the fields deserialization and integrity validation dereference; the remaining fields pass
* through, since a full SceneNode schema would duplicate the Scene Graph types.
*/
const SerializedNode = v.custom<SceneNode>((value) => v.is(SerializedNodeShape, value))
const SerializedVariable = v.custom<Variable>((value) => v.is(SerializedVariableShape, value))
const SerializedCollection = v.custom<VariableCollection>((value) =>
v.is(SerializedCollectionShape, value)
)
/** Image bytes: a Uint8Array, or the index-keyed object `JSON.stringify` writes for one. */
const SerializedBytes = v.union([
v.instance(Uint8Array),
v.pipe(
v.record(v.string(), v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(255))),
// A gap or a stray key would silently shift the bytes; asset hashes do not cover images.
v.check(
(bytes) => Object.keys(bytes).every((key, index) => key === String(index)),
'Expected byte indexes 0 to length - 1'
),
v.transform((bytes) => Uint8Array.from(Object.values(bytes)))
)
])
const LibraryAssetDescriptor = v.object({
key: v.string(),
type: v.picklist(['COMPONENT', 'COMPONENT_SET']),
name: v.string(),
description: v.string(),
sourceNodeId: v.string(),
contentHash: v.string(),
thumbnail: v.optional(SerializedBytes)
})
export const SerializedLibraryRevisionSchema = v.object({
manifest: v.object({
schemaVersion: v.literal(COMPONENT_LIBRARY_SCHEMA_VERSION),
libraryId: v.string(),
name: v.string(),
revisionId: v.string(),
previousRevisionId: v.nullable(v.string()),
publishedAt: v.string(),
description: v.string(),
assets: v.array(LibraryAssetDescriptor)
}),
graph: v.object({
rootId: v.string(),
nodes: v.array(v.tuple([v.string(), SerializedNode])),
images: v.array(v.tuple([v.string(), SerializedBytes])),
variables: v.array(v.tuple([v.string(), SerializedVariable])),
variableCollections: v.array(v.tuple([v.string(), SerializedCollection])),
activeMode: v.array(v.tuple([v.string(), v.string()])),
documentColorSpace: v.picklist(['srgb', 'display-p3'])
})
}) satisfies v.GenericSchema<unknown, SerializedComponentLibraryRevision>
export const LibrarySummarySchema = v.object({
libraryId: v.string(),
name: v.string(),
latestRevisionId: v.string(),
publishedAt: v.string(),
assetCount: v.pipe(v.number(), v.safeInteger(), v.minValue(0))
}) satisfies v.GenericSchema<unknown, LibrarySummary>
export const StoredLibraryLatestManifestSchema = v.object({
schemaVersion: v.literal(COMPONENT_LIBRARY_SCHEMA_VERSION),
summary: LibrarySummarySchema
}) satisfies v.GenericSchema<unknown, StoredLibraryLatestManifest>

View file

@ -1,3 +1,5 @@
import * as v from 'valibot'
import type { SceneGraph } from '@open-pencil/scene-graph'
export const SOURCE_LIBRARY_PUBLICATION_PLUGIN_KEY = 'sourceLibraryPublication'
@ -10,16 +12,16 @@ export interface SourceLibraryPublication {
catalogSource?: string
}
function isPublication(value: unknown): value is SourceLibraryPublication {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
const publication = value as Partial<SourceLibraryPublication>
return (
typeof publication.libraryId === 'string' &&
typeof publication.revisionId === 'string' &&
typeof publication.name === 'string' &&
(publication.catalogSource === undefined || typeof publication.catalogSource === 'string')
)
}
const SourceLibraryPublicationJSON = v.pipe(
v.string(),
v.parseJson(),
v.object({
libraryId: v.string(),
revisionId: v.string(),
name: v.string(),
catalogSource: v.optional(v.string())
})
) satisfies v.GenericSchema<string, SourceLibraryPublication>
export function readSourceLibraryPublication(graph: SceneGraph): SourceLibraryPublication | null {
const root = graph.getNode(graph.rootId)
@ -28,12 +30,8 @@ export function readSourceLibraryPublication(graph: SceneGraph): SourceLibraryPu
item.pluginId === OPEN_PENCIL_PLUGIN_ID && item.key === SOURCE_LIBRARY_PUBLICATION_PLUGIN_KEY
)
if (!entry) return null
try {
const parsed: unknown = JSON.parse(entry.value)
return isPublication(parsed) ? parsed : null
} catch {
return null
}
const parsed = v.safeParse(SourceLibraryPublicationJSON, entry.value)
return parsed.success ? parsed.output : null
}
export function writeSourceLibraryPublication(

View file

@ -17,6 +17,9 @@ function evalExpr(
}
}
/** A JSON array of expressions; anything that is not a JSON array is one expression. */
const ExpressionListJSON = v.pipe(v.string(), v.parseJson(), v.array(v.string()))
export const calc = defineTool({
name: 'calc',
description:
@ -30,13 +33,11 @@ export const calc = defineTool({
expr: v.pipe(v.string(), v.description('Single expression or JSON array of expressions'))
}),
execute: (_figma, { expr }) => {
let exprs: string[]
try {
const parsed = JSON.parse(expr)
exprs = Array.isArray(parsed) ? parsed : [expr]
} catch {
exprs = [expr]
}
const parsed = v.safeParse(ExpressionListJSON, expr)
// An issue with a path is a non-string array element; the rest mean "not a JSON array".
if (!parsed.success && parsed.issues.some((issue) => issue.path !== undefined))
return { expr, error: 'A JSON array of expressions must contain only strings' }
const exprs = parsed.success ? parsed.output : [expr]
if (exprs.length === 1) {
return evalExpr(exprs[0])

View file

@ -1,17 +1,13 @@
import { safeDestr } from 'destr'
import * as v from 'valibot'
import {
normalizeVectorNetwork,
transformVectorNetwork,
validateVectorNetwork
} from '@open-pencil/scene-graph'
import { transformVectorNetwork } from '@open-pencil/scene-graph'
import type { VectorNetwork } from '@open-pencil/scene-graph'
import { parseColor } from '@open-pencil/scene-graph/color'
import { parseSVGPath } from '@open-pencil/scene-graph/parse-path'
import { toolNumber, positionInputs } from '#core/tools/input'
import { defineTool, nodeSummary } from '#core/tools/schema'
import { parseVectorNetworkJSON } from '#core/tools/vector/network'
import { computeAccurateBounds } from '#core/vector/curve-math'
interface ParsedVectorPath {
@ -34,24 +30,11 @@ function parseSVGVectorPath(path: string): VectorPathResult {
}
}
function parseVectorNetworkJSON(path: string): VectorPathResult {
let parsed: unknown
try {
parsed = safeDestr(path)
} catch {
return { error: 'Invalid VectorNetwork JSON' }
}
const errors = validateVectorNetwork(parsed)
if (errors.length > 0) return { error: `Invalid VectorNetwork: ${errors.join('; ')}` }
return { network: normalizeVectorNetwork(parsed as VectorNetwork), size: null }
}
function parseVectorPath(path: string): VectorPathResult {
const trimmed = path.trim()
if (/^[Mm]/.test(trimmed)) return parseSVGVectorPath(trimmed)
return parseVectorNetworkJSON(trimmed)
const parsed = parseVectorNetworkJSON(trimmed)
return 'error' in parsed ? parsed : { network: parsed.network, size: null }
}
export const createVector = defineTool({

View file

@ -0,0 +1,66 @@
import * as v from 'valibot'
import { validateVectorNetwork } from '@open-pencil/scene-graph'
import type { VectorNetwork } from '@open-pencil/scene-graph'
const finiteNumber = v.pipe(v.number(), v.finite())
const zeroVector = () => ({ x: 0, y: 0 })
const point = v.object({ x: finiteNumber, y: finiteNumber })
const VectorNetworkShape = v.object({
vertices: v.array(
v.object({
x: finiteNumber,
y: finiteNumber,
strokeCap: v.optional(v.string()),
strokeJoin: v.optional(v.string()),
cornerRadius: v.optional(finiteNumber),
handleMirroring: v.optional(v.picklist(['NONE', 'ANGLE', 'ANGLE_AND_LENGTH']))
})
),
segments: v.array(
v.object({
start: v.pipe(v.number(), v.integer()),
end: v.pipe(v.number(), v.integer()),
tangentStart: v.optional(point, zeroVector),
tangentEnd: v.optional(point, zeroVector)
})
),
regions: v.optional(
v.array(
v.object({
windingRule: v.picklist(['NONZERO', 'EVENODD']),
loops: v.array(v.array(v.pipe(v.number(), v.integer())))
})
),
() => []
)
})
/**
* VectorNetwork JSON from a tool argument. Index ranges and region chains are checked by
* `validateVectorNetwork`; the shape schema then types the value and fills missing tangents
* and regions.
*/
const VectorNetworkJSON = v.pipe(
v.string(),
v.parseJson(),
v.rawCheck<unknown>(({ dataset, addIssue }) => {
if (!dataset.typed) return
for (const message of validateVectorNetwork(dataset.value)) addIssue({ message })
}),
VectorNetworkShape
)
export type VectorNetworkJSONResult = { network: VectorNetwork } | { error: string }
/** Parse and validate VectorNetwork JSON, returning a tool error instead of throwing. */
export function parseVectorNetworkJSON(text: string): VectorNetworkJSONResult {
const result = v.safeParse(VectorNetworkJSON, text)
if (result.success) return { network: result.output }
if (result.issues.some((issue) => issue.type === 'parse_json'))
return { error: 'Invalid VectorNetwork JSON' }
return {
error: `Invalid VectorNetwork: ${result.issues.map((issue) => issue.message).join('; ')}`
}
}

View file

@ -6,6 +6,7 @@ import type { SceneNode, VectorNetwork } from '@open-pencil/scene-graph'
import type { FigmaAPI } from '#core/figma-api'
import { toolNumber, nodeIdInput, nodeInput } from '#core/tools/input'
import { defineTool } from '#core/tools/schema'
import { parseVectorNetworkJSON } from '#core/tools/vector/network'
function getVectorNode(
figma: FigmaAPI,
@ -43,8 +44,9 @@ export const pathSet = defineTool({
execute: (figma, args) => {
const raw = figma.graph.getNode(args.id)
if (!raw) return { error: `Node "${args.id}" not found` }
const network = JSON.parse(args.path)
figma.graph.updateNode(args.id, { vectorNetwork: network })
const parsed = parseVectorNetworkJSON(args.path)
if ('error' in parsed) return parsed
figma.graph.updateNode(args.id, { vectorNetwork: parsed.network })
return { id: args.id }
}
})

View file

@ -10,11 +10,11 @@ import { rescaleNodeTree } from '@open-pencil/scene-graph'
import { expectDefined } from '../helpers/assert'
import { inheritedNestedBindingRecords } from '../helpers/fig/nested-binding'
import { FIXTURES, readFixtureJSON } from '../helpers/fig/fixtures'
import { FIXTURES, readFixtureObject } from '../helpers/fig/fixtures'
const fixture = readFixtureJSON<Record<string, unknown>[]>('nested-layout-scale.json')
const fixture = readFixtureObject('nested-layout-scale.json')
const declared = readFixtureJSON<Record<string, unknown>[]>('nested-binding-ownership-records.json')
const declared = readFixtureObject('nested-binding-ownership-records.json')
for (const edit of [
'direct',

View file

@ -3,9 +3,9 @@ import { expect, test } from 'bun:test'
import { SceneGraph } from '@open-pencil/scene-graph'
import { prepareClipboardImport } from '#core/clipboard/fig-import'
import { readFixtureJSON } from '#core-tests/helpers/fig/fixtures'
import { readFixtureObject } from '#core-tests/helpers/fig/fixtures'
const fixture = readFixtureJSON<Record<string, unknown>[]>('nested-binding-ownership-records.json')
const fixture = readFixtureObject('nested-binding-ownership-records.json')
test('replacement clipboard preparation imports real nested component bindings', () => {

View file

@ -0,0 +1,75 @@
import { describe, expect, test } from 'bun:test'
import { deflateSync } from 'fflate'
import { fromUint8Array } from 'js-base64'
import { buildOpenPencilClipboardHTML, parseOpenPencilClipboard } from '@open-pencil/core/clipboard'
import { SceneGraph } from '@open-pencil/scene-graph'
function clipboardHTML(payload: string): string {
const compressed = deflateSync(new TextEncoder().encode(payload))
return `<!--(openpencil)${fromUint8Array(compressed)}(/openpencil)-->`
}
function copiedNode() {
const graph = new SceneGraph()
const node = graph.createNode('RECTANGLE', graph.getPages()[0].id, { name: 'Card', x: 5, y: 6 })
return { graph, node }
}
describe('OpenPencil clipboard parsing', () => {
test('restores nodes copied by OpenPencil', () => {
const { graph, node } = copiedNode()
const parsed = parseOpenPencilClipboard(buildOpenPencilClipboardHTML([node], graph))
expect(parsed?.nodes).toHaveLength(1)
expect(parsed?.nodes[0]).toMatchObject({ id: node.id, type: 'RECTANGLE', name: 'Card', x: 5 })
})
test.each([
['malformed JSON', '{"format": "openpencil/v1", "nodes": ['],
['an unknown format', JSON.stringify({ format: 'openpencil/v2', nodes: [] })],
['non-array nodes', JSON.stringify({ format: 'openpencil/v1', nodes: {} })],
[
'a node with a string coordinate',
JSON.stringify({
format: 'openpencil/v1',
nodes: [{ id: '0:1', type: 'RECTANGLE', x: '5', y: 6 }]
})
],
[
'a node with an unknown type',
JSON.stringify({ format: 'openpencil/v1', nodes: [{ id: '0:1', type: 'WIDGET', x: 5, y: 6 }] })
],
[
'a malformed child',
JSON.stringify({
format: 'openpencil/v1',
nodes: [{ id: '0:1', type: 'FRAME', x: 0, y: 0, children: [{ id: 7 }] }]
})
],
[
'malformed geometry',
JSON.stringify({
format: 'openpencil/v1',
nodes: [{ id: '0:1', type: 'VECTOR', x: 0, y: 0, fillGeometry: [null] }]
})
],
[
'geometry bytes outside 0-255',
JSON.stringify({
format: 'openpencil/v1',
nodes: [
{
id: '0:1',
type: 'VECTOR',
x: 0,
y: 0,
fillGeometry: [{ windingRule: 'NONZERO', commandsBlob: { 0: 1, 1: 300 } }]
}
]
})
]
])('ignores %s', (_label, payload) => {
expect(parseOpenPencilClipboard(clipboardHTML(payload))).toBeNull()
})
})

View file

@ -7,9 +7,9 @@ import { materializeFigFragment } from '@open-pencil/fig'
import { CommittedGraphEventError } from '@open-pencil/scene-graph'
import { expectDefined } from '#core-tests/helpers/assert'
import { readFixtureJSON } from '#core-tests/helpers/fig/fixtures'
import { readFixtureObject } from '#core-tests/helpers/fig/fixtures'
const fixture = readFixtureJSON<Record<string, unknown>[]>('nested-binding-ownership-records.json')
const fixture = readFixtureObject('nested-binding-ownership-records.json')
for (const failObserver of [false, true]) {
for (const replaceSelection of [false, true]) {

View file

@ -7,6 +7,7 @@ import {
type SceneGraph,
type SceneNode
} from '@open-pencil/core'
import * as v from 'valibot'
import { collectAllNodes } from './traversal'
@ -33,9 +34,15 @@ export const VALID_NODE_TYPES = new Set<string>([
'SHAPE_WITH_TEXT'
])
/** Read a shared JSON fixture as data; a module import would escape the package root. */
export function readFixtureJSON<T>(name: string): T {
return JSON.parse(readFileSync(resolve(FIXTURES, name), 'utf8')) as T
const FixtureObjectJSON = v.pipe(
v.string(),
v.parseJson(),
v.record(v.string(), v.unknown())
)
/** Read a shared JSON fixture object; a module import would escape the package root. */
export function readFixtureObject(name: string): Record<string, unknown> {
return v.parse(FixtureObjectJSON, readFileSync(resolve(FIXTURES, name), 'utf8'))
}
export function readFixtureBytes(name: string): Uint8Array {

View file

@ -2,9 +2,9 @@ import type { SymbolData } from '@open-pencil/fig/instance-overrides'
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
import { expectDefined } from '../assert'
import { readFixtureJSON } from './fixtures'
import { readFixtureObject } from './fixtures'
const fixture = readFixtureJSON<Record<string, unknown>[]>('nested-binding-ownership-records.json')
const fixture = readFixtureObject('nested-binding-ownership-records.json')
/** Remove only the placed owner's declaration, retaining the saved inherited expression. */
export function inheritedNestedBindingRecords(): NodeChange[] {

View file

@ -10,11 +10,11 @@ import { cloneInstanceOverrideState } from '@open-pencil/scene-graph'
import { expectDefined } from '#core-tests/helpers/assert'
import { inheritedNestedBindingRecords } from '#core-tests/helpers/fig/nested-binding'
import { readFixtureJSON } from '#core-tests/helpers/fig/fixtures'
import { readFixtureObject } from '#core-tests/helpers/fig/fixtures'
const oracle = readFixtureJSON<Record<string, unknown>[]>('nested-binding-authoring.json')
const oracle = readFixtureObject('nested-binding-authoring.json')
const fixture = readFixtureJSON<Record<string, unknown>[]>('nested-binding-ownership-records.json')
const fixture = readFixtureObject('nested-binding-ownership-records.json')
function document(inherited = false) {
const changes = inherited

View file

@ -5,9 +5,9 @@ import { exportFigFile } from '@open-pencil/core/io'
import { initCodec } from '@open-pencil/core/kiwi'
import { materializeDocument, parseFigBuffer } from '@open-pencil/fig'
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
import { readFixtureJSON } from '#core-tests/helpers/fig/fixtures'
import { readFixtureObject } from '#core-tests/helpers/fig/fixtures'
const fixture = readFixtureJSON<Record<string, unknown>[]>('nested-binding-ownership-records.json')
const fixture = readFixtureObject('nested-binding-ownership-records.json')
test('binding patches keep unrelated inherited fields in their original units', () => {

View file

@ -8,9 +8,9 @@ import { materializeDocument, parseFigBuffer } from '@open-pencil/fig'
import { expectDefined } from '#core-tests/helpers/assert'
import { inheritedNestedBindingRecords } from '#core-tests/helpers/fig/nested-binding'
import { readFixtureJSON } from '#core-tests/helpers/fig/fixtures'
import { readFixtureObject } from '#core-tests/helpers/fig/fixtures'
const fixture = readFixtureJSON<Record<string, unknown>[]>('nested-binding-ownership-records.json')
const fixture = readFixtureObject('nested-binding-ownership-records.json')
test('a placed binding does not freeze an unrelated inherited binding', async () => {
const { graph, sources } = materializeDocument(

View file

@ -8,9 +8,9 @@ import { materializeDocument, parseFigBuffer } from '@open-pencil/fig'
import type { SymbolData } from '@open-pencil/fig/instance-overrides'
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
import { hasInstanceOverride } from '@open-pencil/scene-graph'
import { readFixtureJSON } from '#core-tests/helpers/fig/fixtures'
import { readFixtureObject } from '#core-tests/helpers/fig/fixtures'
const fixture = readFixtureJSON<Record<string, unknown>[]>('nested-layout-scale.json')
const fixture = readFixtureObject('nested-layout-scale.json')
function assembly(phase: 'before' | 'edited') {

View file

@ -10,9 +10,9 @@ import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
import { setInstanceOverride } from '@open-pencil/scene-graph'
import { expectDefined } from '#core-tests/helpers/assert'
import { readFixtureJSON } from '#core-tests/helpers/fig/fixtures'
import { readFixtureObject } from '#core-tests/helpers/fig/fixtures'
const fixture = readFixtureJSON<Record<string, unknown>[]>('nested-binding-ownership-records.json')
const fixture = readFixtureObject('nested-binding-ownership-records.json')
for (const scale of [0.5, 2]) {
for (const literalClaim of [false, true]) {

View file

@ -0,0 +1,63 @@
import { describe, expect, test } from 'bun:test'
import { FigmaAPI } from '@open-pencil/core/figma-api'
import { ALL_TOOLS } from '@open-pencil/core/tools'
import { SceneGraph } from '@open-pencil/scene-graph'
const pathSet = ALL_TOOLS.find((tool) => tool.name === 'path_set')
if (!pathSet) throw new Error('path_set tool missing')
function setup() {
const graph = new SceneGraph()
const figma = new FigmaAPI(graph)
const node = graph.createNode('VECTOR', graph.getPages()[0].id, { name: 'Path' })
return { graph, figma, id: node.id }
}
describe('path_set', () => {
test('writes a normalized vector network', () => {
const { graph, figma, id } = setup()
const path = JSON.stringify({
vertices: [
{ x: 0, y: 0 },
{ x: 10, y: 0 }
],
segments: [{ start: 0, end: 1 }]
})
expect(pathSet.execute(figma, { id, path })).toEqual({ id })
expect(graph.getNode(id)?.vectorNetwork).toEqual({
vertices: [
{ x: 0, y: 0 },
{ x: 10, y: 0 }
],
segments: [{ start: 0, end: 1, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }],
regions: []
})
})
test.each([
['malformed JSON', '{"vertices": [', 'Invalid VectorNetwork JSON'],
['a non-object', '42', 'Invalid VectorNetwork: network must be an object'],
[
'an out-of-range segment',
JSON.stringify({ vertices: [{ x: 0, y: 0 }], segments: [{ start: 0, end: 4 }] }),
'Invalid VectorNetwork: segment[0]: end index 4 out of range'
],
[
'a malformed tangent',
JSON.stringify({
vertices: [
{ x: 0, y: 0 },
{ x: 1, y: 1 }
],
segments: [{ start: 0, end: 1, tangentStart: { x: 'a', y: 0 } }]
}),
'Invalid VectorNetwork: segment[0]: tangentStart must contain finite x and y numbers'
]
])('rejects %s without touching the node', (_label, path, error) => {
const { graph, figma, id } = setup()
const before = graph.getNode(id)?.vectorNetwork
expect(pathSet.execute(figma, { id, path })).toEqual({ error })
expect(graph.getNode(id)?.vectorNetwork).toEqual(before)
})
})

View file

@ -40,13 +40,17 @@ export interface SdkComponentMeta {
exposed: SdkComponentExposeMeta[]
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function findWorkspaceRoot(start: string): string {
let directory = start
while (true) {
const packagePath = join(directory, 'package.json')
if (existsSync(packagePath)) {
const packageJSON = JSON.parse(readFileSync(packagePath, 'utf8')) as { workspaces?: unknown }
if (Array.isArray(packageJSON.workspaces)) return directory
const packageJSON: unknown = JSON.parse(readFileSync(packagePath, 'utf8'))
if (isObject(packageJSON) && Array.isArray(packageJSON.workspaces)) return directory
}
const parent = dirname(directory)
if (parent === directory) throw new Error('Unable to locate workspace root')

View file

@ -1,5 +1,6 @@
import { inflateSync } from 'fflate'
import { decode as decodeText, toUint8Array, isValid } from 'js-base64'
import * as v from 'valibot'
import type { NodeChange as KiwiNodeChange } from '@open-pencil/kiwi/fig/codec'
import { decodeBinarySchema, compileSchema, ByteBuffer } from '@open-pencil/kiwi/schema-runtime'
@ -7,11 +8,13 @@ import { decodeBinarySchema, compileSchema, ByteBuffer } from '@open-pencil/kiwi
import { parseFigKiwiChunks, decompressFigKiwiDataAsync } from '../node-change'
import { isFigClipboardVisualType } from '../node-classification'
interface FigmaClipboardMeta {
fileKey: string
pasteID: number
dataType: string
}
const FigmaClipboardMetaJSON = v.pipe(
v.string(),
v.parseJson(),
v.object({ fileKey: v.string(), pasteID: v.number(), dataType: v.string() })
)
type FigmaClipboardMeta = v.InferOutput<typeof FigmaClipboardMetaJSON>
export async function parseFigmaClipboard(
html: string
@ -22,7 +25,9 @@ export async function parseFigmaClipboard(
// Clipboard HTML comes from other applications, so its Base64 is checked first.
if (!isValid(metaMatch[1]) || !isValid(bufMatch[1])) throw new TypeError('Invalid Base64 string')
const meta: FigmaClipboardMeta = JSON.parse(decodeText(metaMatch[1]))
const parsedMeta = v.safeParse(FigmaClipboardMetaJSON, decodeText(metaMatch[1]))
if (!parsedMeta.success) return null
const meta = parsedMeta.output
const binary = toUint8Array(bufMatch[1])
try {

View file

@ -1,8 +1,19 @@
import * as v from 'valibot'
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
import type { SceneGraph } from '@open-pencil/scene-graph'
import { ENABLED_LIBRARIES_PLUGIN_KEY, getOpenPencilPluginValue } from '../node-change/plugin-data'
const EnabledLibrariesJSON = v.pipe(v.string(), v.parseJson(), v.array(v.unknown()))
/** One enabled-library entry; invalid entries are skipped individually. */
const EnabledLibraryEntry = v.object({
libraryId: v.string(),
revisionId: v.string(),
enabled: v.optional(v.unknown())
})
export function applyDocumentMetadata(graph: SceneGraph, document: NodeChange | undefined): void {
const root = graph.getNode(graph.rootId)
if (!root || !document) return
@ -15,23 +26,15 @@ export function applyDocumentMetadata(graph: SceneGraph, document: NodeChange |
})) ?? []
root.source.fig.rawNodeFields.strokeJoin = document.strokeJoin
root.source.fig.rawNodeFields.strokeWeight = document.strokeWeight
const bindings = getOpenPencilPluginValue(document, ENABLED_LIBRARIES_PLUGIN_KEY)
if (!bindings) return
let parsed: unknown
try {
parsed = JSON.parse(bindings)
} catch {
return
}
if (!Array.isArray(parsed)) return
for (const entry of parsed) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue
if (!('libraryId' in entry) || !('revisionId' in entry)) continue
if (typeof entry.libraryId !== 'string' || typeof entry.revisionId !== 'string') continue
graph.enabledLibraries.set(entry.libraryId, {
libraryId: entry.libraryId,
revisionId: entry.revisionId,
enabled: 'enabled' in entry && entry.enabled === true
})
const bindings = v.safeParse(
EnabledLibrariesJSON,
getOpenPencilPluginValue(document, ENABLED_LIBRARIES_PLUGIN_KEY)
)
if (!bindings.success) return
for (const candidate of bindings.output) {
const entry = v.safeParse(EnabledLibraryEntry, candidate)
if (!entry.success) continue
const { libraryId, revisionId, enabled } = entry.output
graph.enabledLibraries.set(libraryId, { libraryId, revisionId, enabled: enabled === true })
}
}

View file

@ -1,4 +1,5 @@
import { omit } from 'es-toolkit/object'
import * as v from 'valibot'
import type { NodeChange, PluginData, PluginRelaunchData } from '@open-pencil/kiwi/fig/codec'
import { guidToString } from '@open-pencil/kiwi/fig/guid'
@ -31,6 +32,65 @@ export const TEXT_PATH_BOX_PLUGIN_KEY = 'textPathBox'
export const LIBRARY_SOURCE_PLUGIN_KEY = 'librarySource'
export const ENABLED_LIBRARIES_PLUGIN_KEY = 'enabledLibraries'
const TextPathBoxJSON = v.pipe(
v.string(),
v.parseJson(),
v.object({ x: v.number(), y: v.number(), width: v.number(), height: v.number() }),
v.check(
({ x, y, width, height }) => Number.isFinite(x + y + width + height) && width > 0 && height > 0
)
)
/** String-valued entries of a JSON object; other entries are dropped, not rejected. */
const BoundVariablesJSON = v.pipe(
v.string(),
v.parseJson(),
v.check((value) => !Array.isArray(value)),
v.record(v.string(), v.unknown()),
v.transform((value) =>
Object.fromEntries(
Object.entries(value).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string'
)
)
)
)
/** Every entry must be valid, or the plugin value is ignored in favour of native settings. */
const ExportSettingsJSON = v.pipe(
v.string(),
v.parseJson(),
v.array(
v.pipe(
v.object({
scale: v.pipe(v.number(), v.finite()),
format: v.custom<ExportFormatId>(isExportFormatId)
}),
// Clamp at the file-format boundary: imported plugin data may carry an
// out-of-range scale the UI would never produce.
v.transform(({ scale, format }): ExportSetting => ({
scale: clampExportScale(scale),
format
}))
)
)
)
const LibrarySourceJSON = v.pipe(
v.string(),
v.parseJson(),
v.object({
identity: v.object({ libraryId: v.string(), assetKey: v.string(), revisionId: v.string() }),
sourceNodeId: v.optional(v.unknown()),
readOnly: v.optional(v.unknown())
}),
v.transform(({ identity, sourceNodeId, readOnly }): NonNullable<SceneNode['librarySource']> => ({
identity,
sourceNodeId: typeof sourceNodeId === 'string' ? sourceNodeId : null,
readOnly: readOnly === true
}))
)
const NATIVE_EXPORT_FORMATS: Record<string, ExportFormatId> = {
PNG: 'png',
JPEG: 'jpg',
@ -85,25 +145,11 @@ export function applyTextPathBoxPluginData(node: {
}
export function extractTextPathBox(nc: NodeChange): Rect | null {
const value = getOpenPencilPluginValue(nc, TEXT_PATH_BOX_PLUGIN_KEY)
if (!value) return null
try {
const parsed = JSON.parse(value) as Partial<Rect> | null
if (!parsed || typeof parsed !== 'object') return null
const { x, y, width, height } = parsed
if (
typeof x !== 'number' ||
typeof y !== 'number' ||
typeof width !== 'number' ||
typeof height !== 'number'
) {
return null
}
if (!Number.isFinite(x + y + width + height) || width <= 0 || height <= 0) return null
return { x, y, width, height }
} catch {
return null
}
const parsed = v.safeParse(
TextPathBoxJSON,
getOpenPencilPluginValue(nc, TEXT_PATH_BOX_PLUGIN_KEY)
)
return parsed.success ? parsed.output : null
}
function hasOpenPencilExportSettingsPluginData(pluginData: PluginDataEntry[]): boolean {
@ -112,26 +158,12 @@ function hasOpenPencilExportSettingsPluginData(pluginData: PluginDataEntry[]): b
)
}
function parseBoundVariablesPluginValue(value: string | null): Record<string, string> {
if (!value) return {}
try {
const parsed = JSON.parse(value) as unknown
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
return Object.fromEntries(
Object.entries(parsed).filter(
(entry): entry is [string, string] =>
typeof entry[0] === 'string' && typeof entry[1] === 'string'
)
)
} catch {
return {}
}
}
export function extractBoundVariables(nc: NodeChange): Record<string, string> {
let bindings = parseBoundVariablesPluginValue(
const stored = v.safeParse(
BoundVariablesJSON,
getOpenPencilPluginValue(nc, BOUND_VARIABLES_PLUGIN_KEY)
)
let bindings: Record<string, string> = stored.success ? stored.output : {}
for (const entry of variableConsumptionEntries(nc)) {
const binding = resolveVariableConsumptionEntry(entry)
if (binding) bindings[binding.field] = binding.variableId
@ -151,28 +183,6 @@ export function extractBoundVariables(nc: NodeChange): Record<string, string> {
return bindings
}
function parseExportSettingsPluginValue(value: string | null): ExportSetting[] | null {
if (!value) return null
try {
const parsed = JSON.parse(value) as unknown
if (!Array.isArray(parsed)) return null
const settings = parsed.flatMap((entry): ExportSetting[] => {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return []
const scale = (entry as { scale?: unknown }).scale
const format = (entry as { format?: unknown }).format
if (typeof scale !== 'number' || !Number.isFinite(scale) || !isExportFormatId(format)) {
return []
}
// Clamp at the file-format boundary: imported plugin data may carry an
// out-of-range scale the UI would never produce.
return [{ scale: clampExportScale(scale), format }]
})
return settings.length === parsed.length ? settings : null
} catch {
return null
}
}
function mapNativeImageType(imageType: unknown): ExportFormatId | null {
if (typeof imageType === 'string') return NATIVE_EXPORT_FORMATS[imageType] ?? null
if (imageType === 0) return 'png'
@ -192,10 +202,11 @@ function extractNativeConstraintScale(constraint: unknown): number {
}
export function extractExportSettings(nc: NodeChange): ExportSetting[] {
const pluginSettings = parseExportSettingsPluginValue(
const pluginSettings = v.safeParse(
ExportSettingsJSON,
getOpenPencilPluginValue(nc, EXPORT_SETTINGS_PLUGIN_KEY)
)
if (pluginSettings) return pluginSettings
if (pluginSettings.success) return pluginSettings.output
return (nc.exportSettings ?? []).flatMap((entry): ExportSetting[] => {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return []
@ -219,35 +230,11 @@ export function extractPluginData(nc: NodeChange): PluginDataEntry[] {
}
export function extractLibrarySource(nc: NodeChange): SceneNode['librarySource'] {
const value = getOpenPencilPluginValue(nc, LIBRARY_SOURCE_PLUGIN_KEY)
if (!value) return null
try {
const parsed = JSON.parse(value) as unknown
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
const source = parsed as {
identity?: { libraryId?: unknown; assetKey?: unknown; revisionId?: unknown }
sourceNodeId?: unknown
readOnly?: unknown
}
if (
typeof source.identity?.libraryId !== 'string' ||
typeof source.identity.assetKey !== 'string' ||
typeof source.identity.revisionId !== 'string'
) {
return null
}
return {
identity: {
libraryId: source.identity.libraryId,
assetKey: source.identity.assetKey,
revisionId: source.identity.revisionId
},
sourceNodeId: typeof source.sourceNodeId === 'string' ? source.sourceNodeId : null,
readOnly: source.readOnly === true
}
} catch {
return null
}
const parsed = v.safeParse(
LibrarySourceJSON,
getOpenPencilPluginValue(nc, LIBRARY_SOURCE_PLUGIN_KEY)
)
return parsed.success ? parsed.output : null
}
export function applyLibrarySourcePluginData(node: SceneNode): void {

View file

@ -0,0 +1,39 @@
import { describe, expect, test } from 'bun:test'
import { applyDocumentMetadata } from '#fig/document/metadata'
import { ENABLED_LIBRARIES_PLUGIN_KEY, OPEN_PENCIL_PLUGIN_ID } from '#fig/node-change/plugin-data'
import { SceneGraph } from '@open-pencil/scene-graph'
function enabledLibraries(value: string) {
const graph = new SceneGraph()
applyDocumentMetadata(graph, {
pluginData: [{ pluginID: OPEN_PENCIL_PLUGIN_ID, key: ENABLED_LIBRARIES_PLUGIN_KEY, value }]
})
return [...graph.enabledLibraries.values()]
}
describe('document metadata', () => {
test('restores valid enabled libraries and skips invalid entries', () => {
expect(
enabledLibraries(
JSON.stringify([
{ libraryId: 'design-system', revisionId: 'r1', enabled: true },
{ libraryId: 'icons', revisionId: 'r2', enabled: 'yes' },
{ libraryId: 'broken', revisionId: 3 },
null
])
)
).toEqual([
{ libraryId: 'design-system', revisionId: 'r1', enabled: true },
{ libraryId: 'icons', revisionId: 'r2', enabled: false }
])
})
test('ignores malformed or non-array enabled libraries', () => {
expect(enabledLibraries('{not json')).toEqual([])
expect(
enabledLibraries(JSON.stringify({ libraryId: 'design-system', revisionId: 'r1' }))
).toEqual([])
})
})

View file

@ -4,11 +4,6 @@ import { resolve } from 'node:path'
/** Shared archives live outside the package; this is the only path that reaches them. */
export const FIXTURES = resolve(import.meta.dir, '../../../../tests/fixtures')
/** Read a shared JSON fixture as data; a module import would escape the package root. */
export function readFixtureJSON<T>(name: string): T {
return JSON.parse(readFileSync(resolve(FIXTURES, name), 'utf8')) as T
}
export function readFixtureBytes(name: string): Uint8Array {
return readFileSync(resolve(FIXTURES, name))
}

View file

@ -0,0 +1,98 @@
import { describe, expect, test } from 'bun:test'
import {
BOUND_VARIABLES_PLUGIN_KEY,
EXPORT_SETTINGS_PLUGIN_KEY,
extractBoundVariables,
extractExportSettings,
extractLibrarySource,
extractTextPathBox,
LIBRARY_SOURCE_PLUGIN_KEY,
OPEN_PENCIL_PLUGIN_ID,
TEXT_PATH_BOX_PLUGIN_KEY
} from '#fig/node-change/plugin-data'
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
import { clampExportScale } from '@open-pencil/scene-graph'
function withPluginValue(key: string, value: string, extra: NodeChange = {}): NodeChange {
return { ...extra, pluginData: [{ pluginID: OPEN_PENCIL_PLUGIN_ID, key, value }] }
}
describe('OpenPencil plugin data readers', () => {
test('textPathBox requires finite numbers and a positive size', () => {
const box = { x: 1, y: 2, width: 30, height: 40 }
expect(
extractTextPathBox(withPluginValue(TEXT_PATH_BOX_PLUGIN_KEY, JSON.stringify(box)))
).toEqual(box)
for (const value of [
'{not json',
'null',
JSON.stringify({ ...box, width: 0 }),
JSON.stringify({ ...box, x: '1' })
]) {
expect(extractTextPathBox(withPluginValue(TEXT_PATH_BOX_PLUGIN_KEY, value))).toBeNull()
}
})
test('bound variables keep string entries and drop the rest', () => {
const value = JSON.stringify({ opacity: 'VariableID:1:2', width: 3 })
expect(extractBoundVariables(withPluginValue(BOUND_VARIABLES_PLUGIN_KEY, value))).toEqual({
opacity: 'VariableID:1:2'
})
for (const malformed of ['{not json', '["VariableID:1:2"]', '"VariableID:1:2"']) {
expect(extractBoundVariables(withPluginValue(BOUND_VARIABLES_PLUGIN_KEY, malformed))).toEqual(
{}
)
}
})
test('export settings clamp scales and fall back to native settings when any entry is invalid', () => {
const native: NodeChange = {
exportSettings: [{ imageType: 'SVG', constraint: { type: 'CONTENT_SCALE', value: 1 } }]
}
expect(
extractExportSettings(
withPluginValue(
EXPORT_SETTINGS_PLUGIN_KEY,
JSON.stringify([{ scale: 1000, format: 'png' }])
)
)
).toEqual([{ scale: clampExportScale(1000), format: 'png' }])
for (const value of [
'{not json',
JSON.stringify({ scale: 1, format: 'png' }),
JSON.stringify([
{ scale: 2, format: 'png' },
{ scale: 1, format: 'gif' }
])
]) {
expect(
extractExportSettings(withPluginValue(EXPORT_SETTINGS_PLUGIN_KEY, value, native))
).toEqual([{ scale: 1, format: 'svg' }])
}
})
test('library source defaults its optional fields and rejects a malformed identity', () => {
const identity = { libraryId: 'lib', assetKey: 'button', revisionId: 'r1' }
expect(
extractLibrarySource(
withPluginValue(LIBRARY_SOURCE_PLUGIN_KEY, JSON.stringify({ identity, readOnly: 'yes' }))
)
).toEqual({ identity, sourceNodeId: null, readOnly: false })
expect(
extractLibrarySource(
withPluginValue(
LIBRARY_SOURCE_PLUGIN_KEY,
JSON.stringify({ identity, sourceNodeId: '1:2', readOnly: true })
)
)
).toEqual({ identity, sourceNodeId: '1:2', readOnly: true })
for (const value of [
'{not json',
JSON.stringify({ identity: { ...identity, revisionId: 1 } })
]) {
expect(extractLibrarySource(withPluginValue(LIBRARY_SOURCE_PLUGIN_KEY, value))).toBeNull()
}
})
})

View file

@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto'
import * as v from 'valibot'
import type { WebSocket } from 'ws'
import { isAuthorized } from '#mcp/auth'
@ -21,14 +22,30 @@ type BrowserRPCBridgeOptions = {
type ConnectionListener = (connected: boolean) => void
type BrowserMessage = {
type: string
id?: string
token?: unknown
result?: unknown
error?: string
ok?: boolean
}
/** Any JSON object; the per-type schemas below run only once its `type` is known. */
const BrowserEnvelopeJSON = v.pipe(
v.string(),
v.parseJson(),
v.custom<Record<string, unknown>>(
(value) => typeof value === 'object' && value !== null && !Array.isArray(value)
)
)
const HandshakeMessage = v.looseObject({ token: v.optional(v.nullable(v.string())) })
const RequestMessage = v.looseObject({
type: v.literal('request'),
id: v.pipe(v.string(), v.nonEmpty())
})
const ResponseMessage = v.looseObject({
type: v.literal('response'),
id: v.pipe(v.string(), v.nonEmpty()),
ok: v.optional(v.boolean()),
error: v.optional(v.string())
})
type BrowserMessage = v.InferOutput<typeof RequestMessage> | v.InferOutput<typeof ResponseMessage>
function stripEnvelope(msg: BrowserMessage): Record<string, unknown> {
const { type: _type, id: _id, ...body } = msg
@ -205,8 +222,7 @@ export function createBrowserRPCBridge({
})
}
async function handleClientRequest(ws: WebSocket, msg: BrowserMessage) {
if (!msg.id) return
async function handleClientRequest(ws: WebSocket, msg: v.InferOutput<typeof RequestMessage>) {
try {
const result = await sendRPC(stripEnvelope(msg))
sendJSON(ws, { ...responsePayload(result), type: 'response', id: msg.id, ok: true })
@ -246,8 +262,8 @@ export function createBrowserRPCBridge({
broadcastRegisterPrompt()
}
function handleBrowserResponse(msg: BrowserMessage, ws: WebSocket) {
if (!browserRegistered || browserWs !== ws || !msg.id) return
function handleBrowserResponse(msg: v.InferOutput<typeof ResponseMessage>, ws: WebSocket) {
if (!browserRegistered || browserWs !== ws) return
const req = pending.get(msg.id)
if (!req) return
pending.delete(msg.id)
@ -261,41 +277,36 @@ export function createBrowserRPCBridge({
function handleMessage(data: string, ws: WebSocket) {
if (bridgeClosed) return
let parsed: unknown
try {
parsed = JSON.parse(data)
} catch (e) {
console.warn('Malformed automation message:', e)
return
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
ws.close()
return
}
const msg = parsed as BrowserMessage
if (msg.type === 'auth') {
// Authenticate a stdio bridge client without registering it as the
// browser app. This lets the client send request/response messages
// without becoming the RPC target. The token is validated the same
// way as registerBrowser — when auth is disabled (authToken === null),
// any token is accepted.
if (msg.token === null || typeof msg.token === 'string') {
if (!isAuthorized(msg.token, authToken)) {
ws.close()
return
}
authenticatedClients.add(ws)
} else if (msg.token !== undefined) {
const envelope = v.safeParse(BrowserEnvelopeJSON, data)
if (!envelope.success) {
if (envelope.issues.some((issue) => issue.type === 'parse_json')) {
console.warn('Malformed automation message:', v.summarize(envelope.issues))
} else {
ws.close()
}
return
}
const msg = envelope.output
if (msg.type === 'register') {
if (msg.token === null || typeof msg.token === 'string') {
registerBrowser(ws, msg.token)
} else if (msg.token !== undefined) {
if (msg.type === 'auth' || msg.type === 'register') {
// `auth` authenticates a stdio bridge client without registering it as
// the browser app, so it can send request/response messages without
// becoming the RPC target. The token is validated the same way as
// registerBrowser — when auth is disabled (authToken === null), any
// token is accepted. A message without a token is ignored; a token of
// any other type closes the socket.
const handshake = v.safeParse(HandshakeMessage, msg)
if (!handshake.success) {
ws.close()
return
}
const { token } = handshake.output
if (token === undefined) return
if (msg.type === 'register') {
registerBrowser(ws, token)
} else if (isAuthorized(token, authToken)) {
authenticatedClients.add(ws)
} else {
ws.close()
}
return
@ -308,11 +319,13 @@ export function createBrowserRPCBridge({
ws.close()
return
}
if (msg.type === 'request') {
void handleClientRequest(ws, msg)
const request = v.safeParse(RequestMessage, msg)
if (request.success) {
void handleClientRequest(ws, request.output)
return
}
if (msg.type === 'response') handleBrowserResponse(msg, ws)
const response = v.safeParse(ResponseMessage, msg)
if (response.success) handleBrowserResponse(response.output, ws)
}
function handleClose(ws: WebSocket) {

View file

@ -9,6 +9,7 @@ import type { Hono } from 'hono'
import type { WebSocketServer } from 'ws'
import {
parseDiscoveryInfo,
removeDiscoveryFile,
removeStaleSocket,
writeDiscoveryFile
@ -269,15 +270,8 @@ export async function cleanupDiscovery(
// millisecond is extremely unlikely.
const discoveryPath = await getDiscoveryPath()
try {
const raw = await readFile(discoveryPath, 'utf-8')
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== 'object') return
const info = parsed as {
authToken: string | null
socketPath?: string | null
httpPort?: number
startedAt?: string
}
const info = parseDiscoveryInfo(await readFile(discoveryPath, 'utf-8'))
if (!info) return
if (info.authToken !== ownAuthToken) return
if (info.socketPath !== ownSocketPath) return
if (info.httpPort !== ownHttpPort) return

View file

@ -2,6 +2,8 @@ import { randomBytes } from 'node:crypto'
import { access, constants, lstat, readFile, rename, unlink, writeFile } from 'node:fs/promises'
import { Socket } from 'node:net'
import * as v from 'valibot'
import { getDiscoveryPath, getSocketPath, platformHasUnixSockets } from '#mcp/transport/paths'
/**
@ -24,6 +26,27 @@ export interface DiscoveryInfo {
disabledTools?: string[]
}
const DiscoveryInfoJSON = v.pipe(
v.string(),
v.parseJson(),
v.object({
pid: v.pipe(v.number(), v.integer(), v.minValue(1)),
version: v.string(),
httpPort: v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(65535)),
authRequired: v.boolean(),
startedAt: v.string(),
socketPath: v.nullable(v.pipe(v.string(), v.nonEmpty())),
authToken: v.nullable(v.string()),
disabledTools: v.optional(v.array(v.string()), () => [])
})
) satisfies v.GenericSchema<string, DiscoveryInfo>
/** Parse discovery file contents, or null when the file is not valid discovery JSON. */
export function parseDiscoveryInfo(raw: string): DiscoveryInfo | null {
const result = v.safeParse(DiscoveryInfoJSON, raw)
return result.success ? result.output : null
}
/**
* Writes the discovery JSON file at the platform-appropriate location.
* Overwrites any existing file (from a previous server instance).
@ -74,52 +97,13 @@ export async function readDiscoveryFile(): Promise<DiscoveryInfo | null> {
return null
}
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
return null
}
if (!parsed || typeof parsed !== 'object') return null
const obj = parsed as { [key: string]: unknown }
const info = validateDiscoveryFields(obj)
const info = parseDiscoveryInfo(raw)
if (!info) return null
if (!isProcessAlive(info.pid)) return null
return info
}
function validateDiscoveryFields(obj: { [key: string]: unknown }): DiscoveryInfo | null {
const { pid, version, httpPort, authRequired, startedAt, socketPath, authToken, disabledTools } =
obj
if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) return null
if (typeof version !== 'string') return null
if (typeof httpPort !== 'number' || !Number.isInteger(httpPort)) return null
if (httpPort < 0 || httpPort > 65535) return null
if (typeof authRequired !== 'boolean') return null
if (typeof startedAt !== 'string') return null
if (typeof socketPath !== 'string' && socketPath !== null) return null
if (socketPath === '') return null
if (authToken !== null && typeof authToken !== 'string') return null
if (
disabledTools !== undefined &&
(!Array.isArray(disabledTools) || disabledTools.some((name) => typeof name !== 'string'))
) {
return null
}
return {
pid,
version,
httpPort,
authRequired,
startedAt,
socketPath,
authToken,
disabledTools: disabledTools ?? []
}
}
/**
* Removes the discovery file. Does not throw if the file does not exist.
*/
@ -145,15 +129,7 @@ async function isSocketLiveViaTcp(socketPath: string): Promise<boolean> {
}
const raw = await readFile(discoveryPath, 'utf-8').catch(() => null)
if (!raw) return false
let info: DiscoveryInfo | null = null
try {
const parsed: unknown = JSON.parse(raw)
if (parsed && typeof parsed === 'object') {
info = validateDiscoveryFields(parsed as { [key: string]: unknown })
}
} catch {
return false
}
const info = parseDiscoveryInfo(raw)
if (!info || info.socketPath !== socketPath) return false
// If TCP is disabled (httpPort <= 0), fall back to PID-based liveness check

View file

@ -0,0 +1,35 @@
import { describe, expect, test } from 'bun:test'
import { parseDiscoveryInfo } from '@open-pencil/mcp/discovery'
const VALID = {
pid: 4242,
socketPath: '/tmp/open-pencil.sock',
httpPort: 7600,
authRequired: true,
authToken: 'token',
version: '1.0.0',
startedAt: '2026-01-01T00:00:00.000Z'
}
describe('parseDiscoveryInfo', () => {
test('defaults disabledTools to an empty list', () => {
expect(parseDiscoveryInfo(JSON.stringify(VALID))).toEqual({ ...VALID, disabledTools: [] })
expect(parseDiscoveryInfo(JSON.stringify({ ...VALID, disabledTools: ['eval'] }))).toEqual({
...VALID,
disabledTools: ['eval']
})
})
test.each([
['malformed JSON', '{not json'],
['a non-object', '[]'],
['a non-string token', JSON.stringify({ ...VALID, authToken: 42 })],
['an empty socket path', JSON.stringify({ ...VALID, socketPath: '' })],
['an out-of-range port', JSON.stringify({ ...VALID, httpPort: 70_000 })],
['a non-positive pid', JSON.stringify({ ...VALID, pid: 0 })],
['non-string disabled tools', JSON.stringify({ ...VALID, disabledTools: [1] })]
])('rejects %s', (_label, raw) => {
expect(parseDiscoveryInfo(raw)).toBeNull()
})
})

View file

@ -96,31 +96,25 @@ export function serializeOkHCLPayload(payload: OkHCLPayload): string {
return JSON.stringify(payload)
}
function isJSONObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
export function parseOkHCLPayload(value: string): OkHCLPayload | null {
let parsed: unknown
try {
const parsed = JSON.parse(value) as Partial<OkHCLPayload>
if (parsed.version !== 1) return null
if (parsed.kind !== 'fill' && parsed.kind !== 'stroke') return null
if (typeof parsed.index !== 'number') return null
if (!parsed.color) return null
const color = parsed.color as Partial<OkHCLColor>
if (typeof color.h !== 'number' || typeof color.c !== 'number' || typeof color.l !== 'number') {
return null
}
return {
version: 1,
kind: parsed.kind,
index: parsed.index,
color: {
h: color.h,
c: color.c,
l: color.l,
a: typeof color.a === 'number' ? color.a : undefined
}
}
parsed = JSON.parse(value)
} catch {
return null
}
if (!isJSONObject(parsed) || parsed.version !== 1) return null
const { kind, index, color } = parsed
if (kind !== 'fill' && kind !== 'stroke') return null
if (typeof index !== 'number') return null
if (!isJSONObject(color)) return null
const { h, c, l, a } = color
if (typeof h !== 'number' || typeof c !== 'number' || typeof l !== 'number') return null
return { version: 1, kind, index, color: { h, c, l, a: typeof a === 'number' ? a : undefined } }
}
function createOkHCLPayload(

View file

@ -1,23 +1,38 @@
import * as v from 'valibot'
import { AI_PROVIDERS } from '@open-pencil/core/constants'
import type { AIProviderID, ModelOption } from '@open-pencil/core/constants'
import { readCacheJSON, writeCacheJSON } from '@/app/cache'
type OpenRouterModel = {
id?: unknown
name?: unknown
supported_parameters?: unknown
architecture?: {
input_modalities?: unknown
}
top_provider?: {
max_completion_tokens?: unknown
}
}
const CachedModelOptions = v.array(
v.object({
id: v.string(),
name: v.string(),
tag: v.optional(v.string()),
capabilities: v.optional(v.array(v.picklist(['tools', 'vision']))),
recommendedMaxOutputTokens: v.optional(v.number()),
releaseDate: v.optional(v.string()),
status: v.optional(v.picklist(['active', 'beta', 'deprecated']))
})
) satisfies v.GenericSchema<unknown, ModelOption[]>
type OpenRouterModelsResponse = {
data?: OpenRouterModel[]
}
const OpenRouterModelSchema = v.looseObject({
id: v.optional(v.unknown()),
name: v.optional(v.unknown()),
supported_parameters: v.optional(v.unknown()),
architecture: v.nullish(v.looseObject({ input_modalities: v.optional(v.unknown()) })),
top_provider: v.nullish(v.looseObject({ max_completion_tokens: v.optional(v.unknown()) }))
})
type OpenRouterModel = v.InferOutput<typeof OpenRouterModelSchema>
// Entries are checked one by one in `fetchOpenRouterModels`, so one odd model keeps the rest.
const OpenRouterModelsResponseJSON = v.pipe(
v.string(),
v.parseJson(),
v.object({ data: v.optional(v.array(v.unknown())) })
)
const OPENROUTER_MODELS_URL = 'https://openrouter.ai/api/v1/models'
const OPENROUTER_MODELS_CACHE_KEY = 'openrouter/models'
@ -57,14 +72,18 @@ export function normalizeOpenRouterModel(model: OpenRouterModel): ModelOption |
async function fetchOpenRouterModels(fetcher: typeof fetch): Promise<ModelOption[]> {
const response = await fetcher(OPENROUTER_MODELS_URL)
if (!response.ok) throw new Error(`OpenRouter models request failed: ${response.status}`)
const json = (await response.json()) as OpenRouterModelsResponse
return json.data?.map(normalizeOpenRouterModel).filter((model) => model !== null) ?? []
const json = v.parse(OpenRouterModelsResponseJSON, await response.text())
return (json.data ?? []).flatMap((entry) => {
const model = v.is(OpenRouterModelSchema, entry) ? normalizeOpenRouterModel(entry) : null
return model ? [model] : []
})
}
async function listOpenRouterModels(fetcher: typeof fetch = fetch): Promise<ModelOption[]> {
modelsPromise ??= (async () => {
const cached = await readCacheJSON<ModelOption[]>(
const cached = await readCacheJSON(
OPENROUTER_MODELS_CACHE_KEY,
CachedModelOptions,
OPENROUTER_MODELS_CACHE_TTL_MS
)
if (cached?.length) return cached

View file

@ -1,7 +1,45 @@
import type { HarnessSidecarMessage } from '@open-pencil/harness'
import * as v from 'valibot'
import type { HarnessSidecarMessage, JSONValue } from '@open-pencil/harness'
import { resolvePlatformCommand } from '@/app/tauri/command'
/** Any value `JSON.parse` produced is a JSONValue; only presence needs checking. */
const jsonValue = v.custom<JSONValue>((value) => value !== undefined)
const HarnessTurnEventSchema = v.variant('type', [
v.object({ type: v.literal('text-delta'), text: v.string() }),
v.object({ type: v.literal('reasoning-delta'), text: v.string() }),
v.object({
type: v.literal('tool-call'),
toolCallId: v.string(),
toolName: v.string(),
input: jsonValue
}),
v.object({
type: v.literal('tool-result'),
toolCallId: v.string(),
toolName: v.string(),
output: jsonValue
}),
v.object({ type: v.literal('finish'), finishReason: v.string() }),
v.object({ type: v.literal('error'), message: v.string() })
])
const HarnessSidecarMessageJSON = v.pipe(
v.string(),
v.parseJson(),
v.variant('type', [
v.object({
type: v.literal('response'),
id: v.string(),
result: v.optional(jsonValue),
error: v.optional(v.string())
}),
v.object({ type: v.literal('turn.event'), id: v.string(), event: HarnessTurnEventSchema })
])
) satisfies v.GenericSchema<string, HarnessSidecarMessage>
export type HarnessChild = {
write(data: number[]): Promise<void>
kill(): Promise<void>
@ -39,11 +77,9 @@ export async function spawnHarnessProcess(options: {
buffer = lines.pop() ?? ''
for (const line of lines) {
if (!line.trim()) continue
try {
controller?.enqueue(JSON.parse(line) as HarnessSidecarMessage)
} catch (error) {
console.warn('[Harness] Ignoring malformed sidecar output:', error)
}
const message = v.safeParse(HarnessSidecarMessageJSON, line)
if (message.success) controller?.enqueue(message.output)
else console.warn('[Harness] Ignoring malformed sidecar output:', v.summarize(message.issues))
}
}

View file

@ -1,3 +1,5 @@
import * as v from 'valibot'
import { AI_PROVIDERS } from '@open-pencil/core/constants'
import type { AIProviderID, ModelOption } from '@open-pencil/core/constants'
@ -34,6 +36,21 @@ type ModelsDevProvider = {
type ModelsDevCatalog = Record<string, ModelsDevProvider>
const ModelsDevCatalogSchema = v.record(
v.string(),
v.object({
models: v.optional(
v.record(
v.string(),
v.looseObject({
modalities: v.optional(v.looseObject({ output: v.optional(v.unknown()) })),
limit: v.optional(v.looseObject({ output: v.optional(v.unknown()) }))
})
)
)
})
) satisfies v.GenericSchema<unknown, ModelsDevCatalog>
let catalogPromise: Promise<ModelsDevCatalog | null> | null = null
function normalizedStatus(status: unknown): ModelOption['status'] {
@ -64,8 +81,9 @@ async function loadCatalog(
options: { useCache: boolean }
): Promise<ModelsDevCatalog | null> {
if (options.useCache) {
const cached = await readCacheJSON<ModelsDevCatalog>(
const cached = await readCacheJSON(
MODELS_DEV_CACHE_KEY,
ModelsDevCatalogSchema,
MODELS_DEV_CACHE_TTL_MS
)
if (cached) return cached
@ -73,7 +91,11 @@ async function loadCatalog(
try {
const response = await fetcher(MODELS_DEV_URL)
if (!response.ok) throw new Error(`models.dev catalog request failed: ${response.status}`)
const catalog = (await response.json()) as ModelsDevCatalog
// Validated like the cached copy, so a bad entry falls back instead of crashing the list.
const catalog = v.parse(
v.pipe(v.string(), v.parseJson(), ModelsDevCatalogSchema),
await response.text()
)
if (options.useCache) await writeCacheJSON(MODELS_DEV_CACHE_KEY, catalog)
return catalog
} catch {

View file

@ -4,7 +4,7 @@ import { computed } from 'vue'
import { configurableAITools, isAIToolEnabled } from './catalog'
const overridesSchema = v.record(v.string(), v.boolean())
const OverridesJSON = v.pipe(v.string(), v.parseJson(), v.record(v.string(), v.boolean()))
export const aiToolOverrides = useLocalStorage<Record<string, boolean>>(
'open-pencil:ai:tool-access',
@ -12,12 +12,8 @@ export const aiToolOverrides = useLocalStorage<Record<string, boolean>>(
{
serializer: {
read: (raw) => {
try {
const result = v.safeParse(overridesSchema, JSON.parse(raw))
return result.success ? result.output : {}
} catch {
return {}
}
const result = v.safeParse(OverridesJSON, raw)
return result.success ? result.output : {}
},
write: JSON.stringify
}

View file

@ -4,12 +4,26 @@
* Connects to the bridge via WebSocket, receives RPC requests,
* executes them against the live EditorStore, and sends results back.
*/
import * as v from 'valibot'
import { randomHex } from '@open-pencil/core/random'
import { makeFigmaFromStore } from '@/app/automation/bridge/figma-factory'
import { createAutomationCommandHandlers } from '@/app/automation/bridge/handlers'
import type { EditorStore } from '@/app/editor/active-store'
/** Requests from the MCP bridge; other message types (such as the register prompt) are ignored. */
const AutomationRequestJSON = v.pipe(
v.string(),
v.parseJson(),
v.object({
type: v.literal('request'),
id: v.pipe(v.string(), v.nonEmpty()),
command: v.string(),
args: v.optional(v.unknown())
})
)
export function connectAutomation(
getStore: () => EditorStore,
authToken: string | null = null,
@ -48,13 +62,13 @@ export function connectAutomation(
socket.onmessage = async (event) => {
try {
const msg = JSON.parse(event.data) as {
type: string
id: string
command: string
args?: unknown
const parsed = v.safeParse(AutomationRequestJSON, event.data)
if (!parsed.success) {
if (parsed.issues.some((issue) => issue.type === 'parse_json'))
console.warn('Failed to parse WebSocket message:', v.summarize(parsed.issues))
return
}
if (msg.type !== 'request' || !msg.id) return
const msg = parsed.output
try {
const result = await handleRequest(msg.id, msg.command, msg.args)
if (socket.readyState !== WebSocket.OPEN) return

View file

@ -1,4 +1,5 @@
import { promiseTimeout } from '@vueuse/core'
import * as v from 'valibot'
import { AUTOMATION_HTTP_PORT } from '@open-pencil/core/constants'
import { randomHex } from '@open-pencil/core/random'
@ -117,6 +118,13 @@ function rememberStartupError(error: unknown): null {
return null
}
/** The one discovery-file field the app reads; the MCP package validates the whole file. */
const DiscoveryTokenJSON = v.pipe(
v.string(),
v.parseJson(),
v.looseObject({ authToken: v.optional(v.nullable(v.string())) })
) satisfies v.GenericSchema<string, Partial<Pick<DiscoveryInfo, 'authToken'>>>
/**
* Reads the auth token from the MCP discovery file via Tauri's FS plugin.
* The discovery file path is computed locally (not from the /health endpoint)
@ -126,9 +134,8 @@ function rememberStartupError(error: unknown): null {
async function readDiscoveryToken(discoveryPath: string): Promise<string | null> {
try {
const { readTextFile } = await import('@tauri-apps/plugin-fs')
const raw = await readTextFile(discoveryPath)
const info = JSON.parse(raw) as DiscoveryInfo
return info.authToken ?? null
const info = v.safeParse(DiscoveryTokenJSON, await readTextFile(discoveryPath))
return info.success ? (info.output.authToken ?? null) : null
} catch {
return null
}

View file

@ -1,3 +1,5 @@
import * as v from 'valibot'
import { IS_TAURI } from '@open-pencil/core/constants'
const APP_CACHE_DIR = 'cache/v1'
@ -116,23 +118,29 @@ export async function removeCachePrefix(prefix: string): Promise<void> {
removeStorageEntriesWithPrefix(prefix)
}
type JSONCacheEnvelope<T> = {
updatedAt: number
value: T
}
const CacheEnvelopeJSON = v.pipe(
v.string(),
v.parseJson(),
v.object({ updatedAt: v.number(), value: v.unknown() })
)
export async function readCacheJSON<T>(key: string, maxAgeMs?: number): Promise<T | null> {
/**
* Read a JSON value written by `writeCacheJSON`. A missing, expired, malformed or
* mismatched entry reads as null, so callers refetch instead of trusting stale shapes.
*/
export async function readCacheJSON<TSchema extends v.GenericSchema>(
key: string,
schema: TSchema,
maxAgeMs?: number
): Promise<v.InferOutput<TSchema> | null> {
const raw = await readCacheText(key)
if (!raw) return null
try {
const envelope = JSON.parse(raw) as Partial<JSONCacheEnvelope<T>>
if (typeof envelope.updatedAt !== 'number' || !('value' in envelope)) return null
if (maxAgeMs !== undefined && Date.now() - envelope.updatedAt > maxAgeMs) return null
return envelope.value as T
} catch {
return null
}
const envelope = v.safeParse(CacheEnvelopeJSON, raw)
if (!envelope.success) return null
if (maxAgeMs !== undefined && Date.now() - envelope.output.updatedAt > maxAgeMs) return null
const value = v.safeParse(schema, envelope.output.value)
return value.success ? value.output : null
}
export async function writeCacheJSON(key: string, value: unknown): Promise<void> {

View file

@ -1,3 +1,5 @@
import * as v from 'valibot'
import type { DownloadedFontCache } from '@open-pencil/core/text'
import {
@ -22,6 +24,21 @@ type FontCacheManifest = {
entries: Partial<Record<string, FontCacheEntry>>
}
const CachedFontManifest = v.object({
version: v.literal(1),
entries: v.record(
v.string(),
v.object({
family: v.string(),
style: v.string(),
file: v.string(),
byteLength: v.number(),
sha256: v.string(),
updatedAt: v.number()
})
)
}) satisfies v.GenericSchema<unknown, FontCacheManifest>
export interface DownloadedFontCacheSummary {
count: number
byteLength: number
@ -53,9 +70,7 @@ function hexDigest(data: ArrayBuffer) {
}
async function readManifest(): Promise<FontCacheManifest> {
const manifest = await readCacheJSON<Partial<FontCacheManifest>>(MANIFEST_PATH)
if (manifest?.version !== 1 || !manifest.entries) return EMPTY_MANIFEST
return { version: 1, entries: manifest.entries }
return (await readCacheJSON(MANIFEST_PATH, CachedFontManifest)) ?? EMPTY_MANIFEST
}
async function writeManifest(manifest: FontCacheManifest) {

View file

@ -1,3 +1,5 @@
import * as v from 'valibot'
import { extractFigThumbnailFromReader } from '@open-pencil/fig'
import { isTauri } from '@/app/tauri/env'
@ -61,25 +63,35 @@ async function resolveConfig(runtime: StorageProviderRuntime): Promise<S3Compati
}
}
/** Each field falls back on its own; only a document with both fields is authoritative. */
const DocumentMetadataJSON = v.pipe(
v.string(),
v.parseJson(),
v.object({
name: v.fallback(
v.nullable(
v.pipe(
v.string(),
v.check((name) => name.trim() !== '')
)
),
null
),
updatedAt: v.fallback(v.nullable(v.pipe(v.string(), v.nonEmpty())), null)
})
)
function parseMetadata(
bytes: Uint8Array | null,
fallback: StorageDocumentMetadata
): { metadata: StorageDocumentMetadata; authoritative: boolean } {
if (!bytes) return { metadata: fallback, authoritative: false }
try {
const parsed = JSON.parse(new TextDecoder().decode(bytes)) as Partial<StorageDocumentMetadata>
const name = typeof parsed.name === 'string' && parsed.name.trim() ? parsed.name : null
const updatedAt =
typeof parsed.updatedAt === 'string' && parsed.updatedAt ? parsed.updatedAt : null
return {
metadata: {
name: name ?? fallback.name,
updatedAt: updatedAt ?? fallback.updatedAt
},
authoritative: name !== null && updatedAt !== null
}
} catch {
return { metadata: fallback, authoritative: false }
const parsed = v.safeParse(DocumentMetadataJSON, new TextDecoder().decode(bytes))
if (!parsed.success) return { metadata: fallback, authoritative: false }
const { name, updatedAt } = parsed.output
return {
metadata: { name: name ?? fallback.name, updatedAt: updatedAt ?? fallback.updatedAt },
authoritative: name !== null && updatedAt !== null
}
}

View file

@ -1,10 +1,13 @@
import { fromUint8Array, toUint8Array } from 'js-base64'
import * as v from 'valibot'
import {
createLibraryRevision,
deserializeLibraryRevision,
MAX_LIBRARY_REVISION_BYTES,
SerializedLibraryRevisionSchema,
serializeLibraryRevision,
StoredLibraryLatestManifestSchema,
validateLibraryRevision
} from '@open-pencil/core/library'
import type {
@ -12,7 +15,6 @@ import type {
LibraryCatalog,
LibrarySummary,
PublishLibraryInput,
SerializedComponentLibraryRevision,
StoredLibraryLatestManifest
} from '@open-pencil/core/library'
@ -112,40 +114,26 @@ function encodeRevision(revision: ComponentLibraryRevision): Uint8Array {
return textEncoder.encode(JSON.stringify(encodeValue(serializeLibraryRevision(revision))))
}
const RevisionJSON = v.pipe(
v.string(),
v.parseJson(),
v.transform(decodeValue),
SerializedLibraryRevisionSchema
)
const LatestManifestJSON = v.pipe(v.string(), v.parseJson(), StoredLibraryLatestManifestSchema)
function decodeRevision(bytes: Uint8Array): ComponentLibraryRevision {
const parsed = decodeValue(
JSON.parse(textDecoder.decode(bytes))
) as SerializedComponentLibraryRevision
return deserializeLibraryRevision(parsed)
const parsed = v.safeParse(RevisionJSON, textDecoder.decode(bytes))
if (!parsed.success)
throw new Error(`Invalid component library revision: ${v.summarize(parsed.issues)}`)
return deserializeLibraryRevision(parsed.output)
}
function decodeLatest(bytes: Uint8Array): StoredLibraryLatestManifest {
const parsed = JSON.parse(textDecoder.decode(bytes)) as unknown
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Invalid component library manifest')
}
const candidate = parsed as { schemaVersion?: unknown; summary?: unknown }
if (
candidate.schemaVersion !== 1 ||
!candidate.summary ||
typeof candidate.summary !== 'object' ||
Array.isArray(candidate.summary)
) {
throw new Error('Invalid component library manifest')
}
const summary = candidate.summary as Partial<LibrarySummary>
if (
typeof summary.libraryId !== 'string' ||
typeof summary.name !== 'string' ||
typeof summary.latestRevisionId !== 'string' ||
typeof summary.publishedAt !== 'string' ||
typeof summary.assetCount !== 'number' ||
!Number.isSafeInteger(summary.assetCount) ||
summary.assetCount < 0
) {
throw new Error('Invalid component library manifest')
}
return candidate as StoredLibraryLatestManifest
const parsed = v.safeParse(LatestManifestJSON, textDecoder.decode(bytes))
if (!parsed.success) throw new Error('Invalid component library manifest')
return parsed.output
}
export class StorageLibraryCatalog implements LibraryCatalog {

View file

@ -1,19 +1,20 @@
import * as v from 'valibot'
import { IS_BROWSER } from '@open-pencil/core/constants'
const EDITOR_LAYOUT_KEY = 'open-pencil:editor-layout'
const DEFAULT_EDITOR_LAYOUT = [18, 64, 18]
const EditorLayoutJSON = v.pipe(
v.string(),
v.parseJson(),
v.strictTuple([v.number(), v.number(), v.number()])
)
export function loadEditorLayout(): number[] {
if (!IS_BROWSER) return DEFAULT_EDITOR_LAYOUT
try {
const raw = window.localStorage.getItem(EDITOR_LAYOUT_KEY)
if (!raw) return DEFAULT_EDITOR_LAYOUT
const parsed = JSON.parse(raw)
return Array.isArray(parsed) &&
parsed.length === 3 &&
parsed.every((v) => typeof v === 'number')
? parsed
: DEFAULT_EDITOR_LAYOUT
const parsed = v.safeParse(EditorLayoutJSON, window.localStorage.getItem(EDITOR_LAYOUT_KEY))
return parsed.success ? parsed.output : DEFAULT_EDITOR_LAYOUT
} catch {
return DEFAULT_EDITOR_LAYOUT
}

View file

@ -1,13 +1,30 @@
import { readFileSync } from 'node:fs'
import * as v from 'valibot'
import type { RecordedWheelSample } from '@open-pencil/core/profiler'
import { expect, test, useEditorSetup } from '#tests/e2e/fixtures'
const editor = useEditorSetup('/?test&no-chrome&no-rulers&renderer=tiled&navigation-benchmark')
const reversal = JSON.parse(
const WheelSample: v.GenericSchema<unknown, RecordedWheelSample> = v.object({
timeMs: v.number(),
deltaX: v.number(),
deltaY: v.number(),
deltaMode: v.number(),
ctrlKey: v.optional(v.boolean(), false),
metaKey: v.optional(v.boolean(), false),
shiftKey: v.optional(v.boolean(), false),
clientX: v.number(),
clientY: v.number(),
cancelable: v.optional(v.boolean(), true),
directionInvertedFromDevice: v.optional(v.boolean())
})
const GestureJSON = v.pipe(v.string(), v.parseJson(), v.object({ wheel: v.array(WheelSample) }))
const reversal = v.parse(
GestureJSON,
readFileSync('tests/fixtures/navigation/gestures/synthetic-repeated-pinch-reversal.json', 'utf8')
) as { wheel: RecordedWheelSample[] }
)
async function waitForTiledSettlement() {
await editor.page.evaluate(() => window.openPencil?.test?.navigation?.waitForSettlement())

View file

@ -1,6 +1,6 @@
import { expect, test, type Page } from '@playwright/test'
import type { OkHCLPayload } from '@open-pencil/scene-graph/color'
import { parseOkHCLPayload } from '@open-pencil/scene-graph/color'
import { CanvasHelper } from '#tests/helpers/canvas'
@ -32,7 +32,7 @@ async function getSelectedFill() {
}
async function getSelectedFillOkHCL() {
return page.evaluate(() => {
const value = await page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const id = [...store.state.selectedIds][0]
@ -41,10 +41,10 @@ async function getSelectedFillOkHCL() {
const entry = node?.pluginData.find(
(value) => value.pluginId === 'open-pencil' && value.key === 'okhcl'
)
if (!entry) return null
const payload = JSON.parse(entry.value) as Partial<OkHCLPayload>
return payload.kind === 'fill' && payload.index === 0 ? (payload.color ?? null) : null
return entry?.value ?? null
})
const payload = value === null ? null : parseOkHCLPayload(value)
return payload?.kind === 'fill' && payload.index === 0 ? payload.color : null
}
async function openFillPicker() {

View file

@ -1,7 +1,10 @@
import { expect, test, type Page } from '@playwright/test'
import * as v from 'valibot'
import { CanvasHelper } from '#tests/helpers/canvas'
const PublicationJSON = v.pipe(v.string(), v.parseJson(), v.object({ revisionId: v.string() }))
async function openAssets(page: Page) {
await page.getByTestId('left-panel-assets-tab').click()
}
@ -37,13 +40,14 @@ test('preserves source publication identity across FIG save and reopen', async (
await expect(publish.getByLabel('Library ID')).toBeHidden()
await page.keyboard.press('Escape')
await expect(page.getByTestId('asset-libraries-dialog')).toBeHidden()
const firstRevision = await page.evaluate(() => {
const firstPublication = await page.evaluate(() => {
const store = window.openPencil?.getStore?.()
const value = store?.graph
.getNode(store.graph.rootId)
?.pluginData.find((entry) => entry.key === 'sourceLibraryPublication')?.value
return value ? (JSON.parse(value) as { revisionId: string }).revisionId : null
return value ?? null
})
const firstRevision = firstPublication && v.parse(PublicationJSON, firstPublication).revisionId
expect(firstRevision).toBeTruthy()
const saved = await page.evaluate(async () => {
@ -105,13 +109,14 @@ test('preserves source publication identity across FIG save and reopen', async (
await publish.getByRole('button', { name: 'Publish library' }).click()
await expect(publish.getByLabel('Library ID')).toBeHidden()
const secondRevision = await page.evaluate(() => {
const secondPublication = await page.evaluate(() => {
const store = window.openPencil?.getStore?.()
const value = store?.graph
.getNode(store.graph.rootId)
?.pluginData.find((entry) => entry.key === 'sourceLibraryPublication')?.value
return value ? (JSON.parse(value) as { revisionId: string }).revisionId : null
return value ?? null
})
const secondRevision = secondPublication && v.parse(PublicationJSON, secondPublication).revisionId
expect(secondRevision).toBeTruthy()
expect(secondRevision).not.toBe(firstRevision)
})

View file

@ -1,8 +1,16 @@
import * as v from 'valibot'
import { expect, test, useEditorSetup } from '#tests/e2e/fixtures'
import { expectDefined } from '#tests/helpers/assert'
const editor = useEditorSetup()
const ObservedShortcutJSON = v.pipe(
v.string(),
v.parseJson(),
v.object({ defaultPrevented: v.boolean(), shiftKey: v.boolean() })
)
function getActiveTool() {
return editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
@ -344,11 +352,12 @@ test.describe('zoom shortcuts', () => {
await editor.page.keyboard.press(shortcut)
await editor.canvas.waitForRender()
const event = await editor.page.evaluate(() => {
const observed = await editor.page.evaluate(() => {
const observed = document.documentElement.dataset.observedShortcut
if (!observed) throw new Error('Expected shortcut keydown event')
return JSON.parse(observed) as { defaultPrevented: boolean; shiftKey: boolean }
return observed
})
const event = v.parse(ObservedShortcutJSON, observed)
return { event, zoom: await getZoom() }
}

View file

@ -1,12 +1,17 @@
import { strict as assert } from 'node:assert'
import { readFileSync, renameSync } from 'node:fs'
import * as v from 'valibot'
import { invokeNative } from '#tests/helpers/tauri/invoke'
interface DiscoveryFile {
httpPort: number
authToken: string
}
const HealthJSON = v.pipe(v.string(), v.parseJson(), v.object({ status: v.string() }))
const DiscoveryFileJSON = v.pipe(
v.string(),
v.parseJson(),
v.object({ httpPort: v.number(), authToken: v.string() })
)
const NOT_INSTALLED = 'MCP automation is not installed'
@ -144,7 +149,7 @@ describe('native MCP server lifecycle', () => {
await waitForStatus('Running')
const discoveryPath = process.env.OPENPENCIL_MCP_DISCOVERY_PATH
assert.ok(discoveryPath, 'discovery path missing from the wdio configuration')
const discovery = JSON.parse(readFileSync(discoveryPath, 'utf8')) as DiscoveryFile
const discovery = v.parse(DiscoveryFileJSON, readFileSync(discoveryPath, 'utf8'))
assert.ok(discovery.authToken, 'discovery file omitted the auth token')
// The spawned server serves the app: real port, real token.
@ -152,7 +157,7 @@ describe('native MCP server lifecycle', () => {
headers: { authorization: `Bearer ${discovery.authToken}` }
})
assert.equal(health.status, 200)
assert.equal(((await health.json()) as { status: string }).status, 'ok')
assert.equal(v.parse(HealthJSON, await health.text()).status, 'ok')
// Hiding the resolved binary must report the missing install, and restoring
// it must recover without relaunching the app.

View file

@ -1,4 +1,7 @@
import { expect, test } from '@playwright/test'
import * as v from 'valibot'
const ModeValuesJSON = v.pipe(v.string(), v.parseJson(), v.record(v.string(), v.number()))
test('missing definitions retain identity and remain explicitly detachable', async ({ page }) => {
await page.goto('/iframe.html?id=vue-sdk-primitives-bindablevalue--mode-editing&viewMode=story')
@ -19,7 +22,7 @@ for (const outcome of ['Cancel', 'Commit']) {
test(`captured variable modes survive target changes: ${outcome}`, async ({ page }) => {
await page.goto('/iframe.html?id=vue-sdk-primitives-bindablevalue--mode-editing&viewMode=story')
const values = async () =>
JSON.parse(await page.getByLabel('Mode values').innerText()) as Record<string, number>
v.parse(ModeValuesJSON, await page.getByLabel('Mode values').innerText())
const before = await values()
await page.getByRole('button', { name: 'Begin edit', exact: true }).click()
await page.getByRole('button', { name: 'Set 12', exact: true }).click()

View file

@ -102,6 +102,16 @@ describe('models.dev catalog', () => {
expect(models[0]).toMatchObject({ id: 'claude-sonnet-5-5', tag: 'Best for design' })
})
test('falls back to curated models when the catalog has a malformed entry', async () => {
const failingFetch = (async () => new Response(null, { status: 503 })) as typeof fetch
const models = await listCatalogModels(
'anthropic',
catalogResponse({ anthropic: { models: { 'claude-sonnet-5': null } } })
)
expect(models).toEqual(await listCatalogModels('anthropic', failingFetch))
})
test('retries a failed shared request and shares a successful request', async () => {
const originalFetch = globalThis.fetch
let requests = 0

View file

@ -1,5 +1,7 @@
import { afterEach, describe, expect, test } from 'bun:test'
import * as v from 'valibot'
function installLocalStorage() {
const data = new Map<string, string>()
const storage = {
@ -38,8 +40,22 @@ describe('app cache', () => {
await writeCacheJSON('json/key', { ok: true })
await expect(readCacheJSON('json/key', 60_000)).resolves.toEqual({ ok: true })
await expect(readCacheJSON('json/key', -1)).resolves.toBeNull()
const schema = v.object({ ok: v.boolean() })
await expect(readCacheJSON('json/key', schema, 60_000)).resolves.toEqual({ ok: true })
await expect(readCacheJSON('json/key', schema, -1)).resolves.toBeNull()
})
test('reads malformed or mismatched JSON entries as missing', async () => {
const storage = installLocalStorage()
const { readCacheJSON, writeCacheJSON } = await import('@/app/cache')
const schema = v.object({ ok: v.boolean() })
storage.set('open-pencil:cache:v1:json/key', '{not json')
await expect(readCacheJSON('json/key', schema)).resolves.toBeNull()
storage.set('open-pencil:cache:v1:json/key', JSON.stringify({ value: { ok: true } }))
await expect(readCacheJSON('json/key', schema)).resolves.toBeNull()
await writeCacheJSON('json/key', { ok: 'yes' })
await expect(readCacheJSON('json/key', schema)).resolves.toBeNull()
})
test('removes a web cache prefix', async () => {

View file

@ -1,5 +1,7 @@
import { describe, expect, test } from 'bun:test'
import * as v from 'valibot'
import {
buildCORSConfigurationJSON,
buildCORSConfigurationXML,
@ -27,11 +29,20 @@ describe('cloud S3 CORS helpers', () => {
})
test('builds AWS console JSON', () => {
const json = JSON.parse(buildCORSConfigurationJSON(['https://app.openpencil.dev'])) as Array<{
AllowedOrigins: string[]
AllowedMethods: string[]
AllowedHeaders: string[]
}>
const json = v.parse(
v.pipe(
v.string(),
v.parseJson(),
v.array(
v.object({
AllowedOrigins: v.array(v.string()),
AllowedMethods: v.array(v.string()),
AllowedHeaders: v.array(v.string())
})
)
),
buildCORSConfigurationJSON(['https://app.openpencil.dev'])
)
expect(json).toHaveLength(1)
expect(json[0]?.AllowedOrigins).toContain('https://app.openpencil.dev')
expect(json[0]?.AllowedMethods).toEqual(

View file

@ -1,5 +1,7 @@
import { expect, test } from 'bun:test'
import { encode } from 'js-base64'
import { encodeFigmaClipboard, parseFigmaClipboard } from '@open-pencil/fig/clipboard'
import { initCodec } from '@open-pencil/kiwi/fig/codec'
@ -24,3 +26,18 @@ test('Fig clipboard codec preserves prepared node changes and binary blobs', asy
expect(parsed?.blobs[0]).toEqual(bytes)
expect(parsed?.blobs[0]).toBeInstanceOf(Uint8Array)
})
test('Fig clipboard with malformed or mistyped metadata is ignored', async () => {
await initCodec()
const html = encodeFigmaClipboard([], [], 123)
for (const meta of [
'{not json',
JSON.stringify({ fileKey: 42, pasteID: 123, dataType: 'scene' })
]) {
const tampered = html.replace(
/\(figmeta\).*?\(\/figmeta\)/,
`(figmeta)${encode(meta)}(/figmeta)`
)
expect(await parseFigmaClipboard(tampered)).toBeNull()
}
})

View file

@ -1,25 +1,30 @@
import { describe, expect, test } from 'bun:test'
import { readFileSync } from 'node:fs'
import * as v from 'valibot'
import { nodeChangeToProps } from '@open-pencil/fig/node-change'
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
import { SceneGraph, type MaskType } from '@open-pencil/scene-graph'
import { sceneNodeToKiwi } from '#core/kiwi/fig/node-change/serialize'
interface MaskOracleEntry {
isMask: boolean
maskType: MaskType
}
const MASK_TYPE_VALUES = ['ALPHA', 'VECTOR', 'LUMINANCE'] as const satisfies readonly MaskType[]
interface MaskOracle {
masks: MaskOracleEntry[]
nested: { children: MaskOracleEntry[] }
maskIsOutline: { pluginApiReadable: boolean; note: string }
}
const MaskOracleEntry = v.object({ isMask: v.boolean(), maskType: v.picklist(MASK_TYPE_VALUES) })
function readOracle(): MaskOracle {
return JSON.parse(readFileSync('tests/fixtures/figma-oracles/masks.json', 'utf8')) as MaskOracle
const MaskOracleJSON = v.pipe(
v.string(),
v.parseJson(),
v.object({
masks: v.array(MaskOracleEntry),
nested: v.object({ children: v.array(MaskOracleEntry) }),
maskIsOutline: v.object({ pluginApiReadable: v.boolean(), note: v.string() })
})
)
function readOracle() {
return v.parse(MaskOracleJSON, readFileSync('tests/fixtures/figma-oracles/masks.json', 'utf8'))
}
describe('Figma mask oracle', () => {

View file

@ -1,66 +1,77 @@
import { describe, expect, test } from 'bun:test'
import { readFileSync } from 'node:fs'
import type { Vector } from '@open-pencil/scene-graph/primitives'
import * as v from 'valibot'
interface PatternOracleFill {
type: string
sourceNodeId: string
tileType: string
spacing: Vector
horizontalAlignment: string
verticalAlignment: string
}
const OracleVector = v.object({ x: v.number(), y: v.number() })
interface EffectOracleResult {
ok: boolean
effects: Array<{
type: string
noiseType?: string
noiseSize?: number
noiseSizeVector?: Vector
density?: number
opacity?: number
secondaryColor?: unknown
}>
}
const PatternOracleFill = v.object({
type: v.string(),
sourceNodeId: v.string(),
tileType: v.string(),
spacing: OracleVector,
horizontalAlignment: v.string(),
verticalAlignment: v.string()
})
interface PaintOracle {
pattern: {
frame: { id: string }
source: { id: string; visible: boolean }
target: { fills: PatternOracleFill[] }
}
patternAlignment: {
frame: { id: string }
source: { id: string; visible: boolean }
targets: Array<{ alignment: string; fills: PatternOracleFill[] }>
metrics: { rmseNormalized: number; fuzzDifferentPixels: number }
analysis: Record<
string,
{
pairedRowCount: number
avgDeltaY: number
avgDeltaFirstX: number
missingOpenPencilRows: number
extraOpenPencilRows: number
}
>
}
effects: {
noise: { results: Record<string, EffectOracleResult> }
textureAndGlass: { results: Record<string, EffectOracleResult> }
}
pluginRuntimeCreation: Record<string, { ok: boolean; message: string }>
currentFileFillTypes: Record<string, number>
localFigFixtureFillTypes: Record<string, Record<string, number>>
status: 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())
})
)
})
function readOracle(): PaintOracle {
return JSON.parse(
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')
) as PaintOracle
)
}
describe('Figma pattern/noise/custom paint oracle availability', () => {

View file

@ -1,46 +1,51 @@
import { describe, expect, test } from 'bun:test'
import { readFileSync } from 'node:fs'
import * as v from 'valibot'
import { nodeChangeToProps } from '@open-pencil/fig/node-change'
import type { NodeChange, Paint } from '@open-pencil/kiwi/fig/codec'
import { SceneGraph } from '@open-pencil/scene-graph'
import { sceneNodeToKiwi } from '#core/kiwi/fig/node-change/serialize'
interface OracleColor {
r: number
g: number
b: number
}
const OracleLength = v.object({ unit: v.string(), value: v.number() })
interface OracleDecorationColor {
value: {
color: OracleColor
opacity: number
}
}
const OracleDecorationColor = v.object({
value: v.object({
color: v.object({ r: v.number(), g: v.number(), b: v.number() }),
opacity: v.number()
})
})
interface OracleRange {
start: number
end: number
textDecoration: string
textDecorationStyle: string
textDecorationThickness: { unit: string; value: number }
textDecorationColor: OracleDecorationColor
}
type OracleDecorationColor = v.InferOutput<typeof OracleDecorationColor>
interface RichTextOracle {
characters: string
leadingTrim: string
textDecorationOffset: { unit: string; value: number }
textDecorationSkipInk: boolean
ranges: OracleRange[]
}
const RichTextOracleJSON = v.pipe(
v.string(),
v.parseJson(),
v.object({
characters: v.string(),
leadingTrim: v.string(),
textDecorationOffset: OracleLength,
textDecorationSkipInk: v.boolean(),
ranges: v.array(
v.object({
start: v.number(),
end: v.number(),
textDecoration: v.string(),
textDecorationStyle: v.string(),
textDecorationThickness: OracleLength,
textDecorationColor: OracleDecorationColor
})
)
})
)
function readOracle(): RichTextOracle {
return JSON.parse(
function readOracle() {
return v.parse(
RichTextOracleJSON,
readFileSync('tests/fixtures/figma-oracles/rich-text-decoration.json', 'utf8')
) as RichTextOracle
)
}
function oracleColorToPaint(color: OracleDecorationColor): Paint {
@ -53,7 +58,7 @@ function oracleColorToPaint(color: OracleDecorationColor): Paint {
}
}
function styleIdsForOracle(oracle: RichTextOracle): number[] {
function styleIdsForOracle(oracle: ReturnType<typeof readOracle>): number[] {
const ids = Array.from({ length: oracle.characters.length }, () => 0)
const secondRange = oracle.ranges[1]
if (!secondRange) return ids

View file

@ -47,9 +47,21 @@ describe('source library publication identity', () => {
const graph = new SceneGraph()
const root = graph.getNode(graph.rootId)
if (!root) throw new Error('Root missing')
graph.updateNode(root.id, {
pluginData: [{ pluginId: 'open-pencil', key: 'sourceLibraryPublication', value: '{}' }]
})
expect(readSourceLibraryPublication(graph)).toBeNull()
for (const value of [
'{}',
'{not json',
JSON.stringify({ libraryId: 'design-system', revisionId: 'revision-1', name: 1 }),
JSON.stringify({
libraryId: 'design-system',
revisionId: 'revision-1',
name: 'Design system',
catalogSource: 7
})
]) {
graph.updateNode(root.id, {
pluginData: [{ pluginId: 'open-pencil', key: 'sourceLibraryPublication', value }]
})
expect(readSourceLibraryPublication(graph)).toBeNull()
}
})
})

View file

@ -128,6 +128,66 @@ describe('storage library catalog', () => {
).rejects.toThrow('hash mismatch')
})
test('rejects malformed revision objects before deserializing them', async () => {
const objects = new MemoryObjects()
const catalog = new StorageLibraryCatalog(objects)
const revision = await catalog.publishRevision({
libraryId: 'design-system',
name: 'Design system',
graph: sourceGraph()
})
const key = `open-pencil/libraries/design-system/revisions/${revision.manifest.revisionId}.json`
for (const source of [
'{not json',
JSON.stringify({ manifest: revision.manifest, graph: { nodes: 'none' } })
]) {
objects.values.set(key, new TextEncoder().encode(source))
await expect(
catalog.getRevision('design-system', revision.manifest.revisionId)
).rejects.toThrow('Invalid component library revision')
}
})
test('rejects image bytes with missing indexes, which hashes do not cover', async () => {
const objects = new MemoryObjects()
const catalog = new StorageLibraryCatalog(objects)
const graph = sourceGraph()
const button = [...graph.getAllNodes()].find((node) => node.componentKey === 'button')
if (!button) throw new Error('Expected component')
graph.images.set('logo', new Uint8Array([17, 34, 51]))
graph.updateNode(button.id, {
fills: [
{
type: 'IMAGE',
visible: true,
opacity: 1,
color: { r: 0, g: 0, b: 0, a: 1 },
imageHash: 'logo',
imageScaleMode: 'FILL'
}
]
})
const revision = await catalog.publishRevision({
libraryId: 'design-system',
name: 'Design system',
graph
})
const key = `open-pencil/libraries/design-system/revisions/${revision.manifest.revisionId}.json`
const bytes = objects.values.get(key)
if (!bytes) throw new Error('Expected revision object')
const source = new TextDecoder().decode(bytes)
expect(source).toContain('{"$bytes":"ESIz"}')
objects.values.set(
key,
// The index-keyed form older revisions used, with byte 1 missing.
new TextEncoder().encode(source.replace('{"$bytes":"ESIz"}', '{"0":17,"2":51}'))
)
await expect(
catalog.getRevision('design-system', revision.manifest.revisionId)
).rejects.toThrow('Invalid component library revision')
})
test('rejects updates when the provider cannot return latest-manifest ETags', async () => {
const objects = new MemoryObjects()
const catalog = new StorageLibraryCatalog(objects)

View file

@ -438,4 +438,41 @@ describe('BrowserRpcBridge reconnection', () => {
expect(result.legitimate).toBe(true)
expect(result.injected).toBeUndefined()
})
test('ignores malformed JSON but closes sockets that send malformed messages', async () => {
const bridge = createBrowserRPCBridge({
authToken: AUTH_TOKEN,
onConnectionChange: () => undefined
})
const closed = (ws: WebSocket) =>
new Promise<void>((resolve) => {
ws.once('close', () => resolve())
})
const pair = await setupWsPair()
track(pair)
pair.serverWs.on('message', (raw: Buffer) => {
bridge.handleMessage(raw.toString(), pair.serverWs)
})
pair.clientWs.send('{not json')
pair.clientWs.send(JSON.stringify({ type: 'register', token: AUTH_TOKEN }))
for (let i = 0; i < 200 && !bridge.isConnected(); i++) {
await new Promise<void>((resolve) => {
setTimeout(resolve, 5)
})
}
expect(bridge.isConnected()).toBe(true)
const arrayClosed = closed(pair.clientWs)
pair.clientWs.send('[]')
await arrayClosed
const mistyped = await setupWsPair()
track(mistyped)
mistyped.serverWs.on('message', (raw: Buffer) => {
bridge.handleMessage(raw.toString(), mistyped.serverWs)
})
const tokenClosed = closed(mistyped.clientWs)
mistyped.clientWs.send(JSON.stringify({ type: 'auth', token: 42 }))
await tokenClosed
})
})

View file

@ -1,7 +1,11 @@
import { describe, expect, test } from 'bun:test'
import * as v from 'valibot'
import { MAX_RESULT_BYTES, ok } from '#mcp/result'
const ErrorJSON = v.pipe(v.string(), v.parseJson(), v.object({ error: v.string() }))
describe('MCP result formatting', () => {
test('returns structured errors for oversized text results', () => {
const result = ok({ payload: 'x'.repeat(MAX_RESULT_BYTES) }, 'large_tool')
@ -9,7 +13,7 @@ describe('MCP result formatting', () => {
expect(result.isError).toBe(true)
expect(result.content[0].type).toBe('text')
if (result.content[0].type !== 'text') throw new Error('Expected text result')
const error = JSON.parse(result.content[0].text) as { error: string }
const error = v.parse(ErrorJSON, result.content[0].text)
expect(error.error).toContain('large_tool')
expect(error.error).toContain('too large')
})

View file

@ -9,7 +9,7 @@ import { WebSocket } from 'ws'
import { startServer, type ServerHandle } from '#mcp/server'
import { getDiscoveryPath } from '#mcp/transport/paths'
import { socketRequest, type HealthResponse } from '#tests/helpers/mcp/server'
import { readHealth, socketRequest } from '#tests/helpers/mcp/server'
const isUnix = process.platform !== 'win32'
const SOCKET_DIR = join(tmpdir(), `openpencil-test-lifecycle-${process.pid}`)
@ -64,7 +64,7 @@ describe('MCP lifecycle failure cleanup', () => {
// must not have corrupted the first server's listener.
const health = await fetch(`http://127.0.0.1:${occupiedPort}/health`)
expect(health.status).toBe(200)
const data = (await health.json()) as HealthResponse
const data = await readHealth(health)
expect(data.status).toBe('no_app')
} finally {
await handle1.close()

View file

@ -11,11 +11,13 @@ import { SceneGraph } from '@open-pencil/scene-graph'
import { startServer } from '#mcp/server'
import { createToolDescriptors, getMCPToolDefinitions } from '#mcp/tool/manifest'
import { parseDiscoveryInfo } from '#mcp/transport/discovery'
import { expectDefined } from '#tests/helpers/assert'
import {
connectMockBrowser,
readHealth,
waitForBrowserRegistration,
type HealthResponse,
type MockBrowser
} from '#tests/helpers/mcp/server'
@ -211,7 +213,7 @@ describe('MCP server', () => {
const healthResponse = await fetch(`http://127.0.0.1:${ctx.handle.httpPort}/health`, {
headers: { Authorization: `Bearer ${TEST_CLIENT_AUTH_TOKEN}` }
})
const health = (await healthResponse.json()) as HealthResponse
const health = await readHealth(healthResponse)
const descriptors = health.tools ?? []
expect(descriptors.find((tool) => tool.name === 'create_shape')?.enabled).toBe(false)
expect(descriptors.find((tool) => tool.name === 'list_documents')?.enabled).toBe(false)
@ -633,12 +635,8 @@ describe('MCP server concurrent startServer', () => {
expect(a.httpPort).not.toBe(b.httpPort)
// Each responds on /health with the expected auth state.
const aHealth = (await (
await fetch(`http://127.0.0.1:${a.httpPort}/health`)
).json()) as HealthResponse
const bHealth = (await (
await fetch(`http://127.0.0.1:${b.httpPort}/health`)
).json()) as HealthResponse
const aHealth = await readHealth(await fetch(`http://127.0.0.1:${a.httpPort}/health`))
const bHealth = await readHealth(await fetch(`http://127.0.0.1:${b.httpPort}/health`))
expect(aHealth.status).toBe('no_app')
expect(bHealth.status).toBe('no_app')
@ -647,7 +645,7 @@ describe('MCP server concurrent startServer', () => {
const discoveryPath = await getDiscoveryPath()
const file = Bun.file(discoveryPath)
expect(await file.exists()).toBe(true)
const info = (await file.json()) as { pid: number; authToken: string }
const info = expectDefined(parseDiscoveryInfo(await file.text()), 'discovery file')
expect(info.pid).toBe(process.pid)
expect(['token-a', 'token-b']).toContain(info.authToken)
} finally {
@ -689,16 +687,14 @@ describe('MCP server concurrent startServer', () => {
await a.close()
// Server b should still be healthy and reachable.
const bHealth = (await (
await fetch(`http://127.0.0.1:${b.httpPort}/health`)
).json()) as HealthResponse
const bHealth = await readHealth(await fetch(`http://127.0.0.1:${b.httpPort}/health`))
expect(bHealth.status).toBe('no_app')
// Discovery file should still exist (owned by server b now).
const discoveryPath = await getDiscoveryPath()
const file = Bun.file(discoveryPath)
expect(await file.exists()).toBe(true)
const info = (await file.json()) as { authToken: string }
const info = expectDefined(parseDiscoveryInfo(await file.text()), 'discovery file')
expect(info.authToken).toBe('token-b')
} finally {
await b.close()

View file

@ -10,13 +10,14 @@ import { toolNumber } from '@open-pencil/core/tools'
import { SceneGraph } from '@open-pencil/scene-graph'
import { startServer } from '#mcp/server'
import type { DiscoveryInfo } from '#mcp/transport/discovery'
import { parseDiscoveryInfo } from '#mcp/transport/discovery'
import { DESKTOP_APP_ORIGINS, parseCORSOrigins, resolveCORSOrigins } from '#mcp/transport/origins'
import { expectDefined } from '#tests/helpers/assert'
import {
connectMockBrowser,
waitForBrowserRegistration,
type HealthResponse
readHealth,
waitForBrowserRegistration
} from '#tests/helpers/mcp/server'
const isUnix = process.platform !== 'win32'
@ -170,7 +171,7 @@ describe('MCP server auto-generated auth token', () => {
// /health should show authRequired: true
const healthResp = await fetch(`http://127.0.0.1:${httpPort}/health`)
const health = (await healthResp.json()) as HealthResponse
const health = await readHealth(healthResp)
expect(health.authRequired).toBe(true)
expect(health.tools).toBeUndefined()
} finally {
@ -204,7 +205,7 @@ describe('MCP server /rpc auth skip', () => {
try {
// /health should show authRequired: false
const healthResp = await fetch(`http://127.0.0.1:${httpPort}/health`)
const health = (await healthResp.json()) as HealthResponse
const health = await readHealth(healthResp)
expect(health.authRequired).toBe(false)
// Connect a browser
@ -354,9 +355,7 @@ describe('MCP auth boundary', () => {
}
try {
const healthResp = (await (
await fetch(`http://127.0.0.1:${httpPort}/health`)
).json()) as HealthResponse
const healthResp = await readHealth(await fetch(`http://127.0.0.1:${httpPort}/health`))
expect(healthResp.authRequired).toBe(false)
const mcpResp = await fetch(`http://127.0.0.1:${httpPort}/mcp`, {
@ -463,7 +462,10 @@ describe('Discovery PID liveness', () => {
})
try {
const discoveryPath = await getDiscoveryPath()
const raw = (await Bun.file(discoveryPath).json()) as Pick<DiscoveryInfo, 'pid'>
const raw = expectDefined(
parseDiscoveryInfo(await Bun.file(discoveryPath).text()),
'discovery file'
)
expect(raw.pid).toBe(process.pid)
} finally {
await handle.close()

View file

@ -8,16 +8,20 @@ import type { WebSocket } from 'ws'
import { SceneGraph } from '@open-pencil/scene-graph'
import { startServer, type ServerHandle } from '#mcp/server'
import { parseDiscoveryInfo } from '#mcp/transport/discovery'
import { expectDefined } from '#tests/helpers/assert'
import {
connectMockBrowser,
openWs,
readHealth,
readNextResponse,
readWsJSON,
RegisterMessage,
ResponseMessage,
socketRequest,
tcpRequest,
waitForBrowserRegistration,
type HealthResponse,
type MockBrowser
} from '#tests/helpers/mcp/server'
@ -138,13 +142,7 @@ describe('MCP server unified transport', () => {
const discoveryPath = await getDiscoveryPath()
const file = Bun.file(discoveryPath)
expect(await file.exists()).toBe(true)
const info = (await file.json()) as {
pid: number
httpPort: number
socketPath: string | null
version: string
authToken: string | null
}
const info = expectDefined(parseDiscoveryInfo(await file.text()), 'discovery file')
expect(info.pid).toBe(process.pid)
expect(info.httpPort).toBe(handle?.httpPort ?? 0)
expect(info.socketPath).toBe(handle?.socketPath ?? null)
@ -233,7 +231,7 @@ describe('MCP WebSocket stdio bridge routing', () => {
await waitForBrowserRegistration(httpPort)
clientWs = await openWs(`ws://127.0.0.1:${httpPort}`, authToken)
const register = await readWsJSON<{ type: string; token?: string | null }>(clientWs)
const register = await readWsJSON(clientWs, RegisterMessage)
expect(register.type).toBe('register')
expect(register.token).toBeNull()
@ -245,12 +243,7 @@ describe('MCP WebSocket stdio bridge routing', () => {
args: { name: 'get_current_page', args: {} }
})
)
const response = await readNextResponse<{
type: string
id: string
ok?: boolean
result?: { name: string }
}>(clientWs)
const response = await readNextResponse(clientWs, ResponseMessage)
expect(response.type).toBe('response')
expect(response.id).toBe('stdio-1')
@ -283,7 +276,7 @@ describe('MCP WebSocket stdio bridge routing', () => {
clientWs = await openWs(`ws://127.0.0.1:${httpPort}`, 'bridge-test-token')
const register = await readWsJSON<{ type: string; token?: string | null }>(clientWs)
const register = await readWsJSON(clientWs, RegisterMessage)
expect(register.type).toBe('register')
clientWs.send(
@ -294,12 +287,7 @@ describe('MCP WebSocket stdio bridge routing', () => {
args: { name: 'get_current_page', args: {} }
})
)
const response = await readNextResponse<{
type: string
id: string
ok?: boolean
error?: string
}>(clientWs, 15_000)
const response = await readNextResponse(clientWs, ResponseMessage, 15_000)
expect(response.type).toBe('response')
expect(response.id).toBe('stdio-no-app')
@ -332,11 +320,11 @@ describe('MCP WebSocket stdio bridge routing', () => {
clientWs = await openWs(`ws://127.0.0.1:${httpPort}`, authToken)
const graph = new SceneGraph()
const initialRegister = await readWsJSON<{ type: string; token?: string | null }>(clientWs)
const initialRegister = await readWsJSON(clientWs, RegisterMessage)
expect(initialRegister.type).toBe('register')
expect(initialRegister.token).toBeNull()
const broadcastPromise = readWsJSON<{ type: string; token?: string | null }>(clientWs, 3_000)
const broadcastPromise = readWsJSON(clientWs, RegisterMessage, 3_000)
browser = await connectMockBrowser(httpPort, graph, authToken)
const broadcastRegister = await broadcastPromise
expect(broadcastRegister.type).toBe('register')
@ -369,7 +357,7 @@ describe('MCP WebSocket stdio bridge routing', () => {
clientWs = await openWs(`ws://127.0.0.1:${httpPort}`, authToken)
const ws = clientWs
const initReg = await readWsJSON<{ type: string; token?: string | null }>(ws)
const initReg = await readWsJSON(ws, RegisterMessage)
expect(initReg.type).toBe('register')
const requestPromise = (async () => {
@ -381,12 +369,7 @@ describe('MCP WebSocket stdio bridge routing', () => {
args: { name: 'get_current_page', args: {} }
})
)
return readNextResponse<{
type: string
id: string
ok?: boolean
result?: { name: string }
}>(ws, 15_000)
return readNextResponse(ws, ResponseMessage, 15_000)
})()
await new Promise<void>((r) => {
@ -437,7 +420,7 @@ describe('MCP WebSocket stdio bridge routing', () => {
let last: { status: string } = { status: 'unknown' }
while (Date.now() - start < timeoutMs) {
const r = await fetch(`http://127.0.0.1:${httpPort}/health`)
last = (await r.json()) as { status: string }
last = await readHealth(r)
if (predicate(last.status)) return last
await sleep(25)
}
@ -461,7 +444,7 @@ describe('MCP WebSocket stdio bridge routing', () => {
clientWs = await openWs(`ws://127.0.0.1:${httpPort}`, authToken)
const ws = clientWs
const initReg = await readWsJSON<{ type: string; token?: string | null }>(ws)
const initReg = await readWsJSON(ws, RegisterMessage)
expect(initReg.type).toBe('register')
expect(initReg.token).toBeNull()
@ -473,7 +456,7 @@ describe('MCP WebSocket stdio bridge routing', () => {
args: { name: 'get_current_page', args: {} }
})
)
const response = await readNextResponse<{ type: string; id: string; ok?: boolean }>(ws)
const response = await readNextResponse(ws, ResponseMessage)
expect(response.type).toBe('response')
expect(response.id).toBe('stdio-after-reconnect')

View file

@ -8,7 +8,7 @@ import { SceneGraph } from '@open-pencil/scene-graph'
import { startServer, type ServerHandle } from '#mcp/server'
import { getDiscoveryPath } from '#mcp/transport/paths'
import { connectMockBrowser, type HealthResponse } from '#tests/helpers/mcp/server'
import { connectMockBrowser, readHealth, type HealthResponse } from '#tests/helpers/mcp/server'
// Issue #488: an MCP server spawned by the app but never claimed by one
// (renderer crash, forced reload) used to squat its port forever with a
@ -37,7 +37,7 @@ function sleep(ms: number): Promise<void> {
async function waitForHealthStatus(port: number, status: HealthResponse['status']): Promise<void> {
for (let attempt = 0; attempt < 20; attempt++) {
const response = await fetch(`http://127.0.0.1:${port}/health`)
const health = (await response.json()) as HealthResponse
const health = await readHealth(response)
if (health.status === status) return
await sleep(5)
}
@ -96,7 +96,7 @@ describe('MCP app-attach watchdog', () => {
const health = await fetch(`http://127.0.0.1:${port}/health`)
expect(health.status).toBe(200)
const data = (await health.json()) as HealthResponse
const data = await readHealth(health)
expect(data.status).toBe('ok')
} finally {
browser.close()
@ -143,7 +143,7 @@ describe('MCP app-attach watchdog', () => {
await sleep(250)
const health = await fetch(`http://127.0.0.1:${port}/health`)
expect(health.status).toBe(200)
const data = (await health.json()) as HealthResponse
const data = await readHealth(health)
expect(data.status).toBe('ok')
} finally {
secondBrowser.close()

View file

@ -5,6 +5,7 @@ import { join } from 'node:path'
import type { Client } from '@modelcontextprotocol/client'
import type { StdioClientTransport } from '@modelcontextprotocol/client/stdio'
import * as v from 'valibot'
import { SceneGraph } from '@open-pencil/scene-graph'
@ -116,6 +117,11 @@ function textContent(content: unknown): string {
).text
}
/** The JSON a tool returned as text, checked against the shape the test expects. */
function parseTextContent<T>(content: unknown, schema: v.GenericSchema<unknown, T>): T {
return v.parse(v.pipe(v.string(), v.parseJson(), schema), textContent(content))
}
describe('MCP stdio transport', () => {
let handle: ServerHandle | undefined
let graph: SceneGraph | undefined
@ -218,11 +224,10 @@ describe('MCP stdio transport', () => {
arguments: { type: 'FRAME', x: 10, y: 20, width: 200, height: 100, name: 'StdioFrame' }
})
expect(result.isError).not.toBe(true)
const data = JSON.parse(textContent(result.content)) as {
id: string
name: string
type: string
}
const data = parseTextContent(
result.content,
v.object({ id: v.string(), name: v.string(), type: v.string() })
)
expect(data.type).toBe('FRAME')
expect(data.name).toBe('StdioFrame')
@ -268,9 +273,12 @@ describe('MCP stdio transport', () => {
test('list_documents via stdio returns open documents', async () => {
const result = await client.callTool({ name: 'list_documents', arguments: {} })
expect(result.isError).not.toBe(true)
const data = JSON.parse(textContent(result.content)) as {
documents: Array<{ id: string; current_page_id: string }>
}
const data = parseTextContent(
result.content,
v.object({
documents: v.array(v.object({ id: v.string(), current_page_id: v.string() }))
})
)
expect(data.documents[0].id).toBe('doc-1')
expect(data.documents[0].current_page_id).toBe(browser?.graph.getPages()[0].id)
})
@ -278,14 +286,14 @@ describe('MCP stdio transport', () => {
test('save_file via stdio succeeds', async () => {
const result = await requireClient().callTool({ name: 'save_file', arguments: {} })
expect(result.isError).not.toBe(true)
const data = JSON.parse(textContent(result.content)) as { saved: boolean }
const data = parseTextContent(result.content, v.object({ saved: v.boolean() }))
expect(data.saved).toBe(true)
}, 10000)
test('get_codegen_prompt via stdio returns prompt', async () => {
const result = await requireClient().callTool({ name: 'get_codegen_prompt', arguments: {} })
expect(result.isError).not.toBe(true)
const data = JSON.parse(textContent(result.content)) as { prompt: string }
const data = parseTextContent(result.content, v.object({ prompt: v.string() }))
expect(data.prompt.length).toBeGreaterThan(100)
}, 10000)
@ -295,7 +303,7 @@ describe('MCP stdio transport', () => {
arguments: { type: 'RECTANGLE', x: 0, y: 0, width: 50, height: 50 }
})
expect(create.isError).not.toBe(true)
const { id } = JSON.parse(textContent(create.content)) as { id: string }
const { id } = parseTextContent(create.content, v.object({ id: v.string() }))
expect(getNodeOrThrow(requireGraph(), id)).toBeDefined()

View file

@ -49,6 +49,13 @@ describe('calc tool', () => {
expect((run('{"a": 1}') as { error: string }).error).toContain('Unexpected "{"')
})
test('rejects a JSON array that contains anything but expressions', () => {
expect(run('["2 + 2", 3]')).toEqual({
expr: '["2 + 2", 3]',
error: 'A JSON array of expressions must contain only strings'
})
})
test('advertises exactly the functions it supports', () => {
const { description } = getTool('calc')
for (const name of ['min', 'max', 'floor', 'ceil', 'round', 'abs', 'sqrt', 'pow']) {

View file

@ -1,5 +1,7 @@
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'
@ -23,8 +25,10 @@ async function evalCode(
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }
}
function parseJSON<T>(stdout: string): T {
return JSON.parse(stdout) as T
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', () => {
@ -38,7 +42,7 @@ heavy('CLI tool operations via eval', () => {
return r.toJSON()
`)
expect(exitCode).toBe(0)
const result = parseJSON<Record<string, unknown>>(stdout)
const result = parseRecord(stdout)
expect(result.name).toBe('TestRect')
expect(result.x).toBe(100)
expect(result.y).toBe(200)
@ -54,7 +58,7 @@ heavy('CLI tool operations via eval', () => {
return { fills: r.fills }
`)
expect(exitCode).toBe(0)
const result = parseJSON<Record<string, unknown>>(stdout)
const result = parseRecord(stdout)
expect(result.fills.length).toBe(1)
expect(result.fills[0].color.r).toBe(1)
})
@ -76,7 +80,7 @@ heavy('CLI tool operations via eval', () => {
}
`)
expect(exitCode).toBe(0)
const result = parseJSON<Record<string, unknown>>(stdout)
const result = parseRecord(stdout)
expect(result.layoutMode).toBe('VERTICAL')
expect(result.itemSpacing).toBe(16)
expect(result.paddingLeft).toBe(20)
@ -91,7 +95,7 @@ heavy('CLI tool operations via eval', () => {
return { name: comp.name, type: comp.type }
`)
expect(exitCode).toBe(0)
const result = parseJSON<Record<string, unknown>>(stdout)
const result = parseRecord(stdout)
expect(result.name).toBe('Button')
expect(result.type).toBe('COMPONENT')
})
@ -110,7 +114,7 @@ heavy('CLI tool operations via eval', () => {
return { groupType, childCount, ungroupedExists: ungrouped !== null }
`)
expect(exitCode).toBe(0)
const result = parseJSON<Record<string, unknown>>(stdout)
const result = parseRecord(stdout)
expect(result.groupType).toBe('GROUP')
expect(result.childCount).toBe(2)
expect(result.ungroupedExists).toBe(false)
@ -122,7 +126,7 @@ heavy('CLI tool operations via eval', () => {
return { count: texts.length, hasTexts: texts.length > 0 }
`)
expect(exitCode).toBe(0)
const result = parseJSON<Record<string, unknown>>(stdout)
const result = parseRecord(stdout)
expect(result.hasTexts).toBe(true)
expect(result.count).toBeGreaterThan(0)
})
@ -140,7 +144,7 @@ heavy('CLI tool operations via eval', () => {
}
`)
expect(exitCode).toBe(0)
const result = parseJSON<Record<string, unknown>>(stdout)
const result = parseRecord(stdout)
expect(result.same).toBe(false)
expect(result.cloneName).toBe('Original')
expect(result.cloneWidth).toBe(100)
@ -159,7 +163,7 @@ heavy('CLI tool operations via eval', () => {
}
`)
expect(exitCode).toBe(0)
const result = parseJSON<Record<string, unknown>>(stdout)
const result = parseRecord(stdout)
expect(result.isChild).toBe(true)
})
@ -171,7 +175,7 @@ heavy('CLI tool operations via eval', () => {
return r.constraints
`)
expect(exitCode).toBe(0)
const result = parseJSON<Record<string, unknown>>(stdout)
const result = parseRecord(stdout)
expect(result.horizontal).toBe('CENTER')
expect(result.vertical).toBe('STRETCH')
})
@ -192,7 +196,7 @@ heavy('CLI tool operations via eval', () => {
return { count: f.effects.length, type: f.effects[0].type }
`)
expect(exitCode).toBe(0)
const result = parseJSON<Record<string, unknown>>(stdout)
const result = parseRecord(stdout)
expect(result.count).toBe(1)
expect(result.type).toBe('DROP_SHADOW')
})
@ -204,7 +208,7 @@ heavy('CLI tool operations via eval', () => {
return { variables: vars.length, collections: cols.length }
`)
expect(exitCode).toBe(0)
const result = parseJSON<Record<string, unknown>>(stdout)
const result = parseRecord(stdout)
expect(typeof result.variables).toBe('number')
expect(typeof result.collections).toBe('number')
})
@ -217,7 +221,7 @@ heavy('CLI tool operations via eval', () => {
return { page: figma.currentPage.name, pageCount: pages.length }
`)
expect(exitCode).toBe(0)
const result = parseJSON<Record<string, unknown>>(stdout)
const result = parseRecord(stdout)
expect(result.page).toBeTruthy()
expect(result.pageCount).toBeGreaterThanOrEqual(1)
})

View file

@ -1,6 +1,8 @@
import { existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import * as v from 'valibot'
import {
parseFigFile,
type ParseFigFileOptions,
@ -33,9 +35,11 @@ export const VALID_NODE_TYPES = new Set<string>([
'SHAPE_WITH_TEXT'
])
/** Read a shared JSON fixture as data. */
export function readFixtureJSON<T>(name: string): T {
return JSON.parse(readFileSync(resolve(FIXTURES, name), 'utf8')) as T
const FixtureObjectJSON = v.pipe(v.string(), v.parseJson(), v.record(v.string(), v.unknown()))
/** Read a shared JSON fixture object. */
export function readFixtureObject(name: string): Record<string, unknown> {
return v.parse(FixtureObjectJSON, readFileSync(resolve(FIXTURES, name), 'utf8'))
}
export function readFixtureBytes(name: string): Uint8Array {

View file

@ -1,5 +1,6 @@
import { type RequestOptions, request as httpRequest } from 'node:http'
import * as v from 'valibot'
import { WebSocket } from 'ws'
import {
@ -11,12 +12,28 @@ import {
} from '@open-pencil/core'
import type { ToolDescriptor } from '@open-pencil/mcp/tools'
export interface HealthResponse {
status: string
version: string
authRequired: boolean
tools?: ToolDescriptor[]
discoveryPath?: string
const ToolDescriptorShape = v.looseObject({ name: v.string(), enabled: v.boolean() })
export const HealthResponseSchema = v.object({
status: v.string(),
version: v.string(),
authRequired: v.boolean(),
tools: v.optional(v.array(v.custom<ToolDescriptor>((value) => v.is(ToolDescriptorShape, value)))),
discoveryPath: v.optional(v.string())
})
export type HealthResponse = v.InferOutput<typeof HealthResponseSchema>
/** Read a JSON response body or file, checked against `schema`. */
export async function readJSON<T>(
source: { text(): Promise<string> },
schema: v.GenericSchema<unknown, T>
): Promise<T> {
return v.parse(v.pipe(v.string(), v.parseJson(), schema), await source.text())
}
export function readHealth(response: Response): Promise<HealthResponse> {
return readJSON(response, HealthResponseSchema)
}
export interface MockBrowserRequest {
@ -31,8 +48,27 @@ export interface MockBrowser {
close: () => void
}
/** Read the next WebSocket JSON message with a timeout. */
export function readWsJSON<T>(ws: WebSocket, timeoutMs = 1000): Promise<T> {
/** A `register` broadcast: the browser bridge announcing its token. */
export const RegisterMessage = v.object({
type: v.string(),
token: v.optional(v.nullable(v.string()))
})
/** A tool response relayed through the browser bridge. */
export const ResponseMessage = v.object({
type: v.string(),
id: v.string(),
ok: v.optional(v.boolean()),
result: v.optional(v.looseObject({ name: v.optional(v.string()) })),
error: v.optional(v.string())
})
/** Read the next WebSocket JSON message with a timeout, checked against `schema`. */
export function readWsJSON<T>(
ws: WebSocket,
schema: v.GenericSchema<unknown, T>,
timeoutMs = 1000
): Promise<T> {
return new Promise((resolve, reject) => {
const cleanup = () => {
clearTimeout(timer)
@ -42,7 +78,7 @@ export function readWsJSON<T>(ws: WebSocket, timeoutMs = 1000): Promise<T> {
const onMessage = (raw: WebSocket.RawData) => {
cleanup()
try {
resolve(JSON.parse(raw.toString()) as T)
resolve(v.parse(v.pipe(v.string(), v.parseJson(), schema), raw.toString()))
} catch (error) {
reject(error)
}
@ -78,13 +114,17 @@ export function openWs(url: string, authToken?: string | null): Promise<WebSocke
}
/** Read the next WebSocket JSON message, skipping any 'register' broadcasts. */
export async function readNextResponse<T>(ws: WebSocket, timeoutMs = 5000): Promise<T> {
export async function readNextResponse<T>(
ws: WebSocket,
schema: v.GenericSchema<unknown, T>,
timeoutMs = 5000
): Promise<T> {
const start = Date.now()
for (let i = 0; ; i++) {
const remaining = timeoutMs - (Date.now() - start)
if (remaining <= 0) throw new Error(`Timed out after reading ${i} register messages`)
const msg = await readWsJSON<T & { type: string }>(ws, remaining)
if (msg.type !== 'register') return msg
const msg = await readWsJSON(ws, v.looseObject({ type: v.string() }), remaining)
if (msg.type !== 'register') return v.parse(schema, msg)
}
}
@ -307,7 +347,7 @@ export async function waitForBrowserRegistration(port: number, timeoutMs = 5000)
while (Date.now() - start < timeoutMs) {
try {
const resp = await fetch(`http://127.0.0.1:${port}/health`)
const health = (await resp.json()) as HealthResponse
const health = await readHealth(resp)
lastStatus = health.status
if (health.status === 'ok') return
} catch {

View file

@ -3,9 +3,9 @@ import { expect, test } from 'bun:test'
import { materializeFigFragment } from '@open-pencil/fig'
import { prepareGraphTransfer, applyGraphTransfer, SceneGraph } from '@open-pencil/scene-graph'
import { readFixtureJSON } from '#tests/helpers/fig/fixtures'
import { readFixtureObject } from '#tests/helpers/fig/fixtures'
const fixture = readFixtureJSON<Record<string, unknown>[]>('nested-binding-ownership-records.json')
const fixture = readFixtureObject('nested-binding-ownership-records.json')
test('prepares a real FIG fragment without mutating the destination', () => {
const fragment = materializeFigFragment(

View file

@ -2,6 +2,8 @@
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import * as v from 'valibot'
import type { JSONObject } from '@open-pencil/scene-graph/primitives'
import {
LOCALE_DIR_NAMES,
@ -38,8 +40,10 @@ function localeFileName(namespace: string) {
return LOCALE_FILE_NAMES[namespace] ?? namespace
}
const JSONObjectText = v.pipe(v.string(), v.parseJson(), v.record(v.string(), v.unknown()))
function readJSONObject(path: string): JSONObject {
return JSON.parse(readFileSync(path, 'utf-8')) as JSONObject
return v.parse(JSONObjectText, readFileSync(path, 'utf-8'))
}
function report(message: string) {

View file

@ -4,6 +4,7 @@ import {
noMixedCaseAcronymIdentifiers
} from '#lint/rules/policy.ts'
import { noHandRolledBase64Rule } from '#lint/rules/quality/base64.ts'
import { noUnvalidatedJSONParseRule } from '#lint/rules/quality/json-parse.ts'
import { noModuleMockingRule } from '#lint/rules/quality/module-mocking.ts'
import { noReduceAccumulatorCopyRule } from '#lint/rules/quality/reduce-accumulator-copy.ts'
import { noWidenThenAssertRule } from '#lint/rules/quality/widen-then-assert.ts'
@ -492,6 +493,7 @@ const plugin = {
'no-top-level-prefixed-test-files': noTopLevelPrefixedTestFiles,
'no-conditional-object-spreads': noConditionalObjectSpreads,
'no-hand-rolled-base64': noHandRolledBase64Rule,
'no-unvalidated-json-parse': noUnvalidatedJSONParseRule,
'no-module-mocking': noModuleMockingRule,
'no-reduce-accumulator-copy': noReduceAccumulatorCopyRule,
'no-widen-then-assert': noWidenThenAssertRule,

View file

@ -0,0 +1,106 @@
import { resolveVariable } from '#lint/support/scope.ts'
import { defineRule } from '@oxlint/plugins'
import type { ESTree, SourceCode } from '@oxlint/plugins'
type Assertion = Extract<ESTree.Node, { type: 'TSAsExpression' | 'TSTypeAssertion' }>
const GLOBAL_OBJECTS = new Set(['globalThis', 'window', 'self'])
function propertyName(node: ESTree.MemberExpression): string | null {
if (!node.computed && node.property.type === 'Identifier') return node.property.name
if (node.computed && node.property.type === 'Literal' && typeof node.property.value === 'string')
return node.property.value
return null
}
/** The global `JSON`, directly or through `globalThis`, that no local binding shadows. */
function isGlobalJSON(sourceCode: SourceCode, node: ESTree.Expression): boolean {
if (node.type === 'Identifier') {
if (node.name !== 'JSON') return false
const variable = resolveVariable(sourceCode, node)
return variable === null || variable.defs.length === 0
}
return (
node.type === 'MemberExpression' &&
node.object.type === 'Identifier' &&
GLOBAL_OBJECTS.has(node.object.name) &&
propertyName(node) === 'JSON'
)
}
function isJSONParseCall(sourceCode: SourceCode, node: ESTree.Expression): boolean {
if (node.type !== 'CallExpression' || node.callee.type !== 'MemberExpression') return false
return propertyName(node.callee) === 'parse' && isGlobalJSON(sourceCode, node.callee.object)
}
/** `response.json()`, `Bun.file(path).json()` and the like: a parse with no schema. */
function isJSONMethodCall(node: ESTree.Expression): boolean {
return (
node.type === 'CallExpression' &&
node.arguments.length === 0 &&
node.callee.type === 'MemberExpression' &&
propertyName(node.callee) === 'json'
)
}
/**
* Strip parentheses, `await`, and the assertions layered on the parsed value
* (`as unknown as Foo`).
*/
function assertedExpression(node: Assertion): ESTree.Expression {
let current: ESTree.Expression = node.expression
while (
current.type === 'ParenthesizedExpression' ||
current.type === 'AwaitExpression' ||
current.type === 'TSAsExpression' ||
current.type === 'TSTypeAssertion' ||
current.type === 'TSSatisfiesExpression' ||
current.type === 'TSNonNullExpression'
) {
current = current.type === 'AwaitExpression' ? current.argument : current.expression
}
return current
}
/** `unknown`, or `Promise<unknown>` for an un-awaited `.json()`. */
function isUnknownType(type: ESTree.TSType): boolean {
let current = type
while (current.type === 'TSParenthesizedType') current = current.typeAnnotation
if (
current.type === 'TSTypeReference' &&
current.typeName.type === 'Identifier' &&
current.typeName.name === 'Promise' &&
current.typeArguments?.params.length === 1
) {
const [argument] = current.typeArguments.params
return argument !== undefined && isUnknownType(argument)
}
return current.type === 'TSUnknownKeyword'
}
/** Require a schema, not a type assertion, to give `JSON.parse` output a shape. */
export const noUnvalidatedJSONParseRule = defineRule({
meta: {
type: 'problem',
docs: {
description:
'Disallow type assertions on JSON.parse and `.json()` results other than `as unknown`.'
},
messages: {
unvalidated:
'Validate parsed JSON instead of asserting its type: v.safeParse(v.pipe(v.string(), v.parseJson(), Schema), text) (for a Response, read `await response.text()`), or assert only `as unknown` and narrow.'
}
},
createOnce(context) {
function check(node: Assertion): void {
if (isUnknownType(node.typeAnnotation)) return
const parsed = assertedExpression(node)
if (isJSONParseCall(context.sourceCode, parsed) || isJSONMethodCall(parsed))
context.report({ node, messageId: 'unvalidated' })
}
return {
TSAsExpression: check,
TSTypeAssertion: check
}
}
})

View file

@ -0,0 +1,40 @@
import { describe, expect, test } from 'bun:test'
import { lint, ruleDiagnostics } from './helpers/lint.ts'
const rule = 'no-unvalidated-json-parse'
const rules = { [`open-pencil/${rule}`]: 'error' }
describe('no-unvalidated-json-parse', () => {
test.each([
'type Foo = { a: number }; JSON.parse(text) as Foo',
'type Foo = { a: number }; <Foo>JSON.parse(text)',
'JSON.parse(text) as { a: number }',
'JSON.parse(text) as unknown as string[]',
'(JSON.parse(text)) as Record<string, unknown>',
'globalThis.JSON.parse(text) as string[]',
"JSON['parse'](text) as string[]",
'declare const response: Response; (await response.json()) as { a: number }',
'declare const response: Response; response.json() as Promise<string[]>',
'declare const file: { json(): Promise<unknown> }; (await file.json()) as string[]'
])('rejects %s', async (source) => {
expect(
ruleDiagnostics(await lint(`declare const text: string; ${source}`, rules), rule)
).toHaveLength(1)
})
test.each([
'JSON.parse(text) as unknown',
'const value: unknown = JSON.parse(text)',
'JSON.stringify(text) as string',
'const JSON = { parse: (value: string) => value }; JSON.parse(text) as string',
'type Foo = { a: number }; declare const value: unknown; value as Foo',
'declare const response: Response; (await response.json()) as unknown',
'declare const response: Response; response.json() as Promise<unknown>',
'declare const api: { json(key: string): unknown }; api.json(text) as string'
])('accepts %s', async (source) => {
expect(
ruleDiagnostics(await lint(`declare const text: string; ${source}`, rules), rule)
).toHaveLength(0)
})
})

View file

@ -1,16 +1,26 @@
import { readFile } from 'node:fs/promises'
import { pathToFileURL } from 'node:url'
import * as v from 'valibot'
const CODE_RABBIT_AUTHORS = new Set(['coderabbitai[bot]', 'coderabbitai'])
const REVIEW_GUIDANCE_PREFIXES = ['pr hygiene', 'pr readability', 'pr description']
export interface GitHubEvent {
sender?: { login?: string }
review?: { state?: string; body?: string }
pull_request?: { number?: number }
issue?: { number?: number; pull_request?: unknown }
comment?: { body?: string }
}
const GitHubEventJSON = v.pipe(
v.string(),
v.parseJson(),
v.object({
sender: v.optional(v.object({ login: v.optional(v.string()) })),
review: v.optional(v.object({ state: v.optional(v.string()), body: v.optional(v.string()) })),
pull_request: v.optional(v.object({ number: v.optional(v.number()) })),
issue: v.optional(
v.object({ number: v.optional(v.number()), pull_request: v.optional(v.unknown()) })
),
comment: v.optional(v.object({ body: v.optional(v.string()) }))
})
)
export type GitHubEvent = v.InferOutput<typeof GitHubEventJSON>
export interface PullRequestSummary {
state: string
@ -19,6 +29,13 @@ export interface PullRequestSummary {
user: { login: string }
}
const PullRequestSummarySchema: v.GenericSchema<unknown, PullRequestSummary> = v.object({
state: v.string(),
author_association: v.string(),
title: v.string(),
user: v.object({ login: v.string() })
})
interface EventContext {
issueNumber?: number
shouldInspect: boolean
@ -113,6 +130,7 @@ async function github<T>(
token: string,
path: string,
fetchImpl: FetchLike,
schema: v.GenericSchema<unknown, T>,
options: RequestInit = {}
): Promise<T | null> {
const response = await fetchImpl(`${apiURL}${path}`, {
@ -134,7 +152,7 @@ async function github<T>(
}
if (response.status === 204) return null
return response.json() as Promise<T>
return v.parse(v.pipe(v.string(), v.parseJson(), schema), await response.text())
}
function repositoryName(repository?: string): string | undefined {
@ -159,7 +177,7 @@ export async function monitorPRReviewGuidance(options: MonitorOptions = {}): Pro
const env = requiredEnvironment(options.env ?? process.env)
const log = options.log ?? ((message: string) => process.stdout.write(`${message}\n`))
const fetchImpl = options.fetchImpl ?? fetch
const event = JSON.parse(await readFile(env.eventPath, 'utf8')) as GitHubEvent
const event = v.parse(GitHubEventJSON, await readFile(env.eventPath, 'utf8'))
const sender = event.sender?.login ?? ''
if (!CODE_RABBIT_AUTHORS.has(sender)) {
@ -181,11 +199,12 @@ export async function monitorPRReviewGuidance(options: MonitorOptions = {}): Pro
return
}
const pr = await github<PullRequestSummary>(
const pr = await github(
env.apiURL,
env.token,
`/repos/${env.owner}/${env.repo}/pulls/${context.issueNumber}`,
fetchImpl
fetchImpl,
PullRequestSummarySchema
)
if (!pr) throw new Error(`PR #${context.issueNumber} returned no data`)

View file

@ -4,12 +4,12 @@ import { resolve } from 'node:path'
import { chromium } from '@playwright/test'
import { startChromiumTrace } from './chromium-trace'
import { compareNavigationMetrics } from './compare'
import { compareNavigationMetrics, readMetrics } from './compare'
import { computeNavigationMetrics } from './metrics'
import { readRecording } from './recording'
import { replay, type ReplayMode } from './replay'
import { setupScenario, type NavigationScenario } from './scenario'
import type { NavigationMetrics, NavigationRecordingFile } from './types'
import type { NavigationRecordingFile } from './types'
function argument(name: string, fallback?: string): string {
const index = process.argv.indexOf(name)
@ -20,8 +20,8 @@ function argument(name: string, fallback?: string): string {
const command = process.argv[2]
if (command === 'compare') {
const baseline = JSON.parse(await readFile(argument('--baseline'), 'utf8')) as NavigationMetrics
const candidate = JSON.parse(await readFile(argument('--candidate'), 'utf8')) as NavigationMetrics
const baseline = await readMetrics(argument('--baseline'))
const candidate = await readMetrics(argument('--candidate'))
const comparison = compareNavigationMetrics(baseline, candidate)
const comparisonOutput = process.argv.includes('--output') ? argument('--output') : null
if (comparisonOutput) await writeFile(comparisonOutput, JSON.stringify(comparison, null, 2))

View file

@ -1,5 +1,57 @@
import { readFile } from 'node:fs/promises'
import * as v from 'valibot'
import type { NavigationMetrics } from './types'
const distributionSchema = v.object({
count: v.number(),
min: v.number(),
median: v.number(),
p95: v.number(),
p99: v.number(),
max: v.number(),
mean: v.number()
})
const thresholdsSchema = v.object({
over8Ms: v.number(),
over16Ms: v.number(),
over33Ms: v.number(),
over50Ms: v.number()
})
const metricsSchema: v.GenericSchema<unknown, NavigationMetrics> = v.object({
durationMs: v.number(),
eventCount: v.number(),
viewportUpdateCount: v.number(),
renderCount: v.number(),
displayFrameIntervalsMs: distributionSchema,
renderFrameIntervalsMs: distributionSchema,
renderDurationsMs: distributionSchema,
eventToViewportMs: distributionSchema,
eventToRenderEndMs: distributionSchema,
zoomAnchorDriftPx: distributionSchema,
maximumJumpPx: v.number(),
finalInputToCrispMs: v.nullable(v.number()),
scheduler: v.object({
frameCount: v.number(),
maximumJobsPerFrame: v.number(),
maximumJobRenderMs: v.number(),
overBudgetJobs: v.number(),
maximumDeadlineOverrunMs: v.number(),
cancelledJobs: v.number()
}),
longTasks: v.object({ count: v.number(), totalMs: v.number(), maximumMs: v.number() }),
missedDisplayFrames: thresholdsSchema,
renderGaps: thresholdsSchema
})
/** Read metrics a previous `run` wrote. */
export async function readMetrics(path: string): Promise<NavigationMetrics> {
return v.parse(v.pipe(v.string(), v.parseJson(), metricsSchema), await readFile(path, 'utf8'))
}
export interface MetricComparison {
baseline: number | null
candidate: number | null

View file

@ -1,3 +1,5 @@
import * as v from 'valibot'
export interface SceneOracleNode {
path: number[]
type: string
@ -14,6 +16,22 @@ export interface SceneOracleNode {
strokes: string[]
}
/** One node of a scene oracle, as captures and baseline files store it. */
export const SceneOracleNodeSchema: v.GenericSchema<unknown, SceneOracleNode> = v.object({
path: v.array(v.number()),
type: v.string(),
name: v.string(),
visible: v.boolean(),
x: v.number(),
y: v.number(),
width: v.number(),
height: v.number(),
text: v.nullable(v.string()),
main: v.nullable(v.string()),
fills: v.array(v.string()),
strokes: v.array(v.string())
})
export interface SceneOracleDifference {
path: number[]
category:

View file

@ -1,5 +1,7 @@
import { parseArgs } from 'node:util'
import * as v from 'valibot'
/**
* Bring a Figma desktop tab to the front by its title. `figma-use` and the oracle captures
* target the active document, so a reopened export must be the visible tab first. The tab
@ -10,18 +12,30 @@ const { values } = parseArgs({
})
if (!values.title) throw new Error('Required: --title <tab title> [--port 9222]')
interface Target {
type: string
url: string
webSocketDebuggerUrl: string
}
const targets = (await (
await fetch(`http://localhost:${values.port}/json/list`)
).json()) as Target[]
const TargetsJSON = v.pipe(
v.string(),
v.parseJson(),
v.array(v.looseObject({ type: v.string(), url: v.string(), webSocketDebuggerUrl: v.string() }))
)
const targets = v.parse(
TargetsJSON,
await (await fetch(`http://localhost:${values.port}/json/list`)).text()
)
const shell = targets.find((t) => t.type === 'page' && t.url.endsWith('shell.html'))
if (!shell)
throw new Error('Figma desktop shell page not found; is Figma running with remote debugging?')
const EvaluateResponseJSON = v.pipe(
v.string(),
v.parseJson(),
v.object({
// CDP omits `value` for results it cannot return by value.
result: v.optional(
v.object({ result: v.optional(v.object({ value: v.optional(v.unknown()) })) })
)
})
)
const socket = new WebSocket(shell.webSocketDebuggerUrl)
await new Promise<void>((resolve, reject) => {
socket.onopen = () => resolve()
@ -37,15 +51,14 @@ const expression = `(() => {
socket.send(
JSON.stringify({ id: 1, method: 'Runtime.evaluate', params: { expression, returnByValue: true } })
)
const result = await new Promise<unknown>((resolve) => {
// A throw inside onmessage would leave this pending forever, so a bad frame rejects instead.
const result = await new Promise<unknown>((resolve, reject) => {
socket.onmessage = (message) => {
const payload = JSON.parse(String(message.data)) as {
result?: { result?: { value?: unknown } }
}
resolve(payload.result?.result?.value)
const parsed = v.safeParse(EvaluateResponseJSON, String(message.data))
if (parsed.success) resolve(parsed.output.result?.result?.value)
else reject(new v.ValiError(parsed.issues))
}
})
socket.close()
}).finally(() => socket.close())
if (result !== true)
throw new Error(`No tab titled ${values.title}; open tabs: ${JSON.stringify(result)}`)
await new Promise<void>((resolve) => {

View file

@ -2,7 +2,8 @@
import { parseArgs } from 'node:util'
import { captureDocumentOracle } from '#visual/capture/document'
import { compareSceneOracle, type SceneOracleNode } from '#visual/scene-oracle'
import { compareSceneOracle, SceneOracleNodeSchema } from '#visual/scene-oracle'
import * as v from 'valibot'
import { materializeFigArchive } from '@open-pencil/fig'
@ -33,7 +34,10 @@ if (!values.baseline) {
JSON.stringify({ nodes: nodes.length, pages: graph.getPages().length, unresolved }, null, 2)
)
} else {
const baseline = (await Bun.file(values.baseline).json()) as SceneOracleNode[]
const baseline = v.parse(
v.pipe(v.string(), v.parseJson(), v.array(SceneOracleNodeSchema)),
await Bun.file(values.baseline).text()
)
const differences = compareSceneOracle(baseline, nodes)
const counts: Record<string, number> = {}
for (const difference of differences)

View file

@ -5,8 +5,9 @@ import { parseArgs } from 'node:util'
import { captureGraphOracle, figmaOracleScript } from '#visual/capture/scene'
import { summarizePathDiagnostics } from '#visual/path-diagnostics'
import { compareSceneOracle, type SceneOracleNode } from '#visual/scene-oracle'
import { compareSceneOracle, SceneOracleNodeSchema } from '#visual/scene-oracle'
import { $ } from 'bun'
import * as v from 'valibot'
import { materializeDocument, parseFigBuffer } from '@open-pencil/fig'
import type { InstancePathDiagnostic } from '@open-pencil/fig/instance-overrides'
@ -26,7 +27,14 @@ if (!values.file || !values.node || !values['figma-key'] || !values.output) {
await mkdir(values.output, { recursive: true })
const script = figmaOracleScript(values['figma-key'], values.node)
const capture = await $`figma-use eval ${script} --json`.quiet().text()
const oracle = JSON.parse(capture) as { fileKey: string; rootId: string; nodes: SceneOracleNode[] }
const oracle = v.parse(
v.pipe(
v.string(),
v.parseJson(),
v.object({ fileKey: v.string(), rootId: v.string(), nodes: v.array(SceneOracleNodeSchema) })
),
capture
)
if (
oracle.fileKey !== values['figma-key'] ||
oracle.rootId !== values.node ||

View file

@ -4,6 +4,7 @@ import { parseArgs } from 'node:util'
import { $ } from 'bun'
import { toUint8Array } from 'js-base64'
import * as v from 'valibot'
import { SkiaRenderer } from '@open-pencil/core/canvas'
import { initCanvasKit, renderNodesToImage } from '@open-pencil/core/io'
@ -92,10 +93,10 @@ try {
}
if (values['figma-key']) {
const code = `if(figma.fileKey!==${JSON.stringify(values['figma-key'])})throw Error("Wrong Figma document");const n=await figma.getNodeByIdAsync(${JSON.stringify(values.node)});if(!n||!("exportAsync"in n))throw Error("Missing export node");return {png:figma.base64Encode(await n.exportAsync({format:"PNG",constraint:{type:"SCALE",value:${scale}}})),bounds:n.absoluteRenderBounds}`
const exported = (await $`figma-use eval --json --timeout 60 ${code}`.json()) as {
png: string
bounds: unknown
}
const exported = v.parse(
v.pipe(v.string(), v.parseJson(), v.object({ png: v.string(), bounds: v.unknown() })),
await $`figma-use eval --json --timeout 60 ${code}`.text()
)
await Bun.write(join(values.output, 'figma.png'), toUint8Array(exported.png))
await Bun.write(
join(values.output, 'figma-bounds.json'),

View file

@ -2,35 +2,46 @@
import { readFileSync, writeFileSync } from 'node:fs'
import { parseArgs } from 'node:util'
interface Metrics {
figmaSize: string
openPencilSize: string
differentPixels: number
differentPercent: number
fuzz?: string
fuzzDifferentPixels?: number
fuzzDifferentPercent?: number
rmse: string
}
import * as v from 'valibot'
interface Comparison {
name: string
nodeId: string
output: string
figmaSize?: string
openPencilSize?: string
differentPixels?: number
differentPercent?: number
fuzz?: string
fuzzDifferentPixels?: number
fuzzDifferentPercent?: number
rmse?: string
rmseNormalized?: number
}
const MetricsJSON = v.pipe(
v.string(),
v.parseJson(),
v.object({
figmaSize: v.string(),
openPencilSize: v.string(),
differentPixels: v.number(),
differentPercent: v.number(),
fuzz: v.optional(v.string()),
fuzzDifferentPixels: v.optional(v.number()),
fuzzDifferentPercent: v.optional(v.number()),
rmse: v.string()
})
)
interface Report {
comparisons: Comparison[]
}
// The report is written back in place, so loose objects keep fields this script does not own.
const ReportJSON = v.pipe(
v.string(),
v.parseJson(),
v.looseObject({
comparisons: v.array(
v.looseObject({
name: v.string(),
nodeId: v.string(),
output: v.string(),
figmaSize: v.optional(v.string()),
openPencilSize: v.optional(v.string()),
differentPixels: v.optional(v.number()),
differentPercent: v.optional(v.number()),
fuzz: v.optional(v.string()),
fuzzDifferentPixels: v.optional(v.number()),
fuzzDifferentPercent: v.optional(v.number()),
rmse: v.optional(v.string()),
rmseNormalized: v.optional(v.number())
})
)
})
)
const { values: opts } = parseArgs({
options: {
@ -46,8 +57,8 @@ const { values: opts } = parseArgs({
if (!opts.name) throw new Error('--name is required')
if (!opts.metrics) throw new Error('--metrics is required')
const report = JSON.parse(readFileSync(opts.report, 'utf8')) as Report
const metrics = JSON.parse(readFileSync(opts.metrics, 'utf8')) as Metrics
const report = v.parse(ReportJSON, readFileSync(opts.report, 'utf8'))
const metrics = v.parse(MetricsJSON, readFileSync(opts.metrics, 'utf8'))
const comparison = report.comparisons.find((item) => item.name === opts.name)
if (!comparison) throw new Error(`No comparison named ${opts.name}`)

View file

@ -12,6 +12,7 @@ import {
validatePublicationArtifacts
} from '#release/workflow'
import { createTarGzip } from 'nanotar'
import * as v from 'valibot'
import { readTarball } from '@open-pencil/package-artifacts-tools/tarball'
@ -29,12 +30,15 @@ async function listFiles(directory: string, base = directory): Promise<string[]>
return nested.flat().sort()
}
const ManifestJSON = v.pipe(
v.string(),
v.parseJson(),
v.object({ name: v.string(), version: v.string() })
)
/** Packs a prepared directory the way npm lays out a tarball, without starting npm. */
async function packInProcess(directory: string, destination: string): Promise<string> {
const manifest = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8')) as {
name: string
version: string
}
const manifest = v.parse(ManifestJSON, await readFile(join(directory, 'package.json'), 'utf8'))
const files = await Promise.all(
(await listFiles(directory)).map(async (name) => ({
name: `package/${name}`,