* 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
218 lines
8.4 KiB
TypeScript
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()
|
|
}
|