fix: validate parsed JSON at untrusted boundaries with Valibot (#855)
* fix: validate parsed JSON at untrusted boundaries with Valibot Clipboard HTML, library revisions from shared storage, MCP and automation WebSocket messages, the MCP discovery file, sidecar output and AI/MCP tool arguments were JSON.parse'd and cast to their expected types, so a malformed payload reached the document or crashed paste. They now go through v.pipe(v.string(), v.parseJson(), Schema), which reports bad JSON and a wrong shape as the same validation failure. The path_set tool rejects an invalid VectorNetwork and shares its parser with create_vector. The CLI library catalog validates its files and runs revisions through the same size, identity and content-hash checks as the app; reading image bytes as index-keyed records also stops them coming back empty. Hand-rolled typeof readers for plugin data, document metadata, caches and preferences become schemas with their behaviour preserved, and readCacheJSON takes a schema for its payload. open-pencil/no-unvalidated-json-parse rejects type assertions on JSON.parse results other than `as unknown` in src and packages/*/src. * refactor: validate parsed JSON in tests and tooling Extend open-pencil/no-unvalidated-json-parse beyond source: tests, helpers and repo tooling now parse JSON through Valibot schemas instead of asserting a type. The shared fixture reader returns a validated object; its old array annotation never matched the fixtures. * fix: validate clipboard geometry bytes, library images and model catalogs Clipboard geometry blobs and library image bytes must be bytes at contiguous indexes, so out-of-range or gapped values are rejected instead of silently becoming different geometry or images; serialized library nodes must carry source metadata. The models.dev and OpenRouter responses are validated like their cached copies, and activate-tab rejects a CDP frame it cannot read instead of hanging. * refactor: extend the JSON validation lint to .json() results no-unvalidated-json-parse now also rejects type assertions on Response, Bun.file and shell .json() results, the same unchecked parse in another form. MCP server tests read /health through a validated readHealth helper and discovery files through parseDiscoveryInfo; the remaining tooling reads its JSON through schemas. * test: validate the RPC request body in the CLI app export test * test: validate CLI JSON output in the tool and app command tests * test: compare the malformed models.dev fallback with the curated list
This commit is contained in:
parent
b9753aa016
commit
e2a3aa3f80
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -107,6 +107,8 @@
|
|||
- Load the Bold, Medium, and other styles of macOS system fonts packaged as font collections, such as Menlo, Helvetica Neue, and Avenir Next, instead of reporting them as substituted or drawing a different style (#746).
|
||||
- Load the Medium, Semibold, Bold, and other styles of installed variable fonts such as SF Pro on macOS instead of reporting them as substituted (#752).
|
||||
- Ship the MIT license text in every published npm package, and add READMEs for `@open-pencil/core`, `@open-pencil/cli`, and `@open-pencil/mcp` on npm.
|
||||
- Reject malformed vector network JSON in the AI and MCP `path_set` tool with an error instead of storing it on the node, where later path tools failed on it.
|
||||
- Keep image bytes when the CLI reads a component library revision back from its catalog, instead of restoring every image empty.
|
||||
- Work with pages other than the one on screen through MCP, `eval`, and the CLI against the running app without switching to them: `openpencil export --page` and `--page-id` export that page instead of the selection, `export_image` and `openpencil export --node` export layers from any page instead of failing with "Raster export selection must stay on a single page", and a `.fig` page that has not been shown yet gets its layers, fonts, and layout before a command reads or changes it.
|
||||
- Fill the open subpaths of filled, unstroked SVG paths as if they were closed, as SVG does, in icons from `insert_icon` and Design JSX `<Icon>`, inline Design JSX `<svg>`, SVG from `import_svg` or dropped and pasted files, and SVG clip paths. Icons that cut holes with open subpaths, such as some Font Awesome icons, no longer render with those holes filled in, and filled `<polyline>` elements render filled instead of not at all.
|
||||
- Keep round and other stroke caps and joins after saving and reopening the file on icons from `insert_icon` and Design JSX `<Icon>`, and on vectors from inline Design JSX `<svg>`, `import_svg`, and dropped or pasted SVG files. They were set only on the stroke paint, which `.fig` does not store, so outline icons such as Lucide's reopened with butt caps and miter joins and showed gaps where their strokes meet.
|
||||
|
|
@ -123,6 +125,10 @@
|
|||
- Validate cursors, selections, and names that collaborators send before drawing them, and cap their size, so a broken or hostile peer cannot crash or flood the canvas.
|
||||
- Evaluate `calc` expressions through `jsep` and an arithmetic allowlist that never compiles input into JavaScript, replacing the `expr-eval` dependency and its unpatched critical code-execution advisory (GHSA-q9v2-7m5w-4693).
|
||||
- Escape layer names and other text properties in JSX and Tailwind JSX export, so text from a document can no longer add attributes or JavaScript expressions that the AI and MCP `render` and `replace` tools would execute, and names containing `&` no longer change when the JSX is rendered back.
|
||||
- Validate OpenPencil and Figma clipboard data before pasting, so malformed or hostile clipboard content is ignored instead of throwing out of paste or writing mistyped layers into the document.
|
||||
- Validate component library revisions from shared storage and from CLI catalogs before reading them, and run the CLI's revisions through the same size, identity, and content-hash checks as the app, so a malformed or tampered revision is rejected instead of crashing or entering the document.
|
||||
- Validate MCP and automation WebSocket messages and the MCP discovery file, so a malformed message or a non-string auth token is rejected instead of being used unchecked.
|
||||
- List AI models when the models.dev catalog returns a malformed entry: the curated list is shown instead of model listing failing until the app restarts.
|
||||
|
||||
## 0.15.1 — 2026-09-18
|
||||
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -43,13 +43,7 @@ export async function readManifest(outputDir: string): Promise<StoryManifest> {
|
|||
return { version: 1, files: {} }
|
||||
throw error
|
||||
}
|
||||
let data: unknown
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch {
|
||||
data = undefined
|
||||
}
|
||||
const result = v.safeParse(ManifestSchema, data)
|
||||
const result = v.safeParse(v.pipe(v.string(), v.parseJson(), ManifestSchema), text)
|
||||
if (!result.success)
|
||||
throw new Error(
|
||||
`${path} is not a valid OpenPencil stories manifest; remove it together with the stories it listed, then export again.`
|
||||
|
|
|
|||
|
|
@ -1,18 +1,24 @@
|
|||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
||||
import { dirname, resolve, sep } from 'node:path'
|
||||
|
||||
import * as v from 'valibot'
|
||||
|
||||
import type {
|
||||
ComponentLibraryRevision,
|
||||
LibraryCatalog,
|
||||
LibrarySummary,
|
||||
PublishLibraryInput,
|
||||
SerializedComponentLibraryRevision,
|
||||
StoredLibraryLatestManifest
|
||||
} from '@open-pencil/core/library'
|
||||
import {
|
||||
createLibraryRevision,
|
||||
deserializeLibraryRevision,
|
||||
serializeLibraryRevision
|
||||
LibrarySummarySchema,
|
||||
MAX_LIBRARY_REVISION_BYTES,
|
||||
SerializedLibraryRevisionSchema,
|
||||
serializeLibraryRevision,
|
||||
StoredLibraryLatestManifestSchema,
|
||||
validateLibraryRevision
|
||||
} from '@open-pencil/core/library'
|
||||
|
||||
const catalogPublicationQueues = new Map<string, Promise<void>>()
|
||||
|
|
@ -43,7 +49,7 @@ export class FileSystemLibraryCatalog implements LibraryCatalog {
|
|||
|
||||
async listLibraries(): Promise<LibrarySummary[]> {
|
||||
try {
|
||||
return await this.#readJSON<LibrarySummary[]>('libraries.json')
|
||||
return await this.#readJSON('libraries.json', v.array(LibrarySummarySchema))
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return []
|
||||
throw error
|
||||
|
|
@ -53,12 +59,22 @@ export class FileSystemLibraryCatalog implements LibraryCatalog {
|
|||
async getRevision(libraryId: string, revisionId?: string): Promise<ComponentLibraryRevision> {
|
||||
const resolvedRevisionId =
|
||||
revisionId ??
|
||||
(await this.#readJSON<StoredLibraryLatestManifest>(`${libraryId}/manifest.json`)).summary
|
||||
.latestRevisionId
|
||||
const data = await this.#readJSON<SerializedComponentLibraryRevision>(
|
||||
`${libraryId}/revisions/${resolvedRevisionId}.json`
|
||||
(await this.#readJSON(`${libraryId}/manifest.json`, StoredLibraryLatestManifestSchema))
|
||||
.summary.latestRevisionId
|
||||
const data = await this.#readJSON(
|
||||
`${libraryId}/revisions/${resolvedRevisionId}.json`,
|
||||
SerializedLibraryRevisionSchema,
|
||||
MAX_LIBRARY_REVISION_BYTES
|
||||
)
|
||||
return deserializeLibraryRevision(data)
|
||||
const revision = deserializeLibraryRevision(data)
|
||||
if (
|
||||
revision.manifest.libraryId !== libraryId ||
|
||||
revision.manifest.revisionId !== resolvedRevisionId
|
||||
) {
|
||||
throw new Error('Library revision identity mismatch')
|
||||
}
|
||||
await validateLibraryRevision(revision)
|
||||
return revision
|
||||
}
|
||||
|
||||
async publishRevision(input: PublishLibraryInput): Promise<ComponentLibraryRevision> {
|
||||
|
|
@ -107,8 +123,19 @@ export class FileSystemLibraryCatalog implements LibraryCatalog {
|
|||
return path
|
||||
}
|
||||
|
||||
async #readJSON<T>(relative: string): Promise<T> {
|
||||
return JSON.parse(await readFile(this.#path(relative), 'utf8')) as T
|
||||
async #readJSON<TSchema extends v.GenericSchema>(
|
||||
relative: string,
|
||||
schema: TSchema,
|
||||
maxBytes = Number.POSITIVE_INFINITY
|
||||
): Promise<v.InferOutput<TSchema>> {
|
||||
const bytes = await readFile(this.#path(relative))
|
||||
if (bytes.byteLength > maxBytes)
|
||||
throw new Error(`Library catalog file exceeds size limit: ${relative}`)
|
||||
const text = new TextDecoder().decode(bytes)
|
||||
const result = v.safeParse(v.pipe(v.string(), v.parseJson(), schema), text)
|
||||
if (!result.success)
|
||||
throw new Error(`Invalid library catalog file ${relative}: ${v.summarize(result.issues)}`)
|
||||
return result.output
|
||||
}
|
||||
|
||||
async #writeJSON(relative: string, value: unknown): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import type { AddressInfo } from 'node:net'
|
|||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
import * as v from 'valibot'
|
||||
|
||||
import { CLI_ENTRY } from '#cli-tests/helpers/paths'
|
||||
|
||||
setDefaultTimeout(60_000)
|
||||
|
|
@ -17,7 +19,13 @@ const TARGET = {
|
|||
pageName: 'Page 1'
|
||||
}
|
||||
|
||||
type Request = { command: string; args: Record<string, unknown> }
|
||||
const RequestJSON = v.pipe(
|
||||
v.string(),
|
||||
v.parseJson(),
|
||||
v.object({ command: v.string(), args: v.record(v.string(), v.unknown()) })
|
||||
)
|
||||
|
||||
type Request = v.InferOutput<typeof RequestJSON>
|
||||
|
||||
const requests: Request[] = []
|
||||
const responses = new Map<string, unknown>()
|
||||
|
|
@ -31,7 +39,7 @@ beforeAll(async () => {
|
|||
body += chunk.toString()
|
||||
})
|
||||
request.on('end', () => {
|
||||
const parsed = JSON.parse(body) as Request
|
||||
const parsed = v.parse(RequestJSON, body)
|
||||
requests.push(parsed)
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
response.end(
|
||||
|
|
|
|||
|
|
@ -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}')
|
||||
|
||||
|
|
|
|||
|
|
@ -6,13 +6,20 @@ import { tmpdir } from 'node:os'
|
|||
import { join } from 'node:path'
|
||||
|
||||
import { runCommand } from 'citty'
|
||||
import * as v from 'valibot'
|
||||
|
||||
import type { AutomationDocumentSummary } from '@open-pencil/core/rpc'
|
||||
import { removeDiscoveryFile, writeDiscoveryFile } from '@open-pencil/mcp/discovery'
|
||||
|
||||
import exportCommand from '#cli/commands/export'
|
||||
|
||||
type RPCRequest = { command: string; args: Record<string, unknown> }
|
||||
const RPCRequestJSON = v.pipe(
|
||||
v.string(),
|
||||
v.parseJson(),
|
||||
v.object({ command: v.string(), args: v.record(v.string(), v.unknown()) })
|
||||
)
|
||||
|
||||
type RPCRequest = v.InferOutput<typeof RPCRequestJSON>
|
||||
|
||||
const DOCUMENT: AutomationDocumentSummary = {
|
||||
id: 'tab-1',
|
||||
|
|
@ -46,7 +53,7 @@ beforeAll(async () => {
|
|||
const chunks: Buffer[] = []
|
||||
request.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||
request.on('end', () => {
|
||||
const body = JSON.parse(Buffer.concat(chunks).toString('utf-8')) as RPCRequest
|
||||
const body = v.parse(RPCRequestJSON, Buffer.concat(chunks).toString('utf-8'))
|
||||
requests.push(body)
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
response.end(JSON.stringify({ ok: true, result: reply(body.command) }))
|
||||
|
|
|
|||
|
|
@ -3,12 +3,18 @@ import { mkdtemp } from 'node:fs/promises'
|
|||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import * as v from 'valibot'
|
||||
|
||||
import { CLI_ENTRY, FIXTURES } from '#cli-tests/helpers/paths'
|
||||
|
||||
setDefaultTimeout(60_000)
|
||||
|
||||
const FIXTURE = join(FIXTURES, 'gold-preview.fig')
|
||||
|
||||
function parseJSON<T>(text: string, schema: v.GenericSchema<unknown, T>): T {
|
||||
return v.parse(v.pipe(v.string(), v.parseJson(), schema), text)
|
||||
}
|
||||
|
||||
async function cli(args: string[]) {
|
||||
const proc = Bun.spawn([process.execPath, CLI_ENTRY, ...args], { stdout: 'pipe', stderr: 'pipe' })
|
||||
const [stdout, stderr] = await Promise.all([
|
||||
|
|
@ -22,13 +28,19 @@ describe('tool CLI', () => {
|
|||
test('lists and describes tools with their effect and argument schema', async () => {
|
||||
const listed = await cli(['tool', 'list', '--json'])
|
||||
expect(listed.exitCode).toBe(0)
|
||||
const tools = JSON.parse(listed.stdout) as Array<{ name: string; effect: string }>
|
||||
const tools = parseJSON(
|
||||
listed.stdout,
|
||||
v.array(v.looseObject({ name: v.string(), effect: v.string() }))
|
||||
)
|
||||
expect(tools).toContainEqual(expect.objectContaining({ name: 'set_fill', effect: 'write' }))
|
||||
expect(tools).toContainEqual(expect.objectContaining({ name: 'list_pages', effect: 'read' }))
|
||||
|
||||
const described = await cli(['tool', 'describe', 'create_page', '--json'])
|
||||
expect(described.exitCode).toBe(0)
|
||||
const info = JSON.parse(described.stdout) as { schema: { properties: object } }
|
||||
const info = parseJSON(
|
||||
described.stdout,
|
||||
v.looseObject({ schema: v.looseObject({ properties: v.record(v.string(), v.unknown()) }) })
|
||||
)
|
||||
expect(info.schema.properties).toHaveProperty('name')
|
||||
})
|
||||
|
||||
|
|
@ -49,7 +61,10 @@ describe('tool CLI', () => {
|
|||
expect(JSON.parse(created.stdout)).toMatchObject({ name: 'From CLI' })
|
||||
|
||||
const pages = await cli(['tool', 'call', 'list_pages', outPath, '--json'])
|
||||
const result = JSON.parse(pages.stdout) as { pages: Array<{ name: string }> }
|
||||
const result = parseJSON(
|
||||
pages.stdout,
|
||||
v.looseObject({ pages: v.array(v.looseObject({ name: v.string() })) })
|
||||
)
|
||||
expect(result.pages.map((page) => page.name)).toContain('From CLI')
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { deflateSync, inflateSync } from 'fflate'
|
||||
import { fromUint8Array, isValid, toUint8Array } from 'js-base64'
|
||||
import * as v from 'valibot'
|
||||
|
||||
import {
|
||||
createInstanceOverrideState,
|
||||
|
|
@ -8,21 +9,97 @@ import {
|
|||
setInstanceOverride,
|
||||
type InstanceOverrideState,
|
||||
type GeometryPath,
|
||||
type NodeType,
|
||||
type SceneGraph,
|
||||
type SceneNode,
|
||||
type SerializedInstanceOverrideState
|
||||
type WindingRule
|
||||
} from '@open-pencil/scene-graph'
|
||||
import type { JSONObject } from '@open-pencil/scene-graph/primitives'
|
||||
|
||||
import type { ClipboardSnapshot } from '#core/editor/clipboard/copy'
|
||||
|
||||
interface SerializedClipboardNode extends JSONObject {
|
||||
overrides?: Record<string, unknown>
|
||||
instanceOverrides?: SerializedInstanceOverrideState
|
||||
textPicture?: string | Uint8Array
|
||||
children?: SerializedClipboardNode[]
|
||||
const NODE_TYPES: Record<NodeType, true> = {
|
||||
CANVAS: true,
|
||||
FRAME: true,
|
||||
RECTANGLE: true,
|
||||
ROUNDED_RECTANGLE: true,
|
||||
ELLIPSE: true,
|
||||
TEXT: true,
|
||||
LINE: true,
|
||||
STAR: true,
|
||||
POLYGON: true,
|
||||
VECTOR: true,
|
||||
BOOLEAN_OPERATION: true,
|
||||
GROUP: true,
|
||||
SECTION: true,
|
||||
COMPONENT: true,
|
||||
COMPONENT_SET: true,
|
||||
INSTANCE: true,
|
||||
CONNECTOR: true,
|
||||
SHAPE_WITH_TEXT: true
|
||||
}
|
||||
|
||||
interface SerializedGeometryPath {
|
||||
windingRule: WindingRule
|
||||
commandsBlob: Record<string, number>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** The node fields paste dereferences; the remaining SceneNode fields pass through. */
|
||||
interface SerializedClipboardNode {
|
||||
id: string
|
||||
type: NodeType
|
||||
x: number
|
||||
y: number
|
||||
overrides?: Record<string, unknown>
|
||||
instanceOverrides?: unknown
|
||||
textPicture?: string | null
|
||||
fillGeometry?: SerializedGeometryPath[]
|
||||
strokeGeometry?: SerializedGeometryPath[]
|
||||
children?: SerializedClipboardNode[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const finiteNumber = v.pipe(v.number(), v.finite())
|
||||
const byte = v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(255))
|
||||
|
||||
const SerializedGeometry = v.optional(
|
||||
v.array(
|
||||
v.looseObject({
|
||||
windingRule: v.picklist(['NONZERO', 'EVENODD']),
|
||||
commandsBlob: v.record(v.string(), byte)
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const SerializedClipboardNodeSchema: v.GenericSchema<unknown, SerializedClipboardNode> =
|
||||
v.looseObject({
|
||||
id: v.string(),
|
||||
type: v.custom<NodeType>(
|
||||
(value) => typeof value === 'string' && Object.hasOwn(NODE_TYPES, value)
|
||||
),
|
||||
// Older clipboard payloads may omit a position; paste offsets from the origin then.
|
||||
x: v.optional(finiteNumber, 0),
|
||||
y: v.optional(finiteNumber, 0),
|
||||
overrides: v.optional(v.record(v.string(), v.unknown())),
|
||||
instanceOverrides: v.optional(v.unknown()),
|
||||
textPicture: v.optional(v.nullable(v.string())),
|
||||
fillGeometry: SerializedGeometry,
|
||||
strokeGeometry: SerializedGeometry,
|
||||
children: v.optional(v.array(v.lazy(() => SerializedClipboardNodeSchema)))
|
||||
})
|
||||
|
||||
/** Clipboard HTML is writable by any application, so the payload is validated before paste. */
|
||||
const OpenPencilClipboardJSON = v.pipe(
|
||||
v.string(),
|
||||
v.parseJson(),
|
||||
v.object({
|
||||
format: v.literal('openpencil/v1'),
|
||||
nodes: v.array(SerializedClipboardNodeSchema),
|
||||
images: v.optional(v.record(v.string(), v.unknown()))
|
||||
})
|
||||
)
|
||||
|
||||
type ClipboardNode = SceneNode & { children?: ClipboardNode[] }
|
||||
|
||||
export type OpenPencilClipboardData = Pick<ClipboardSnapshot, 'nodes' | 'images'>
|
||||
|
|
@ -39,19 +116,17 @@ export function parseOpenPencilClipboard(html: string): OpenPencilClipboardData
|
|||
} catch {
|
||||
bytes = raw
|
||||
}
|
||||
const decoded = JSON.parse(new TextDecoder().decode(bytes))
|
||||
if (decoded.format === 'openpencil/v1' && Array.isArray(decoded.nodes)) {
|
||||
const nodes = restoreNodeData(decoded.nodes as SerializedClipboardNode[])
|
||||
const images = new Map<string, Uint8Array>()
|
||||
if (decoded.images && typeof decoded.images === 'object') {
|
||||
for (const [hash, b64] of Object.entries(decoded.images)) {
|
||||
if (typeof b64 === 'string') {
|
||||
images.set(hash, clipboardBytes(b64))
|
||||
}
|
||||
}
|
||||
}
|
||||
return { nodes, images }
|
||||
const decoded = v.safeParse(OpenPencilClipboardJSON, new TextDecoder().decode(bytes))
|
||||
if (!decoded.success) {
|
||||
console.warn('Ignoring malformed OpenPencil clipboard data:', v.summarize(decoded.issues))
|
||||
return null
|
||||
}
|
||||
const nodes = restoreNodeData(decoded.output.nodes)
|
||||
const images = new Map<string, Uint8Array>()
|
||||
for (const [hash, b64] of Object.entries(decoded.output.images ?? {})) {
|
||||
if (typeof b64 === 'string') images.set(hash, clipboardBytes(b64))
|
||||
}
|
||||
return { nodes, images }
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse OpenPencil clipboard data:', e)
|
||||
}
|
||||
|
|
@ -74,17 +149,11 @@ function legacyInstanceOverrides(
|
|||
return state
|
||||
}
|
||||
|
||||
function restoreGeometry(paths: unknown): GeometryPath[] {
|
||||
if (!Array.isArray(paths)) return []
|
||||
return paths.map(
|
||||
(path: GeometryPath & { commandsBlob: Uint8Array | Record<string, number> }) => ({
|
||||
...path,
|
||||
commandsBlob:
|
||||
path.commandsBlob instanceof Uint8Array
|
||||
? path.commandsBlob
|
||||
: Uint8Array.from(Object.values(path.commandsBlob))
|
||||
})
|
||||
)
|
||||
function restoreGeometry(paths: SerializedGeometryPath[] | undefined): GeometryPath[] {
|
||||
return (paths ?? []).map((path) => ({
|
||||
...path,
|
||||
commandsBlob: Uint8Array.from(Object.values(path.commandsBlob))
|
||||
}))
|
||||
}
|
||||
|
||||
/** Clipboard HTML comes from other applications, so its Base64 is checked before decoding. */
|
||||
|
|
@ -96,10 +165,9 @@ function clipboardBytes(value: string): Uint8Array {
|
|||
function restoreNodeData(nodes: SerializedClipboardNode[]): ClipboardNode[] {
|
||||
return nodes.map((node) => {
|
||||
const { children, instanceOverrides, overrides, textPicture, ...rest } = node
|
||||
const nodeId = typeof rest.id === 'string' ? rest.id : ''
|
||||
const overrideState = instanceOverrides
|
||||
? deserializeInstanceOverrideState(instanceOverrides)
|
||||
: legacyInstanceOverrides(nodeId, overrides)
|
||||
: legacyInstanceOverrides(rest.id, overrides)
|
||||
return {
|
||||
...rest,
|
||||
fillGeometry: restoreGeometry(rest.fillGeometry),
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
98
packages/core/src/library/schema.ts
Normal file
98
packages/core/src/library/schema.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import * as v from 'valibot'
|
||||
|
||||
import type { SceneNode, Variable, VariableCollection } from '@open-pencil/scene-graph'
|
||||
|
||||
import type { SerializedComponentLibraryRevision } from './serialization'
|
||||
import { COMPONENT_LIBRARY_SCHEMA_VERSION } from './types'
|
||||
import type { LibrarySummary, StoredLibraryLatestManifest } from './types'
|
||||
|
||||
const SerializedNodeShape = v.looseObject({
|
||||
id: v.string(),
|
||||
type: v.string(),
|
||||
parentId: v.nullable(v.string()),
|
||||
childIds: v.array(v.string()),
|
||||
// Asset keys fall back to `source.id`, which is null for nodes that never came from a file.
|
||||
source: v.looseObject({ id: v.nullable(v.string()) })
|
||||
})
|
||||
|
||||
const SerializedVariableShape = v.looseObject({
|
||||
id: v.string(),
|
||||
collectionId: v.string(),
|
||||
valuesByMode: v.record(v.string(), v.unknown())
|
||||
})
|
||||
|
||||
const SerializedCollectionShape = v.looseObject({
|
||||
id: v.string(),
|
||||
modes: v.array(v.unknown()),
|
||||
variableIds: v.array(v.string())
|
||||
})
|
||||
|
||||
/**
|
||||
* Library revisions come from shared catalogs. Nodes, variables and collections are checked for
|
||||
* the fields deserialization and integrity validation dereference; the remaining fields pass
|
||||
* through, since a full SceneNode schema would duplicate the Scene Graph types.
|
||||
*/
|
||||
const SerializedNode = v.custom<SceneNode>((value) => v.is(SerializedNodeShape, value))
|
||||
const SerializedVariable = v.custom<Variable>((value) => v.is(SerializedVariableShape, value))
|
||||
const SerializedCollection = v.custom<VariableCollection>((value) =>
|
||||
v.is(SerializedCollectionShape, value)
|
||||
)
|
||||
|
||||
/** Image bytes: a Uint8Array, or the index-keyed object `JSON.stringify` writes for one. */
|
||||
const SerializedBytes = v.union([
|
||||
v.instance(Uint8Array),
|
||||
v.pipe(
|
||||
v.record(v.string(), v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(255))),
|
||||
// A gap or a stray key would silently shift the bytes; asset hashes do not cover images.
|
||||
v.check(
|
||||
(bytes) => Object.keys(bytes).every((key, index) => key === String(index)),
|
||||
'Expected byte indexes 0 to length - 1'
|
||||
),
|
||||
v.transform((bytes) => Uint8Array.from(Object.values(bytes)))
|
||||
)
|
||||
])
|
||||
|
||||
const LibraryAssetDescriptor = v.object({
|
||||
key: v.string(),
|
||||
type: v.picklist(['COMPONENT', 'COMPONENT_SET']),
|
||||
name: v.string(),
|
||||
description: v.string(),
|
||||
sourceNodeId: v.string(),
|
||||
contentHash: v.string(),
|
||||
thumbnail: v.optional(SerializedBytes)
|
||||
})
|
||||
|
||||
export const SerializedLibraryRevisionSchema = v.object({
|
||||
manifest: v.object({
|
||||
schemaVersion: v.literal(COMPONENT_LIBRARY_SCHEMA_VERSION),
|
||||
libraryId: v.string(),
|
||||
name: v.string(),
|
||||
revisionId: v.string(),
|
||||
previousRevisionId: v.nullable(v.string()),
|
||||
publishedAt: v.string(),
|
||||
description: v.string(),
|
||||
assets: v.array(LibraryAssetDescriptor)
|
||||
}),
|
||||
graph: v.object({
|
||||
rootId: v.string(),
|
||||
nodes: v.array(v.tuple([v.string(), SerializedNode])),
|
||||
images: v.array(v.tuple([v.string(), SerializedBytes])),
|
||||
variables: v.array(v.tuple([v.string(), SerializedVariable])),
|
||||
variableCollections: v.array(v.tuple([v.string(), SerializedCollection])),
|
||||
activeMode: v.array(v.tuple([v.string(), v.string()])),
|
||||
documentColorSpace: v.picklist(['srgb', 'display-p3'])
|
||||
})
|
||||
}) satisfies v.GenericSchema<unknown, SerializedComponentLibraryRevision>
|
||||
|
||||
export const LibrarySummarySchema = v.object({
|
||||
libraryId: v.string(),
|
||||
name: v.string(),
|
||||
latestRevisionId: v.string(),
|
||||
publishedAt: v.string(),
|
||||
assetCount: v.pipe(v.number(), v.safeInteger(), v.minValue(0))
|
||||
}) satisfies v.GenericSchema<unknown, LibrarySummary>
|
||||
|
||||
export const StoredLibraryLatestManifestSchema = v.object({
|
||||
schemaVersion: v.literal(COMPONENT_LIBRARY_SCHEMA_VERSION),
|
||||
summary: LibrarySummarySchema
|
||||
}) satisfies v.GenericSchema<unknown, StoredLibraryLatestManifest>
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import * as v from 'valibot'
|
||||
|
||||
import type { SceneGraph } from '@open-pencil/scene-graph'
|
||||
|
||||
export const SOURCE_LIBRARY_PUBLICATION_PLUGIN_KEY = 'sourceLibraryPublication'
|
||||
|
|
@ -10,16 +12,16 @@ export interface SourceLibraryPublication {
|
|||
catalogSource?: string
|
||||
}
|
||||
|
||||
function isPublication(value: unknown): value is SourceLibraryPublication {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
|
||||
const publication = value as Partial<SourceLibraryPublication>
|
||||
return (
|
||||
typeof publication.libraryId === 'string' &&
|
||||
typeof publication.revisionId === 'string' &&
|
||||
typeof publication.name === 'string' &&
|
||||
(publication.catalogSource === undefined || typeof publication.catalogSource === 'string')
|
||||
)
|
||||
}
|
||||
const SourceLibraryPublicationJSON = v.pipe(
|
||||
v.string(),
|
||||
v.parseJson(),
|
||||
v.object({
|
||||
libraryId: v.string(),
|
||||
revisionId: v.string(),
|
||||
name: v.string(),
|
||||
catalogSource: v.optional(v.string())
|
||||
})
|
||||
) satisfies v.GenericSchema<string, SourceLibraryPublication>
|
||||
|
||||
export function readSourceLibraryPublication(graph: SceneGraph): SourceLibraryPublication | null {
|
||||
const root = graph.getNode(graph.rootId)
|
||||
|
|
@ -28,12 +30,8 @@ export function readSourceLibraryPublication(graph: SceneGraph): SourceLibraryPu
|
|||
item.pluginId === OPEN_PENCIL_PLUGIN_ID && item.key === SOURCE_LIBRARY_PUBLICATION_PLUGIN_KEY
|
||||
)
|
||||
if (!entry) return null
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(entry.value)
|
||||
return isPublication(parsed) ? parsed : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const parsed = v.safeParse(SourceLibraryPublicationJSON, entry.value)
|
||||
return parsed.success ? parsed.output : null
|
||||
}
|
||||
|
||||
export function writeSourceLibraryPublication(
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
66
packages/core/src/tools/vector/network.ts
Normal file
66
packages/core/src/tools/vector/network.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import * as v from 'valibot'
|
||||
|
||||
import { validateVectorNetwork } from '@open-pencil/scene-graph'
|
||||
import type { VectorNetwork } from '@open-pencil/scene-graph'
|
||||
|
||||
const finiteNumber = v.pipe(v.number(), v.finite())
|
||||
const zeroVector = () => ({ x: 0, y: 0 })
|
||||
const point = v.object({ x: finiteNumber, y: finiteNumber })
|
||||
|
||||
const VectorNetworkShape = v.object({
|
||||
vertices: v.array(
|
||||
v.object({
|
||||
x: finiteNumber,
|
||||
y: finiteNumber,
|
||||
strokeCap: v.optional(v.string()),
|
||||
strokeJoin: v.optional(v.string()),
|
||||
cornerRadius: v.optional(finiteNumber),
|
||||
handleMirroring: v.optional(v.picklist(['NONE', 'ANGLE', 'ANGLE_AND_LENGTH']))
|
||||
})
|
||||
),
|
||||
segments: v.array(
|
||||
v.object({
|
||||
start: v.pipe(v.number(), v.integer()),
|
||||
end: v.pipe(v.number(), v.integer()),
|
||||
tangentStart: v.optional(point, zeroVector),
|
||||
tangentEnd: v.optional(point, zeroVector)
|
||||
})
|
||||
),
|
||||
regions: v.optional(
|
||||
v.array(
|
||||
v.object({
|
||||
windingRule: v.picklist(['NONZERO', 'EVENODD']),
|
||||
loops: v.array(v.array(v.pipe(v.number(), v.integer())))
|
||||
})
|
||||
),
|
||||
() => []
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* VectorNetwork JSON from a tool argument. Index ranges and region chains are checked by
|
||||
* `validateVectorNetwork`; the shape schema then types the value and fills missing tangents
|
||||
* and regions.
|
||||
*/
|
||||
const VectorNetworkJSON = v.pipe(
|
||||
v.string(),
|
||||
v.parseJson(),
|
||||
v.rawCheck<unknown>(({ dataset, addIssue }) => {
|
||||
if (!dataset.typed) return
|
||||
for (const message of validateVectorNetwork(dataset.value)) addIssue({ message })
|
||||
}),
|
||||
VectorNetworkShape
|
||||
)
|
||||
|
||||
export type VectorNetworkJSONResult = { network: VectorNetwork } | { error: string }
|
||||
|
||||
/** Parse and validate VectorNetwork JSON, returning a tool error instead of throwing. */
|
||||
export function parseVectorNetworkJSON(text: string): VectorNetworkJSONResult {
|
||||
const result = v.safeParse(VectorNetworkJSON, text)
|
||||
if (result.success) return { network: result.output }
|
||||
if (result.issues.some((issue) => issue.type === 'parse_json'))
|
||||
return { error: 'Invalid VectorNetwork JSON' }
|
||||
return {
|
||||
error: `Invalid VectorNetwork: ${result.issues.map((issue) => issue.message).join('; ')}`
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -10,11 +10,11 @@ import { rescaleNodeTree } from '@open-pencil/scene-graph'
|
|||
|
||||
import { expectDefined } from '../helpers/assert'
|
||||
import { inheritedNestedBindingRecords } from '../helpers/fig/nested-binding'
|
||||
import { FIXTURES, readFixtureJSON } from '../helpers/fig/fixtures'
|
||||
import { FIXTURES, readFixtureObject } from '../helpers/fig/fixtures'
|
||||
|
||||
const fixture = readFixtureJSON<Record<string, unknown>[]>('nested-layout-scale.json')
|
||||
const fixture = readFixtureObject('nested-layout-scale.json')
|
||||
|
||||
const declared = readFixtureJSON<Record<string, unknown>[]>('nested-binding-ownership-records.json')
|
||||
const declared = readFixtureObject('nested-binding-ownership-records.json')
|
||||
|
||||
for (const edit of [
|
||||
'direct',
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ import { expect, test } from 'bun:test'
|
|||
import { SceneGraph } from '@open-pencil/scene-graph'
|
||||
|
||||
import { prepareClipboardImport } from '#core/clipboard/fig-import'
|
||||
import { readFixtureJSON } from '#core-tests/helpers/fig/fixtures'
|
||||
import { readFixtureObject } from '#core-tests/helpers/fig/fixtures'
|
||||
|
||||
const fixture = readFixtureJSON<Record<string, unknown>[]>('nested-binding-ownership-records.json')
|
||||
const fixture = readFixtureObject('nested-binding-ownership-records.json')
|
||||
|
||||
|
||||
test('replacement clipboard preparation imports real nested component bindings', () => {
|
||||
|
|
|
|||
75
packages/core/tests/clipboard/openpencil.test.ts
Normal file
75
packages/core/tests/clipboard/openpencil.test.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { deflateSync } from 'fflate'
|
||||
import { fromUint8Array } from 'js-base64'
|
||||
|
||||
import { buildOpenPencilClipboardHTML, parseOpenPencilClipboard } from '@open-pencil/core/clipboard'
|
||||
import { SceneGraph } from '@open-pencil/scene-graph'
|
||||
|
||||
function clipboardHTML(payload: string): string {
|
||||
const compressed = deflateSync(new TextEncoder().encode(payload))
|
||||
return `<!--(openpencil)${fromUint8Array(compressed)}(/openpencil)-->`
|
||||
}
|
||||
|
||||
function copiedNode() {
|
||||
const graph = new SceneGraph()
|
||||
const node = graph.createNode('RECTANGLE', graph.getPages()[0].id, { name: 'Card', x: 5, y: 6 })
|
||||
return { graph, node }
|
||||
}
|
||||
|
||||
describe('OpenPencil clipboard parsing', () => {
|
||||
test('restores nodes copied by OpenPencil', () => {
|
||||
const { graph, node } = copiedNode()
|
||||
const parsed = parseOpenPencilClipboard(buildOpenPencilClipboardHTML([node], graph))
|
||||
expect(parsed?.nodes).toHaveLength(1)
|
||||
expect(parsed?.nodes[0]).toMatchObject({ id: node.id, type: 'RECTANGLE', name: 'Card', x: 5 })
|
||||
})
|
||||
|
||||
test.each([
|
||||
['malformed JSON', '{"format": "openpencil/v1", "nodes": ['],
|
||||
['an unknown format', JSON.stringify({ format: 'openpencil/v2', nodes: [] })],
|
||||
['non-array nodes', JSON.stringify({ format: 'openpencil/v1', nodes: {} })],
|
||||
[
|
||||
'a node with a string coordinate',
|
||||
JSON.stringify({
|
||||
format: 'openpencil/v1',
|
||||
nodes: [{ id: '0:1', type: 'RECTANGLE', x: '5', y: 6 }]
|
||||
})
|
||||
],
|
||||
[
|
||||
'a node with an unknown type',
|
||||
JSON.stringify({ format: 'openpencil/v1', nodes: [{ id: '0:1', type: 'WIDGET', x: 5, y: 6 }] })
|
||||
],
|
||||
[
|
||||
'a malformed child',
|
||||
JSON.stringify({
|
||||
format: 'openpencil/v1',
|
||||
nodes: [{ id: '0:1', type: 'FRAME', x: 0, y: 0, children: [{ id: 7 }] }]
|
||||
})
|
||||
],
|
||||
[
|
||||
'malformed geometry',
|
||||
JSON.stringify({
|
||||
format: 'openpencil/v1',
|
||||
nodes: [{ id: '0:1', type: 'VECTOR', x: 0, y: 0, fillGeometry: [null] }]
|
||||
})
|
||||
],
|
||||
[
|
||||
'geometry bytes outside 0-255',
|
||||
JSON.stringify({
|
||||
format: 'openpencil/v1',
|
||||
nodes: [
|
||||
{
|
||||
id: '0:1',
|
||||
type: 'VECTOR',
|
||||
x: 0,
|
||||
y: 0,
|
||||
fillGeometry: [{ windingRule: 'NONZERO', commandsBlob: { 0: 1, 1: 300 } }]
|
||||
}
|
||||
]
|
||||
})
|
||||
]
|
||||
])('ignores %s', (_label, payload) => {
|
||||
expect(parseOpenPencilClipboard(clipboardHTML(payload))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -7,9 +7,9 @@ import { materializeFigFragment } from '@open-pencil/fig'
|
|||
import { CommittedGraphEventError } from '@open-pencil/scene-graph'
|
||||
|
||||
import { expectDefined } from '#core-tests/helpers/assert'
|
||||
import { readFixtureJSON } from '#core-tests/helpers/fig/fixtures'
|
||||
import { readFixtureObject } from '#core-tests/helpers/fig/fixtures'
|
||||
|
||||
const fixture = readFixtureJSON<Record<string, unknown>[]>('nested-binding-ownership-records.json')
|
||||
const fixture = readFixtureObject('nested-binding-ownership-records.json')
|
||||
|
||||
for (const failObserver of [false, true]) {
|
||||
for (const replaceSelection of [false, true]) {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
type SceneGraph,
|
||||
type SceneNode
|
||||
} from '@open-pencil/core'
|
||||
import * as v from 'valibot'
|
||||
|
||||
import { collectAllNodes } from './traversal'
|
||||
|
||||
|
|
@ -33,9 +34,15 @@ export const VALID_NODE_TYPES = new Set<string>([
|
|||
'SHAPE_WITH_TEXT'
|
||||
])
|
||||
|
||||
/** Read a shared JSON fixture as data; a module import would escape the package root. */
|
||||
export function readFixtureJSON<T>(name: string): T {
|
||||
return JSON.parse(readFileSync(resolve(FIXTURES, name), 'utf8')) as T
|
||||
const FixtureObjectJSON = v.pipe(
|
||||
v.string(),
|
||||
v.parseJson(),
|
||||
v.record(v.string(), v.unknown())
|
||||
)
|
||||
|
||||
/** Read a shared JSON fixture object; a module import would escape the package root. */
|
||||
export function readFixtureObject(name: string): Record<string, unknown> {
|
||||
return v.parse(FixtureObjectJSON, readFileSync(resolve(FIXTURES, name), 'utf8'))
|
||||
}
|
||||
|
||||
export function readFixtureBytes(name: string): Uint8Array {
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ import type { SymbolData } from '@open-pencil/fig/instance-overrides'
|
|||
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
|
||||
|
||||
import { expectDefined } from '../assert'
|
||||
import { readFixtureJSON } from './fixtures'
|
||||
import { readFixtureObject } from './fixtures'
|
||||
|
||||
const fixture = readFixtureJSON<Record<string, unknown>[]>('nested-binding-ownership-records.json')
|
||||
const fixture = readFixtureObject('nested-binding-ownership-records.json')
|
||||
|
||||
/** Remove only the placed owner's declaration, retaining the saved inherited expression. */
|
||||
export function inheritedNestedBindingRecords(): NodeChange[] {
|
||||
|
|
|
|||
|
|
@ -10,11 +10,11 @@ import { cloneInstanceOverrideState } from '@open-pencil/scene-graph'
|
|||
|
||||
import { expectDefined } from '#core-tests/helpers/assert'
|
||||
import { inheritedNestedBindingRecords } from '#core-tests/helpers/fig/nested-binding'
|
||||
import { readFixtureJSON } from '#core-tests/helpers/fig/fixtures'
|
||||
import { readFixtureObject } from '#core-tests/helpers/fig/fixtures'
|
||||
|
||||
const oracle = readFixtureJSON<Record<string, unknown>[]>('nested-binding-authoring.json')
|
||||
const oracle = readFixtureObject('nested-binding-authoring.json')
|
||||
|
||||
const fixture = readFixtureJSON<Record<string, unknown>[]>('nested-binding-ownership-records.json')
|
||||
const fixture = readFixtureObject('nested-binding-ownership-records.json')
|
||||
|
||||
function document(inherited = false) {
|
||||
const changes = inherited
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ import { exportFigFile } from '@open-pencil/core/io'
|
|||
import { initCodec } from '@open-pencil/core/kiwi'
|
||||
import { materializeDocument, parseFigBuffer } from '@open-pencil/fig'
|
||||
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
|
||||
import { readFixtureJSON } from '#core-tests/helpers/fig/fixtures'
|
||||
import { readFixtureObject } from '#core-tests/helpers/fig/fixtures'
|
||||
|
||||
const fixture = readFixtureJSON<Record<string, unknown>[]>('nested-binding-ownership-records.json')
|
||||
const fixture = readFixtureObject('nested-binding-ownership-records.json')
|
||||
|
||||
|
||||
test('binding patches keep unrelated inherited fields in their original units', () => {
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ import { materializeDocument, parseFigBuffer } from '@open-pencil/fig'
|
|||
|
||||
import { expectDefined } from '#core-tests/helpers/assert'
|
||||
import { inheritedNestedBindingRecords } from '#core-tests/helpers/fig/nested-binding'
|
||||
import { readFixtureJSON } from '#core-tests/helpers/fig/fixtures'
|
||||
import { readFixtureObject } from '#core-tests/helpers/fig/fixtures'
|
||||
|
||||
const fixture = readFixtureJSON<Record<string, unknown>[]>('nested-binding-ownership-records.json')
|
||||
const fixture = readFixtureObject('nested-binding-ownership-records.json')
|
||||
|
||||
test('a placed binding does not freeze an unrelated inherited binding', async () => {
|
||||
const { graph, sources } = materializeDocument(
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ import { materializeDocument, parseFigBuffer } from '@open-pencil/fig'
|
|||
import type { SymbolData } from '@open-pencil/fig/instance-overrides'
|
||||
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
|
||||
import { hasInstanceOverride } from '@open-pencil/scene-graph'
|
||||
import { readFixtureJSON } from '#core-tests/helpers/fig/fixtures'
|
||||
import { readFixtureObject } from '#core-tests/helpers/fig/fixtures'
|
||||
|
||||
const fixture = readFixtureJSON<Record<string, unknown>[]>('nested-layout-scale.json')
|
||||
const fixture = readFixtureObject('nested-layout-scale.json')
|
||||
|
||||
|
||||
function assembly(phase: 'before' | 'edited') {
|
||||
|
|
|
|||
|
|
@ -10,9 +10,9 @@ import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
|
|||
import { setInstanceOverride } from '@open-pencil/scene-graph'
|
||||
|
||||
import { expectDefined } from '#core-tests/helpers/assert'
|
||||
import { readFixtureJSON } from '#core-tests/helpers/fig/fixtures'
|
||||
import { readFixtureObject } from '#core-tests/helpers/fig/fixtures'
|
||||
|
||||
const fixture = readFixtureJSON<Record<string, unknown>[]>('nested-binding-ownership-records.json')
|
||||
const fixture = readFixtureObject('nested-binding-ownership-records.json')
|
||||
|
||||
for (const scale of [0.5, 2]) {
|
||||
for (const literalClaim of [false, true]) {
|
||||
|
|
|
|||
63
packages/core/tests/tools/vector/path.test.ts
Normal file
63
packages/core/tests/tools/vector/path.test.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { FigmaAPI } from '@open-pencil/core/figma-api'
|
||||
import { ALL_TOOLS } from '@open-pencil/core/tools'
|
||||
import { SceneGraph } from '@open-pencil/scene-graph'
|
||||
|
||||
const pathSet = ALL_TOOLS.find((tool) => tool.name === 'path_set')
|
||||
if (!pathSet) throw new Error('path_set tool missing')
|
||||
|
||||
function setup() {
|
||||
const graph = new SceneGraph()
|
||||
const figma = new FigmaAPI(graph)
|
||||
const node = graph.createNode('VECTOR', graph.getPages()[0].id, { name: 'Path' })
|
||||
return { graph, figma, id: node.id }
|
||||
}
|
||||
|
||||
describe('path_set', () => {
|
||||
test('writes a normalized vector network', () => {
|
||||
const { graph, figma, id } = setup()
|
||||
const path = JSON.stringify({
|
||||
vertices: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 10, y: 0 }
|
||||
],
|
||||
segments: [{ start: 0, end: 1 }]
|
||||
})
|
||||
expect(pathSet.execute(figma, { id, path })).toEqual({ id })
|
||||
expect(graph.getNode(id)?.vectorNetwork).toEqual({
|
||||
vertices: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 10, y: 0 }
|
||||
],
|
||||
segments: [{ start: 0, end: 1, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }],
|
||||
regions: []
|
||||
})
|
||||
})
|
||||
|
||||
test.each([
|
||||
['malformed JSON', '{"vertices": [', 'Invalid VectorNetwork JSON'],
|
||||
['a non-object', '42', 'Invalid VectorNetwork: network must be an object'],
|
||||
[
|
||||
'an out-of-range segment',
|
||||
JSON.stringify({ vertices: [{ x: 0, y: 0 }], segments: [{ start: 0, end: 4 }] }),
|
||||
'Invalid VectorNetwork: segment[0]: end index 4 out of range'
|
||||
],
|
||||
[
|
||||
'a malformed tangent',
|
||||
JSON.stringify({
|
||||
vertices: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 1 }
|
||||
],
|
||||
segments: [{ start: 0, end: 1, tangentStart: { x: 'a', y: 0 } }]
|
||||
}),
|
||||
'Invalid VectorNetwork: segment[0]: tangentStart must contain finite x and y numbers'
|
||||
]
|
||||
])('rejects %s without touching the node', (_label, path, error) => {
|
||||
const { graph, figma, id } = setup()
|
||||
const before = graph.getNode(id)?.vectorNetwork
|
||||
expect(pathSet.execute(figma, { id, path })).toEqual({ error })
|
||||
expect(graph.getNode(id)?.vectorNetwork).toEqual(before)
|
||||
})
|
||||
})
|
||||
|
|
@ -40,13 +40,17 @@ export interface SdkComponentMeta {
|
|||
exposed: SdkComponentExposeMeta[]
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
function findWorkspaceRoot(start: string): string {
|
||||
let directory = start
|
||||
while (true) {
|
||||
const packagePath = join(directory, 'package.json')
|
||||
if (existsSync(packagePath)) {
|
||||
const packageJSON = JSON.parse(readFileSync(packagePath, 'utf8')) as { workspaces?: unknown }
|
||||
if (Array.isArray(packageJSON.workspaces)) return directory
|
||||
const packageJSON: unknown = JSON.parse(readFileSync(packagePath, 'utf8'))
|
||||
if (isObject(packageJSON) && Array.isArray(packageJSON.workspaces)) return directory
|
||||
}
|
||||
const parent = dirname(directory)
|
||||
if (parent === directory) throw new Error('Unable to locate workspace root')
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { inflateSync } from 'fflate'
|
||||
import { decode as decodeText, toUint8Array, isValid } from 'js-base64'
|
||||
import * as v from 'valibot'
|
||||
|
||||
import type { NodeChange as KiwiNodeChange } from '@open-pencil/kiwi/fig/codec'
|
||||
import { decodeBinarySchema, compileSchema, ByteBuffer } from '@open-pencil/kiwi/schema-runtime'
|
||||
|
|
@ -7,11 +8,13 @@ import { decodeBinarySchema, compileSchema, ByteBuffer } from '@open-pencil/kiwi
|
|||
import { parseFigKiwiChunks, decompressFigKiwiDataAsync } from '../node-change'
|
||||
import { isFigClipboardVisualType } from '../node-classification'
|
||||
|
||||
interface FigmaClipboardMeta {
|
||||
fileKey: string
|
||||
pasteID: number
|
||||
dataType: string
|
||||
}
|
||||
const FigmaClipboardMetaJSON = v.pipe(
|
||||
v.string(),
|
||||
v.parseJson(),
|
||||
v.object({ fileKey: v.string(), pasteID: v.number(), dataType: v.string() })
|
||||
)
|
||||
|
||||
type FigmaClipboardMeta = v.InferOutput<typeof FigmaClipboardMetaJSON>
|
||||
|
||||
export async function parseFigmaClipboard(
|
||||
html: string
|
||||
|
|
@ -22,7 +25,9 @@ export async function parseFigmaClipboard(
|
|||
|
||||
// Clipboard HTML comes from other applications, so its Base64 is checked first.
|
||||
if (!isValid(metaMatch[1]) || !isValid(bufMatch[1])) throw new TypeError('Invalid Base64 string')
|
||||
const meta: FigmaClipboardMeta = JSON.parse(decodeText(metaMatch[1]))
|
||||
const parsedMeta = v.safeParse(FigmaClipboardMetaJSON, decodeText(metaMatch[1]))
|
||||
if (!parsedMeta.success) return null
|
||||
const meta = parsedMeta.output
|
||||
const binary = toUint8Array(bufMatch[1])
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { omit } from 'es-toolkit/object'
|
||||
import * as v from 'valibot'
|
||||
|
||||
import type { NodeChange, PluginData, PluginRelaunchData } from '@open-pencil/kiwi/fig/codec'
|
||||
import { guidToString } from '@open-pencil/kiwi/fig/guid'
|
||||
|
|
@ -31,6 +32,65 @@ export const TEXT_PATH_BOX_PLUGIN_KEY = 'textPathBox'
|
|||
export const LIBRARY_SOURCE_PLUGIN_KEY = 'librarySource'
|
||||
export const ENABLED_LIBRARIES_PLUGIN_KEY = 'enabledLibraries'
|
||||
|
||||
const TextPathBoxJSON = v.pipe(
|
||||
v.string(),
|
||||
v.parseJson(),
|
||||
v.object({ x: v.number(), y: v.number(), width: v.number(), height: v.number() }),
|
||||
v.check(
|
||||
({ x, y, width, height }) => Number.isFinite(x + y + width + height) && width > 0 && height > 0
|
||||
)
|
||||
)
|
||||
|
||||
/** String-valued entries of a JSON object; other entries are dropped, not rejected. */
|
||||
const BoundVariablesJSON = v.pipe(
|
||||
v.string(),
|
||||
v.parseJson(),
|
||||
v.check((value) => !Array.isArray(value)),
|
||||
v.record(v.string(), v.unknown()),
|
||||
v.transform((value) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(value).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === 'string'
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
/** Every entry must be valid, or the plugin value is ignored in favour of native settings. */
|
||||
const ExportSettingsJSON = v.pipe(
|
||||
v.string(),
|
||||
v.parseJson(),
|
||||
v.array(
|
||||
v.pipe(
|
||||
v.object({
|
||||
scale: v.pipe(v.number(), v.finite()),
|
||||
format: v.custom<ExportFormatId>(isExportFormatId)
|
||||
}),
|
||||
// Clamp at the file-format boundary: imported plugin data may carry an
|
||||
// out-of-range scale the UI would never produce.
|
||||
v.transform(({ scale, format }): ExportSetting => ({
|
||||
scale: clampExportScale(scale),
|
||||
format
|
||||
}))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
const LibrarySourceJSON = v.pipe(
|
||||
v.string(),
|
||||
v.parseJson(),
|
||||
v.object({
|
||||
identity: v.object({ libraryId: v.string(), assetKey: v.string(), revisionId: v.string() }),
|
||||
sourceNodeId: v.optional(v.unknown()),
|
||||
readOnly: v.optional(v.unknown())
|
||||
}),
|
||||
v.transform(({ identity, sourceNodeId, readOnly }): NonNullable<SceneNode['librarySource']> => ({
|
||||
identity,
|
||||
sourceNodeId: typeof sourceNodeId === 'string' ? sourceNodeId : null,
|
||||
readOnly: readOnly === true
|
||||
}))
|
||||
)
|
||||
|
||||
const NATIVE_EXPORT_FORMATS: Record<string, ExportFormatId> = {
|
||||
PNG: 'png',
|
||||
JPEG: 'jpg',
|
||||
|
|
@ -85,25 +145,11 @@ export function applyTextPathBoxPluginData(node: {
|
|||
}
|
||||
|
||||
export function extractTextPathBox(nc: NodeChange): Rect | null {
|
||||
const value = getOpenPencilPluginValue(nc, TEXT_PATH_BOX_PLUGIN_KEY)
|
||||
if (!value) return null
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Partial<Rect> | null
|
||||
if (!parsed || typeof parsed !== 'object') return null
|
||||
const { x, y, width, height } = parsed
|
||||
if (
|
||||
typeof x !== 'number' ||
|
||||
typeof y !== 'number' ||
|
||||
typeof width !== 'number' ||
|
||||
typeof height !== 'number'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
if (!Number.isFinite(x + y + width + height) || width <= 0 || height <= 0) return null
|
||||
return { x, y, width, height }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const parsed = v.safeParse(
|
||||
TextPathBoxJSON,
|
||||
getOpenPencilPluginValue(nc, TEXT_PATH_BOX_PLUGIN_KEY)
|
||||
)
|
||||
return parsed.success ? parsed.output : null
|
||||
}
|
||||
|
||||
function hasOpenPencilExportSettingsPluginData(pluginData: PluginDataEntry[]): boolean {
|
||||
|
|
@ -112,26 +158,12 @@ function hasOpenPencilExportSettingsPluginData(pluginData: PluginDataEntry[]): b
|
|||
)
|
||||
}
|
||||
|
||||
function parseBoundVariablesPluginValue(value: string | null): Record<string, string> {
|
||||
if (!value) return {}
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
|
||||
return Object.fromEntries(
|
||||
Object.entries(parsed).filter(
|
||||
(entry): entry is [string, string] =>
|
||||
typeof entry[0] === 'string' && typeof entry[1] === 'string'
|
||||
)
|
||||
)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function extractBoundVariables(nc: NodeChange): Record<string, string> {
|
||||
let bindings = parseBoundVariablesPluginValue(
|
||||
const stored = v.safeParse(
|
||||
BoundVariablesJSON,
|
||||
getOpenPencilPluginValue(nc, BOUND_VARIABLES_PLUGIN_KEY)
|
||||
)
|
||||
let bindings: Record<string, string> = stored.success ? stored.output : {}
|
||||
for (const entry of variableConsumptionEntries(nc)) {
|
||||
const binding = resolveVariableConsumptionEntry(entry)
|
||||
if (binding) bindings[binding.field] = binding.variableId
|
||||
|
|
@ -151,28 +183,6 @@ export function extractBoundVariables(nc: NodeChange): Record<string, string> {
|
|||
return bindings
|
||||
}
|
||||
|
||||
function parseExportSettingsPluginValue(value: string | null): ExportSetting[] | null {
|
||||
if (!value) return null
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown
|
||||
if (!Array.isArray(parsed)) return null
|
||||
const settings = parsed.flatMap((entry): ExportSetting[] => {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return []
|
||||
const scale = (entry as { scale?: unknown }).scale
|
||||
const format = (entry as { format?: unknown }).format
|
||||
if (typeof scale !== 'number' || !Number.isFinite(scale) || !isExportFormatId(format)) {
|
||||
return []
|
||||
}
|
||||
// Clamp at the file-format boundary: imported plugin data may carry an
|
||||
// out-of-range scale the UI would never produce.
|
||||
return [{ scale: clampExportScale(scale), format }]
|
||||
})
|
||||
return settings.length === parsed.length ? settings : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function mapNativeImageType(imageType: unknown): ExportFormatId | null {
|
||||
if (typeof imageType === 'string') return NATIVE_EXPORT_FORMATS[imageType] ?? null
|
||||
if (imageType === 0) return 'png'
|
||||
|
|
@ -192,10 +202,11 @@ function extractNativeConstraintScale(constraint: unknown): number {
|
|||
}
|
||||
|
||||
export function extractExportSettings(nc: NodeChange): ExportSetting[] {
|
||||
const pluginSettings = parseExportSettingsPluginValue(
|
||||
const pluginSettings = v.safeParse(
|
||||
ExportSettingsJSON,
|
||||
getOpenPencilPluginValue(nc, EXPORT_SETTINGS_PLUGIN_KEY)
|
||||
)
|
||||
if (pluginSettings) return pluginSettings
|
||||
if (pluginSettings.success) return pluginSettings.output
|
||||
|
||||
return (nc.exportSettings ?? []).flatMap((entry): ExportSetting[] => {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return []
|
||||
|
|
@ -219,35 +230,11 @@ export function extractPluginData(nc: NodeChange): PluginDataEntry[] {
|
|||
}
|
||||
|
||||
export function extractLibrarySource(nc: NodeChange): SceneNode['librarySource'] {
|
||||
const value = getOpenPencilPluginValue(nc, LIBRARY_SOURCE_PLUGIN_KEY)
|
||||
if (!value) return null
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
|
||||
const source = parsed as {
|
||||
identity?: { libraryId?: unknown; assetKey?: unknown; revisionId?: unknown }
|
||||
sourceNodeId?: unknown
|
||||
readOnly?: unknown
|
||||
}
|
||||
if (
|
||||
typeof source.identity?.libraryId !== 'string' ||
|
||||
typeof source.identity.assetKey !== 'string' ||
|
||||
typeof source.identity.revisionId !== 'string'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
identity: {
|
||||
libraryId: source.identity.libraryId,
|
||||
assetKey: source.identity.assetKey,
|
||||
revisionId: source.identity.revisionId
|
||||
},
|
||||
sourceNodeId: typeof source.sourceNodeId === 'string' ? source.sourceNodeId : null,
|
||||
readOnly: source.readOnly === true
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const parsed = v.safeParse(
|
||||
LibrarySourceJSON,
|
||||
getOpenPencilPluginValue(nc, LIBRARY_SOURCE_PLUGIN_KEY)
|
||||
)
|
||||
return parsed.success ? parsed.output : null
|
||||
}
|
||||
|
||||
export function applyLibrarySourcePluginData(node: SceneNode): void {
|
||||
|
|
|
|||
39
packages/fig/tests/document/metadata.test.ts
Normal file
39
packages/fig/tests/document/metadata.test.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { applyDocumentMetadata } from '#fig/document/metadata'
|
||||
import { ENABLED_LIBRARIES_PLUGIN_KEY, OPEN_PENCIL_PLUGIN_ID } from '#fig/node-change/plugin-data'
|
||||
|
||||
import { SceneGraph } from '@open-pencil/scene-graph'
|
||||
|
||||
function enabledLibraries(value: string) {
|
||||
const graph = new SceneGraph()
|
||||
applyDocumentMetadata(graph, {
|
||||
pluginData: [{ pluginID: OPEN_PENCIL_PLUGIN_ID, key: ENABLED_LIBRARIES_PLUGIN_KEY, value }]
|
||||
})
|
||||
return [...graph.enabledLibraries.values()]
|
||||
}
|
||||
|
||||
describe('document metadata', () => {
|
||||
test('restores valid enabled libraries and skips invalid entries', () => {
|
||||
expect(
|
||||
enabledLibraries(
|
||||
JSON.stringify([
|
||||
{ libraryId: 'design-system', revisionId: 'r1', enabled: true },
|
||||
{ libraryId: 'icons', revisionId: 'r2', enabled: 'yes' },
|
||||
{ libraryId: 'broken', revisionId: 3 },
|
||||
null
|
||||
])
|
||||
)
|
||||
).toEqual([
|
||||
{ libraryId: 'design-system', revisionId: 'r1', enabled: true },
|
||||
{ libraryId: 'icons', revisionId: 'r2', enabled: false }
|
||||
])
|
||||
})
|
||||
|
||||
test('ignores malformed or non-array enabled libraries', () => {
|
||||
expect(enabledLibraries('{not json')).toEqual([])
|
||||
expect(
|
||||
enabledLibraries(JSON.stringify({ libraryId: 'design-system', revisionId: 'r1' }))
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
@ -4,11 +4,6 @@ import { resolve } from 'node:path'
|
|||
/** Shared archives live outside the package; this is the only path that reaches them. */
|
||||
export const FIXTURES = resolve(import.meta.dir, '../../../../tests/fixtures')
|
||||
|
||||
/** Read a shared JSON fixture as data; a module import would escape the package root. */
|
||||
export function readFixtureJSON<T>(name: string): T {
|
||||
return JSON.parse(readFileSync(resolve(FIXTURES, name), 'utf8')) as T
|
||||
}
|
||||
|
||||
export function readFixtureBytes(name: string): Uint8Array {
|
||||
return readFileSync(resolve(FIXTURES, name))
|
||||
}
|
||||
|
|
|
|||
98
packages/fig/tests/node-change/plugin-data.test.ts
Normal file
98
packages/fig/tests/node-change/plugin-data.test.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import {
|
||||
BOUND_VARIABLES_PLUGIN_KEY,
|
||||
EXPORT_SETTINGS_PLUGIN_KEY,
|
||||
extractBoundVariables,
|
||||
extractExportSettings,
|
||||
extractLibrarySource,
|
||||
extractTextPathBox,
|
||||
LIBRARY_SOURCE_PLUGIN_KEY,
|
||||
OPEN_PENCIL_PLUGIN_ID,
|
||||
TEXT_PATH_BOX_PLUGIN_KEY
|
||||
} from '#fig/node-change/plugin-data'
|
||||
|
||||
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
|
||||
import { clampExportScale } from '@open-pencil/scene-graph'
|
||||
|
||||
function withPluginValue(key: string, value: string, extra: NodeChange = {}): NodeChange {
|
||||
return { ...extra, pluginData: [{ pluginID: OPEN_PENCIL_PLUGIN_ID, key, value }] }
|
||||
}
|
||||
|
||||
describe('OpenPencil plugin data readers', () => {
|
||||
test('textPathBox requires finite numbers and a positive size', () => {
|
||||
const box = { x: 1, y: 2, width: 30, height: 40 }
|
||||
expect(
|
||||
extractTextPathBox(withPluginValue(TEXT_PATH_BOX_PLUGIN_KEY, JSON.stringify(box)))
|
||||
).toEqual(box)
|
||||
for (const value of [
|
||||
'{not json',
|
||||
'null',
|
||||
JSON.stringify({ ...box, width: 0 }),
|
||||
JSON.stringify({ ...box, x: '1' })
|
||||
]) {
|
||||
expect(extractTextPathBox(withPluginValue(TEXT_PATH_BOX_PLUGIN_KEY, value))).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
test('bound variables keep string entries and drop the rest', () => {
|
||||
const value = JSON.stringify({ opacity: 'VariableID:1:2', width: 3 })
|
||||
expect(extractBoundVariables(withPluginValue(BOUND_VARIABLES_PLUGIN_KEY, value))).toEqual({
|
||||
opacity: 'VariableID:1:2'
|
||||
})
|
||||
for (const malformed of ['{not json', '["VariableID:1:2"]', '"VariableID:1:2"']) {
|
||||
expect(extractBoundVariables(withPluginValue(BOUND_VARIABLES_PLUGIN_KEY, malformed))).toEqual(
|
||||
{}
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('export settings clamp scales and fall back to native settings when any entry is invalid', () => {
|
||||
const native: NodeChange = {
|
||||
exportSettings: [{ imageType: 'SVG', constraint: { type: 'CONTENT_SCALE', value: 1 } }]
|
||||
}
|
||||
expect(
|
||||
extractExportSettings(
|
||||
withPluginValue(
|
||||
EXPORT_SETTINGS_PLUGIN_KEY,
|
||||
JSON.stringify([{ scale: 1000, format: 'png' }])
|
||||
)
|
||||
)
|
||||
).toEqual([{ scale: clampExportScale(1000), format: 'png' }])
|
||||
for (const value of [
|
||||
'{not json',
|
||||
JSON.stringify({ scale: 1, format: 'png' }),
|
||||
JSON.stringify([
|
||||
{ scale: 2, format: 'png' },
|
||||
{ scale: 1, format: 'gif' }
|
||||
])
|
||||
]) {
|
||||
expect(
|
||||
extractExportSettings(withPluginValue(EXPORT_SETTINGS_PLUGIN_KEY, value, native))
|
||||
).toEqual([{ scale: 1, format: 'svg' }])
|
||||
}
|
||||
})
|
||||
|
||||
test('library source defaults its optional fields and rejects a malformed identity', () => {
|
||||
const identity = { libraryId: 'lib', assetKey: 'button', revisionId: 'r1' }
|
||||
expect(
|
||||
extractLibrarySource(
|
||||
withPluginValue(LIBRARY_SOURCE_PLUGIN_KEY, JSON.stringify({ identity, readOnly: 'yes' }))
|
||||
)
|
||||
).toEqual({ identity, sourceNodeId: null, readOnly: false })
|
||||
expect(
|
||||
extractLibrarySource(
|
||||
withPluginValue(
|
||||
LIBRARY_SOURCE_PLUGIN_KEY,
|
||||
JSON.stringify({ identity, sourceNodeId: '1:2', readOnly: true })
|
||||
)
|
||||
)
|
||||
).toEqual({ identity, sourceNodeId: '1:2', readOnly: true })
|
||||
for (const value of [
|
||||
'{not json',
|
||||
JSON.stringify({ identity: { ...identity, revisionId: 1 } })
|
||||
]) {
|
||||
expect(extractLibrarySource(withPluginValue(LIBRARY_SOURCE_PLUGIN_KEY, value))).toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
import * as v from 'valibot'
|
||||
import type { WebSocket } from 'ws'
|
||||
|
||||
import { isAuthorized } from '#mcp/auth'
|
||||
|
|
@ -21,14 +22,30 @@ type BrowserRPCBridgeOptions = {
|
|||
|
||||
type ConnectionListener = (connected: boolean) => void
|
||||
|
||||
type BrowserMessage = {
|
||||
type: string
|
||||
id?: string
|
||||
token?: unknown
|
||||
result?: unknown
|
||||
error?: string
|
||||
ok?: boolean
|
||||
}
|
||||
/** Any JSON object; the per-type schemas below run only once its `type` is known. */
|
||||
const BrowserEnvelopeJSON = v.pipe(
|
||||
v.string(),
|
||||
v.parseJson(),
|
||||
v.custom<Record<string, unknown>>(
|
||||
(value) => typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
)
|
||||
)
|
||||
|
||||
const HandshakeMessage = v.looseObject({ token: v.optional(v.nullable(v.string())) })
|
||||
|
||||
const RequestMessage = v.looseObject({
|
||||
type: v.literal('request'),
|
||||
id: v.pipe(v.string(), v.nonEmpty())
|
||||
})
|
||||
|
||||
const ResponseMessage = v.looseObject({
|
||||
type: v.literal('response'),
|
||||
id: v.pipe(v.string(), v.nonEmpty()),
|
||||
ok: v.optional(v.boolean()),
|
||||
error: v.optional(v.string())
|
||||
})
|
||||
|
||||
type BrowserMessage = v.InferOutput<typeof RequestMessage> | v.InferOutput<typeof ResponseMessage>
|
||||
|
||||
function stripEnvelope(msg: BrowserMessage): Record<string, unknown> {
|
||||
const { type: _type, id: _id, ...body } = msg
|
||||
|
|
@ -205,8 +222,7 @@ export function createBrowserRPCBridge({
|
|||
})
|
||||
}
|
||||
|
||||
async function handleClientRequest(ws: WebSocket, msg: BrowserMessage) {
|
||||
if (!msg.id) return
|
||||
async function handleClientRequest(ws: WebSocket, msg: v.InferOutput<typeof RequestMessage>) {
|
||||
try {
|
||||
const result = await sendRPC(stripEnvelope(msg))
|
||||
sendJSON(ws, { ...responsePayload(result), type: 'response', id: msg.id, ok: true })
|
||||
|
|
@ -246,8 +262,8 @@ export function createBrowserRPCBridge({
|
|||
broadcastRegisterPrompt()
|
||||
}
|
||||
|
||||
function handleBrowserResponse(msg: BrowserMessage, ws: WebSocket) {
|
||||
if (!browserRegistered || browserWs !== ws || !msg.id) return
|
||||
function handleBrowserResponse(msg: v.InferOutput<typeof ResponseMessage>, ws: WebSocket) {
|
||||
if (!browserRegistered || browserWs !== ws) return
|
||||
const req = pending.get(msg.id)
|
||||
if (!req) return
|
||||
pending.delete(msg.id)
|
||||
|
|
@ -261,41 +277,36 @@ export function createBrowserRPCBridge({
|
|||
|
||||
function handleMessage(data: string, ws: WebSocket) {
|
||||
if (bridgeClosed) return
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(data)
|
||||
} catch (e) {
|
||||
console.warn('Malformed automation message:', e)
|
||||
return
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
const msg = parsed as BrowserMessage
|
||||
|
||||
if (msg.type === 'auth') {
|
||||
// Authenticate a stdio bridge client without registering it as the
|
||||
// browser app. This lets the client send request/response messages
|
||||
// without becoming the RPC target. The token is validated the same
|
||||
// way as registerBrowser — when auth is disabled (authToken === null),
|
||||
// any token is accepted.
|
||||
if (msg.token === null || typeof msg.token === 'string') {
|
||||
if (!isAuthorized(msg.token, authToken)) {
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
authenticatedClients.add(ws)
|
||||
} else if (msg.token !== undefined) {
|
||||
const envelope = v.safeParse(BrowserEnvelopeJSON, data)
|
||||
if (!envelope.success) {
|
||||
if (envelope.issues.some((issue) => issue.type === 'parse_json')) {
|
||||
console.warn('Malformed automation message:', v.summarize(envelope.issues))
|
||||
} else {
|
||||
ws.close()
|
||||
}
|
||||
return
|
||||
}
|
||||
const msg = envelope.output
|
||||
|
||||
if (msg.type === 'register') {
|
||||
if (msg.token === null || typeof msg.token === 'string') {
|
||||
registerBrowser(ws, msg.token)
|
||||
} else if (msg.token !== undefined) {
|
||||
if (msg.type === 'auth' || msg.type === 'register') {
|
||||
// `auth` authenticates a stdio bridge client without registering it as
|
||||
// the browser app, so it can send request/response messages without
|
||||
// becoming the RPC target. The token is validated the same way as
|
||||
// registerBrowser — when auth is disabled (authToken === null), any
|
||||
// token is accepted. A message without a token is ignored; a token of
|
||||
// any other type closes the socket.
|
||||
const handshake = v.safeParse(HandshakeMessage, msg)
|
||||
if (!handshake.success) {
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
const { token } = handshake.output
|
||||
if (token === undefined) return
|
||||
if (msg.type === 'register') {
|
||||
registerBrowser(ws, token)
|
||||
} else if (isAuthorized(token, authToken)) {
|
||||
authenticatedClients.add(ws)
|
||||
} else {
|
||||
ws.close()
|
||||
}
|
||||
return
|
||||
|
|
@ -308,11 +319,13 @@ export function createBrowserRPCBridge({
|
|||
ws.close()
|
||||
return
|
||||
}
|
||||
if (msg.type === 'request') {
|
||||
void handleClientRequest(ws, msg)
|
||||
const request = v.safeParse(RequestMessage, msg)
|
||||
if (request.success) {
|
||||
void handleClientRequest(ws, request.output)
|
||||
return
|
||||
}
|
||||
if (msg.type === 'response') handleBrowserResponse(msg, ws)
|
||||
const response = v.safeParse(ResponseMessage, msg)
|
||||
if (response.success) handleBrowserResponse(response.output, ws)
|
||||
}
|
||||
|
||||
function handleClose(ws: WebSocket) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { randomBytes } from 'node:crypto'
|
|||
import { access, constants, lstat, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
||||
import { Socket } from 'node:net'
|
||||
|
||||
import * as v from 'valibot'
|
||||
|
||||
import { getDiscoveryPath, getSocketPath, platformHasUnixSockets } from '#mcp/transport/paths'
|
||||
|
||||
/**
|
||||
|
|
@ -24,6 +26,27 @@ export interface DiscoveryInfo {
|
|||
disabledTools?: string[]
|
||||
}
|
||||
|
||||
const DiscoveryInfoJSON = v.pipe(
|
||||
v.string(),
|
||||
v.parseJson(),
|
||||
v.object({
|
||||
pid: v.pipe(v.number(), v.integer(), v.minValue(1)),
|
||||
version: v.string(),
|
||||
httpPort: v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(65535)),
|
||||
authRequired: v.boolean(),
|
||||
startedAt: v.string(),
|
||||
socketPath: v.nullable(v.pipe(v.string(), v.nonEmpty())),
|
||||
authToken: v.nullable(v.string()),
|
||||
disabledTools: v.optional(v.array(v.string()), () => [])
|
||||
})
|
||||
) satisfies v.GenericSchema<string, DiscoveryInfo>
|
||||
|
||||
/** Parse discovery file contents, or null when the file is not valid discovery JSON. */
|
||||
export function parseDiscoveryInfo(raw: string): DiscoveryInfo | null {
|
||||
const result = v.safeParse(DiscoveryInfoJSON, raw)
|
||||
return result.success ? result.output : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the discovery JSON file at the platform-appropriate location.
|
||||
* Overwrites any existing file (from a previous server instance).
|
||||
|
|
@ -74,52 +97,13 @@ export async function readDiscoveryFile(): Promise<DiscoveryInfo | null> {
|
|||
return null
|
||||
}
|
||||
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') return null
|
||||
|
||||
const obj = parsed as { [key: string]: unknown }
|
||||
const info = validateDiscoveryFields(obj)
|
||||
const info = parseDiscoveryInfo(raw)
|
||||
if (!info) return null
|
||||
if (!isProcessAlive(info.pid)) return null
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
function validateDiscoveryFields(obj: { [key: string]: unknown }): DiscoveryInfo | null {
|
||||
const { pid, version, httpPort, authRequired, startedAt, socketPath, authToken, disabledTools } =
|
||||
obj
|
||||
if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) return null
|
||||
if (typeof version !== 'string') return null
|
||||
if (typeof httpPort !== 'number' || !Number.isInteger(httpPort)) return null
|
||||
if (httpPort < 0 || httpPort > 65535) return null
|
||||
if (typeof authRequired !== 'boolean') return null
|
||||
if (typeof startedAt !== 'string') return null
|
||||
if (typeof socketPath !== 'string' && socketPath !== null) return null
|
||||
if (socketPath === '') return null
|
||||
if (authToken !== null && typeof authToken !== 'string') return null
|
||||
if (
|
||||
disabledTools !== undefined &&
|
||||
(!Array.isArray(disabledTools) || disabledTools.some((name) => typeof name !== 'string'))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
pid,
|
||||
version,
|
||||
httpPort,
|
||||
authRequired,
|
||||
startedAt,
|
||||
socketPath,
|
||||
authToken,
|
||||
disabledTools: disabledTools ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the discovery file. Does not throw if the file does not exist.
|
||||
*/
|
||||
|
|
@ -145,15 +129,7 @@ async function isSocketLiveViaTcp(socketPath: string): Promise<boolean> {
|
|||
}
|
||||
const raw = await readFile(discoveryPath, 'utf-8').catch(() => null)
|
||||
if (!raw) return false
|
||||
let info: DiscoveryInfo | null = null
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
info = validateDiscoveryFields(parsed as { [key: string]: unknown })
|
||||
}
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
const info = parseDiscoveryInfo(raw)
|
||||
if (!info || info.socketPath !== socketPath) return false
|
||||
|
||||
// If TCP is disabled (httpPort <= 0), fall back to PID-based liveness check
|
||||
|
|
|
|||
35
packages/mcp/tests/transport/discovery.test.ts
Normal file
35
packages/mcp/tests/transport/discovery.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { parseDiscoveryInfo } from '@open-pencil/mcp/discovery'
|
||||
|
||||
const VALID = {
|
||||
pid: 4242,
|
||||
socketPath: '/tmp/open-pencil.sock',
|
||||
httpPort: 7600,
|
||||
authRequired: true,
|
||||
authToken: 'token',
|
||||
version: '1.0.0',
|
||||
startedAt: '2026-01-01T00:00:00.000Z'
|
||||
}
|
||||
|
||||
describe('parseDiscoveryInfo', () => {
|
||||
test('defaults disabledTools to an empty list', () => {
|
||||
expect(parseDiscoveryInfo(JSON.stringify(VALID))).toEqual({ ...VALID, disabledTools: [] })
|
||||
expect(parseDiscoveryInfo(JSON.stringify({ ...VALID, disabledTools: ['eval'] }))).toEqual({
|
||||
...VALID,
|
||||
disabledTools: ['eval']
|
||||
})
|
||||
})
|
||||
|
||||
test.each([
|
||||
['malformed JSON', '{not json'],
|
||||
['a non-object', '[]'],
|
||||
['a non-string token', JSON.stringify({ ...VALID, authToken: 42 })],
|
||||
['an empty socket path', JSON.stringify({ ...VALID, socketPath: '' })],
|
||||
['an out-of-range port', JSON.stringify({ ...VALID, httpPort: 70_000 })],
|
||||
['a non-positive pid', JSON.stringify({ ...VALID, pid: 0 })],
|
||||
['non-string disabled tools', JSON.stringify({ ...VALID, disabledTools: [1] })]
|
||||
])('rejects %s', (_label, raw) => {
|
||||
expect(parseDiscoveryInfo(raw)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -96,31 +96,25 @@ export function serializeOkHCLPayload(payload: OkHCLPayload): string {
|
|||
return JSON.stringify(payload)
|
||||
}
|
||||
|
||||
function isJSONObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
export function parseOkHCLPayload(value: string): OkHCLPayload | null {
|
||||
let parsed: unknown
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Partial<OkHCLPayload>
|
||||
if (parsed.version !== 1) return null
|
||||
if (parsed.kind !== 'fill' && parsed.kind !== 'stroke') return null
|
||||
if (typeof parsed.index !== 'number') return null
|
||||
if (!parsed.color) return null
|
||||
const color = parsed.color as Partial<OkHCLColor>
|
||||
if (typeof color.h !== 'number' || typeof color.c !== 'number' || typeof color.l !== 'number') {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
kind: parsed.kind,
|
||||
index: parsed.index,
|
||||
color: {
|
||||
h: color.h,
|
||||
c: color.c,
|
||||
l: color.l,
|
||||
a: typeof color.a === 'number' ? color.a : undefined
|
||||
}
|
||||
}
|
||||
parsed = JSON.parse(value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!isJSONObject(parsed) || parsed.version !== 1) return null
|
||||
const { kind, index, color } = parsed
|
||||
if (kind !== 'fill' && kind !== 'stroke') return null
|
||||
if (typeof index !== 'number') return null
|
||||
if (!isJSONObject(color)) return null
|
||||
const { h, c, l, a } = color
|
||||
if (typeof h !== 'number' || typeof c !== 'number' || typeof l !== 'number') return null
|
||||
return { version: 1, kind, index, color: { h, c, l, a: typeof a === 'number' ? a : undefined } }
|
||||
}
|
||||
|
||||
function createOkHCLPayload(
|
||||
|
|
|
|||
|
|
@ -1,23 +1,38 @@
|
|||
import * as v from 'valibot'
|
||||
|
||||
import { AI_PROVIDERS } from '@open-pencil/core/constants'
|
||||
import type { AIProviderID, ModelOption } from '@open-pencil/core/constants'
|
||||
|
||||
import { readCacheJSON, writeCacheJSON } from '@/app/cache'
|
||||
|
||||
type OpenRouterModel = {
|
||||
id?: unknown
|
||||
name?: unknown
|
||||
supported_parameters?: unknown
|
||||
architecture?: {
|
||||
input_modalities?: unknown
|
||||
}
|
||||
top_provider?: {
|
||||
max_completion_tokens?: unknown
|
||||
}
|
||||
}
|
||||
const CachedModelOptions = v.array(
|
||||
v.object({
|
||||
id: v.string(),
|
||||
name: v.string(),
|
||||
tag: v.optional(v.string()),
|
||||
capabilities: v.optional(v.array(v.picklist(['tools', 'vision']))),
|
||||
recommendedMaxOutputTokens: v.optional(v.number()),
|
||||
releaseDate: v.optional(v.string()),
|
||||
status: v.optional(v.picklist(['active', 'beta', 'deprecated']))
|
||||
})
|
||||
) satisfies v.GenericSchema<unknown, ModelOption[]>
|
||||
|
||||
type OpenRouterModelsResponse = {
|
||||
data?: OpenRouterModel[]
|
||||
}
|
||||
const OpenRouterModelSchema = v.looseObject({
|
||||
id: v.optional(v.unknown()),
|
||||
name: v.optional(v.unknown()),
|
||||
supported_parameters: v.optional(v.unknown()),
|
||||
architecture: v.nullish(v.looseObject({ input_modalities: v.optional(v.unknown()) })),
|
||||
top_provider: v.nullish(v.looseObject({ max_completion_tokens: v.optional(v.unknown()) }))
|
||||
})
|
||||
|
||||
type OpenRouterModel = v.InferOutput<typeof OpenRouterModelSchema>
|
||||
|
||||
// Entries are checked one by one in `fetchOpenRouterModels`, so one odd model keeps the rest.
|
||||
const OpenRouterModelsResponseJSON = v.pipe(
|
||||
v.string(),
|
||||
v.parseJson(),
|
||||
v.object({ data: v.optional(v.array(v.unknown())) })
|
||||
)
|
||||
|
||||
const OPENROUTER_MODELS_URL = 'https://openrouter.ai/api/v1/models'
|
||||
const OPENROUTER_MODELS_CACHE_KEY = 'openrouter/models'
|
||||
|
|
@ -57,14 +72,18 @@ export function normalizeOpenRouterModel(model: OpenRouterModel): ModelOption |
|
|||
async function fetchOpenRouterModels(fetcher: typeof fetch): Promise<ModelOption[]> {
|
||||
const response = await fetcher(OPENROUTER_MODELS_URL)
|
||||
if (!response.ok) throw new Error(`OpenRouter models request failed: ${response.status}`)
|
||||
const json = (await response.json()) as OpenRouterModelsResponse
|
||||
return json.data?.map(normalizeOpenRouterModel).filter((model) => model !== null) ?? []
|
||||
const json = v.parse(OpenRouterModelsResponseJSON, await response.text())
|
||||
return (json.data ?? []).flatMap((entry) => {
|
||||
const model = v.is(OpenRouterModelSchema, entry) ? normalizeOpenRouterModel(entry) : null
|
||||
return model ? [model] : []
|
||||
})
|
||||
}
|
||||
|
||||
async function listOpenRouterModels(fetcher: typeof fetch = fetch): Promise<ModelOption[]> {
|
||||
modelsPromise ??= (async () => {
|
||||
const cached = await readCacheJSON<ModelOption[]>(
|
||||
const cached = await readCacheJSON(
|
||||
OPENROUTER_MODELS_CACHE_KEY,
|
||||
CachedModelOptions,
|
||||
OPENROUTER_MODELS_CACHE_TTL_MS
|
||||
)
|
||||
if (cached?.length) return cached
|
||||
|
|
|
|||
|
|
@ -1,7 +1,45 @@
|
|||
import type { HarnessSidecarMessage } from '@open-pencil/harness'
|
||||
import * as v from 'valibot'
|
||||
|
||||
import type { HarnessSidecarMessage, JSONValue } from '@open-pencil/harness'
|
||||
|
||||
import { resolvePlatformCommand } from '@/app/tauri/command'
|
||||
|
||||
/** Any value `JSON.parse` produced is a JSONValue; only presence needs checking. */
|
||||
const jsonValue = v.custom<JSONValue>((value) => value !== undefined)
|
||||
|
||||
const HarnessTurnEventSchema = v.variant('type', [
|
||||
v.object({ type: v.literal('text-delta'), text: v.string() }),
|
||||
v.object({ type: v.literal('reasoning-delta'), text: v.string() }),
|
||||
v.object({
|
||||
type: v.literal('tool-call'),
|
||||
toolCallId: v.string(),
|
||||
toolName: v.string(),
|
||||
input: jsonValue
|
||||
}),
|
||||
v.object({
|
||||
type: v.literal('tool-result'),
|
||||
toolCallId: v.string(),
|
||||
toolName: v.string(),
|
||||
output: jsonValue
|
||||
}),
|
||||
v.object({ type: v.literal('finish'), finishReason: v.string() }),
|
||||
v.object({ type: v.literal('error'), message: v.string() })
|
||||
])
|
||||
|
||||
const HarnessSidecarMessageJSON = v.pipe(
|
||||
v.string(),
|
||||
v.parseJson(),
|
||||
v.variant('type', [
|
||||
v.object({
|
||||
type: v.literal('response'),
|
||||
id: v.string(),
|
||||
result: v.optional(jsonValue),
|
||||
error: v.optional(v.string())
|
||||
}),
|
||||
v.object({ type: v.literal('turn.event'), id: v.string(), event: HarnessTurnEventSchema })
|
||||
])
|
||||
) satisfies v.GenericSchema<string, HarnessSidecarMessage>
|
||||
|
||||
export type HarnessChild = {
|
||||
write(data: number[]): Promise<void>
|
||||
kill(): Promise<void>
|
||||
|
|
@ -39,11 +77,9 @@ export async function spawnHarnessProcess(options: {
|
|||
buffer = lines.pop() ?? ''
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
try {
|
||||
controller?.enqueue(JSON.parse(line) as HarnessSidecarMessage)
|
||||
} catch (error) {
|
||||
console.warn('[Harness] Ignoring malformed sidecar output:', error)
|
||||
}
|
||||
const message = v.safeParse(HarnessSidecarMessageJSON, line)
|
||||
if (message.success) controller?.enqueue(message.output)
|
||||
else console.warn('[Harness] Ignoring malformed sidecar output:', v.summarize(message.issues))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import * as v from 'valibot'
|
||||
|
||||
import { AI_PROVIDERS } from '@open-pencil/core/constants'
|
||||
import type { AIProviderID, ModelOption } from '@open-pencil/core/constants'
|
||||
|
||||
|
|
@ -34,6 +36,21 @@ type ModelsDevProvider = {
|
|||
|
||||
type ModelsDevCatalog = Record<string, ModelsDevProvider>
|
||||
|
||||
const ModelsDevCatalogSchema = v.record(
|
||||
v.string(),
|
||||
v.object({
|
||||
models: v.optional(
|
||||
v.record(
|
||||
v.string(),
|
||||
v.looseObject({
|
||||
modalities: v.optional(v.looseObject({ output: v.optional(v.unknown()) })),
|
||||
limit: v.optional(v.looseObject({ output: v.optional(v.unknown()) }))
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
) satisfies v.GenericSchema<unknown, ModelsDevCatalog>
|
||||
|
||||
let catalogPromise: Promise<ModelsDevCatalog | null> | null = null
|
||||
|
||||
function normalizedStatus(status: unknown): ModelOption['status'] {
|
||||
|
|
@ -64,8 +81,9 @@ async function loadCatalog(
|
|||
options: { useCache: boolean }
|
||||
): Promise<ModelsDevCatalog | null> {
|
||||
if (options.useCache) {
|
||||
const cached = await readCacheJSON<ModelsDevCatalog>(
|
||||
const cached = await readCacheJSON(
|
||||
MODELS_DEV_CACHE_KEY,
|
||||
ModelsDevCatalogSchema,
|
||||
MODELS_DEV_CACHE_TTL_MS
|
||||
)
|
||||
if (cached) return cached
|
||||
|
|
@ -73,7 +91,11 @@ async function loadCatalog(
|
|||
try {
|
||||
const response = await fetcher(MODELS_DEV_URL)
|
||||
if (!response.ok) throw new Error(`models.dev catalog request failed: ${response.status}`)
|
||||
const catalog = (await response.json()) as ModelsDevCatalog
|
||||
// Validated like the cached copy, so a bad entry falls back instead of crashing the list.
|
||||
const catalog = v.parse(
|
||||
v.pipe(v.string(), v.parseJson(), ModelsDevCatalogSchema),
|
||||
await response.text()
|
||||
)
|
||||
if (options.useCache) await writeCacheJSON(MODELS_DEV_CACHE_KEY, catalog)
|
||||
return catalog
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { computed } from 'vue'
|
|||
|
||||
import { configurableAITools, isAIToolEnabled } from './catalog'
|
||||
|
||||
const overridesSchema = v.record(v.string(), v.boolean())
|
||||
const OverridesJSON = v.pipe(v.string(), v.parseJson(), v.record(v.string(), v.boolean()))
|
||||
|
||||
export const aiToolOverrides = useLocalStorage<Record<string, boolean>>(
|
||||
'open-pencil:ai:tool-access',
|
||||
|
|
@ -12,12 +12,8 @@ export const aiToolOverrides = useLocalStorage<Record<string, boolean>>(
|
|||
{
|
||||
serializer: {
|
||||
read: (raw) => {
|
||||
try {
|
||||
const result = v.safeParse(overridesSchema, JSON.parse(raw))
|
||||
return result.success ? result.output : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
const result = v.safeParse(OverridesJSON, raw)
|
||||
return result.success ? result.output : {}
|
||||
},
|
||||
write: JSON.stringify
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { promiseTimeout } from '@vueuse/core'
|
||||
import * as v from 'valibot'
|
||||
|
||||
import { AUTOMATION_HTTP_PORT } from '@open-pencil/core/constants'
|
||||
import { randomHex } from '@open-pencil/core/random'
|
||||
|
|
@ -117,6 +118,13 @@ function rememberStartupError(error: unknown): null {
|
|||
return null
|
||||
}
|
||||
|
||||
/** The one discovery-file field the app reads; the MCP package validates the whole file. */
|
||||
const DiscoveryTokenJSON = v.pipe(
|
||||
v.string(),
|
||||
v.parseJson(),
|
||||
v.looseObject({ authToken: v.optional(v.nullable(v.string())) })
|
||||
) satisfies v.GenericSchema<string, Partial<Pick<DiscoveryInfo, 'authToken'>>>
|
||||
|
||||
/**
|
||||
* Reads the auth token from the MCP discovery file via Tauri's FS plugin.
|
||||
* The discovery file path is computed locally (not from the /health endpoint)
|
||||
|
|
@ -126,9 +134,8 @@ function rememberStartupError(error: unknown): null {
|
|||
async function readDiscoveryToken(discoveryPath: string): Promise<string | null> {
|
||||
try {
|
||||
const { readTextFile } = await import('@tauri-apps/plugin-fs')
|
||||
const raw = await readTextFile(discoveryPath)
|
||||
const info = JSON.parse(raw) as DiscoveryInfo
|
||||
return info.authToken ?? null
|
||||
const info = v.safeParse(DiscoveryTokenJSON, await readTextFile(discoveryPath))
|
||||
return info.success ? (info.output.authToken ?? null) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
34
src/app/cache/index.ts
vendored
34
src/app/cache/index.ts
vendored
|
|
@ -1,3 +1,5 @@
|
|||
import * as v from 'valibot'
|
||||
|
||||
import { IS_TAURI } from '@open-pencil/core/constants'
|
||||
|
||||
const APP_CACHE_DIR = 'cache/v1'
|
||||
|
|
@ -116,23 +118,29 @@ export async function removeCachePrefix(prefix: string): Promise<void> {
|
|||
removeStorageEntriesWithPrefix(prefix)
|
||||
}
|
||||
|
||||
type JSONCacheEnvelope<T> = {
|
||||
updatedAt: number
|
||||
value: T
|
||||
}
|
||||
const CacheEnvelopeJSON = v.pipe(
|
||||
v.string(),
|
||||
v.parseJson(),
|
||||
v.object({ updatedAt: v.number(), value: v.unknown() })
|
||||
)
|
||||
|
||||
export async function readCacheJSON<T>(key: string, maxAgeMs?: number): Promise<T | null> {
|
||||
/**
|
||||
* Read a JSON value written by `writeCacheJSON`. A missing, expired, malformed or
|
||||
* mismatched entry reads as null, so callers refetch instead of trusting stale shapes.
|
||||
*/
|
||||
export async function readCacheJSON<TSchema extends v.GenericSchema>(
|
||||
key: string,
|
||||
schema: TSchema,
|
||||
maxAgeMs?: number
|
||||
): Promise<v.InferOutput<TSchema> | null> {
|
||||
const raw = await readCacheText(key)
|
||||
if (!raw) return null
|
||||
|
||||
try {
|
||||
const envelope = JSON.parse(raw) as Partial<JSONCacheEnvelope<T>>
|
||||
if (typeof envelope.updatedAt !== 'number' || !('value' in envelope)) return null
|
||||
if (maxAgeMs !== undefined && Date.now() - envelope.updatedAt > maxAgeMs) return null
|
||||
return envelope.value as T
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const envelope = v.safeParse(CacheEnvelopeJSON, raw)
|
||||
if (!envelope.success) return null
|
||||
if (maxAgeMs !== undefined && Date.now() - envelope.output.updatedAt > maxAgeMs) return null
|
||||
const value = v.safeParse(schema, envelope.output.value)
|
||||
return value.success ? value.output : null
|
||||
}
|
||||
|
||||
export async function writeCacheJSON(key: string, value: unknown): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import * as v from 'valibot'
|
||||
|
||||
import type { DownloadedFontCache } from '@open-pencil/core/text'
|
||||
|
||||
import {
|
||||
|
|
@ -22,6 +24,21 @@ type FontCacheManifest = {
|
|||
entries: Partial<Record<string, FontCacheEntry>>
|
||||
}
|
||||
|
||||
const CachedFontManifest = v.object({
|
||||
version: v.literal(1),
|
||||
entries: v.record(
|
||||
v.string(),
|
||||
v.object({
|
||||
family: v.string(),
|
||||
style: v.string(),
|
||||
file: v.string(),
|
||||
byteLength: v.number(),
|
||||
sha256: v.string(),
|
||||
updatedAt: v.number()
|
||||
})
|
||||
)
|
||||
}) satisfies v.GenericSchema<unknown, FontCacheManifest>
|
||||
|
||||
export interface DownloadedFontCacheSummary {
|
||||
count: number
|
||||
byteLength: number
|
||||
|
|
@ -53,9 +70,7 @@ function hexDigest(data: ArrayBuffer) {
|
|||
}
|
||||
|
||||
async function readManifest(): Promise<FontCacheManifest> {
|
||||
const manifest = await readCacheJSON<Partial<FontCacheManifest>>(MANIFEST_PATH)
|
||||
if (manifest?.version !== 1 || !manifest.entries) return EMPTY_MANIFEST
|
||||
return { version: 1, entries: manifest.entries }
|
||||
return (await readCacheJSON(MANIFEST_PATH, CachedFontManifest)) ?? EMPTY_MANIFEST
|
||||
}
|
||||
|
||||
async function writeManifest(manifest: FontCacheManifest) {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import * as v from 'valibot'
|
||||
|
||||
import { extractFigThumbnailFromReader } from '@open-pencil/fig'
|
||||
|
||||
import { isTauri } from '@/app/tauri/env'
|
||||
|
|
@ -61,25 +63,35 @@ async function resolveConfig(runtime: StorageProviderRuntime): Promise<S3Compati
|
|||
}
|
||||
}
|
||||
|
||||
/** Each field falls back on its own; only a document with both fields is authoritative. */
|
||||
const DocumentMetadataJSON = v.pipe(
|
||||
v.string(),
|
||||
v.parseJson(),
|
||||
v.object({
|
||||
name: v.fallback(
|
||||
v.nullable(
|
||||
v.pipe(
|
||||
v.string(),
|
||||
v.check((name) => name.trim() !== '')
|
||||
)
|
||||
),
|
||||
null
|
||||
),
|
||||
updatedAt: v.fallback(v.nullable(v.pipe(v.string(), v.nonEmpty())), null)
|
||||
})
|
||||
)
|
||||
|
||||
function parseMetadata(
|
||||
bytes: Uint8Array | null,
|
||||
fallback: StorageDocumentMetadata
|
||||
): { metadata: StorageDocumentMetadata; authoritative: boolean } {
|
||||
if (!bytes) return { metadata: fallback, authoritative: false }
|
||||
try {
|
||||
const parsed = JSON.parse(new TextDecoder().decode(bytes)) as Partial<StorageDocumentMetadata>
|
||||
const name = typeof parsed.name === 'string' && parsed.name.trim() ? parsed.name : null
|
||||
const updatedAt =
|
||||
typeof parsed.updatedAt === 'string' && parsed.updatedAt ? parsed.updatedAt : null
|
||||
return {
|
||||
metadata: {
|
||||
name: name ?? fallback.name,
|
||||
updatedAt: updatedAt ?? fallback.updatedAt
|
||||
},
|
||||
authoritative: name !== null && updatedAt !== null
|
||||
}
|
||||
} catch {
|
||||
return { metadata: fallback, authoritative: false }
|
||||
const parsed = v.safeParse(DocumentMetadataJSON, new TextDecoder().decode(bytes))
|
||||
if (!parsed.success) return { metadata: fallback, authoritative: false }
|
||||
const { name, updatedAt } = parsed.output
|
||||
return {
|
||||
metadata: { name: name ?? fallback.name, updatedAt: updatedAt ?? fallback.updatedAt },
|
||||
authoritative: name !== null && updatedAt !== null
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { fromUint8Array, toUint8Array } from 'js-base64'
|
||||
import * as v from 'valibot'
|
||||
|
||||
import {
|
||||
createLibraryRevision,
|
||||
deserializeLibraryRevision,
|
||||
MAX_LIBRARY_REVISION_BYTES,
|
||||
SerializedLibraryRevisionSchema,
|
||||
serializeLibraryRevision,
|
||||
StoredLibraryLatestManifestSchema,
|
||||
validateLibraryRevision
|
||||
} from '@open-pencil/core/library'
|
||||
import type {
|
||||
|
|
@ -12,7 +15,6 @@ import type {
|
|||
LibraryCatalog,
|
||||
LibrarySummary,
|
||||
PublishLibraryInput,
|
||||
SerializedComponentLibraryRevision,
|
||||
StoredLibraryLatestManifest
|
||||
} from '@open-pencil/core/library'
|
||||
|
||||
|
|
@ -112,40 +114,26 @@ function encodeRevision(revision: ComponentLibraryRevision): Uint8Array {
|
|||
return textEncoder.encode(JSON.stringify(encodeValue(serializeLibraryRevision(revision))))
|
||||
}
|
||||
|
||||
const RevisionJSON = v.pipe(
|
||||
v.string(),
|
||||
v.parseJson(),
|
||||
v.transform(decodeValue),
|
||||
SerializedLibraryRevisionSchema
|
||||
)
|
||||
|
||||
const LatestManifestJSON = v.pipe(v.string(), v.parseJson(), StoredLibraryLatestManifestSchema)
|
||||
|
||||
function decodeRevision(bytes: Uint8Array): ComponentLibraryRevision {
|
||||
const parsed = decodeValue(
|
||||
JSON.parse(textDecoder.decode(bytes))
|
||||
) as SerializedComponentLibraryRevision
|
||||
return deserializeLibraryRevision(parsed)
|
||||
const parsed = v.safeParse(RevisionJSON, textDecoder.decode(bytes))
|
||||
if (!parsed.success)
|
||||
throw new Error(`Invalid component library revision: ${v.summarize(parsed.issues)}`)
|
||||
return deserializeLibraryRevision(parsed.output)
|
||||
}
|
||||
|
||||
function decodeLatest(bytes: Uint8Array): StoredLibraryLatestManifest {
|
||||
const parsed = JSON.parse(textDecoder.decode(bytes)) as unknown
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('Invalid component library manifest')
|
||||
}
|
||||
const candidate = parsed as { schemaVersion?: unknown; summary?: unknown }
|
||||
if (
|
||||
candidate.schemaVersion !== 1 ||
|
||||
!candidate.summary ||
|
||||
typeof candidate.summary !== 'object' ||
|
||||
Array.isArray(candidate.summary)
|
||||
) {
|
||||
throw new Error('Invalid component library manifest')
|
||||
}
|
||||
const summary = candidate.summary as Partial<LibrarySummary>
|
||||
if (
|
||||
typeof summary.libraryId !== 'string' ||
|
||||
typeof summary.name !== 'string' ||
|
||||
typeof summary.latestRevisionId !== 'string' ||
|
||||
typeof summary.publishedAt !== 'string' ||
|
||||
typeof summary.assetCount !== 'number' ||
|
||||
!Number.isSafeInteger(summary.assetCount) ||
|
||||
summary.assetCount < 0
|
||||
) {
|
||||
throw new Error('Invalid component library manifest')
|
||||
}
|
||||
return candidate as StoredLibraryLatestManifest
|
||||
const parsed = v.safeParse(LatestManifestJSON, textDecoder.decode(bytes))
|
||||
if (!parsed.success) throw new Error('Invalid component library manifest')
|
||||
return parsed.output
|
||||
}
|
||||
|
||||
export class StorageLibraryCatalog implements LibraryCatalog {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,30 @@
|
|||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import * as v from 'valibot'
|
||||
|
||||
import type { RecordedWheelSample } from '@open-pencil/core/profiler'
|
||||
|
||||
import { expect, test, useEditorSetup } from '#tests/e2e/fixtures'
|
||||
|
||||
const editor = useEditorSetup('/?test&no-chrome&no-rulers&renderer=tiled&navigation-benchmark')
|
||||
const reversal = JSON.parse(
|
||||
const WheelSample: v.GenericSchema<unknown, RecordedWheelSample> = v.object({
|
||||
timeMs: v.number(),
|
||||
deltaX: v.number(),
|
||||
deltaY: v.number(),
|
||||
deltaMode: v.number(),
|
||||
ctrlKey: v.optional(v.boolean(), false),
|
||||
metaKey: v.optional(v.boolean(), false),
|
||||
shiftKey: v.optional(v.boolean(), false),
|
||||
clientX: v.number(),
|
||||
clientY: v.number(),
|
||||
cancelable: v.optional(v.boolean(), true),
|
||||
directionInvertedFromDevice: v.optional(v.boolean())
|
||||
})
|
||||
const GestureJSON = v.pipe(v.string(), v.parseJson(), v.object({ wheel: v.array(WheelSample) }))
|
||||
const reversal = v.parse(
|
||||
GestureJSON,
|
||||
readFileSync('tests/fixtures/navigation/gestures/synthetic-repeated-pinch-reversal.json', 'utf8')
|
||||
) as { wheel: RecordedWheelSample[] }
|
||||
)
|
||||
|
||||
async function waitForTiledSettlement() {
|
||||
await editor.page.evaluate(() => window.openPencil?.test?.navigation?.waitForSettlement())
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
import type { OkHCLPayload } from '@open-pencil/scene-graph/color'
|
||||
import { parseOkHCLPayload } from '@open-pencil/scene-graph/color'
|
||||
|
||||
import { CanvasHelper } from '#tests/helpers/canvas'
|
||||
|
||||
|
|
@ -32,7 +32,7 @@ async function getSelectedFill() {
|
|||
}
|
||||
|
||||
async function getSelectedFillOkHCL() {
|
||||
return page.evaluate(() => {
|
||||
const value = await page.evaluate(() => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store) throw new Error('OpenPencil store not initialized')
|
||||
const id = [...store.state.selectedIds][0]
|
||||
|
|
@ -41,10 +41,10 @@ async function getSelectedFillOkHCL() {
|
|||
const entry = node?.pluginData.find(
|
||||
(value) => value.pluginId === 'open-pencil' && value.key === 'okhcl'
|
||||
)
|
||||
if (!entry) return null
|
||||
const payload = JSON.parse(entry.value) as Partial<OkHCLPayload>
|
||||
return payload.kind === 'fill' && payload.index === 0 ? (payload.color ?? null) : null
|
||||
return entry?.value ?? null
|
||||
})
|
||||
const payload = value === null ? null : parseOkHCLPayload(value)
|
||||
return payload?.kind === 'fill' && payload.index === 0 ? payload.color : null
|
||||
}
|
||||
|
||||
async function openFillPicker() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { expect, test } from '@playwright/test'
|
||||
import * as v from 'valibot'
|
||||
|
||||
const ModeValuesJSON = v.pipe(v.string(), v.parseJson(), v.record(v.string(), v.number()))
|
||||
|
||||
test('missing definitions retain identity and remain explicitly detachable', async ({ page }) => {
|
||||
await page.goto('/iframe.html?id=vue-sdk-primitives-bindablevalue--mode-editing&viewMode=story')
|
||||
|
|
@ -19,7 +22,7 @@ for (const outcome of ['Cancel', 'Commit']) {
|
|||
test(`captured variable modes survive target changes: ${outcome}`, async ({ page }) => {
|
||||
await page.goto('/iframe.html?id=vue-sdk-primitives-bindablevalue--mode-editing&viewMode=story')
|
||||
const values = async () =>
|
||||
JSON.parse(await page.getByLabel('Mode values').innerText()) as Record<string, number>
|
||||
v.parse(ModeValuesJSON, await page.getByLabel('Mode values').innerText())
|
||||
const before = await values()
|
||||
await page.getByRole('button', { name: 'Begin edit', exact: true }).click()
|
||||
await page.getByRole('button', { name: 'Set 12', exact: true }).click()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
|
||||
import * as v from 'valibot'
|
||||
|
||||
function installLocalStorage() {
|
||||
const data = new Map<string, string>()
|
||||
const storage = {
|
||||
|
|
@ -38,8 +40,22 @@ describe('app cache', () => {
|
|||
|
||||
await writeCacheJSON('json/key', { ok: true })
|
||||
|
||||
await expect(readCacheJSON('json/key', 60_000)).resolves.toEqual({ ok: true })
|
||||
await expect(readCacheJSON('json/key', -1)).resolves.toBeNull()
|
||||
const schema = v.object({ ok: v.boolean() })
|
||||
await expect(readCacheJSON('json/key', schema, 60_000)).resolves.toEqual({ ok: true })
|
||||
await expect(readCacheJSON('json/key', schema, -1)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
test('reads malformed or mismatched JSON entries as missing', async () => {
|
||||
const storage = installLocalStorage()
|
||||
const { readCacheJSON, writeCacheJSON } = await import('@/app/cache')
|
||||
const schema = v.object({ ok: v.boolean() })
|
||||
|
||||
storage.set('open-pencil:cache:v1:json/key', '{not json')
|
||||
await expect(readCacheJSON('json/key', schema)).resolves.toBeNull()
|
||||
storage.set('open-pencil:cache:v1:json/key', JSON.stringify({ value: { ok: true } }))
|
||||
await expect(readCacheJSON('json/key', schema)).resolves.toBeNull()
|
||||
await writeCacheJSON('json/key', { ok: 'yes' })
|
||||
await expect(readCacheJSON('json/key', schema)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
test('removes a web cache prefix', async () => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -1,66 +1,77 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import type { Vector } from '@open-pencil/scene-graph/primitives'
|
||||
import * as v from 'valibot'
|
||||
|
||||
interface PatternOracleFill {
|
||||
type: string
|
||||
sourceNodeId: string
|
||||
tileType: string
|
||||
spacing: Vector
|
||||
horizontalAlignment: string
|
||||
verticalAlignment: string
|
||||
}
|
||||
const OracleVector = v.object({ x: v.number(), y: v.number() })
|
||||
|
||||
interface EffectOracleResult {
|
||||
ok: boolean
|
||||
effects: Array<{
|
||||
type: string
|
||||
noiseType?: string
|
||||
noiseSize?: number
|
||||
noiseSizeVector?: Vector
|
||||
density?: number
|
||||
opacity?: number
|
||||
secondaryColor?: unknown
|
||||
}>
|
||||
}
|
||||
const PatternOracleFill = v.object({
|
||||
type: v.string(),
|
||||
sourceNodeId: v.string(),
|
||||
tileType: v.string(),
|
||||
spacing: OracleVector,
|
||||
horizontalAlignment: v.string(),
|
||||
verticalAlignment: v.string()
|
||||
})
|
||||
|
||||
interface PaintOracle {
|
||||
pattern: {
|
||||
frame: { id: string }
|
||||
source: { id: string; visible: boolean }
|
||||
target: { fills: PatternOracleFill[] }
|
||||
}
|
||||
patternAlignment: {
|
||||
frame: { id: string }
|
||||
source: { id: string; visible: boolean }
|
||||
targets: Array<{ alignment: string; fills: PatternOracleFill[] }>
|
||||
metrics: { rmseNormalized: number; fuzzDifferentPixels: number }
|
||||
analysis: Record<
|
||||
string,
|
||||
{
|
||||
pairedRowCount: number
|
||||
avgDeltaY: number
|
||||
avgDeltaFirstX: number
|
||||
missingOpenPencilRows: number
|
||||
extraOpenPencilRows: number
|
||||
}
|
||||
>
|
||||
}
|
||||
effects: {
|
||||
noise: { results: Record<string, EffectOracleResult> }
|
||||
textureAndGlass: { results: Record<string, EffectOracleResult> }
|
||||
}
|
||||
pluginRuntimeCreation: Record<string, { ok: boolean; message: string }>
|
||||
currentFileFillTypes: Record<string, number>
|
||||
localFigFixtureFillTypes: Record<string, Record<string, number>>
|
||||
status: string
|
||||
}
|
||||
const EffectOracleResult = v.object({
|
||||
ok: v.boolean(),
|
||||
effects: v.array(
|
||||
v.object({
|
||||
type: v.string(),
|
||||
noiseType: v.optional(v.string()),
|
||||
noiseSize: v.optional(v.number()),
|
||||
noiseSizeVector: v.optional(OracleVector),
|
||||
density: v.optional(v.number()),
|
||||
opacity: v.optional(v.number()),
|
||||
secondaryColor: v.optional(v.unknown())
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
function readOracle(): PaintOracle {
|
||||
return JSON.parse(
|
||||
const OracleSource = v.object({ id: v.string(), visible: v.boolean() })
|
||||
|
||||
const PaintOracleJSON = v.pipe(
|
||||
v.string(),
|
||||
v.parseJson(),
|
||||
v.object({
|
||||
pattern: v.object({
|
||||
frame: v.object({ id: v.string() }),
|
||||
source: OracleSource,
|
||||
target: v.object({ fills: v.array(PatternOracleFill) })
|
||||
}),
|
||||
patternAlignment: v.object({
|
||||
frame: v.object({ id: v.string() }),
|
||||
source: OracleSource,
|
||||
targets: v.array(v.object({ alignment: v.string(), fills: v.array(PatternOracleFill) })),
|
||||
metrics: v.object({ rmseNormalized: v.number(), fuzzDifferentPixels: v.number() }),
|
||||
analysis: v.record(
|
||||
v.string(),
|
||||
v.object({
|
||||
pairedRowCount: v.number(),
|
||||
avgDeltaY: v.number(),
|
||||
avgDeltaFirstX: v.number(),
|
||||
missingOpenPencilRows: v.number(),
|
||||
extraOpenPencilRows: v.number()
|
||||
})
|
||||
)
|
||||
}),
|
||||
effects: v.object({
|
||||
noise: v.object({ results: v.record(v.string(), EffectOracleResult) }),
|
||||
textureAndGlass: v.object({ results: v.record(v.string(), EffectOracleResult) })
|
||||
}),
|
||||
pluginRuntimeCreation: v.record(v.string(), v.object({ ok: v.boolean(), message: v.string() })),
|
||||
currentFileFillTypes: v.record(v.string(), v.number()),
|
||||
localFigFixtureFillTypes: v.record(v.string(), v.record(v.string(), v.number())),
|
||||
status: v.string()
|
||||
})
|
||||
)
|
||||
|
||||
function readOracle() {
|
||||
return v.parse(
|
||||
PaintOracleJSON,
|
||||
readFileSync('tests/fixtures/figma-oracles/pattern-noise-custom-paints.json', 'utf8')
|
||||
) as PaintOracle
|
||||
)
|
||||
}
|
||||
|
||||
describe('Figma pattern/noise/custom paint oracle availability', () => {
|
||||
|
|
|
|||
|
|
@ -1,46 +1,51 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import * as v from 'valibot'
|
||||
|
||||
import { nodeChangeToProps } from '@open-pencil/fig/node-change'
|
||||
import type { NodeChange, Paint } from '@open-pencil/kiwi/fig/codec'
|
||||
import { SceneGraph } from '@open-pencil/scene-graph'
|
||||
|
||||
import { sceneNodeToKiwi } from '#core/kiwi/fig/node-change/serialize'
|
||||
|
||||
interface OracleColor {
|
||||
r: number
|
||||
g: number
|
||||
b: number
|
||||
}
|
||||
const OracleLength = v.object({ unit: v.string(), value: v.number() })
|
||||
|
||||
interface OracleDecorationColor {
|
||||
value: {
|
||||
color: OracleColor
|
||||
opacity: number
|
||||
}
|
||||
}
|
||||
const OracleDecorationColor = v.object({
|
||||
value: v.object({
|
||||
color: v.object({ r: v.number(), g: v.number(), b: v.number() }),
|
||||
opacity: v.number()
|
||||
})
|
||||
})
|
||||
|
||||
interface OracleRange {
|
||||
start: number
|
||||
end: number
|
||||
textDecoration: string
|
||||
textDecorationStyle: string
|
||||
textDecorationThickness: { unit: string; value: number }
|
||||
textDecorationColor: OracleDecorationColor
|
||||
}
|
||||
type OracleDecorationColor = v.InferOutput<typeof OracleDecorationColor>
|
||||
|
||||
interface RichTextOracle {
|
||||
characters: string
|
||||
leadingTrim: string
|
||||
textDecorationOffset: { unit: string; value: number }
|
||||
textDecorationSkipInk: boolean
|
||||
ranges: OracleRange[]
|
||||
}
|
||||
const RichTextOracleJSON = v.pipe(
|
||||
v.string(),
|
||||
v.parseJson(),
|
||||
v.object({
|
||||
characters: v.string(),
|
||||
leadingTrim: v.string(),
|
||||
textDecorationOffset: OracleLength,
|
||||
textDecorationSkipInk: v.boolean(),
|
||||
ranges: v.array(
|
||||
v.object({
|
||||
start: v.number(),
|
||||
end: v.number(),
|
||||
textDecoration: v.string(),
|
||||
textDecorationStyle: v.string(),
|
||||
textDecorationThickness: OracleLength,
|
||||
textDecorationColor: OracleDecorationColor
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
function readOracle(): RichTextOracle {
|
||||
return JSON.parse(
|
||||
function readOracle() {
|
||||
return v.parse(
|
||||
RichTextOracleJSON,
|
||||
readFileSync('tests/fixtures/figma-oracles/rich-text-decoration.json', 'utf8')
|
||||
) as RichTextOracle
|
||||
)
|
||||
}
|
||||
|
||||
function oracleColorToPaint(color: OracleDecorationColor): Paint {
|
||||
|
|
@ -53,7 +58,7 @@ function oracleColorToPaint(color: OracleDecorationColor): Paint {
|
|||
}
|
||||
}
|
||||
|
||||
function styleIdsForOracle(oracle: RichTextOracle): number[] {
|
||||
function styleIdsForOracle(oracle: ReturnType<typeof readOracle>): number[] {
|
||||
const ids = Array.from({ length: oracle.characters.length }, () => 0)
|
||||
const secondRange = oracle.ranges[1]
|
||||
if (!secondRange) return ids
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -438,4 +438,41 @@ describe('BrowserRpcBridge reconnection', () => {
|
|||
expect(result.legitimate).toBe(true)
|
||||
expect(result.injected).toBeUndefined()
|
||||
})
|
||||
|
||||
test('ignores malformed JSON but closes sockets that send malformed messages', async () => {
|
||||
const bridge = createBrowserRPCBridge({
|
||||
authToken: AUTH_TOKEN,
|
||||
onConnectionChange: () => undefined
|
||||
})
|
||||
const closed = (ws: WebSocket) =>
|
||||
new Promise<void>((resolve) => {
|
||||
ws.once('close', () => resolve())
|
||||
})
|
||||
|
||||
const pair = await setupWsPair()
|
||||
track(pair)
|
||||
pair.serverWs.on('message', (raw: Buffer) => {
|
||||
bridge.handleMessage(raw.toString(), pair.serverWs)
|
||||
})
|
||||
pair.clientWs.send('{not json')
|
||||
pair.clientWs.send(JSON.stringify({ type: 'register', token: AUTH_TOKEN }))
|
||||
for (let i = 0; i < 200 && !bridge.isConnected(); i++) {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 5)
|
||||
})
|
||||
}
|
||||
expect(bridge.isConnected()).toBe(true)
|
||||
const arrayClosed = closed(pair.clientWs)
|
||||
pair.clientWs.send('[]')
|
||||
await arrayClosed
|
||||
|
||||
const mistyped = await setupWsPair()
|
||||
track(mistyped)
|
||||
mistyped.serverWs.on('message', (raw: Buffer) => {
|
||||
bridge.handleMessage(raw.toString(), mistyped.serverWs)
|
||||
})
|
||||
const tokenClosed = closed(mistyped.clientWs)
|
||||
mistyped.clientWs.send(JSON.stringify({ type: 'auth', token: 42 }))
|
||||
await tokenClosed
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -10,13 +10,14 @@ import { toolNumber } from '@open-pencil/core/tools'
|
|||
import { SceneGraph } from '@open-pencil/scene-graph'
|
||||
|
||||
import { startServer } from '#mcp/server'
|
||||
import type { DiscoveryInfo } from '#mcp/transport/discovery'
|
||||
import { parseDiscoveryInfo } from '#mcp/transport/discovery'
|
||||
import { DESKTOP_APP_ORIGINS, parseCORSOrigins, resolveCORSOrigins } from '#mcp/transport/origins'
|
||||
|
||||
import { expectDefined } from '#tests/helpers/assert'
|
||||
import {
|
||||
connectMockBrowser,
|
||||
waitForBrowserRegistration,
|
||||
type HealthResponse
|
||||
readHealth,
|
||||
waitForBrowserRegistration
|
||||
} from '#tests/helpers/mcp/server'
|
||||
|
||||
const isUnix = process.platform !== 'win32'
|
||||
|
|
@ -170,7 +171,7 @@ describe('MCP server auto-generated auth token', () => {
|
|||
|
||||
// /health should show authRequired: true
|
||||
const healthResp = await fetch(`http://127.0.0.1:${httpPort}/health`)
|
||||
const health = (await healthResp.json()) as HealthResponse
|
||||
const health = await readHealth(healthResp)
|
||||
expect(health.authRequired).toBe(true)
|
||||
expect(health.tools).toBeUndefined()
|
||||
} finally {
|
||||
|
|
@ -204,7 +205,7 @@ describe('MCP server /rpc auth skip', () => {
|
|||
try {
|
||||
// /health should show authRequired: false
|
||||
const healthResp = await fetch(`http://127.0.0.1:${httpPort}/health`)
|
||||
const health = (await healthResp.json()) as HealthResponse
|
||||
const health = await readHealth(healthResp)
|
||||
expect(health.authRequired).toBe(false)
|
||||
|
||||
// Connect a browser
|
||||
|
|
@ -354,9 +355,7 @@ describe('MCP auth boundary', () => {
|
|||
}
|
||||
|
||||
try {
|
||||
const healthResp = (await (
|
||||
await fetch(`http://127.0.0.1:${httpPort}/health`)
|
||||
).json()) as HealthResponse
|
||||
const healthResp = await readHealth(await fetch(`http://127.0.0.1:${httpPort}/health`))
|
||||
expect(healthResp.authRequired).toBe(false)
|
||||
|
||||
const mcpResp = await fetch(`http://127.0.0.1:${httpPort}/mcp`, {
|
||||
|
|
@ -463,7 +462,10 @@ describe('Discovery PID liveness', () => {
|
|||
})
|
||||
try {
|
||||
const discoveryPath = await getDiscoveryPath()
|
||||
const raw = (await Bun.file(discoveryPath).json()) as Pick<DiscoveryInfo, 'pid'>
|
||||
const raw = expectDefined(
|
||||
parseDiscoveryInfo(await Bun.file(discoveryPath).text()),
|
||||
'discovery file'
|
||||
)
|
||||
expect(raw.pid).toBe(process.pid)
|
||||
} finally {
|
||||
await handle.close()
|
||||
|
|
|
|||
|
|
@ -8,16 +8,20 @@ import type { WebSocket } from 'ws'
|
|||
import { SceneGraph } from '@open-pencil/scene-graph'
|
||||
|
||||
import { startServer, type ServerHandle } from '#mcp/server'
|
||||
import { parseDiscoveryInfo } from '#mcp/transport/discovery'
|
||||
|
||||
import { expectDefined } from '#tests/helpers/assert'
|
||||
import {
|
||||
connectMockBrowser,
|
||||
openWs,
|
||||
readHealth,
|
||||
readNextResponse,
|
||||
readWsJSON,
|
||||
RegisterMessage,
|
||||
ResponseMessage,
|
||||
socketRequest,
|
||||
tcpRequest,
|
||||
waitForBrowserRegistration,
|
||||
type HealthResponse,
|
||||
type MockBrowser
|
||||
} from '#tests/helpers/mcp/server'
|
||||
|
||||
|
|
@ -138,13 +142,7 @@ describe('MCP server unified transport', () => {
|
|||
const discoveryPath = await getDiscoveryPath()
|
||||
const file = Bun.file(discoveryPath)
|
||||
expect(await file.exists()).toBe(true)
|
||||
const info = (await file.json()) as {
|
||||
pid: number
|
||||
httpPort: number
|
||||
socketPath: string | null
|
||||
version: string
|
||||
authToken: string | null
|
||||
}
|
||||
const info = expectDefined(parseDiscoveryInfo(await file.text()), 'discovery file')
|
||||
expect(info.pid).toBe(process.pid)
|
||||
expect(info.httpPort).toBe(handle?.httpPort ?? 0)
|
||||
expect(info.socketPath).toBe(handle?.socketPath ?? null)
|
||||
|
|
@ -233,7 +231,7 @@ describe('MCP WebSocket stdio bridge routing', () => {
|
|||
await waitForBrowserRegistration(httpPort)
|
||||
clientWs = await openWs(`ws://127.0.0.1:${httpPort}`, authToken)
|
||||
|
||||
const register = await readWsJSON<{ type: string; token?: string | null }>(clientWs)
|
||||
const register = await readWsJSON(clientWs, RegisterMessage)
|
||||
expect(register.type).toBe('register')
|
||||
expect(register.token).toBeNull()
|
||||
|
||||
|
|
@ -245,12 +243,7 @@ describe('MCP WebSocket stdio bridge routing', () => {
|
|||
args: { name: 'get_current_page', args: {} }
|
||||
})
|
||||
)
|
||||
const response = await readNextResponse<{
|
||||
type: string
|
||||
id: string
|
||||
ok?: boolean
|
||||
result?: { name: string }
|
||||
}>(clientWs)
|
||||
const response = await readNextResponse(clientWs, ResponseMessage)
|
||||
|
||||
expect(response.type).toBe('response')
|
||||
expect(response.id).toBe('stdio-1')
|
||||
|
|
@ -283,7 +276,7 @@ describe('MCP WebSocket stdio bridge routing', () => {
|
|||
|
||||
clientWs = await openWs(`ws://127.0.0.1:${httpPort}`, 'bridge-test-token')
|
||||
|
||||
const register = await readWsJSON<{ type: string; token?: string | null }>(clientWs)
|
||||
const register = await readWsJSON(clientWs, RegisterMessage)
|
||||
expect(register.type).toBe('register')
|
||||
|
||||
clientWs.send(
|
||||
|
|
@ -294,12 +287,7 @@ describe('MCP WebSocket stdio bridge routing', () => {
|
|||
args: { name: 'get_current_page', args: {} }
|
||||
})
|
||||
)
|
||||
const response = await readNextResponse<{
|
||||
type: string
|
||||
id: string
|
||||
ok?: boolean
|
||||
error?: string
|
||||
}>(clientWs, 15_000)
|
||||
const response = await readNextResponse(clientWs, ResponseMessage, 15_000)
|
||||
|
||||
expect(response.type).toBe('response')
|
||||
expect(response.id).toBe('stdio-no-app')
|
||||
|
|
@ -332,11 +320,11 @@ describe('MCP WebSocket stdio bridge routing', () => {
|
|||
clientWs = await openWs(`ws://127.0.0.1:${httpPort}`, authToken)
|
||||
const graph = new SceneGraph()
|
||||
|
||||
const initialRegister = await readWsJSON<{ type: string; token?: string | null }>(clientWs)
|
||||
const initialRegister = await readWsJSON(clientWs, RegisterMessage)
|
||||
expect(initialRegister.type).toBe('register')
|
||||
expect(initialRegister.token).toBeNull()
|
||||
|
||||
const broadcastPromise = readWsJSON<{ type: string; token?: string | null }>(clientWs, 3_000)
|
||||
const broadcastPromise = readWsJSON(clientWs, RegisterMessage, 3_000)
|
||||
browser = await connectMockBrowser(httpPort, graph, authToken)
|
||||
const broadcastRegister = await broadcastPromise
|
||||
expect(broadcastRegister.type).toBe('register')
|
||||
|
|
@ -369,7 +357,7 @@ describe('MCP WebSocket stdio bridge routing', () => {
|
|||
clientWs = await openWs(`ws://127.0.0.1:${httpPort}`, authToken)
|
||||
const ws = clientWs
|
||||
|
||||
const initReg = await readWsJSON<{ type: string; token?: string | null }>(ws)
|
||||
const initReg = await readWsJSON(ws, RegisterMessage)
|
||||
expect(initReg.type).toBe('register')
|
||||
|
||||
const requestPromise = (async () => {
|
||||
|
|
@ -381,12 +369,7 @@ describe('MCP WebSocket stdio bridge routing', () => {
|
|||
args: { name: 'get_current_page', args: {} }
|
||||
})
|
||||
)
|
||||
return readNextResponse<{
|
||||
type: string
|
||||
id: string
|
||||
ok?: boolean
|
||||
result?: { name: string }
|
||||
}>(ws, 15_000)
|
||||
return readNextResponse(ws, ResponseMessage, 15_000)
|
||||
})()
|
||||
|
||||
await new Promise<void>((r) => {
|
||||
|
|
@ -437,7 +420,7 @@ describe('MCP WebSocket stdio bridge routing', () => {
|
|||
let last: { status: string } = { status: 'unknown' }
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const r = await fetch(`http://127.0.0.1:${httpPort}/health`)
|
||||
last = (await r.json()) as { status: string }
|
||||
last = await readHealth(r)
|
||||
if (predicate(last.status)) return last
|
||||
await sleep(25)
|
||||
}
|
||||
|
|
@ -461,7 +444,7 @@ describe('MCP WebSocket stdio bridge routing', () => {
|
|||
clientWs = await openWs(`ws://127.0.0.1:${httpPort}`, authToken)
|
||||
const ws = clientWs
|
||||
|
||||
const initReg = await readWsJSON<{ type: string; token?: string | null }>(ws)
|
||||
const initReg = await readWsJSON(ws, RegisterMessage)
|
||||
expect(initReg.type).toBe('register')
|
||||
expect(initReg.token).toBeNull()
|
||||
|
||||
|
|
@ -473,7 +456,7 @@ describe('MCP WebSocket stdio bridge routing', () => {
|
|||
args: { name: 'get_current_page', args: {} }
|
||||
})
|
||||
)
|
||||
const response = await readNextResponse<{ type: string; id: string; ok?: boolean }>(ws)
|
||||
const response = await readNextResponse(ws, ResponseMessage)
|
||||
|
||||
expect(response.type).toBe('response')
|
||||
expect(response.id).toBe('stdio-after-reconnect')
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { SceneGraph } from '@open-pencil/scene-graph'
|
|||
import { startServer, type ServerHandle } from '#mcp/server'
|
||||
import { getDiscoveryPath } from '#mcp/transport/paths'
|
||||
|
||||
import { connectMockBrowser, type HealthResponse } from '#tests/helpers/mcp/server'
|
||||
import { connectMockBrowser, readHealth, type HealthResponse } from '#tests/helpers/mcp/server'
|
||||
|
||||
// Issue #488: an MCP server spawned by the app but never claimed by one
|
||||
// (renderer crash, forced reload) used to squat its port forever with a
|
||||
|
|
@ -37,7 +37,7 @@ function sleep(ms: number): Promise<void> {
|
|||
async function waitForHealthStatus(port: number, status: HealthResponse['status']): Promise<void> {
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/health`)
|
||||
const health = (await response.json()) as HealthResponse
|
||||
const health = await readHealth(response)
|
||||
if (health.status === status) return
|
||||
await sleep(5)
|
||||
}
|
||||
|
|
@ -96,7 +96,7 @@ describe('MCP app-attach watchdog', () => {
|
|||
|
||||
const health = await fetch(`http://127.0.0.1:${port}/health`)
|
||||
expect(health.status).toBe(200)
|
||||
const data = (await health.json()) as HealthResponse
|
||||
const data = await readHealth(health)
|
||||
expect(data.status).toBe('ok')
|
||||
} finally {
|
||||
browser.close()
|
||||
|
|
@ -143,7 +143,7 @@ describe('MCP app-attach watchdog', () => {
|
|||
await sleep(250)
|
||||
const health = await fetch(`http://127.0.0.1:${port}/health`)
|
||||
expect(health.status).toBe(200)
|
||||
const data = (await health.json()) as HealthResponse
|
||||
const data = await readHealth(health)
|
||||
expect(data.status).toBe('ok')
|
||||
} finally {
|
||||
secondBrowser.close()
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { join } from 'node:path'
|
|||
|
||||
import type { Client } from '@modelcontextprotocol/client'
|
||||
import type { StdioClientTransport } from '@modelcontextprotocol/client/stdio'
|
||||
import * as v from 'valibot'
|
||||
|
||||
import { SceneGraph } from '@open-pencil/scene-graph'
|
||||
|
||||
|
|
@ -116,6 +117,11 @@ function textContent(content: unknown): string {
|
|||
).text
|
||||
}
|
||||
|
||||
/** The JSON a tool returned as text, checked against the shape the test expects. */
|
||||
function parseTextContent<T>(content: unknown, schema: v.GenericSchema<unknown, T>): T {
|
||||
return v.parse(v.pipe(v.string(), v.parseJson(), schema), textContent(content))
|
||||
}
|
||||
|
||||
describe('MCP stdio transport', () => {
|
||||
let handle: ServerHandle | undefined
|
||||
let graph: SceneGraph | undefined
|
||||
|
|
@ -218,11 +224,10 @@ describe('MCP stdio transport', () => {
|
|||
arguments: { type: 'FRAME', x: 10, y: 20, width: 200, height: 100, name: 'StdioFrame' }
|
||||
})
|
||||
expect(result.isError).not.toBe(true)
|
||||
const data = JSON.parse(textContent(result.content)) as {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
const data = parseTextContent(
|
||||
result.content,
|
||||
v.object({ id: v.string(), name: v.string(), type: v.string() })
|
||||
)
|
||||
expect(data.type).toBe('FRAME')
|
||||
expect(data.name).toBe('StdioFrame')
|
||||
|
||||
|
|
@ -268,9 +273,12 @@ describe('MCP stdio transport', () => {
|
|||
test('list_documents via stdio returns open documents', async () => {
|
||||
const result = await client.callTool({ name: 'list_documents', arguments: {} })
|
||||
expect(result.isError).not.toBe(true)
|
||||
const data = JSON.parse(textContent(result.content)) as {
|
||||
documents: Array<{ id: string; current_page_id: string }>
|
||||
}
|
||||
const data = parseTextContent(
|
||||
result.content,
|
||||
v.object({
|
||||
documents: v.array(v.object({ id: v.string(), current_page_id: v.string() }))
|
||||
})
|
||||
)
|
||||
expect(data.documents[0].id).toBe('doc-1')
|
||||
expect(data.documents[0].current_page_id).toBe(browser?.graph.getPages()[0].id)
|
||||
})
|
||||
|
|
@ -278,14 +286,14 @@ describe('MCP stdio transport', () => {
|
|||
test('save_file via stdio succeeds', async () => {
|
||||
const result = await requireClient().callTool({ name: 'save_file', arguments: {} })
|
||||
expect(result.isError).not.toBe(true)
|
||||
const data = JSON.parse(textContent(result.content)) as { saved: boolean }
|
||||
const data = parseTextContent(result.content, v.object({ saved: v.boolean() }))
|
||||
expect(data.saved).toBe(true)
|
||||
}, 10000)
|
||||
|
||||
test('get_codegen_prompt via stdio returns prompt', async () => {
|
||||
const result = await requireClient().callTool({ name: 'get_codegen_prompt', arguments: {} })
|
||||
expect(result.isError).not.toBe(true)
|
||||
const data = JSON.parse(textContent(result.content)) as { prompt: string }
|
||||
const data = parseTextContent(result.content, v.object({ prompt: v.string() }))
|
||||
expect(data.prompt.length).toBeGreaterThan(100)
|
||||
}, 10000)
|
||||
|
||||
|
|
@ -295,7 +303,7 @@ describe('MCP stdio transport', () => {
|
|||
arguments: { type: 'RECTANGLE', x: 0, y: 0, width: 50, height: 50 }
|
||||
})
|
||||
expect(create.isError).not.toBe(true)
|
||||
const { id } = JSON.parse(textContent(create.content)) as { id: string }
|
||||
const { id } = parseTextContent(create.content, v.object({ id: v.string() }))
|
||||
|
||||
expect(getNodeOrThrow(requireGraph(), id)).toBeDefined()
|
||||
|
||||
|
|
|
|||
|
|
@ -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']) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { expect, setDefaultTimeout, test } from 'bun:test'
|
||||
|
||||
import * as v from 'valibot'
|
||||
|
||||
import { cliSourcePath, repoPath } from '#tests/helpers/paths'
|
||||
import { heavy } from '#tests/helpers/test-utils'
|
||||
|
||||
|
|
@ -23,8 +25,10 @@ async function evalCode(
|
|||
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }
|
||||
}
|
||||
|
||||
function parseJSON<T>(stdout: string): T {
|
||||
return JSON.parse(stdout) as T
|
||||
const RecordJSON = v.pipe(v.string(), v.parseJson(), v.record(v.string(), v.unknown()))
|
||||
|
||||
function parseRecord(stdout: string): Record<string, unknown> {
|
||||
return v.parse(RecordJSON, stdout)
|
||||
}
|
||||
|
||||
heavy('CLI tool operations via eval', () => {
|
||||
|
|
@ -38,7 +42,7 @@ heavy('CLI tool operations via eval', () => {
|
|||
return r.toJSON()
|
||||
`)
|
||||
expect(exitCode).toBe(0)
|
||||
const result = parseJSON<Record<string, unknown>>(stdout)
|
||||
const result = parseRecord(stdout)
|
||||
expect(result.name).toBe('TestRect')
|
||||
expect(result.x).toBe(100)
|
||||
expect(result.y).toBe(200)
|
||||
|
|
@ -54,7 +58,7 @@ heavy('CLI tool operations via eval', () => {
|
|||
return { fills: r.fills }
|
||||
`)
|
||||
expect(exitCode).toBe(0)
|
||||
const result = parseJSON<Record<string, unknown>>(stdout)
|
||||
const result = parseRecord(stdout)
|
||||
expect(result.fills.length).toBe(1)
|
||||
expect(result.fills[0].color.r).toBe(1)
|
||||
})
|
||||
|
|
@ -76,7 +80,7 @@ heavy('CLI tool operations via eval', () => {
|
|||
}
|
||||
`)
|
||||
expect(exitCode).toBe(0)
|
||||
const result = parseJSON<Record<string, unknown>>(stdout)
|
||||
const result = parseRecord(stdout)
|
||||
expect(result.layoutMode).toBe('VERTICAL')
|
||||
expect(result.itemSpacing).toBe(16)
|
||||
expect(result.paddingLeft).toBe(20)
|
||||
|
|
@ -91,7 +95,7 @@ heavy('CLI tool operations via eval', () => {
|
|||
return { name: comp.name, type: comp.type }
|
||||
`)
|
||||
expect(exitCode).toBe(0)
|
||||
const result = parseJSON<Record<string, unknown>>(stdout)
|
||||
const result = parseRecord(stdout)
|
||||
expect(result.name).toBe('Button')
|
||||
expect(result.type).toBe('COMPONENT')
|
||||
})
|
||||
|
|
@ -110,7 +114,7 @@ heavy('CLI tool operations via eval', () => {
|
|||
return { groupType, childCount, ungroupedExists: ungrouped !== null }
|
||||
`)
|
||||
expect(exitCode).toBe(0)
|
||||
const result = parseJSON<Record<string, unknown>>(stdout)
|
||||
const result = parseRecord(stdout)
|
||||
expect(result.groupType).toBe('GROUP')
|
||||
expect(result.childCount).toBe(2)
|
||||
expect(result.ungroupedExists).toBe(false)
|
||||
|
|
@ -122,7 +126,7 @@ heavy('CLI tool operations via eval', () => {
|
|||
return { count: texts.length, hasTexts: texts.length > 0 }
|
||||
`)
|
||||
expect(exitCode).toBe(0)
|
||||
const result = parseJSON<Record<string, unknown>>(stdout)
|
||||
const result = parseRecord(stdout)
|
||||
expect(result.hasTexts).toBe(true)
|
||||
expect(result.count).toBeGreaterThan(0)
|
||||
})
|
||||
|
|
@ -140,7 +144,7 @@ heavy('CLI tool operations via eval', () => {
|
|||
}
|
||||
`)
|
||||
expect(exitCode).toBe(0)
|
||||
const result = parseJSON<Record<string, unknown>>(stdout)
|
||||
const result = parseRecord(stdout)
|
||||
expect(result.same).toBe(false)
|
||||
expect(result.cloneName).toBe('Original')
|
||||
expect(result.cloneWidth).toBe(100)
|
||||
|
|
@ -159,7 +163,7 @@ heavy('CLI tool operations via eval', () => {
|
|||
}
|
||||
`)
|
||||
expect(exitCode).toBe(0)
|
||||
const result = parseJSON<Record<string, unknown>>(stdout)
|
||||
const result = parseRecord(stdout)
|
||||
expect(result.isChild).toBe(true)
|
||||
})
|
||||
|
||||
|
|
@ -171,7 +175,7 @@ heavy('CLI tool operations via eval', () => {
|
|||
return r.constraints
|
||||
`)
|
||||
expect(exitCode).toBe(0)
|
||||
const result = parseJSON<Record<string, unknown>>(stdout)
|
||||
const result = parseRecord(stdout)
|
||||
expect(result.horizontal).toBe('CENTER')
|
||||
expect(result.vertical).toBe('STRETCH')
|
||||
})
|
||||
|
|
@ -192,7 +196,7 @@ heavy('CLI tool operations via eval', () => {
|
|||
return { count: f.effects.length, type: f.effects[0].type }
|
||||
`)
|
||||
expect(exitCode).toBe(0)
|
||||
const result = parseJSON<Record<string, unknown>>(stdout)
|
||||
const result = parseRecord(stdout)
|
||||
expect(result.count).toBe(1)
|
||||
expect(result.type).toBe('DROP_SHADOW')
|
||||
})
|
||||
|
|
@ -204,7 +208,7 @@ heavy('CLI tool operations via eval', () => {
|
|||
return { variables: vars.length, collections: cols.length }
|
||||
`)
|
||||
expect(exitCode).toBe(0)
|
||||
const result = parseJSON<Record<string, unknown>>(stdout)
|
||||
const result = parseRecord(stdout)
|
||||
expect(typeof result.variables).toBe('number')
|
||||
expect(typeof result.collections).toBe('number')
|
||||
})
|
||||
|
|
@ -217,7 +221,7 @@ heavy('CLI tool operations via eval', () => {
|
|||
return { page: figma.currentPage.name, pageCount: pages.length }
|
||||
`)
|
||||
expect(exitCode).toBe(0)
|
||||
const result = parseJSON<Record<string, unknown>>(stdout)
|
||||
const result = parseRecord(stdout)
|
||||
expect(result.page).toBeTruthy()
|
||||
expect(result.pageCount).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
import * as v from 'valibot'
|
||||
|
||||
import {
|
||||
parseFigFile,
|
||||
type ParseFigFileOptions,
|
||||
|
|
@ -33,9 +35,11 @@ export const VALID_NODE_TYPES = new Set<string>([
|
|||
'SHAPE_WITH_TEXT'
|
||||
])
|
||||
|
||||
/** Read a shared JSON fixture as data. */
|
||||
export function readFixtureJSON<T>(name: string): T {
|
||||
return JSON.parse(readFileSync(resolve(FIXTURES, name), 'utf8')) as T
|
||||
const FixtureObjectJSON = v.pipe(v.string(), v.parseJson(), v.record(v.string(), v.unknown()))
|
||||
|
||||
/** Read a shared JSON fixture object. */
|
||||
export function readFixtureObject(name: string): Record<string, unknown> {
|
||||
return v.parse(FixtureObjectJSON, readFileSync(resolve(FIXTURES, name), 'utf8'))
|
||||
}
|
||||
|
||||
export function readFixtureBytes(name: string): Uint8Array {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { type RequestOptions, request as httpRequest } from 'node:http'
|
||||
|
||||
import * as v from 'valibot'
|
||||
import { WebSocket } from 'ws'
|
||||
|
||||
import {
|
||||
|
|
@ -11,12 +12,28 @@ import {
|
|||
} from '@open-pencil/core'
|
||||
import type { ToolDescriptor } from '@open-pencil/mcp/tools'
|
||||
|
||||
export interface HealthResponse {
|
||||
status: string
|
||||
version: string
|
||||
authRequired: boolean
|
||||
tools?: ToolDescriptor[]
|
||||
discoveryPath?: string
|
||||
const ToolDescriptorShape = v.looseObject({ name: v.string(), enabled: v.boolean() })
|
||||
|
||||
export const HealthResponseSchema = v.object({
|
||||
status: v.string(),
|
||||
version: v.string(),
|
||||
authRequired: v.boolean(),
|
||||
tools: v.optional(v.array(v.custom<ToolDescriptor>((value) => v.is(ToolDescriptorShape, value)))),
|
||||
discoveryPath: v.optional(v.string())
|
||||
})
|
||||
|
||||
export type HealthResponse = v.InferOutput<typeof HealthResponseSchema>
|
||||
|
||||
/** Read a JSON response body or file, checked against `schema`. */
|
||||
export async function readJSON<T>(
|
||||
source: { text(): Promise<string> },
|
||||
schema: v.GenericSchema<unknown, T>
|
||||
): Promise<T> {
|
||||
return v.parse(v.pipe(v.string(), v.parseJson(), schema), await source.text())
|
||||
}
|
||||
|
||||
export function readHealth(response: Response): Promise<HealthResponse> {
|
||||
return readJSON(response, HealthResponseSchema)
|
||||
}
|
||||
|
||||
export interface MockBrowserRequest {
|
||||
|
|
@ -31,8 +48,27 @@ export interface MockBrowser {
|
|||
close: () => void
|
||||
}
|
||||
|
||||
/** Read the next WebSocket JSON message with a timeout. */
|
||||
export function readWsJSON<T>(ws: WebSocket, timeoutMs = 1000): Promise<T> {
|
||||
/** A `register` broadcast: the browser bridge announcing its token. */
|
||||
export const RegisterMessage = v.object({
|
||||
type: v.string(),
|
||||
token: v.optional(v.nullable(v.string()))
|
||||
})
|
||||
|
||||
/** A tool response relayed through the browser bridge. */
|
||||
export const ResponseMessage = v.object({
|
||||
type: v.string(),
|
||||
id: v.string(),
|
||||
ok: v.optional(v.boolean()),
|
||||
result: v.optional(v.looseObject({ name: v.optional(v.string()) })),
|
||||
error: v.optional(v.string())
|
||||
})
|
||||
|
||||
/** Read the next WebSocket JSON message with a timeout, checked against `schema`. */
|
||||
export function readWsJSON<T>(
|
||||
ws: WebSocket,
|
||||
schema: v.GenericSchema<unknown, T>,
|
||||
timeoutMs = 1000
|
||||
): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer)
|
||||
|
|
@ -42,7 +78,7 @@ export function readWsJSON<T>(ws: WebSocket, timeoutMs = 1000): Promise<T> {
|
|||
const onMessage = (raw: WebSocket.RawData) => {
|
||||
cleanup()
|
||||
try {
|
||||
resolve(JSON.parse(raw.toString()) as T)
|
||||
resolve(v.parse(v.pipe(v.string(), v.parseJson(), schema), raw.toString()))
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
|
|
@ -78,13 +114,17 @@ export function openWs(url: string, authToken?: string | null): Promise<WebSocke
|
|||
}
|
||||
|
||||
/** Read the next WebSocket JSON message, skipping any 'register' broadcasts. */
|
||||
export async function readNextResponse<T>(ws: WebSocket, timeoutMs = 5000): Promise<T> {
|
||||
export async function readNextResponse<T>(
|
||||
ws: WebSocket,
|
||||
schema: v.GenericSchema<unknown, T>,
|
||||
timeoutMs = 5000
|
||||
): Promise<T> {
|
||||
const start = Date.now()
|
||||
for (let i = 0; ; i++) {
|
||||
const remaining = timeoutMs - (Date.now() - start)
|
||||
if (remaining <= 0) throw new Error(`Timed out after reading ${i} register messages`)
|
||||
const msg = await readWsJSON<T & { type: string }>(ws, remaining)
|
||||
if (msg.type !== 'register') return msg
|
||||
const msg = await readWsJSON(ws, v.looseObject({ type: v.string() }), remaining)
|
||||
if (msg.type !== 'register') return v.parse(schema, msg)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -307,7 +347,7 @@ export async function waitForBrowserRegistration(port: number, timeoutMs = 5000)
|
|||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const resp = await fetch(`http://127.0.0.1:${port}/health`)
|
||||
const health = (await resp.json()) as HealthResponse
|
||||
const health = await readHealth(resp)
|
||||
lastStatus = health.status
|
||||
if (health.status === 'ok') return
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ import { expect, test } from 'bun:test'
|
|||
import { materializeFigFragment } from '@open-pencil/fig'
|
||||
import { prepareGraphTransfer, applyGraphTransfer, SceneGraph } from '@open-pencil/scene-graph'
|
||||
|
||||
import { readFixtureJSON } from '#tests/helpers/fig/fixtures'
|
||||
import { readFixtureObject } from '#tests/helpers/fig/fixtures'
|
||||
|
||||
const fixture = readFixtureJSON<Record<string, unknown>[]>('nested-binding-ownership-records.json')
|
||||
const fixture = readFixtureObject('nested-binding-ownership-records.json')
|
||||
|
||||
test('prepares a real FIG fragment without mutating the destination', () => {
|
||||
const fragment = materializeFigFragment(
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
106
tools/checks/lint/src/rules/quality/json-parse.ts
Normal file
106
tools/checks/lint/src/rules/quality/json-parse.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { resolveVariable } from '#lint/support/scope.ts'
|
||||
import { defineRule } from '@oxlint/plugins'
|
||||
import type { ESTree, SourceCode } from '@oxlint/plugins'
|
||||
|
||||
type Assertion = Extract<ESTree.Node, { type: 'TSAsExpression' | 'TSTypeAssertion' }>
|
||||
|
||||
const GLOBAL_OBJECTS = new Set(['globalThis', 'window', 'self'])
|
||||
|
||||
function propertyName(node: ESTree.MemberExpression): string | null {
|
||||
if (!node.computed && node.property.type === 'Identifier') return node.property.name
|
||||
if (node.computed && node.property.type === 'Literal' && typeof node.property.value === 'string')
|
||||
return node.property.value
|
||||
return null
|
||||
}
|
||||
|
||||
/** The global `JSON`, directly or through `globalThis`, that no local binding shadows. */
|
||||
function isGlobalJSON(sourceCode: SourceCode, node: ESTree.Expression): boolean {
|
||||
if (node.type === 'Identifier') {
|
||||
if (node.name !== 'JSON') return false
|
||||
const variable = resolveVariable(sourceCode, node)
|
||||
return variable === null || variable.defs.length === 0
|
||||
}
|
||||
return (
|
||||
node.type === 'MemberExpression' &&
|
||||
node.object.type === 'Identifier' &&
|
||||
GLOBAL_OBJECTS.has(node.object.name) &&
|
||||
propertyName(node) === 'JSON'
|
||||
)
|
||||
}
|
||||
|
||||
function isJSONParseCall(sourceCode: SourceCode, node: ESTree.Expression): boolean {
|
||||
if (node.type !== 'CallExpression' || node.callee.type !== 'MemberExpression') return false
|
||||
return propertyName(node.callee) === 'parse' && isGlobalJSON(sourceCode, node.callee.object)
|
||||
}
|
||||
|
||||
/** `response.json()`, `Bun.file(path).json()` and the like: a parse with no schema. */
|
||||
function isJSONMethodCall(node: ESTree.Expression): boolean {
|
||||
return (
|
||||
node.type === 'CallExpression' &&
|
||||
node.arguments.length === 0 &&
|
||||
node.callee.type === 'MemberExpression' &&
|
||||
propertyName(node.callee) === 'json'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip parentheses, `await`, and the assertions layered on the parsed value
|
||||
* (`as unknown as Foo`).
|
||||
*/
|
||||
function assertedExpression(node: Assertion): ESTree.Expression {
|
||||
let current: ESTree.Expression = node.expression
|
||||
while (
|
||||
current.type === 'ParenthesizedExpression' ||
|
||||
current.type === 'AwaitExpression' ||
|
||||
current.type === 'TSAsExpression' ||
|
||||
current.type === 'TSTypeAssertion' ||
|
||||
current.type === 'TSSatisfiesExpression' ||
|
||||
current.type === 'TSNonNullExpression'
|
||||
) {
|
||||
current = current.type === 'AwaitExpression' ? current.argument : current.expression
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
/** `unknown`, or `Promise<unknown>` for an un-awaited `.json()`. */
|
||||
function isUnknownType(type: ESTree.TSType): boolean {
|
||||
let current = type
|
||||
while (current.type === 'TSParenthesizedType') current = current.typeAnnotation
|
||||
if (
|
||||
current.type === 'TSTypeReference' &&
|
||||
current.typeName.type === 'Identifier' &&
|
||||
current.typeName.name === 'Promise' &&
|
||||
current.typeArguments?.params.length === 1
|
||||
) {
|
||||
const [argument] = current.typeArguments.params
|
||||
return argument !== undefined && isUnknownType(argument)
|
||||
}
|
||||
return current.type === 'TSUnknownKeyword'
|
||||
}
|
||||
|
||||
/** Require a schema, not a type assertion, to give `JSON.parse` output a shape. */
|
||||
export const noUnvalidatedJSONParseRule = defineRule({
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow type assertions on JSON.parse and `.json()` results other than `as unknown`.'
|
||||
},
|
||||
messages: {
|
||||
unvalidated:
|
||||
'Validate parsed JSON instead of asserting its type: v.safeParse(v.pipe(v.string(), v.parseJson(), Schema), text) (for a Response, read `await response.text()`), or assert only `as unknown` and narrow.'
|
||||
}
|
||||
},
|
||||
createOnce(context) {
|
||||
function check(node: Assertion): void {
|
||||
if (isUnknownType(node.typeAnnotation)) return
|
||||
const parsed = assertedExpression(node)
|
||||
if (isJSONParseCall(context.sourceCode, parsed) || isJSONMethodCall(parsed))
|
||||
context.report({ node, messageId: 'unvalidated' })
|
||||
}
|
||||
return {
|
||||
TSAsExpression: check,
|
||||
TSTypeAssertion: check
|
||||
}
|
||||
}
|
||||
})
|
||||
40
tools/checks/lint/tests/json-parse.test.ts
Normal file
40
tools/checks/lint/tests/json-parse.test.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { lint, ruleDiagnostics } from './helpers/lint.ts'
|
||||
|
||||
const rule = 'no-unvalidated-json-parse'
|
||||
const rules = { [`open-pencil/${rule}`]: 'error' }
|
||||
|
||||
describe('no-unvalidated-json-parse', () => {
|
||||
test.each([
|
||||
'type Foo = { a: number }; JSON.parse(text) as Foo',
|
||||
'type Foo = { a: number }; <Foo>JSON.parse(text)',
|
||||
'JSON.parse(text) as { a: number }',
|
||||
'JSON.parse(text) as unknown as string[]',
|
||||
'(JSON.parse(text)) as Record<string, unknown>',
|
||||
'globalThis.JSON.parse(text) as string[]',
|
||||
"JSON['parse'](text) as string[]",
|
||||
'declare const response: Response; (await response.json()) as { a: number }',
|
||||
'declare const response: Response; response.json() as Promise<string[]>',
|
||||
'declare const file: { json(): Promise<unknown> }; (await file.json()) as string[]'
|
||||
])('rejects %s', async (source) => {
|
||||
expect(
|
||||
ruleDiagnostics(await lint(`declare const text: string; ${source}`, rules), rule)
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
test.each([
|
||||
'JSON.parse(text) as unknown',
|
||||
'const value: unknown = JSON.parse(text)',
|
||||
'JSON.stringify(text) as string',
|
||||
'const JSON = { parse: (value: string) => value }; JSON.parse(text) as string',
|
||||
'type Foo = { a: number }; declare const value: unknown; value as Foo',
|
||||
'declare const response: Response; (await response.json()) as unknown',
|
||||
'declare const response: Response; response.json() as Promise<unknown>',
|
||||
'declare const api: { json(key: string): unknown }; api.json(text) as string'
|
||||
])('accepts %s', async (source) => {
|
||||
expect(
|
||||
ruleDiagnostics(await lint(`declare const text: string; ${source}`, rules), rule)
|
||||
).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,16 +1,26 @@
|
|||
import { readFile } from 'node:fs/promises'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import * as v from 'valibot'
|
||||
|
||||
const CODE_RABBIT_AUTHORS = new Set(['coderabbitai[bot]', 'coderabbitai'])
|
||||
const REVIEW_GUIDANCE_PREFIXES = ['pr hygiene', 'pr readability', 'pr description']
|
||||
|
||||
export interface GitHubEvent {
|
||||
sender?: { login?: string }
|
||||
review?: { state?: string; body?: string }
|
||||
pull_request?: { number?: number }
|
||||
issue?: { number?: number; pull_request?: unknown }
|
||||
comment?: { body?: string }
|
||||
}
|
||||
const GitHubEventJSON = v.pipe(
|
||||
v.string(),
|
||||
v.parseJson(),
|
||||
v.object({
|
||||
sender: v.optional(v.object({ login: v.optional(v.string()) })),
|
||||
review: v.optional(v.object({ state: v.optional(v.string()), body: v.optional(v.string()) })),
|
||||
pull_request: v.optional(v.object({ number: v.optional(v.number()) })),
|
||||
issue: v.optional(
|
||||
v.object({ number: v.optional(v.number()), pull_request: v.optional(v.unknown()) })
|
||||
),
|
||||
comment: v.optional(v.object({ body: v.optional(v.string()) }))
|
||||
})
|
||||
)
|
||||
|
||||
export type GitHubEvent = v.InferOutput<typeof GitHubEventJSON>
|
||||
|
||||
export interface PullRequestSummary {
|
||||
state: string
|
||||
|
|
@ -19,6 +29,13 @@ export interface PullRequestSummary {
|
|||
user: { login: string }
|
||||
}
|
||||
|
||||
const PullRequestSummarySchema: v.GenericSchema<unknown, PullRequestSummary> = v.object({
|
||||
state: v.string(),
|
||||
author_association: v.string(),
|
||||
title: v.string(),
|
||||
user: v.object({ login: v.string() })
|
||||
})
|
||||
|
||||
interface EventContext {
|
||||
issueNumber?: number
|
||||
shouldInspect: boolean
|
||||
|
|
@ -113,6 +130,7 @@ async function github<T>(
|
|||
token: string,
|
||||
path: string,
|
||||
fetchImpl: FetchLike,
|
||||
schema: v.GenericSchema<unknown, T>,
|
||||
options: RequestInit = {}
|
||||
): Promise<T | null> {
|
||||
const response = await fetchImpl(`${apiURL}${path}`, {
|
||||
|
|
@ -134,7 +152,7 @@ async function github<T>(
|
|||
}
|
||||
|
||||
if (response.status === 204) return null
|
||||
return response.json() as Promise<T>
|
||||
return v.parse(v.pipe(v.string(), v.parseJson(), schema), await response.text())
|
||||
}
|
||||
|
||||
function repositoryName(repository?: string): string | undefined {
|
||||
|
|
@ -159,7 +177,7 @@ export async function monitorPRReviewGuidance(options: MonitorOptions = {}): Pro
|
|||
const env = requiredEnvironment(options.env ?? process.env)
|
||||
const log = options.log ?? ((message: string) => process.stdout.write(`${message}\n`))
|
||||
const fetchImpl = options.fetchImpl ?? fetch
|
||||
const event = JSON.parse(await readFile(env.eventPath, 'utf8')) as GitHubEvent
|
||||
const event = v.parse(GitHubEventJSON, await readFile(env.eventPath, 'utf8'))
|
||||
const sender = event.sender?.login ?? ''
|
||||
|
||||
if (!CODE_RABBIT_AUTHORS.has(sender)) {
|
||||
|
|
@ -181,11 +199,12 @@ export async function monitorPRReviewGuidance(options: MonitorOptions = {}): Pro
|
|||
return
|
||||
}
|
||||
|
||||
const pr = await github<PullRequestSummary>(
|
||||
const pr = await github(
|
||||
env.apiURL,
|
||||
env.token,
|
||||
`/repos/${env.owner}/${env.repo}/pulls/${context.issueNumber}`,
|
||||
fetchImpl
|
||||
fetchImpl,
|
||||
PullRequestSummarySchema
|
||||
)
|
||||
if (!pr) throw new Error(`PR #${context.issueNumber} returned no data`)
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -1,5 +1,57 @@
|
|||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
import * as v from 'valibot'
|
||||
|
||||
import type { NavigationMetrics } from './types'
|
||||
|
||||
const distributionSchema = v.object({
|
||||
count: v.number(),
|
||||
min: v.number(),
|
||||
median: v.number(),
|
||||
p95: v.number(),
|
||||
p99: v.number(),
|
||||
max: v.number(),
|
||||
mean: v.number()
|
||||
})
|
||||
|
||||
const thresholdsSchema = v.object({
|
||||
over8Ms: v.number(),
|
||||
over16Ms: v.number(),
|
||||
over33Ms: v.number(),
|
||||
over50Ms: v.number()
|
||||
})
|
||||
|
||||
const metricsSchema: v.GenericSchema<unknown, NavigationMetrics> = v.object({
|
||||
durationMs: v.number(),
|
||||
eventCount: v.number(),
|
||||
viewportUpdateCount: v.number(),
|
||||
renderCount: v.number(),
|
||||
displayFrameIntervalsMs: distributionSchema,
|
||||
renderFrameIntervalsMs: distributionSchema,
|
||||
renderDurationsMs: distributionSchema,
|
||||
eventToViewportMs: distributionSchema,
|
||||
eventToRenderEndMs: distributionSchema,
|
||||
zoomAnchorDriftPx: distributionSchema,
|
||||
maximumJumpPx: v.number(),
|
||||
finalInputToCrispMs: v.nullable(v.number()),
|
||||
scheduler: v.object({
|
||||
frameCount: v.number(),
|
||||
maximumJobsPerFrame: v.number(),
|
||||
maximumJobRenderMs: v.number(),
|
||||
overBudgetJobs: v.number(),
|
||||
maximumDeadlineOverrunMs: v.number(),
|
||||
cancelledJobs: v.number()
|
||||
}),
|
||||
longTasks: v.object({ count: v.number(), totalMs: v.number(), maximumMs: v.number() }),
|
||||
missedDisplayFrames: thresholdsSchema,
|
||||
renderGaps: thresholdsSchema
|
||||
})
|
||||
|
||||
/** Read metrics a previous `run` wrote. */
|
||||
export async function readMetrics(path: string): Promise<NavigationMetrics> {
|
||||
return v.parse(v.pipe(v.string(), v.parseJson(), metricsSchema), await readFile(path, 'utf8'))
|
||||
}
|
||||
|
||||
export interface MetricComparison {
|
||||
baseline: number | null
|
||||
candidate: number | null
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import * as v from 'valibot'
|
||||
|
||||
export interface SceneOracleNode {
|
||||
path: number[]
|
||||
type: string
|
||||
|
|
@ -14,6 +16,22 @@ export interface SceneOracleNode {
|
|||
strokes: string[]
|
||||
}
|
||||
|
||||
/** One node of a scene oracle, as captures and baseline files store it. */
|
||||
export const SceneOracleNodeSchema: v.GenericSchema<unknown, SceneOracleNode> = v.object({
|
||||
path: v.array(v.number()),
|
||||
type: v.string(),
|
||||
name: v.string(),
|
||||
visible: v.boolean(),
|
||||
x: v.number(),
|
||||
y: v.number(),
|
||||
width: v.number(),
|
||||
height: v.number(),
|
||||
text: v.nullable(v.string()),
|
||||
main: v.nullable(v.string()),
|
||||
fills: v.array(v.string()),
|
||||
strokes: v.array(v.string())
|
||||
})
|
||||
|
||||
export interface SceneOracleDifference {
|
||||
path: number[]
|
||||
category:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { parseArgs } from 'node:util'
|
||||
|
||||
import * as v from 'valibot'
|
||||
|
||||
/**
|
||||
* Bring a Figma desktop tab to the front by its title. `figma-use` and the oracle captures
|
||||
* target the active document, so a reopened export must be the visible tab first. The tab
|
||||
|
|
@ -10,18 +12,30 @@ const { values } = parseArgs({
|
|||
})
|
||||
if (!values.title) throw new Error('Required: --title <tab title> [--port 9222]')
|
||||
|
||||
interface Target {
|
||||
type: string
|
||||
url: string
|
||||
webSocketDebuggerUrl: string
|
||||
}
|
||||
const targets = (await (
|
||||
await fetch(`http://localhost:${values.port}/json/list`)
|
||||
).json()) as Target[]
|
||||
const TargetsJSON = v.pipe(
|
||||
v.string(),
|
||||
v.parseJson(),
|
||||
v.array(v.looseObject({ type: v.string(), url: v.string(), webSocketDebuggerUrl: v.string() }))
|
||||
)
|
||||
const targets = v.parse(
|
||||
TargetsJSON,
|
||||
await (await fetch(`http://localhost:${values.port}/json/list`)).text()
|
||||
)
|
||||
const shell = targets.find((t) => t.type === 'page' && t.url.endsWith('shell.html'))
|
||||
if (!shell)
|
||||
throw new Error('Figma desktop shell page not found; is Figma running with remote debugging?')
|
||||
|
||||
const EvaluateResponseJSON = v.pipe(
|
||||
v.string(),
|
||||
v.parseJson(),
|
||||
v.object({
|
||||
// CDP omits `value` for results it cannot return by value.
|
||||
result: v.optional(
|
||||
v.object({ result: v.optional(v.object({ value: v.optional(v.unknown()) })) })
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
const socket = new WebSocket(shell.webSocketDebuggerUrl)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.onopen = () => resolve()
|
||||
|
|
@ -37,15 +51,14 @@ const expression = `(() => {
|
|||
socket.send(
|
||||
JSON.stringify({ id: 1, method: 'Runtime.evaluate', params: { expression, returnByValue: true } })
|
||||
)
|
||||
const result = await new Promise<unknown>((resolve) => {
|
||||
// A throw inside onmessage would leave this pending forever, so a bad frame rejects instead.
|
||||
const result = await new Promise<unknown>((resolve, reject) => {
|
||||
socket.onmessage = (message) => {
|
||||
const payload = JSON.parse(String(message.data)) as {
|
||||
result?: { result?: { value?: unknown } }
|
||||
}
|
||||
resolve(payload.result?.result?.value)
|
||||
const parsed = v.safeParse(EvaluateResponseJSON, String(message.data))
|
||||
if (parsed.success) resolve(parsed.output.result?.result?.value)
|
||||
else reject(new v.ValiError(parsed.issues))
|
||||
}
|
||||
})
|
||||
socket.close()
|
||||
}).finally(() => socket.close())
|
||||
if (result !== true)
|
||||
throw new Error(`No tab titled ${values.title}; open tabs: ${JSON.stringify(result)}`)
|
||||
await new Promise<void>((resolve) => {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
import { parseArgs } from 'node:util'
|
||||
|
||||
import { captureDocumentOracle } from '#visual/capture/document'
|
||||
import { compareSceneOracle, type SceneOracleNode } from '#visual/scene-oracle'
|
||||
import { compareSceneOracle, SceneOracleNodeSchema } from '#visual/scene-oracle'
|
||||
import * as v from 'valibot'
|
||||
|
||||
import { materializeFigArchive } from '@open-pencil/fig'
|
||||
|
||||
|
|
@ -33,7 +34,10 @@ if (!values.baseline) {
|
|||
JSON.stringify({ nodes: nodes.length, pages: graph.getPages().length, unresolved }, null, 2)
|
||||
)
|
||||
} else {
|
||||
const baseline = (await Bun.file(values.baseline).json()) as SceneOracleNode[]
|
||||
const baseline = v.parse(
|
||||
v.pipe(v.string(), v.parseJson(), v.array(SceneOracleNodeSchema)),
|
||||
await Bun.file(values.baseline).text()
|
||||
)
|
||||
const differences = compareSceneOracle(baseline, nodes)
|
||||
const counts: Record<string, number> = {}
|
||||
for (const difference of differences)
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
validatePublicationArtifacts
|
||||
} from '#release/workflow'
|
||||
import { createTarGzip } from 'nanotar'
|
||||
import * as v from 'valibot'
|
||||
|
||||
import { readTarball } from '@open-pencil/package-artifacts-tools/tarball'
|
||||
|
||||
|
|
@ -29,12 +30,15 @@ async function listFiles(directory: string, base = directory): Promise<string[]>
|
|||
return nested.flat().sort()
|
||||
}
|
||||
|
||||
const ManifestJSON = v.pipe(
|
||||
v.string(),
|
||||
v.parseJson(),
|
||||
v.object({ name: v.string(), version: v.string() })
|
||||
)
|
||||
|
||||
/** Packs a prepared directory the way npm lays out a tarball, without starting npm. */
|
||||
async function packInProcess(directory: string, destination: string): Promise<string> {
|
||||
const manifest = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8')) as {
|
||||
name: string
|
||||
version: string
|
||||
}
|
||||
const manifest = v.parse(ManifestJSON, await readFile(join(directory, 'package.json'), 'utf8'))
|
||||
const files = await Promise.all(
|
||||
(await listFiles(directory)).map(async (name) => ({
|
||||
name: `package/${name}`,
|
||||
|
|
|
|||
Loading…
Reference in a new issue