diff --git a/docs/eve-migration-roadmap.md b/docs/eve-migration-roadmap.md index f9d48a6..d4d20fe 100644 --- a/docs/eve-migration-roadmap.md +++ b/docs/eve-migration-roadmap.md @@ -286,6 +286,14 @@ Exit criteria: Recently cut: +- The web reference now has a schema-backed layout probe for the + `embedded-surface` fixture. `scripts/run-web-reference-layout-probe-smoke.ps1` + builds the browser lowerer, runs Chrome headless through + `tools/web-reference/run-layout-probe.mjs`, writes + `gamecult.eve.web_layout_probe.v1` evidence under + `artifacts/web-reference-layout-probe/latest/embedded-surface.json`, and + parity consumes that artifact so `web:embedded-surface:bounding-boxes` is + measured runtime evidence instead of `pending-runtime-probe`. - The conformance export now carries `screenshotComparisonMetrics[]` records. Runtime owners get structure, color-token, bounding-box, and text-presence metrics per runtime/fixture pair without relying on byte-identical screenshots diff --git a/docs/parity-testing-harness.md b/docs/parity-testing-harness.md index 41fc2d4..2b27e91 100644 --- a/docs/parity-testing-harness.md +++ b/docs/parity-testing-harness.md @@ -434,6 +434,14 @@ box records name whether authored layout boxes exist or whether a runtime still owes a capture/layout probe. A missing Unity, Electron, TUI, or Direct2D capture body therefore appears as `pending-capture`, not as fake visual parity. +The web reference has the first measured bounding-box probe. The harness runs +`scripts/run-web-reference-layout-probe-smoke.ps1`, which builds the browser +lowerer and uses Chrome headless to write +`artifacts/web-reference-layout-probe/latest/embedded-surface.json` as +`gamecult.eve.web_layout_probe.v1`. Parity consumes that artifact and marks +`web:embedded-surface:bounding-boxes` as `pass` with +`evidenceLayer: runtime-layout-probe`. + The fixture matrix also includes `embedded-surface`, which proves that nested CultMesh document slots survive the shared surface contract. The web reference, Flutter parity body, Unity UI Toolkit lowerer, iOS UIKit renderer, and native @@ -501,9 +509,8 @@ Pending runtimes are allowed. Silent fake parity is not. ## Next Cuts -1. Attach runtime-owned image/layout probes to the screenshot comparison metric - lane so bounding-box records can move from `pending-runtime-probe` or - `pending-capture` to measured runtime evidence. +1. Extend runtime-owned image/layout probes beyond web `embedded-surface`, + starting with Sai VN in web and the Unity/Electron/TUI capture blockers. 2. Normalize text scale and font loading across web, Flutter Android, Flutter desktop, and iOS. 3. Give iOS a real `vn.stage` scene compositor instead of compact stacked diff --git a/packages/eve-browser-lowering/dist/index.js b/packages/eve-browser-lowering/dist/index.js index c1a9573..32b9bce 100644 --- a/packages/eve-browser-lowering/dist/index.js +++ b/packages/eve-browser-lowering/dist/index.js @@ -2109,6 +2109,10 @@ function withJapaneseGuiFallback(stack) { function assignId(element, node) { if (node.id) element.id = node.id; + if (node.id) + element.dataset.eveNodeId = node.id; + if (node.kind) + element.dataset.eveKind = node.kind; } function objectProps(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : {}; diff --git a/packages/eve-browser-lowering/src/index.ts b/packages/eve-browser-lowering/src/index.ts index 25c1aa2..b94a177 100644 --- a/packages/eve-browser-lowering/src/index.ts +++ b/packages/eve-browser-lowering/src/index.ts @@ -2454,6 +2454,8 @@ function withJapaneseGuiFallback(stack: string): string { function assignId(element: HTMLElement, node: EveSurfaceComponent): void { if (node.id) element.id = node.id; + if (node.id) element.dataset.eveNodeId = node.id; + if (node.kind) element.dataset.eveKind = node.kind; } function objectProps(value: unknown): Record { diff --git a/schemas/gamecult.eve.web_layout_probe.v1.schema.json b/schemas/gamecult.eve.web_layout_probe.v1.schema.json new file mode 100644 index 0000000..abd26a4 --- /dev/null +++ b/schemas/gamecult.eve.web_layout_probe.v1.schema.json @@ -0,0 +1,108 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "gamecult.eve.web_layout_probe.v1", + "title": "Eve Web Layout Probe", + "type": "object", + "required": [ + "schema", + "runtimeId", + "providerId", + "surfaceId", + "fixtureId", + "generatedAt", + "viewport", + "summary", + "nodes" + ], + "properties": { + "schema": { + "const": "gamecult.eve.web_layout_probe.v1" + }, + "runtimeId": { + "const": "web" + }, + "providerId": { + "type": "string", + "minLength": 1 + }, + "surfaceId": { + "type": "string", + "minLength": 1 + }, + "fixtureId": { + "type": "string", + "minLength": 1 + }, + "generatedAt": { + "type": "string", + "minLength": 1 + }, + "viewport": { + "type": "object", + "required": ["width", "height"], + "properties": { + "width": { + "type": "number" + }, + "height": { + "type": "number" + } + }, + "additionalProperties": true + }, + "summary": { + "type": "object", + "required": ["nodeCount", "measuredNodeCount", "zeroAreaCount"], + "properties": { + "nodeCount": { + "type": "number" + }, + "measuredNodeCount": { + "type": "number" + }, + "zeroAreaCount": { + "type": "number" + } + }, + "additionalProperties": true + }, + "nodes": { + "type": "array", + "items": { + "type": "object", + "required": ["nodeId", "kind", "rect"], + "properties": { + "nodeId": { + "type": "string", + "minLength": 1 + }, + "kind": { + "type": "string", + "minLength": 1 + }, + "rect": { + "type": "object", + "required": ["x", "y", "width", "height"], + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "width": { + "type": "number" + }, + "height": { + "type": "number" + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true + } + } + }, + "additionalProperties": true +} diff --git a/scripts/run-conformance-consumer-smoke.ps1 b/scripts/run-conformance-consumer-smoke.ps1 index 9f89975..2b2b7e2 100644 --- a/scripts/run-conformance-consumer-smoke.ps1 +++ b/scripts/run-conformance-consumer-smoke.ps1 @@ -47,6 +47,7 @@ try { --expect-schema gamecult.eve.provider_handoff.v1 ` --expect-schema gamecult.eve.electron_shell_projection.v1 ` --expect-schema gamecult.eve.tui_grid.v1 ` + --expect-schema gamecult.eve.web_layout_probe.v1 ` --expect-capability-gap runtime:Fensalir:direct2d:capture:missing ` --expect-world-lowering-coverage aetheria:aetheria.daemon.game:web-reference:claimed:Eve:web ` --expect-world-lowering-coverage aetheria:aetheria.daemon.game:unity-uitoolkit:claimed:EveUnity:unity-uitoolkit ` @@ -65,7 +66,7 @@ try { --expect-screenshot-metric web:embedded-surface:structure:pass ` --expect-screenshot-metric web:embedded-surface:color-tokens:pass ` --expect-screenshot-metric web:embedded-surface:text-presence:pass ` - --expect-screenshot-metric web:embedded-surface:bounding-boxes:pending-runtime-probe ` + --expect-screenshot-metric web:embedded-surface:bounding-boxes:pass ` --expect-screenshot-metric electron-shell:aetheria-world:bounding-boxes:pending-capture ` --expect-capability-gap "split-target:EveUnity:EveUnity:proof:Tagged UPM release" ` --expect-conformance-handoff diff --git a/scripts/run-eveconformance-handoff-smoke.ps1 b/scripts/run-eveconformance-handoff-smoke.ps1 index 49835c6..e85377d 100644 --- a/scripts/run-eveconformance-handoff-smoke.ps1 +++ b/scripts/run-eveconformance-handoff-smoke.ps1 @@ -92,6 +92,7 @@ foreach ($contract in @( "gamecult.eve.provider_handoff.v1", "gamecult.eve.electron_shell_projection.v1", "gamecult.eve.tui_grid.v1", + "gamecult.eve.web_layout_probe.v1", "gamecult.eve.runtime_capability.v1", "gamecult.eve.runtime_lifecycle.v1", "gamecult.eve.runtime_split_handoff.v1" @@ -128,6 +129,7 @@ if (Test-Path -LiteralPath (Join-Path $absoluteExportDirectory "index.json")) { "gamecult.eve.provider_handoff.v1", "gamecult.eve.electron_shell_projection.v1", "gamecult.eve.tui_grid.v1", + "gamecult.eve.web_layout_probe.v1", "gamecult.eve.runtime_capability.v1", "gamecult.eve.runtime_lifecycle.v1", "gamecult.eve.runtime_split_handoff.v1", diff --git a/scripts/run-parity-harness.ps1 b/scripts/run-parity-harness.ps1 index c4c6230..6cda988 100644 --- a/scripts/run-parity-harness.ps1 +++ b/scripts/run-parity-harness.ps1 @@ -13,6 +13,7 @@ $env:EVE_PARITY_OUTPUT = if ([System.IO.Path]::IsPathRooted($OutputDirectory)) { Push-Location $projectRoot try { + & (Join-Path $PSScriptRoot "run-web-reference-layout-probe-smoke.ps1") node .\tools\parity\run-parity.mjs & (Join-Path $PSScriptRoot "run-eveconformance-handoff-smoke.ps1") & (Join-Path $PSScriptRoot "run-plugin-handoff-smoke.ps1") diff --git a/scripts/run-runtime-owner-conformance-consumer-smoke.ps1 b/scripts/run-runtime-owner-conformance-consumer-smoke.ps1 index caa374b..9ff8da3 100644 --- a/scripts/run-runtime-owner-conformance-consumer-smoke.ps1 +++ b/scripts/run-runtime-owner-conformance-consumer-smoke.ps1 @@ -47,6 +47,7 @@ node $consumerScript $consumerExport ` --expect-schema gamecult.eve.runtime_split_handoff.v1 ` --expect-schema gamecult.eve.electron_shell_projection.v1 ` --expect-schema gamecult.eve.tui_grid.v1 ` + --expect-schema gamecult.eve.web_layout_probe.v1 ` --expect-runtime-status web:active ` --expect-runtime-status windows-flutter:active ` --expect-runtime-status linux-flutter:active ` @@ -126,7 +127,7 @@ node $consumerScript $consumerExport ` --expect-screenshot-metric web:embedded-surface:structure:pass ` --expect-screenshot-metric web:embedded-surface:color-tokens:pass ` --expect-screenshot-metric web:embedded-surface:text-presence:pass ` - --expect-screenshot-metric web:embedded-surface:bounding-boxes:pending-runtime-probe ` + --expect-screenshot-metric web:embedded-surface:bounding-boxes:pass ` --expect-screenshot-metric unity-scene:aetheria-world:bounding-boxes:pending-capture ` --expect-screenshot-metric electron-shell:aetheria-world:bounding-boxes:pending-capture ` --expect-screenshot-metric tui:aetheria-world:bounding-boxes:pending-capture ` diff --git a/scripts/run-web-reference-layout-probe-smoke.ps1 b/scripts/run-web-reference-layout-probe-smoke.ps1 new file mode 100644 index 0000000..f6a4dd7 --- /dev/null +++ b/scripts/run-web-reference-layout-probe-smoke.ps1 @@ -0,0 +1,59 @@ +param( + [string] $ProviderId = "gamecult.eve.embedded-demo", + [string] $FixtureId = "embedded-surface", + [string] $OutputPath = "artifacts\web-reference-layout-probe\latest\embedded-surface.json", + [int] $Port = 8892, + [int] $Width = 1280, + [int] $Height = 720 +) + +$ErrorActionPreference = "Stop" + +$projectRoot = Split-Path -Parent $PSScriptRoot +$absoluteOutputPath = if ([System.IO.Path]::IsPathRooted($OutputPath)) { + $OutputPath +} else { + Join-Path $projectRoot $OutputPath +} + +Push-Location $projectRoot +try { + npm --prefix packages/eve-browser-lowering run build | Out-Host + if ($LASTEXITCODE -ne 0) { + throw "Eve browser lowering build failed with exit code $LASTEXITCODE" + } + + node .\tools\web-reference\run-layout-probe.mjs ` + --provider $ProviderId ` + --fixture $FixtureId ` + --output $OutputPath ` + --port $Port ` + --width $Width ` + --height $Height | Out-Host + if ($LASTEXITCODE -ne 0) { + throw "Web reference layout probe failed with exit code $LASTEXITCODE" + } + + if (-not (Test-Path $absoluteOutputPath)) { + throw "Web reference layout probe did not write output: $absoluteOutputPath" + } + $probe = Get-Content $absoluteOutputPath -Raw | ConvertFrom-Json + if ($probe.schema -ne "gamecult.eve.web_layout_probe.v1") { + throw "Unexpected web layout probe schema: $($probe.schema)" + } + if ($probe.runtimeId -ne "web") { + throw "Unexpected web layout probe runtime: $($probe.runtimeId)" + } + if ($probe.providerId -ne $ProviderId) { + throw "Unexpected web layout probe provider: $($probe.providerId)" + } + if ($probe.fixtureId -ne $FixtureId) { + throw "Unexpected web layout probe fixture: $($probe.fixtureId)" + } + if ($probe.summary.measuredNodeCount -le 0) { + throw "Web layout probe measured no node boxes." + } + Write-Host "Web reference layout probe smoke passed: $absoluteOutputPath" +} finally { + Pop-Location +} diff --git a/tools/conformance/eveconformance-handoff.json b/tools/conformance/eveconformance-handoff.json index 280bd52..05b94ee 100644 --- a/tools/conformance/eveconformance-handoff.json +++ b/tools/conformance/eveconformance-handoff.json @@ -28,6 +28,7 @@ "schemas/gamecult.eve.provider_handoff.v1.schema.json", "schemas/gamecult.eve.electron_shell_projection.v1.schema.json", "schemas/gamecult.eve.tui_grid.v1.schema.json", + "schemas/gamecult.eve.web_layout_probe.v1.schema.json", "schemas/gamecult.eve.runtime_capability.v1.schema.json", "schemas/gamecult.eve.runtime_lifecycle.v1.schema.json", "schemas/gamecult.eve.runtime_split_handoff.v1.schema.json", @@ -92,6 +93,7 @@ "gamecult.eve.provider_handoff.v1", "gamecult.eve.electron_shell_projection.v1", "gamecult.eve.tui_grid.v1", + "gamecult.eve.web_layout_probe.v1", "gamecult.eve.runtime_capability.v1", "gamecult.eve.runtime_lifecycle.v1", "gamecult.eve.runtime_split_handoff.v1" diff --git a/tools/parity/parity-manifest.json b/tools/parity/parity-manifest.json index 712eb83..68e1234 100644 --- a/tools/parity/parity-manifest.json +++ b/tools/parity/parity-manifest.json @@ -24,6 +24,7 @@ "gamecult.eve.runtime_split_handoff.v1": "schemas/gamecult.eve.runtime_split_handoff.v1.schema.json", "gamecult.eve.runtime_release_request.v1": "schemas/gamecult.eve.runtime_release_request.v1.schema.json", "gamecult.eve.runtime_capture_request.v1": "schemas/gamecult.eve.runtime_capture_request.v1.schema.json", + "gamecult.eve.web_layout_probe.v1": "schemas/gamecult.eve.web_layout_probe.v1.schema.json", "gamecult.eve.provider_scenario.v1": "schemas/gamecult.eve.provider_scenario.v1.schema.json", "gamecult.eve.provider_advertisement.v1": "schemas/gamecult.eve.provider_advertisement.v1.schema.json", "gamecult.eve.provider_handoff.v1": "schemas/gamecult.eve.provider_handoff.v1.schema.json", @@ -1112,7 +1113,17 @@ }, "capture": { "status": "chrome-headless", - "note": "Chrome headless captures phone, tablet, and desktop PNGs through the provider query parameter." + "note": "Chrome headless captures phone, tablet, and desktop PNGs through the provider query parameter.", + "layoutProbes": [ + { + "fixtureId": "embedded-surface", + "providerId": "gamecult.eve.embedded-demo", + "surfaceId": "gamecult.eve.embedded-demo", + "schema": "gamecult.eve.web_layout_probe.v1", + "path": "artifacts/web-reference-layout-probe/latest/embedded-surface.json", + "runner": "scripts/run-web-reference-layout-probe-smoke.ps1" + } + ] } }, { diff --git a/tools/parity/run-parity.mjs b/tools/parity/run-parity.mjs index 9e8e995..ea034f9 100644 --- a/tools/parity/run-parity.mjs +++ b/tools/parity/run-parity.mjs @@ -2503,6 +2503,47 @@ function buildColorTokenMetric(runtime, fixture) { } function buildBoundingBoxMetric(runtime, fixture) { + const layoutProbe = readRuntimeLayoutProbe(runtime, fixture.id); + if (layoutProbe.config) { + if (layoutProbe.document && !layoutProbe.errors.length) { + const measured = Number(layoutProbe.document.summary?.measuredNodeCount || 0); + const total = Number(layoutProbe.document.summary?.nodeCount || 0); + return buildScreenshotMetric(runtime, fixture, { + metricKind: "bounding-boxes", + status: measured > 0 ? "pass" : "fail", + score: ratio(measured, total), + expected: { + schema: layoutProbe.config.schema || "", + path: layoutProbe.config.path || "", + providerId: layoutProbe.config.providerId || "", + surfaceId: layoutProbe.config.surfaceId || "", + }, + actual: { + nodeCount: total, + measuredNodeCount: measured, + zeroAreaCount: Number(layoutProbe.document.summary?.zeroAreaCount || 0), + path: layoutProbe.config.path || "", + }, + evidenceLayer: "runtime-layout-probe", + detail: "Compares runtime-owned DOM bounding boxes from the web reference layout probe.", + }); + } + return buildScreenshotMetric(runtime, fixture, { + metricKind: "bounding-boxes", + status: "fail", + score: 0, + expected: { + schema: layoutProbe.config.schema || "", + path: layoutProbe.config.path || "", + }, + actual: { + errors: layoutProbe.errors, + }, + evidenceLayer: "runtime-layout-probe", + detail: "Declared runtime layout probe exists, but the artifact is missing or invalid.", + }); + } + const captureStatus = runtime.capture?.status || ""; const runtimeHasCaptureBody = ["chrome-headless", "ssh-png", "adb-png", "golden", "ssh-golden"].includes(captureStatus); let status = "pass"; @@ -2564,7 +2605,7 @@ function buildScreenshotMetric(runtime, fixture, metric) { score: Number(metric.score.toFixed(3)), expected: metric.expected, actual: metric.actual, - evidenceLayer: "surface-structure", + evidenceLayer: metric.evidenceLayer || "surface-structure", detail: metric.detail, }; } @@ -2573,6 +2614,37 @@ function ratio(numerator, denominator) { return denominator ? Math.max(0, Math.min(1, numerator / denominator)) : 1; } +function readRuntimeLayoutProbe(runtime, fixtureId) { + const config = (runtime.capture?.layoutProbes || []).find(candidate => candidate.fixtureId === fixtureId); + if (!config) return { config: null, document: null, errors: [] }; + const errors = []; + const probePath = config.path || ""; + const absoluteProbePath = path.join(repoRoot, probePath); + if (!probePath || !existsSync(absoluteProbePath)) { + return { config, document: null, errors: [`${probePath || "layoutProbe.path"}:missing`] }; + } + let document; + try { + document = JSON.parse(readFileSync(absoluteProbePath, "utf8")); + } catch (error) { + return { config, document: null, errors: [`${probePath}:invalid-json:${error instanceof Error ? error.message : String(error)}`] }; + } + if (config.schema && document.schema !== config.schema) errors.push(`${probePath}:schema:expected ${config.schema} got ${document.schema || ""}`); + if (config.providerId && document.providerId !== config.providerId) errors.push(`${probePath}:providerId:expected ${config.providerId} got ${document.providerId || ""}`); + if (config.surfaceId && document.surfaceId !== config.surfaceId) errors.push(`${probePath}:surfaceId:expected ${config.surfaceId} got ${document.surfaceId || ""}`); + if (document.fixtureId !== fixtureId) errors.push(`${probePath}:fixtureId:expected ${fixtureId} got ${document.fixtureId || ""}`); + const schemaPath = config.schema ? manifest.schemas?.[config.schema] : ""; + if (schemaPath) { + try { + const schema = JSON.parse(readFileSync(path.join(repoRoot, schemaPath), "utf8")); + errors.push(...validateSchemaSubset(schema, document, `layoutProbe:${fixtureId}`)); + } catch (error) { + errors.push(`${schemaPath}:unreadable:${error instanceof Error ? error.message : String(error)}`); + } + } + return { config, document, errors }; +} + function collectSplitTargetBlockers(report) { return (report.splitTargets || []).flatMap(target => target.blockerRecords || []); } diff --git a/tools/web-reference/run-layout-probe.mjs b/tools/web-reference/run-layout-probe.mjs new file mode 100644 index 0000000..b7a56ef --- /dev/null +++ b/tools/web-reference/run-layout-probe.mjs @@ -0,0 +1,191 @@ +import { createServer } from "node:http"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { existsSync, readFile as readFileCallback } from "node:fs"; +import { spawn } from "node:child_process"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const args = parseArgs(process.argv.slice(2)); +const port = Number(args.port || 8892); +const debugPort = Number(args.debugPort || 18892); +const providerId = args.provider || "gamecult.eve.embedded-demo"; +const fixtureId = args.fixture || "embedded-surface"; +const width = Number(args.width || 1280); +const height = Number(args.height || 720); +const outputPath = path.resolve(repoRoot, args.output || `artifacts/web-reference-layout-probe/latest/${fixtureId}.json`); + +const server = createStaticServer(repoRoot); +await new Promise(resolve => server.listen(port, "127.0.0.1", resolve)); +const profile = await mkdtemp(path.join(os.tmpdir(), "eve-web-layout-probe-")); + +try { + const url = `http://127.0.0.1:${port}/web/layout-probe.html?provider=${encodeURIComponent(providerId)}&fixture=${encodeURIComponent(fixtureId)}`; + const probe = await runChromeProbe(url, profile); + if (probe.error) throw new Error(probe.error); + if (probe.schema !== "gamecult.eve.web_layout_probe.v1") throw new Error(`Unexpected probe schema: ${probe.schema || ""}`); + if (probe.runtimeId !== "web") throw new Error(`Unexpected probe runtime: ${probe.runtimeId || ""}`); + if (probe.providerId !== providerId) throw new Error(`Unexpected probe provider: ${probe.providerId || ""}`); + if (probe.fixtureId !== fixtureId) throw new Error(`Unexpected probe fixture: ${probe.fixtureId || ""}`); + if (!probe.summary?.measuredNodeCount) throw new Error("Web layout probe measured no node boxes."); + await mkdir(path.dirname(outputPath), { recursive: true }); + await writeFile(outputPath, `${JSON.stringify(probe, null, 2)}\n`); + console.log(outputPath); +} finally { + server.close(); + await rm(profile, { recursive: true, force: true }); +} + +function createStaticServer(root) { + const types = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".eve": "text/plain; charset=utf-8", + }; + return createServer((request, response) => { + const urlPath = decodeURIComponent(new URL(request.url || "/", "http://localhost").pathname); + const clean = urlPath === "/" ? "/web/index.html" : urlPath; + const file = path.normalize(path.join(root, clean)); + if (!file.startsWith(root)) { + response.writeHead(403); + response.end("forbidden"); + return; + } + readFileCallback(file, (error, data) => { + if (error) { + response.writeHead(404); + response.end("not found"); + return; + } + response.writeHead(200, { "content-type": types[path.extname(file)] || "application/octet-stream" }); + response.end(data); + }); + }); +} + +async function runChromeProbe(url, profile) { + const chrome = findChrome(); + const child = spawn(chrome, [ + "--headless=new", + "--disable-gpu", + "--hide-scrollbars", + `--remote-debugging-port=${debugPort}`, + `--user-data-dir=${profile}`, + `--window-size=${width},${height}`, + url, + ], { stdio: ["ignore", "pipe", "pipe"] }); + let stderr = ""; + let closed = false; + child.stderr.on("data", chunk => { stderr += chunk; }); + child.once("close", () => { closed = true; }); + try { + const target = await waitForPageTarget(url); + const client = await connectCdp(target.webSocketDebuggerUrl); + try { + await client.send("Runtime.enable"); + for (let attempt = 0; attempt < 100; attempt += 1) { + const ready = await client.send("Runtime.evaluate", { + expression: "document.body?.dataset.probeReady === 'true'", + returnByValue: true, + }); + if (ready.result?.value === true) break; + await delay(100); + } + const result = await client.send("Runtime.evaluate", { + expression: "document.querySelector('#eve-layout-probe-output')?.textContent || ''", + returnByValue: true, + }); + const text = result.result?.value || ""; + if (!text.trim()) throw new Error("Chrome layout probe produced an empty output document."); + return JSON.parse(text); + } finally { + client.close(); + } + } finally { + if (!closed) { + child.kill(); + await new Promise(resolve => child.once("close", resolve)); + } + if (stderr && process.env.EVE_DEBUG_WEB_LAYOUT_PROBE) process.stderr.write(stderr); + } +} + +async function waitForPageTarget(url) { + for (let attempt = 0; attempt < 100; attempt += 1) { + try { + const response = await fetch(`http://127.0.0.1:${debugPort}/json`); + const targets = await response.json(); + const target = targets.find(candidate => candidate.type === "page" && candidate.url === url) + || targets.find(candidate => candidate.type === "page" && candidate.url?.includes("/web/layout-probe.html")); + if (target?.webSocketDebuggerUrl) return target; + } catch { + // Chrome is still booting. + } + await delay(100); + } + throw new Error(`Timed out waiting for Chrome DevTools target on port ${debugPort}.`); +} + +function connectCdp(webSocketUrl) { + return new Promise((resolve, reject) => { + const socket = new WebSocket(webSocketUrl); + let nextId = 1; + const pending = new Map(); + socket.addEventListener("open", () => { + resolve({ + send(method, params = {}) { + const id = nextId++; + socket.send(JSON.stringify({ id, method, params })); + return new Promise((messageResolve, messageReject) => { + pending.set(id, { resolve: messageResolve, reject: messageReject }); + }); + }, + close() { + socket.close(); + }, + }); + }); + socket.addEventListener("message", event => { + const message = JSON.parse(event.data); + if (!message.id || !pending.has(message.id)) return; + const callbacks = pending.get(message.id); + pending.delete(message.id); + if (message.error) callbacks.reject(new Error(message.error.message || JSON.stringify(message.error))); + else callbacks.resolve(message.result || {}); + }); + socket.addEventListener("error", reject); + }); +} + +function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function findChrome() { + const candidates = [ + process.env.CHROME_PATH, + path.join(process.env.ProgramFiles || "", "Google/Chrome/Application/chrome.exe"), + path.join(process.env["ProgramFiles(x86)"] || "", "Google/Chrome/Application/chrome.exe"), + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/usr/bin/google-chrome", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + ].filter(Boolean); + const chrome = candidates.find(candidate => existsSync(candidate)); + if (!chrome) throw new Error("Chrome is required for the web layout probe and was not found."); + return chrome; +} + +function parseArgs(values) { + const parsed = {}; + for (let index = 0; index < values.length; index += 1) { + const key = values[index]; + if (!key.startsWith("--")) continue; + parsed[key.slice(2)] = values[index + 1]; + index += 1; + } + return parsed; +} diff --git a/web/eve-runtime-capability.json b/web/eve-runtime-capability.json index 12fc411..7e2260b 100644 --- a/web/eve-runtime-capability.json +++ b/web/eve-runtime-capability.json @@ -92,6 +92,10 @@ "status": "chrome-headless", "evidencePaths": [ "scripts/capture-web-reference.ps1", + "scripts/run-web-reference-layout-probe-smoke.ps1", + "tools/web-reference/run-layout-probe.mjs", + "web/layout-probe.html", + "web/layout-probe.js", "scripts/run-parity-smoke.ps1", "tools/parity/render-contact-sheet.py" ], diff --git a/web/layout-probe.html b/web/layout-probe.html new file mode 100644 index 0000000..bbdcad9 --- /dev/null +++ b/web/layout-probe.html @@ -0,0 +1,15 @@ + + + + + + Eve Web Layout Probe + + + + +
+

