Add web layout probe evidence
This commit is contained in:
parent
e01b71495a
commit
c0c17beb45
17 changed files with 605 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
4
packages/eve-browser-lowering/dist/index.js
vendored
4
packages/eve-browser-lowering/dist/index.js
vendored
|
|
@ -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 : {};
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> {
|
||||
|
|
|
|||
108
schemas/gamecult.eve.web_layout_probe.v1.schema.json
Normal file
108
schemas/gamecult.eve.web_layout_probe.v1.schema.json
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 `
|
||||
|
|
|
|||
59
scripts/run-web-reference-layout-probe-smoke.ps1
Normal file
59
scripts/run-web-reference-layout-probe-smoke.ps1
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 || []);
|
||||
}
|
||||
|
|
|
|||
191
tools/web-reference/run-layout-probe.mjs
Normal file
191
tools/web-reference/run-layout-probe.mjs
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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"
|
||||
],
|
||||
|
|
|
|||
15
web/layout-probe.html
Normal file
15
web/layout-probe.html
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Eve Web Layout Probe</title>
|
||||
<link rel="stylesheet" href="../packages/eve-browser-lowering/styles.css" />
|
||||
<link rel="stylesheet" href="./cultui-reference-overrides.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main id="app" class="surface"></main>
|
||||
<pre id="eve-layout-probe-output"></pre>
|
||||
<script type="module" src="./layout-probe.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
110
web/layout-probe.js
Normal file
110
web/layout-probe.js
Normal file
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in a new issue