From e2a3aa3f803029a6ecc7064e8834aadfc55239a3 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 4 Oct 2026 17:01:50 +0000 Subject: [PATCH] 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 --- AGENTS.md | 1 + CHANGELOG.md | 6 + oxlint.json | 6 + .../src/commands/export/storybook/manifest.ts | 8 +- packages/cli/src/library/catalog.ts | 47 +++-- packages/cli/tests/commands/app.test.ts | 12 +- packages/cli/tests/commands/diff.test.ts | 7 +- .../cli/tests/commands/export/app.test.ts | 11 +- packages/cli/tests/commands/tool.test.ts | 21 ++- packages/cli/tests/library-catalog.test.ts | 60 ++++++- packages/core/src/clipboard/openpencil.ts | 130 ++++++++++---- packages/core/src/library/index.ts | 1 + packages/core/src/library/schema.ts | 98 +++++++++++ .../core/src/library/source-publication.ts | 30 ++-- packages/core/src/tools/calc.ts | 15 +- packages/core/src/tools/create/vector.ts | 25 +-- packages/core/src/tools/vector/network.ts | 66 +++++++ packages/core/src/tools/vector/path.ts | 6 +- .../core/tests/canvas/instance-scale.test.ts | 6 +- .../figma/replacement-import.test.ts | 4 +- .../core/tests/clipboard/openpencil.test.ts | 75 ++++++++ .../clipboard/fig-transfer-history.test.ts | 4 +- packages/core/tests/helpers/fig/fixtures.ts | 13 +- .../core/tests/helpers/fig/nested-binding.ts | 4 +- .../fig/export/binding/authoring.test.ts | 6 +- .../formats/fig/export/binding/import.test.ts | 4 +- .../fig/export/definition-rescale.test.ts | 4 +- .../formats/fig/export/layout-scale.test.ts | 4 +- .../formats/fig/export/rescale-export.test.ts | 4 +- packages/core/tests/tools/vector/path.test.ts | 63 +++++++ .../docs/.vitepress/sdk/component-meta.ts | 8 +- packages/fig/src/clipboard/import.ts | 17 +- packages/fig/src/document/metadata.ts | 39 +++-- packages/fig/src/node-change/plugin-data.ts | 165 ++++++++---------- packages/fig/tests/document/metadata.test.ts | 39 +++++ packages/fig/tests/helpers/fig-fixtures.ts | 5 - .../fig/tests/node-change/plugin-data.test.ts | 98 +++++++++++ packages/mcp/src/browser-rpc.ts | 103 ++++++----- packages/mcp/src/server/lifecycle.ts | 12 +- packages/mcp/src/transport/discovery.ts | 74 +++----- .../mcp/tests/transport/discovery.test.ts | 35 ++++ packages/scene-graph/src/color/okhcl.ts | 34 ++-- src/app/ai/chat/provider-models.ts | 53 ++++-- src/app/ai/harness/process.ts | 48 ++++- src/app/ai/models/catalog/index.ts | 26 ++- src/app/ai/tools/preferences.ts | 10 +- src/app/automation/bridge/server.ts | 26 ++- src/app/automation/mcp/spawn.ts | 13 +- src/app/cache/index.ts | 34 ++-- src/app/editor/fonts/cache.ts | 21 ++- src/app/integrations/storage/s3/adapter.ts | 40 +++-- src/app/libraries/catalog/storage.ts | 50 ++---- src/app/shell/layout-storage.ts | 17 +- .../canvas/tiled-large-blur-visual.spec.ts | 21 ++- tests/e2e/color-picker/basic.spec.ts | 10 +- .../e2e/components/library-lifecycle.spec.ts | 13 +- tests/e2e/keyboard/shortcuts.spec.ts | 13 +- tests/e2e/native/mcp.spec.ts | 17 +- tests/e2e/storybook/binding-modes.spec.ts | 5 +- tests/engine/app/ai/catalog.test.ts | 10 ++ tests/engine/app/cache.test.ts | 20 ++- .../app/integrations/storage/cors.test.ts | 21 ++- tests/engine/clipboard/figma/codec.test.ts | 17 ++ .../engine/io/fig/import/mask-oracle.test.ts | 27 +-- .../fig/import/pattern-paint-oracle.test.ts | 121 +++++++------ .../io/fig/import/rich-text-oracle.test.ts | 65 +++---- .../engine/library/source-publication.test.ts | 20 ++- tests/engine/library/storage-catalog.test.ts | 60 +++++++ tests/engine/mcp/browser-rpc.test.ts | 37 ++++ tests/engine/mcp/result.test.ts | 6 +- tests/engine/mcp/server/cleanup.test.ts | 4 +- tests/engine/mcp/server/index.test.ts | 22 +-- tests/engine/mcp/server/security.test.ts | 20 ++- tests/engine/mcp/server/transport.test.ts | 51 ++---- tests/engine/mcp/server/watchdog.test.ts | 8 +- tests/engine/mcp/stdio/index.test.ts | 30 ++-- tests/engine/tools/calc/tool.test.ts | 7 + tests/engine/tools/cli.test.ts | 32 ++-- tests/helpers/fig/fixtures.ts | 10 +- tests/helpers/mcp/server.ts | 66 +++++-- tests/integration/fig-transfer-plan.test.ts | 4 +- tools/checks/i18n/src/check-locales.ts | 6 +- tools/checks/lint/src/plugin.ts | 2 + .../lint/src/rules/quality/json-parse.ts | 106 +++++++++++ tools/checks/lint/tests/json-parse.test.ts | 40 +++++ tools/ci/pr-review-guidance/src/index.ts | 41 +++-- tools/dev/navigation-benchmark/src/cli.ts | 8 +- tools/dev/navigation-benchmark/src/compare.ts | 52 ++++++ .../src/document/scene-oracle.ts | 18 ++ .../src/operations/activate-tab.ts | 43 +++-- .../src/operations/compare/digest.ts | 8 +- .../compare/interpreted-document.ts | 12 +- .../src/operations/interpret-instance.ts | 9 +- .../src/operations/update-report.ts | 69 +++++--- .../release-packages/tests/workflow.test.ts | 12 +- 95 files changed, 2087 insertions(+), 784 deletions(-) create mode 100644 packages/core/src/library/schema.ts create mode 100644 packages/core/src/tools/vector/network.ts create mode 100644 packages/core/tests/clipboard/openpencil.test.ts create mode 100644 packages/core/tests/tools/vector/path.test.ts create mode 100644 packages/fig/tests/document/metadata.test.ts create mode 100644 packages/fig/tests/node-change/plugin-data.test.ts create mode 100644 packages/mcp/tests/transport/discovery.test.ts create mode 100644 tools/checks/lint/src/rules/quality/json-parse.ts create mode 100644 tools/checks/lint/tests/json-parse.test.ts diff --git a/AGENTS.md b/AGENTS.md index 45a192ace..371dfa2d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 96e56c065..3a7ef3a81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ``, inline Design JSX ``, 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 `` 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 ``, and on vectors from inline Design JSX ``, `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 diff --git a/oxlint.json b/oxlint.json index 58eda9302..004735b10 100644 --- a/oxlint.json +++ b/oxlint.json @@ -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}", diff --git a/packages/cli/src/commands/export/storybook/manifest.ts b/packages/cli/src/commands/export/storybook/manifest.ts index 11ecc30f2..0f9dcbc80 100644 --- a/packages/cli/src/commands/export/storybook/manifest.ts +++ b/packages/cli/src/commands/export/storybook/manifest.ts @@ -43,13 +43,7 @@ export async function readManifest(outputDir: string): Promise { 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.` diff --git a/packages/cli/src/library/catalog.ts b/packages/cli/src/library/catalog.ts index a044d8456..e745a798a 100644 --- a/packages/cli/src/library/catalog.ts +++ b/packages/cli/src/library/catalog.ts @@ -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>() @@ -43,7 +49,7 @@ export class FileSystemLibraryCatalog implements LibraryCatalog { async listLibraries(): Promise { try { - return await this.#readJSON('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 { const resolvedRevisionId = revisionId ?? - (await this.#readJSON(`${libraryId}/manifest.json`)).summary - .latestRevisionId - const data = await this.#readJSON( - `${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 { @@ -107,8 +123,19 @@ export class FileSystemLibraryCatalog implements LibraryCatalog { return path } - async #readJSON(relative: string): Promise { - return JSON.parse(await readFile(this.#path(relative), 'utf8')) as T + async #readJSON( + relative: string, + schema: TSchema, + maxBytes = Number.POSITIVE_INFINITY + ): Promise> { + 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 { diff --git a/packages/cli/tests/commands/app.test.ts b/packages/cli/tests/commands/app.test.ts index 67b59bca7..b4b2ce4b6 100644 --- a/packages/cli/tests/commands/app.test.ts +++ b/packages/cli/tests/commands/app.test.ts @@ -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 } +const RequestJSON = v.pipe( + v.string(), + v.parseJson(), + v.object({ command: v.string(), args: v.record(v.string(), v.unknown()) }) +) + +type Request = v.InferOutput const requests: Request[] = [] const responses = new Map() @@ -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( diff --git a/packages/cli/tests/commands/diff.test.ts b/packages/cli/tests/commands/diff.test.ts index 711b184d5..3462a1346 100644 --- a/packages/cli/tests/commands/diff.test.ts +++ b/packages/cli/tests/commands/diff.test.ts @@ -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}') diff --git a/packages/cli/tests/commands/export/app.test.ts b/packages/cli/tests/commands/export/app.test.ts index 082f31ae4..372cf68df 100644 --- a/packages/cli/tests/commands/export/app.test.ts +++ b/packages/cli/tests/commands/export/app.test.ts @@ -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 } +const RPCRequestJSON = v.pipe( + v.string(), + v.parseJson(), + v.object({ command: v.string(), args: v.record(v.string(), v.unknown()) }) +) + +type RPCRequest = v.InferOutput 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) })) diff --git a/packages/cli/tests/commands/tool.test.ts b/packages/cli/tests/commands/tool.test.ts index 54999cde8..0eac72473 100644 --- a/packages/cli/tests/commands/tool.test.ts +++ b/packages/cli/tests/commands/tool.test.ts @@ -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(text: string, schema: v.GenericSchema): 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') }) diff --git a/packages/cli/tests/library-catalog.test.ts b/packages/cli/tests/library-catalog.test.ts index 167a8359a..c7c637e29 100644 --- a/packages/cli/tests/library-catalog.test.ts +++ b/packages/cli/tests/library-catalog.test.ts @@ -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 () => { diff --git a/packages/core/src/clipboard/openpencil.ts b/packages/core/src/clipboard/openpencil.ts index c5bf6e425..ad40fe721 100644 --- a/packages/core/src/clipboard/openpencil.ts +++ b/packages/core/src/clipboard/openpencil.ts @@ -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 - instanceOverrides?: SerializedInstanceOverrideState - textPicture?: string | Uint8Array - children?: SerializedClipboardNode[] +const NODE_TYPES: Record = { + 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 + [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 + 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 = + v.looseObject({ + id: v.string(), + type: v.custom( + (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 @@ -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() - 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() + 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 }) => ({ - ...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), diff --git a/packages/core/src/library/index.ts b/packages/core/src/library/index.ts index a615cc8fd..259f07d56 100644 --- a/packages/core/src/library/index.ts +++ b/packages/core/src/library/index.ts @@ -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' diff --git a/packages/core/src/library/schema.ts b/packages/core/src/library/schema.ts new file mode 100644 index 000000000..678f1e882 --- /dev/null +++ b/packages/core/src/library/schema.ts @@ -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((value) => v.is(SerializedNodeShape, value)) +const SerializedVariable = v.custom((value) => v.is(SerializedVariableShape, value)) +const SerializedCollection = v.custom((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 + +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 + +export const StoredLibraryLatestManifestSchema = v.object({ + schemaVersion: v.literal(COMPONENT_LIBRARY_SCHEMA_VERSION), + summary: LibrarySummarySchema +}) satisfies v.GenericSchema diff --git a/packages/core/src/library/source-publication.ts b/packages/core/src/library/source-publication.ts index f93af0178..3a1eea874 100644 --- a/packages/core/src/library/source-publication.ts +++ b/packages/core/src/library/source-publication.ts @@ -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 - 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 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( diff --git a/packages/core/src/tools/calc.ts b/packages/core/src/tools/calc.ts index 999c6926f..957a3acce 100644 --- a/packages/core/src/tools/calc.ts +++ b/packages/core/src/tools/calc.ts @@ -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]) diff --git a/packages/core/src/tools/create/vector.ts b/packages/core/src/tools/create/vector.ts index db30c1c27..ea571ceeb 100644 --- a/packages/core/src/tools/create/vector.ts +++ b/packages/core/src/tools/create/vector.ts @@ -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({ diff --git a/packages/core/src/tools/vector/network.ts b/packages/core/src/tools/vector/network.ts new file mode 100644 index 000000000..5273dfcc3 --- /dev/null +++ b/packages/core/src/tools/vector/network.ts @@ -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(({ 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('; ')}` + } +} diff --git a/packages/core/src/tools/vector/path.ts b/packages/core/src/tools/vector/path.ts index 1754f01c9..ca494cbae 100644 --- a/packages/core/src/tools/vector/path.ts +++ b/packages/core/src/tools/vector/path.ts @@ -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 } } }) diff --git a/packages/core/tests/canvas/instance-scale.test.ts b/packages/core/tests/canvas/instance-scale.test.ts index 9d07bfdb7..bbdb400bd 100644 --- a/packages/core/tests/canvas/instance-scale.test.ts +++ b/packages/core/tests/canvas/instance-scale.test.ts @@ -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[]>('nested-layout-scale.json') +const fixture = readFixtureObject('nested-layout-scale.json') -const declared = readFixtureJSON[]>('nested-binding-ownership-records.json') +const declared = readFixtureObject('nested-binding-ownership-records.json') for (const edit of [ 'direct', diff --git a/packages/core/tests/clipboard/figma/replacement-import.test.ts b/packages/core/tests/clipboard/figma/replacement-import.test.ts index fea1c21f8..3e7a76457 100644 --- a/packages/core/tests/clipboard/figma/replacement-import.test.ts +++ b/packages/core/tests/clipboard/figma/replacement-import.test.ts @@ -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[]>('nested-binding-ownership-records.json') +const fixture = readFixtureObject('nested-binding-ownership-records.json') test('replacement clipboard preparation imports real nested component bindings', () => { diff --git a/packages/core/tests/clipboard/openpencil.test.ts b/packages/core/tests/clipboard/openpencil.test.ts new file mode 100644 index 000000000..4fc6076b5 --- /dev/null +++ b/packages/core/tests/clipboard/openpencil.test.ts @@ -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 `` +} + +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() + }) +}) diff --git a/packages/core/tests/editor/clipboard/fig-transfer-history.test.ts b/packages/core/tests/editor/clipboard/fig-transfer-history.test.ts index 0e3ac6d0a..3758ace8b 100644 --- a/packages/core/tests/editor/clipboard/fig-transfer-history.test.ts +++ b/packages/core/tests/editor/clipboard/fig-transfer-history.test.ts @@ -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[]>('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]) { diff --git a/packages/core/tests/helpers/fig/fixtures.ts b/packages/core/tests/helpers/fig/fixtures.ts index d5b9bcbb8..9639b01f7 100644 --- a/packages/core/tests/helpers/fig/fixtures.ts +++ b/packages/core/tests/helpers/fig/fixtures.ts @@ -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([ 'SHAPE_WITH_TEXT' ]) -/** Read a shared JSON fixture as data; a module import would escape the package root. */ -export function readFixtureJSON(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 { + return v.parse(FixtureObjectJSON, readFileSync(resolve(FIXTURES, name), 'utf8')) } export function readFixtureBytes(name: string): Uint8Array { diff --git a/packages/core/tests/helpers/fig/nested-binding.ts b/packages/core/tests/helpers/fig/nested-binding.ts index b25b9ce90..68a484cde 100644 --- a/packages/core/tests/helpers/fig/nested-binding.ts +++ b/packages/core/tests/helpers/fig/nested-binding.ts @@ -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[]>('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[] { diff --git a/packages/core/tests/io/formats/fig/export/binding/authoring.test.ts b/packages/core/tests/io/formats/fig/export/binding/authoring.test.ts index 128b67553..f55522e94 100644 --- a/packages/core/tests/io/formats/fig/export/binding/authoring.test.ts +++ b/packages/core/tests/io/formats/fig/export/binding/authoring.test.ts @@ -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[]>('nested-binding-authoring.json') +const oracle = readFixtureObject('nested-binding-authoring.json') -const fixture = readFixtureJSON[]>('nested-binding-ownership-records.json') +const fixture = readFixtureObject('nested-binding-ownership-records.json') function document(inherited = false) { const changes = inherited diff --git a/packages/core/tests/io/formats/fig/export/binding/import.test.ts b/packages/core/tests/io/formats/fig/export/binding/import.test.ts index b3214ddae..353444413 100644 --- a/packages/core/tests/io/formats/fig/export/binding/import.test.ts +++ b/packages/core/tests/io/formats/fig/export/binding/import.test.ts @@ -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[]>('nested-binding-ownership-records.json') +const fixture = readFixtureObject('nested-binding-ownership-records.json') test('binding patches keep unrelated inherited fields in their original units', () => { diff --git a/packages/core/tests/io/formats/fig/export/definition-rescale.test.ts b/packages/core/tests/io/formats/fig/export/definition-rescale.test.ts index 96fffa76a..27eba7dd8 100644 --- a/packages/core/tests/io/formats/fig/export/definition-rescale.test.ts +++ b/packages/core/tests/io/formats/fig/export/definition-rescale.test.ts @@ -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[]>('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( diff --git a/packages/core/tests/io/formats/fig/export/layout-scale.test.ts b/packages/core/tests/io/formats/fig/export/layout-scale.test.ts index 16c9a0724..4cd6e93cc 100644 --- a/packages/core/tests/io/formats/fig/export/layout-scale.test.ts +++ b/packages/core/tests/io/formats/fig/export/layout-scale.test.ts @@ -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[]>('nested-layout-scale.json') +const fixture = readFixtureObject('nested-layout-scale.json') function assembly(phase: 'before' | 'edited') { diff --git a/packages/core/tests/io/formats/fig/export/rescale-export.test.ts b/packages/core/tests/io/formats/fig/export/rescale-export.test.ts index d0baf6595..3d6d62825 100644 --- a/packages/core/tests/io/formats/fig/export/rescale-export.test.ts +++ b/packages/core/tests/io/formats/fig/export/rescale-export.test.ts @@ -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[]>('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]) { diff --git a/packages/core/tests/tools/vector/path.test.ts b/packages/core/tests/tools/vector/path.test.ts new file mode 100644 index 000000000..a203da567 --- /dev/null +++ b/packages/core/tests/tools/vector/path.test.ts @@ -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) + }) +}) diff --git a/packages/docs/.vitepress/sdk/component-meta.ts b/packages/docs/.vitepress/sdk/component-meta.ts index 23b1c2dad..897853221 100644 --- a/packages/docs/.vitepress/sdk/component-meta.ts +++ b/packages/docs/.vitepress/sdk/component-meta.ts @@ -40,13 +40,17 @@ export interface SdkComponentMeta { exposed: SdkComponentExposeMeta[] } +function isObject(value: unknown): value is Record { + 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') diff --git a/packages/fig/src/clipboard/import.ts b/packages/fig/src/clipboard/import.ts index ae29cd6a9..4c45918e5 100644 --- a/packages/fig/src/clipboard/import.ts +++ b/packages/fig/src/clipboard/import.ts @@ -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 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 { diff --git a/packages/fig/src/document/metadata.ts b/packages/fig/src/document/metadata.ts index 2aaf11315..8d1149b52 100644 --- a/packages/fig/src/document/metadata.ts +++ b/packages/fig/src/document/metadata.ts @@ -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 }) } } diff --git a/packages/fig/src/node-change/plugin-data.ts b/packages/fig/src/node-change/plugin-data.ts index 0ebe7a080..53a2ea175 100644 --- a/packages/fig/src/node-change/plugin-data.ts +++ b/packages/fig/src/node-change/plugin-data.ts @@ -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(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 => ({ + identity, + sourceNodeId: typeof sourceNodeId === 'string' ? sourceNodeId : null, + readOnly: readOnly === true + })) +) + const NATIVE_EXPORT_FORMATS: Record = { 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 | 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 { - 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 { - let bindings = parseBoundVariablesPluginValue( + const stored = v.safeParse( + BoundVariablesJSON, getOpenPencilPluginValue(nc, BOUND_VARIABLES_PLUGIN_KEY) ) + let bindings: Record = 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 { 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 { diff --git a/packages/fig/tests/document/metadata.test.ts b/packages/fig/tests/document/metadata.test.ts new file mode 100644 index 000000000..3ef836895 --- /dev/null +++ b/packages/fig/tests/document/metadata.test.ts @@ -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([]) + }) +}) diff --git a/packages/fig/tests/helpers/fig-fixtures.ts b/packages/fig/tests/helpers/fig-fixtures.ts index 75cb743bc..217973085 100644 --- a/packages/fig/tests/helpers/fig-fixtures.ts +++ b/packages/fig/tests/helpers/fig-fixtures.ts @@ -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(name: string): T { - return JSON.parse(readFileSync(resolve(FIXTURES, name), 'utf8')) as T -} - export function readFixtureBytes(name: string): Uint8Array { return readFileSync(resolve(FIXTURES, name)) } diff --git a/packages/fig/tests/node-change/plugin-data.test.ts b/packages/fig/tests/node-change/plugin-data.test.ts new file mode 100644 index 000000000..10f1869a9 --- /dev/null +++ b/packages/fig/tests/node-change/plugin-data.test.ts @@ -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() + } + }) +}) diff --git a/packages/mcp/src/browser-rpc.ts b/packages/mcp/src/browser-rpc.ts index 82fb77954..e4ad5ccf0 100644 --- a/packages/mcp/src/browser-rpc.ts +++ b/packages/mcp/src/browser-rpc.ts @@ -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>( + (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 | v.InferOutput function stripEnvelope(msg: BrowserMessage): Record { 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) { 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, 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) { diff --git a/packages/mcp/src/server/lifecycle.ts b/packages/mcp/src/server/lifecycle.ts index af78237b7..356808f33 100644 --- a/packages/mcp/src/server/lifecycle.ts +++ b/packages/mcp/src/server/lifecycle.ts @@ -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 diff --git a/packages/mcp/src/transport/discovery.ts b/packages/mcp/src/transport/discovery.ts index 92d81e898..068fc8e7c 100644 --- a/packages/mcp/src/transport/discovery.ts +++ b/packages/mcp/src/transport/discovery.ts @@ -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 + +/** 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 { 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 { } 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 diff --git a/packages/mcp/tests/transport/discovery.test.ts b/packages/mcp/tests/transport/discovery.test.ts new file mode 100644 index 000000000..a7b344af6 --- /dev/null +++ b/packages/mcp/tests/transport/discovery.test.ts @@ -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() + }) +}) diff --git a/packages/scene-graph/src/color/okhcl.ts b/packages/scene-graph/src/color/okhcl.ts index ce03a563d..9e84cd4e4 100644 --- a/packages/scene-graph/src/color/okhcl.ts +++ b/packages/scene-graph/src/color/okhcl.ts @@ -96,31 +96,25 @@ export function serializeOkHCLPayload(payload: OkHCLPayload): string { return JSON.stringify(payload) } +function isJSONObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + export function parseOkHCLPayload(value: string): OkHCLPayload | null { + let parsed: unknown try { - const parsed = JSON.parse(value) as Partial - 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 - 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( diff --git a/src/app/ai/chat/provider-models.ts b/src/app/ai/chat/provider-models.ts index c67629787..7c6fb8d20 100644 --- a/src/app/ai/chat/provider-models.ts +++ b/src/app/ai/chat/provider-models.ts @@ -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 -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 + +// 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 { 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 { modelsPromise ??= (async () => { - const cached = await readCacheJSON( + const cached = await readCacheJSON( OPENROUTER_MODELS_CACHE_KEY, + CachedModelOptions, OPENROUTER_MODELS_CACHE_TTL_MS ) if (cached?.length) return cached diff --git a/src/app/ai/harness/process.ts b/src/app/ai/harness/process.ts index 11821bf0a..60ed6945f 100644 --- a/src/app/ai/harness/process.ts +++ b/src/app/ai/harness/process.ts @@ -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((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 + export type HarnessChild = { write(data: number[]): Promise kill(): Promise @@ -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)) } } diff --git a/src/app/ai/models/catalog/index.ts b/src/app/ai/models/catalog/index.ts index 8f58c27be..c94f4bd28 100644 --- a/src/app/ai/models/catalog/index.ts +++ b/src/app/ai/models/catalog/index.ts @@ -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 +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 + let catalogPromise: Promise | null = null function normalizedStatus(status: unknown): ModelOption['status'] { @@ -64,8 +81,9 @@ async function loadCatalog( options: { useCache: boolean } ): Promise { if (options.useCache) { - const cached = await readCacheJSON( + 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 { diff --git a/src/app/ai/tools/preferences.ts b/src/app/ai/tools/preferences.ts index d1b647bae..a78df4e2b 100644 --- a/src/app/ai/tools/preferences.ts +++ b/src/app/ai/tools/preferences.ts @@ -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>( 'open-pencil:ai:tool-access', @@ -12,12 +12,8 @@ export const aiToolOverrides = useLocalStorage>( { 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 } diff --git a/src/app/automation/bridge/server.ts b/src/app/automation/bridge/server.ts index 789b83f82..6f5ece212 100644 --- a/src/app/automation/bridge/server.ts +++ b/src/app/automation/bridge/server.ts @@ -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 diff --git a/src/app/automation/mcp/spawn.ts b/src/app/automation/mcp/spawn.ts index 363f4f5ef..4a96dbe54 100644 --- a/src/app/automation/mcp/spawn.ts +++ b/src/app/automation/mcp/spawn.ts @@ -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>> + /** * 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 { 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 } diff --git a/src/app/cache/index.ts b/src/app/cache/index.ts index c01f9d5ca..3fa7d77f4 100644 --- a/src/app/cache/index.ts +++ b/src/app/cache/index.ts @@ -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 { removeStorageEntriesWithPrefix(prefix) } -type JSONCacheEnvelope = { - updatedAt: number - value: T -} +const CacheEnvelopeJSON = v.pipe( + v.string(), + v.parseJson(), + v.object({ updatedAt: v.number(), value: v.unknown() }) +) -export async function readCacheJSON(key: string, maxAgeMs?: number): Promise { +/** + * 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( + key: string, + schema: TSchema, + maxAgeMs?: number +): Promise | null> { const raw = await readCacheText(key) if (!raw) return null - try { - const envelope = JSON.parse(raw) as Partial> - 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 { diff --git a/src/app/editor/fonts/cache.ts b/src/app/editor/fonts/cache.ts index a8c7fba62..9813cf1be 100644 --- a/src/app/editor/fonts/cache.ts +++ b/src/app/editor/fonts/cache.ts @@ -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> } +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 + export interface DownloadedFontCacheSummary { count: number byteLength: number @@ -53,9 +70,7 @@ function hexDigest(data: ArrayBuffer) { } async function readManifest(): Promise { - const manifest = await readCacheJSON>(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) { diff --git a/src/app/integrations/storage/s3/adapter.ts b/src/app/integrations/storage/s3/adapter.ts index e65a08fcf..007049b44 100644 --- a/src/app/integrations/storage/s3/adapter.ts +++ b/src/app/integrations/storage/s3/adapter.ts @@ -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 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 - 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 } } diff --git a/src/app/libraries/catalog/storage.ts b/src/app/libraries/catalog/storage.ts index f5285a46a..6d7f7e689 100644 --- a/src/app/libraries/catalog/storage.ts +++ b/src/app/libraries/catalog/storage.ts @@ -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 - 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 { diff --git a/src/app/shell/layout-storage.ts b/src/app/shell/layout-storage.ts index f5308442d..6bf9ceef6 100644 --- a/src/app/shell/layout-storage.ts +++ b/src/app/shell/layout-storage.ts @@ -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 } diff --git a/tests/e2e/canvas/tiled-large-blur-visual.spec.ts b/tests/e2e/canvas/tiled-large-blur-visual.spec.ts index e57da7bdc..e0d8a4ab2 100644 --- a/tests/e2e/canvas/tiled-large-blur-visual.spec.ts +++ b/tests/e2e/canvas/tiled-large-blur-visual.spec.ts @@ -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 = 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()) diff --git a/tests/e2e/color-picker/basic.spec.ts b/tests/e2e/color-picker/basic.spec.ts index 25b1539cd..c798b19ae 100644 --- a/tests/e2e/color-picker/basic.spec.ts +++ b/tests/e2e/color-picker/basic.spec.ts @@ -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 - 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() { diff --git a/tests/e2e/components/library-lifecycle.spec.ts b/tests/e2e/components/library-lifecycle.spec.ts index 3a5078bb2..3557a96ed 100644 --- a/tests/e2e/components/library-lifecycle.spec.ts +++ b/tests/e2e/components/library-lifecycle.spec.ts @@ -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) }) diff --git a/tests/e2e/keyboard/shortcuts.spec.ts b/tests/e2e/keyboard/shortcuts.spec.ts index 5d7dcb981..34742d7bf 100644 --- a/tests/e2e/keyboard/shortcuts.spec.ts +++ b/tests/e2e/keyboard/shortcuts.spec.ts @@ -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() } } diff --git a/tests/e2e/native/mcp.spec.ts b/tests/e2e/native/mcp.spec.ts index 82c50662d..800f4a9a0 100644 --- a/tests/e2e/native/mcp.spec.ts +++ b/tests/e2e/native/mcp.spec.ts @@ -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. diff --git a/tests/e2e/storybook/binding-modes.spec.ts b/tests/e2e/storybook/binding-modes.spec.ts index 1ab68c48f..a08b6f751 100644 --- a/tests/e2e/storybook/binding-modes.spec.ts +++ b/tests/e2e/storybook/binding-modes.spec.ts @@ -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 + 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() diff --git a/tests/engine/app/ai/catalog.test.ts b/tests/engine/app/ai/catalog.test.ts index 6d885efc0..f98648f03 100644 --- a/tests/engine/app/ai/catalog.test.ts +++ b/tests/engine/app/ai/catalog.test.ts @@ -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 diff --git a/tests/engine/app/cache.test.ts b/tests/engine/app/cache.test.ts index 53c49c853..6a81b1fa9 100644 --- a/tests/engine/app/cache.test.ts +++ b/tests/engine/app/cache.test.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, test } from 'bun:test' +import * as v from 'valibot' + function installLocalStorage() { const data = new Map() 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 () => { diff --git a/tests/engine/app/integrations/storage/cors.test.ts b/tests/engine/app/integrations/storage/cors.test.ts index f77368338..12e78f2b1 100644 --- a/tests/engine/app/integrations/storage/cors.test.ts +++ b/tests/engine/app/integrations/storage/cors.test.ts @@ -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( diff --git a/tests/engine/clipboard/figma/codec.test.ts b/tests/engine/clipboard/figma/codec.test.ts index 1e1c57700..082b8eb61 100644 --- a/tests/engine/clipboard/figma/codec.test.ts +++ b/tests/engine/clipboard/figma/codec.test.ts @@ -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() + } +}) diff --git a/tests/engine/io/fig/import/mask-oracle.test.ts b/tests/engine/io/fig/import/mask-oracle.test.ts index 0b7003a65..20997db7e 100644 --- a/tests/engine/io/fig/import/mask-oracle.test.ts +++ b/tests/engine/io/fig/import/mask-oracle.test.ts @@ -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', () => { diff --git a/tests/engine/io/fig/import/pattern-paint-oracle.test.ts b/tests/engine/io/fig/import/pattern-paint-oracle.test.ts index 2b92ee7c4..5f934ac7c 100644 --- a/tests/engine/io/fig/import/pattern-paint-oracle.test.ts +++ b/tests/engine/io/fig/import/pattern-paint-oracle.test.ts @@ -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 } - textureAndGlass: { results: Record } - } - pluginRuntimeCreation: Record - currentFileFillTypes: Record - localFigFixtureFillTypes: Record> - 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', () => { diff --git a/tests/engine/io/fig/import/rich-text-oracle.test.ts b/tests/engine/io/fig/import/rich-text-oracle.test.ts index 77d577766..b68413e08 100644 --- a/tests/engine/io/fig/import/rich-text-oracle.test.ts +++ b/tests/engine/io/fig/import/rich-text-oracle.test.ts @@ -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 -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): number[] { const ids = Array.from({ length: oracle.characters.length }, () => 0) const secondRange = oracle.ranges[1] if (!secondRange) return ids diff --git a/tests/engine/library/source-publication.test.ts b/tests/engine/library/source-publication.test.ts index 28b6808a8..1a4dafaf2 100644 --- a/tests/engine/library/source-publication.test.ts +++ b/tests/engine/library/source-publication.test.ts @@ -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() + } }) }) diff --git a/tests/engine/library/storage-catalog.test.ts b/tests/engine/library/storage-catalog.test.ts index 86230607e..69ec8052d 100644 --- a/tests/engine/library/storage-catalog.test.ts +++ b/tests/engine/library/storage-catalog.test.ts @@ -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) diff --git a/tests/engine/mcp/browser-rpc.test.ts b/tests/engine/mcp/browser-rpc.test.ts index 7299e24d8..594625ccf 100644 --- a/tests/engine/mcp/browser-rpc.test.ts +++ b/tests/engine/mcp/browser-rpc.test.ts @@ -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((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((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 + }) }) diff --git a/tests/engine/mcp/result.test.ts b/tests/engine/mcp/result.test.ts index 3b2093ac5..c3bbe12c4 100644 --- a/tests/engine/mcp/result.test.ts +++ b/tests/engine/mcp/result.test.ts @@ -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') }) diff --git a/tests/engine/mcp/server/cleanup.test.ts b/tests/engine/mcp/server/cleanup.test.ts index b1961c045..1389a6339 100644 --- a/tests/engine/mcp/server/cleanup.test.ts +++ b/tests/engine/mcp/server/cleanup.test.ts @@ -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() diff --git a/tests/engine/mcp/server/index.test.ts b/tests/engine/mcp/server/index.test.ts index e7f31ca45..ebb4f72e9 100644 --- a/tests/engine/mcp/server/index.test.ts +++ b/tests/engine/mcp/server/index.test.ts @@ -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() diff --git a/tests/engine/mcp/server/security.test.ts b/tests/engine/mcp/server/security.test.ts index 07ae52430..5c3d9fb8b 100644 --- a/tests/engine/mcp/server/security.test.ts +++ b/tests/engine/mcp/server/security.test.ts @@ -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 + const raw = expectDefined( + parseDiscoveryInfo(await Bun.file(discoveryPath).text()), + 'discovery file' + ) expect(raw.pid).toBe(process.pid) } finally { await handle.close() diff --git a/tests/engine/mcp/server/transport.test.ts b/tests/engine/mcp/server/transport.test.ts index 9d1d42649..58438ea6a 100644 --- a/tests/engine/mcp/server/transport.test.ts +++ b/tests/engine/mcp/server/transport.test.ts @@ -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((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') diff --git a/tests/engine/mcp/server/watchdog.test.ts b/tests/engine/mcp/server/watchdog.test.ts index 0c0cd1bcf..3630fdd90 100644 --- a/tests/engine/mcp/server/watchdog.test.ts +++ b/tests/engine/mcp/server/watchdog.test.ts @@ -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 { async function waitForHealthStatus(port: number, status: HealthResponse['status']): Promise { 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() diff --git a/tests/engine/mcp/stdio/index.test.ts b/tests/engine/mcp/stdio/index.test.ts index 83f0d94aa..52ed4536d 100644 --- a/tests/engine/mcp/stdio/index.test.ts +++ b/tests/engine/mcp/stdio/index.test.ts @@ -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(content: unknown, schema: v.GenericSchema): 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() diff --git a/tests/engine/tools/calc/tool.test.ts b/tests/engine/tools/calc/tool.test.ts index e3905ae15..f98728250 100644 --- a/tests/engine/tools/calc/tool.test.ts +++ b/tests/engine/tools/calc/tool.test.ts @@ -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']) { diff --git a/tests/engine/tools/cli.test.ts b/tests/engine/tools/cli.test.ts index 2c3352a3b..3ab13f35c 100644 --- a/tests/engine/tools/cli.test.ts +++ b/tests/engine/tools/cli.test.ts @@ -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(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 { + 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>(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>(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>(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>(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>(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>(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>(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>(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>(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>(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>(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>(stdout) + const result = parseRecord(stdout) expect(result.page).toBeTruthy() expect(result.pageCount).toBeGreaterThanOrEqual(1) }) diff --git a/tests/helpers/fig/fixtures.ts b/tests/helpers/fig/fixtures.ts index 4fa87ba2d..cfe052e4c 100644 --- a/tests/helpers/fig/fixtures.ts +++ b/tests/helpers/fig/fixtures.ts @@ -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([ 'SHAPE_WITH_TEXT' ]) -/** Read a shared JSON fixture as data. */ -export function readFixtureJSON(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 { + return v.parse(FixtureObjectJSON, readFileSync(resolve(FIXTURES, name), 'utf8')) } export function readFixtureBytes(name: string): Uint8Array { diff --git a/tests/helpers/mcp/server.ts b/tests/helpers/mcp/server.ts index 3d0ff89e2..60612176e 100644 --- a/tests/helpers/mcp/server.ts +++ b/tests/helpers/mcp/server.ts @@ -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((value) => v.is(ToolDescriptorShape, value)))), + discoveryPath: v.optional(v.string()) +}) + +export type HealthResponse = v.InferOutput + +/** Read a JSON response body or file, checked against `schema`. */ +export async function readJSON( + source: { text(): Promise }, + schema: v.GenericSchema +): Promise { + return v.parse(v.pipe(v.string(), v.parseJson(), schema), await source.text()) +} + +export function readHealth(response: Response): Promise { + 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(ws: WebSocket, timeoutMs = 1000): Promise { +/** 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( + ws: WebSocket, + schema: v.GenericSchema, + timeoutMs = 1000 +): Promise { return new Promise((resolve, reject) => { const cleanup = () => { clearTimeout(timer) @@ -42,7 +78,7 @@ export function readWsJSON(ws: WebSocket, timeoutMs = 1000): Promise { 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(ws: WebSocket, timeoutMs = 5000): Promise { +export async function readNextResponse( + ws: WebSocket, + schema: v.GenericSchema, + timeoutMs = 5000 +): Promise { 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(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 { diff --git a/tests/integration/fig-transfer-plan.test.ts b/tests/integration/fig-transfer-plan.test.ts index 89f439fe5..5bdf4750a 100644 --- a/tests/integration/fig-transfer-plan.test.ts +++ b/tests/integration/fig-transfer-plan.test.ts @@ -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[]>('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( diff --git a/tools/checks/i18n/src/check-locales.ts b/tools/checks/i18n/src/check-locales.ts index 8fa4aa11f..e2e904341 100644 --- a/tools/checks/i18n/src/check-locales.ts +++ b/tools/checks/i18n/src/check-locales.ts @@ -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) { diff --git a/tools/checks/lint/src/plugin.ts b/tools/checks/lint/src/plugin.ts index f581c3626..e5c791b8d 100644 --- a/tools/checks/lint/src/plugin.ts +++ b/tools/checks/lint/src/plugin.ts @@ -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, diff --git a/tools/checks/lint/src/rules/quality/json-parse.ts b/tools/checks/lint/src/rules/quality/json-parse.ts new file mode 100644 index 000000000..702ae6602 --- /dev/null +++ b/tools/checks/lint/src/rules/quality/json-parse.ts @@ -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 + +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` 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 + } + } +}) diff --git a/tools/checks/lint/tests/json-parse.test.ts b/tools/checks/lint/tests/json-parse.test.ts new file mode 100644 index 000000000..5ad578d13 --- /dev/null +++ b/tools/checks/lint/tests/json-parse.test.ts @@ -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 }; JSON.parse(text)', + 'JSON.parse(text) as { a: number }', + 'JSON.parse(text) as unknown as string[]', + '(JSON.parse(text)) as Record', + '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', + 'declare const file: { json(): Promise }; (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', + '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) + }) +}) diff --git a/tools/ci/pr-review-guidance/src/index.ts b/tools/ci/pr-review-guidance/src/index.ts index 2911b45bf..1203312bf 100644 --- a/tools/ci/pr-review-guidance/src/index.ts +++ b/tools/ci/pr-review-guidance/src/index.ts @@ -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 export interface PullRequestSummary { state: string @@ -19,6 +29,13 @@ export interface PullRequestSummary { user: { login: string } } +const PullRequestSummarySchema: v.GenericSchema = 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( token: string, path: string, fetchImpl: FetchLike, + schema: v.GenericSchema, options: RequestInit = {} ): Promise { const response = await fetchImpl(`${apiURL}${path}`, { @@ -134,7 +152,7 @@ async function github( } if (response.status === 204) return null - return response.json() as Promise + 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( + 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`) diff --git a/tools/dev/navigation-benchmark/src/cli.ts b/tools/dev/navigation-benchmark/src/cli.ts index 740514a52..be14b17eb 100644 --- a/tools/dev/navigation-benchmark/src/cli.ts +++ b/tools/dev/navigation-benchmark/src/cli.ts @@ -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)) diff --git a/tools/dev/navigation-benchmark/src/compare.ts b/tools/dev/navigation-benchmark/src/compare.ts index 4f87548bd..55072b96f 100644 --- a/tools/dev/navigation-benchmark/src/compare.ts +++ b/tools/dev/navigation-benchmark/src/compare.ts @@ -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 = 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 { + return v.parse(v.pipe(v.string(), v.parseJson(), metricsSchema), await readFile(path, 'utf8')) +} + export interface MetricComparison { baseline: number | null candidate: number | null diff --git a/tools/generate/visual-oracles/src/document/scene-oracle.ts b/tools/generate/visual-oracles/src/document/scene-oracle.ts index 4bd2bb551..02df0939a 100644 --- a/tools/generate/visual-oracles/src/document/scene-oracle.ts +++ b/tools/generate/visual-oracles/src/document/scene-oracle.ts @@ -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 = 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: diff --git a/tools/generate/visual-oracles/src/operations/activate-tab.ts b/tools/generate/visual-oracles/src/operations/activate-tab.ts index a3b48207d..275331323 100644 --- a/tools/generate/visual-oracles/src/operations/activate-tab.ts +++ b/tools/generate/visual-oracles/src/operations/activate-tab.ts @@ -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 [--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((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((resolve) => { +// A throw inside onmessage would leave this pending forever, so a bad frame rejects instead. +const result = await new Promise((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((resolve) => { diff --git a/tools/generate/visual-oracles/src/operations/compare/digest.ts b/tools/generate/visual-oracles/src/operations/compare/digest.ts index dfc7871fe..e2f948f0d 100644 --- a/tools/generate/visual-oracles/src/operations/compare/digest.ts +++ b/tools/generate/visual-oracles/src/operations/compare/digest.ts @@ -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 = {} for (const difference of differences) diff --git a/tools/generate/visual-oracles/src/operations/compare/interpreted-document.ts b/tools/generate/visual-oracles/src/operations/compare/interpreted-document.ts index 4a5b4b3a8..ebb853709 100644 --- a/tools/generate/visual-oracles/src/operations/compare/interpreted-document.ts +++ b/tools/generate/visual-oracles/src/operations/compare/interpreted-document.ts @@ -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 || diff --git a/tools/generate/visual-oracles/src/operations/interpret-instance.ts b/tools/generate/visual-oracles/src/operations/interpret-instance.ts index 4b52074de..f80c49808 100644 --- a/tools/generate/visual-oracles/src/operations/interpret-instance.ts +++ b/tools/generate/visual-oracles/src/operations/interpret-instance.ts @@ -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'), diff --git a/tools/generate/visual-oracles/src/operations/update-report.ts b/tools/generate/visual-oracles/src/operations/update-report.ts index 2e8e1452b..27af61a75 100644 --- a/tools/generate/visual-oracles/src/operations/update-report.ts +++ b/tools/generate/visual-oracles/src/operations/update-report.ts @@ -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}`) diff --git a/tools/release/release-packages/tests/workflow.test.ts b/tools/release/release-packages/tests/workflow.test.ts index 888034cae..f7e2a6d98 100644 --- a/tools/release/release-packages/tests/workflow.test.ts +++ b/tools/release/release-packages/tests/workflow.test.ts @@ -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 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 { - 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}`,