diff --git a/apps/cursor/app.tsx b/apps/cursor/app.tsx index 9e2f24cd..e3fbabdb 100644 --- a/apps/cursor/app.tsx +++ b/apps/cursor/app.tsx @@ -8,11 +8,16 @@ // the nub path is covered by tests/cursor.test.ts). import { Text, View } from "@pocketjs/framework/components"; -import { createSignal } from "solid-js"; +import { enableCursor } from "@pocketjs/framework/input"; +import { createSignal, onCleanup } from "solid-js"; const ROWS = ["REPLAY TAPE", "OPEN MEMORY STICK", "LAUNCH SHELL"] as const; export default function CursorDemo() { + // Keep the feature opt-in with the editable component so the Playground + // exercises the same cursor path as the packaged entry. Disposing the demo + // restores classic d-pad focus before another Playground app mounts. + onCleanup(enableCursor({ dpadSpeed: 60 })); const [status, setStatus] = createSignal("hover a row, press CIRCLE"); return ( diff --git a/apps/cursor/main.tsx b/apps/cursor/main.tsx index b285064d..11e461c5 100644 --- a/apps/cursor/main.tsx +++ b/apps/cursor/main.tsx @@ -1,11 +1,5 @@ // @title PocketJS: Cursor import CursorDemo from "./app.tsx"; import { mount } from "@pocketjs/framework"; -import { enableCursor } from "@pocketjs/framework/input"; - -// Opt in to the virtual cursor (input.cursor). Safe before mount — the -// sprite uploads lazily on the first frame. dpadSpeed keeps the golden tape -// button-only (1 px/frame); the nub steers at the default 240 px/s. -enableCursor({ dpadSpeed: 60 }); mount(() => ); diff --git a/package.json b/package.json index 53900ec8..9a377bf5 100644 --- a/package.json +++ b/package.json @@ -159,6 +159,7 @@ "dev": "bun tools/dev.ts", "wasm": "bun tools/wasm.ts", "site:build": "bun tools/site-build.ts", + "site:verify-playground": "bun run site:build && bun site/verify-playground.ts", "gba:imagegen": "bun imagegen", "vapor": "bun vapor/compiler/cli.ts vapor/examples/todo/todo.tsx", "vapor:play": "bun vapor/scripts/play.ts", diff --git a/site/build.ts b/site/build.ts index eecf482f..6ade6716 100644 --- a/site/build.ts +++ b/site/build.ts @@ -16,6 +16,7 @@ import { validateAndResolveBuildPlan } from "../framework/src/manifest/resolve.ts"; import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, cpSync, rmSync } from "node:fs"; import { dirname, join } from "node:path"; +import { pathToFileURL } from "node:url"; import { marked } from "marked"; import { createHighlighter } from "shiki"; import { @@ -69,6 +70,21 @@ const shimPlugin: import("bun").BunPlugin = { }, }; +// octane 0.1.26's package root re-exports compile() from compile.js while the +// package is marked side-effect free. Bun 1.3.14 can retain the re-export +// binding but prune its declaration in a browser bundle. Resolve the browser +// compiler straight to the owning module; normal Bun/Node consumers keep using +// the public `octane/compiler` subpath in source. +const octaneBrowserCompilerPlugin: import("bun").BunPlugin = { + name: "octane-browser-compiler", + setup(b) { + b.onResolve({ filter: /^octane\/compiler$/ }, () => { + const packageDir = dirname(Bun.resolveSync("octane/package.json", ROOT)); + return { path: join(packageDir, "dist/compiler/compile.js") }; + }); + }, +}; + // A `process` shim, prepended before any bundled import runs (babel reads the // global process.* at module-eval time — a define/import shim is too late). const PROCESS_PRELUDE = @@ -79,6 +95,14 @@ const PROCESS_PRELUDE = `removeListener:function(){},emit:function(){},emitWarning:function(){},exit:function(){},` + `hrtime:function(){return[0,0]},browser:true});globalThis.global||=globalThis;\n`; +// Vue Vapor's DOM helpers must target PocketJS's native-tree facade, not the +// embedding playground page. Native app builds apply this same define across +// the whole guest bundle in tools/build.ts; the site's split Vue runtime and +// JSX helper bundles need it independently. +const VUE_VAPOR_DOCUMENT_DEFINE = { + document: "globalThis.__pocketDocument", +} as const; + async function bundle( entry: string, outfile: string, @@ -164,7 +188,10 @@ async function bundleVueVapor(outfile: string) { target: "browser", format: "esm", conditions: ["browser"], - define: { "process.env.NODE_ENV": '"production"' }, + define: { + "process.env.NODE_ENV": '"production"', + ...VUE_VAPOR_DOCUMENT_DEFINE, + }, minify: true, sourcemap: "none", }); @@ -173,12 +200,15 @@ async function bundleVueVapor(outfile: string) { throw new Error("bundle failed: vue-vapor"); } const code = await res.outputs[0].text(); + if (!code.includes("globalThis.__pocketDocument")) { + throw new Error("Vue Vapor browser runtime does not target the PocketJS document facade"); + } write(outfile, code); console.log(` ${outfile} (${(code.length / 1024).toFixed(0)} KiB)`); } function patchVaporHelperCode(code: string): string { - return code.replace( + const patched = code.replace( `if (i && i.appContext.vapor && p === "__vapor") { return true; } @@ -191,19 +221,28 @@ function patchVaporHelperCode(code: string): string { } return Reflect.get`, ); + return new Bun.Transpiler({ + loader: "js", + define: VUE_VAPOR_DOCUMENT_DEFINE, + }).transformSync(patched); } function writeVueVaporHelpers(): void { const helpers = new Map([ [propsHelperId, propsHelperCode], [vdomHelperId, vdomHelperCode], - [vaporHelperId, patchVaporHelperCode(vaporHelperCode)], + [vaporHelperId, vaporHelperCode], [ssrHelperId, ssrHelperCode], ]); for (const [id, code] of helpers) { const name = id.split("/").pop(); if (!name) continue; - write(`pg/vue-jsx-vapor/${name}.js`, code); + const isVaporHelper = id === vaporHelperId; + const output = isVaporHelper ? patchVaporHelperCode(code) : code; + if (isVaporHelper && !output.includes("globalThis.__pocketDocument")) { + throw new Error("Vue Vapor JSX helper does not target the PocketJS document facade"); + } + write(`pg/vue-jsx-vapor/${name}.js`, output); } console.log(" pg/vue-jsx-vapor/* (4 helpers)"); } @@ -224,14 +263,31 @@ type DemoVariant = { framework: "solid" | "vue-vapor" | "octane"; source: string type DemoEntry = { name: string; title: string; variants: DemoVariant[] }; function inlinePlaygroundImports(name: string, source: string): string | null { + if (name === "launcher") { + const registryPath = ROOT + "apps/launcher/registry.generated.ts"; + const registrySource = readFileSync(registryPath, "utf8"); + const registryStart = registrySource.indexOf("export const REGISTRY"); + if (registryStart < 0) throw new Error("launcher registry has no REGISTRY export"); + const registry = registrySource.slice(registryStart).replace(/^export\s+/gm, ""); + const withDefaultRegistry = source.replace( + "export default function Launcher(props: LauncherProps) {", + "export default function Launcher(props: LauncherProps = { registry: REGISTRY }) {", + ); + if (withDefaultRegistry === source) { + throw new Error("launcher Playground wrapper could not supply its registry"); + } + return registry + "\n" + withDefaultRegistry; + } if (!/from\s+["']\.\.?\//.test(source)) return source; - if (name !== "gallery") return null; - const tilesPath = ROOT + "apps/gallery/tiles.ts"; - const tiles = readFileSync(tilesPath, "utf8").replace(/^export\s+/gm, ""); - return source.replace( - /import\s+\{\s*GALLERY_PAGES,\s*TILES_PER_PAGE,\s*TILE_SRCS\s*\}\s+from\s+["']\.\/tiles\.ts["'];\n?/, - tiles + "\n", - ); + if (name === "gallery") { + const tilesPath = ROOT + "apps/gallery/tiles.ts"; + const tiles = readFileSync(tilesPath, "utf8").replace(/^export\s+/gm, ""); + return source.replace( + /import\s+\{\s*GALLERY_PAGES,\s*TILES_PER_PAGE,\s*TILE_SRCS\s*\}\s+from\s+["']\.\/tiles\.ts["'];\n?/, + tiles + "\n", + ); + } + return null; } function demoSpriteMeta(name: string): SpriteMeta | undefined { @@ -295,6 +351,122 @@ function copyDemoAssets(): void { if (/\.(?:png|svg)$/i.test(file)) copy(dir + file, "demo-assets/" + file); } } + const launcherCovers = ROOT + "apps/launcher/covers/"; + if (existsSync(launcherCovers)) copy(launcherCovers, "demo-assets/covers/"); +} + +type BabelImport = { + type: "ImportDeclaration"; + source: { value: string }; + specifiers: Array< + | { type: "ImportDefaultSpecifier" } + | { type: "ImportNamespaceSpecifier" } + | { type: "ImportSpecifier"; imported: { type: string; name?: string; value?: string } } + >; +}; + +/** + * Link every generated variant against the exact browser import map and the + * actual emitted bundle exports. A site build used to stop after writing the + * editable source into demos.json, so missing subpath mappings and curated + * facade exports could ship while every build stayed green. + */ +async function verifyPlaygroundModules(demos: DemoEntry[]): Promise { + // Import the emitted browser artifact rather than its source entry. This is + // what catches bundler-only failures such as a retained call whose imported + // binding was tree-shaken out. Bust Bun's ESM cache for repeated builds in + // one process. + const compilerUrl = pathToFileURL(OUT + "pg/compiler.js"); + compilerUrl.searchParams.set("build", String(Date.now())); + const [{ transformAppSource }, { transformAsync }] = await Promise.all([ + import(compilerUrl.href) as Promise, + import("@babel/core"), + ]); + const scanner = new Bun.Transpiler({ loader: "js" }); + const exportCache = new Map>(); + const failures: string[] = []; + + const emittedPath = (specifier: string): string | null => { + const mapped = PLAYGROUND_IMPORTS[specifier]; + if (mapped) return mapped; + if (specifier.startsWith("/pg/")) return specifier; + try { + const url = new URL(specifier); + if (url.origin === "https://pocketjs.dev" && url.pathname.startsWith("/pg/")) { + return url.pathname; + } + } catch { + // Bare specifier: the missing-map error below should name it directly. + } + return null; + }; + + const moduleExports = async (path: string): Promise | null> => { + const cached = exportCache.get(path); + if (cached) return cached; + const file = OUT + path.replace(/^\//, ""); + if (!existsSync(file)) return null; + const exports = new Set(scanner.scan(readFileSync(file, "utf8")).exports); + exportCache.set(path, exports); + return exports; + }; + + for (const demo of demos) { + for (const variant of demo.variants) { + const label = `${demo.name}/${variant.framework}`; + try { + const { code } = await transformAppSource( + variant.source, + variant.framework, + "https://pocketjs.dev/", + ); + if (!scanner.scan(code).exports.includes("default")) { + failures.push(`${label}: transformed module has no default export`); + } + const parsed = await transformAsync(code, { + filename: `${label}.js`, + ast: true, + code: false, + babelrc: false, + configFile: false, + sourceMaps: false, + }); + const imports = (parsed?.ast?.program.body ?? []).filter( + (node) => node.type === "ImportDeclaration", + ) as unknown as BabelImport[]; + for (const node of imports) { + const specifier = node.source.value; + const target = emittedPath(specifier); + if (!target) { + failures.push(`${label}: import map has no entry for ${specifier}`); + continue; + } + const exports = await moduleExports(target); + if (!exports) { + failures.push(`${label}: mapped module does not exist: ${specifier} -> ${target}`); + continue; + } + for (const imported of node.specifiers) { + if (imported.type === "ImportNamespaceSpecifier") continue; + const name = imported.type === "ImportDefaultSpecifier" + ? "default" + : imported.imported.name ?? imported.imported.value; + if (name && !exports.has(name)) { + failures.push(`${label}: ${specifier} does not export ${name}`); + } + } + } + } catch (error) { + failures.push(`${label}: transform failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + } + + if (failures.length > 0) { + throw new Error(`playground module audit failed:\n ${failures.join("\n ")}`); + } + const variants = demos.reduce((count, demo) => count + demo.variants.length, 0); + console.log(` playground modules linked (${variants} variants)`); } async function main() { @@ -310,7 +482,11 @@ async function main() { writeVueVaporHelpers(); await bundle("playground/runtime-entry.ts", "pg/runtime.js", { external: ["solid-js", "solid-js/universal"] }); await bundle("playground/runtime-vue-vapor-entry.ts", "pg/runtime-vue-vapor.js", { external: ["vue"] }); - await bundle("playground/compiler-entry.ts", "pg/compiler.js", { shims: true, prelude: PROCESS_PRELUDE }); + await bundle("playground/compiler-entry.ts", "pg/compiler.js", { + shims: true, + prelude: PROCESS_PRELUDE, + plugins: [octaneBrowserCompilerPlugin], + }); // Octane framework modules must pass through the Octane compiler (hook call // sites get slots; JSX lowers to universal plans), so this bundle runs under // the same jsxPlugin the real build uses. Self-contained: the universal @@ -370,6 +546,7 @@ async function main() { const demos = demoManifest(); write("pg/demos.json", JSON.stringify(demos)); console.log(` pg/demos.json (${demos.length} demos: ${demos.map((d) => d.name).join(", ")})`); + await verifyPlaygroundModules(demos); // 4. static assets + Tailwind CSS (compiled AFTER pages exist so the content // scan sees every class; we render pages to a temp first, then compile). @@ -515,41 +692,47 @@ async function compileCss() { console.log(` assets/site.css (${(bytes / 1024).toFixed(0)} KiB)`); } -// import-map so compiled apps resolve PocketJS to one runtime and Solid to its -// own dependency bundle. -const IMPORT_MAP = ``; +// Compiled apps resolve every supported public subpath to one singleton +// runtime per framework. Keep the object available to the build-time link +// audit below; JSON.stringify is the one source of truth for the page. +const PLAYGROUND_IMPORTS: Record = { + "solid-js": "/pg/solid.js", + "solid-js/universal": "/pg/solid-universal.js", + vue: "/pg/vue-vapor.js", + "/vue-jsx-vapor/props": "/pg/vue-jsx-vapor/props.js", + "/vue-jsx-vapor/vdom": "/pg/vue-jsx-vapor/vdom.js", + "/vue-jsx-vapor/vapor": "/pg/vue-jsx-vapor/vapor.js", + "/vue-jsx-vapor/ssr": "/pg/vue-jsx-vapor/ssr.js", + "@pocketjs/framework": "/pg/runtime.js", + "@pocketjs/framework/animation": "/pg/runtime.js", + "@pocketjs/framework/audio": "/pg/runtime.js", + "@pocketjs/framework/clock": "/pg/runtime.js", + "@pocketjs/framework/components": "/pg/runtime.js", + "@pocketjs/framework/host": "/pg/runtime.js", + "@pocketjs/framework/input": "/pg/runtime.js", + "@pocketjs/framework/launcher": "/pg/runtime.js", + "@pocketjs/framework/lifecycle": "/pg/runtime.js", + "@pocketjs/framework/renderer": "/pg/runtime.js", + "@pocketjs/framework/solid": "/pg/runtime.js", + "@pocketjs/framework/solid/components": "/pg/runtime.js", + "@pocketjs/framework/solid/lifecycle": "/pg/runtime.js", + "@pocketjs/framework/solid/renderer": "/pg/runtime.js", + "@pocketjs/framework/vue-vapor": "/pg/runtime-vue-vapor.js", + "@pocketjs/framework/vue-vapor/animation": "/pg/runtime-vue-vapor.js", + "@pocketjs/framework/vue-vapor/audio": "/pg/runtime-vue-vapor.js", + "@pocketjs/framework/vue-vapor/components": "/pg/runtime-vue-vapor.js", + "@pocketjs/framework/vue-vapor/input": "/pg/runtime-vue-vapor.js", + "@pocketjs/framework/vue-vapor/lifecycle": "/pg/runtime-vue-vapor.js", + "@pocketjs/framework/vue-vapor/renderer": "/pg/runtime-vue-vapor.js", + "@pocketjs/framework/octane": "/pg/runtime-octane.js", + "@pocketjs/framework/octane/animation": "/pg/runtime-octane.js", + "@pocketjs/framework/octane/audio": "/pg/runtime-octane.js", + "@pocketjs/framework/octane/components": "/pg/runtime-octane.js", + "@pocketjs/framework/octane/input": "/pg/runtime-octane.js", + "@pocketjs/framework/octane/lifecycle": "/pg/runtime-octane.js", + "@pocketjs/framework/octane/renderer": "/pg/runtime-octane.js", +}; +const IMPORT_MAP = ``; type Highlight = (text: string, rawLang: string) => string; diff --git a/site/playground/compiler-entry.ts b/site/playground/compiler-entry.ts index e07e6613..5bf1325d 100644 --- a/site/playground/compiler-entry.ts +++ b/site/playground/compiler-entry.ts @@ -13,7 +13,6 @@ import { transformAsync, type PluginObj } from "@babel/core"; import solidPreset from "babel-preset-solid"; import tsPreset from "@babel/preset-typescript"; import { transformVueJsxVapor } from "vue-jsx-vapor/api"; -import { compile as octaneCompile } from "octane/compiler"; import { parse as parseFont, type Font } from "opentype.js"; import { compileClasses, fontSlotInfo } from "../../framework/compiler/tailwind.ts"; @@ -176,9 +175,10 @@ function collectorPlugin(out: Collected, framework: PlaygroundFramework): Plugin /** Run the exact build transform in the browser. Throws with a code frame on * lint/syntax errors (message carries the frame). */ -async function transform( +export async function transformAppSource( source: string, framework: PlaygroundFramework, + baseUrl = typeof location === "undefined" ? "https://pocketjs.dev/" : location.href, ): Promise<{ code: string; collected: Collected }> { const collected: Collected = { classStrings: [], codepoints: new Set() }; let res; @@ -197,7 +197,7 @@ async function transform( "app.tsx", { compiler: { - runtimeModuleName: new URL("/pg/vue-jsx-vapor/vapor.js", location.href).href, + runtimeModuleName: new URL("/pg/vue-jsx-vapor/vapor.js", baseUrl).href, }, }, false, @@ -222,7 +222,11 @@ async function transform( configFile: false, sourceMaps: false, }); - res = octaneCompile(source, "app.tsx", { mode: "client", renderer: OCTANE_RENDERER }) as { + // Load this branch lazily. Octane marks its package side-effect free; a + // static import from 0.1.26 was pruned by Bun while its call site survived, + // leaving every Octane demo with an undefined minified binding. + const { compile } = await import("octane/compiler"); + res = compile(source, "app.tsx", { mode: "client", renderer: OCTANE_RENDERER }) as { code: string; }; } else { @@ -340,7 +344,7 @@ export async function compileApp( } = {}, ): Promise { const framework = opts.framework ?? "solid"; - const { code, collected } = await transform(source, framework); + const { code, collected } = await transformAppSource(source, framework); const styles = compileClasses(collected.classStrings); const atlases = await bakeAtlases(collected.codepoints, styles.usedFontSlots, opts.extraChars ?? ""); diff --git a/site/playground/playground.js b/site/playground/playground.js index d3c41859..5048c10c 100644 --- a/site/playground/playground.js +++ b/site/playground/playground.js @@ -30,6 +30,8 @@ function loadCompiler() { const $ = (sel) => document.querySelector(sel); async function main() { + const query = new URLSearchParams(location.search); + const verifyMode = query.get("verify") === "1"; const canvas = $("#pg-canvas"); const statusEl = $("#pg-status"); const errorEl = $("#pg-error"); @@ -49,10 +51,13 @@ async function main() { // --- host ----------------------------------------------------------------- const host = new PocketHost(); + if (verifyMode) globalThis.__pgHost = host; await host.mount(canvas, { wasmUrl: PG + "pocketjs.wasm", onError: (e) => showError(String(e && e.stack ? e.stack : e)), onLog: () => {}, + showHud: !verifyMode, + idleAfterMs: verifyMode ? 0 : Infinity, }); // --- editor --------------------------------------------------------------- @@ -172,6 +177,10 @@ async function main() { try { await import(/* @vite-ignore */ bootUrl); host.begin(); + // Browser regression mode advances exact virtual frames itself. Stop + // the RAF after begin()'s single initial frame so ambient animation or + // HUD timing cannot masquerade as an input result. + if (verifyMode) host.stop(); const ms = Math.round(performance.now() - t0); const fwLabel = { "vue-vapor": "Vue Vapor", octane: "Octane" }[activeFramework] || "Solid"; setStatus( @@ -232,8 +241,8 @@ async function main() { canvas.addEventListener("click", () => canvas.focus()); // boot with the first demo (or a fallback), honoring ?demo= - const boot = new URLSearchParams(location.search).get("demo"); - const bootFramework = new URLSearchParams(location.search).get("framework"); + const boot = query.get("demo"); + const bootFramework = query.get("framework"); if (boot && demos.some((d) => d.name === boot)) demoSel.value = boot; if ( (bootFramework === "vue-vapor" || bootFramework === "octane") && diff --git a/site/playground/runtime-entry.ts b/site/playground/runtime-entry.ts index a03ed205..89d26293 100644 --- a/site/playground/runtime-entry.ts +++ b/site/playground/runtime-entry.ts @@ -19,7 +19,7 @@ // a bare `export *` would hit, e.g. app-`render` vs universal-`render`). // ---- public app surface ----------------------------------------------------- -export { frameworkName, mount, render } from "../../framework/src/index.ts"; +export { frameworkName, mount, registerTexture, render } from "../../framework/src/index.ts"; export { View, Text, @@ -37,7 +37,13 @@ export { Lazy, Gallery, } from "../../framework/src/components.ts"; -export { animate, spring, cancelAnim } from "../../framework/src/animation.ts"; +export { + animate, + spring, + cancelAnim, + jump, + createJumpBatch, +} from "../../framework/src/animation.ts"; export { onFrame, onButtonPress, @@ -46,11 +52,17 @@ export { } from "../../framework/src/lifecycle.ts"; export { BTN, + enableCursor, focusNode, getFocused, pushFocusGrid, pushFocusScope, + touches, } from "../../framework/src/input-api.ts"; +export { createWavPlayer } from "../../framework/src/audio-api.ts"; +export { ticksPerFrame } from "../../framework/src/clock.ts"; +export { getOps, hostViewport } from "../../framework/src/host.ts"; +export { appTable, frozenShot, launchApp } from "../../framework/src/launcher.ts"; // ---- universal-renderer surface (what babel-preset-solid imports from the // `moduleName` specifier — must exist under this one module) ------------------ diff --git a/site/playground/runtime-octane-entry.ts b/site/playground/runtime-octane-entry.ts index 2007d03c..b55d3526 100644 --- a/site/playground/runtime-octane-entry.ts +++ b/site/playground/runtime-octane-entry.ts @@ -6,7 +6,12 @@ export * from "../../framework/src/renderer-octane.ts"; -export { frameworkName, mount, render } from "../../framework/src/index-octane.ts"; +export { + frameworkName, + mount, + render, + setTextContent, +} from "../../framework/src/index-octane.ts"; export { View, Text, @@ -24,7 +29,7 @@ export { Lazy, Gallery, } from "../../framework/src/components-octane.tsx"; -export { animate, spring, cancelAnim } from "../../framework/src/animation.ts"; +export { animate, spring, cancelAnim, jump } from "../../framework/src/animation.ts"; export { useFrame, useButtonPress, @@ -38,6 +43,7 @@ export { pushFocusGrid, pushFocusScope, } from "../../framework/src/input-api.ts"; +export { createWavPlayer } from "../../framework/src/audio-api.ts"; import { resetRendererState, diff --git a/site/playground/runtime-vue-vapor-entry.ts b/site/playground/runtime-vue-vapor-entry.ts index 28e232d7..9dc72770 100644 --- a/site/playground/runtime-vue-vapor-entry.ts +++ b/site/playground/runtime-vue-vapor-entry.ts @@ -34,6 +34,7 @@ export { pushFocusGrid, pushFocusScope, } from "../../framework/src/input-api.ts"; +export { createWavPlayer } from "../../framework/src/audio-api.ts"; import { resetRendererState, diff --git a/site/verify-playground.ts b/site/verify-playground.ts new file mode 100644 index 00000000..7398c66b --- /dev/null +++ b/site/verify-playground.ts @@ -0,0 +1,513 @@ +// Browser regression for every public Playground demo/framework variant. +// Run after site:build. By default this owns an isolated local server; set +// POCKETJS_PLAYGROUND_URL only when intentionally verifying an existing one. + +import { createServer } from "node:net"; +import { join } from "node:path"; + +type Framework = "solid" | "vue-vapor" | "octane"; + +interface DemoVariant { + framework: Framework; + source: string; +} + +interface Demo { + name: string; + variants: DemoVariant[]; +} + +interface CanvasProbe { + w: number; + h: number; + nonblackPct: number; + coloredPct: number; + controlHash: number; + interactionHash: number; +} + +interface PlaygroundProbe { + selectedDemo: string | null; + activeFramework: string | null; + status: string | null; + statusKind: string | null; + controlError: string | null; + interactionError: string | null; + controlFrameAlive: boolean; + interactionFrameAlive: boolean; + invalidNodeInserts: number; + invalidTextWrites: number; + pressed: string[]; + textWrites: string[]; + canvas: CanvasProbe | null; +} + +interface VerifyReport { + probe: PlaygroundProbe; + pageErrors: string[]; + consoleErrors: string[]; + networkErrors: string[]; +} + +const ROOT = join(import.meta.dir, ".."); +const MANIFEST = join(ROOT, "site/dist/pg/demos.json"); +let BASE_URL = ""; +let ownedServer: ReturnType | null = null; +let activeVerifier: ReturnType | null = null; +let cleanupPromise: Promise | null = null; +let shuttingDown = false; + +function cleanup(): Promise { + cleanupPromise ??= (async () => { + if (activeVerifier?.exitCode === null) activeVerifier.kill(); + if (activeVerifier) await activeVerifier.exited; + activeVerifier = null; + if (ownedServer?.exitCode === null) ownedServer.kill(); + if (ownedServer) await ownedServer.exited; + ownedServer = null; + })(); + return cleanupPromise; +} +const onSigterm = () => { + shuttingDown = true; + void cleanup().finally(() => process.exit(143)); +}; +const onSigint = () => { + shuttingDown = true; + void cleanup().finally(() => process.exit(130)); +}; +process.once("SIGTERM", onSigterm); +process.once("SIGINT", onSigint); + +const CIRCLE = "0x2000"; +const RIGHT = "0x0020"; +const DOWN = "0x0040"; +const R = "0x0200"; + +// Every public demo gets an interaction that exercises its primary controls. +const INPUTS: Record = { + cards: [RIGHT, CIRCLE], + chrome: [RIGHT, CIRCLE], + cursor: [RIGHT, DOWN, CIRCLE], + gallery: [R, RIGHT, CIRCLE], + hero: [RIGHT, CIRCLE, CIRCLE, CIRCLE, CIRCLE], + launcher: [RIGHT, R, CIRCLE], + library: [RIGHT, CIRCLE], + motions: [RIGHT], + music: [DOWN, CIRCLE, R], + notifications: [DOWN, CIRCLE], + settings: [DOWN, CIRCLE], + stats: [RIGHT], +}; + +// A rendered canvas is not enough: a DOM/native-tree mismatch can preserve +// every styled box while silently dropping Text children. These sentinels are +// required to reach the actual host setText op on each fresh app mount. +const EXPECTED_TEXT: Record = { + cards: ["Feature Cards", "3 MODULES"], + chrome: ["POCKETJS — CHROME", "480 x 272"], + cursor: ["REPLAY TAPE", "hover a row, press CIRCLE"], + gallery: ["SYNTHWAVE", "01 / 04"], + hero: ["PocketJS", "Press Circle"], + launcher: ["Pocket Note", "browse only — this host cannot switch apps"], + library: ["Game Library", "5 TITLES"], + motions: ["MOTIONS/53", "(yui540)"], + music: ["Now Playing", "MIDNIGHT REPLAY"], + notifications: ["Notifications", "UPDATE AVAILABLE"], + settings: ["Settings", "4 OPTIONS"], + stats: ["Mission Control", "LIVE TELEMETRY"], +}; + +// Vue's static template() text comes from the Vue runtime bundle, while +// expressions/loops come from the vue-jsx-vapor helper bundle. Assert a +// dynamic child too so either split artifact regressing fails the matrix. +const EXPECTED_VUE_DYNAMIC_TEXT: Partial> = { + cards: "Layout", + gallery: "SYNTHWAVE", + hero: "Vue Vapor", + library: "NEON DRIFT", + music: "MIDNIGHT REPLAY", + notifications: "UPDATE AVAILABLE", + settings: "SOUND EFFECTS", + stats: "PLAYERS ONLINE", +}; + +const EXPECTED_VARIANTS: Record = { + cards: ["solid", "vue-vapor", "octane"], + chrome: ["solid"], + cursor: ["solid"], + gallery: ["solid", "vue-vapor", "octane"], + hero: ["solid", "vue-vapor", "octane"], + launcher: ["solid"], + library: ["solid", "vue-vapor", "octane"], + motions: ["solid"], + music: ["solid", "vue-vapor", "octane"], + notifications: ["solid", "vue-vapor", "octane"], + settings: ["solid", "vue-vapor", "octane"], + stats: ["solid", "vue-vapor", "octane"], +}; + +const FRAMEWORK_LABEL: Record = { + solid: "Solid", + "vue-vapor": "Vue Vapor", + octane: "Octane", +}; + +function matrixFromManifest(demos: Demo[]) { + const actual = Object.fromEntries( + demos.map((demo) => [demo.name, demo.variants.map((variant) => variant.framework)]), + ); + if (JSON.stringify(actual) !== JSON.stringify(EXPECTED_VARIANTS)) { + throw new Error( + `Playground matrix changed; update its interaction coverage.\nExpected: ${JSON.stringify(EXPECTED_VARIANTS)}\nActual: ${JSON.stringify(actual)}`, + ); + } + for (const demo of demos) { + for (const variant of demo.variants) { + if (!variant.source.trim()) throw new Error(`${demo.name}/${variant.framework} has empty source`); + } + } + return demos.flatMap((demo) => + demo.variants.map((variant) => ({ demo: demo.name, framework: variant.framework })), + ); +} + +async function unusedPort(): Promise { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("could not allocate a local port"); + await new Promise((resolve, reject) => + server.close((error) => error ? reject(error) : resolve()), + ); + return address.port; +} + +async function serverReady(expectedManifest: string) { + try { + const response = await fetch(`${BASE_URL}/pg/demos.json`, { + signal: AbortSignal.timeout(2_000), + }); + return response.ok && await response.text() === expectedManifest; + } catch { + return false; + } +} + +async function ensureServer(expectedManifest: string) { + const configured = process.env.POCKETJS_PLAYGROUND_URL; + if (configured) { + BASE_URL = configured.replace(/\/$/, ""); + if (!await serverReady(expectedManifest)) { + throw new Error(`${BASE_URL} is unavailable or does not serve this checkout's demos.json`); + } + return null; + } + + const port = await unusedPort(); + BASE_URL = `http://127.0.0.1:${port}`; + const server = Bun.spawn(["bun", "site/serve.ts"], { + cwd: ROOT, + env: { ...process.env, PORT: String(port) }, + stdout: "ignore", + stderr: "pipe", + }); + ownedServer = server; + for (let attempt = 0; attempt < 80; attempt++) { + if (await serverReady(expectedManifest)) return server; + if (server.exitCode !== null) { + const stderr = await new Response(server.stderr).text(); + throw new Error(`site server exited before it was ready:\n${stderr}`); + } + await Bun.sleep(100); + } + server.kill(); + await server.exited; + throw new Error(`timed out waiting for ${BASE_URL}`); +} + +function makeProbe(buttons: string[]) { + return `(async () => { + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const waitForRun = async () => { + const deadline = performance.now() + 12000; + while (document.querySelector('#pg-status')?.dataset.kind !== 'ok' && performance.now() < deadline) { + if (document.querySelector('#pg-status')?.dataset.kind === 'err') break; + await sleep(25); + } + }; + await waitForRun(); + const canvas = document.querySelector('#pg-canvas'); + const host = globalThis.__pgHost; + const hash = (ctx, width, height) => { + const data = ctx.getImageData(0, 0, width, height).data; + let value = 2166136261; + for (let i = 0; i < data.length; i += 4) { + value ^= data[i]; value = Math.imul(value, 16777619); + value ^= data[i + 1]; value = Math.imul(value, 16777619); + value ^= data[i + 2]; value = Math.imul(value, 16777619); + } + return value >>> 0; + }; + const ctx = canvas?.getContext('2d'); + const sequence = ${JSON.stringify(buttons)}; + const edgeFrames = 3; + let controlHash = 0; + let interactionHash = 0; + let controlError = null; + let interactionError = null; + let controlFrameAlive = false; + let interactionFrameAlive = false; + let invalidNodeInserts = 0; + let invalidTextWrites = 0; + const pressed = []; + const textWritesByNode = new Map(); + const insertedNodeIds = new Set(); + if (host && ctx) { + // Control run: advance the same exact number of virtual frames with no + // buttons. The verifier query disables HUD and ambient RAF advancement. + host.stop(); + host.held = 0; + for (let i = 0; i < sequence.length * edgeFrames * 2; i++) { + host._safeFrame(); + await Promise.resolve(); + } + host._blit(); + controlHash = hash(ctx, canvas.width, canvas.height); + const controlErrorEl = document.querySelector('#pg-error'); + controlError = controlErrorEl && !controlErrorEl.hidden ? controlErrorEl.textContent : null; + controlFrameAlive = typeof host.frameCb === 'function'; + + // Fresh component state, then the same frame count through the visible + // gamepad controls. Any final pixel difference is attributable to input, + // including for demos whose normal UI animates continuously. + const runButton = document.querySelector('#pg-run'); + if (!runButton) throw new Error('Playground Run button is missing'); + const originalSetText = host.ops.setText; + const originalReplaceText = host.ops.replaceText; + const originalInsertBefore = host.ops.insertBefore; + const recordText = (id, value) => { + if (!Number.isInteger(id) || id <= 0) { + invalidTextWrites++; + return; + } + let values = textWritesByNode.get(id); + if (!values) textWritesByNode.set(id, values = new Set()); + values.add(String(value)); + }; + host.ops.setText = (id, value) => { + recordText(id, value); + return originalSetText(id, value); + }; + host.ops.replaceText = (id, value) => { + recordText(id, value); + return originalReplaceText(id, value); + }; + host.ops.insertBefore = (parent, node, anchor) => { + const valid = Number.isInteger(parent) && parent > 0 && + Number.isInteger(node) && node > 0 && + Number.isInteger(anchor) && anchor >= 0; + if (valid) insertedNodeIds.add(node); + else invalidNodeInserts++; + return originalInsertBefore(parent, node, anchor); + }; + try { + runButton.click(); + if (document.querySelector('#pg-status')?.dataset.kind !== 'busy') { + throw new Error('Playground fresh rerun did not start'); + } + await waitForRun(); + host.stop(); + for (const value of sequence) { + const button = document.querySelector('[data-btn="' + value + '"]'); + if (!button) continue; + button.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); + host.stop(); + for (let i = 0; i < edgeFrames; i++) { + host._safeFrame(); + await Promise.resolve(); + } + button.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true })); + host.stop(); + for (let i = 0; i < edgeFrames; i++) { + host._safeFrame(); + await Promise.resolve(); + } + pressed.push(value); + } + host.held = 0; + host._blit(); + interactionHash = hash(ctx, canvas.width, canvas.height); + const interactionErrorEl = document.querySelector('#pg-error'); + interactionError = interactionErrorEl && !interactionErrorEl.hidden ? interactionErrorEl.textContent : null; + interactionFrameAlive = typeof host.frameCb === 'function'; + } finally { + host.ops.setText = originalSetText; + host.ops.replaceText = originalReplaceText; + host.ops.insertBefore = originalInsertBefore; + } + } + let canvasResult = null; + if (ctx) { + const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data; + let nonblack = 0; + let colored = 0; + for (let i = 0; i < data.length; i += 4) { + const r = data[i], g = data[i + 1], b = data[i + 2]; + if (r + g + b > 24) nonblack++; + if (Math.abs(r - g) + Math.abs(g - b) > 40) colored++; + } + const pixels = data.length / 4; + canvasResult = { + w: canvas.width, + h: canvas.height, + nonblackPct: +(100 * nonblack / pixels).toFixed(1), + coloredPct: +(100 * colored / pixels).toFixed(1), + controlHash, + interactionHash, + }; + } + const status = document.querySelector('#pg-status'); + return { + selectedDemo: document.querySelector('#pg-demo')?.value ?? null, + activeFramework: document.querySelector('[data-framework].is-active')?.dataset.framework ?? null, + status: status?.textContent ?? null, + statusKind: status?.dataset.kind ?? null, + controlError, + interactionError, + controlFrameAlive, + interactionFrameAlive, + invalidNodeInserts, + invalidTextWrites, + pressed, + textWrites: [...textWritesByNode] + .filter(([id]) => insertedNodeIds.has(id)) + .flatMap(([, values]) => [...values]), + canvas: canvasResult, + }; + })()`; +} + +async function verifyVariant(demo: string, framework: Framework) { + if (shuttingDown) throw new Error("Playground verification interrupted"); + const url = new URL("/playground/", BASE_URL); + url.searchParams.set("demo", demo); + url.searchParams.set("framework", framework); + url.searchParams.set("verify", "1"); + const child = Bun.spawn( + ["bun", "site/verify.ts", url.toString(), "500", makeProbe(INPUTS[demo])], + { + cwd: ROOT, + env: { ...process.env, POCKETJS_VERIFY_NO_SHOT: "1" }, + stdout: "pipe", + stderr: "pipe", + }, + ); + activeVerifier = child; + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + child.kill(); + }, 45_000); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + if (activeVerifier === child) activeVerifier = null; + clearTimeout(timeout); + if (timedOut) throw new Error(`${demo}/${framework}: verifier timed out after 45s`); + if (exitCode !== 0) throw new Error(`${demo}/${framework}: verifier exited ${exitCode}\n${stderr}`); + let report: VerifyReport; + try { + report = JSON.parse(stdout) as VerifyReport; + } catch { + throw new Error(`${demo}/${framework}: verifier returned invalid JSON\n${stdout}\n${stderr}`); + } + + const errors: string[] = []; + const probe = report.probe; + if (probe.selectedDemo !== demo) errors.push(`selected demo is ${probe.selectedDemo}`); + if (probe.activeFramework !== framework) errors.push(`active framework is ${probe.activeFramework}`); + if (probe.statusKind !== "ok" || !probe.status?.startsWith(`${FRAMEWORK_LABEL[framework]} · ok ·`)) { + errors.push(`status is ${JSON.stringify(probe.status)}`); + } + if (probe.controlError) errors.push(`no-input control reported ${probe.controlError}`); + if (!probe.controlFrameAlive) errors.push("no-input control frame loop stopped"); + if (probe.interactionError) errors.push(`input run reported ${probe.interactionError}`); + if (!probe.interactionFrameAlive) errors.push("input run frame loop stopped"); + if (probe.invalidNodeInserts > 0) { + errors.push(`${probe.invalidNodeInserts} host insert(s) used invalid native node IDs`); + } + if (probe.invalidTextWrites > 0) { + errors.push(`${probe.invalidTextWrites} host text write(s) used an invalid native node id`); + } + if (probe.pressed.length !== INPUTS[demo].length) { + errors.push(`only ${probe.pressed.length}/${INPUTS[demo].length} controls were found`); + } + for (const expectedText of EXPECTED_TEXT[demo]) { + if (!probe.textWrites.some((value) => value.includes(expectedText))) { + errors.push(`host never rendered expected text ${JSON.stringify(expectedText)}`); + } + } + const expectedVueText = framework === "vue-vapor" ? EXPECTED_VUE_DYNAMIC_TEXT[demo] : undefined; + if (expectedVueText && !probe.textWrites.some((value) => value.includes(expectedVueText))) { + errors.push(`Vue helper never rendered dynamic text ${JSON.stringify(expectedVueText)}`); + } + if (!probe.canvas || probe.canvas.w !== 480 || probe.canvas.h !== 272) { + errors.push("480x272 canvas is missing"); + } else { + if (probe.canvas.nonblackPct < 1) errors.push(`canvas is blank (${probe.canvas.nonblackPct}% non-black)`); + if (probe.canvas.controlHash === probe.canvas.interactionHash) { + errors.push("input run matched the no-input deterministic control"); + } + } + for (const error of report.pageErrors ?? []) errors.push(`page error: ${error}`); + for (const error of report.consoleErrors ?? []) errors.push(`console error: ${error}`); + for (const error of report.networkErrors ?? []) errors.push(`network error: ${error}`); + if (errors.length) throw new Error(`${demo}/${framework}:\n ${errors.join("\n ")}`); + return probe; +} + +const manifestText = await Bun.file(MANIFEST).text(); +const demos = JSON.parse(manifestText) as Demo[]; +const fullMatrix = matrixFromManifest(demos); +const selectors = process.argv.slice(2); +const matrix = selectors.length + ? fullMatrix.filter(({ demo, framework }) => + selectors.some((selector) => selector === demo || selector === `${demo}/${framework}`), + ) + : fullMatrix; +if (!matrix.length) throw new Error(`no Playground variants match: ${selectors.join(", ")}`); +const failures: string[] = []; +let passed = 0; + +try { + await ensureServer(manifestText); + for (const { demo, framework } of matrix) { + try { + const probe = await verifyVariant(demo, framework); + passed++; + console.log( + `ok ${String(passed).padStart(2, " ")}/${matrix.length} ${demo}/${framework}` + + ` (${probe.canvas?.nonblackPct}% non-black, ${probe.pressed.length} inputs)`, + ); + } catch (error) { + failures.push(error instanceof Error ? error.message : String(error)); + console.error(`not ok ${demo}/${framework}`); + } + } +} finally { + await cleanup(); + process.off("SIGTERM", onSigterm); + process.off("SIGINT", onSigint); +} + +if (failures.length) { + throw new Error(`${passed}/${matrix.length} Playground variants passed\n\n${failures.join("\n\n")}`); +} + +console.log(`Playground browser matrix passed (${passed}/${matrix.length} variants, deterministic input comparisons passed)`); diff --git a/site/verify.ts b/site/verify.ts index 9dcc2f10..72b422b8 100644 --- a/site/verify.ts +++ b/site/verify.ts @@ -4,6 +4,11 @@ // expression (default: canvas non-black pixel ratio + status/error text), saves // a screenshot, and prints a JSON report. Local verification only. +import { mkdtempSync, rmSync } from "node:fs"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + const CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; const url = process.argv[2] ?? "http://127.0.0.1:8140/"; const waitMs = Number(process.argv[3] ?? 4000); @@ -30,18 +35,60 @@ const probe = return out; })()`; -const SHOT = process.env.SHOT ?? "/private/tmp/claude-501/-Users-evan-code-pocketjs/92a09046-b511-4ab3-a360-0de941219d40/scratchpad/shot.png"; +const SHOT = process.env.SHOT ?? join(tmpdir(), `pocketjs-site-verify-${process.pid}.png`); // --- launch chrome with a debugging port ----------------------------------- -const port = 9333; -const proc = Bun.spawn( - // Recent Chrome only opens the debugging port with a dedicated profile dir, - // and the Pocket Stage hero needs WebGL — SwiftShader instead of --disable-gpu. - [CHROME, "--headless=old", `--remote-debugging-port=${port}`, `--user-data-dir=/tmp/pocketjs-verify-profile`, "--no-first-run", "--no-default-browser-check", - "--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", - "--hide-scrollbars", "--window-size=1400,1600", "--force-device-scale-factor=1", "about:blank"], - { stdout: "ignore", stderr: "ignore" }, -); +async function unusedPort(): Promise { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("could not allocate a Chrome debug port"); + await new Promise((resolve, reject) => + server.close((error) => error ? reject(error) : resolve()), + ); + return address.port; +} + +const port = await unusedPort(); +const profile = mkdtempSync(join(tmpdir(), "pocketjs-site-verify-")); +const proc = (() => { + try { + return Bun.spawn( + // Recent Chrome only opens the debugging port with a dedicated profile + // dir, and the Pocket Stage needs SwiftShader-backed WebGL. + [CHROME, "--headless=old", `--remote-debugging-port=${port}`, `--user-data-dir=${profile}`, "--no-first-run", "--no-default-browser-check", + "--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", + "--hide-scrollbars", "--window-size=1400,1600", "--force-device-scale-factor=1", "about:blank"], + { stdout: "ignore", stderr: "ignore" }, + ); + } catch (error) { + rmSync(profile, { recursive: true, force: true }); + throw error; + } +})(); + +let ws: WebSocket | null = null; +let cleanupPromise: Promise | null = null; +function cleanup(): Promise { + cleanupPromise ??= (async () => { + ws?.close(); + if (proc.exitCode === null) { + proc.kill(); + await Promise.race([proc.exited, Bun.sleep(3_000)]); + if (proc.exitCode === null) proc.kill(9); + } + await proc.exited; + rmSync(profile, { recursive: true, force: true }); + })(); + return cleanupPromise; +} +const onSigterm = () => void cleanup().finally(() => process.exit(143)); +const onSigint = () => void cleanup().finally(() => process.exit(130)); +process.once("SIGTERM", onSigterm); +process.once("SIGINT", onSigint); async function waitFor(fn: () => Promise, tries = 40, gap = 100) { for (let i = 0; i < tries; i++) { @@ -54,86 +101,151 @@ async function waitFor(fn: () => Promise, tries = 40, gap = 100) { throw new Error("timed out waiting for chrome"); } -const version = await waitFor(() => fetch(`http://127.0.0.1:${port}/json/version`).then((r) => r.json())); -const wsUrl = version.webSocketDebuggerUrl as string; -const ws = new WebSocket(wsUrl); -await new Promise((res, rej) => { - ws.onopen = res; - ws.onerror = rej; -}); +try { + const version = await waitFor(async () => { + const response = await fetch(`http://127.0.0.1:${port}/json/version`); + if (!response.ok) throw new Error(`Chrome returned ${response.status}`); + return response.json(); + }); + const wsUrl = version.webSocketDebuggerUrl as string; + ws = new WebSocket(wsUrl); + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("timed out opening Chrome debugger")), 10_000); + ws!.onopen = () => { + clearTimeout(timeout); + resolve(); + }; + ws!.onerror = (error) => { + clearTimeout(timeout); + reject(error); + }; + }); -let msgId = 0; -const pending = new Map void>(); -const events: any[] = []; -ws.onmessage = (ev) => { - const m = JSON.parse(ev.data as string); - if (m.id && pending.has(m.id)) { - pending.get(m.id)!(m.error ? { __error: m.error } : (m.result ?? {})); - pending.delete(m.id); - } else if (m.method) events.push(m); -}; -function send(method: string, params: any = {}, sessionId?: string): Promise { - const id = ++msgId; - const payload: any = { id, method, params }; - if (sessionId) payload.sessionId = sessionId; - ws.send(JSON.stringify(payload)); - return new Promise((res) => pending.set(id, res)); -} + let msgId = 0; + const pending = new Map void; + reject: (error: Error) => void; + timeout: ReturnType; + }>(); + const rejectPending = (reason: string) => { + for (const [id, item] of pending) { + clearTimeout(item.timeout); + item.reject(new Error(`${reason} (CDP request ${id})`)); + } + pending.clear(); + }; + ws.onclose = () => rejectPending("Chrome debugger closed"); + ws.onmessage = (event) => { + const message = JSON.parse(event.data as string); + if (!message.id) return; + const item = pending.get(message.id); + if (!item) return; + pending.delete(message.id); + clearTimeout(item.timeout); + if (message.error) item.reject(new Error(`CDP error: ${JSON.stringify(message.error)}`)); + else item.resolve(message.result ?? {}); + }; + function send(method: string, params: any = {}, sessionId?: string): Promise { + const id = ++msgId; + const payload: any = { id, method, params }; + if (sessionId) payload.sessionId = sessionId; + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + pending.delete(id); + reject(new Error(`CDP ${method} timed out`)); + }, Number(process.env.POCKETJS_VERIFY_CDP_TIMEOUT ?? 30_000)); + pending.set(id, { resolve, reject, timeout }); + ws!.send(JSON.stringify(payload)); + }); + } + + // Attach to the blank page target created on the Chrome command line. + const { targetInfos } = await send("Target.getTargets"); + const pageTarget = targetInfos.find((target: any) => target.type === "page"); + if (!pageTarget) throw new Error("Chrome has no page target"); + const { sessionId } = await send("Target.attachToTarget", { + targetId: pageTarget.targetId, + flatten: true, + }); + const S = (method: string, params?: any) => send(method, params, sessionId); -// attach to a page target -const { targetInfos } = await send("Target.getTargets"); -let pageTarget = targetInfos.find((t: any) => t.type === "page"); -const { sessionId } = await send("Target.attachToTarget", { targetId: pageTarget.targetId, flatten: true }); -const S = (method: string, params?: any) => send(method, params, sessionId); + const pageErrors: string[] = []; + const consoleErrors: string[] = []; + const networkErrors: string[] = []; + ws.addEventListener("message", (event: any) => { + const message = JSON.parse(event.data); + if (message.sessionId !== sessionId) return; + if (message.method === "Runtime.exceptionThrown") { + const detail = message.params.exceptionDetails; + pageErrors.push(detail.exception?.description || detail.text || JSON.stringify(detail)); + } + if (message.method === "Runtime.consoleAPICalled" && message.params.type === "error") { + consoleErrors.push( + message.params.args.map((arg: any) => arg.value ?? arg.description ?? "").join(" "), + ); + } + if (message.method === "Network.loadingFailed") { + networkErrors.push(`${message.params.errorText}: ${message.params.requestId}`); + } + if (message.method === "Network.responseReceived" && message.params.response.status >= 400) { + networkErrors.push(`${message.params.response.status}: ${message.params.response.url}`); + } + }); -const pageErrors: string[] = []; -const consoleErrors: string[] = []; -ws.addEventListener("message", (ev: any) => { - const m = JSON.parse(ev.data); - if (m.sessionId !== sessionId) return; - if (m.method === "Runtime.exceptionThrown") { - const d = m.params.exceptionDetails; - pageErrors.push(d.exception?.description || d.text || JSON.stringify(d)); - } - if (m.method === "Runtime.consoleAPICalled" && m.params.type === "error") { - consoleErrors.push(m.params.args.map((a: any) => a.value ?? a.description ?? "").join(" ")); + await S("Page.enable"); + await S("Runtime.enable"); + await S("Log.enable"); + await S("Network.enable"); + if (process.env.WIDTH) { + await S("Emulation.setDeviceMetricsOverride", { + width: Number(process.env.WIDTH), + height: Number(process.env.HEIGHT ?? 800), + deviceScaleFactor: 2, + mobile: !!process.env.MOBILE, + }); } -}); + await S("Page.navigate", { url }); + await Bun.sleep(waitMs); -await S("Page.enable"); -await S("Runtime.enable"); -await S("Log.enable"); -if (process.env.WIDTH) { - await S("Emulation.setDeviceMetricsOverride", { - width: Number(process.env.WIDTH), height: Number(process.env.HEIGHT ?? 800), - deviceScaleFactor: 2, mobile: !!process.env.MOBILE, + const evalRes = await S("Runtime.evaluate", { + expression: probe, + returnByValue: true, + awaitPromise: true, }); -} -await S("Page.navigate", { url }); -await Bun.sleep(waitMs); + if (evalRes.exceptionDetails) { + const detail = evalRes.exceptionDetails; + throw new Error(detail.exception?.description || detail.text || "probe evaluation failed"); + } + let screenshot: string | null = null; + if (process.env.POCKETJS_VERIFY_NO_SHOT !== "1") { + const shotOpts: any = { format: "png", captureBeyondViewport: true }; + if (process.env.CLIP) { + const [x, y, w, h] = process.env.CLIP.split(",").map(Number); + shotOpts.clip = { x, y, width: w, height: h, scale: 1 }; + } + const shot = await S("Page.captureScreenshot", shotOpts); + if (shot.data) { + await Bun.write(SHOT, Buffer.from(shot.data, "base64")); + screenshot = SHOT; + } + } -const evalRes = await S("Runtime.evaluate", { expression: probe, returnByValue: true, awaitPromise: true }); -const shotOpts: any = { format: "png", captureBeyondViewport: true }; -if (process.env.CLIP) { - const [x, y, w, h] = process.env.CLIP.split(",").map(Number); - shotOpts.clip = { x, y, width: w, height: h, scale: 1 }; + console.log( + JSON.stringify( + { + url, + probe: evalRes.result?.value ?? evalRes.result ?? evalRes, + pageErrors: pageErrors.slice(0, 8), + consoleErrors: consoleErrors.slice(0, 8), + networkErrors: networkErrors.slice(0, 8), + screenshot, + }, + null, + 2, + ), + ); +} finally { + await cleanup(); + process.off("SIGTERM", onSigterm); + process.off("SIGINT", onSigint); } -const shot = await S("Page.captureScreenshot", shotOpts); -if (shot.data) await Bun.write(SHOT, Buffer.from(shot.data, "base64")); - -console.log( - JSON.stringify( - { - url, - probe: evalRes.result?.value ?? evalRes.result ?? evalRes, - pageErrors: pageErrors.slice(0, 8), - consoleErrors: consoleErrors.slice(0, 8), - screenshot: SHOT, - }, - null, - 2, - ), -); - -ws.close(); -proc.kill();