openpencil/tools/dev/navigation-benchmark/src/cli.ts
Danila Poyarkov e2a3aa3f80
fix: validate parsed JSON at untrusted boundaries with Valibot (#855)
* fix: validate parsed JSON at untrusted boundaries with Valibot

Clipboard HTML, library revisions from shared storage, MCP and automation
WebSocket messages, the MCP discovery file, sidecar output and AI/MCP tool
arguments were JSON.parse'd and cast to their expected types, so a
malformed payload reached the document or crashed paste. They now go
through v.pipe(v.string(), v.parseJson(), Schema), which reports bad JSON
and a wrong shape as the same validation failure.

The path_set tool rejects an invalid VectorNetwork and shares its parser
with create_vector. The CLI library catalog validates its files and runs
revisions through the same size, identity and content-hash checks as the
app; reading image bytes as index-keyed records also stops them coming
back empty. Hand-rolled typeof readers for plugin data, document metadata,
caches and preferences become schemas with their behaviour preserved, and
readCacheJSON takes a schema for its payload.

open-pencil/no-unvalidated-json-parse rejects type assertions on
JSON.parse results other than `as unknown` in src and packages/*/src.

* refactor: validate parsed JSON in tests and tooling

Extend open-pencil/no-unvalidated-json-parse beyond source: tests, helpers and repo tooling now parse JSON through Valibot schemas instead of asserting a type. The shared fixture reader returns a validated object; its old array annotation never matched the fixtures.

* fix: validate clipboard geometry bytes, library images and model catalogs

Clipboard geometry blobs and library image bytes must be bytes at contiguous indexes, so out-of-range or gapped values are rejected instead of silently becoming different geometry or images; serialized library nodes must carry source metadata. The models.dev and OpenRouter responses are validated like their cached copies, and activate-tab rejects a CDP frame it cannot read instead of hanging.

* refactor: extend the JSON validation lint to .json() results

no-unvalidated-json-parse now also rejects type assertions on Response, Bun.file and shell .json() results, the same unchecked parse in another form. MCP server tests read /health through a validated readHealth helper and discovery files through parseDiscoveryInfo; the remaining tooling reads its JSON through schemas.

* test: validate the RPC request body in the CLI app export test

* test: validate CLI JSON output in the tool and app command tests

* test: compare the malformed models.dev fallback with the curated list
2026-10-04 17:01:50 +00:00

218 lines
8.4 KiB
TypeScript

import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { chromium } from '@playwright/test'
import { startChromiumTrace } from './chromium-trace'
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 { NavigationRecordingFile } from './types'
function argument(name: string, fallback?: string): string {
const index = process.argv.indexOf(name)
const value = index === -1 ? fallback : process.argv[index + 1]
if (!value) throw new Error(`Missing ${name}`)
return value
}
const command = process.argv[2]
if (command === 'compare') {
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))
console.log(JSON.stringify(comparison, null, 2))
process.exit(0)
}
if (command !== 'run') {
console.log(
'Usage: bun tools/dev/navigation-benchmark/src/cli.ts run --url URL --gesture FILE [--mode cdp|dom] [--scenario light|large-flat|raster-stress|current-document] [--document FILE] [--no-trace] [--cpu-profile] [--software-gpu] [--output DIR]\n bun tools/dev/navigation-benchmark/src/cli.ts compare --baseline METRICS --candidate METRICS [--output FILE]'
)
process.exit(command ? 1 : 0)
}
const url = argument('--url')
const gesturePath = argument('--gesture')
const mode = argument('--mode', 'cdp') as ReplayMode
const scenario = argument('--scenario', 'light') as NavigationScenario
const documentPath = process.argv.includes('--document') ? resolve(argument('--document')) : null
const mutationNodeId = process.argv.includes('--mutate-node') ? argument('--mutate-node') : null
const mutationOpacity = process.argv.includes('--mutate-opacity')
? Number(argument('--mutate-opacity'))
: null
const replayAfterMutation = process.argv.includes('--replay-after-mutation')
const traceEnabled = !process.argv.includes('--no-trace')
const cpuProfile = process.argv.includes('--cpu-profile')
const softwareGpu = process.argv.includes('--software-gpu')
const output = resolve(argument('--output', 'artifacts/navigation-benchmark'))
if (mode !== 'cdp' && mode !== 'dom') throw new Error(`Unsupported replay mode: ${mode}`)
if (!['light', 'large-flat', 'raster-stress', 'current-document'].includes(scenario)) {
throw new Error(`Unsupported scenario: ${scenario}`)
}
if (scenario === 'current-document' && !documentPath) {
throw new Error('--document is required with --scenario current-document')
}
if (scenario !== 'current-document' && documentPath) {
throw new Error('--document requires --scenario current-document')
}
if (mutationOpacity !== null && (!Number.isFinite(mutationOpacity) || !mutationNodeId)) {
throw new Error('--mutate-opacity requires a finite value and --mutate-node')
}
await mkdir(output, { recursive: true })
const input = await readRecording(gesturePath)
function hardwareGpuArgs(): string[] {
const base = ['--ignore-gpu-blocklist', '--enable-gpu']
return process.platform === 'darwin' ? [...base, '--use-angle=metal'] : base
}
const browser = await chromium.launch({
headless: true,
args: softwareGpu ? ['--enable-unsafe-swiftshader'] : hardwareGpuArgs()
})
const context = await browser.newContext({
viewport: { width: 1280, height: 800 },
deviceScaleFactor: 2
})
const page = await context.newPage()
const documentURLPath = '/__navigation-benchmark-document.fig'
if (documentPath) {
const documentBytes = await readFile(documentPath)
await page.route(`**${documentURLPath}`, (route) =>
route.fulfill({
status: 200,
contentType: 'application/octet-stream',
body: documentBytes
})
)
}
try {
await page.goto(
`${url}${url.includes('?') ? '&' : '?'}test&no-chrome&no-rulers&navigation-benchmark`
)
const glInfo = await page.evaluate(() => {
const canvas = document.createElement('canvas')
const gl = canvas.getContext('webgl2')
if (!gl) return { renderer: 'unavailable', vendor: 'unavailable' }
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info')
return {
renderer: String(
gl.getParameter(debugInfo?.UNMASKED_RENDERER_WEBGL ?? gl.RENDERER) ?? 'unknown'
),
vendor: String(gl.getParameter(debugInfo?.UNMASKED_VENDOR_WEBGL ?? gl.VENDOR) ?? 'unknown')
}
})
if (!softwareGpu && /SwiftShader/i.test(glInfo.renderer)) {
throw new Error(`Hardware GPU benchmark requested but Chromium uses ${glInfo.renderer}`)
}
await page.locator('[data-test-id="canvas-element"][data-ready="1"]').waitFor({ timeout: 30_000 })
if (documentPath) {
await page.evaluate((path) => window.openPencil?.openFile?.(path), documentURLPath)
await page.waitForFunction(
() => {
const store = window.openPencil?.getStore?.()
return store != null && store.graph.getChildren(store.state.currentPageId).length > 0
},
undefined,
{ timeout: 90_000 }
)
await page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not available after opening benchmark document')
store.zoomToFit()
})
} else {
await setupScenario(page, scenario)
}
await page.evaluate(() => window.openPencil?.test?.navigation?.waitForSettlement())
if (!documentPath) {
await page.evaluate((viewport) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not available')
store.state.panX = viewport.panX
store.state.panY = viewport.panY
store.state.zoom = viewport.zoom
store.requestRepaint()
}, input.initialViewport)
}
await page.evaluate(() => window.openPencil?.test?.navigation?.waitForSettlement())
await page.evaluate(
(name) => window.openPencil?.test?.navigation?.startRecording(name),
input.name
)
const trace = traceEnabled ? await startChromiumTrace(page, { cpuProfile }) : null
if (mutationNodeId && mutationOpacity !== null) {
const previousSceneVersion = await page.evaluate(
() => window.openPencil?.getStore?.().state.sceneVersion ?? -1
)
await page.evaluate(
({ nodeId, opacity }) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not available for benchmark mutation')
if (!store.graph.getNode(nodeId))
throw new Error(`Benchmark mutation node not found: ${nodeId}`)
store.graph.updateNode(nodeId, { opacity })
},
{ nodeId: mutationNodeId, opacity: mutationOpacity }
)
if (replayAfterMutation) {
await page.waitForFunction((sceneVersion) => {
const store = window.openPencil?.getStore?.()
return (
store != null &&
store.state.sceneVersion > sceneVersion &&
store.canvasRenderers.some(
(renderer) => renderer.tracksSceneSettlement && renderer.tiledScenePending
)
)
}, previousSceneVersion)
await replay(page, input, mode)
}
} else {
await replay(page, input, mode)
}
await page.evaluate(() => window.openPencil?.test?.navigation?.waitForSettlement())
await trace?.stop(resolve(output, 'trace.json.gz'))
const recording = await page.evaluate(
() => window.openPencil?.test?.navigation?.stopRecording() as NavigationRecordingFile
)
const metrics = computeNavigationMetrics(recording)
const environment = {
url,
mode,
scenario,
traceEnabled,
cpuProfile,
softwareGpu,
glInfo,
documentPath,
mutationNodeId,
mutationOpacity,
replayAfterMutation,
gesturePath: resolve(gesturePath),
browserVersion: await browser.version(),
platform: process.platform,
arch: process.arch,
bunVersion: Bun.version,
recordedAt: new Date().toISOString()
}
await Promise.all([
writeFile(resolve(output, 'recording.json'), JSON.stringify(recording, null, 2)),
writeFile(resolve(output, 'metrics.json'), JSON.stringify(metrics, null, 2)),
writeFile(resolve(output, 'environment.json'), JSON.stringify(environment, null, 2))
])
console.log(JSON.stringify(metrics, null, 2))
} finally {
await context.close()
await browser.close()
}