From 7db8dec0d919cbca837465ae08e2654b87dae901 Mon Sep 17 00:00:00 2001 From: leader Date: Mon, 19 Oct 2026 23:21:22 +0900 Subject: [PATCH 1/2] test: require real-browser render verification for generated pages --- .../changepack_log_GfFuy6qyWB53YjWJTrRNT.json | 7 + .../examples/render_fixture.rs | 23 + .../skills/devup-implementation-plan/SKILL.md | 23 +- .../src/server/skills/devup/SKILL.md | 11 +- .../devup-mcp/src/server/skills/manifest.json | 8 +- harness/runtime-audit/README.md | 76 ++ .../runtime-audit/fixtures/render-hero.png | Bin 0 -> 877 bytes .../fixtures/render-reference.png | Bin 0 -> 1083 bytes .../fixtures/render-snapshot.json | 28 + harness/runtime-audit/package-lock.json | 933 ++++++++++++++++++ harness/runtime-audit/package.json | 11 +- .../runtime-audit/test/real-render.test.mjs | 107 ++ harness/runtime-audit/verify-render.mjs | 97 ++ 13 files changed, 1313 insertions(+), 11 deletions(-) create mode 100644 .changepacks/changepack_log_GfFuy6qyWB53YjWJTrRNT.json create mode 100644 crates/devup-mcp-devup-ui/examples/render_fixture.rs create mode 100644 harness/runtime-audit/README.md create mode 100644 harness/runtime-audit/fixtures/render-hero.png create mode 100644 harness/runtime-audit/fixtures/render-reference.png create mode 100644 harness/runtime-audit/fixtures/render-snapshot.json create mode 100644 harness/runtime-audit/test/real-render.test.mjs create mode 100644 harness/runtime-audit/verify-render.mjs diff --git a/.changepacks/changepack_log_GfFuy6qyWB53YjWJTrRNT.json b/.changepacks/changepack_log_GfFuy6qyWB53YjWJTrRNT.json new file mode 100644 index 00000000..6e34790c --- /dev/null +++ b/.changepacks/changepack_log_GfFuy6qyWB53YjWJTrRNT.json @@ -0,0 +1,7 @@ +{ + "changes": { + "Cargo.toml": "Patch" + }, + "note": "Require real Chromium rendering and pinned Figma pixel comparisons before page completion; cover generated DevupUI pages, missing backgrounds, wrong colors and failed assets in CI.", + "date": "2026-10-19T14:17:11.350699200Z" +} \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/examples/render_fixture.rs b/crates/devup-mcp-devup-ui/examples/render_fixture.rs new file mode 100644 index 00000000..ae6f30c6 --- /dev/null +++ b/crates/devup-mcp-devup-ui/examples/render_fixture.rs @@ -0,0 +1,23 @@ +//! Replay a snapshot through production codegen for the browser regression test. +use std::{error::Error, path::Path}; + +use devup_mcp_devup_ui::codegen::{ + CodegenOptions, generate_component, node_ids::annotate_node_ids, +}; +use devup_mcp_figma::Snapshot; + +fn main() -> Result<(), Box> { + let args: Vec = std::env::args().skip(1).collect(); + if args.len() != 2 { + return Err("usage: render_fixture ".into()); + } + let snapshot: Snapshot = serde_json::from_slice(&std::fs::read(&args[0])?)?; + let options = CodegenOptions { + component_name: Some("RenderFixture".into()), + ..Default::default() + }; + let mut output = generate_component(&snapshot, &snapshot.roots[0], &options)?; + annotate_node_ids(&mut output, &snapshot.nodes.keys().cloned().collect()); + std::fs::write(Path::new(&args[1]), output.tsx)?; + Ok(()) +} diff --git a/crates/devup-mcp/src/server/skills/devup-implementation-plan/SKILL.md b/crates/devup-mcp/src/server/skills/devup-implementation-plan/SKILL.md index b4597c39..255599ef 100644 --- a/crates/devup-mcp/src/server/skills/devup-implementation-plan/SKILL.md +++ b/crates/devup-mcp/src/server/skills/devup-implementation-plan/SKILL.md @@ -378,6 +378,13 @@ including a "view transitions: enabled (mechanism) / skipped (reason)" line. with the app running. If the runner cannot be installed, call status with `auditPlanPath` and `toolingUnavailable: ""`: that URL's audit is inconclusive and the rest of the work goes on. + This real-browser step is required before claiming any page is done: build + and serve the actual generated app, not a hand-written stand-in. Static + checks and TSX snapshots cannot detect missing background images, wrong + colors or assets that never load. Run `image-loaded` and `page-health`, read + the browser report, and retain screenshots with their viewport and reference + identities. A report's `completed` means execution finished; a capture check's + `pass` means only that pixels were captured. Neither is a visual verdict. 9. **Read the status, fix, re-run.** `devup_screen_plan` `action: "status"` with `auditPlanPath` and `auditReportPath`. Run every `visualComparisons` call (`devup_visual_compare`, each with its `threshold` and `diffPath`) @@ -421,10 +428,15 @@ including a "view transitions: enabled (mechanism) / skipped (reason)" line. with `assume` (`about`: the decision id, `decision`: the recommended option). A destructive one (deleting, overwriting, reaching outside the project) stays unbuilt and is listed in the report. -11. **Done means zero fails.** A URL is done when every check and comparison - passes at its drawn widths and health and overflow pass at every width - - or when each remaining finding is a converter bug with its repro filed, or - listed as unresolved with its evidence. Before reporting done, also: +11. **Done means zero fails.** A URL is done only after its actual Chromium + screenshots have been compared with independently exported Figma originals, + every check and comparison passes at its drawn widths, and health and + overflow pass at every width. Inspect the page/section/asset diffs for + background images, colors and loaded assets; feed every result back to + status. Missing references, unavailable browser tooling, not-run comparisons, + converter bugs and unresolved findings remain incomplete with evidence. + Preserve screenshots, reports, Figma reference identities and diffs. + Before reporting done, also: - every confirmed URL is implemented; - the build passes: `bun run build` (`vinext build` for every app); @@ -453,7 +465,8 @@ Hard rules, after confirm: - Visually verify every screen, section and asset at every drawn width. - Never stop to wait; reorder the work. -- Done = zero fails, or listed as unresolved with evidence. +- Done = a real browser render and zero failed or missing comparisons; + unresolved findings are reported as incomplete with evidence. - Layout is exact; a mismatch is drift or a converter bug, never tuned. ## What the questions mean diff --git a/crates/devup-mcp/src/server/skills/devup/SKILL.md b/crates/devup-mcp/src/server/skills/devup/SKILL.md index a363690f..349dc650 100644 --- a/crates/devup-mcp/src/server/skills/devup/SKILL.md +++ b/crates/devup-mcp/src/server/skills/devup/SKILL.md @@ -179,7 +179,16 @@ the full rules; these are the steps. 11. **Never stop to wait.** A decision mid-run: `action: "defer"` with the recommended option, keep working, take it with `assume` at the end; a destructive one stays unbuilt. -12. **Done = zero fails.** Every check and comparison passes at the drawn +12. **Done = zero fails.** A real Chromium render is mandatory before claiming + any page is done. Build and serve the generated app, execute the Playwright + runner, read its report and compare the actual screenshots with independent + Figma references. Static checks, TSX snapshots, a generated audit plan and a + capture check's `pass` do not prove visual correctness. Verify background + images, colors and loaded assets; inspect page/section/asset diffs and feed + every visual result back to status. Missing references, unavailable browser + tooling, failed assets and not-run comparisons mean incomplete. Preserve the + screenshots, reference identity, report and diffs in the final evidence. + Every check and comparison passes at the drawn widths and health and overflow pass at every required width. Report filed converter bugs and unresolved findings with evidence as incomplete. Also: every URL implemented, the production build free of `data-figma-node-id` (the diff --git a/crates/devup-mcp/src/server/skills/manifest.json b/crates/devup-mcp/src/server/skills/manifest.json index 0d525f81..fa42f605 100644 --- a/crates/devup-mcp/src/server/skills/manifest.json +++ b/crates/devup-mcp/src/server/skills/manifest.json @@ -71,8 +71,8 @@ "documents": [ { "path": "SKILL.md", - "bytes": 35206, - "sha256": "a60e55d51cc97c563419dcc50b449c8d83c2a8324470cfc6ade304a84b85f541" + "bytes": 36174, + "sha256": "3278b142e6780fb99413185b9c01ebabd1293fea1a924fc09aeae5b818c07f0f" } ], "sourceUrl": "https://github.com/dev-five-git/devup-mcp/blob/HEAD/crates/devup-mcp/src/server/skills/devup-implementation-plan", @@ -89,8 +89,8 @@ "documents": [ { "path": "SKILL.md", - "bytes": 13682, - "sha256": "8e9f69df9be904af473844f4db606f2b2f35fe0318da07f142d08a9163a9e783" + "bytes": 14375, + "sha256": "c866ca964eb42cc01a4477a6922b6187abe3992cee6228476501373c1486a092" } ], "sourceUrl": "https://github.com/dev-five-git/devup-mcp/blob/HEAD/crates/devup-mcp/src/server/skills/devup", diff --git a/harness/runtime-audit/README.md b/harness/runtime-audit/README.md new file mode 100644 index 00000000..57c7c3c6 --- /dev/null +++ b/harness/runtime-audit/README.md @@ -0,0 +1,76 @@ +# Real-render verification + +Static validation and a successful screenshot capture do not establish visual +correctness. With the generated app running, execute its runtime audit, compare +the actual Chromium screenshots against the independently acquired Figma PNGs, +and inspect the diffs before calling the page done. + +The existing `run.mjs` + `devup_runtime_audit ingest` + `devup_visual_compare` +workflow supports page, section and asset comparisons. A capture check's `pass` +means **captured**, not **visually matched**. Feed every comparison result back +to screen-plan status; missing, failed or inconclusive cells block completion. + +For a command-line page gate (including CI): + +```sh +npm ci +npx playwright install chromium +cargo build -p devup-mcp-visual +node verify-render.mjs --plan plan.json --references references.json --out out/page --visual-bin ../../target/debug/devup-mcp-visual +``` + +On Windows append `.exe` to the comparator path. With a custom Cargo target +directory use the binary under `cargo metadata`'s `target_directory`. + +Use an audit plan containing the drawn viewports at deviceScaleFactor 1, with +`page-health`, `image-loaded` and the relevant geometry checks. Use the normal +runtime audit separately for widths without Figma originals. Each planned +screen/viewport must have exactly one entry in `references.json`: + +```json +{ + "references": [{ + "screen": "home", + "width": 1440, + "height": 900, + "path": "figma/home-1440.png", + "sha256": "", + "threshold": 0.03 + }] +} +``` + +Paths are relative to the manifest. Export the original frame at scale 1 and +the drawn dimensions, without resizing it. Never create a reference from the +generated page, replace an original to pass a test, or raise thresholds to hide +wrong backgrounds, colors or assets. Pin the original hash and retain its Figma +file/node/version provenance alongside the manifest. + +`verify-render.mjs` launches the runtime-audit Chromium runner and compares every +page screenshot with `devup-mcp-visual`. It writes the runtime report, screenshots, +`real-render.json` and `visual-diff-*.png`. Exit codes: 0 means runtime and pixels +passed; 1 means a runtime or visual failure; 2 means tooling, input or reference +failure. Missing/duplicate references, changed reference bytes, missing captures, +browser errors and invalid image dimensions cannot pass. Do not infer success +from `report.status: completed`, which describes execution, not correctness. + +## Offline regression + +`npm test` runs all tests, including `test/real-render.test.mjs`. This needs Rust, +Node and Chromium; it does not silently skip when they are missing. The test +replays `fixtures/render-snapshot.json` through production Rust codegen, builds +the result with the real DevupUI plugin, serves the production app, and compares +the Chromium capture through the production Rust pixel comparator. The correct +page must match exactly. Removing its background, changing a card color or +requesting an unavailable asset must fail while the geometry still passes. + +The small checked-in PNG oracle is **synthetic**, not a live Figma acquisition: +its independent pixel construction represents the snapshot's 320x240 frame, +with `#123456` above y=120, `#abcdef` below, and a red 80x60 card at (32,32). +`render-hero.png` is the same background without the card. The oracle is never +rendered from the generated code or updated by the test. This deterministic +offline contract proves the gate detects rendering failures; verification of an +actual Figma page requires that page's exported original as described above. + +Set `DEVUP_KEEP_RENDER_EVIDENCE=1` to retain regression artifacts under `out/`; +CI uploads them even on failure. diff --git a/harness/runtime-audit/fixtures/render-hero.png b/harness/runtime-audit/fixtures/render-hero.png new file mode 100644 index 0000000000000000000000000000000000000000..d0238a82801cf293fba877e70aa8fef54922dbdc GIT binary patch literal 877 zcmeAS@N?(olHy`uVBq!ia0y~yU~~YoKX9-C$wJ+|*$fQKCY~;iAr*7pUNK~BFc3I! zAj^R}%Aiwhi^4AI(+rI|%q*LD1Wt@{1|STUWifj+9sE4`$Ey%vHe&E}^>bP0l+XkKq%83i literal 0 HcmV?d00001 diff --git a/harness/runtime-audit/fixtures/render-reference.png b/harness/runtime-audit/fixtures/render-reference.png new file mode 100644 index 0000000000000000000000000000000000000000..cd99c3435cec76d9fc33eef46517e674e006124d GIT binary patch literal 1083 zcmeAS@N?(olHy`uVBq!ia0y~yU~~YoKX9-C$wJ+|*$fOUOr9=|Ar*7pUUBqgU=(0E zc)3ArOM;MYhT|c{rsHXszOMMMGWpBrk3YXMiP&CeW?09eFhRA!VHyKdBojvpCk;5) zDjDuSe<;81e{(96#k*hj5o!&OM>zu#24!*%4cfGd<+8l;P=U=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz", + "integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz", + "integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz", + "integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz", + "integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz", + "integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz", + "integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz", + "integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz", + "integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz", + "integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz", + "integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz", + "integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz", + "integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz", + "integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz", + "integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype-extra": { + "version": "0.1.37", + "resolved": "https://registry.npmjs.org/csstype-extra/-/csstype-extra-0.1.37.tgz", + "integrity": "sha512-zrYj38Dm1DAMWM9lW1tto2oGewGfCqRNN80HEV5TVnkTTo1aSYPaiuGz8SXtFwd7534PSGLZ0zxW8+3X74Mcdg==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "typescript": "^5" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/playwright": { @@ -38,6 +758,219 @@ "engines": { "node": ">=20" } + }, + "node_modules/postcss": { + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz", + "integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.154.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.14", + "@rolldown/binding-android-arm64": "1.2.14", + "@rolldown/binding-darwin-arm64": "1.2.14", + "@rolldown/binding-darwin-x64": "1.2.14", + "@rolldown/binding-freebsd-x64": "1.2.14", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.14", + "@rolldown/binding-linux-arm64-gnu": "1.2.14", + "@rolldown/binding-linux-arm64-musl": "1.2.14", + "@rolldown/binding-linux-ppc64-gnu": "1.2.14", + "@rolldown/binding-linux-s390x-gnu": "1.2.14", + "@rolldown/binding-linux-x64-gnu": "1.2.14", + "@rolldown/binding-linux-x64-musl": "1.2.14", + "@rolldown/binding-openharmony-arm64": "1.2.14", + "@rolldown/binding-win32-arm64-msvc": "1.2.14", + "@rolldown/binding-win32-x64-msvc": "1.2.14" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } } } } diff --git a/harness/runtime-audit/package.json b/harness/runtime-audit/package.json index 4ca404f9..609d93fb 100644 --- a/harness/runtime-audit/package.json +++ b/harness/runtime-audit/package.json @@ -7,10 +7,19 @@ "devup-runtime-audit": "run.mjs" }, "scripts": { - "test": "node --test test/checks.test.mjs test/browser.test.mjs test/layout.test.mjs test/appearance.test.mjs", + "test": "node --test test/*.test.mjs", + "verify:render": "node verify-render.mjs", "test:unit": "node --test test/checks.test.mjs" }, "dependencies": { "playwright": "1.63.0" + }, + "devDependencies": { + "@devup-ui/react": "^1.0.41", + "@devup-ui/reset-css": "^1.0.28", + "@devup-ui/vite-plugin": "^1.0.69", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "vite": "^8.3.0" } } diff --git a/harness/runtime-audit/test/real-render.test.mjs b/harness/runtime-audit/test/real-render.test.mjs new file mode 100644 index 00000000..2f94bfba --- /dev/null +++ b/harness/runtime-audit/test/real-render.test.mjs @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict'; +import { execFile, spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { readFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import test from 'node:test'; +import { build, preview } from 'vite'; +import { DevupUI } from '@devup-ui/vite-plugin'; +import { verifyRender } from '../verify-render.mjs'; + +const harness = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const repo = resolve(harness, '../..'); +const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex'); +const execute = promisify(execFile); + +async function cli(args) { + // Keep the Vite preview server's event loop free while Chromium visits it. + try { + return { status: 0, ...await execute(process.execPath, [join(harness, 'verify-render.mjs'), ...args], { windowsHide: true, timeout: 60_000 }) }; + } catch (error) { + return { status: error.code, stdout: error.stdout, stderr: error.stderr }; + } +} + +function cargo(args) { + const result = spawnSync('cargo', args, { cwd: repo, encoding: 'utf8', windowsHide: true, timeout: 300_000 }); + assert.equal(result.status, 0, `${args.join(' ')}\n${result.error ?? ''}\n${result.stderr}`); + return result.stdout; +} + +test('production codegen renders in Chromium; geometry alone cannot pass broken pixels or assets', { timeout: 360_000 }, async () => { + const outRoot = join(harness, 'out'); + await mkdir(outRoot, { recursive: true }); + const dir = await mkdtemp(join(outRoot, 'real-render-')); + let server; + try { + cargo(['run', '--quiet', '-p', 'devup-mcp-devup-ui', '--example', 'render_fixture', '--', + join(harness, 'fixtures/render-snapshot.json'), join(dir, 'Generated.tsx')]); + cargo(['build', '--quiet', '-p', 'devup-mcp-visual']); + const target = JSON.parse(cargo(['metadata', '--format-version', '1', '--no-deps'])).target_directory; + const visualBin = join(target, 'debug', process.platform === 'win32' ? 'devup-mcp-visual.exe' : 'devup-mcp-visual'); + await mkdir(join(dir, 'public/images'), { recursive: true }); + const hero = await readFile(join(harness, 'fixtures/render-hero.png')); + await writeFile(join(dir, 'public/images/Hero.png'), hero); + await writeFile(join(dir, 'devup.json'), JSON.stringify({ theme: {} })); + await writeFile(join(dir, 'index.html'), '
'); + await writeFile(join(dir, 'main.tsx'), `import '@devup-ui/reset-css'; +import { createRoot } from 'react-dom/client'; +import { flushSync } from 'react-dom'; +import { RenderFixture } from './Generated'; +flushSync(() => createRoot(document.getElementById('root')!).render()); +const mode = new URLSearchParams(location.search).get('mode'); +const style = document.createElement('style'); +style.textContent = mode === 'missing-background' ? '[data-figma-node-id="1:1"] img{visibility:hidden!important}' + : mode === 'wrong-color' ? '[data-figma-node-id="1:2"]{background:#00ff00!important}' + : ''; +if (mode === 'unloaded-asset') document.querySelector('img')!.src = '/missing.png'; +document.head.append(style);`); + const config = { root: dir, configFile: false, logLevel: 'error', plugins: [DevupUI()], build: { outDir: 'dist' }, preview: { host: '127.0.0.1', port: 0 } }; + await build(config); + server = await preview(config); + const baseUrl = `http://127.0.0.1:${server.httpServer.address().port}`; + const reference = join(harness, 'fixtures/render-reference.png'); + const references = join(dir, 'references.json'); + await writeFile(references, JSON.stringify({ references: [{ screen: 'hero', width: 320, height: 240, + path: reference, sha256: sha256(await readFile(reference)), threshold: 0.005 }] })); + for (const mode of ['correct', 'missing-background', 'wrong-color', 'unloaded-asset']) { + const plan = join(dir, `${mode}.json`); + await writeFile(plan, JSON.stringify({ schemaVersion: 1, kind: 'devup-runtime-audit-plan', planId: mode, plan: { + baseUrl, allowedOrigins: [baseUrl], viewports: [{ width: 320, height: 240, deviceScaleFactor: 1 }], + readiness: { selector: '[data-figma-node-id="1:2"]' }, + screens: [{ id: 'hero', routePath: `/?mode=${mode}`, checks: [ + { kind: 'bounds', subject: { css: '[data-figma-node-id="1:1"]' }, container: { css: 'body' }, expected: { x: 0, y: 0, width: 320, height: 240 } }, + { kind: 'image-loaded' }, { kind: 'page-health' }, + ] }], + } })); + const output = join(dir, mode); + const command = await cli(['--plan', plan, '--references', references, '--out', output, '--visual-bin', visualBin]); + assert.equal(command.status, mode === 'correct' ? 0 : 1, `${mode}: ${command.stderr}\n${command.stdout}`); + const result = JSON.parse(await readFile(join(output, 'real-render.json'), 'utf8')); + const runtime = JSON.parse(await readFile(join(output, 'report.json'), 'utf8')); + assert.equal(runtime.runs[0].screens[0].checks[0].status, 'pass', JSON.stringify(runtime)); + assert.equal(result.verdict, mode === 'correct' ? 'pass' : 'fail', JSON.stringify(result)); + assert.equal(result.comparisons[0].status, mode === 'correct' ? 'exact' : 'mismatch', JSON.stringify(result)); + assert.ok((await readFile(join(output, 'visual-diff-0.png'))).length > 0); + const images = runtime.runs[0].screens[0].checks[1]; + assert.equal(images.status, mode === 'unloaded-asset' ? 'fail' : 'pass'); + } + const plan = join(dir, 'correct.json'); + const valid = JSON.parse(await readFile(references, 'utf8')); + await writeFile(references, JSON.stringify({ references: [valid.references[0], valid.references[0]] })); + await assert.rejects(verifyRender({ plan, references, out: join(dir, 'duplicate-reference'), visualBin }), /one Figma reference/); + valid.references[0].sha256 = '0'.repeat(64); + await writeFile(references, JSON.stringify(valid)); + await assert.rejects(verifyRender({ plan, references, out: join(dir, 'changed-reference'), visualBin }), /hash differs/); + await writeFile(references, '{"references":[]}'); + await assert.rejects(verifyRender({ plan, references, out: join(dir, 'missing-reference'), visualBin }), /manifest is empty/); + const invalid = await cli(['--plan', plan, '--references', references, '--out', join(dir, 'invalid'), '--visual-bin', visualBin]); + assert.equal(invalid.status, 2, invalid.stderr); + } finally { + if (server) await new Promise((done) => server.httpServer.close(done)); + // Preserve browser evidence on request for local diagnosis and CI uploads. + if (!process.env.DEVUP_KEEP_RENDER_EVIDENCE) await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/harness/runtime-audit/verify-render.mjs b/harness/runtime-audit/verify-render.mjs new file mode 100644 index 00000000..c33cf7d4 --- /dev/null +++ b/harness/runtime-audit/verify-render.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +// A capture is evidence, not a visual verdict. Run Chromium, then compare every +// planned screen/viewport against an independently acquired, pinned Figma PNG. +import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { readFile, writeFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { runPlan } from './run.mjs'; + +const hash = (bytes) => createHash('sha256').update(bytes).digest('hex'); + +function compare(binary, args) { + return new Promise((resolveResult, reject) => { + const child = spawn(binary, args, { windowsHide: true }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (data) => { stdout += data; }); + child.stderr.on('data', (data) => { stderr += data; }); + child.on('error', reject); + child.on('close', (code) => { + if (code !== 0 && code !== 1) return reject(new Error(`visual comparator exited ${code}: ${stderr}`)); + try { resolveResult({ exitCode: code, ...JSON.parse(stdout) }); } catch (error) { reject(error); } + }); + }); +} + +export async function verifyRender({ plan, references, out, visualBin, maxMinutes = 10 }) { + const document = JSON.parse(await readFile(plan, 'utf8')); + const manifest = JSON.parse(await readFile(references, 'utf8')); + const entries = manifest.references; + if (!Array.isArray(entries) || !entries.length) throw new Error('Figma reference manifest is empty'); + const expected = document.plan.screens.flatMap((screen) => document.plan.viewports.map((viewport) => ({ screen: screen.id, viewport }))); + if (entries.length !== expected.length) throw new Error('Require one Figma reference per planned screen and viewport'); + // Validate and pin all originals before opening the browser. Never derive an + // expected image from the generated page or silently accept missing widths. + const originals = []; + for (const screen of document.plan.screens) { + for (const kind of ['page-health', 'image-loaded']) { + if (!screen.checks.some((check) => check.kind === kind && !check.widths?.length)) { + throw new Error(`Screen ${screen.id} requires ${kind} at every viewport`); + } + } + } + for (const { screen, viewport } of expected) { + const matches = entries.filter((entry) => entry.screen === screen && entry.width === viewport.width && entry.height === viewport.height); + if (matches.length !== 1) throw new Error(`Require exactly one Figma reference for ${screen} ${viewport.width}x${viewport.height}`); + const entry = matches[0]; + if (viewport.deviceScaleFactor != null && viewport.deviceScaleFactor !== 1) throw new Error('Figma comparison requires deviceScaleFactor 1'); + if (!Number.isFinite(entry.threshold) || entry.threshold < 0 || entry.threshold >= 1) throw new Error('Reference threshold must be in [0, 1)'); + const path = resolve(dirname(references), entry.path); + if (!/^[a-f0-9]{64}$/.test(entry.sha256) || hash(await readFile(path)) !== entry.sha256) throw new Error(`Figma reference hash differs: ${path}`); + originals.push({ ...entry, path }); + } + const runtime = await runPlan({ plan, out, maxMinutes }); + const comparisons = []; + for (const [index, original] of originals.entries()) { + const run = runtime.runs.find((item) => item.viewport.width === original.width && item.viewport.height === original.height); + const screen = run?.screens.find((item) => item.id === original.screen); + if (!screen?.screenshot) { + comparisons.push({ screen: original.screen, width: original.width, status: 'not-run' }); + continue; + } + const actual = resolve(out, screen.screenshot.path); + if (hash(await readFile(actual)) !== screen.screenshot.sha256) throw new Error(`Screenshot hash differs: ${actual}`); + const result = await compare(visualBin, ['compare', '--reference', original.path, '--actual', actual, + '--diff', resolve(out, `visual-diff-${index}.png`), '--max-changed-ratio', String(original.threshold)]); + comparisons.push({ screen: original.screen, width: original.width, referenceSha256: original.sha256, actual, ...result }); + } + const passed = runtime.status === 'completed' + && runtime.runs.length === document.plan.viewports.length + && runtime.runs.every((run) => !run.environmentErrors.length && run.screens.length === document.plan.screens.length + && run.screens.every((screen) => screen.status === 'completed' && screen.checks.every((check) => check.status === 'pass'))) + && comparisons.every((item) => item.exitCode === 0 && ['exact', 'within-threshold'].includes(item.status)); + const result = { verdict: passed ? 'pass' : 'fail', runtimeReport: join(out, 'report.json'), comparisons }; + await writeFile(join(out, 'real-render.json'), `${JSON.stringify(result, null, 2)}\n`); + return result; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { + const options = {}; + const names = { '--plan': 'plan', '--references': 'references', '--out': 'out', '--visual-bin': 'visualBin' }; + for (let index = 2; index < process.argv.length; index += 2) { + const name = names[process.argv[index]]; + if (!name || !process.argv[index + 1]) throw new Error('usage: node verify-render.mjs --plan --references --out --visual-bin '); + options[name] = resolve(process.argv[index + 1]); + } + if (Object.keys(options).length !== 4) throw new Error('plan, references, out and visual-bin are required'); + const result = await verifyRender(options); + console.log(`${result.verdict}: ${result.comparisons.length} real-browser comparisons`); + process.exitCode = result.verdict === 'pass' ? 0 : 1; + } catch (error) { + console.error(String(error?.message ?? error)); + process.exitCode = 2; + } +} From 783da2b360ab561afd1e73f051dedb95c5f7388a Mon Sep 17 00:00:00 2001 From: leader Date: Mon, 19 Oct 2026 23:23:01 +0900 Subject: [PATCH 2/2] docs: record CI workflow permission limit --- ...rRNT.json => changepack_log_wVMbmtKOkV4yK0ZUQvwQ2.json} | 4 ++-- harness/runtime-audit/README.md | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) rename .changepacks/{changepack_log_GfFuy6qyWB53YjWJTrRNT.json => changepack_log_wVMbmtKOkV4yK0ZUQvwQ2.json} (57%) diff --git a/.changepacks/changepack_log_GfFuy6qyWB53YjWJTrRNT.json b/.changepacks/changepack_log_wVMbmtKOkV4yK0ZUQvwQ2.json similarity index 57% rename from .changepacks/changepack_log_GfFuy6qyWB53YjWJTrRNT.json rename to .changepacks/changepack_log_wVMbmtKOkV4yK0ZUQvwQ2.json index 6e34790c..ce1d882c 100644 --- a/.changepacks/changepack_log_GfFuy6qyWB53YjWJTrRNT.json +++ b/.changepacks/changepack_log_wVMbmtKOkV4yK0ZUQvwQ2.json @@ -2,6 +2,6 @@ "changes": { "Cargo.toml": "Patch" }, - "note": "Require real Chromium rendering and pinned Figma pixel comparisons before page completion; cover generated DevupUI pages, missing backgrounds, wrong colors and failed assets in CI.", - "date": "2026-10-19T14:17:11.350699200Z" + "note": "Require real Chromium rendering and pinned Figma pixel comparisons before page completion; cover generated DevupUI pages, missing backgrounds, wrong colors and failed assets in the end-to-end harness.", + "date": "2026-10-19T14:22:49.794557600Z" } \ No newline at end of file diff --git a/harness/runtime-audit/README.md b/harness/runtime-audit/README.md index 57c7c3c6..22bf7a50 100644 --- a/harness/runtime-audit/README.md +++ b/harness/runtime-audit/README.md @@ -72,5 +72,8 @@ rendered from the generated code or updated by the test. This deterministic offline contract proves the gate detects rendering failures; verification of an actual Figma page requires that page's exported original as described above. -Set `DEVUP_KEEP_RENDER_EVIDENCE=1` to retain regression artifacts under `out/`; -CI uploads them even on failure. +Set `DEVUP_KEEP_RENDER_EVIDENCE=1` to retain regression artifacts under `out/` +for diagnosis or upload by a CI caller. Run `npm ci`, +`npx playwright install --with-deps chromium`, and `npm test` to use this suite +in Linux CI. The repository workflow is not wired to this browser suite yet: +the GitHub proxy rejected pushes containing that workflow-file modification.