+  
+
+
diff --git a/web/layout-probe.js b/web/layout-probe.js
new file mode 100644
index 0000000..4ed49c9
--- /dev/null
+++ b/web/layout-probe.js
@@ -0,0 +1,110 @@
+import { renderEveSurface } from "../packages/eve-browser-lowering/dist/index.js";
+import { compileEveDsl } from "./eve-dsl.js";
+
+const app = document.querySelector("#app");
+const output = document.querySelector("#eve-layout-probe-output");
+const params = new URLSearchParams(location.search);
+const providerId = params.get("provider") || "gamecult.eve.embedded-demo";
+const fixtureId = params.get("fixture") || providerId;
+
+try {
+  const provider = await loadProvider(providerId);
+  const surfaceEntry = provider.surfaces?.[0];
+  if (!surfaceEntry) throw new Error(`${providerId} has no advertised surface`);
+  const state = await loadSurface(surfaceEntry);
+  renderEveSurface(state, app, {
+    activeSurfaceId: state.surface?.id,
+    body: document.body,
+    clientId: "browser.reference.layout-probe",
+    provider,
+    source: "web layout probe",
+  });
+  await animationFrame();
+  await animationFrame();
+  publishProbe(measureLayout(state, provider, fixtureId));
+} catch (error) {
+  publishProbe({
+    schema: "gamecult.eve.web_layout_probe.v1",
+    runtimeId: "web",
+    providerId,
+    surfaceId: "",
+    fixtureId,
+    generatedAt: new Date().toISOString(),
+    viewport: { width: window.innerWidth, height: window.innerHeight },
+    summary: { nodeCount: 0, measuredNodeCount: 0, zeroAreaCount: 0 },
+    nodes: [],
+    error: error instanceof Error ? error.message : String(error),
+  });
+}
+
+async function loadProvider(requestedProviderId) {
+  const response = await fetch("./local-provider-catalog.json", { cache: "no-store" });
+  const catalog = await response.json();
+  const provider = (catalog.providers || []).find(candidate =>
+    candidate.providerId === requestedProviderId || candidate.aliases?.includes(requestedProviderId));
+  if (!provider) throw new Error(`Unknown local provider: ${requestedProviderId}`);
+  if (!provider.advertisement) return provider;
+  const advertisement = await (await fetch(provider.advertisement, { cache: "no-store" })).json();
+  return {
+    ...advertisement,
+    ...provider,
+    surfaces: provider.surfaces || advertisement.surfaces || [],
+    localAdvertisement: advertisement,
+  };
+}
+
+async function loadSurface(surface) {
+  const response = await fetch(surface.url, { cache: "no-store" });
+  const source = await response.text();
+  if (surface.transport === "local-eve-dsl") return compileEveDsl(source);
+  if (surface.transport === "local-json") return JSON.parse(source);
+  throw new Error(`Unsupported probe surface transport: ${surface.transport}`);
+}
+
+function measureLayout(state, provider, fixture) {
+  const nodes = [...document.querySelectorAll("[data-eve-node-id]")].map(element => {
+    const rect = element.getBoundingClientRect();
+    return {
+      nodeId: element.dataset.eveNodeId || "",
+      kind: element.dataset.eveKind || "",
+      rect: {
+        x: round(rect.x),
+        y: round(rect.y),
+        width: round(rect.width),
+        height: round(rect.height),
+      },
+    };
+  }).filter(node => node.nodeId && node.kind);
+  const measuredNodeCount = nodes.filter(node => node.rect.width > 0 && node.rect.height > 0).length;
+  return {
+    schema: "gamecult.eve.web_layout_probe.v1",
+    runtimeId: "web",
+    providerId: state.providerId || provider.providerId || providerId,
+    surfaceId: state.surface?.id || "",
+    fixtureId: fixture,
+    generatedAt: new Date().toISOString(),
+    viewport: {
+      width: window.innerWidth,
+      height: window.innerHeight,
+    },
+    summary: {
+      nodeCount: nodes.length,
+      measuredNodeCount,
+      zeroAreaCount: nodes.length - measuredNodeCount,
+    },
+    nodes,
+  };
+}
+
+function publishProbe(probeDocument) {
+  output.textContent = JSON.stringify(probeDocument, null, 2);
+  document.body?.setAttribute?.("data-probe-ready", "true");
+}
+
+function animationFrame() {
+  return new Promise(resolve => requestAnimationFrame(() => resolve()));
+}
+
+function round(value) {
+  return Math.round(value * 100) / 100;
+}