diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index c8d013938..ce69edfbb 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "hyperframes", "description": "HyperFrames by HeyGen. Write HTML, render video. Compositions, GSAP and runtime adapter animations, captions, voiceovers, audio-reactive visuals, and website-to-video capture for HyperFrames.", - "version": "0.7.46", + "version": "0.7.51", "author": { "name": "HeyGen", "email": "hyperframes@heygen.com", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index c484f35bc..e3d71df7a 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "hyperframes", - "version": "0.7.46", + "version": "0.7.51", "description": "Write HTML, render video. Compositions, Tailwind v4 styles, GSAP and runtime adapter animations, captions, voiceovers, audio-reactive visuals, and website-to-video capture for HyperFrames.", "author": { "name": "HeyGen", diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index ffb484b14..856a65c7d 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://cursor.com/schemas/cursor-plugin/plugin.json", "name": "hyperframes", "displayName": "HyperFrames by HeyGen", - "version": "0.7.46", + "version": "0.7.51", "description": "Write HTML, render video. Compositions, Tailwind v4 styles, GSAP and runtime adapter animations, captions, voiceovers, audio-reactive visuals, and website-to-video capture for HyperFrames.", "author": { "name": "HeyGen", diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 0c09507e3..32a6d5bc8 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -22,6 +22,9 @@ // Built as standalone IIFE for the browser-side sandbox runtime; // referenced by file path (not import) in build-hyperframes-runtime-artifact.ts. "packages/core/src/runtime/entry.ts", + // Bundled as a standalone IIFE by the position-edits render artifact + // generator; referenced by file path rather than imported by TypeScript. + "packages/core/stubs/position-edits-render-entry.ts", // In-page audit scripts read as raw strings and injected via // page.addScriptTag (see layout.ts / validate.ts) — referenced by file // path, never imported, so they have no import-graph referrer. diff --git a/.gitignore b/.gitignore index 211ff22fb..21053dc04 100644 --- a/.gitignore +++ b/.gitignore @@ -72,7 +72,9 @@ tmp/ .tmp/ # Generated files -packages/core/src/generated/ +packages/core/src/generated/* +!packages/core/src/generated/position-edits-render-inline.ts +!packages/core/src/generated/position-edits-render-inline.test.ts packages/producer/src/services/fontData.generated.ts # Plan documents (working artifacts, not committed) diff --git a/AGENTS.md b/AGENTS.md index 7264b1adb..1c1cd9eff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,7 @@ After creating or editing any `.html` composition: ```bash npx hyperframes lint # Static HTML structure check -npx hyperframes validate # Runtime check (headless Chrome — catches JS errors, missing assets) +npx hyperframes check # Browser gate (headless Chrome — runtime errors, layout, motion, WCAG contrast) ``` Both must pass before previewing or considering work complete. diff --git a/CLAUDE.md b/CLAUDE.md index 7c895db16..abd8a019f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,7 +39,7 @@ Atomic capabilities the creation workflows compose against — pull one when you - `/hyperframes-keyframes` — seek-safe keyframe authoring across runtimes: GSAP timelines, CSS keyframes, Anime.js, WAAPI, FLIP, paths, masks, SVG morph/draw, text trails, 3D depth; plus `hyperframes keyframes` diagnostics for surfacing and verifying rendered motion. - `/hyperframes-creative` — non-animation creative direction: `frame.md` / `design.md` handling, palettes, typography, narration, beat planning, audio-reactive visuals, composition patterns. - `/media-use` — the media OS: resolve any media need (BGM, SFX, image, icon, logo, voice, color grade, LUT) into a frozen local file or paste-ready block + ledger record; generate via TTS / music / image models when the catalog misses; transcribe, caption, remove backgrounds, and reuse assets across projects. One shared `scripts/audio.mjs` engine + manifest tracking; keeps search noise on disk. -- `/hyperframes-cli` — CLI dev loop: `init`, `add`, `lint`, `validate`, `inspect`, `preview`, `render`, `publish`, `doctor`, `lambda` (AWS Lambda cloud rendering). +- `/hyperframes-cli` — CLI dev loop: `init`, `add`, `lint`, `check`, `snapshot`, `preview`, `render`, `publish`, `doctor`, `lambda` (AWS Lambda cloud rendering). - `/hyperframes-registry` — install and wire registry blocks and components into compositions via `hyperframes add`. Covers authoring a new block or component to contribute upstream. - `/figma` — import Figma assets, tokens, components, and storyboard sections → reconstructed motion (frames read as states, not slides) (REST/CLI) plus Motion animations (MCP) and shaders (MCP source / native export) into a composition. @@ -81,7 +81,7 @@ After creating or editing any `.html` composition: ```bash npx hyperframes lint # Static HTML structure check -npx hyperframes validate # Runtime check (headless Chrome — catches JS errors, missing assets) +npx hyperframes check # Browser gate (headless Chrome — runtime errors, layout, motion, WCAG contrast) ``` Both must pass before previewing or considering work complete. diff --git a/README.md b/README.md index e6b2a4a7b..d12516ace 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ Atomic capabilities the creation workflows compose against — pull one when you | `/hyperframes-keyframes` | Seek-safe keyframe authoring across runtimes — GSAP timelines, CSS keyframes, Anime.js, WAAPI, FLIP, paths, masks, SVG morph/draw, 3D depth — plus `hyperframes keyframes` diagnostics for rendered motion. | | `/hyperframes-creative` | Non-animation creative direction — `frame.md` / `design.md`, palettes, typography, narration, beat planning, audio-reactive visuals, composition patterns. | | `/media-use` | The media OS — resolve any media need (BGM, SFX, image, icon, logo, voice, color grade, LUT) into a frozen local file or paste-ready block + ledger record, generate via TTS/music/image models when the catalog misses, transcribe, caption, remove backgrounds, and reuse assets across projects. One shared audio engine + manifest tracking. | -| `/hyperframes-cli` | CLI dev loop — `init`, `lint`, `validate`, `inspect`, `preview`, `render`, `publish`, `doctor`, plus AWS Lambda cloud rendering (`lambda deploy / render / progress`). | +| `/hyperframes-cli` | CLI dev loop — `init`, `lint`, `check`, `snapshot`, `preview`, `render`, `publish`, `doctor`, plus AWS Lambda cloud rendering (`lambda deploy / render / progress`). | | `/hyperframes-registry` | Install and wire registry blocks and components into compositions via `hyperframes add`. Authoring a new block or component to contribute upstream. | | `/figma` | Import Figma assets, tokens, components, and storyboard sections → reconstructed motion (frames read as states, not slides) (REST/CLI) plus Motion animations (MCP) and shaders (MCP source / native export) into a composition. | diff --git a/docs/changelog.mdx b/docs/changelog.mdx index cf5315ee2..2b01be57d 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -8,6 +8,194 @@ Recent HyperFrames releases, including user-facing features, fixes, and migratio {/* New release entries are prepended by `bun run changelog:draft --write`. */} + +`media-use`'s HeyGen resolves now tag whether a call went through the free (OAuth) or paid (API-key) path and classify failures into stable reasons, with a test-isolation fix so local runs can't reach the production telemetry endpoint. `hyperframes skills update` now converges when a skill has been retired or renamed upstream — it no longer fails with `Skill(s) still missing after install` or loops on `No matching skills found`, and reconciles the orphaned skill away instead. + +## Features + +- **Media Use:** Usage telemetry for HeyGen conversion ([521f2c9ba](https://github.com/heygen-com/hyperframes/commit/521f2c9ba7016f08ea3ad7fff4a97d323363eedb), [#2130](https://github.com/heygen-com/hyperframes/pull/2130)) + +## Fixes + +- **CLI:** Make skills update converge on a skill retired upstream ([b1f1c0571](https://github.com/heygen-com/hyperframes/commit/b1f1c0571e0dbbc6f12ccfde68345349704ee86e), [#2176](https://github.com/heygen-com/hyperframes/pull/2176)) + +[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.50...v0.7.51). + + + +This release makes `hyperframes check` the canonical verification gate: one command, one browser session covering lint, runtime errors, layout, motion intent, and WCAG contrast, with agent-actionable findings (source anchors, contrast fix suggestions) and snapshot evidence via annotated frames and per-finding crops. `validate`, `inspect`, and `layout` are deprecated but fully functional; `snapshot` gains `--zoom` for high-density crops of any element or region. + +## Features + +- **CLI:** --frame-check accepts a severity/seek/tol spec ([659cb6a23](https://github.com/heygen-com/hyperframes/commit/659cb6a23605546e93e95977315f473c86d0f8fe)) +- **CLI:** Persistence-tiered findings, frozen-sweep guard, occlusion coverage ([94f6de8b8](https://github.com/heygen-com/hyperframes/commit/94f6de8b8d57bbbadb5032905584881c7b3eaad8)) +- **CLI:** Snapshot --zoom and per-finding crops on check --snapshots ([f4cef54b8](https://github.com/heygen-com/hyperframes/commit/f4cef54b8bde538b9a470f4bcee35f989ef3c71a)) +- **CLI:** Deprecate validate, inspect, layout in favor of check ([58f45ef75](https://github.com/heygen-com/hyperframes/commit/58f45ef75871045ea9a5ae7fb7229e4f641336be)) +- **CLI:** Run-ID telemetry correlation and check breakdown event ([3a02942a0](https://github.com/heygen-com/hyperframes/commit/3a02942a03edd42ffde353e1e1d9331a045283c0)) +- **CLI:** Caption-zone and frame-check gates on check ([7ab6c2b7a](https://github.com/heygen-com/hyperframes/commit/7ab6c2b7a2611a25a613fa41ee4d3b2efbf3be53)) +- **CLI:** Add check — single-session verification gate ([7d6d41361](https://github.com/heygen-com/hyperframes/commit/7d6d41361bbf9db633dd061747d39ce24b326269)) + +## Fixes + +- **CLI:** Address review — clip-duration audit in check, failure classing, crop observability ([67cdf0fdb](https://github.com/heygen-com/hyperframes/commit/67cdf0fdb795984addb0cf3588a1678db107bd08)) +- **Core:** Preserve playhead during volume probing ([16ab8b293](https://github.com/heygen-com/hyperframes/commit/16ab8b2935fb3ab6f8746ae1742ec757f064375e), [#2143](https://github.com/heygen-com/hyperframes/pull/2143)) +- Escape NUL bytes in HFMASK regex (Bun blank renders) + Windows junction for studio preview links ([718c67b38](https://github.com/heygen-com/hyperframes/commit/718c67b387a00d3240dda53911b883ff6a5b4da1), [#2140](https://github.com/heygen-com/hyperframes/pull/2140)) +- **CLI:** Page closures survive keepNames transpilation; format motion-blur ([2ceb0683a](https://github.com/heygen-com/hyperframes/commit/2ceb0683a8c1e515867cc159621b1d32510f2230)) +- **CLI:** Contrast gate judges only readable content; examples pass check ([4b03866c0](https://github.com/heygen-com/hyperframes/commit/4b03866c02801bdc755f4ae7fd86e40b051b60d2)) + +## Docs & Examples + +- **Cli,skills:** Teach check as the canonical verification gate ([cf7c1d760](https://github.com/heygen-com/hyperframes/commit/cf7c1d7609cb7816eaaa5e6651d53dd3873de162)) + +## Internal + +- **CLI:** Type the guard test's mock so the full typecheck passes ([96cb5e39d](https://github.com/heygen-com/hyperframes/commit/96cb5e39dd1accb764bf52648ad55116fd29998b)) +- **CLI:** Import the page-function guard in its new unit test ([c169dbaa5](https://github.com/heygen-com/hyperframes/commit/c169dbaa54fe1eda6bd4e34fa8181d221c054ece)) +- **CLI:** Single-source-of-truth pass over the check branch ([cea345801](https://github.com/heygen-com/hyperframes/commit/cea34580160da6164bf75c679d9df8e76a063211)) +- **Examples:** Mark product-promo cursor and comment layering intentional ([b218ee744](https://github.com/heygen-com/hyperframes/commit/b218ee744397a0f27eb16586db9c7691049903e7)) +- **CLI:** Unify seek/settle and Chrome launch across browser commands ([feb256df8](https://github.com/heygen-com/hyperframes/commit/feb256df8adf02a7aa26ebd791a9d939485c5dce)) + +[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.49...v0.7.50). + + + +Hardens the Figma integration end to end — clearer 403/scope errors, 429 retry with backoff, batch asset fetch, and mapper fidelity fixes (dropped image fills, double-painted rasterized shapes) — plus a fix for creation-workflow skills bypassing the integration entirely. Also ships render-reapply hardening for SDK position edits and prompt-guide pipeline fixes. + +## Fixes + +- **Skills:** Address PR #2110 review feedback ([23c9d15b6](https://github.com/heygen-com/hyperframes/commit/23c9d15b6998fd118112cb1c5eb9fb7bdc87037a)) +- **Skills:** Pipeline fixes from prompt-guide validation (BGM, caption accent, voice, PR version) ([c992a136b](https://github.com/heygen-com/hyperframes/commit/c992a136bf74cd9e87ba23fc824448f1a9e5c94c)) +- **Skills:** Force figma.com sources through /figma, not raw MCP tools ([d13c96d47](https://github.com/heygen-com/hyperframes/commit/d13c96d4704dc125d577c9612549f1e290b928f9)) +- **Core,cli:** Figma IMAGE fills dropped, rasterize double-paint, tokens false-success ([c8eff1a4b](https://github.com/heygen-com/hyperframes/commit/c8eff1a4ba5b25b2c821354cae51bbf12d180244)) +- Clear fallow and windows artifact checks ([70ac80b08](https://github.com/heygen-com/hyperframes/commit/70ac80b08b0e120cf8f8092904f6d2732c19c419)) +- **Core:** Make generated artifact formatting deterministic ([960c31f66](https://github.com/heygen-com/hyperframes/commit/960c31f66805c17ca06a24104d8976638854609a)) +- **Core:** Narrow wrapped seek function type ([d2f32c831](https://github.com/heygen-com/hyperframes/commit/d2f32c831cd158d1d5d2507d601d73b46dc940c2)) +- **Core:** Use execFileSync for generated-file format step ([67a4fb6b7](https://github.com/heygen-com/hyperframes/commit/67a4fb6b7097c97ed81268d10830ca6f56c06776)) +- **Core,producer:** Harden render reapply wiring ([68ae4e516](https://github.com/heygen-com/hyperframes/commit/68ae4e51601006d1c8b465884767f4aa7ddda869)) +- **Core:** Preserve position edit fold detection during seeks ([4e3d639e1](https://github.com/heygen-com/hyperframes/commit/4e3d639e1a082b27a1a5d80a4cffcc2b575c6d0d)) +- **Core,producer:** Render SDK position edits in producer pipeline ([85bab88af](https://github.com/heygen-com/hyperframes/commit/85bab88afbbc5350028fd9bd149e6796d9bdd203)) +- **Core,cli:** Address PR review — consistent 403 error shape, cap Retry-After, URL-safe ref split ([87e2a70f9](https://github.com/heygen-com/hyperframes/commit/87e2a70f9a9fe4bba2b3bee8cd8a27b4c303f00a)) +- **Core,cli:** Parse figma 403 body, batch asset fetch, fix NO_TOKEN box ([4fc699fee](https://github.com/heygen-com/hyperframes/commit/4fc699fee6aa124717d899b7675a591f01325d4e)) +- **Core:** Name missing figma scope in 403, retry 429 with backoff ([1bb768834](https://github.com/heygen-com/hyperframes/commit/1bb7688347126fe9e0213d93b36f50dade252b8a)) + +## Docs & Examples + +- **Figma:** Lead setup with "which credential do I need", not a token wall ([ffac2ecbf](https://github.com/heygen-com/hyperframes/commit/ffac2ecbfe542054648338098f1a2f9df5c4f8fb)) +- **Figma:** Remove stale duplicate RATE_LIMITED troubleshooting row ([49a78a30e](https://github.com/heygen-com/hyperframes/commit/49a78a30e49e83504dba6e11e2f9d15c50bb8e84)) +- **Skills:** Remove unused animation example assets ([ac8ac34bd](https://github.com/heygen-com/hyperframes/commit/ac8ac34bd86c55a7f61b0ce4ea1264ed06657bfa)) +- **Figma:** Sync guide scopes + error table; gate batch line, regen index in finally ([43af58253](https://github.com/heygen-com/hyperframes/commit/43af5825342a43653ad39c5fadaf51914c0323d6)) + +## Internal + +- **Core:** Refresh position edits render artifact ([0971f3b2a](https://github.com/heygen-com/hyperframes/commit/0971f3b2a703eb829c86990f7fd4b33dd8f60ed9)) +- **CLI:** Add renderNodes to tokens.test figma client mock ([1d18b3062](https://github.com/heygen-com/hyperframes/commit/1d18b30625ffd9ee4a0fbd5875ebb21fa2878a4f)) + +[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.48...v0.7.49). + + + +Prevents slow Chrome downloads from losing their install lock and colliding with another HyperFrames process. Also restores `hyperframes tts --text-file` compatibility for existing skills and scripts. + +## Fixes + +- **CLI:** Restore `tts --text-file` compatibility for file-based narration workflows ([ef38321d5](https://github.com/heygen-com/hyperframes/commit/ef38321d5d1b549777b846d1156de40a0f475b4e), [#2117](https://github.com/heygen-com/hyperframes/pull/2117)) +- **CLI:** Keep slow browser installs serialized with an active lock heartbeat and visible wait progress ([7bd8c0942](https://github.com/heygen-com/hyperframes/commit/7bd8c0942e2d0863b7c82fe5a3dd1c026f1e5db1), [#2116](https://github.com/heygen-com/hyperframes/pull/2116)) + +[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.47...v0.7.48). + + + +Ships a lane-model Studio timeline (multi-select, marquee, group move/resize, magnetic snapping, NLE-style z-index stacking) and a declarative variables stack spanning core, SDK, and Studio (bindings, promote-to-variable, live preview injection). Also adds shared telemetry and free-usage onboarding to `media-use`. + +## Features + +- **Media Use:** Usage visibility — shared telemetry identity, miss log, resolve --stats ([16eb11367](https://github.com/heygen-com/hyperframes/commit/16eb11367a5f0b4213874b626b305e80edcf1d24), [#2113](https://github.com/heygen-com/hyperframes/pull/2113)) +- **Media Use:** Fast heygen CLI onboarding — actionable diagnostics, --doctor, free-usage framing ([cdb8d736f](https://github.com/heygen-com/hyperframes/commit/cdb8d736f19c9d39b3fec51e6363c68895b28fe4), [#2065](https://github.com/heygen-com/hyperframes/pull/2065)) +- **Media Use:** Use CLI free HeyGen usage ([3b93f516b](https://github.com/heygen-com/hyperframes/commit/3b93f516b40bc96f6dba3941d827529f04709642), [#2027](https://github.com/heygen-com/hyperframes/pull/2027)) +- **Studio:** Resize selected timeline clips together ([36413da7f](https://github.com/heygen-com/hyperframes/commit/36413da7f69231ea2c9e39019508ed3307741dbe)) +- **Studio:** Move selected timeline clips together ([5d27af30b](https://github.com/heygen-com/hyperframes/commit/5d27af30b23e35d8fa2346909eb502bea05cd76f)) +- **Studio:** Add timeline marquee selection ([45d09047b](https://github.com/heygen-com/hyperframes/commit/45d09047b7ef65ad4a30347dba4d91002e1c5d38)) +- **Studio:** Batch timeline timing commits ([1cf601202](https://github.com/heygen-com/hyperframes/commit/1cf601202d6c7bd032cf56adef2a9b7d357fb3ed)) +- **Studio:** Highlight timeline selection sets ([1d858f004](https://github.com/heygen-com/hyperframes/commit/1d858f004d6c6171d6c4df090448f11429bc15a4)) +- **Studio:** Activate selected element set state ([cca31feff](https://github.com/heygen-com/hyperframes/commit/cca31feffeda60ab98e1cea7f01438ac299b0e42)) +- **Core:** Sub-composition variable render path ([a9901cb66](https://github.com/heygen-com/hyperframes/commit/a9901cb661bf057fd7f0d1341645614bbf55b2b8)) +- **Sdk,studio:** Editable template sub-compositions + promote sub-comp element properties ([8de80bf36](https://github.com/heygen-com/hyperframes/commit/8de80bf3695400f7d22a37dd669cb87be80f653e)) +- **Studio:** Promote to variable from the design panel ([7c144ecc3](https://github.com/heygen-com/hyperframes/commit/7c144ecc302d5ea8c4136912b03860bb708daf73)) +- **Studio:** Bind selected element properties to variables ([267b289bb](https://github.com/heygen-com/hyperframes/commit/267b289bb831c26f5ea823e9c501276b828ad975)) +- **Core:** Declarative variable bindings — data-var-src, data-var-text, css custom props ([f7ee0768a](https://github.com/heygen-com/hyperframes/commit/f7ee0768aeb03fb53e6468526591c665387bb6b2)) +- **Studio:** Render with preview variables + template handoff + docs ([010d49327](https://github.com/heygen-com/hyperframes/commit/010d49327e75bd05dbe9e3db1b29a9d755a60160)) +- **Studio:** Variables inspector panel with live preview values ([b34ad8516](https://github.com/heygen-com/hyperframes/commit/b34ad851652fb2698a4578c6c02d1194f0bdaa8c)) +- **Studio Server:** Preview variable injection + render variables forwarding ([2e0b88452](https://github.com/heygen-com/hyperframes/commit/2e0b8845211535778bdd67c6d603282ffdd993a6)) +- **Sdk:** Variable usage scan + preview-values adapter seam ([91831f3f8](https://github.com/heygen-com/hyperframes/commit/91831f3f8e197a5de510f1036e86b61b0b72c54c)) +- **Sdk:** Variable declaration edit ops (declare/update/remove) ([839881a98](https://github.com/heygen-com/hyperframes/commit/839881a98e21d83a567dc670a2b21fe400e0c558), [#2047](https://github.com/heygen-com/hyperframes/pull/2047)) +- **Sdk:** Variable declaration read apis + browser-safe variables entry ([fcbd4cb0f](https://github.com/heygen-com/hyperframes/commit/fcbd4cb0f61f063b252fe583f6643252bcf2118d), [#2046](https://github.com/heygen-com/hyperframes/pull/2046)) +- **Studio:** Fill the ruler with ticks across the full timeline width ([6f0bf75b6](https://github.com/heygen-com/hyperframes/commit/6f0bf75b69216b029c6a5410daecbe5166315575)) +- **Studio:** Lanes and ruler span the full timeline width at any zoom ([eb31e86d4](https://github.com/heygen-com/hyperframes/commit/eb31e86d4f407845c498c92ad6aef5dadde38dcd)) +- **Studio:** Magnetic snapping to clip edges, playhead, and bounds ([d7e7fca8e](https://github.com/heygen-com/hyperframes/commit/d7e7fca8ec93a894ec76404ecc8026a5168d7b60)) +- **Studio:** Drag a clip past the video end to extend its duration ([5e3ca6ae6](https://github.com/heygen-com/hyperframes/commit/5e3ca6ae63d3c6484a4e5cde0eea4e3987f8236a)) +- **Studio:** Lane-model timeline (non-overlapping clips share a row) ([ed8bf475d](https://github.com/heygen-com/hyperframes/commit/ed8bf475d327951a63c61880e31e1d1f9121ea6e)) +- **Studio:** Timeline layer UI — context headers, audio lanes, drop affordances ([bc5fc410e](https://github.com/heygen-com/hyperframes/commit/bc5fc410ee9a57f377f3f6421b4142706cf11a46)) +- **Studio:** Timeline track = stacking layer (NLE-style layering) ([e5ed51252](https://github.com/heygen-com/hyperframes/commit/e5ed5125293c0d85aae5db5482563f8dfe44ffa4)) +- **Studio:** Unify timeline vertical reorder with z-index stacking ([dd980697a](https://github.com/heygen-com/hyperframes/commit/dd980697a20bacf04ec3d4d5a6171c040a78dbca)) +- **Studio:** Expose resolved z-index and stacking context on timeline clips ([070ee9169](https://github.com/heygen-com/hyperframes/commit/070ee91694d341310d8541a3370fcf05827b6e7e)) + +## Fixes + +- **CLI:** Sample real pixels behind hidden text for contrast-audit ([1614dd3e5](https://github.com/heygen-com/hyperframes/commit/1614dd3e5ad287305d0d9d39402cb8bd66849250)) +- **Studio:** Drop stale timeline-select results to stop selection flicker ([1265702ed](https://github.com/heygen-com/hyperframes/commit/1265702edc2ac102abb687b6eb026a506f610f20)) +- **Studio:** Fold GSAP timing rewrites into the recorded history entry ([076c656d6](https://github.com/heygen-com/hyperframes/commit/076c656d6e99c954604e7679293ec33a97e21ad8)) +- **Studio:** Make the player store the single source of truth for selection ([9cf575c6f](https://github.com/heygen-com/hyperframes/commit/9cf575c6f928e45cd88e6f4fd5809af43b5c2e77)) +- **Studio:** Keep timeline selection authoritative in the preview sync ([6673c3286](https://github.com/heygen-com/hyperframes/commit/6673c328686c39b153d2487289573396794cc620)) +- **Studio:** Harden group timeline edits (capabilities, rollback, snapping, marquee) ([04ddd411e](https://github.com/heygen-com/hyperframes/commit/04ddd411ecf14123c13e4d766454c9385fa4b9f0)) +- **Studio:** Draw the timeline marquee in the theme accent color ([95ded6c02](https://github.com/heygen-com/hyperframes/commit/95ded6c0268cb41d0667552f43d2d22897731454)) +- **Studio:** Keep the timeline multi-selection through a group edit ([fa3848a33](https://github.com/heygen-com/hyperframes/commit/fa3848a33d868f1b9796212bdc4efba4487ac14b)) +- **Studio:** Propagate z-index reorder save failures and drop dead targetTrack ([6080f5ad3](https://github.com/heygen-com/hyperframes/commit/6080f5ad3e57af63578606838692f6da9027b267)) +- **Studio:** Code-review and live-test fixes for the variables stack ([bc0e0b314](https://github.com/heygen-com/hyperframes/commit/bc0e0b314b96b83ddc01d42bd7780a9ee347b130)) +- **Studio:** Order the timing write after the z-index commit on a diagonal drag ([51dd8a075](https://github.com/heygen-com/hyperframes/commit/51dd8a0753ca42b144966d7a821d5d9490749dcb)) +- **Studio:** Keep a remounted clip marked active when it stays at the playhead ([ffd513130](https://github.com/heygen-com/hyperframes/commit/ffd513130b712129b2a0359616070410a98199a8)) +- **Studio:** Make a timeline lane a z-band so a vertical restack moves the row ([eb6a311d8](https://github.com/heygen-com/hyperframes/commit/eb6a311d82484e5c3d8061d360302e8699ac55fb)) +- **Studio:** Stop horizontal drag from cancelling a vertical restack ([db6ccb808](https://github.com/heygen-com/hyperframes/commit/db6ccb80820f8aca219761ee16fd08cb14f07ebf)) +- **Studio:** Keep Timeline under the 600-line cap and un-export postRootDurationToPreview ([ce0ddbccf](https://github.com/heygen-com/hyperframes/commit/ce0ddbccf827b8581025ea451ab13f405b61e439)) +- **Studio:** Freeze timeline zoom during extend-drag so clips don't jump ([580676bbf](https://github.com/heygen-com/hyperframes/commit/580676bbf46d2d6c5a39df0ccb6860407521adda)) +- **Studio:** Dropping an overlapping clip on a lane restacks it, not a no-op ([6b4870246](https://github.com/heygen-com/hyperframes/commit/6b4870246ce7d9c4f259c1f714c1467c97c0df29)) +- **Studio:** Sub-composition child clips restack via self-contained intent ([82fe779bd](https://github.com/heygen-com/hyperframes/commit/82fe779bd86f8a7d2b517e6e10c0f1d1111b6195)) +- **Studio:** Realm-safe isHTMLElement so timeline z-index commits land ([eef500c89](https://github.com/heygen-com/hyperframes/commit/eef500c8902de9573d6941234693f06a8d018991)) +- **Studio:** Capture effective (computed) z-index for timeline ordering ([03f8089e5](https://github.com/heygen-com/hyperframes/commit/03f8089e523980105f22b31161628b75c521ed82)) + +## Performance + +- **Studio:** Grow composition duration live on extend, no preview remount ([9f6c20e48](https://github.com/heygen-com/hyperframes/commit/9f6c20e4826e1304114dcdf8f963da6d75ef0cc2)) + +## Internal + +- **Studio:** Single-source timeline selection id-resolution ([0fe38e8cc](https://github.com/heygen-com/hyperframes/commit/0fe38e8cc87dfa125d65023147d1f838ef1bfd57)) +- **Studio:** Single-source timeline id-resolution and resize-clamp math ([3c6c1d3f2](https://github.com/heygen-com/hyperframes/commit/3c6c1d3f2778a2be912e05b42f094b2c3e4a3b3d)) +- **Studio:** Single-source the timeline stacking key + guard audio reorder ([5ce362299](https://github.com/heygen-com/hyperframes/commit/5ce362299c72f7d188adca608ff907b2f9e5eb73)) +- **Studio:** Extract shared layerOrdering module from LayersPanel ([dae26e72b](https://github.com/heygen-com/hyperframes/commit/dae26e72b23a5907d173177a72e1f96bd5483934)) + +[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.46...v0.7.47). + + ```bash hyperframes lint - hyperframes validate + hyperframes check npx oxfmt your-block.html ``` diff --git a/docs/guides/figma.mdx b/docs/guides/figma.mdx index c10dc4fdf..6d180861e 100644 --- a/docs/guides/figma.mdx +++ b/docs/guides/figma.mdx @@ -10,7 +10,7 @@ The work your designer already did in Figma — layout, color, type, motion — | Capability | What you get | Surface | | --- | --- | --- | | **Static assets** | A frame/layer rendered to SVG/PNG/JPG/PDF, frozen under `.media/` | `hyperframes figma asset` | -| **Brand tokens** | Figma variables/styles as composition brand variables | `hyperframes figma tokens` | +| **Brand tokens** | Figma variables/styles as composition brand variables | `hyperframes figma tokens` (Enterprise) or the `/figma` skill via MCP (any plan) | | **Components** | A frame as editable HTML with brand-linked colors | `hyperframes figma component` | | **Motion** | A Figma Motion timeline as an editable, paused GSAP timeline | `/figma` skill (agent, MCP) | | **Shaders** | A shader fill/effect as a frozen still or clip | `/figma` skill (agent, MCP) | @@ -20,22 +20,32 @@ Two transports, split by what Figma exposes: assets, tokens, and components run ## One-time setup -The CLI paths need a Figma personal access token in the `FIGMA_TOKEN` environment variable. +There are two credentials, and most people only need to set up one to start: + +| You want to import… | Set up | +| --- | --- | +| A logo, image, or a whole frame as HTML (assets, components) | A **token** — Step A below | +| Brand colors (tokens) | Either works, but on a non-Enterprise plan the **MCP connector** (Step B) gets you there in one click — the token path needs an Enterprise plan for this specific pull | +| Motion, shaders, or a storyboard | The **MCP connector** only — no token, no setup beyond connecting it | + +Do both if your project needs everything; each is independent, so it doesn't matter which you set up first. + +### Step A — Figma token (assets, tokens, components) + +Needed for anything you run from the `hyperframes figma` CLI. In Figma: **Settings → Security → Personal access tokens → Generate new token.** - - The integration never writes to Figma — read-only is all it ever needs: + + Read-only is all it ever needs — the integration never writes to Figma. **On most accounts (not Figma Enterprise), check exactly these three:** - | Scope | Setting | Needed for | - | --- | --- | --- | - | File content | Read-only | assets, components | - | File metadata | Read-only | version tracking, refresh | - | Variables | Read-only | brand variables — **Figma Enterprise only** | + - **File content** — Read-only + - **File metadata** — Read-only + - **Library content** — Read-only — easy to miss, and without it `tokens` 403s the moment it tries the published-styles fallback - No Enterprise plan? Skip the Variables scope — `tokens` automatically falls back to your published styles. That's expected behavior, not an error. + On a **Figma Enterprise** plan, also check **Variables — Read-only** to pull brand colors directly via `tokens`. Not on Enterprise? Skip it — `tokens` falls back to published styles automatically, or use the MCP connector (Step B) instead, which reaches variables on any plan. ```bash @@ -46,7 +56,11 @@ The CLI paths need a Figma personal access token in the `FIGMA_TOKEN` environmen -Motion and shader import use the **Figma MCP connector** instead — a one-click OAuth from your agent, separate from the token. Connect it when your agent asks; no scopes to configure. +### Step B — Figma MCP connector (motion, shaders, storyboards — and an easier token-free path to brand colors) + +No token, no scopes to pick — connect it once when your agent asks (a one-click OAuth) and it stays connected. + +This is also the easiest way to pull brand colors on **any** Figma plan, including free: the connector's variable-reading tool isn't Enterprise-gated, only rate-limited by plan — Starter/free caps at **6 calls/month**, a paid Full/Dev seat gets 200–600/day. Fine for an occasional brand pull; not for iterating call-by-call. ## Import an asset @@ -109,9 +123,9 @@ Every import records where it came from (`fileKey`, `nodeId`, `version`) in `.me | Error | Meaning | Fix | | --- | --- | --- | | `NO_TOKEN` | `FIGMA_TOKEN` unset | Follow [One-time setup](#one-time-setup) | -| `BAD_TOKEN` (401) | Token expired or revoked | Re-mint the token | -| `FORBIDDEN` (403) | Token missing a read scope, or no access to the file | Check the read-only scopes above and file visibility | -| `REQUIRES_ENTERPRISE` (403) | Variables API needs Figma Enterprise | Not a failure — the styles fallback already ran | -| `RATE_LIMITED` (429) | REST per-minute budget hit | Wait a minute and retry; chunk batch renders | +| `BAD_TOKEN` | Token invalid, expired, or revoked (Figma returns **403 `Invalid token`** for bad PATs, not 401) | Re-mint the token | +| `FORBIDDEN` (403) | Missing a read scope, or no access to the file | The message names the exact scope Figma wants (e.g. `library_content:read` for the styles fallback) — add it, or check file visibility | +| `REQUIRES_ENTERPRISE` (403) | Variables API needs Figma Enterprise | Not a failure — `tokens` falls back to published styles (which needs the Library content scope above) | +| `RATE_LIMITED` (429) | Figma's per-minute limit | The client retries with backoff automatically (honoring `Retry-After`); if it still surfaces, wait a minute or batch fewer nodes | | "Render timeout" on batch export | Too many large frames in one `/v1/images` call | Chunk to ~4 ids per call | | `ref has no node id` | Link points at a file, not a node | Copy the link with `?node-id=…` (right-click layer → Copy link) | diff --git a/docs/guides/pipeline.mdx b/docs/guides/pipeline.mdx index c701ddb7a..8298adf98 100644 --- a/docs/guides/pipeline.mdx +++ b/docs/guides/pipeline.mdx @@ -165,7 +165,7 @@ Three checks before delivery: ```bash npx hyperframes lint # static HTML structure checks -npx hyperframes validate # loads in headless Chrome, catches runtime errors +npx hyperframes check # one browser session: runtime errors, layout, motion, contrast npx hyperframes snapshot my-video --at 2.9,10.4 # PNGs at beat midpoints ``` diff --git a/docs/guides/prompting.mdx b/docs/guides/prompting.mdx index 49907ccc6..fe78e6904 100644 --- a/docs/guides/prompting.mdx +++ b/docs/guides/prompting.mdx @@ -268,7 +268,7 @@ Things that cause friction (or wrong output): - **Don't ask for React / Vue components.** Hyperframes compositions are plain HTML with `data-*` attributes and a GSAP timeline. Asking for "a React component for the intro" forces the agent to translate later. - **Don't ask for 4K or 60fps unless you need it.** Defaults (1920×1080, 30fps) render fast and look great. Higher specs slow rendering meaningfully. - **Don't skip the slash command.** Without `/hyperframes`, the agent may guess at HTML video conventions instead of using the framework's actual rules (`class="clip"` on timed elements, `window.__timelines` registration, etc.). -- **Don't paste long error logs into the prompt without context.** Run `npx hyperframes lint` and `npx hyperframes validate` first — lint catches structural issues, validate catches runtime errors (JS exceptions, missing assets, contrast problems). +- **Don't paste long error logs into the prompt without context.** Run `npx hyperframes check` first — lint catches structural issues, validate catches runtime errors (JS exceptions, missing assets, contrast problems). - **Don't assume the agent knows your assets.** Mention file paths explicitly (`assets/intro.mp4`, `assets/logo.png`) — the agent will check what's there but a hint speeds it up. ## Recommended workflow diff --git a/docs/guides/skills.mdx b/docs/guides/skills.mdx index 9579f7df6..254d85aea 100644 --- a/docs/guides/skills.mdx +++ b/docs/guides/skills.mdx @@ -99,7 +99,7 @@ Atomic capabilities the creation workflows compose against — pull one when you | `/hyperframes-keyframes` | Seek-safe keyframe authoring across runtimes — GSAP timelines, CSS keyframes, Anime.js, WAAPI, FLIP, paths, masks, SVG morph/draw, 3D depth — plus `hyperframes keyframes` diagnostics for rendered motion. | | `/hyperframes-creative` | Non-animation creative direction — `frame.md` / `design.md`, palettes, typography, narration, beat planning, audio-reactive visuals, composition patterns. | | `/media-use` | The media OS — resolve any media need (BGM, SFX, image, icon, logo, voice, color grade, LUT) into a frozen local file or paste-ready block + ledger record, generate via TTS/music/image models when the catalog misses, transcribe, caption, remove backgrounds, and reuse assets across projects. One shared audio engine + manifest tracking. | -| `/hyperframes-cli` | CLI dev loop — `init`, `lint`, `validate`, `inspect`, `preview`, `render`, `publish`, `doctor`, plus AWS Lambda cloud rendering (`lambda deploy / render / progress / destroy / policies`). | +| `/hyperframes-cli` | CLI dev loop — `init`, `lint`, `check`, `snapshot`, `preview`, `render`, `publish`, `doctor`, plus AWS Lambda cloud rendering (`lambda deploy / render / progress / destroy / policies`). | | `/hyperframes-registry` | Install and wire registry blocks and components into compositions via `hyperframes add`. Authoring a new block or component to contribute upstream. | | `/figma` | Import Figma assets, tokens, components, and storyboard sections → reconstructed motion (frames read as states, not slides) (REST/CLI) plus Motion animations (MCP) and shaders (MCP source / native export) into a composition. | diff --git a/docs/guides/video-editor-cheatsheet.mdx b/docs/guides/video-editor-cheatsheet.mdx index e9216f6bf..16e3431ab 100644 --- a/docs/guides/video-editor-cheatsheet.mdx +++ b/docs/guides/video-editor-cheatsheet.mdx @@ -27,7 +27,7 @@ Before showing or rendering a project: ```bash npx hyperframes lint -npx hyperframes validate +npx hyperframes check npx hyperframes render --quality standard --output review.mp4 ``` @@ -174,8 +174,8 @@ I moved the hero title and resized the CTA manually in Studio. Inspect the chang | `npx hyperframes capture https://example.com` | Capture a website as source material for a video | | `npx hyperframes preview` | Open the live Studio preview | | `npx hyperframes lint` | Catch structural mistakes before preview or render | -| `npx hyperframes validate` | Run the composition in headless Chrome to catch runtime errors | -| `npx hyperframes inspect` | Find text overflow and layout problems across the timeline | +| `npx hyperframes check` | Run the composition in headless Chrome to catch runtime errors | +| `npx hyperframes check` | Find text overflow and layout problems across the timeline | | `npx hyperframes snapshot --at 1,3,5` | Save PNG checks at exact timestamps | | `npx hyperframes render --output final.mp4` | Render the video | | `npx hyperframes publish` | Upload the project and get a shareable HyperFrames URL | @@ -278,9 +278,9 @@ For editor-facing changes, keep `npx hyperframes preview` running, then have the | --- | --- | | Preview will not start | `npx hyperframes doctor` | | Port already in use | `npx hyperframes preview --port 4567` | -| Render fails | `npx hyperframes lint` then `npx hyperframes validate` | +| Render fails | `npx hyperframes lint` then `npx hyperframes check` | | Need exact frame checks | `npx hyperframes snapshot --at 1,2.5,5` | -| Text overflows in the frame | `npx hyperframes inspect` | +| Text overflows in the frame | `npx hyperframes check` | | Final render is too slow | Try `--quality draft`, reduce image sizes, or lower `--fps` | | Need to share editable project | `npx hyperframes publish` | diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index bd4839625..91094b0ed 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -520,6 +520,34 @@ Word-level transcripts (whisper output) are grouped into readable caption cues o The linter detects missing attributes, missing adapter libraries (GSAP, Lottie, Three.js), structural problems, and more. See [Common Mistakes](/guides/common-mistakes) for details on each rule. + ### `check` + + The browser verification gate: everything the old `validate` → `inspect` → `snapshot` loop did, in **one** command with one browser session: + + ```bash + npx hyperframes check [dir] + npx hyperframes check [dir] --json # {ok, lint, runtime, layout, motion, contrast, snapshots} + npx hyperframes check [dir] --snapshots # annotated overview frames + per-finding crops + npx hyperframes check [dir] --at 1.5,4,7.25 + npx hyperframes check [dir] --strict # exit non-zero on warnings too + ``` + + `check` runs the linter first (browser skipped entirely on lint errors), then loads the bundled composition once and sweeps one seek grid running every audit per sample: runtime console errors and failed requests, layout defects (overflow, clipping, held overlaps, occlusion), `*.motion.json` sidecar assertions, and WCAG AA contrast. + + | Flag | Description | + |------|-------------| + | `--json` | Aggregated machine-readable envelope; every finding carries selector, `data-*` identity, source file, bbox, and sample time | + | `--snapshots` | Write overview frames (annotated with labeled finding boxes when there are errors) plus `finding-NN-.png` crops | + | `--samples` / `--at` / `--at-transitions` | Control the seek grid (default 9 samples; `--at-transitions` adds tween boundaries) | + | `--tolerance` | Allowed overflow in px before reporting (default 2) | + | `--timeout` | Initial settle budget in ms (default 3000) | + | `--no-contrast` | Skip the WCAG audit while iterating | + | `--strict` | Exit non-zero on warnings too (default: only errors) | + | `--caption-zone ""` | Opt-in band gate: flags content whose center sits inside the fractional band (optional `severity`, `seek`) | + | `--frame-check` | Opt-in media out-of-frame detection (img/svg/video/canvas) | + + Contrast failures are **errors** and include the sampled fg/bg colors, measured vs required ratio, and a suggested compliant color. Severity is persistence-aware: single-sample transients demote to info, held findings gate the exit code, and a frozen timeline on a 3s+ composition fails with `sweep_static`. + ### `beats` Detect the beats in a composition's music track and write them to a beat file the Studio uses to draw beat guides on the timeline: @@ -549,6 +577,8 @@ Word-level transcripts (whisper output) are grouped into readable caption cues o ### `inspect` + Deprecated: use [`check`](#check) — it covers this layout sweep plus runtime, motion, and contrast in one browser session. `inspect` keeps working and marks `_meta.deprecated: true` in JSON output. + Inspect rendered visual layout across the composition timeline: ```bash diff --git a/packages/aws-lambda/package.json b/packages/aws-lambda/package.json index 895334fa7..26002cdaf 100644 --- a/packages/aws-lambda/package.json +++ b/packages/aws-lambda/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/aws-lambda", - "version": "0.7.46", + "version": "0.7.51", "description": "AWS Lambda adapter for HyperFrames distributed rendering — handler, client-side SDK, and CDK construct.", "repository": { "type": "git", diff --git a/packages/cli/package.json b/packages/cli/package.json index f5f9d1de3..e4b13ef68 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/cli", - "version": "0.7.46", + "version": "0.7.51", "description": "HyperFrames CLI — create, preview, and render HTML video compositions", "repository": { "type": "git", diff --git a/packages/cli/src/browser/manager.test.ts b/packages/cli/src/browser/manager.test.ts index 2d74f204d..1b9bf0481 100644 --- a/packages/cli/src/browser/manager.test.ts +++ b/packages/cli/src/browser/manager.test.ts @@ -50,14 +50,21 @@ const HF_BINARY = join( "chrome-headless-shell", ); const SYSTEM_CHROME = "/usr/bin/google-chrome"; +const TEST_LOCK_TIMINGS = { + staleMs: 50, + pollMs: 5, + heartbeatMs: 10, + waitNoticeMs: 1_000, +}; interface FsMockOptions { existing: ReadonlySet; /** map of dir path -> entries returned by readdirSync */ dirs?: Record; + touchError?: Error; } -function installFsMocks({ existing, dirs }: FsMockOptions) { +function installFsMocks({ existing, dirs, touchError }: FsMockOptions) { // Mutable, and returned, so tests can pre-seed a "lock already held" path or // assert the lock dir doesn't leak after ensureBrowser resolves. const paths = new Set(existing); @@ -97,6 +104,15 @@ function installFsMocks({ existing, dirs }: FsMockOptions) { } return { mtimeMs: mtimes.get(p) ?? 0 }; }, + utimesSync: (p: string, _atime: Date, mtime: Date) => { + if (touchError) throw touchError; + if (!paths.has(p)) { + const err = new Error(`ENOENT: no such file or directory, utimes '${p}'`); + (err as NodeJS.ErrnoException).code = "ENOENT"; + throw err; + } + mtimes.set(p, mtime.getTime()); + }, })); vi.doMock("node:os", () => ({ homedir: () => FAKE_HOME, @@ -322,17 +338,10 @@ describe("findBrowser — cache resolution", () => { }); it("withInstallLock reclaims a lock held past the timeout instead of hanging forever", async () => { - // A crashed/killed process could leave the lock directory behind - // permanently. Reclaiming after a timeout (rather than hanging or - // refusing forever) is the behavior that makes the lock safe to add at - // all — otherwise one bad exit wedges every future render. Exercises - // withInstallLock directly with tiny real timeouts (it takes an - // injectable timeoutMs/pollMs for exactly this) rather than mocking - // Date.now()/setTimeout through the full ensureBrowser call graph. const paths = installFsMocks({ existing: new Set([CACHE_ROOT, HF_LOCK]) }); const { withInstallLock } = await import("./manager.js"); - const result = await withInstallLock(async () => "done", 10, 5); + const result = await withInstallLock(async () => "done", TEST_LOCK_TIMINGS); expect(result).toBe("done"); expect(paths.has(HF_LOCK)).toBe(false); @@ -346,21 +355,72 @@ describe("findBrowser — cache resolution", () => { const paths = installFsMocks({ existing: new Set([CACHE_ROOT, HF_LOCK]) }); const { withInstallLock } = await import("./manager.js"); - const first = withInstallLock( - async () => { - await new Promise((resolve) => setTimeout(resolve, 30)); - return "first"; - }, - 10, - 5, - ); - const second = withInstallLock(async () => "second", 10, 5); + const reclaimOnlyTimings = { ...TEST_LOCK_TIMINGS, heartbeatMs: 1_000 }; + const first = withInstallLock(async () => { + await new Promise((resolve) => setTimeout(resolve, 30)); + return "first"; + }, reclaimOnlyTimings); + const second = withInstallLock(async () => "second", reclaimOnlyTimings); await expect(Promise.all([first, second])).resolves.toEqual(["first", "second"]); expect(paths.has(HF_LOCK)).toBe(false); expect(paths.has(HF_RECLAIM_LOCK)).toBe(false); }); + it("withInstallLock does not let a second caller run concurrently with a slow-but-alive holder", async () => { + const paths = installFsMocks({ existing: new Set([CACHE_ROOT]) }); + + const { withInstallLock } = await import("./manager.js"); + + let concurrent = 0; + let maxConcurrent = 0; + const trackConcurrency = async (label: string, durationMs: number) => { + concurrent += 1; + maxConcurrent = Math.max(maxConcurrent, concurrent); + await new Promise((resolve) => setTimeout(resolve, durationMs)); + concurrent -= 1; + return label; + }; + + const first = withInstallLock(() => trackConcurrency("first", 120), TEST_LOCK_TIMINGS); + await new Promise((resolve) => setTimeout(resolve, 5)); // let `first` acquire the lock + const second = withInstallLock(() => trackConcurrency("second", 10), TEST_LOCK_TIMINGS); + + await expect(Promise.all([first, second])).resolves.toEqual(["first", "second"]); + expect(maxConcurrent).toBe(1); + expect(paths.has(HF_LOCK)).toBe(false); + }); + + it("withInstallLock reports progress while waiting instead of staying silent", async () => { + const paths = installFsMocks({ existing: new Set([CACHE_ROOT, HF_LOCK]) }); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const { withInstallLock } = await import("./manager.js"); + await withInstallLock(async () => "done", { ...TEST_LOCK_TIMINGS, waitNoticeMs: 20 }); + + expect(paths.has(HF_LOCK)).toBe(false); + expect( + warnSpy.mock.calls.some(([msg]) => + String(msg).includes("Waiting for another hyperframes process"), + ), + ).toBe(true); + }); + + it("keeps the holder running when a heartbeat cannot touch the lock", async () => { + installFsMocks({ + existing: new Set([CACHE_ROOT]), + touchError: Object.assign(new Error("EACCES"), { code: "EACCES" }), + }); + + const { withInstallLock } = await import("./manager.js"); + await expect( + withInstallLock(async () => { + await new Promise((resolve) => setTimeout(resolve, 25)); + return "done"; + }, TEST_LOCK_TIMINGS), + ).resolves.toBe("done"); + }); + it("warns and falls through when the hyperframes cache cannot be read", async () => { installFsMocks({ existing: new Set([HF_CACHE, SYSTEM_CHROME]) }); installPuppeteerBrowsersMock({ diff --git a/packages/cli/src/browser/manager.ts b/packages/cli/src/browser/manager.ts index 7899ee861..ff18e230d 100644 --- a/packages/cli/src/browser/manager.ts +++ b/packages/cli/src/browser/manager.ts @@ -1,6 +1,6 @@ // fallow-ignore-file code-duplication import { execSync, spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs"; +import { existsSync, mkdirSync, readdirSync, rmSync, statSync, utimesSync } from "node:fs"; import { basename } from "node:path"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -42,8 +42,19 @@ const PUPPETEER_CACHE_DIR = join(homedir(), ".cache", "puppeteer", "chrome-headl // doubles as a zero-dependency cross-process mutex — no lockfile library needed. const INSTALL_LOCK_DIR = join(CACHE_ROOT_DIR, ".chrome.install.lock"); const INSTALL_RECLAIM_LOCK_DIR = join(CACHE_ROOT_DIR, ".chrome.install.reclaim.lock"); -const INSTALL_LOCK_TIMEOUT_MS = 120_000; // generous: a real download+extract can take a while -const INSTALL_LOCK_POLL_MS = 200; +const INSTALL_LOCK_TIMINGS = { + staleMs: 120_000, + pollMs: 200, + heartbeatMs: 15_000, + waitNoticeMs: 10_000, +}; + +interface InstallLockTimings { + staleMs: number; + pollMs: number; + heartbeatMs: number; + waitNoticeMs: number; +} function isErrno(err: unknown, code: string): boolean { return (err as NodeJS.ErrnoException).code === code; @@ -80,43 +91,60 @@ function reclaimStaleInstallLock(timeoutMs: number): void { } } -// timeoutMs/pollMs are parameters (not just the module constants) so tests can -// exercise the reclaim-on-timeout branch with real but tiny waits instead of -// mocking Date.now()/setTimeout through the full ensureBrowser call graph. +function touchInstallLock(): void { + try { + const now = new Date(); + utimesSync(INSTALL_LOCK_DIR, now, now); + } catch { + // ponytail: heartbeat is best-effort; stale-lock reclaim remains the fallback. + } +} + export async function withInstallLock( fn: () => Promise, - timeoutMs = INSTALL_LOCK_TIMEOUT_MS, - pollMs = INSTALL_LOCK_POLL_MS, + timings: InstallLockTimings = INSTALL_LOCK_TIMINGS, ): Promise { // recursive:false below needs the parent to already exist (unlike `mkdir -p`). // Keep lock dirs outside CACHE_DIR so force-clearing the Chrome cache cannot // delete another installer's in-flight lock. if (!existsSync(CACHE_ROOT_DIR)) mkdirSync(CACHE_ROOT_DIR, { recursive: true }); - let deadline = Date.now() + timeoutMs; + let deadline = Date.now() + timings.staleMs; + const waitStart = Date.now(); + let lastNoticeMs = 0; for (;;) { if (existsSync(INSTALL_RECLAIM_LOCK_DIR)) { - await sleep(pollMs); + await sleep(timings.pollMs); continue; } if (tryAcquireDirLock(INSTALL_LOCK_DIR)) { rmSync(INSTALL_RECLAIM_LOCK_DIR, { recursive: true, force: true }); break; } + const waitedMs = Date.now() - waitStart; + if (waitedMs - lastNoticeMs >= timings.waitNoticeMs) { + lastNoticeMs = waitedMs; + console.warn( + `[browser] Waiting for another hyperframes process to finish installing chrome-headless-shell (${Math.round(waitedMs / 1000)}s elapsed)...`, + ); + } if (Date.now() > deadline) { // The reclaim gate matters when multiple waiters cross the timeout at // once: without it, waiter A can delete the stale lock and acquire a // fresh one, then waiter B (whose old deadline also expired) can delete // A's fresh lock. The gate serializes reclaimers, and the mtime re-check // after the gate prevents deleting a fresh lock another waiter just won. - reclaimStaleInstallLock(timeoutMs); - deadline = Date.now() + timeoutMs; + reclaimStaleInstallLock(timings.staleMs); + deadline = Date.now() + timings.staleMs; continue; } - await sleep(pollMs); + await sleep(timings.pollMs); } + const heartbeat = setInterval(touchInstallLock, timings.heartbeatMs); + if (typeof heartbeat.unref === "function") heartbeat.unref(); try { return await fn(); } finally { + clearInterval(heartbeat); rmSync(INSTALL_LOCK_DIR, { recursive: true, force: true }); } } diff --git a/packages/cli/src/capture/captureCompositionFrame.test.ts b/packages/cli/src/capture/captureCompositionFrame.test.ts index cf942565c..c2e83396c 100644 --- a/packages/cli/src/capture/captureCompositionFrame.test.ts +++ b/packages/cli/src/capture/captureCompositionFrame.test.ts @@ -1,13 +1,316 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { runFfmpegOnce } from "./captureCompositionFrame.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + captureRegionCrop, + clampCropRegion, + installPageFunctionGuard, + padCropRegion, + parseZoomTarget, + resolveCliChromeGpuMode, + resolveCropRegion, + runFfmpegOnce, + seekCompositionTimeline, + type CompositionSeekPage, +} from "./captureCompositionFrame.js"; function tempDir(): string { return mkdtempSync(join(tmpdir(), "hf-capture-frame-test-")); } +function fakeSeekPage() { + const evaluate = vi.fn( + async ( + _pageFunction: Parameters[0], + _value?: number, + _fallbackToBridgeAndTimelines?: boolean, + ): Promise => undefined, + ); + const waitForFunction = vi.fn( + async (_pageFunction: () => boolean, _options: { timeout: number }): Promise => + undefined, + ); + const page: CompositionSeekPage = { evaluate, waitForFunction }; + return { page, evaluate, waitForFunction }; +} + +function runBrowserSeek(evaluate: ReturnType["evaluate"]): void { + const seekInBrowser = evaluate.mock.calls[0]?.[0]; + if (typeof seekInBrowser !== "function") throw new Error("Expected a browser seek function"); + Reflect.apply(seekInBrowser, undefined, evaluate.mock.calls[0]?.slice(1) ?? []); +} + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("seekCompositionTimeline", () => { + it("keeps the existing raced double-frame settle as the default", async () => { + const { page, evaluate, waitForFunction } = fakeSeekPage(); + + await seekCompositionTimeline(page, 1.25); + + expect(waitForFunction).not.toHaveBeenCalled(); + expect(evaluate).toHaveBeenCalledTimes(2); + expect(evaluate).toHaveBeenNthCalledWith(1, expect.any(Function), 1.25, false); + expect(evaluate.mock.calls[1]?.[0]).toContain("window.setTimeout(finish, 100)"); + }); + + it("prefers renderSeek so the runtime synchronizes clip visibility", async () => { + const { page, evaluate } = fakeSeekPage(); + const renderSeek = vi.fn(); + const bridgeSeek = vi.fn(); + const playerSeek = vi.fn(); + const timelineSeek = vi.fn(); + vi.stubGlobal("window", { + __player: { renderSeek, seek: playerSeek }, + __hf: { seek: bridgeSeek }, + __timelines: { main: { seek: timelineSeek } }, + }); + + await seekCompositionTimeline(page, 2.25); + runBrowserSeek(evaluate); + + expect(renderSeek).toHaveBeenCalledWith(2.25); + expect(bridgeSeek).not.toHaveBeenCalled(); + expect(playerSeek).not.toHaveBeenCalled(); + expect(timelineSeek).not.toHaveBeenCalled(); + }); + + function fakeBridgeOnlySeekPage() { + const { page, evaluate } = fakeSeekPage(); + const bridgeSeek = vi.fn(); + const tickerTick = vi.fn(); + vi.stubGlobal("window", { __hf: { seek: bridgeSeek }, gsap: { ticker: { tick: tickerTick } } }); + return { page, evaluate, bridgeSeek, tickerTick }; + } + + it("keeps bridge and raw fallbacks disabled for default capture callers", async () => { + const { page, evaluate, bridgeSeek, tickerTick } = fakeBridgeOnlySeekPage(); + + await seekCompositionTimeline(page, 2.5); + runBrowserSeek(evaluate); + + expect(bridgeSeek).not.toHaveBeenCalled(); + expect(tickerTick).not.toHaveBeenCalled(); + }); + + it("opts into the bridge before player and raw timeline fallbacks", async () => { + const { page, evaluate, bridgeSeek, tickerTick } = fakeBridgeOnlySeekPage(); + + await seekCompositionTimeline(page, 2.5, { fallbackToBridgeAndTimelines: true }); + runBrowserSeek(evaluate); + + expect(bridgeSeek).toHaveBeenCalledWith(2.5); + expect(tickerTick).toHaveBeenCalledOnce(); + }); + + it("opts into pausing and seeking raw timelines when no preferred target exists", async () => { + const { page, evaluate } = fakeSeekPage(); + const pause = vi.fn(); + const seek = vi.fn(); + vi.stubGlobal("window", { __timelines: { main: { pause, seek } } }); + + await seekCompositionTimeline(page, 1.75, { fallbackToBridgeAndTimelines: true }); + runBrowserSeek(evaluate); + + expect(pause).toHaveBeenCalledOnce(); + expect(seek).toHaveBeenCalledWith(1.75); + }); + + it("supports validate settling without adding an animation-frame or font wait", async () => { + vi.useFakeTimers(); + const { page, evaluate, waitForFunction } = fakeSeekPage(); + + const pending = seekCompositionTimeline(page, 3, { + fallbackToBridgeAndTimelines: true, + waitForPreferredSeekTargetMs: 500, + animationFrameSettle: "none", + settleMs: 150, + }); + await vi.advanceTimersByTimeAsync(150); + await pending; + + expect(waitForFunction).toHaveBeenCalledWith(expect.any(Function), { timeout: 500 }); + expect(evaluate).toHaveBeenCalledTimes(1); + expect(evaluate).toHaveBeenCalledWith(expect.any(Function), 3, true); + }); + + it("supports layout's ordered double-frame, bounded font, and sleep settles", async () => { + vi.useFakeTimers(); + const { page, evaluate } = fakeSeekPage(); + + const pending = seekCompositionTimeline(page, 4, { + fallbackToBridgeAndTimelines: true, + animationFrameSettle: "double", + waitForFontsMs: 500, + settleMs: 120, + }); + await vi.advanceTimersByTimeAsync(120); + await pending; + + expect(evaluate).toHaveBeenCalledTimes(3); + expect(evaluate).toHaveBeenNthCalledWith(1, expect.any(Function), 4, true); + expect(evaluate).toHaveBeenNthCalledWith(2, expect.any(Function)); + expect(evaluate).toHaveBeenNthCalledWith(3, expect.any(Function), 500); + }); +}); + +describe("resolveCliChromeGpuMode", () => { + it("preserves validate's software-only opt-in mapping", () => { + expect(resolveCliChromeGpuMode("software")).toBe("software"); + expect(resolveCliChromeGpuMode("hardware")).toBe("hardware"); + expect(resolveCliChromeGpuMode("auto")).toBe("hardware"); + expect(resolveCliChromeGpuMode("")).toBe("hardware"); + }); +}); + +describe("screenshot Chrome arguments", () => { + it("leaves shared capture and layout on the engine's software default", () => { + const defaultScreenshotArgs = + /args:\s*buildChromeArgs\(\s*\{[^}]*captureMode:\s*"screenshot"[^}]*\}\s*\),/; + const captureSource = readFileSync( + new URL("./captureCompositionFrame.ts", import.meta.url), + "utf8", + ); + const layoutSource = readFileSync(new URL("../commands/layout.ts", import.meta.url), "utf8"); + + // openSettledCompositionPage threads the caller's optional browserGpuMode; + // callers that omit it (snapshot, compare) fall through to the engine's + // software default for screenshot capture. + expect(captureSource).toMatch( + /args:\s*buildChromeArgs\(\s*\{[^}]*captureMode:\s*"screenshot"[^}]*\},\s*\{\s*browserGpuMode:\s*options\.browserGpuMode\s*\},?\s*\),/, + ); + expect(layoutSource).toMatch(defaultScreenshotArgs); + }); +}); + +describe("parseZoomTarget", () => { + it("parses four comma-separated numbers as an exact region", () => { + expect(parseZoomTarget("100,50,400,300")).toEqual({ + kind: "region", + region: { x: 100, y: 50, width: 400, height: 300 }, + }); + }); + + it("treats anything else as a CSS selector", () => { + expect(parseZoomTarget("#headline")).toEqual({ kind: "selector", selector: "#headline" }); + expect(parseZoomTarget(".card:nth-of-type(2)")).toEqual({ + kind: "selector", + selector: ".card:nth-of-type(2)", + }); + }); +}); + +describe("clampCropRegion / padCropRegion", () => { + it("clamps a region to the canvas bounds", () => { + expect( + clampCropRegion({ x: -10, y: -10, width: 50, height: 50 }, { width: 30, height: 30 }), + ).toEqual({ + x: 0, + y: 0, + width: 30, + height: 30, + }); + }); + + it("pads a region on every side when it fits within the canvas", () => { + expect( + padCropRegion({ x: 500, y: 500, width: 100, height: 40 }, { width: 1920, height: 1080 }, 24), + ).toEqual({ x: 476, y: 476, width: 148, height: 88 }); + }); + + it("pads then clamps when padding would spill outside the canvas", () => { + expect( + padCropRegion({ x: 10, y: 10, width: 20, height: 20 }, { width: 200, height: 200 }, 24), + ).toEqual({ x: 0, y: 0, width: 54, height: 54 }); + }); +}); + +describe("resolveCropRegion", () => { + it("resolves a selector to its bbox, padded 24px and clamped", async () => { + const page = { evaluate: vi.fn(async () => ({ x: 500, y: 500, width: 100, height: 40 })) }; + + const region = await resolveCropRegion( + page, + { kind: "selector", selector: "#headline" }, + { width: 1920, height: 1080 }, + ); + + expect(region).toEqual({ x: 476, y: 476, width: 148, height: 88 }); + }); + + it("crops a region exactly, without padding, when it already fits the canvas", async () => { + const page = { evaluate: vi.fn() }; + + const region = await resolveCropRegion( + page, + { kind: "region", region: { x: 100, y: 50, width: 400, height: 300 } }, + { width: 1920, height: 1080 }, + ); + + expect(region).toEqual({ x: 100, y: 50, width: 400, height: 300 }); + expect(page.evaluate).not.toHaveBeenCalled(); + }); + + it("throws a clear, loud error when the selector matches nothing", async () => { + const page = { evaluate: vi.fn(async () => null) }; + + await expect( + resolveCropRegion( + page, + { kind: "selector", selector: "#missing" }, + { width: 640, height: 360 }, + ), + ).rejects.toThrow("--zoom selector matched no element: #missing"); + }); + + it("returns null when the clamped region is a sliver (element animated off-canvas)", async () => { + // Element slid past the right edge: raw bbox is large, but clamping the + // padded region to the canvas leaves ~1px — a useless crop. + const page = { evaluate: vi.fn(async () => ({ x: 2500, y: 400, width: 600, height: 250 })) }; + + const region = await resolveCropRegion( + page, + { kind: "selector", selector: "#gone-by-now" }, + { width: 1920, height: 1080 }, + ); + + expect(region).toBeNull(); + }); +}); + +describe("captureRegionCrop", () => { + it("raises deviceScaleFactor for the clip shot, then restores the original viewport", async () => { + const original = { width: 1920, height: 1080, deviceScaleFactor: 1 }; + const setViewport = vi.fn(async () => undefined); + const screenshot = vi.fn(async () => new Uint8Array([1, 2, 3])); + const page = { viewport: () => original, setViewport, screenshot }; + const region = { x: 10, y: 20, width: 100, height: 50 }; + + const buffer = await captureRegionCrop(page, region, 3); + + expect(setViewport).toHaveBeenNthCalledWith(1, { ...original, deviceScaleFactor: 3 }); + expect(screenshot).toHaveBeenCalledWith({ clip: region, type: "png" }); + expect(setViewport).toHaveBeenNthCalledWith(2, original); + expect(buffer).toBeInstanceOf(Buffer); + expect(Array.from(buffer)).toEqual([1, 2, 3]); + }); + + it("honors an explicit scale other than the default", async () => { + const original = { width: 800, height: 600 }; + const setViewport = vi.fn(async () => undefined); + const screenshot = vi.fn(async () => new Uint8Array()); + const page = { viewport: () => original, setViewport, screenshot }; + + await captureRegionCrop(page, { x: 0, y: 0, width: 10, height: 10 }, 2); + + expect(setViewport).toHaveBeenNthCalledWith(1, { ...original, deviceScaleFactor: 2 }); + }); +}); + describe("runFfmpegOnce", () => { it("returns the process exit code and collected stderr", async () => { const dir = tempDir(); @@ -37,3 +340,21 @@ describe("runFfmpegOnce", () => { } }); }); + +describe("installPageFunctionGuard", () => { + it("defines the keepNames __name shim in the page before any script runs", async () => { + const evaluateOnNewDocument = vi.fn(async (_source: string) => undefined); + + await installPageFunctionGuard({ evaluateOnNewDocument }); + + expect(evaluateOnNewDocument).toHaveBeenCalledOnce(); + const source = evaluateOnNewDocument.mock.calls[0]?.[0] ?? ""; + expect(source).toContain("self.__name"); + // The shim must be a no-op passthrough so wrapped functions stay callable. + const shim = new Function(`const self = {}; ${source}; return self.__name;`)() as ( + fn: unknown, + ) => unknown; + const marker = () => 42; + expect(shim(marker)).toBe(marker); + }); +}); diff --git a/packages/cli/src/capture/captureCompositionFrame.ts b/packages/cli/src/capture/captureCompositionFrame.ts index fbc15331a..2b0d16409 100644 --- a/packages/cli/src/capture/captureCompositionFrame.ts +++ b/packages/cli/src/capture/captureCompositionFrame.ts @@ -3,17 +3,44 @@ import type { Browser, Page } from "puppeteer-core"; import { c } from "../ui/colors.js"; import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js"; -const CHROME_LAUNCH_ARGS = [ - "--no-sandbox", - "--disable-gpu", - "--disable-dev-shm-usage", - "--enable-webgl", - "--use-gl=angle", - "--use-angle=swiftshader", -]; - const SHADER_TRANSITIONS_TIMEOUT_MS = 90_000; const CAPTURE_SETTLE_MS = 1500; +const PREFERRED_SEEK_TARGET_WAIT_MS = 500; + +// The audit-grade seek tuning shared by check and the deprecated inspect/layout: +// bridge+timeline fallback, ordered double-rAF settle, bounded font wait, sleep. +export const AUDIT_SEEK_OPTIONS = { + fallbackToBridgeAndTimelines: true, + animationFrameSettle: "double", + waitForFontsMs: 500, + settleMs: 120, +} as const; + +export interface SeekCompositionTimelineOptions { + fallbackToBridgeAndTimelines?: boolean; + waitForPreferredSeekTargetMs?: number; + animationFrameSettle?: "race" | "double" | "none"; + waitForFontsMs?: number; + settleMs?: number; +} + +type CompositionPageFunction = + | string + | (() => unknown) + | ((value: number) => unknown) + | ((value: number, fallbackToBridgeAndTimelines: boolean) => unknown); + +export interface CompositionEvaluationPage { + evaluate( + pageFunction: CompositionPageFunction, + value?: number, + fallbackToBridgeAndTimelines?: boolean, + ): Promise; +} + +export interface CompositionSeekPage extends CompositionEvaluationPage { + waitForFunction?(pageFunction: () => boolean, options: { timeout: number }): Promise; +} export interface SettledCompositionPage { browser: Browser; @@ -26,6 +53,12 @@ export interface SettledCompositionPage { export interface OpenSettledCompositionPageOptions { renderReadyTimeoutMs: number; renderReadyWarningSuffix: string; + // Screenshot paths take the engine's software-GPU default; validate/check + // thread the PRODUCER_BROWSER_GPU_MODE opt-in through here. + browserGpuMode?: "software" | "hardware"; + // Runs after the page exists but before page.goto, so console/pageerror/ + // request listeners can attach without missing load-time events. + beforeNavigate?: (page: Page) => void | Promise; } export interface FfmpegRunResult { @@ -34,6 +67,12 @@ export interface FfmpegRunResult { timedOut: boolean; } +export function resolveCliChromeGpuMode( + envMode = process.env.PRODUCER_BROWSER_GPU_MODE, +): "software" | "hardware" { + return envMode === "software" ? "software" : "hardware"; +} + function compositionRuntimeReadyInBrowser(): boolean { return Boolean(Reflect.get(window, "__renderReady")); } @@ -92,25 +131,45 @@ async function waitForCompositionSettle( return runtimeReady; } +// tsx/esbuild-style dev transpilers run with keepNames, which rewrites named +// inner functions in serialized page closures into __name(...) calls — a +// helper that exists in the Node bundle but not in the browser realm, so any +// page.evaluate/waitForFunction whose closure defines a named function throws +// "__name is not defined" when the CLI runs from source. Defining a no-op in +// the page before any script runs immunizes every serialized closure, current +// and future, regardless of how the CLI was built. +export async function installPageFunctionGuard(page: { + evaluateOnNewDocument(source: string): Promise; +}): Promise { + await page.evaluateOnNewDocument("self.__name = self.__name || ((fn) => fn);"); +} + export async function openSettledCompositionPage( html: string, url: string, options: OpenSettledCompositionPageOptions, ): Promise { + const viewport = resolveCompositionViewportFromHtml(html); const { ensureBrowser } = await import("../browser/manager.js"); const browser = await ensureBrowser(); const puppeteer = await import("puppeteer-core"); + const { buildChromeArgs } = await import("@hyperframes/engine"); let chromeBrowser: Browser | undefined; try { chromeBrowser = await puppeteer.default.launch({ headless: true, executablePath: browser.executablePath, - args: CHROME_LAUNCH_ARGS, + args: buildChromeArgs( + { ...viewport, captureMode: "screenshot" }, + { browserGpuMode: options.browserGpuMode }, + ), }); const page = await chromeBrowser.newPage(); - await page.setViewport(resolveCompositionViewportFromHtml(html)); + await installPageFunctionGuard(page); + await page.setViewport(viewport); + await options.beforeNavigate?.(page); await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 }); const renderReadyTimedOut = !(await waitForCompositionSettle(page, options)); return { browser: chromeBrowser, page, renderReadyTimedOut }; @@ -121,29 +180,271 @@ export async function openSettledCompositionPage( } export async function seekCompositionTimeline( - page: Pick, + page: CompositionSeekPage, timeSeconds: number, + options: SeekCompositionTimelineOptions = {}, ): Promise { - await page.evaluate((t: number) => { - const player = (window as any).__player; - if (!player) return; - const safe = Math.max(0, Number(t) || 0); - if (typeof player.renderSeek === "function") { - player.renderSeek(safe); - } else if (typeof player.seek === "function") { - player.seek(safe); - } - if ((window as any).gsap?.ticker?.tick) { - (window as any).gsap.ticker.tick(); - } - }, timeSeconds); + if (options.waitForPreferredSeekTargetMs !== undefined) { + await waitForPreferredSeekTarget(page, options.waitForPreferredSeekTargetMs); + } - await page.evaluate(`new Promise(function(r) { - var settled = false; - function finish() { if (settled) return; settled = true; r(); } - window.setTimeout(finish, 100); - requestAnimationFrame(function() { requestAnimationFrame(finish); }); - })`); + await page.evaluate( + // Serialized into the page; the seek-target cascade must stay one function. + // fallow-ignore-next-line complexity + (t: number, fallbackToBridgeAndTimelines: boolean) => { + const getProperty = (target: unknown, key: string): unknown => { + if ((typeof target !== "object" || target === null) && typeof target !== "function") { + return undefined; + } + return Reflect.get(target, key); + }; + const call = (fn: unknown, receiver: unknown, args: unknown[]): boolean => { + if (typeof fn !== "function") return false; + Reflect.apply(fn, receiver, args); + return true; + }; + + const player = Reflect.get(window, "__player"); + if (!player && !fallbackToBridgeAndTimelines) return; + + const safe = Math.max(0, Number(t) || 0); + const renderSeek = getProperty(player, "renderSeek"); + const playerSeek = getProperty(player, "seek"); + const hf = Reflect.get(window, "__hf"); + const bridgeSeek = getProperty(hf, "seek"); + + // Prefer renderSeek because it also runs the runtime's data-start/data-duration + // visibility sync; raw timeline seeks leave off-window clips visible to audits. + if (call(renderSeek, player, [safe])) { + // Preferred runtime target handled the seek. + } else if (fallbackToBridgeAndTimelines && call(bridgeSeek, hf, [safe])) { + // Producer bridge handled the seek. + } else if (call(playerSeek, player, [safe])) { + // Legacy player target handled the seek. + } else if (fallbackToBridgeAndTimelines) { + const timelines = Reflect.get(window, "__timelines"); + if (typeof timelines === "object" && timelines !== null) { + for (const key of Object.keys(timelines)) { + const timeline = Reflect.get(timelines, key); + call(getProperty(timeline, "pause"), timeline, []); + call(getProperty(timeline, "seek"), timeline, [safe]); + } + } + } + + const gsap = Reflect.get(window, "gsap"); + const ticker = getProperty(gsap, "ticker"); + call(getProperty(ticker, "tick"), ticker, []); + }, + timeSeconds, + options.fallbackToBridgeAndTimelines === true, + ); + + const animationFrameSettle = options.animationFrameSettle ?? "race"; + if (animationFrameSettle === "race") { + await page.evaluate(`new Promise(function(r) { + var settled = false; + function finish() { if (settled) return; settled = true; r(); } + window.setTimeout(finish, 100); + requestAnimationFrame(function() { requestAnimationFrame(finish); }); + })`); + } else if (animationFrameSettle === "double") { + await page.evaluate( + () => + new Promise((resolveFrame) => + requestAnimationFrame(() => requestAnimationFrame(() => resolveFrame())), + ), + ); + } + + if (options.waitForFontsMs !== undefined) { + await waitForCompositionFonts(page, options.waitForFontsMs); + } + if (options.settleMs !== undefined) { + const settleMs = Math.max(0, options.settleMs); + await new Promise((resolveSettle) => setTimeout(resolveSettle, settleMs)); + } +} + +export async function waitForPreferredSeekTarget( + page: Pick, + timeoutMs = PREFERRED_SEEK_TARGET_WAIT_MS, +): Promise { + if (!page.waitForFunction) return; + try { + await page.waitForFunction( + () => { + const player = Reflect.get(window, "__player"); + const hf = Reflect.get(window, "__hf"); + const renderSeek = + typeof player === "object" && player !== null + ? Reflect.get(player, "renderSeek") + : undefined; + const bridgeSeek = + typeof hf === "object" && hf !== null ? Reflect.get(hf, "seek") : undefined; + return typeof renderSeek === "function" || typeof bridgeSeek === "function"; + }, + { timeout: timeoutMs }, + ); + } catch { + // Legacy/static pages may only expose raw timelines; keep that fallback available. + } +} + +export async function waitForCompositionFonts( + page: CompositionEvaluationPage, + timeoutMs: number, +): Promise { + await page + .evaluate((ms: number) => { + const fonts = Reflect.get(document, "fonts"); + if (typeof fonts !== "object" || fonts === null) return Promise.resolve(); + const ready = Reflect.get(fonts, "ready"); + if (!ready) return Promise.resolve(); + return Promise.race([ + Promise.resolve(ready).then(() => undefined), + new Promise((resolve) => setTimeout(resolve, ms)), + ]); + }, timeoutMs) + .catch(() => {}); +} + +export interface CropRegion { + x: number; + y: number; + width: number; + height: number; +} + +export interface CropCanvas { + width: number; + height: number; +} + +export type ZoomTarget = + | { kind: "selector"; selector: string } + | { kind: "region"; region: CropRegion }; + +// Four bare comma-separated numbers is unambiguous — no valid CSS selector +// parses as that shape — so it always means "exact pixel region". +const ZOOM_REGION_PATTERN = /^-?\d+(?:\.\d+)?(?:,-?\d+(?:\.\d+)?){3}$/; +export const DEFAULT_ZOOM_PADDING_PX = 24; +// One knob for every zoom/crop consumer (snapshot --zoom-scale default, check's +// finding crops): density of the captured pixels relative to CSS pixels. +export const DEFAULT_ZOOM_SCALE = 3; + +/** Parse `snapshot --zoom` into either a CSS selector or an exact pixel region "x,y,w,h". */ +export function parseZoomTarget(value: string): ZoomTarget { + const trimmed = value.trim(); + if (ZOOM_REGION_PATTERN.test(trimmed)) { + const [x, y, width, height] = trimmed.split(",").map(Number) as [ + number, + number, + number, + number, + ]; + return { kind: "region", region: { x, y, width, height } }; + } + return { kind: "selector", selector: trimmed }; +} + +/** Clamp a region to the canvas bounds — Puppeteer's clip screenshot rejects a + * region that spills outside the viewport. Keeps at least 1px on each side. */ +export function clampCropRegion(region: CropRegion, canvas: CropCanvas): CropRegion { + const x = Math.max(0, Math.min(region.x, canvas.width)); + const y = Math.max(0, Math.min(region.y, canvas.height)); + const x2 = Math.max(x + 1, Math.min(region.x + region.width, canvas.width)); + const y2 = Math.max(y + 1, Math.min(region.y + region.height, canvas.height)); + return { x, y, width: x2 - x, height: y2 - y }; +} + +/** Pad a region on every side (context around a zoomed element), then clamp. */ +export function padCropRegion( + region: CropRegion, + canvas: CropCanvas, + paddingPx: number, +): CropRegion { + return clampCropRegion( + { + x: region.x - paddingPx, + y: region.y - paddingPx, + width: region.width + paddingPx * 2, + height: region.height + paddingPx * 2, + }, + canvas, + ); +} + +export interface ZoomSelectorPage { + evaluate( + pageFunction: (selector: string) => CropRegion | null, + selector: string, + ): Promise; +} + +/** + * Resolve a `--zoom` target to a concrete crop region. A selector resolves to + * its live bbox (padded ~24px, then clamped); an explicit region is used + * as-is (clamped only, never padded — region form crops exactly). A selector + * matching nothing throws: a loud error beats a silent full-frame fallback. + */ +// A selector can match an element whose visible box is gone at the sampled +// time — collapsed (display:none mid-timeline) or animated off-canvas, where +// clamping leaves a pixel-wide remnant. Either way the crop would be a sliver +// that tells an agent nothing, so the final clamped region is what's guarded +// and callers skip the frame on null. Explicit x,y,w,h regions stay literal. +const MIN_CROP_REGION_PX = 8; + +export async function resolveCropRegion( + page: ZoomSelectorPage, + target: ZoomTarget, + canvas: CropCanvas, + paddingPx = DEFAULT_ZOOM_PADDING_PX, +): Promise { + if (target.kind === "region") return clampCropRegion(target.region, canvas); + const bbox = await page.evaluate((selector) => { + const element = document.querySelector(selector); + if (!element) return null; + const rect = element.getBoundingClientRect(); + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; + }, target.selector); + if (!bbox) throw new Error(`--zoom selector matched no element: ${target.selector}`); + const region = padCropRegion(bbox, canvas, paddingPx); + if (region.width < MIN_CROP_REGION_PX || region.height < MIN_CROP_REGION_PX) return null; + return region; +} + +export interface CropCapturePage { + viewport(): { width: number; height: number; deviceScaleFactor?: number } | null; + setViewport(viewport: { + width: number; + height: number; + deviceScaleFactor?: number; + }): Promise; + screenshot(options: { clip: CropRegion; type: "png" }): Promise; +} + +/** + * Capture a high-density crop of `region`: raise `deviceScaleFactor` to + * `scale`, take a clip screenshot, then restore the original viewport. + * Deliberately NOT CSS zoom or a viewport resize — DSF only changes how + * densely Chrome rasterizes the existing CSS-pixel layout, so the + * composition's layout (and its render determinism) is untouched. The PNG + * comes out at `region.width * scale` real device pixels, not an upscale. + */ +export async function captureRegionCrop( + page: CropCapturePage, + region: CropRegion, + scale: number, +): Promise { + const original = page.viewport(); + if (original) await page.setViewport({ ...original, deviceScaleFactor: scale }); + try { + const shot = await page.screenshot({ clip: region, type: "png" }); + return Buffer.isBuffer(shot) ? shot : Buffer.from(shot); + } finally { + if (original) await page.setViewport(original); + } } export async function runFfmpegOnce( diff --git a/packages/cli/src/cli.commands.test.ts b/packages/cli/src/cli.commands.test.ts index 4a533eeb5..cc62420cf 100644 --- a/packages/cli/src/cli.commands.test.ts +++ b/packages/cli/src/cli.commands.test.ts @@ -26,4 +26,17 @@ describe("CLI command registration", () => { '["keyframes", "Inspect keyframes and render onion-shot diagnostics"]', ); }); + + // A command actively reconciling skills (`skills check`/`skills update`) + // must not also nudge the user to go reconcile skills — that nudge is + // either redundant (it just ran) or misleading (a stale cached count from + // the 24h background check, contradicting whatever it just reported). + it("excludes 'skills' from the background skills-nudge gate, alongside 'upgrade' and 'events'", () => { + const match = cliSource.match(/if \(([\s\S]*?)\) \{\s*\/\/ Report any completed auto-install/); + expect(match, "expected to find the background nudge gate's if-condition").toBeTruthy(); + const condition = match![1]!; + expect(condition).toContain('command !== "upgrade"'); + expect(condition).toContain('command !== "events"'); + expect(condition).toContain('command !== "skills"'); + }); }); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index b98ad6d44..a640bf218 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -101,6 +101,7 @@ try { import { defineCommand, runMain } from "citty"; import type { ArgsDef, CommandDef } from "citty"; +import { getRunId } from "./telemetry/runId.js"; import { reportCommandFailure, trackCommandFailures } from "./utils/command-failure-tracking.js"; const isHelp = process.argv.includes("--help") || process.argv.includes("-h"); @@ -119,6 +120,7 @@ const commandLoaders = { publish: () => import("./commands/publish.js").then((m) => m.default), render: () => import("./commands/render.js").then((m) => m.default), lint: () => import("./commands/lint.js").then((m) => m.default), + check: () => import("./commands/check.js").then((m) => m.default), beats: () => import("./commands/beats.js").then((m) => m.default), inspect: () => import("./commands/inspect.js").then((m) => m.default), keyframes: () => import("./commands/keyframes.js").then((m) => m.default), @@ -195,7 +197,13 @@ let _trackCliError: }) => void) | undefined; let _trackCommandResult: - | ((props: { command: string; success: boolean; exitCode: number; durationMs: number }) => void) + | ((props: { + command: string; + success: boolean; + exitCode: number; + durationMs: number; + runId?: string; + }) => void) | undefined; let _printUpdateNotice: (() => void) | undefined; let _printSkillsUpdateNotice: (() => void) | undefined; @@ -210,14 +218,26 @@ if (!isHelp && command !== "telemetry" && command !== "events" && command !== "u _trackCliError = mod.trackCliError; _trackCommandResult = mod.trackCommandResult; mod.showTelemetryNotice(); - mod.trackCommand(command); + mod.trackCommand(command, runId); if (mod.shouldTrack()) mod.incrementCommandCount(); }); } // `events` skips the update check too — a skill-usage beacon must not add // network latency or trigger a background self-upgrade on the calling skill. -if (!isHelp && !hasJsonFlag && command !== "upgrade" && command !== "events") { +// `skills` is excluded from the SKILLS nudge for the same reason `upgrade` is +// excluded from the self-update notice: a command that is itself actively +// checking/reconciling skills (`skills check`, `skills update`) must not also +// tell the user to go run `skills update` — that's either redundant (it just +// did) or, worse, misleading (it printed a stale nudge count from the last +// cached check while reporting fresh results of its own). +if ( + !isHelp && + !hasJsonFlag && + command !== "upgrade" && + command !== "events" && + command !== "skills" +) { // Report any completed auto-install from the previous run first, before // kicking off the next check — so the user sees "updated to vX" once and // we don't over-print. @@ -241,6 +261,7 @@ if (!isHelp && !hasJsonFlag && command !== "upgrade" && command !== "events") { } const commandStart = Date.now(); +const runId = getRunId(); // Async flush for normal exit. `beforeExit` re-fires every time the // event loop drains, and the async `_flush()` itself schedules new @@ -264,6 +285,7 @@ process.on("exit", (code) => { success: code === 0 && !commandFailed, exitCode: code, durationMs: Date.now() - commandStart, + runId, }); _flushSync?.(); }); diff --git a/packages/cli/src/commands/check.test.ts b/packages/cli/src/commands/check.test.ts new file mode 100644 index 000000000..06af01eb1 --- /dev/null +++ b/packages/cli/src/commands/check.test.ts @@ -0,0 +1,1331 @@ +import { runCommand } from "citty"; +import { readFileSync } from "node:fs"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const trackCheckReport = vi.fn(); +vi.mock("../telemetry/events.js", () => ({ + trackCheckReport: (...args: unknown[]) => trackCheckReport(...args), + trackCommandFailure: vi.fn(), +})); + +import { contrastRatio, parseColorRGBA } from "./contrast-bg.js"; +import { createCheckCommand } from "./check.js"; +import { + DEFAULT_CHECK_OPTIONS, + checkExitCode, + findingCropFilename, + runAuditGrid, + runCheckPipeline, + selectContrastTimes, + selectFindingCropRequests, + type AnchoredLayoutIssue, + type CheckAnchor, + type CheckAuditDriver, + type CheckBrowserResult, + type CheckDependencies, + type CheckFinding, + type CheckFindingCropRequest, + type CheckOptions, + type CheckReport, + type ContrastAuditEntry, + type MotionSpecResolution, +} from "../utils/checkPipeline.js"; +import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js"; +import type { ProjectLintResult } from "../utils/lintProject.js"; +import type { + LayoutIssue, + LayoutIssueCode, + LayoutOverflow, + LayoutRect, +} from "../utils/layoutAudit.js"; +import type { ProjectDir } from "../utils/project.js"; + +const PROJECT: ProjectDir = { + dir: "/project", + name: "project", + indexPath: "/project/index.html", +}; +const PNG_BASE64 = Buffer.from("png-bytes").toString("base64"); +const ORIGINAL_EXIT_CODE = process.exitCode; + +afterEach(() => { + process.exitCode = ORIGINAL_EXIT_CODE; + trackCheckReport.mockClear(); + vi.restoreAllMocks(); +}); + +function cleanLint(): ProjectLintResult { + return { + results: [ + { + file: "index.html", + result: { + ok: true, + errorCount: 0, + warningCount: 0, + infoCount: 0, + findings: [], + }, + }, + ], + totalErrors: 0, + totalWarnings: 0, + totalInfos: 0, + }; +} + +function lintWith( + severity: "error" | "warning" | "info", + code: string, + message: string, +): ProjectLintResult { + return { + results: [ + { + file: "index.html", + result: { + ok: severity !== "error", + errorCount: severity === "error" ? 1 : 0, + warningCount: severity === "warning" ? 1 : 0, + infoCount: severity === "info" ? 1 : 0, + findings: [{ severity, code, message }], + }, + }, + ], + totalErrors: severity === "error" ? 1 : 0, + totalWarnings: severity === "warning" ? 1 : 0, + totalInfos: severity === "info" ? 1 : 0, + }; +} + +function anchor(selector: string, time: number): CheckAnchor { + return { + selector, + dataAttributes: { "data-layout-name": "hero" }, + sourceFile: "compositions/scene.html", + bbox: { x: 10, y: 20, width: 300, height: 80 }, + time, + }; +} + +function layoutIssue( + severity: "error" | "warning" | "info" = "error", + overrides: { time?: number; code?: AnchoredLayoutIssue["code"] } = {}, +): AnchoredLayoutIssue { + const time = overrides.time ?? 0.5; + return { + ...anchor("#hero", time), + code: overrides.code ?? (severity === "warning" ? "content_overlap" : "clipped_text"), + severity, + text: "Hero", + message: severity === "warning" ? "Text may overlap." : "Text is clipped.", + rect: { left: 10, top: 20, right: 310, bottom: 100, width: 300, height: 80 }, + }; +} + +function contrastEntry(overrides: Partial = {}): ContrastAuditEntry { + return { + ...anchor("#hero", 0.5), + text: "Body text", + ratio: 2.5, + wcagAA: false, + large: false, + fg: "rgb(110,110,110)", + bg: "rgb(30,30,30)", + ...overrides, + }; +} + +function fakeDriver(overrides: Partial = {}): CheckAuditDriver { + // A distinct string per call so the frozen-sweep guard (#U10) never fires + // by accident in unrelated scenarios — tests that want it force a constant + // via `collectLayoutGeometry: vi.fn(async () => "same")`. + let geometryCallCount = 0; + return { + initialize: vi.fn(async (_contrast: boolean) => undefined), + getDuration: vi.fn(async () => 9), + getTransitionBoundaries: vi.fn(async () => []), + getCanvas: vi.fn(async () => ({ width: 1920, height: 1080 })), + findAmbiguousSelectors: vi.fn(async (_selectors: string[]) => []), + seek: vi.fn(async (_time: number) => undefined), + collectLayout: vi.fn(async (_time: number, _tolerance: number) => []), + collectLayoutGeometry: vi.fn(async () => `geometry-${geometryCallCount++}`), + collectGeometryCandidates: vi.fn(async () => []), + collectMotionFrame: vi.fn(async (time: number) => ({ time, data: {}, liveness: {} })), + anchorMotionIssues: vi.fn(async (issues: LayoutIssue[]) => + issues.map((issue) => ({ + ...issue, + ...anchor(issue.selector, issue.time), + })), + ), + collectContrast: vi.fn(async (_time: number) => ({ entries: [], pngBase64: PNG_BASE64 })), + ...overrides, + }; +} + +interface GeometryFixture { + kind: "text" | "media"; + tag: string; + text: string; + selector: string; + rect: LayoutRect; + elementRect?: LayoutRect; + time: number; + overflow?: LayoutOverflow; +} + +function geometryCandidate(fixture: GeometryFixture) { + return { + ...anchor(fixture.selector, fixture.time), + kind: fixture.kind, + tag: fixture.tag, + text: fixture.text, + rect: fixture.rect, + elementRect: fixture.elementRect ?? fixture.rect, + bbox: { + x: fixture.rect.left, + y: fixture.rect.top, + width: fixture.rect.width, + height: fixture.rect.height, + }, + overflow: fixture.overflow, + }; +} + +function fixtureRect(left: number, top: number, width: number, height: number): LayoutRect { + return { left, top, right: left + width, bottom: top + height, width, height }; +} + +function checkBrowserSource(): string { + return readFileSync(new URL("../utils/checkBrowser.ts", import.meta.url), "utf8"); +} + +async function gateCandidates( + time: number, + request: { text: boolean; media: boolean; tolerance: number }, +) { + const candidates = []; + if (request.text) { + candidates.push( + geometryCandidate({ + kind: "text", + tag: "h2", + text: "Repeated heading", + selector: time === 2 ? "#first-heading" : "#later-heading", + rect: fixtureRect(800, 880, 320, 60), + time, + }), + ); + } + if (request.media) { + candidates.push( + geometryCandidate({ + kind: "media", + tag: "video", + text: "video", + selector: "#midpoint-video", + rect: fixtureRect(-140, 100, 100, 100), + overflow: { left: 140 }, + time, + }), + ); + } + return candidates; +} + +function noMotion(): MotionSpecResolution { + return { kind: "none" }; +} + +function heroMotionFrame(time: number, visibleAt: (time: number) => boolean) { + return { + time, + data: { + "#hero": { + rect: { left: 10, top: 20, right: 310, bottom: 100, width: 300, height: 80 }, + opacity: visibleAt(time) ? 1 : 0, + visible: visibleAt(time), + }, + }, + liveness: {}, + }; +} + +function dependencies( + driver: CheckAuditDriver, + options: { + lint?: ProjectLintResult; + motion?: MotionSpecResolution; + runtime?: CheckFinding[]; + writeSnapshot?: CheckDependencies["writeSnapshot"]; + captureFindingCrops?: CheckDependencies["captureFindingCrops"]; + } = {}, +): { deps: CheckDependencies; runBrowserCheck: ReturnType } { + const runBrowserCheck = vi.fn( + async ( + _project: ProjectDir, + checkOptions: CheckOptions, + motion: MotionSpecResolution, + ): Promise => { + const result = await runAuditGrid(driver, checkOptions, motion); + return { ...result, runtimeFindings: options.runtime ?? [] }; + }, + ); + const deps: CheckDependencies = { + lintProject: vi.fn(async () => options.lint ?? cleanLint()), + resolveMotionSpec: vi.fn(() => options.motion ?? noMotion()), + runBrowserCheck, + writeSnapshot: + options.writeSnapshot ?? + vi.fn((_projectDir: string, index: number, time: number, _pngBase64: string) => + Promise.resolve( + `snapshots/frame-${String(index).padStart(2, "0")}-at-${time.toFixed(1)}s.png`, + ), + ), + captureFindingCrops: options.captureFindingCrops ?? vi.fn(async () => []), + }; + return { deps, runBrowserCheck }; +} + +async function runScenario( + driver: CheckAuditDriver, + optionOverrides: Partial = {}, + dependencyOverrides: Parameters[1] = {}, +): Promise<{ report: CheckReport; deps: CheckDependencies; browser: ReturnType }> { + const { deps, runBrowserCheck } = dependencies(driver, dependencyOverrides); + const report = await runCheckPipeline( + PROJECT, + { ...DEFAULT_CHECK_OPTIONS, ...optionOverrides }, + deps, + ); + return { report, deps, browser: runBrowserCheck }; +} + +function runtimeError(): CheckFinding { + return { + code: "console_error", + severity: "error", + message: "boom", + ...anchor("[data-composition-id]", 0), + }; +} + +describe("contrast sample selection", () => { + it("chooses five evenly distributed grid points including both ends", () => { + expect(selectContrastTimes([0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5])).toEqual([ + 0.5, 2.5, 4.5, 6.5, 8.5, + ]); + expect(selectContrastTimes([1, 2, 3])).toEqual([1, 2, 3]); + }); +}); + +it("parses the caption-zone grammar and enables the frame gate", async () => { + const { report } = await runScenario(fakeDriver()); + const runPipeline = vi.fn(async (_project: ProjectDir, _options: CheckOptions) => report); + vi.spyOn(console, "log").mockImplementation(() => undefined); + const command = createCheckCommand({ + resolveProject: () => PROJECT, + runPipeline, + withMeta: (value) => value, + }); + + await runCommand(command, { + rawArgs: [ + "--json", + "--caption-zone", + "x0=0;y0=.82;x1=1;y1=1;severity=error;seek=.25,1", + "--frame-check", + ], + }); + + expect(runPipeline).toHaveBeenCalledWith( + PROJECT, + expect.objectContaining({ + captionZone: { + x0: 0, + y0: 0.82, + x1: 1, + y1: 1, + severity: "error", + seek: [0.25, 1], + }, + frameCheck: {}, + }), + ); +}); + +it("rejects malformed caption-zone specs instead of silently disabling the gate", async () => { + const { report } = await runScenario(fakeDriver()); + const runPipeline = vi.fn(async () => report); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const command = createCheckCommand({ + resolveProject: () => PROJECT, + runPipeline, + withMeta: (value) => ({ ...value, _meta: { version: "test" } }), + }); + + await runCommand(command, { + rawArgs: ["--json", "--caption-zone", "x0=0;y0=.8;x1=1;y1=1.2"], + }); + + expect(runPipeline).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(log).toHaveBeenCalledTimes(1); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + ok: false, + error: expect.stringContaining("Invalid --caption-zone"), + _meta: { version: "test" }, + }); +}); + +it("flags only text whose center is inside the caption band at the default end seek", async () => { + const collectGeometryCandidates = vi.fn(async (time: number) => [ + geometryCandidate({ + kind: "text", + tag: "div", + text: "Centered title", + selector: "#centered", + rect: fixtureRect(860, 870, 200, 60), + time, + }), + geometryCandidate({ + kind: "text", + tag: "div", + text: "Overlap only", + selector: "#overlap-only", + rect: fixtureRect(860, 830, 200, 60), + time, + }), + ]); + const { report } = await runScenario( + fakeDriver({ + getDuration: vi.fn(async () => 10), + collectGeometryCandidates, + }), + { + samples: 1, + contrast: false, + captionZone: { x0: 0, y0: 0.8, x1: 1, y1: 0.9 }, + }, + ); + + expect(collectGeometryCandidates).toHaveBeenCalledTimes(1); + expect(collectGeometryCandidates).toHaveBeenCalledWith(10, { + text: true, + media: false, + tolerance: 2, + }); + expect(report.layout.samples).toEqual([5, 10]); + expect(report.layout.findings).toEqual([ + expect.objectContaining({ + code: "caption_zone_collision", + severity: "warning", + selector: "#centered", + text: "Centered title", + time: 10, + }), + ]); + expect(report.ok).toBe(true); +}); + +it("filters caption candidates by the element box while centering the text rect", async () => { + const collectGeometryCandidates = vi.fn(async (time: number) => [ + geometryCandidate({ + kind: "text", + tag: "div", + text: "Full-frame wrapper copy", + selector: "#full-frame-wrapper", + rect: fixtureRect(860, 870, 200, 60), + elementRect: fixtureRect(0, 0, 1920, 1080), + time, + }), + geometryCandidate({ + kind: "text", + tag: "span", + text: "Tiny wrapper copy", + selector: "#tiny-wrapper", + rect: fixtureRect(860, 870, 200, 60), + elementRect: fixtureRect(860, 870, 3, 3), + time, + }), + ]); + const { report } = await runScenario(fakeDriver({ collectGeometryCandidates }), { + contrast: false, + captionZone: { x0: 0, y0: 0.8, x1: 1, y1: 0.9 }, + }); + + expect(report.layout.findings).toEqual([]); +}); + +it("checks media overflow at the default midpoint and applies warning severity", async () => { + const collectGeometryCandidates = vi.fn(async (time: number) => [ + geometryCandidate({ + kind: "media", + tag: "img", + text: "img", + selector: "#hero-image", + rect: fixtureRect(1840, 100, 220, 200), + overflow: { right: 140 }, + time, + }), + ]); + const { report } = await runScenario( + fakeDriver({ getDuration: vi.fn(async () => 10), collectGeometryCandidates }), + { samples: 1, contrast: false, frameCheck: {} }, + ); + + expect(collectGeometryCandidates).toHaveBeenCalledWith(5, { + text: false, + media: true, + tolerance: 2, + }); + expect(report.layout.findings[0]).toMatchObject({ + code: "frame_out_of_frame", + severity: "warning", + selector: "#hero-image", + overflow: { right: 140 }, + time: 5, + }); +}); + +it("converts progress seeks to time, gates each collector, and keeps the first caption hit", async () => { + const collectGeometryCandidates = vi.fn(gateCandidates); + const { report } = await runScenario( + fakeDriver({ + getDuration: vi.fn(async () => 8), + collectGeometryCandidates, + }), + { + samples: 1, + contrast: false, + captionZone: { + x0: 0, + y0: 0.8, + x1: 1, + y1: 0.9, + severity: "error", + seek: [0.25, 0.75], + }, + frameCheck: { severity: "error" }, + }, + ); + + expect(report.layout.samples).toEqual([2, 4, 6]); + expect(collectGeometryCandidates.mock.calls).toEqual([ + [2, { text: true, media: false, tolerance: 2 }], + [4, { text: false, media: true, tolerance: 2 }], + [6, { text: true, media: false, tolerance: 2 }], + ]); + expect(report.layout.findings).toEqual([ + expect.objectContaining({ + code: "caption_zone_collision", + severity: "error", + selector: "#first-heading", + time: 2, + }), + expect.objectContaining({ code: "frame_out_of_frame", severity: "error", time: 4 }), + ]); + expect(report.ok).toBe(false); +}); + +it("does not collect or emit opt-in geometry findings when both flags are off", async () => { + const collectGeometryCandidates = vi.fn(async () => [ + geometryCandidate({ + kind: "media", + tag: "video", + text: "video", + selector: "#video", + rect: fixtureRect(1900, 0, 200, 200), + overflow: { right: 180 }, + time: 4.5, + }), + ]); + const { report } = await runScenario(fakeDriver({ collectGeometryCandidates }), { + contrast: false, + }); + + expect(collectGeometryCandidates).not.toHaveBeenCalled(); + expect(JSON.stringify(report)).not.toContain("caption_zone_collision"); + expect(JSON.stringify(report)).not.toContain("frame_out_of_frame"); +}); + +it("computes caption bands from a portrait composition viewport", async () => { + const viewport = resolveCompositionViewportFromHtml( + '
', + ); + const collectGeometryCandidates = vi.fn(async (time: number) => [ + geometryCandidate({ + kind: "text", + tag: "p", + text: "Portrait caption collision", + selector: "#portrait-copy", + rect: fixtureRect(480, 1570, 120, 60), + time, + }), + ]); + const getCanvas = vi.fn(async () => viewport); + const { report } = await runScenario( + fakeDriver({ + getDuration: vi.fn(async () => 4), + getCanvas, + collectGeometryCandidates, + }), + { + samples: 1, + contrast: false, + captionZone: { x0: 0.4, y0: 0.8, x1: 0.6, y1: 0.9 }, + }, + ); + + expect(viewport).toEqual({ width: 1080, height: 1920 }); + expect(getCanvas).toHaveBeenCalled(); + expect(report.layout.findings).toEqual([ + expect.objectContaining({ code: "caption_zone_collision", selector: "#portrait-copy" }), + ]); +}); + +it("suppresses frame breaches below the per-canvas floor and reports those above it", async () => { + const collectGeometryCandidates = vi.fn(async (time: number) => [ + geometryCandidate({ + kind: "media", + tag: "canvas", + text: "canvas", + selector: "#under-floor", + rect: fixtureRect(3980, 100, 199, 100), + overflow: { right: 179 }, + time, + }), + geometryCandidate({ + kind: "media", + tag: "canvas", + text: "canvas", + selector: "#over-floor", + rect: fixtureRect(3980, 300, 201, 100), + overflow: { right: 181 }, + time, + }), + ]); + const { report } = await runScenario( + fakeDriver({ + getCanvas: vi.fn(async () => ({ width: 4000, height: 3000 })), + collectGeometryCandidates, + }), + { + contrast: false, + frameCheck: {}, + }, + ); + + expect(report.layout.findings).toEqual([ + expect.objectContaining({ + code: "frame_out_of_frame", + selector: "#over-floor", + overflow: { right: 181 }, + }), + ]); +}); + +it("keeps frame findings at distinct rounded positions across requested seeks", async () => { + const collectGeometryCandidates = vi.fn(async (time: number) => [ + geometryCandidate({ + kind: "media", + tag: "img", + text: "img", + selector: "#moving-image", + rect: fixtureRect(1920, time === 2 ? 100 : 300, 130, 100), + overflow: { right: 130 }, + time, + }), + ]); + const { report } = await runScenario( + fakeDriver({ + getDuration: vi.fn(async () => 8), + collectGeometryCandidates, + }), + { + samples: 1, + contrast: false, + frameCheck: { seek: [0.25, 0.75] }, + }, + ); + + expect(report.layout.findings).toEqual([ + expect.objectContaining({ code: "frame_out_of_frame", time: 2 }), + expect.objectContaining({ code: "frame_out_of_frame", time: 6 }), + ]); +}); + +it("keeps contrast and snapshot sampling on the pre-gate layout grid", async () => { + const collectContrast = vi.fn(async () => ({ entries: [], pngBase64: PNG_BASE64 })); + const { report } = await runScenario( + fakeDriver({ + getDuration: vi.fn(async () => 10), + collectContrast, + }), + { + samples: 1, + captionZone: { x0: 0, y0: 0.8, x1: 1, y1: 1 }, + }, + ); + + expect(report.layout.samples).toEqual([5, 10]); + expect(report.contrast.samples).toEqual([5]); + expect(collectContrast).toHaveBeenCalledTimes(1); + expect(collectContrast).toHaveBeenCalledWith(5); +}); + +function layoutFindingOf( + code: LayoutIssueCode, + severity: "error" | "warning" | "info", + bbox: { x: number; y: number; width: number; height: number }, + time = 1, +): AnchoredLayoutIssue { + return { + code, + severity, + message: code, + ...anchor("#el", time), + bbox, + rect: { + left: bbox.x, + top: bbox.y, + right: bbox.x + bbox.width, + bottom: bbox.y + bbox.height, + ...bbox, + }, + }; +} + +function checkFindingOf( + code: string, + severity: "error" | "warning" | "info", + bbox: { x: number; y: number; width: number; height: number }, + time = 1, +): CheckFinding { + return { code, severity, message: code, ...anchor("#el", time), bbox }; +} + +function emptySection(findings: T[] = []) { + return { ok: true, errorCount: 0, warningCount: 0, infoCount: 0, findings }; +} + +function reportWithFindings(overrides: Partial = {}): CheckReport { + return { + ok: true, + strict: false, + lint: { ...emptySection(), filesScanned: 0 }, + runtime: emptySection(), + layout: { + ...emptySection(), + duration: 10, + samples: [], + transitionSamples: [], + transitionSamplesDropped: 0, + tolerance: 2, + totalIssueCount: 0, + truncated: false, + }, + motion: { ...emptySection(), enabled: false, samples: 0 }, + contrast: { ...emptySection(), enabled: true, samples: [], checked: 0, passed: 0 }, + snapshots: { enabled: false, files: [], times: [], findingFiles: [] }, + ...overrides, + }; +} + +describe("selectFindingCropRequests", () => { + const NON_ZERO_BBOX = { x: 10, y: 20, width: 100, height: 50 }; + const ZERO_BBOX = { x: 0, y: 0, width: 0, height: 0 }; + + it("filenames a request finding-NN-.png with the finding's time and bbox", () => { + const report = reportWithFindings({ + layout: { + ...reportWithFindings().layout, + findings: [layoutFindingOf("clipped_text", "error", NON_ZERO_BBOX, 2.5)], + }, + }); + + expect(selectFindingCropRequests(report)).toEqual([ + { filename: "finding-00-clipped_text.png", time: 2.5, bbox: NON_ZERO_BBOX }, + ]); + }); + + it("skips warnings/info and findings without a real bbox", () => { + const report = reportWithFindings({ + layout: { + ...reportWithFindings().layout, + findings: [ + layoutFindingOf("content_overlap", "warning", NON_ZERO_BBOX), + layoutFindingOf("clipped_text", "error", ZERO_BBOX), + ], + }, + runtime: { ...emptySection([checkFindingOf("console_error", "info", NON_ZERO_BBOX)]) }, + }); + + expect(selectFindingCropRequests(report)).toEqual([]); + }); + + it("caps at 12 requests across sections", () => { + const findings = Array.from({ length: 15 }, (_, index) => + checkFindingOf(`code_${index}`, "error", NON_ZERO_BBOX, index), + ); + const report = reportWithFindings({ + runtime: { ...emptySection(findings) }, + }); + + const requests = selectFindingCropRequests(report); + expect(requests).toHaveLength(12); + expect(requests[0]?.filename).toBe(findingCropFilename(0, "code_0")); + expect(requests[11]?.filename).toBe(findingCropFilename(11, "code_11")); + }); + + it("sanitizes unusual characters out of the code when building a filename", () => { + expect(findingCropFilename(3, "weird code/name")).toBe("finding-03-weird_code_name.png"); + }); +}); + +describe("check pipeline", () => { + const originalExitCode = process.exitCode; + + afterEach(() => { + process.exitCode = originalExitCode; + vi.restoreAllMocks(); + }); + + it("emits one clean JSON envelope with every section and exit 0", async () => { + const { report } = await runScenario(fakeDriver()); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const command = createCheckCommand({ + resolveProject: () => PROJECT, + runPipeline: vi.fn(async () => report), + withMeta: (value) => ({ ...value, _meta: { version: "test" } }), + }); + + await runCommand(command, { rawArgs: ["--json"] }); + + expect(report.ok).toBe(true); + expect(checkExitCode(report)).toBe(0); + expect(process.exitCode).toBe(0); + expect(log).toHaveBeenCalledTimes(1); + const output = log.mock.calls[0]?.[0]; + expect(typeof output).toBe("string"); + if (typeof output !== "string") throw new Error("expected JSON output"); + const envelope = JSON.parse(output); + expect(envelope).toMatchObject({ + ok: true, + lint: { ok: true }, + runtime: { ok: true }, + layout: { ok: true }, + motion: { ok: true }, + contrast: { ok: true }, + snapshots: { enabled: false }, + _meta: { version: "test" }, + }); + }); + + it("short-circuits on lint errors without launching a browser", async () => { + const lint = lintWith( + "error", + "root_missing_composition_id", + "Root element needs data-composition-id.", + ); + const { report, browser } = await runScenario(fakeDriver(), {}, { lint }); + + expect(report.ok).toBe(false); + expect(checkExitCode(report)).toBe(1); + expect(report.lint.findings).toHaveLength(1); + expect(browser).not.toHaveBeenCalled(); + }); + + it("gates AA contrast failures and --no-contrast skips the pass", async () => { + const failingContrast = vi.fn(async (time: number) => ({ + entries: time === 0.5 ? [contrastEntry()] : [], + pngBase64: PNG_BASE64, + })); + const { report } = await runScenario(fakeDriver({ collectContrast: failingContrast })); + expect(report.ok).toBe(false); + expect(checkExitCode(report)).toBe(1); + expect(report.contrast.errorCount).toBe(1); + + const skippedContrast = vi.fn(async () => ({ + entries: [contrastEntry()], + pngBase64: PNG_BASE64, + })); + const skipped = await runScenario(fakeDriver({ collectContrast: skippedContrast }), { + contrast: false, + }); + expect(skipped.report.ok).toBe(true); + expect(checkExitCode(skipped.report)).toBe(0); + expect(skipped.report.contrast.enabled).toBe(false); + expect(skippedContrast).not.toHaveBeenCalled(); + }); + + it("includes measured colors, thresholds, and a passing palette-direction suggestion", async () => { + const { report } = await runScenario( + fakeDriver({ + collectContrast: vi.fn(async () => ({ + entries: [contrastEntry()], + pngBase64: PNG_BASE64, + })), + }), + ); + const finding = report.contrast.findings[0]; + expect(finding).toMatchObject({ + fg: "rgb(110,110,110)", + bg: "rgb(30,30,30)", + ratio: 2.5, + requiredRatio: 4.5, + }); + if (!finding) throw new Error("expected contrast finding"); + const suggested = parseColorRGBA(finding.suggestedColor); + const background = parseColorRGBA(finding.bg); + expect(suggested).not.toBeNull(); + expect(background).not.toBeNull(); + if (!suggested || !background) throw new Error("expected parseable colors"); + expect( + contrastRatio( + [suggested[0], suggested[1], suggested[2]], + [background[0], background[1], background[2]], + ), + ).toBeGreaterThanOrEqual(finding.requiredRatio); + expect(suggested[0]).toBeGreaterThan(110); + }); + + it("preserves a resolving selector, source file, identity, bbox, and sample time", async () => { + const { report } = await runScenario( + fakeDriver({ + collectLayout: vi.fn(async (time: number) => [layoutIssue("error", { time })]), + }), + ); + expect(report.layout.findings[0]).toMatchObject({ + selector: "#hero", + dataAttributes: { "data-layout-name": "hero" }, + sourceFile: "compositions/scene.html", + bbox: { x: 10, y: 20, width: 300, height: 80 }, + time: 0.5, + }); + }); + + it("reports layout and runtime errors from one browser session", async () => { + const { report, browser } = await runScenario( + fakeDriver({ + collectLayout: vi.fn(async (time: number) => [layoutIssue("error", { time })]), + }), + {}, + { runtime: [runtimeError()] }, + ); + expect(report.runtime.errorCount).toBe(1); + expect(report.layout.errorCount).toBe(1); + expect(browser).toHaveBeenCalledTimes(1); + }); + + it("reports a failing appearsBy sidecar as motion_appears_late", async () => { + const motion: MotionSpecResolution = { + kind: "valid", + path: "/project/index.motion.json", + spec: { assertions: [{ kind: "appearsBy", selector: "#hero", bySec: 0.2 }] }, + }; + const driver = fakeDriver({ + getDuration: vi.fn(async () => 1), + collectMotionFrame: vi.fn(async (time: number) => heroMotionFrame(time, (t) => t >= 0.5)), + }); + const { report } = await runScenario(driver, {}, { motion }); + + expect(report.motion.findings).toEqual([ + expect.objectContaining({ + code: "motion_appears_late", + severity: "error", + selector: "#hero", + }), + ]); + expect(report.ok).toBe(false); + }); + + it("writes cached contrast PNGs only with --snapshots at the contrast timestamps", async () => { + const writer = vi.fn( + async (_projectDir: string, index: number, time: number, _pngBase64: string) => + `snapshots/frame-${String(index).padStart(2, "0")}-at-${time.toFixed(1)}s.png`, + ); + const captured = fakeDriver({ + collectContrast: vi.fn(async () => ({ entries: [], pngBase64: PNG_BASE64 })), + }); + const { report } = await runScenario(captured, { snapshots: true }, { writeSnapshot: writer }); + + expect(report.snapshots.times).toEqual([0.5, 2.5, 4.5, 6.5, 8.5]); + expect(report.snapshots.files).toEqual([ + "snapshots/frame-00-at-0.5s.png", + "snapshots/frame-01-at-2.5s.png", + "snapshots/frame-02-at-4.5s.png", + "snapshots/frame-03-at-6.5s.png", + "snapshots/frame-04-at-8.5s.png", + ]); + expect(writer).toHaveBeenCalledTimes(5); + + const absentWriter = vi.fn(async () => "unused.png"); + await runScenario(fakeDriver(), { snapshots: false }, { writeSnapshot: absentWriter }); + expect(absentWriter).not.toHaveBeenCalled(); + }); + + it("captures finding crops for error findings with bboxes only when --snapshots is set", async () => { + const capture = vi.fn( + async ( + _project: ProjectDir, + _options: CheckOptions, + _requests: CheckFindingCropRequest[], + ) => ["snapshots/finding-00-clipped_text.png"], + ); + const { report } = await runScenario( + fakeDriver({ + collectLayout: vi.fn(async (time: number) => [layoutIssue("error", { time })]), + }), + { snapshots: true }, + { captureFindingCrops: capture }, + ); + + expect(capture).toHaveBeenCalledTimes(1); + expect(capture.mock.calls[0]?.[2]).toEqual([ + { + filename: "finding-00-clipped_text.png", + time: 0.5, + bbox: { x: 10, y: 20, width: 300, height: 80 }, + }, + ]); + expect(report.snapshots.findingFiles).toEqual(["snapshots/finding-00-clipped_text.png"]); + + const withoutSnapshots = vi.fn(async () => ["unused.png"]); + await runScenario( + fakeDriver({ + collectLayout: vi.fn(async (time: number) => [layoutIssue("error", { time })]), + }), + { snapshots: false }, + { captureFindingCrops: withoutSnapshots }, + ); + expect(withoutSnapshots).not.toHaveBeenCalled(); + + const noErrors = vi.fn(async () => ["unused.png"]); + await runScenario( + fakeDriver({ + collectLayout: vi.fn( + async (time: number) => + // container_overflow, not content_overlap: this fixture wants a plain + // warning-severity finding held across the whole run, unaffected by + // content_overlap's #U10 held-duration re-promotion to error. + [layoutIssue("warning", { time, code: "container_overflow" })], + ), + }), + { snapshots: true }, + { captureFindingCrops: noErrors }, + ); + expect(noErrors).not.toHaveBeenCalled(); + }); + + it("--strict flips a warnings-only result from exit 0 to exit 1", async () => { + const warningDriver = () => + fakeDriver({ + collectLayout: vi.fn( + async (time: number) => + // container_overflow, not content_overlap: this fixture wants a plain + // warning-severity finding held across the whole run, unaffected by + // content_overlap's #U10 held-duration re-promotion to error. + [layoutIssue("warning", { time, code: "container_overflow" })], + ), + }); + const normal = await runScenario(warningDriver(), { strict: false }); + const strict = await runScenario(warningDriver(), { strict: true }); + + expect(checkExitCode(normal.report)).toBe(0); + expect(checkExitCode(strict.report)).toBe(1); + }); + + it("fails clearly without samples when no timeline duration is available, without hanging", async () => { + const driver = fakeDriver({ getDuration: vi.fn(async () => 0) }); + await expect(runAuditGrid(driver, DEFAULT_CHECK_OPTIONS, noMotion())).rejects.toThrow( + "Could not determine composition duration — no layout samples run", + ); + await expect( + runAuditGrid( + driver, + { + ...DEFAULT_CHECK_OPTIONS, + captionZone: { x0: 0, y0: 0.8, x1: 1, y1: 1 }, + }, + noMotion(), + ), + ).rejects.toThrow("Could not determine composition duration — no layout samples run"); + + const { report, browser } = await runScenario(driver); + expect(browser).toHaveBeenCalledTimes(1); + expect(report.runtime.findings[0]?.message).toContain( + "Could not determine composition duration — no layout samples run", + ); + expect(checkExitCode(report)).toBe(1); + }); + + describe("frozen-sweep guard (#U10)", () => { + it("fails with sweep_static when a 6s composition's geometry never changes across samples", async () => { + const driver = fakeDriver({ + getDuration: vi.fn(async () => 6), + collectLayoutGeometry: vi.fn(async () => "frozen"), + }); + const { report } = await runScenario(driver); + + expect(report.ok).toBe(false); + expect( + report.layout.findings.some( + (finding) => + finding.code === "sweep_static" && + finding.severity === "error" && + finding.message.includes("did not advance"), + ), + ).toBe(true); + }); + + it("does not flag a 1.5s static title card — too short for the guard to apply", async () => { + const driver = fakeDriver({ + getDuration: vi.fn(async () => 1.5), + collectLayoutGeometry: vi.fn(async () => "frozen"), + }); + const { report } = await runScenario(driver); + + expect(report.layout.findings.some((finding) => finding.code === "sweep_static")).toBe(false); + }); + + it("does not double-report when a motion_frozen finding already covers the same symptom", async () => { + const motion: MotionSpecResolution = { + kind: "valid", + path: "/project/index.motion.json", + spec: { assertions: [{ kind: "keepsMoving" }] }, + }; + const driver = fakeDriver({ + getDuration: vi.fn(async () => 6), + collectLayoutGeometry: vi.fn(async () => "frozen"), + collectMotionFrame: vi.fn(async (time: number) => ({ + time, + data: {}, + liveness: { "*": "unchanging" }, + })), + }); + const { report } = await runScenario(driver, {}, { motion }); + + expect(report.motion.findings.some((finding) => finding.code === "motion_frozen")).toBe(true); + expect(report.layout.findings.some((finding) => finding.code === "sweep_static")).toBe(false); + }); + }); +}); + +describe("frame-check flag grammar", () => { + it("keeps bare --frame-check on defaults and parses the value form", async () => { + const { parseFrameCheck } = await import("./check.js"); + expect(parseFrameCheck(undefined)).toBeUndefined(); + expect(parseFrameCheck(true)).toEqual({}); + expect(parseFrameCheck("")).toEqual({}); + expect(parseFrameCheck("severity=error;seek=.25,.75;tol=4")).toEqual({ + severity: "error", + seek: [0.25, 0.75], + tol: 4, + }); + expect(() => parseFrameCheck("bogus=1")).toThrow("Invalid --frame-check"); + expect(() => parseFrameCheck("tol=-2")).toThrow("Invalid --frame-check"); + }); +}); + +describe("contrast persistence", () => { + it("demotes a single-sample contrast failure to warning but gates held failures", async () => { + const driver = fakeDriver({ + collectContrast: vi.fn(async (time: number) => ({ + entries: [ + // #hero fails at every sample: held, stays an error. + contrastEntry({ time, selector: "#hero" }), + // #entrance fails only at the first sample (mid-entrance): demoted. + ...(time < 1 + ? [contrastEntry({ time, selector: "#entrance", text: "Fading in" })] + : [ + contrastEntry({ + time, + selector: "#entrance", + text: "Fading in", + ratio: 8, + wcagAA: true, + }), + ]), + ], + pngBase64: PNG_BASE64, + })), + }); + const { report } = await runScenario(driver); + + const bySelector = new Map( + report.contrast.findings.map((finding) => [finding.selector, finding.severity]), + ); + expect(bySelector.get("#hero")).toBe("error"); + expect(bySelector.get("#entrance")).toBe("warning"); + expect(checkExitCode(report)).toBe(1); + }); + + it("keeps full severity when only one sample time exists", async () => { + const driver = fakeDriver({ + collectContrast: vi.fn(async (time: number) => ({ + entries: [contrastEntry({ time })], + pngBase64: PNG_BASE64, + })), + }); + const { report } = await runScenario(driver, { samples: 1, at: [2] }); + + expect(report.contrast.findings[0]?.severity).toBe("error"); + }); +}); + +describe("check report telemetry", () => { + it("reports one clean run with every gate and sampled-point count", async () => { + const motion: MotionSpecResolution = { + kind: "valid", + path: "/project/index.motion.json", + spec: { duration: 1, assertions: [{ kind: "appearsBy", selector: "#hero", bySec: 1 }] }, + }; + const driver = fakeDriver({ + getDuration: vi.fn(async () => 1), + collectMotionFrame: vi.fn(async (time: number) => heroMotionFrame(time, () => true)), + collectContrast: vi.fn(async (time: number) => ({ + entries: [contrastEntry({ time, ratio: 7, wcagAA: true })], + pngBase64: PNG_BASE64, + })), + }); + + const { report } = await runScenario( + driver, + { + samples: 1, + captionZone: { x0: 0, y0: 0.8, x1: 1, y1: 1 }, + frameCheck: {}, + snapshots: true, + }, + { motion }, + ); + + expect(trackCheckReport).toHaveBeenCalledTimes(1); + expect(trackCheckReport).toHaveBeenCalledWith( + expect.objectContaining({ + contrastGate: true, + motionGate: true, + captionZoneGate: true, + frameCheckGate: true, + snapshotsGate: true, + gridPoints: 2, + contrastPoints: 1, + ok: true, + exitCode: 0, + }), + ); + expect(report.ok).toBe(true); + }); + + it("reports one failing contrast run with its section error count", async () => { + const collectContrast = vi.fn(async (time: number) => ({ + entries: time === 0.5 ? [contrastEntry()] : [], + pngBase64: PNG_BASE64, + })); + + const { report } = await runScenario(fakeDriver({ collectContrast })); + + expect(trackCheckReport).toHaveBeenCalledTimes(1); + expect(trackCheckReport).toHaveBeenCalledWith( + expect.objectContaining({ + ok: false, + exitCode: 1, + contrastErrors: report.contrast.errorCount, + }), + ); + expect(report.contrast.errorCount).toBe(1); + }); + + it("reports zero browser samples and timings after a lint short circuit", async () => { + const lint = lintWith( + "error", + "root_missing_composition_id", + "Root element needs data-composition-id.", + ); + + const { report, browser } = await runScenario(fakeDriver(), {}, { lint }); + + expect(browser).not.toHaveBeenCalled(); + expect(trackCheckReport).toHaveBeenCalledTimes(1); + expect(trackCheckReport).toHaveBeenCalledWith( + expect.objectContaining({ + ok: false, + exitCode: 1, + gridPoints: 0, + contrastPoints: 0, + launchSettleMs: 0, + seekLoopMs: 0, + contrastMs: 0, + }), + ); + expect(report.ok).toBe(false); + }); + + it("matches report counts for mixed findings across classes", async () => { + const lint = lintWith("warning", "lint_warning", "Lint warning."); + const driver = fakeDriver({ + collectLayout: vi.fn(async () => [layoutIssue(), layoutIssue("warning")]), + collectContrast: vi.fn(async () => ({ + entries: [contrastEntry()], + pngBase64: PNG_BASE64, + })), + }); + + const { report } = await runScenario( + driver, + { samples: 1 }, + { lint, runtime: [runtimeError()] }, + ); + + expect(trackCheckReport).toHaveBeenCalledTimes(1); + expect(trackCheckReport).toHaveBeenCalledWith( + expect.objectContaining({ + lintErrors: report.lint.errorCount, + lintWarnings: report.lint.warningCount, + runtimeErrors: report.runtime.errorCount, + runtimeWarnings: report.runtime.warningCount, + layoutErrors: report.layout.errorCount, + layoutWarnings: report.layout.warningCount, + motionErrors: report.motion.errorCount, + motionWarnings: report.motion.warningCount, + contrastErrors: report.contrast.errorCount, + contrastWarnings: report.contrast.warningCount, + }), + ); + expect(report.lint.warningCount).toBe(1); + expect(report.runtime.errorCount).toBe(1); + expect(report.layout.errorCount).toBe(1); + expect(report.layout.warningCount).toBe(1); + }); + + it("measures contrast work inside the overall seek loop", async () => { + vi.spyOn(Date, "now") + .mockReturnValueOnce(100) + .mockReturnValueOnce(105) + .mockReturnValueOnce(110) + .mockReturnValueOnce(120); + + const result = await runAuditGrid( + fakeDriver(), + { ...DEFAULT_CHECK_OPTIONS, samples: 1 }, + noMotion(), + ); + + expect(result.timings).toEqual({ launchSettleMs: 0, seekLoopMs: 20, contrastMs: 5 }); + }); +}); + +describe("contrast candidate round-trip", () => { + it("passes the browser script's raw candidates back to finish, never the normalized copies", () => { + const source = checkBrowserSource(); + + // __contrastAuditFinish samples pixels via the page script's own bbox + // shape ({x, y, w, h}); sending the Node-normalized candidate + // ({width, height}) makes every sample rect NaN and the audit silently + // reports zero checked elements. The raw object must round-trip verbatim. + expect(source).toMatch(/prepared\.map\(\(entry\) => entry\.raw\)/); + expect(source).toMatch(/raw: unknown;/); + expect(source).not.toMatch(/prepared\.map\(\(entry\) => entry\.candidate\)/); + }); +}); diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts new file mode 100644 index 000000000..a0adf3767 --- /dev/null +++ b/packages/cli/src/commands/check.ts @@ -0,0 +1,409 @@ +import { defineCommand } from "citty"; +import type { Example } from "./_examples.js"; +import { parseAt } from "./layout.js"; +import { c } from "../ui/colors.js"; +import { normalizeErrorMessage } from "../utils/errorMessage.js"; +import { formatLayoutIssue } from "../utils/layoutAudit.js"; +import { resolveProject, type ProjectDir } from "../utils/project.js"; +import { withMeta } from "../utils/updateCheck.js"; +import { + DEFAULT_CHECK_OPTIONS, + checkExitCode, + runCheckPipeline, + type CheckFinding, + type CheckOptions, + type CheckReport, + type CheckSection, +} from "../utils/checkPipeline.js"; +import type { CaptionZoneOptions, FrameCheckOptions } from "../utils/checkTypes.js"; + +export const examples: Example[] = [ + ["Run the full verification gate", "hyperframes check"], + ["Output one agent-readable envelope", "hyperframes check --json"], + ["Persist the five audited contrast frames", "hyperframes check --snapshots"], + ["Also fail on warnings", "hyperframes check --strict"], +]; + +export interface CheckCommandDependencies { + resolveProject(dir: string | undefined): ProjectDir; + runPipeline(project: ProjectDir, options: CheckOptions): Promise; + withMeta(value: object): object; +} + +const DEFAULT_COMMAND_DEPENDENCIES: CheckCommandDependencies = { + resolveProject, + runPipeline: runCheckPipeline, + withMeta, +}; + +export function createCheckCommand( + dependencies: CheckCommandDependencies = DEFAULT_COMMAND_DEPENDENCIES, +) { + return defineCommand({ + meta: { + name: "check", + description: + "Run lint, runtime, layout, motion, and WCAG contrast verification in one browser session", + }, + args: { + dir: { type: "positional", description: "Project directory", required: false }, + json: { type: "boolean", description: "Output agent-readable JSON", default: false }, + samples: { + type: "string", + description: "Number of midpoint samples across the duration (default: 9)", + default: "9", + }, + at: { + type: "string", + description: "Comma-separated timestamps in seconds (e.g., --at 1.5,4,7.25)", + }, + "at-transitions": { + type: "boolean", + description: + "Also sample at every tween start/end boundary (plus segment midpoints) to catch transient overlaps at transition seams", + default: false, + }, + "max-transition-samples": { + type: "string", + description: + "Optional cap on transition-derived samples; when it truncates, the omitted count is reported (default: unlimited)", + }, + "max-issues": { + type: "string", + description: "Maximum issues to print or return after static collapse (default: 80)", + default: "80", + }, + "collapse-static": { + type: "boolean", + description: "Collapse repeated static issues across samples (default: true)", + default: true, + }, + tolerance: { + type: "string", + description: "Allowed pixel overflow before reporting an issue (default: 2)", + default: "2", + }, + timeout: { + type: "string", + description: "Ms to wait for scripts and media to settle initially (default: 3000)", + default: "3000", + }, + contrast: { + type: "boolean", + description: "Run the WCAG AA contrast pass (enabled by default)", + default: true, + }, + strict: { + type: "boolean", + description: "Exit non-zero on warnings too", + default: false, + }, + snapshots: { + type: "boolean", + description: "Save the five contrast-pass PNGs under snapshots/", + default: false, + }, + "caption-zone": { + type: "string", + description: + 'Caption band "x0=0;y0=.82;x1=1;y1=1[;severity=warning|error][;seek=.5,1]" (fractions 0-1; defaults: warning, seek=1)', + }, + "frame-check": { + type: "string", + description: + 'Bare --frame-check uses defaults (tol=2px, severity=warning, seek=.5; breach floor=max(120px, 6% of shorter canvas edge)); or pass "severity=error;seek=.25,.75;tol=4" to tune', + }, + }, + async run({ args }) { + const asJson = args.json === true; + + try { + const project = dependencies.resolveProject(args.dir); + const options = parseCheckOptions(args); + if (!asJson) { + console.log(`${c.accent("◆")} Checking ${c.accent(project.name)}`); + } + const report = await dependencies.runPipeline(project, options); + if (asJson) { + console.log(JSON.stringify(dependencies.withMeta(report), null, 2)); + } else { + printHumanReport(report); + } + process.exitCode = checkExitCode(report); + } catch (error) { + const message = normalizeErrorMessage(error); + if (asJson) { + console.log( + JSON.stringify(dependencies.withMeta({ ok: false, error: message }), null, 2), + ); + } else { + console.error(`${c.error("✗")} Check failed: ${message}`); + } + process.exitCode = 1; + } + }, + }); +} + +function parseCheckOptions(args: Record): CheckOptions { + const maxTransitionSamples = positiveInteger(args["max-transition-samples"], 0); + return { + samples: positiveInteger(args.samples, DEFAULT_CHECK_OPTIONS.samples), + at: parseAt(args.at), + atTransitions: args["at-transitions"] === true, + maxTransitionSamples: maxTransitionSamples > 0 ? maxTransitionSamples : undefined, + maxIssues: positiveInteger(args["max-issues"], DEFAULT_CHECK_OPTIONS.maxIssues), + collapseStatic: args["collapse-static"] !== false, + tolerance: nonNegativeNumber(args.tolerance, DEFAULT_CHECK_OPTIONS.tolerance), + timeout: Math.max(500, positiveInteger(args.timeout, DEFAULT_CHECK_OPTIONS.timeout)), + contrast: args.contrast !== false, + strict: args.strict === true, + snapshots: args.snapshots === true, + captionZone: parseCaptionZone(args["caption-zone"]), + frameCheck: parseFrameCheck(args["frame-check"]), + }; +} + +const CAPTION_ZONE_FIELDS = new Set(["x0", "y0", "x1", "y1", "severity", "seek"]); + +const FRAME_CHECK_FIELDS = new Set(["severity", "seek", "tol"]); + +// Mirrors --caption-zone's spec grammar so the EF bridge's severity/seek/tol +// options survive the migration instead of being silently dropped by a +// boolean flag (bare --frame-check keeps today's defaults). +export function parseFrameCheck(value: unknown): FrameCheckOptions | undefined { + if (value === undefined || value === null || value === false) return undefined; + if (value === true || value === "") return {}; + if (typeof value !== "string") throw frameCheckError(); + const fields = parseFrameCheckFields(value); + const severity = captionSeverity(fields.get("severity")); + const seek = captionSeeks(fields.get("seek")); + const tol = parseFrameCheckTolerance(fields.get("tol")); + return { + ...(severity ? { severity } : {}), + ...(seek ? { seek } : {}), + ...(tol !== undefined ? { tol } : {}), + }; +} + +function parseFrameCheckFields(value: string): Map { + const fields = new Map(); + for (const part of value.split(";")) { + const { key, entry } = parseCaptionField(part); + if (!FRAME_CHECK_FIELDS.has(key) || fields.has(key)) throw frameCheckError(); + fields.set(key, entry); + } + return fields; +} + +function parseFrameCheckTolerance(raw: string | undefined): number | undefined { + if (raw === undefined) return undefined; + const tol = Number.parseFloat(raw); + if (!Number.isFinite(tol) || tol < 0) throw frameCheckError(); + return tol; +} + +function frameCheckError(): Error { + return new Error( + 'Invalid --frame-check: use bare --frame-check or "severity=warning|error;seek=.25,.75;tol=4" (all fields optional)', + ); +} + +function parseCaptionZone(value: unknown): CaptionZoneOptions | undefined { + if (value === undefined || value === null) return undefined; + const fields = parseCaptionFields(captionZoneString(value)); + const { x0, y0, x1, y1 } = parseCaptionBounds(fields); + const severity = captionSeverity(fields.get("severity")); + const seek = captionSeeks(fields.get("seek")); + return { + x0, + y0, + x1, + y1, + ...(severity ? { severity } : {}), + ...(seek ? { seek } : {}), + }; +} + +function captionZoneString(value: unknown): string { + if (typeof value !== "string" || value.trim() === "") throw captionZoneError(); + return value; +} + +function parseCaptionFields(value: string): Map { + const fields = new Map(); + for (const part of value.split(";")) { + const { key, entry } = parseCaptionField(part); + if (!CAPTION_ZONE_FIELDS.has(key) || fields.has(key)) throw captionZoneError(); + fields.set(key, entry); + } + return fields; +} + +function parseCaptionField(part: string): { key: string; entry: string } { + const separator = part.indexOf("="); + if (separator <= 0) throw captionZoneError(); + return { + key: part.slice(0, separator).trim(), + entry: part.slice(separator + 1).trim(), + }; +} + +function parseCaptionBounds(fields: Map): { + x0: number; + y0: number; + x1: number; + y1: number; +} { + const x0 = requiredCaptionFraction(fields, "x0"); + const y0 = requiredCaptionFraction(fields, "y0"); + const x1 = requiredCaptionFraction(fields, "x1"); + const y1 = requiredCaptionFraction(fields, "y1"); + if (x0 > x1 || y0 > y1) throw captionZoneError(); + return { x0, y0, x1, y1 }; +} + +function requiredCaptionFraction(fields: Map, key: string): number { + const value = captionFraction(fields.get(key)); + if (value === null) throw captionZoneError(); + return value; +} + +function captionFraction(value: string | undefined): number | null { + if (value === undefined || value === "") return null; + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 ? parsed : null; +} + +function captionSeverity(value: string | undefined): "error" | "warning" | undefined { + if (value === undefined) return undefined; + if (value === "error" || value === "warning") return value; + throw captionZoneError(); +} + +function captionSeeks(value: string | undefined): number[] | undefined { + if (value === undefined) return undefined; + if (value === "") return []; + const values = value.split(",").map(captionFraction); + if (values.some((entry) => entry === null)) throw captionZoneError(); + return values.flatMap((entry) => (entry === null ? [] : entry)); +} + +function captionZoneError(): Error { + return new Error( + 'Invalid --caption-zone; use "x0=0;y0=.82;x1=1;y1=1[;severity=warning|error][;seek=.5,1]" with fractions from 0 to 1.', + ); +} + +function positiveInteger(value: unknown, fallback: number): number { + const parsed = parseInt(String(value ?? ""), 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function nonNegativeNumber(value: unknown, fallback: number): number { + const parsed = parseFloat(String(value ?? "")); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; +} + +function printHumanReport(report: CheckReport): void { + printSection("Lint", report.lint); + printSection("Runtime", report.runtime); + printLayoutSection("Layout", report.layout); + printSection("Motion", report.motion); + printContrastSection(report); + printSnapshotSection(report); + console.log(); + const label = report.ok ? c.success("Check passed") : c.error("Check failed"); + console.log(`${report.ok ? c.success("◇") : c.error("◇")} ${label}`); +} + +function printSection(title: string, section: CheckSection): void { + console.log(); + console.log(c.bold(title)); + if (section.findings.length === 0) { + console.log(` ${c.success("◇")} 0 errors, 0 warnings`); + return; + } + for (const finding of section.findings) printFinding(finding); + printCounts(section); +} + +function printLayoutSection(title: string, section: CheckReport["layout"]): void { + console.log(); + console.log(c.bold(title)); + if (section.findings.length === 0) { + console.log(` ${c.success("◇")} 0 issues across ${section.samples.length} sample(s)`); + } else { + for (const finding of section.findings) { + const formatted = formatLayoutIssue(finding).replace(/\n/g, "\n "); + console.log(` ${findingIcon(finding)} ${formatted}`); + } + printCounts(section); + } + if (section.transitionSamplesDropped > 0) { + console.log( + ` ${c.warn("⚠")} ${section.transitionSamplesDropped} transition sample(s) omitted`, + ); + } +} + +function printContrastSection(report: CheckReport): void { + const section = report.contrast; + console.log(); + console.log(c.bold("Contrast")); + if (!section.enabled) { + console.log(` ${c.dim("◇")} skipped`); + return; + } + if (section.findings.length === 0) { + console.log( + ` ${c.success("◇")} ${section.passed}/${section.checked} text checks pass WCAG AA`, + ); + return; + } + for (const finding of section.findings) { + console.log( + ` ${c.error("✗")} ${finding.selector} ${finding.ratio}:1 (need ${finding.requiredRatio}:1, t=${finding.time}s)`, + ); + console.log(` ${c.dim(`Try ${finding.suggestedColor}; source ${finding.sourceFile}`)}`); + } + printCounts(section); +} + +function printSnapshotSection(report: CheckReport): void { + console.log(); + console.log(c.bold("Snapshots")); + if (!report.snapshots.enabled) { + console.log(` ${c.dim("◇")} disabled`); + } else { + console.log(` ${c.success("◇")} ${report.snapshots.files.length} PNG(s) saved`); + for (const file of report.snapshots.files) console.log(` ${c.dim(file)}`); + if (report.snapshots.findingFiles.length > 0) { + console.log( + ` ${c.success("◇")} ${report.snapshots.findingFiles.length} finding crop(s) saved`, + ); + for (const file of report.snapshots.findingFiles) console.log(` ${c.dim(file)}`); + } + } +} + +function printFinding(finding: CheckFinding): void { + const where = `${finding.sourceFile} ${finding.selector} t=${finding.time}s`; + console.log(` ${findingIcon(finding)} ${finding.code}: ${finding.message}`); + console.log(` ${c.dim(where)}`); + if (finding.fixHint) console.log(` ${c.dim(`Fix: ${finding.fixHint}`)}`); +} + +function findingIcon(finding: CheckFinding): string { + if (finding.severity === "error") return c.error("✗"); + if (finding.severity === "warning") return c.warn("⚠"); + return c.dim("ℹ"); +} + +function printCounts(section: CheckSection): void { + console.log( + ` ${c.dim(`${section.errorCount} error(s), ${section.warningCount} warning(s), ${section.infoCount} info(s)`)}`, + ); +} + +export default createCheckCommand(); diff --git a/packages/cli/src/commands/contrast-audit.browser.js b/packages/cli/src/commands/contrast-audit.browser.js index f89478e50..0aa7efe50 100644 --- a/packages/cli/src/commands/contrast-audit.browser.js +++ b/packages/cli/src/commands/contrast-audit.browser.js @@ -158,6 +158,22 @@ window.__contrastAuditPrepare = function () { } if (!hasText) continue; + // Same decorative opt-out the layout audit honors: text marked (or inside) + // data-layout-ignore is set dressing, not copy a viewer must read — + // deliberately dim rail labels, ghost typography, texture text. + if (el.closest && el.closest("[data-layout-ignore]")) continue; + + // Text that has (nearly) left the canvas — a cursor exiting the frame, an + // element parked off-screen — is not readable content, and sampling its + // clamped edge reads whatever pixels happen to sit at the border (the + // classic false "white-on-white"). Require a minimally-visible on-canvas + // intersection before judging contrast; the layout audit separately owns + // off-canvas detection as its own finding class. + var vis = el.getBoundingClientRect(); + var onX = Math.min(vis.right, window.innerWidth) - Math.max(vis.left, 0); + var onY = Math.min(vis.bottom, window.innerHeight) - Math.max(vis.top, 0); + if (onX < 8 || onY < 8) continue; + var cs = getComputedStyle(el); if (cs.visibility === "hidden" || cs.display === "none") continue; if (parseFloat(cs.opacity) <= 0.01) continue; diff --git a/packages/cli/src/commands/contrast-bg.test.ts b/packages/cli/src/commands/contrast-bg.test.ts index 8a58b3079..fd1dddb85 100644 --- a/packages/cli/src/commands/contrast-bg.test.ts +++ b/packages/cli/src/commands/contrast-bg.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vitest"; -import { parseColorRGBA, pickOpaqueBackground } from "./contrast-bg.js"; +import { + contrastRatio, + parseColorRGBA, + pickOpaqueBackground, + relativeLuminance, + requiredContrastRatio, + suggestCompliantForegroundColor, +} from "./contrast-bg.js"; const opaque = (bg: string) => ({ backgroundColor: bg, backgroundImage: "none" }); @@ -53,3 +60,51 @@ describe("pickOpaqueBackground", () => { expect(pickOpaqueBackground([opaque("rgba(0, 0, 0, 0)")])).toBeNull(); }); }); + +describe("relativeLuminance", () => { + it("uses the WCAG sRGB transfer function", () => { + expect(relativeLuminance([0, 0, 0])).toBe(0); + expect(relativeLuminance([255, 255, 255])).toBe(1); + expect(relativeLuminance([255, 0, 0])).toBeCloseTo(0.2126, 4); + }); +}); + +describe("contrastRatio", () => { + it("is symmetric and reaches 21:1 for black and white", () => { + expect(contrastRatio([0, 0, 0], [255, 255, 255])).toBe(21); + expect(contrastRatio([255, 255, 255], [0, 0, 0])).toBe(21); + }); +}); + +describe("requiredContrastRatio", () => { + it("requires 3:1 for large text and 4.5:1 otherwise", () => { + expect(requiredContrastRatio(true)).toBe(3); + expect(requiredContrastRatio(false)).toBe(4.5); + }); +}); + +describe("suggestCompliantForegroundColor", () => { + it("brightens a failing foreground on a dark background until it passes", () => { + const background: [number, number, number] = [20, 20, 20]; + const foreground: [number, number, number] = [80, 80, 80]; + const suggested = suggestCompliantForegroundColor(foreground, background, 4.5); + + expect(suggested[0]).toBeGreaterThan(foreground[0]); + expect(contrastRatio(suggested, background)).toBeGreaterThanOrEqual(4.5); + }); + + it("darkens a failing foreground on a light background until it passes", () => { + const background: [number, number, number] = [245, 245, 245]; + const foreground: [number, number, number] = [180, 180, 180]; + const suggested = suggestCompliantForegroundColor(foreground, background, 4.5); + + expect(suggested[0]).toBeLessThan(foreground[0]); + expect(contrastRatio(suggested, background)).toBeGreaterThanOrEqual(4.5); + }); + + it("preserves a foreground that already passes", () => { + expect(suggestCompliantForegroundColor([255, 255, 255], [0, 0, 0], 4.5)).toEqual([ + 255, 255, 255, + ]); + }); +}); diff --git a/packages/cli/src/commands/contrast-bg.ts b/packages/cli/src/commands/contrast-bg.ts index fa221a2d9..778a9e64a 100644 --- a/packages/cli/src/commands/contrast-bg.ts +++ b/packages/cli/src/commands/contrast-bg.ts @@ -20,6 +20,59 @@ export type Rgb = [number, number, number]; export type Rgba = [number, number, number, number]; +/** WCAG relative luminance for an sRGB color. Mirrors contrast-audit.browser.js. */ +export function relativeLuminance(color: Rgb): number { + const channel = (value: number) => { + const srgb = value / 255; + return srgb <= 0.03928 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4; + }; + + return 0.2126 * channel(color[0]) + 0.7152 * channel(color[1]) + 0.0722 * channel(color[2]); +} + +/** WCAG contrast ratio between two opaque sRGB colors. */ +export function contrastRatio(first: Rgb, second: Rgb): number { + const firstLuminance = relativeLuminance(first); + const secondLuminance = relativeLuminance(second); + const lighter = Math.max(firstLuminance, secondLuminance); + const darker = Math.min(firstLuminance, secondLuminance); + return (lighter + 0.05) / (darker + 0.05); +} + +/** WCAG AA minimum contrast for body or large text. */ +export function requiredContrastRatio(large: boolean): number { + return large ? 3 : 4.5; +} + +/** + * Find the nearest passing foreground on the line toward the higher-contrast + * pole: white for a dark background, black for a light background. + */ +export function suggestCompliantForegroundColor( + foreground: Rgb, + background: Rgb, + requiredRatio: number, +): Rgb { + if (contrastRatio(foreground, background) >= requiredRatio) return [...foreground]; + + const black: Rgb = [0, 0, 0]; + const white: Rgb = [255, 255, 255]; + const target = + contrastRatio(white, background) >= contrastRatio(black, background) ? white : black; + + for (let step = 1; step <= 255; step += 1) { + const amount = step / 255; + const candidate: Rgb = [ + Math.round(foreground[0] + (target[0] - foreground[0]) * amount), + Math.round(foreground[1] + (target[1] - foreground[1]) * amount), + Math.round(foreground[2] + (target[2] - foreground[2]) * amount), + ]; + if (contrastRatio(candidate, background) >= requiredRatio) return candidate; + } + + return [...target]; +} + /** Parse a CSS `rgb()`/`rgba()` string. Returns null if it is not rgb(a). */ export function parseColorRGBA(color: string | null | undefined): Rgba | null { const body = /rgba?\(([^)]+)\)/.exec(color ?? "")?.[1]; diff --git a/packages/cli/src/commands/deprecationTestHarness.ts b/packages/cli/src/commands/deprecationTestHarness.ts new file mode 100644 index 000000000..bce26290d --- /dev/null +++ b/packages/cli/src/commands/deprecationTestHarness.ts @@ -0,0 +1,115 @@ +// Shared scaffolding for the U5 deprecation tests in inspect.test.ts, +// layout.test.ts, and validate.test.ts: those commands all fail fast (via a +// mocked dynamic import) so the tests can assert the shared deprecation +// envelope (stderr notice, JSON `_meta.deprecated`) without needing a real +// project or headless Chrome. +// +// vi.mock factories are hoisted above imports, so each test file keeps its +// own thin `vi.mock("", () => someFactory())` call (mocking a module +// path can't itself be shared across files) but delegates the factory body +// here. +import type { ArgsDef, CommandDef } from "citty"; +import { runCommand } from "citty"; +import { expect, vi } from "vitest"; + +const FAKE_PROJECT = { + dir: "/fake-project", + name: "fake-project", + indexPath: "/fake-project/index.html", +}; + +export function resolveProjectMock() { + return { resolveProject: vi.fn(() => FAKE_PROJECT) }; +} + +export function bundleToSingleHtmlFailureMock() { + return { + bundleToSingleHtml: vi.fn(async () => { + throw new Error("bundling failed (test double)"); + }), + }; +} + +export function lintProjectFailureMock() { + return { + lintProject: vi.fn(async () => { + throw new Error("lint failed (test double)"); + }), + }; +} + +/** + * citty's `meta` is `Resolvable` (object | promise | thunk). + * These test files always define it as a synchronous object literal, so + * narrow to that shape instead of asserting it with `as`. + */ +export function metaDescription(command: CommandDef): string { + const meta = command.meta; + if (meta && typeof meta === "object" && "description" in meta) { + return String(meta.description ?? ""); + } + throw new Error("expected a synchronous meta object"); +} + +/** + * Run a command with stdout/stderr writes captured (and process.exit / + * console.log stubbed so the run stays silent and non-terminating), and + * return the captured text for the caller to assert on. + */ +export async function runAndCaptureStdio( + command: CommandDef, + rawArgs: string[] = ["--json"], +): Promise<{ stderrText: string; stdoutText: string }> { + const stderrWrites: string[] = []; + const stdoutWrites: string[] = []; + vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => { + stderrWrites.push(String(chunk)); + return true; + }); + vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => { + stdoutWrites.push(String(chunk)); + return true; + }); + vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + vi.spyOn(console, "log").mockImplementation(() => {}); + + await runCommand(command, { rawArgs }); + + return { stderrText: stderrWrites.join(""), stdoutText: stdoutWrites.join("") }; +} + +/** + * Run a command with process.exit stubbed and console.log spied, returning + * the first console.log call that looks like a JSON object (the `--json` + * failure envelope). Callers assert on definedness/shape themselves, since + * that differs slightly per call site. + */ +export async function runAndFindJsonLogCall( + command: CommandDef, + rawArgs: string[] = ["--json"], +): Promise { + vi.spyOn(process.stderr, "write").mockImplementation(() => true); + vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + await runCommand(command, { rawArgs }); + + return logSpy.mock.calls.find(([arg]) => typeof arg === "string" && arg.trim().startsWith("{")); +} + +/** + * Convenience wrapper: parse the JSON envelope found by runAndFindJsonLogCall. + * `parsed` is intentionally left as JSON.parse's inferred `any` (matching + * every call site's prior inline `JSON.parse(...)` usage) rather than + * annotated `unknown`, since callers assert directly into its shape + * (`.ok`, `._meta.deprecated`) the same way the original inline tests did. + */ +export async function runAndParseJsonEnvelope( + command: CommandDef, + rawArgs: string[] = ["--json"], +) { + const jsonCall = await runAndFindJsonLogCall(command, rawArgs); + expect(jsonCall).toBeDefined(); + const parsed = JSON.parse(String(jsonCall?.[0])); + return { jsonCall, parsed }; +} diff --git a/packages/cli/src/commands/figma/asset.test.ts b/packages/cli/src/commands/figma/asset.test.ts index c3e25a340..6e0e1d44d 100644 --- a/packages/cli/src/commands/figma/asset.test.ts +++ b/packages/cli/src/commands/figma/asset.test.ts @@ -3,7 +3,12 @@ import { describe, expect, it, afterEach } from "vitest"; import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { runAssetImport, type AssetImportDeps } from "./asset.js"; +import { + gatherAssetRefs, + runAssetImport, + runAssetImportMany, + type AssetImportDeps, +} from "./asset.js"; import type { FigmaClient } from "@hyperframes/core/figma"; const dirs: string[] = []; @@ -17,8 +22,9 @@ afterEach(() => { }); function fakeClient(overrides: Partial = {}): FigmaClient { - return { + const client: FigmaClient = { renderNode: () => Promise.resolve({ url: "https://cdn.example/a", ext: "png" }), + renderNodes: () => Promise.resolve([]), imageFills: () => Promise.resolve(new Map()), variables: () => Promise.resolve({ variables: {}, variableCollections: {} }), styles: () => Promise.resolve([]), @@ -26,6 +32,19 @@ function fakeClient(overrides: Partial = {}): FigmaClient { fileVersion: () => Promise.resolve({ version: "7", lastModified: "2026-07-01" }), ...overrides, }; + // Default renderNodes delegates to renderNode (honoring any override) so + // existing single-node tests keep controlling behavior via renderNode. + if (!overrides.renderNodes) { + client.renderNodes = (fileKey, nodeIds, opts) => + Promise.all( + nodeIds.map((nodeId) => + client + .renderNode({ fileKey, nodeId }, opts) + .then((r) => ({ nodeId, url: r.url, ext: r.ext })), + ), + ); + } + return client; } const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); @@ -134,6 +153,51 @@ describe("runAssetImport", () => { expect(index).not.toContain("image_002"); }); + it("batches many nodes into ONE renderNodes call and freezes each", async () => { + const dir = scratch(); + let renderNodesCalls = 0; + let batchSize = 0; + const batchClient = fakeClient({ + renderNodes: (fileKey, nodeIds, opts) => { + renderNodesCalls += 1; + batchSize = nodeIds.length; + return Promise.resolve( + nodeIds.map((nodeId) => ({ nodeId, url: `https://cdn/${nodeId}`, ext: opts.format })), + ); + }, + }); + const results = await runAssetImportMany( + ["KEY:1-2", "KEY:3-4", "KEY:5-6"], + { format: "png" }, + deps(dir, { client: batchClient }), + ); + expect(results).toHaveLength(3); + expect(results.every((r) => !r.reused)).toBe(true); + expect(renderNodesCalls).toBe(1); // one REST call for all three + expect(batchSize).toBe(3); + // distinct frozen files, all recorded + expect(new Set(results.map((r) => r.record.id)).size).toBe(3); + }); + + it("gatherAssetRefs splits bare comma-joined ids but keeps URLs whole", () => { + // bare tokens comma-split + expect(gatherAssetRefs(["KEY:1-2,KEY:3-4"])).toEqual(["KEY:1-2", "KEY:3-4"]); + // space-separated positionals preserved + expect(gatherAssetRefs(["KEY:1-2", "KEY:3-4"])).toEqual(["KEY:1-2", "KEY:3-4"]); + // a URL with a comma in its query is NOT torn apart + const url = "https://www.figma.com/design/KEY/F?node-id=1:2,3:4"; + expect(gatherAssetRefs([url])).toEqual([url]); + // mixed: URL stays whole, bare token splits + expect(gatherAssetRefs([url, "KEY:5-6,KEY:7-8"])).toEqual([url, "KEY:5-6", "KEY:7-8"]); + }); + + it("splits comma-joined refs and rejects a cross-file batch", async () => { + const dir = scratch(); + await expect( + runAssetImportMany(["KEY:1-2", "OTHER:3-4"], { format: "png" }, deps(dir)), + ).rejects.toThrow(/share a fileKey/); + }); + it("reuses against ANY matching tuple, not just the oldest row", async () => { const dir = scratch(); await runAssetImport("KEY:1-2", { format: "svg" }, deps(dir)); // image_001 (svg) diff --git a/packages/cli/src/commands/figma/asset.ts b/packages/cli/src/commands/figma/asset.ts index 261dda459..d1a307ea3 100644 --- a/packages/cli/src/commands/figma/asset.ts +++ b/packages/cli/src/commands/figma/asset.ts @@ -9,6 +9,7 @@ import { appendRecord, buildAssetSnippet, createFigmaClient, + FigmaClientError, findAllByFigmaNode, freezeBytes, nextId, @@ -49,56 +50,82 @@ export interface AssetImportResult { reused: boolean; } -export async function runAssetImport( - refInput: string, - opts: AssetImportOptions, - deps: AssetImportDeps, -): Promise { +/** + * Flatten CLI positionals into asset refs. Comma-splits bare + * `fileKey:nodeId` tokens (so `asset A,B` batches) but leaves URL tokens + * whole — a figma URL can carry commas in its query (multi-select + * `node-id=1:2,3:4`), and splitting those would tear the URL apart. To batch + * URLs, pass them as separate positional args. + */ +export function gatherAssetRefs(positionals: string[]): string[] { + return positionals + .flatMap((r) => (/^https?:/i.test(r.trim()) ? [r] : r.split(","))) + .map((r) => r.trim()) + .filter((r) => r.length > 0); +} + +function requireNodeRef(refInput: string): { fileKey: string; nodeId: string } { const ref = parseFigmaRef(refInput); if (!ref.nodeId) throw new Error( `ref "${refInput}" has no node id — share a link with ?node-id=… or use fileKey:nodeId`, ); + return { fileKey: ref.fileKey, nodeId: ref.nodeId }; +} - const { version } = await deps.client.fileVersion(ref.fileKey); - const description = normalizeMeta(opts.description); - const entity = normalizeMeta(opts.entity); - - // Cache key per spec §5: fileKey:nodeId:format:scale:version → reuse. - // Check EVERY row for the node (a node can legitimately have several - // format/scale/version tuples — the oldest-row shortcut minted duplicates - // forever once a second tuple existed). Unspecified scale is canonically 1 - // on both sides (figma's default). Reuse also requires the frozen file to - // still exist — a deleted file falls through to re-import. - const existing = findAllByFigmaNode(deps.projectDir, ref.fileKey, ref.nodeId).find( +/** Cache hit per spec §5 (fileKey:nodeId:format:scale:version). Check EVERY + * row for the node — a node can carry several format/scale/version tuples, + * and the oldest-row shortcut minted duplicates forever. Reuse requires the + * frozen file to still exist; a deleted file falls through to re-import. + * Metadata supplied on a re-import upserts rather than being discarded. */ +function reuseExisting( + fileKey: string, + nodeId: string, + opts: AssetImportOptions, + version: string, + deps: AssetImportDeps, + description: string | undefined, + entity: string | undefined, +): AssetImportResult | null { + const existing = findAllByFigmaNode(deps.projectDir, fileKey, nodeId).find( (r) => r.provenance.format === opts.format && (r.provenance.scale ?? 1) === (opts.scale ?? 1) && r.provenance.version === version && existsSync(join(deps.projectDir, r.path)), ); - if (existing) { - // Metadata supplied on a re-import still lands: upsert the row instead - // of silently discarding the flags. - let record = existing; - if ( - (description !== undefined && description !== existing.description) || - (entity !== undefined && entity !== existing.entity) - ) { - record = { - ...existing, - ...(description !== undefined && { description }), - ...(entity !== undefined && { entity }), - }; - updateRecord(deps.projectDir, record); - } - safeRegenerateIndex(deps.projectDir); - return { record, snippet: buildAssetSnippet(record), reused: true }; + if (!existing) return null; + let record = existing; + if ( + (description !== undefined && description !== existing.description) || + (entity !== undefined && entity !== existing.entity) + ) { + record = { + ...existing, + ...(description !== undefined && { description }), + ...(entity !== undefined && { entity }), + }; + updateRecord(deps.projectDir, record); } + return { record, snippet: buildAssetSnippet(record), reused: true }; +} - const rendered = await deps.client.renderNode(ref, opts); - let bytes = await deps.download(rendered.url); - if (rendered.ext === "svg") { +/** Freeze a rendered node's bytes and record it. Does NOT regenerate index.md + * — the caller does that once (batch imports would otherwise rewrite it N + * times). */ +async function freezeAndRecord( + fileKey: string, + nodeId: string, + url: string, + ext: FigmaAssetFormat, + opts: AssetImportOptions, + version: string, + deps: AssetImportDeps, + description: string | undefined, + entity: string | undefined, +): Promise { + let bytes = await deps.download(url); + if (ext === "svg") { // Sniff before decoding: an SVG starts with '<' or an XML decl/BOM. A // non-text payload would decode to U+FFFD soup and still write to disk. const b0 = bytes[0]; @@ -106,32 +133,108 @@ export async function runAssetImport( throw new Error("figma render returned non-SVG bytes for an svg export — retry the import"); bytes = new TextEncoder().encode(sanitizeSvg(new TextDecoder().decode(bytes))); } - const id = nextId(deps.projectDir, "image"); - const destAbs = join(typeDirPath(deps.projectDir, "image"), `${id}.${rendered.ext}`); + const destAbs = join(typeDirPath(deps.projectDir, "image"), `${id}.${ext}`); freezeBytes(bytes, destAbs); - const record: FigmaManifestRecord = { id, type: "image", path: relative(deps.projectDir, destAbs), - source: `figma:${ref.fileKey}/${ref.nodeId}`, + source: `figma:${fileKey}/${nodeId}`, ...(description !== undefined && { description }), ...(entity !== undefined && { entity }), provenance: { source: "figma", - fileKey: ref.fileKey, - nodeId: ref.nodeId, + fileKey, + nodeId, version, format: opts.format, scale: opts.scale, }, }; appendRecord(deps.projectDir, record); - safeRegenerateIndex(deps.projectDir); return { record, snippet: buildAssetSnippet(record), reused: false }; } +export async function runAssetImport( + refInput: string, + opts: AssetImportOptions, + deps: AssetImportDeps, +): Promise { + const [result] = await runAssetImportMany([refInput], opts, deps); + if (!result) throw new Error(`figma asset import produced no result for "${refInput}"`); + return result; +} + +/** + * Import many nodes of ONE figma file. Cache-checks each, renders the misses + * in a SINGLE /v1/images batch call (figma's documented rate-limit + * workaround — N nodes, one REST request), freezes each, and regenerates + * index.md once. Results come back in input order. + */ +export async function runAssetImportMany( + refInputs: string[], + opts: AssetImportOptions, + deps: AssetImportDeps, +): Promise { + if (refInputs.length === 0) return []; + const refs = refInputs.map(requireNodeRef); + const fileKey = refs[0]!.fileKey; + const mixed = refs.find((r) => r.fileKey !== fileKey); + if (mixed) + throw new Error( + `all refs in one import must share a fileKey (batch is per-file) — got ${fileKey} and ${mixed.fileKey}; run separate commands per file`, + ); + + const { version } = await deps.client.fileVersion(fileKey); + const description = normalizeMeta(opts.description); + const entity = normalizeMeta(opts.entity); + + // Resolve cache hits first; batch-render only the misses. + const slots: (AssetImportResult | null)[] = refs.map((r) => + reuseExisting(fileKey, r.nodeId, opts, version, deps, description, entity), + ); + const missIndexes = slots.flatMap((s, i) => (s === null ? [i] : [])); + try { + if (missIndexes.length > 0) { + const missNodeIds = missIndexes.map((i) => refs[i]!.nodeId); + const rendered = await deps.client.renderNodes(fileKey, missNodeIds, opts); + const byNode = new Map(rendered.map((r) => [r.nodeId, r] as const)); + for (const i of missIndexes) { + const nodeId = refs[i]!.nodeId; + const r = byNode.get(nodeId); + // Keep the typed code: component import's rasterize fallback skips on + // RENDER_FAILED, so a plain Error here would abort the whole import. + if (!r || r.url === null) + throw new FigmaClientError( + "RENDER_FAILED", + `figma could not render node ${nodeId} as ${opts.format}`, + ); + slots[i] = await freezeAndRecord( + fileKey, + nodeId, + r.url, + r.ext, + opts, + version, + deps, + description, + entity, + ); + } + } + } finally { + // Regenerate once — in `finally` so a mid-batch RENDER_FAILED still leaves + // index.md consistent with the nodes that DID freeze, not stale until the + // next import. + safeRegenerateIndex(deps.projectDir); + } + return slots.map((s, i) => { + if (!s) throw new Error(`figma asset import produced no result for "${refInputs[i]}"`); + return s; + }); +} + /** index.md is a single table row per record — newlines/tabs in a * description would corrupt the whole table. */ function normalizeMeta(value: string | undefined): string | undefined { @@ -159,11 +262,12 @@ function parseFormat(raw: string): FigmaAssetFormat { } export default defineCommand({ - meta: { name: "asset", description: "Import a figma node as a frozen local asset" }, + meta: { name: "asset", description: "Import one or more figma nodes as frozen local assets" }, args: { ref: { type: "positional", - description: "figma URL, fileKey:nodeId, or fileKey", + description: + "figma URL, fileKey:nodeId, or fileKey (pass several, or comma-separate ids, to batch)", required: true, }, format: { type: "string", description: "png | svg | jpg | pdf", default: "svg" }, @@ -183,8 +287,16 @@ export default defineCommand({ const t0 = Date.now(); const token = process.env.FIGMA_TOKEN ?? ""; const client = createFigmaClient({ token }); - const result = await runAssetImport( - args.ref, + // citty puts ALL positionals in `args._` (including the one bound to the + // named `ref`), so use `_` as the source of truth — reading both would + // double-count the first. Split any comma-joined ids, so `asset A B`, + // `asset A,B`, and `asset URL1 URL2` all batch into ONE /v1/images call. + const positionals = ( + Array.isArray(args._) && args._.length > 0 ? (args._ as string[]) : [args.ref] + ).map(String); + const refs = gatherAssetRefs(positionals); + const results = await runAssetImportMany( + refs, { format: parseFormat(args.format), scale: args.scale !== undefined ? Number(args.scale) : undefined, @@ -193,11 +305,25 @@ export default defineCommand({ }, { projectDir: args.dir, client, download: downloadRender }, ); - const verb = result.reused ? "reused" : "imported"; - console.log(`${verb} ${result.record.id} → ${result.record.path}`); - console.log(result.snippet.html); + for (const result of results) { + const verb = result.reused ? "reused" : "imported"; + console.log(`${verb} ${result.record.id} → ${result.record.path}`); + console.log(result.snippet.html); + } + if (results.length > 1) { + const rendered = results.filter((r) => !r.reused).length; + console.log( + rendered > 0 + ? `(${results.length} nodes, ${rendered} rendered in 1 figma request)` + : `(${results.length} nodes, all reused from cache — no figma request)`, + ); + } const { trackFigmaImport } = await import("../../telemetry/index.js"); - trackFigmaImport({ phase: "asset", reused: result.reused, durationMs: Date.now() - t0 }); + trackFigmaImport({ + phase: "asset", + reused: results.every((r) => r.reused), + durationMs: Date.now() - t0, + }); }); }, }); diff --git a/packages/cli/src/commands/figma/component.test.ts b/packages/cli/src/commands/figma/component.test.ts index 0f8a7ad5a..260f02aa1 100644 --- a/packages/cli/src/commands/figma/component.test.ts +++ b/packages/cli/src/commands/figma/component.test.ts @@ -41,6 +41,20 @@ const SVG = new TextEncoder().encode(""); function client(): FigmaClient { return { renderNode: () => Promise.resolve({ url: "https://cdn/x", ext: "svg" }), + // Delegates to whatever renderNode is on the final object (via `this`), so + // inline clients that spread `...client()` and override renderNode still + // drive the batch path; rejections propagate (matching production). + renderNodes(fileKey, nodeIds, opts) { + return Promise.all( + nodeIds.map((nodeId) => + this.renderNode({ fileKey, nodeId }, opts).then((r) => ({ + nodeId, + url: r.url, + ext: r.ext, + })), + ), + ); + }, imageFills: () => Promise.resolve(new Map()), variables: () => Promise.resolve({ variables: {}, variableCollections: {} }), styles: () => Promise.resolve([]), diff --git a/packages/cli/src/commands/figma/tokens.test.ts b/packages/cli/src/commands/figma/tokens.test.ts index 760d4d89c..8f7f1c6e9 100644 --- a/packages/cli/src/commands/figma/tokens.test.ts +++ b/packages/cli/src/commands/figma/tokens.test.ts @@ -15,6 +15,7 @@ afterEach(() => rmSync(dir, { recursive: true, force: true })); function client(overrides: Partial): FigmaClient { return { renderNode: () => Promise.reject(new Error("unused")), + renderNodes: () => Promise.reject(new Error("unused")), imageFills: () => Promise.resolve(new Map()), variables: () => Promise.resolve({ @@ -56,12 +57,24 @@ describe("runTokensImport", () => { const out = await runTokensImport("FILE", { projectDir: dir, client: gated }); expect(out.mode).toBe("styles"); expect(out.entries).toEqual([]); + expect(out.styleCount).toBe(1); const sidecar = JSON.parse(readFileSync(join(dir, "figma-tokens.json"), "utf8")) as { tokens: Array<{ name: string; type: string }>; }; expect(sidecar.tokens[0]).toMatchObject({ name: "Primary", type: "style:FILL" }); }); + it("reports styleCount 0 when the file has no published styles — never a false success", async () => { + const gatedNoStyles = client({ + variables: () => + Promise.reject(new FigmaClientError("REQUIRES_ENTERPRISE", "enterprise only", 403)), + styles: () => Promise.resolve([]), + }); + const out = await runTokensImport("FILE", { projectDir: dir, client: gatedNoStyles }); + expect(out.mode).toBe("styles"); + expect(out.styleCount).toBe(0); + }); + it("propagates non-enterprise failures", async () => { const broken = client({ variables: () => Promise.reject(new FigmaClientError("RATE_LIMITED", "429", 429)), diff --git a/packages/cli/src/commands/figma/tokens.ts b/packages/cli/src/commands/figma/tokens.ts index a1cc6a69e..87ac8a8d9 100644 --- a/packages/cli/src/commands/figma/tokens.ts +++ b/packages/cli/src/commands/figma/tokens.ts @@ -30,6 +30,10 @@ export interface TokensImportResult { mode: "variables" | "styles"; entries: CompositionVariableEntry[]; sidecarPath: string; + /** styles mode only: how many published styles were actually found — + * entries is always [] in this mode (style values resolve later, at + * component-import time), so this is what tells success from empty. */ + styleCount?: number; } export async function runTokensImport( @@ -68,7 +72,7 @@ export async function runTokensImport( })), }; writeFileSync(sidecarPath, JSON.stringify(sidecar, null, 2) + "\n"); - return { mode: "styles", entries: [], sidecarPath }; + return { mode: "styles", entries: [], sidecarPath, styleCount: styles.length }; } export default defineCommand({ @@ -84,7 +88,9 @@ export default defineCommand({ const result = await runTokensImport(args.ref, { projectDir: args.dir, client }); if (result.mode === "styles") { console.log( - "variables are Enterprise-gated on this plan — recorded published style metadata instead (style values resolve at component-import time)", + (result.styleCount ?? 0) > 0 + ? `variables are Enterprise-gated on this plan — recorded ${result.styleCount} published style(s) instead (style values resolve at component-import time)` + : "variables are Enterprise-gated on this plan, and this file has no published library styles to fall back to — nothing recorded. Publish the file's styles to a team library, or read variables via the Figma MCP connector's get_variable_defs instead (works on any plan, rate-limited).", ); } console.log(`wrote ${result.sidecarPath} (${result.mode})`); diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts index e913d6119..33248460b 100644 --- a/packages/cli/src/commands/init.test.ts +++ b/packages/cli/src/commands/init.test.ts @@ -29,6 +29,19 @@ function runInit(args: string[]): { status: number; stdout: string; stderr: stri }; } +function expectScaffoldedScripts(target: string): void { + const pkg = JSON.parse(readFileSync(join(target, "package.json"), "utf-8")) as { + scripts?: Record; + }; + expect(pkg.scripts).toMatchObject({ + dev: "npx --yes hyperframes preview", + check: "npx --yes hyperframes check", + render: "npx --yes hyperframes render", + publish: "npx --yes hyperframes publish", + }); + expect(Object.keys(pkg.scripts ?? {}).sort()).toEqual(["check", "dev", "publish", "render"]); +} + describe("hyperframes init flag rename", () => { it("--example blank scaffolds a bundled project with npm scripts", () => { const dir = mkdtempSync(join(tmpdir(), "hf-init-test-")); @@ -44,18 +57,10 @@ describe("hyperframes init flag rename", () => { const pkg = JSON.parse(readFileSync(join(target, "package.json"), "utf-8")) as { private?: boolean; type?: string; - scripts?: Record; }; expect(pkg.private).toBe(true); expect(pkg.type).toBe("module"); - expect(pkg.scripts).toMatchObject({ - dev: "npx --yes hyperframes preview", - check: - "npx --yes hyperframes lint && npx --yes hyperframes validate && npx --yes hyperframes inspect", - render: "npx --yes hyperframes render", - publish: "npx --yes hyperframes publish", - }); - expect(Object.keys(pkg.scripts ?? {}).sort()).toEqual(["check", "dev", "publish", "render"]); + expectScaffoldedScripts(target); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -79,17 +84,7 @@ describe("hyperframes init flag rename", () => { expect(html).toContain(tailwindScript); expect(html).toContain("window.__tailwindReady"); - const pkg = JSON.parse(readFileSync(join(target, "package.json"), "utf-8")) as { - scripts?: Record; - }; - expect(pkg.scripts).toMatchObject({ - dev: "npx --yes hyperframes preview", - check: - "npx --yes hyperframes lint && npx --yes hyperframes validate && npx --yes hyperframes inspect", - render: "npx --yes hyperframes render", - publish: "npx --yes hyperframes publish", - }); - expect(Object.keys(pkg.scripts ?? {}).sort()).toEqual(["check", "dev", "publish", "render"]); + expectScaffoldedScripts(target); } finally { rmSync(dir, { recursive: true, force: true }); } diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 3866a4fbc..9a8ed3cbe 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -1,3 +1,8 @@ +// The scaffolding command predates the complexity gate: run(), probeVideo, +// handleVideoFile, and applyResolutionPreset carry its interactive branching. +// This branch only repointed the scaffolded npm scripts; the refactor is its +// own task. +// fallow-ignore-file complexity import { defineCommand, runCommand } from "citty"; import type { Example } from "./_examples.js"; @@ -224,9 +229,7 @@ function hyperframesScript(command: string): string { function buildPackageScripts(): Record { return { dev: hyperframesScript("preview"), - check: - `${hyperframesScript("lint")} && ${hyperframesScript("validate")} && ` + - `${hyperframesScript("inspect")}`, + check: hyperframesScript("check"), render: hyperframesScript("render"), publish: hyperframesScript("publish"), }; diff --git a/packages/cli/src/commands/inspect.test.ts b/packages/cli/src/commands/inspect.test.ts new file mode 100644 index 000000000..22fd6d027 --- /dev/null +++ b/packages/cli/src/commands/inspect.test.ts @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + bundleToSingleHtmlFailureMock, + metaDescription, + resolveProjectMock, + runAndCaptureStdio, +} from "./deprecationTestHarness.js"; + +// See layout.test.ts for why these two dynamic-import targets are mocked: +// resolveProject skips real filesystem resolution, and bundleToSingleHtml +// gives a fast, deterministic failure that exercises run()'s outer catch +// (the JSON failure envelope) without needing headless Chrome. +vi.mock("../utils/project.js", () => resolveProjectMock()); +vi.mock("@hyperframes/core/compiler", () => bundleToSingleHtmlFailureMock()); + +import inspectCommand from "./inspect.js"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("inspect command deprecation (U5)", () => { + it("is the compatibility alias for layout, sharing its deprecated description", () => { + expect(metaDescription(inspectCommand)).toContain("(deprecated, use check)"); + }); + + it("prints a one-line deprecation notice naming 'inspect' on stderr, never stdout", async () => { + const { stderrText, stdoutText } = await runAndCaptureStdio(inspectCommand); + expect(stderrText).toContain("hyperframes inspect"); + expect(stderrText).toContain("hyperframes check"); + expect(stdoutText).toBe(""); + }); +}); diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index c67ed809e..f939a9dce 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -81,6 +81,22 @@ return `${selectorFor(parent)} > ${element.tagName.toLowerCase()}:nth-of-type(${index})`; } + function uniqueSelectorFor(element) { + const preferred = selectorFor(element); + try { + if (document.querySelectorAll(preferred).length === 1) return preferred; + } catch { + // Fall through to a structural selector. + } + const parent = element.parentElement; + if (!parent) return preferred; + const siblings = Array.from(parent.children).filter( + (child) => child.tagName === element.tagName, + ); + const index = siblings.indexOf(element) + 1; + return `${uniqueSelectorFor(parent)} > ${element.tagName.toLowerCase()}:nth-of-type(${index})`; + } + function hasIgnoreFlag(element) { return !!element.closest("[data-layout-ignore], [data-layout-check='ignore']"); } @@ -98,6 +114,14 @@ return opacity; } + function hasOpacityBelow(element, floor) { + for (let current = element; current; current = current.parentElement) { + const parsed = Number.parseFloat(getComputedStyle(current).opacity || "1"); + if (Number.isFinite(parsed) && parsed < floor) return true; + } + return false; + } + // A clip-path can shrink an element's painted region to nothing (e.g. a // typewriter span pre-reveal at `inset(0 100% 0 0)`, or `circle(0px)`) while // its layout box, opacity, visibility and display all still read as present. @@ -142,9 +166,20 @@ return !paintsAnyProbePoint(element, rect); } - function isVisibleElement(element) { + function isVisibleElement(element, opacityFloor, probeClipPath) { if (IGNORE_TAGS.has(element.tagName)) return false; if (hasIgnoreFlag(element)) return false; + if ( + opacityFloor != null && + typeof element.checkVisibility === "function" && + !element.checkVisibility({ + opacityProperty: true, + visibilityProperty: true, + contentVisibilityAuto: true, + }) + ) { + return false; + } const style = getComputedStyle(element); if ( style.display === "none" || @@ -153,32 +188,57 @@ ) { return false; } - if (opacityChain(element) < 0.2) return false; + if ( + opacityFloor == null ? opacityChain(element) < 0.2 : hasOpacityBelow(element, opacityFloor) + ) { + return false; + } const rect = element.getBoundingClientRect(); if (rect.width <= 0.5 || rect.height <= 0.5) return false; - return !isClippedAway(element); + return probeClipPath === false || !isClippedAway(element); } - function textContentFor(element) { - return (element.innerText || element.textContent || "").replace(/\s+/g, " ").trim(); + function directTextNodes(element) { + return Array.from(element.childNodes).filter((node) => node.nodeType === 3); } - function hasOwnTextCandidate(element) { - const text = textContentFor(element); + function textContentFor(element, ownTextOnly) { + const content = ownTextOnly + ? directTextNodes(element) + .map((node) => node.textContent || "") + .join("") + : element.innerText || element.textContent || ""; + return content.replace(/\s+/g, " ").trim(); + } + + function hasOwnTextCandidate(element, directOnly) { + const text = textContentFor(element, directOnly); if (!text) return false; + if (directOnly) return true; for (const child of Array.from(element.children)) { if (isVisibleElement(child) && textContentFor(child)) return false; } return true; } - function textRectFor(element) { - const range = document.createRange(); - range.selectNodeContents(element); - const rects = Array.from(range.getClientRects()).filter( - (rect) => rect.width > 0.5 && rect.height > 0.5, - ); - range.detach(); + function textClientRects(element, directOnly) { + const subjects = directOnly ? directTextNodes(element) : [element]; + const rects = []; + for (const subject of subjects) { + const range = document.createRange(); + range.selectNodeContents(subject); + rects.push( + ...Array.from(range.getClientRects()).filter( + (rect) => rect.width > 0.5 && rect.height > 0.5, + ), + ); + range.detach(); + } + return rects; + } + + function textRectFor(element, directOnly) { + const rects = textClientRects(element, directOnly); if (rects.length === 0) return null; const union = rects.reduce( @@ -567,9 +627,12 @@ const area = intersectionArea(a.rect, b.rect); if (area <= Math.min(rectArea(a.rect), rectArea(b.rect)) * 0.2) return null; return { - // Warning, not error: must not fail the exit code (ok = errorCount === 0) - // for compositions that intentionally layer text. Re-promote once the - // data-layout-allow-overlap opt-out is widely adopted. + // Warning at the per-sample level: a single-sample overlap is usually an + // entrance/exit transient (two blocks crossing mid-animation), not a real + // collision. `collapseStaticLayoutIssues` (utils/layoutAudit.ts) re-promotes + // this to error once the SAME overlap is held across >= 2 adjacent samples + // (or ~500ms of timeline) — a persistence-tiered replacement for the old + // "re-promote once data-layout-allow-overlap is widely adopted" plan (#U10). code: "content_overlap", severity: "warning", time, @@ -602,6 +665,7 @@ } const RASTER_TAGS = new Set(["IMG", "VIDEO", "CANVAS"]); + const FRAME_MEDIA_TAGS = new Set([...RASTER_TAGS, "SVG"]); // An element hides text beneath it when it paints opaque pixels at near-full // opacity: raster content (img/video/canvas), a background image, or a solid @@ -667,42 +731,135 @@ return hit; } + const OCCLUSION_PROBE_Y_FRACTIONS = [0.25, 0.5, 0.75]; + const OCCLUSION_PROBE_X_FRACTIONS = [0.03, 0.1, 0.2, 0.35, 0.5, 0.65, 0.8, 0.9, 0.97]; + const OCCLUSION_GRID_POINTS = + OCCLUSION_PROBE_Y_FRACTIONS.length * OCCLUSION_PROBE_X_FRACTIONS.length; + + // Short, atomic text (a label/button/word, no whitespace) reads as a single + // unit — ANY covered probe point changes what it says, so flag at any hit + // (the pre-#U10 behaviour). Longer prose survives a nibbled edge; only flag + // once a real share of it is covered — see `occludedTextIssue`. + const ATOMIC_LABEL_MAX_CHARS = 16; + const PROSE_COVERAGE_FLOOR = 0.15; + + function isAtomicLabel(text) { + return text.length > 0 && text.length <= ATOMIC_LABEL_MAX_CHARS && !/\s/.test(text); + } + // Sweep a grid across the text box (three rows, not just the mid-line, so - // overlays covering only part of a multi-line block are caught) and return - // the first opaque element painted over any sample point. - function firstOccluder(element, textRect) { - for (const yFraction of [0.25, 0.5, 0.75]) { + // overlays covering only part of a multi-line block are caught). Unlike a + // first-hit scan, this keeps sampling every point so it can report what + // fraction of the box is actually covered — a corner nibble on a paragraph + // reads very differently from a label buried under an overlay. Still + // returns the first opaque element found, for `containerSelector`. + function occlusionCoverage(element, textRect) { + let occluder = null; + let hits = 0; + for (const yFraction of OCCLUSION_PROBE_Y_FRACTIONS) { const y = textRect.top + textRect.height * yFraction; - for (const xFraction of [0.03, 0.1, 0.2, 0.35, 0.5, 0.65, 0.8, 0.9, 0.97]) { - const occluder = occluderAt(element, textRect.left + textRect.width * xFraction, y); - if (occluder) return occluder; + for (const xFraction of OCCLUSION_PROBE_X_FRACTIONS) { + const hit = occluderAt(element, textRect.left + textRect.width * xFraction, y); + if (!hit) continue; + hits += 1; + if (!occluder) occluder = hit; } } - return null; + return { occluder, coveredFraction: round(hits / OCCLUSION_GRID_POINTS) }; } // Catches the blind spot the overflow checks miss: text that fits its box - // perfectly but is covered by a later sibling/overlay. + // perfectly but is covered by a later sibling/overlay. An atomic label + // (short, no whitespace) flags at any coverage; ordinary prose only flags + // once coveredFraction clears PROSE_COVERAGE_FLOOR, since a sliver of edge + // cover on a paragraph is usually a styling artifact, not a reading defect. function occludedTextIssue(element, time) { if (hasAllowOcclusionFlag(element)) return null; const textRect = textRectFor(element); if (!textRect) return null; - const occluder = firstOccluder(element, textRect); + const text = textContentFor(element); + const { occluder, coveredFraction } = occlusionCoverage(element, textRect); if (!occluder) return null; + if (!isAtomicLabel(text) && coveredFraction < PROSE_COVERAGE_FLOOR) return null; return { code: "text_occluded", severity: "error", time, selector: selectorFor(element), containerSelector: selectorFor(occluder), - text: textContentFor(element), + text, message: "Text is hidden beneath an opaque element.", rect: textRect, + coveredFraction, fixHint: "Give the text its own zone, raise its stacking order above the covering element, or mark intentional layering with data-layout-allow-occlusion.", }; } + function candidateAnchor(element) { + const dataAttributes = {}; + for (const attribute of Array.from(element.attributes)) { + if (attribute.name.startsWith("data-")) dataAttributes[attribute.name] = attribute.value; + } + const source = element + .closest("[data-composition-file]") + ?.getAttribute("data-composition-file"); + return { + selector: uniqueSelectorFor(element), + dataAttributes, + sourceFile: source || "index.html", + }; + } + + function geometryCandidate(element, kind, rect, elementRect, rootRect, tolerance) { + const tag = element.tagName.toLowerCase(); + const text = kind === "text" ? textContentFor(element, true) : tag; + const overflow = kind === "media" ? overflowFor(elementRect, rootRect, tolerance) : null; + return { + kind, + tag, + text, + rect, + elementRect, + ...candidateAnchor(element), + ...(overflow ? { overflow } : {}), + }; + } + + window.__hyperframesGeometryCandidates = function collectGeometryCandidates(options) { + const includeText = options?.text === true; + const includeMedia = options?.media === true; + if (!includeText && !includeMedia) return []; + const tolerance = typeof options?.tolerance === "number" ? options.tolerance : 2; + const root = + document.querySelector("[data-composition-id][data-width][data-height]") || + document.querySelector("[data-composition-id]") || + document.body; + const rootRect = rootRectFor(root); + const candidates = []; + for (const element of Array.from(document.querySelectorAll("body *"))) { + if (element.closest('[data-composition-id="captions"], .caption-layer, #caption-stage')) { + continue; + } + if (!isVisibleElement(element, 0.05, false)) continue; + const elementRect = toRect(element.getBoundingClientRect()); + if (includeText && hasOwnTextCandidate(element, true)) { + const rect = textRectFor(element, true); + if (rect) { + candidates.push( + geometryCandidate(element, "text", rect, elementRect, rootRect, tolerance), + ); + } + } + if (includeMedia && FRAME_MEDIA_TAGS.has(element.tagName.toUpperCase())) { + candidates.push( + geometryCandidate(element, "media", elementRect, elementRect, rootRect, tolerance), + ); + } + } + return candidates; + }; + window.__hyperframesLayoutAudit = function auditLayout(options) { const time = options && typeof options.time === "number" ? options.time : 0; const tolerance = @@ -712,7 +869,9 @@ document.querySelector("[data-composition-id]") || document.body; const rootRect = rootRectFor(root); - const elements = Array.from(root.querySelectorAll("*")).filter(isVisibleElement); + const elements = Array.from(root.querySelectorAll("*")).filter((element) => + isVisibleElement(element), + ); const issues = []; for (const element of elements) { @@ -728,4 +887,28 @@ issues.push(...contentOverlapIssues(root, time)); return issues; }; + + // Frozen-sweep guard (#U10, checkPipeline.ts): a compact per-sample + // fingerprint of every visible element's box + opacity, in DOM order. Node + // calls this once per seeked grid point and compares the strings across the + // whole run — if every sample produces the identical string, the seek never + // actually moved anything and the whole audit run is unreliable. Deliberately + // a single opaque string (not a structured array) since Node only ever needs + // equality, not per-element diffing. + window.__hyperframesLayoutGeometry = function collectLayoutGeometry() { + const root = + document.querySelector("[data-composition-id][data-width][data-height]") || + document.querySelector("[data-composition-id]") || + document.body; + const elements = Array.from(root.querySelectorAll("*")).filter((element) => + isVisibleElement(element), + ); + return elements + .map((element) => { + const rect = toRect(element.getBoundingClientRect()); + const opacity = round(opacityChain(element)); + return `${rect.left},${rect.top},${rect.width},${rect.height},${opacity}`; + }) + .join("|"); + }; })(); diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index 061e18da9..821bad8c1 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -15,11 +15,20 @@ interface RectInput { height: number; } +afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ""; + Reflect.deleteProperty(document, "elementFromPoint"); + Reflect.deleteProperty(window, "__hyperframesLayoutAudit"); + clearGeometryCollector(); +}); + describe("layout-audit.browser", () => { afterEach(() => { vi.restoreAllMocks(); document.body.innerHTML = ""; delete (window as unknown as { __hyperframesLayoutAudit?: unknown }).__hyperframesLayoutAudit; + clearGeometryCollector(); }); it("uses authored canvas dimensions when the root bounding rect is degenerate", () => { @@ -135,6 +144,206 @@ describe("layout-audit.browser", () => { expect(runAudit().some((issue) => issue.code === "text_box_overflow")).toBe(true); }); + + it("keeps auditing visible descendants beyond the second element", () => { + document.body.innerHTML = ` +
+
+
+
+
Late visible copy
+
+ `; + installGeometry({ + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + late: rect({ left: 700, top: 100, width: 140, height: 40 }), + text: rect({ left: 700, top: 100, width: 140, height: 40 }), + }); + installAuditScript(); + + expect(runAudit()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: "canvas_overflow", selector: "#late" }), + ]), + ); + }); +}); + +it("is inert unless text or media candidates are explicitly requested", () => { + document.body.innerHTML = ` +
+
Visible copy
+
+ `; + installGeometry({ + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + copy: rect({ left: 100, top: 100, width: 200, height: 40 }), + text: rect({ left: 100, top: 100, width: 200, height: 40 }), + }); + installAuditScript(); + + expect(runGeometryCandidates({ text: false, media: false, tolerance: 2 })).toEqual([]); +}); + +it("returns own-text rects and media overflow while excluding caption layers", () => { + document.body.innerHTML = ` +
+
+
Own copy Nested
+ + +
+

Authored captions

+
+ `; + installGeometry({ + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + copy: rect({ left: 100, top: 260, width: 180, height: 40 }), + headline: rect({ left: 100, top: 260, width: 180, height: 40 }), + nested: rect({ left: 220, top: 260, width: 60, height: 40 }), + image: rect({ left: 600, top: 40, width: 200, height: 100 }), + vector: rect({ left: -130, top: 160, width: 100, height: 100 }), + caption: rect({ left: 200, top: 300, width: 240, height: 40 }), + text: rect({ left: 100, top: 260, width: 100, height: 40 }), + }); + installAuditScript(); + + const candidates = runGeometryCandidates({ text: true, media: true, tolerance: 2 }); + + expect(candidates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "text", + tag: "div", + text: "Own copy", + selector: "#copy", + sourceFile: "scenes/hero.html", + rect: { left: 100, top: 260, right: 200, bottom: 300, width: 100, height: 40 }, + elementRect: { left: 100, top: 260, right: 280, bottom: 300, width: 180, height: 40 }, + }), + expect.objectContaining({ + kind: "media", + tag: "img", + selector: "#image", + overflow: { right: 160 }, + }), + expect.objectContaining({ + kind: "media", + tag: "svg", + selector: "#vector", + overflow: { left: 130 }, + }), + ]), + ); + expect(candidates.some((candidate) => candidate.selector === "#caption")).toBe(false); +}); + +it("scans body-level composition siblings and includes a media boundary root", () => { + document.body.innerHTML = ` + +
+

Portal copy

+
+ + `; + installGeometry({ + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + "portal-copy": rect({ left: 100, top: 260, width: 180, height: 40 }), + "portal-image": rect({ left: 600, top: 80, width: 180, height: 100 }), + text: rect({ left: 100, top: 260, width: 180, height: 40 }), + }); + installAuditScript(); + + const candidates = runGeometryCandidates({ text: true, media: true, tolerance: 2 }); + + expect(candidates.map((candidate) => candidate.selector)).toEqual( + expect.arrayContaining(["#boundary", "#portal-copy", "#portal-image"]), + ); +}); + +it("returns unique structural selectors for repeated class-only media", () => { + document.body.innerHTML = ` +
+ + +
+ `; + installGeometry({ + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + "": rect({ left: 100, top: 100, width: 100, height: 100 }), + }); + installAuditScript(); + + const candidates = runGeometryCandidates({ text: false, media: true, tolerance: 2 }); + const images = Array.from(document.querySelectorAll("img")); + + expect(candidates).toHaveLength(2); + expect(new Set(candidates.map((candidate) => candidate.selector)).size).toBe(2); + expect(document.querySelector(candidates[0]?.selector ?? "")).toBe(images[0]); + expect(document.querySelector(candidates[1]?.selector ?? "")).toBe(images[1]); +}); + +it("keeps visible clip-path text when pointer events do not participate in hit testing", () => { + document.body.innerHTML = ` +
+

Visible clipped copy

+
+ `; + installGeometry( + { + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + "clipped-copy": rect({ left: 100, top: 100, width: 200, height: 40 }), + text: rect({ left: 100, top: 100, width: 200, height: 40 }), + }, + { "clipped-copy": { clipPath: "inset(0 10% 0 0)", pointerEvents: "none" } }, + ); + Reflect.set( + document, + "elementFromPoint", + vi.fn(() => document.getElementById("root")), + ); + installAuditScript(); + + const candidates = runGeometryCandidates({ text: true, media: false, tolerance: 2 }); + + expect(candidates).toEqual( + expect.arrayContaining([expect.objectContaining({ selector: "#clipped-copy" })]), + ); +}); + +it("uses the bridge opacity floor across the ancestor chain", () => { + document.body.innerHTML = ` +
+

Hidden copy

+

Visible copy

+

Stacked opacity copy

+
+ `; + installGeometry( + { + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + "faint-parent": rect({ left: 40, top: 40, width: 200, height: 40 }), + "hidden-copy": rect({ left: 40, top: 40, width: 200, height: 40 }), + "soft-parent": rect({ left: 40, top: 120, width: 200, height: 40 }), + "visible-copy": rect({ left: 40, top: 120, width: 200, height: 40 }), + "stacked-parent": rect({ left: 40, top: 200, width: 200, height: 40 }), + "stacked-copy": rect({ left: 40, top: 200, width: 200, height: 40 }), + text: rect({ left: 40, top: 120, width: 200, height: 40 }), + }, + { + "faint-parent": { opacity: "0.04" }, + "soft-parent": { opacity: "0.1" }, + "stacked-parent": { opacity: "0.2" }, + "stacked-copy": { opacity: "0.2" }, + }, + ); + installAuditScript(); + + const candidates = runGeometryCandidates({ text: true, media: false, tolerance: 2 }); + + expect(candidates.some((candidate) => candidate.selector === "#hidden-copy")).toBe(false); + expect(candidates.some((candidate) => candidate.selector === "#visible-copy")).toBe(true); + expect(candidates.some((candidate) => candidate.selector === "#stacked-copy")).toBe(true); }); describe("layout-audit.browser content overlap", () => { @@ -143,6 +352,7 @@ describe("layout-audit.browser content overlap", () => { document.body.innerHTML = ""; delete (document as unknown as { elementFromPoint?: unknown }).elementFromPoint; delete (window as unknown as { __hyperframesLayoutAudit?: unknown }).__hyperframesLayoutAudit; + clearGeometryCollector(); }); it("flags two solid text blocks that overlap", () => { @@ -246,6 +456,84 @@ describe("contrast-audit.browser clip-path visibility", () => { expect(await runContrastAudit()).toEqual([]); }); + + it("excludes data-layout-ignore set dressing from contrast reports", async () => { + document.body.innerHTML = ` +
+
+
SHAPE
+
+
Readable copy
+
+ `; + + vi.spyOn(window, "getComputedStyle").mockImplementation( + () => + ({ + display: "block", + visibility: "visible", + opacity: "1", + color: "rgb(30, 30, 42)", + fontSize: "32px", + fontWeight: "400", + clipPath: "none", + }) as unknown as CSSStyleDeclaration, + ); + for (const id of ["rail-label", "headline"]) { + vi.spyOn(document.getElementById(id)!, "getBoundingClientRect").mockReturnValue( + rect({ left: 100, top: id === "headline" ? 200 : 100, width: 400, height: 40 }), + ); + } + (document as unknown as { elementFromPoint: () => Element | null }).elementFromPoint = () => + null; + + installContrastScript(); + + const entries = await runContrastAudit(); + const selectors = entries.map((entry) => entry.selector); + expect(selectors).toContain("#headline"); + expect(selectors).not.toContain("#rail-label"); + }); + + it("excludes text that has left the canvas from contrast reports", async () => { + document.body.innerHTML = ` +
+
You
+
Readable copy
+
+ `; + + vi.spyOn(window, "getComputedStyle").mockImplementation( + () => + ({ + display: "block", + visibility: "visible", + opacity: "1", + color: "rgb(255, 255, 255)", + fontSize: "32px", + fontWeight: "400", + clipPath: "none", + }) as unknown as CSSStyleDeclaration, + ); + Object.defineProperty(window, "innerWidth", { configurable: true, value: 640 }); + Object.defineProperty(window, "innerHeight", { configurable: true, value: 360 }); + // The cursor-exit shape: element parked far past the top-left corner. + vi.spyOn(document.getElementById("exited")!, "getBoundingClientRect").mockReturnValue( + rect({ left: -1420, top: -500, width: 60, height: 24 }), + ); + vi.spyOn(document.getElementById("headline")!, "getBoundingClientRect").mockReturnValue( + rect({ left: 100, top: 200, width: 400, height: 40 }), + ); + (document as unknown as { elementFromPoint: () => Element | null }).elementFromPoint = () => + null; + + installContrastScript(); + + const entries = await runContrastAudit(); + const selectors = entries.map((entry) => entry.selector); + expect(selectors).toContain("#headline"); + expect(selectors).not.toContain("#exited"); + }); }); describe("contrast-audit.browser background sampling", () => { @@ -404,6 +692,7 @@ describe("layout-audit.browser occlusion", () => { document.body.innerHTML = ""; delete (document as unknown as { elementFromPoint?: unknown }).elementFromPoint; delete (window as unknown as { __hyperframesLayoutAudit?: unknown }).__hyperframesLayoutAudit; + clearGeometryCollector(); }); it("flags text painted over by an opaque sibling overlay", () => { @@ -440,8 +729,92 @@ describe("layout-audit.browser occlusion", () => { }); expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false); }); + + it("carries the fully-covered fraction when the occluder hits every probe point", () => { + const occluded = auditOcclusionScene({ + overlayStyle: { backgroundColor: "rgb(10, 10, 10)" }, + topmostId: "overlay", + }).find((issue) => issue.code === "text_occluded"); + expect(occluded?.coveredFraction).toBe(1); + }); + + // #U10: a 2-point hit on the 27-point probe grid (3 rows x 9 columns) is a + // sliver of edge cover — reports ~0.07 coverage either way, but only GATES + // (produces a finding) for short atomic labels; ordinary prose survives it. + it("reports ~0.07 coverage for a 2-of-27 grid hit and flags an atomic label at that coverage", () => { + const issues = auditCoverageScene({ text: "SUBSCRIBE", hitCount: 2 }); + const occluded = issues.find((issue) => issue.code === "text_occluded"); + expect(occluded).toBeDefined(); + expect(occluded?.coveredFraction).toBe(0.07); + }); + + it("does not flag ordinary prose at the same ~0.07 coverage a label would flag at", () => { + const issues = auditCoverageScene({ + text: "This paragraph is long enough to read as ordinary prose, not a label.", + hitCount: 2, + }); + expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false); + }); + + it("flags prose once coverage clears the 0.15 floor", () => { + // 5/27 ≈ 0.185, comfortably over the ~0.15 prose floor. + const issues = auditCoverageScene({ + text: "This paragraph is long enough to read as ordinary prose, not a label.", + hitCount: 5, + }); + expect(issues.some((issue) => issue.code === "text_occluded")).toBe(true); + }); }); +// Mirrors OCCLUSION_PROBE_Y_FRACTIONS / OCCLUSION_PROBE_X_FRACTIONS in +// layout-audit.browser.js, so a test can force an exact number of grid hits +// against the same probe coordinates the audit itself sweeps. +const OCCLUSION_PROBE_Y_FRACTIONS = [0.25, 0.5, 0.75]; +const OCCLUSION_PROBE_X_FRACTIONS = [0.03, 0.1, 0.2, 0.35, 0.5, 0.65, 0.8, 0.9, 0.97]; + +function occlusionProbePoints(textRect: RectInput): Array<{ x: number; y: number }> { + const points: Array<{ x: number; y: number }> = []; + for (const yFraction of OCCLUSION_PROBE_Y_FRACTIONS) { + const y = textRect.top + textRect.height * yFraction; + for (const xFraction of OCCLUSION_PROBE_X_FRACTIONS) { + points.push({ x: textRect.left + textRect.width * xFraction, y }); + } + } + return points; +} + +// Builds an occlusion scene where exactly `hitCount` of the 27 probe points +// are covered by an opaque overlay and the rest hit the headline itself +// (self-hit — not foreign, so not counted as occluded). +function auditCoverageScene(options: { + text: string; + hitCount: number; +}): ReturnType { + const textRect = { left: 200, top: 500, width: 600, height: 80 }; + document.body.innerHTML = ` +
+
${options.text}
+
+
+ `; + installOcclusionGeometry({ + styleOverrides: { overlay: { backgroundColor: "rgb(10, 10, 10)" } }, + headlineTextRect: rect(textRect), + topmostId: "headline", + }); + const hitPoints = occlusionProbePoints(textRect).slice(0, options.hitCount); + ( + document as unknown as { elementFromPoint: (x: number, y: number) => Element | null } + ).elementFromPoint = (x, y) => { + const isHit = hitPoints.some( + (point) => Math.abs(point.x - x) < 0.01 && Math.abs(point.y - y) < 0.01, + ); + return document.getElementById(isHit ? "overlay" : "headline"); + }; + installAuditScript(); + return runAudit(); +} + function auditOcclusionScene(options: { headlineAttrs?: string; overlayStyle: Partial>; @@ -601,28 +974,31 @@ async function runContrastAudit(): Promise>> { return w.__contrastAuditFinish("stub", 0, candidates); } -function runAudit(): Array<{ +interface AuditIssue { code: string; selector: string; containerSelector?: string; overflow?: Record; message?: string; -}> { + coveredFraction?: number; +} + +function runAudit(): AuditIssue[] { const audit = ( window as unknown as { - __hyperframesLayoutAudit: (options: { time: number; tolerance: number }) => Array<{ - code: string; - selector: string; - containerSelector?: string; - overflow?: Record; - message?: string; - }>; + __hyperframesLayoutAudit: (options: { time: number; tolerance: number }) => AuditIssue[]; } ).__hyperframesLayoutAudit; return audit({ time: 1, tolerance: 2 }); } -function installGeometry(rects: Record): void { +function installGeometry( + rects: Record, + styleOverrides: Record> = {}, +): void { + // Style-fixture branching mirrors the audit's per-property reads; splitting + // it would scatter one mock across helpers. + // fallow-ignore-next-line complexity vi.spyOn(window, "getComputedStyle").mockImplementation((element) => { const el = element as Element; const isBubble = el.id === "bubble"; @@ -648,6 +1024,7 @@ function installGeometry(rects: Record): void { paddingBottom: isBubble ? "16px" : "0px", paddingLeft: isBubble ? "16px" : "0px", fontSize: "36px", + ...styleOverrides[el.id], } as unknown as CSSStyleDeclaration; }); @@ -678,6 +1055,41 @@ function installGeometry(rects: Record): void { }); } +interface GeometryCandidateResult { + kind: "text" | "media"; + tag: string; + text: string; + selector: string; + sourceFile: string; + rect: Record; + elementRect: Record; + overflow?: Record; +} + +declare global { + interface Window { + __hyperframesGeometryCandidates?: (options: { + text: boolean; + media: boolean; + tolerance: number; + }) => GeometryCandidateResult[]; + } +} + +function runGeometryCandidates(options: { + text: boolean; + media: boolean; + tolerance: number; +}): GeometryCandidateResult[] { + const collector = window.__hyperframesGeometryCandidates; + if (!collector) throw new Error("Geometry collector was not installed"); + return collector(options); +} + +function clearGeometryCollector(): void { + delete window.__hyperframesGeometryCandidates; +} + function rect({ left, top, width, height }: RectInput): DOMRect { return { left, diff --git a/packages/cli/src/commands/layout.test.ts b/packages/cli/src/commands/layout.test.ts new file mode 100644 index 000000000..ab63315c5 --- /dev/null +++ b/packages/cli/src/commands/layout.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + bundleToSingleHtmlFailureMock, + metaDescription, + resolveProjectMock, + runAndCaptureStdio, + runAndFindJsonLogCall, + runAndParseJsonEnvelope, +} from "./deprecationTestHarness.js"; + +// resolveProject and bundleToSingleHtml are both reached via a dynamic +// `await import(...)` inside layout.ts's run() / runLayoutAudit(), so +// vi.mock intercepts them the same way it would a static import. Mocking +// resolveProject skips real filesystem project resolution; mocking +// bundleToSingleHtml gives a deterministic, fast failure well before any +// real browser or network work — exercising run()'s outer catch (the JSON +// failure envelope) without needing headless Chrome. +vi.mock("../utils/project.js", () => resolveProjectMock()); +vi.mock("@hyperframes/core/compiler", () => bundleToSingleHtmlFailureMock()); + +import { createInspectCommand } from "./layout.js"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("layout command deprecation (U5)", () => { + it("marks both the layout and inspect command names' shared description as deprecated", () => { + expect(metaDescription(createInspectCommand("layout"))).toContain("(deprecated, use check)"); + expect(metaDescription(createInspectCommand("inspect"))).toContain("(deprecated, use check)"); + }); + + it("prints a one-line deprecation notice to stderr and never to stdout", async () => { + const { stderrText, stdoutText } = await runAndCaptureStdio(createInspectCommand("layout")); + expect(stderrText).toContain("hyperframes layout"); + expect(stderrText).toContain("hyperframes check"); + expect(stdoutText).toBe(""); + }); + + it("--json output is valid JSON with _meta.deprecated === true on failure", async () => { + const { parsed } = await runAndParseJsonEnvelope(createInspectCommand("layout")); + expect(parsed.ok).toBe(false); + expect(parsed._meta.deprecated).toBe(true); + }); + + it("the inspect command name produces the same _meta.deprecated === true envelope", async () => { + const jsonCall = await runAndFindJsonLogCall(createInspectCommand("inspect")); + const parsed = JSON.parse(String(jsonCall?.[0])); + expect(parsed._meta.deprecated).toBe(true); + }); +}); diff --git a/packages/cli/src/commands/layout.ts b/packages/cli/src/commands/layout.ts index 34a70eb5f..a7025d787 100644 --- a/packages/cli/src/commands/layout.ts +++ b/packages/cli/src/commands/layout.ts @@ -7,7 +7,7 @@ import { c } from "../ui/colors.js"; import { resolveProject } from "../utils/project.js"; import { normalizeErrorMessage } from "../utils/errorMessage.js"; import { serveStaticProjectHtml } from "../utils/staticProjectServer.js"; -import { withMeta } from "../utils/updateCheck.js"; +import { printDeprecationNotice, withMeta } from "../utils/updateCheck.js"; import { buildLayoutSampleTimes, buildTransitionSampleTimes, @@ -26,10 +26,17 @@ import { type MotionFrame, } from "../utils/motionAudit.js"; import { findMotionSpec, readMotionSpec, type MotionSpec } from "../utils/motionSpec.js"; +import { + AUDIT_SEEK_OPTIONS, + installPageFunctionGuard, + seekCompositionTimeline, + waitForCompositionFonts, + type SeekCompositionTimelineOptions, +} from "../capture/captureCompositionFrame.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -const SEEK_SETTLE_MS = 120; +const LAYOUT_SEEK_OPTIONS: SeekCompositionTimelineOptions = AUDIT_SEEK_OPTIONS; // All new envelope fields are optional (?); additive changes don't bump this. const INSPECT_SCHEMA_VERSION = 1; // Motion verification (#1437): dense sampling grid for the seeked-timeline checks. @@ -68,6 +75,8 @@ function buildMotionSampleTimes(duration: number): number[] { } async function getCompositionDuration(page: import("puppeteer-core").Page): Promise { + // Serialized into the page; the duration-source cascade cannot be split. + // fallow-ignore-next-line complexity return page.evaluate(() => { const win = window as unknown as { __hf?: { duration?: number }; @@ -96,52 +105,6 @@ async function getCompositionDuration(page: import("puppeteer-core").Page): Prom }); } -async function waitForFonts(page: import("puppeteer-core").Page, timeoutMs: number): Promise { - await page - .evaluate((ms: number) => { - const fonts = (document as Document & { fonts?: FontFaceSet }).fonts; - if (!fonts?.ready) return Promise.resolve(); - return Promise.race([ - fonts.ready.then(() => undefined), - new Promise((resolve) => setTimeout(resolve, ms)), - ]); - }, timeoutMs) - .catch(() => {}); -} - -async function seekTo(page: import("puppeteer-core").Page, time: number): Promise { - await page.evaluate((t: number) => { - const win = window as unknown as { - __hf?: { seek?: (time: number) => void }; - __player?: { seek?: (time: number) => void }; - __timelines?: Record void; seek?: (time: number) => void }>; - }; - if (typeof win.__hf?.seek === "function") { - win.__hf.seek(t); - return; - } - if (typeof win.__player?.seek === "function") { - win.__player.seek(t); - return; - } - const timelines = win.__timelines; - if (timelines) { - for (const timeline of Object.values(timelines)) { - if (typeof timeline.pause === "function") timeline.pause(); - if (typeof timeline.seek === "function") timeline.seek(t); - } - } - }, time); - await page.evaluate( - () => - new Promise((resolveFrame) => - requestAnimationFrame(() => requestAnimationFrame(() => resolveFrame())), - ), - ); - await waitForFonts(page, 500); - await new Promise((resolveSettle) => setTimeout(resolveSettle, SEEK_SETTLE_MS)); -} - /** * Collect every tween start/end boundary from the registered timelines, * expressed in the registered timeline's own time (what seekTo consumes). @@ -234,6 +197,7 @@ async function runLayoutAudit( ): Promise { const { ensureBrowser } = await import("../browser/manager.js"); const puppeteer = await import("puppeteer-core"); + const { buildChromeArgs } = await import("@hyperframes/engine"); const html = await bundleProjectHtml(projectDir); const server = await serveStaticProjectHtml( projectDir, @@ -247,17 +211,11 @@ async function runLayoutAudit( chromeBrowser = await puppeteer.default.launch({ headless: true, executablePath: browser.executablePath, - args: [ - "--no-sandbox", - "--disable-gpu", - "--disable-dev-shm-usage", - "--enable-webgl", - "--use-gl=angle", - "--use-angle=swiftshader", - ], + args: buildChromeArgs({ width: 1920, height: 1080, captureMode: "screenshot" }), }); const page = await chromeBrowser.newPage(); + await installPageFunctionGuard(page); await page.setViewport({ width: 1920, height: 1080 }); await page.goto(server.url, { waitUntil: "domcontentloaded", timeout: 10000 }); await alignViewportToComposition(page, server.url); @@ -266,7 +224,7 @@ async function runLayoutAudit( timeout: opts.timeout, }) .catch(() => {}); - await waitForFonts(page, 750); + await waitForCompositionFonts(page, 750); await new Promise((resolveSettle) => setTimeout(resolveSettle, 250)); const duration = await getCompositionDuration(page); @@ -308,7 +266,7 @@ async function runLayoutAudit( } } -function loadBrowserScript(name: string): string { +export function loadBrowserScript(name: string): string { const candidates = [join(__dirname, name), join(__dirname, "commands", name)]; for (const candidate of candidates) { if (existsSync(candidate)) return readFileSync(candidate, "utf-8"); @@ -330,7 +288,7 @@ async function collectLayoutIssues( const issues: LayoutIssue[] = []; for (const time of samples) { - await seekTo(page, time); + await seekCompositionTimeline(page, time, LAYOUT_SEEK_OPTIONS); const sampleIssues = await page.evaluate( (auditOptions: { time: number; tolerance: number }) => { const win = window as unknown as { @@ -373,7 +331,7 @@ async function collectMotionFrames( ): Promise { const frames: MotionFrame[] = []; for (const time of times) { - await seekTo(page, time); + await seekCompositionTimeline(page, time, LAYOUT_SEEK_OPTIONS); const sample = await page.evaluate( (options: { selectors: string[]; livenessScopes: string[] }) => { const win = window as unknown as { @@ -427,16 +385,19 @@ function resolveMotionSpec(specPath: string, json: boolean): MotionSpec { if (json) { console.log( JSON.stringify( - withMeta({ - schemaVersion: INSPECT_SCHEMA_VERSION, - ok: false, - error: message, - issues: [], - errorCount: 0, - warningCount: 0, - infoCount: 0, - issueCount: 0, - }), + withMeta( + { + schemaVersion: INSPECT_SCHEMA_VERSION, + ok: false, + error: message, + issues: [], + errorCount: 0, + warningCount: 0, + infoCount: 0, + issueCount: 0, + }, + { deprecated: true }, + ), null, 2, ), @@ -447,7 +408,7 @@ function resolveMotionSpec(specPath: string, json: boolean): MotionSpec { process.exit(1); } -function parseAt(value: unknown): number[] | undefined { +export function parseAt(value: unknown): number[] | undefined { if (!value) return undefined; const times = String(value) .split(",") @@ -461,7 +422,7 @@ export function createInspectCommand(commandName: "inspect" | "layout") { meta: { name: commandName, description: - "Inspect rendered composition layout for text/container overflow, plus optional motion verification via a *.motion.json sidecar", + "Inspect rendered composition layout for text/container overflow, plus optional motion verification via a *.motion.json sidecar (deprecated, use check)", }, args: { dir: { type: "positional", description: "Project directory", required: false }, @@ -512,7 +473,10 @@ export function createInspectCommand(commandName: "inspect" | "layout") { default: false, }, }, + // Pre-existing command-run branching; U1 only swapped the seek internals. + // fallow-ignore-next-line complexity async run({ args }) { + printDeprecationNotice(commandName); const project = resolveProject(args.dir); const samples = Math.max(1, parseInt(args.samples as string, 10) || 9); const tolerance = Math.max(0, parseFloat(args.tolerance as string) || 2); @@ -562,7 +526,7 @@ export function createInspectCommand(commandName: "inspect" | "layout") { ); } const allIssues = collapseStatic - ? collapseStaticLayoutIssues(result.rawIssues) + ? collapseStaticLayoutIssues(result.rawIssues, result.samples.length) : result.rawIssues; const limited = limitLayoutIssues(allIssues, maxIssues); const summary = summarizeLayoutIssues(allIssues); @@ -571,25 +535,28 @@ export function createInspectCommand(commandName: "inspect" | "layout") { if (args.json) { console.log( JSON.stringify( - withMeta({ - schemaVersion: INSPECT_SCHEMA_VERSION, - duration: result.duration, - samples: result.samples, - transitionSamples: atTransitions ? result.transitionSamples : undefined, - transitionSamplesDropped: atTransitions - ? result.transitionSamplesDropped - : undefined, - tolerance, - strict, - collapseStatic, - motionSpec: motionSpec ? motionSpecPath : undefined, - motionSamples: motionSpec ? result.motionSamples : undefined, - ...summary, - totalIssueCount: limited.totalIssueCount, - truncated: limited.truncated, - ok, - issues: limited.issues, - }), + withMeta( + { + schemaVersion: INSPECT_SCHEMA_VERSION, + duration: result.duration, + samples: result.samples, + transitionSamples: atTransitions ? result.transitionSamples : undefined, + transitionSamplesDropped: atTransitions + ? result.transitionSamplesDropped + : undefined, + tolerance, + strict, + collapseStatic, + motionSpec: motionSpec ? motionSpecPath : undefined, + motionSamples: motionSpec ? result.motionSamples : undefined, + ...summary, + totalIssueCount: limited.totalIssueCount, + truncated: limited.truncated, + ok, + issues: limited.issues, + }, + { deprecated: true }, + ), null, 2, ), @@ -639,16 +606,19 @@ export function createInspectCommand(commandName: "inspect" | "layout") { if (args.json) { console.log( JSON.stringify( - withMeta({ - schemaVersion: INSPECT_SCHEMA_VERSION, - ok: false, - error: message, - issues: [], - errorCount: 0, - warningCount: 0, - infoCount: 0, - issueCount: 0, - }), + withMeta( + { + schemaVersion: INSPECT_SCHEMA_VERSION, + ok: false, + error: message, + issues: [], + errorCount: 0, + warningCount: 0, + infoCount: 0, + issueCount: 0, + }, + { deprecated: true }, + ), null, 2, ), diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index 598d6e92e..6edeec423 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -682,7 +682,9 @@ function linkProjectIntoStudioData( } } if (!existsSync(symlinkPath)) { - symlinkSync(dir, symlinkPath, "dir"); + // Windows: "dir" symlinks need Developer Mode or elevation (EPERM otherwise); + // NTFS junctions are unprivileged and keep the live write-back the studio needs. + symlinkSync(dir, symlinkPath, process.platform === "win32" ? "junction" : "dir"); createdSymlink = true; } } diff --git a/packages/cli/src/commands/skills.test.ts b/packages/cli/src/commands/skills.test.ts index 6995f28bd..0c4b2e85d 100644 --- a/packages/cli/src/commands/skills.test.ts +++ b/packages/cli/src/commands/skills.test.ts @@ -97,6 +97,11 @@ vi.mock("../utils/skillsManifest.js", async (importOriginal) => { checkSkills: vi.fn(async () => DEFAULT_CHECK), hyperframesSkillNames: vi.fn(() => ["hyperframes"]), presentSkills: vi.fn((names: readonly string[]) => [...names]), + // Default: nothing left to prune after `runSkillsRemove`. The real + // (unmocked) fs-level behavior is covered in skillsManifest.test.ts; + // here we only assert the wiring — what update passes in, and that it + // isn't reached when there's nothing removed. + pruneOrphanedLockEntries: vi.fn(() => []), }; }); @@ -383,6 +388,81 @@ describe("hyperframes skills", () => { expect(state.spawnCalls.some((s) => s.args.includes("remove"))).toBe(false); }); + // Retired-skill regression (variant 1): the update engine's OWN targeted- + // install check must resolve the canonical (published) manifest, never a + // stale local `skills-manifest.json` a checkout might still have lying + // around — see resolveLatestManifest's in-repo shortcut. Without this, a + // skill retired upstream but still listed locally gets forced into + // `targets` (isCoreSkill pattern-matches `hyperframes-*`), `skills add` + // silently declines to install something that doesn't exist canonically, + // and the old code strict-threw on a "failure" that was never real. + it("checks freshness against the canonical manifest, never a possibly-stale local one", async () => { + setPlatform("linux"); + const { checkSkills } = await import("../utils/skillsManifest.js"); + + await runSkillsUpdate(); + + // The update engine's own check (first call) must ask for canonical; + // the prune's check (last call, tested separately) intentionally doesn't. + expect(checkSkills).toHaveBeenNthCalledWith(1, expect.objectContaining({ canonical: true })); + }); + + // Retired-skill regression (variant 2): `skills remove` is a silent no-op + // for a lock entry with no on-disk bundle (upstream scans disk, not the + // lock, to decide what's "installed" — see pruneOrphanedLockEntries's + // doc comment). `skills update` must self-heal that lock entry itself so + // `check || update` actually converges instead of re-flagging it forever. + it("self-heals an orphaned lock entry after `skills remove` no-ops on it", async () => { + setPlatform("linux"); + const { checkSkills, pruneOrphanedLockEntries } = await import("../utils/skillsManifest.js"); + vi.mocked(checkSkills) + .mockResolvedValueOnce(DEFAULT_CHECK as never) + .mockResolvedValueOnce({ + scope: "global", + skills: [{ name: "hyperframes-captions", status: "removed" }], + } as never); + vi.mocked(pruneOrphanedLockEntries).mockReturnValueOnce(["hyperframes-captions"]); + + await runSkillsUpdate(); + + expect(pruneOrphanedLockEntries).toHaveBeenCalledWith(["hyperframes-captions"], "global"); + expect(process.exitCode).toBe(0); + }); + + // The idempotent-second-run contract at the command level: once nothing is + // left attributed as removed (the fs-level idempotency of the prune itself + // is covered directly in skillsManifest.test.ts), a second `skills update` + // must be a clean no-op — no `skills remove` spawn, no prune call finding + // anything, still exit 0. + it("running update twice in a row converges — the second run prunes nothing", async () => { + setPlatform("linux"); + const { checkSkills, pruneOrphanedLockEntries } = await import("../utils/skillsManifest.js"); + vi.mocked(checkSkills) + .mockResolvedValueOnce(DEFAULT_CHECK as never) + .mockResolvedValueOnce({ + scope: "global", + skills: [{ name: "hyperframes-captions", status: "removed" }], + } as never); + vi.mocked(pruneOrphanedLockEntries).mockReturnValueOnce(["hyperframes-captions"]); + + await runSkillsUpdate(); + expect(process.exitCode).toBe(0); + expect(state.spawnCalls.some((s) => s.args.includes("remove"))).toBe(true); + + // Second run: nothing attributed as removed anymore (the lock entry was + // pruned above), so there's nothing left to reconcile. + state.spawnCalls = []; + vi.mocked(checkSkills) + .mockResolvedValueOnce(DEFAULT_CHECK as never) + .mockResolvedValueOnce({ scope: "global", skills: [] } as never); + + await runSkillsUpdate(); + expect(process.exitCode).toBe(0); + expect(state.spawnCalls.some((s) => s.args.includes("remove"))).toBe(false); + // Nothing to prune this time — pruneOrphanedLockEntries isn't even reached. + expect(pruneOrphanedLockEntries).toHaveBeenCalledTimes(1); + }); + // `update`'s prune runs the same removed-detection as `check`, so its // --source/--dir must reach the internal checkSkills() — otherwise the prune // reconciles against defaults even when the user pointed elsewhere. @@ -615,6 +695,40 @@ describe("hyperframes skills update ", () => { expect(process.exitCode).toBe(1); }); + it("a malformed canonical manifest warns distinctly, then still degrades to presence mode", async () => { + setPlatform("linux"); + const clack = await import("@clack/prompts"); + vi.mocked(clack.log.warn).mockClear(); + const { checkSkills } = await import("../utils/skillsManifest.js"); + vi.mocked(checkSkills).mockRejectedValue( + new Error("Malformed skills manifest from https://raw.githubusercontent.com/…"), + ); + + await runSkillsUpdateWith(["pr-to-video"]); + + const warnedMalformed = vi + .mocked(clack.log.warn) + .mock.calls.some((args) => String(args[0]).includes("malformed")); + expect(warnedMalformed).toBe(true); + // Still degrades rather than failing the whole command. + expect(process.exitCode).toBe(0); + }); + + it("a genuine offline error degrades silently — no malformed-manifest warning", async () => { + setPlatform("linux"); + const clack = await import("@clack/prompts"); + vi.mocked(clack.log.warn).mockClear(); + const { checkSkills } = await import("../utils/skillsManifest.js"); + vi.mocked(checkSkills).mockRejectedValue(new Error("fetch failed")); + + await runSkillsUpdateWith(["pr-to-video"]); + + const warnedMalformed = vi + .mocked(clack.log.warn) + .mock.calls.some((args) => String(args[0]).includes("malformed")); + expect(warnedMalformed).toBe(false); + }); + it("--json emits a parseable result on success", async () => { setPlatform("linux"); const logSpy = vi.spyOn(console, "log"); diff --git a/packages/cli/src/commands/skills.ts b/packages/cli/src/commands/skills.ts index d7e7e87ff..a7a4c0d72 100644 --- a/packages/cli/src/commands/skills.ts +++ b/packages/cli/src/commands/skills.ts @@ -10,6 +10,7 @@ import { hyperframesSkillNames, isCoreSkill, presentSkills, + pruneOrphanedLockEntries, SKILLS_CLI_LOCK_PATHS_VERIFIED_AT, type SkillDiff, type SkillsCheckResult, @@ -307,8 +308,29 @@ export async function updateSkills( let check: SkillsCheckResult | null = null; try { - check = await checkSkills({ cwd: opts.cwd }); - } catch { + // `canonical: true` — target selection must match what `skills add` + // actually installs from (the canonical published repo), never a local + // checkout's `skills-manifest.json`. Without this, running from inside a + // stale hyperframes checkout could resolve "latest" from that stale local + // file, which may still list a skill that's since been retired/renamed + // upstream. `isCoreSkill` would then force it into `targets`/`toInstall`, + // `skills add` would correctly (and silently) decline to install a skill + // that no longer exists, and verifyInstalled would strict-throw on a + // "failure" that was never real. Resolving canonically means a retired + // skill simply never appears as a target in the first place. + check = await checkSkills({ cwd: opts.cwd, canonical: true }); + } catch (err) { + // A *malformed* canonical manifest (the server was reached, but served a + // bad shape) is otherwise indistinguishable from being offline — both fall + // through to presence-only mode below. Surface it distinctly so ops can + // tell an upstream/CDN problem apart from a genuine network failure. + if (err instanceof Error && err.message.startsWith("Malformed skills manifest")) { + clack.log.warn( + c.warn( + "Canonical skills manifest was malformed — falling back to presence-only mode (an upstream/CDN issue, not your network).", + ), + ); + } check = null; // manifest unreachable (offline / rate-limited) — presence mode below } if (!check) return updateSkillsOffline(requested, { strict, cwd: opts.cwd }); @@ -687,6 +709,24 @@ const updateCommand = defineCommand({ c.dim(`Removing ${removed.length} skill(s) no longer published: ${removed.join(", ")}`), ); await runSkillsRemove(removed, { global: scope === "global" }); + // Self-heal: `skills remove` only clears a lock entry for a name it + // found an on-disk bundle for (see pruneOrphanedLockEntries). A skill + // retired before it ever shipped a bundle to this machine has none, so + // the call above is a silent no-op for it — the lock entry lingers and + // would be re-flagged "removed" on every future run. Prune whatever is + // still attributed after the call so `check || update` converges + // instead of looping forever. Best-effort and scoped to exactly the + // lock the remove above targeted (same `scope`); a write failure here + // must not fail the update — the install already succeeded. + const scopeForPrune = scope ?? "global"; + const stillOrphaned = pruneOrphanedLockEntries(removed, scopeForPrune); + if (stillOrphaned.length) { + console.log( + c.dim( + `Reconciled ${stillOrphaned.length} orphaned lock entr${stillOrphaned.length === 1 ? "y" : "ies"} with no on-disk bundle: ${stillOrphaned.join(", ")}`, + ), + ); + } } } catch (err) { clack.log.warn(c.warn(`Skipped removed-skill cleanup: ${(err as Error).message}`)); diff --git a/packages/cli/src/commands/snapshot.test.ts b/packages/cli/src/commands/snapshot.test.ts index 0537f1705..4cb0d394c 100644 --- a/packages/cli/src/commands/snapshot.test.ts +++ b/packages/cli/src/commands/snapshot.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { computeSnapshotTimes, tailFrameTime } from "./snapshot.js"; +import { computeSnapshotTimes, parseZoomScale, tailFrameTime } from "./snapshot.js"; + +// --zoom's crop-region math (selector bbox + padding + clamp, exact region +// form, no-match error) is owned by and tested in +// ../capture/captureCompositionFrame.test.ts alongside its implementation. describe("tailFrameTime", () => { it("backs off ~3% of duration so the final frame isn't the blank exact-end", () => { @@ -59,3 +63,19 @@ describe("computeSnapshotTimes (FINDING [7]: tail is always captured)", () => { expect(appendedTail).toBe(false); }); }); + +describe("parseZoomScale (--zoom-scale)", () => { + it("defaults to 3 when unset", () => { + expect(parseZoomScale(undefined)).toBe(3); + }); + + it("honors an explicit scale", () => { + expect(parseZoomScale("2")).toBe(2); + }); + + it("falls back to the default for invalid or non-positive input", () => { + expect(parseZoomScale("abc")).toBe(3); + expect(parseZoomScale("0")).toBe(3); + expect(parseZoomScale("-1")).toBe(3); + }); +}); diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index 51e9f3c7e..e1f34f961 100644 --- a/packages/cli/src/commands/snapshot.ts +++ b/packages/cli/src/commands/snapshot.ts @@ -4,9 +4,14 @@ import { existsSync, mkdtempSync, readFileSync, mkdirSync, rmSync, writeFileSync import { tmpdir } from "node:os"; import { resolve, join, relative, isAbsolute, basename } from "node:path"; import { + DEFAULT_ZOOM_SCALE, + captureRegionCrop, openSettledCompositionPage, + parseZoomTarget, + resolveCropRegion, runFfmpegOnce, seekCompositionTimeline, + type ZoomTarget, } from "../capture/captureCompositionFrame.js"; import { resolveProject } from "../utils/project.js"; import { normalizeErrorMessage } from "../utils/errorMessage.js"; @@ -104,8 +109,20 @@ export const examples: Example[] = [ ["Capture 5 key frames from a composition", "snapshot capture"], ["Capture 10 evenly-spaced frames", "snapshot capture --frames 10"], ["View the 3D stage from an isometric angle", "snapshot capture --angle iso"], + ["Zoom into an element for a high-density crop", "snapshot --zoom '#headline'"], + [ + "Zoom into an exact pixel region at 2x density", + "snapshot --zoom 100,50,400,300 --zoom-scale 2", + ], ]; +/** `--zoom-scale`: the deviceScaleFactor used for zoomed crops. Defaults to 3; + * falls back to the default for anything that doesn't parse as a positive number. */ +export function parseZoomScale(value: unknown): number { + const parsed = parseFloat(String(value ?? "")); + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_ZOOM_SCALE; +} + /** * Seeking the timeline to EXACTLY `data-duration` renders blank — the runtime * treats t >= clip-end as past-end and unmounts the clip (verified on a V4 3D @@ -172,6 +189,8 @@ async function captureSnapshots( outputDir?: string; angle?: Camera; includeEnd?: boolean; + zoom?: ZoomTarget; + zoomScale?: number; }, ): Promise { const { bundleToSingleHtml } = await import("@hyperframes/core/compiler"); @@ -425,7 +444,29 @@ async function captureSnapshots( const filename = `frame-${String(i).padStart(2, "0")}-at-${timeLabel}.png`; const framePath = join(snapshotDir, filename); - await page.screenshot({ path: framePath, type: "png" }); + if (opts.zoom) { + // Clip screenshot at a raised deviceScaleFactor — never CSS zoom or + // viewport resizing — so the composition's own layout is untouched. + const canvas = await page.evaluate(() => ({ + width: window.innerWidth, + height: window.innerHeight, + })); + const region = await resolveCropRegion(page, opts.zoom, canvas); + if (!region) { + console.error( + ` ${c.warn("⚠")} --zoom target has no visible box at ${time.toFixed(1)}s — frame skipped`, + ); + continue; + } + const buffer = await captureRegionCrop( + page, + region, + opts.zoomScale ?? DEFAULT_ZOOM_SCALE, + ); + writeFileSync(framePath, buffer); + } else { + await page.screenshot({ path: framePath, type: "png" }); + } const rel = relative(projectDir, framePath); savedPaths.push(rel.startsWith("..") || isAbsolute(rel) ? framePath : rel); } @@ -480,6 +521,16 @@ export default defineCommand({ "Always include a readable end-of-timeline frame (default: true). Pass --no-end to capture only your exact --at times.", default: true, }, + zoom: { + type: "string", + description: + "Zoom into a CSS selector or an exact pixel region 'x,y,w,h'. Crops a high-density screenshot instead of the full frame — a raised deviceScaleFactor, never CSS zoom or viewport resizing, so layout stays identical. A selector matching nothing is an error, not a silent full-frame shot.", + }, + "zoom-scale": { + type: "string", + description: "Device-scale-factor density for --zoom crops (default: 3)", + default: "3", + }, describe: { type: "string", description: @@ -508,6 +559,8 @@ export default defineCommand({ : String(args.describe); const camera = args.angle ? parseAngle(String(args.angle)) : undefined; + const zoomTarget = args.zoom ? parseZoomTarget(String(args.zoom)) : undefined; + const zoomScale = parseZoomScale(args["zoom-scale"]); const label = atTimestamps ? `${atTimestamps.length} frames at [${atTimestamps.map((t) => t.toFixed(1) + "s").join(", ")}]` @@ -529,6 +582,8 @@ export default defineCommand({ outputDir: snapshotDir, angle: camera, includeEnd: args.end !== false, + zoom: zoomTarget, + zoomScale, }); if (paths.length === 0) { diff --git a/packages/cli/src/commands/tts.test.ts b/packages/cli/src/commands/tts.test.ts new file mode 100644 index 000000000..341c9964b --- /dev/null +++ b/packages/cli/src/commands/tts.test.ts @@ -0,0 +1,45 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { assertKnownFlags } from "../utils/reject-unknown-flags.js"; + +const synthesizeMock = vi.fn().mockResolvedValue({ + durationSeconds: 1, + langApplied: true, + outputPath: "/tmp/speech.wav", +}); +vi.mock("../tts/synthesize.js", () => ({ synthesize: synthesizeMock })); + +import ttsCommand from "./tts.js"; + +describe("tts command", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "hf-tts-command-test-")); + synthesizeMock.mockClear(); + vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it("accepts --text-file as a compatibility alias for file input", async () => { + const input = join(dir, "script.txt"); + writeFileSync(input, "Legacy file input\n"); + + expect(() => + assertKnownFlags(ttsCommand as never, ["--text-file", input, "--json"]), + ).not.toThrow(); + await ttsCommand.run!({ args: { "text-file": input, json: true } } as never); + + expect(synthesizeMock).toHaveBeenCalledWith( + "Legacy file input", + expect.stringMatching(/speech\.wav$/), + expect.objectContaining({ lang: "en-us" }), + ); + }); +}); diff --git a/packages/cli/src/commands/tts.ts b/packages/cli/src/commands/tts.ts index 8ad0d3f41..3fa05f953 100644 --- a/packages/cli/src/commands/tts.ts +++ b/packages/cli/src/commands/tts.ts @@ -46,6 +46,10 @@ export default defineCommand({ description: "Text to speak, or path to a .txt file", required: false, }, + "text-file": { + type: "string", + description: "Read text from a .txt file (compatibility alias)", + }, output: { type: "string", description: "Output file path (default: speech.wav in current directory)", @@ -85,13 +89,14 @@ export default defineCommand({ } // ── Resolve input text ──────────────────────────────────────────── - if (!args.input) { + const input = args["text-file"] ?? args.input; + if (!input) { console.error(c.error("Provide text to speak, or use --list to see available voices.")); process.exit(1); } let text: string; - const maybeFile = resolve(args.input); + const maybeFile = resolve(input); if (existsSync(maybeFile) && extname(maybeFile).toLowerCase() === ".txt") { text = readFileSync(maybeFile, "utf-8").trim(); @@ -100,7 +105,7 @@ export default defineCommand({ process.exit(1); } } else { - text = args.input; + text = input; } if (!text.trim()) { diff --git a/packages/cli/src/commands/validate.test.ts b/packages/cli/src/commands/validate.test.ts index 7353806c4..3fe07fbfb 100644 --- a/packages/cli/src/commands/validate.test.ts +++ b/packages/cli/src/commands/validate.test.ts @@ -1,12 +1,23 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +// Imported before "./validate.js" below: validate.js's own static import of +// ../utils/project.js triggers that mocked module's factory as soon as +// validate.js loads, so resolveProjectMock/lintProjectFailureMock must +// already be bound by then (see the vi.mock calls a few lines down). +import { + lintProjectFailureMock, + metaDescription, + resolveProjectMock, + runAndCaptureStdio, + runAndParseJsonEnvelope, +} from "./deprecationTestHarness.js"; import { extractCompositionErrorsFromLint, navigationTimeoutHint, raceMediaReady, resolveNavigationTimeoutMs, shouldIgnoreRequestFailure, - waitForPreferredSeekTarget, } from "./validate.js"; +import { waitForPreferredSeekTarget } from "../capture/captureCompositionFrame.js"; import type { ProjectLintResult } from "../utils/lintProject.js"; // validateInBrowser lazy-loads the producer localize helpers via loadProducer; @@ -28,6 +39,16 @@ vi.mock("../utils/producer.js", () => ({ })), })); +// U5 deprecation tests: resolveProject and lintProject are both reached via a +// dynamic `await import(...)` inside validate.ts's run() / validateInBrowser(), +// so vi.mock intercepts them the same way it would a static import. Mocking +// resolveProject skips real filesystem project resolution; mocking lintProject +// (the first await inside validateInBrowser) gives a fast, deterministic +// failure well before any real browser or network work — exercising run()'s +// outer catch (the JSON failure envelope) without needing headless Chrome. +vi.mock("../utils/project.js", () => resolveProjectMock()); +vi.mock("../utils/lintProject.js", () => lintProjectFailureMock()); + // Regression for the validate audio-duration-probe timeout: a slow-loading // media element's duration was snapshotted once, at a fixed point in time, // and any element still mid-load was permanently misreported as unreadable. @@ -131,6 +152,16 @@ describe("waitForPreferredSeekTarget", () => { await expect(waitForPreferredSeekTarget(page, 1)).resolves.toBeUndefined(); }); + + it("does not fail validation when the page stub throws synchronously", async () => { + const page = { + waitForFunction: vi.fn(() => { + throw new Error("waiting failed synchronously"); + }), + }; + + await expect(waitForPreferredSeekTarget(page, 1)).resolves.toBeUndefined(); + }); }); describe("extractCompositionErrorsFromLint", () => { @@ -272,3 +303,29 @@ describe("navigationTimeoutHint", () => { expect(navigationTimeoutHint("some string failure", 10000)).toBeNull(); }); }); + +describe("validate command deprecation (U5)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("marks the command description as deprecated", async () => { + const { default: validateCommand } = await import("./validate.js"); + expect(metaDescription(validateCommand)).toContain("(deprecated, use check)"); + }); + + it("prints a one-line deprecation notice to stderr and never to stdout", async () => { + const { default: validateCommand } = await import("./validate.js"); + const { stderrText, stdoutText } = await runAndCaptureStdio(validateCommand); + expect(stderrText).toContain("hyperframes validate"); + expect(stderrText).toContain("hyperframes check"); + expect(stdoutText).toBe(""); + }); + + it("--json output is valid JSON with _meta.deprecated === true on failure", async () => { + const { default: validateCommand } = await import("./validate.js"); + const { parsed } = await runAndParseJsonEnvelope(validateCommand); + expect(parsed.ok).toBe(false); + expect(parsed._meta.deprecated).toBe(true); + }); +}); diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 5f10ae3a9..962461571 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -1,3 +1,8 @@ +// The media-metadata wait exists twice on purpose: once Node-side and once +// inside a page.evaluate() body, which is serialized into the browser and +// cannot import the Node helper. Line-level markers don't survive the clone +// window drifting as the file is edited, hence the file-level suppression. +// fallow-ignore-file code-duplication import { defineCommand } from "citty"; import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -8,7 +13,12 @@ import { normalizeErrorMessage } from "../utils/errorMessage.js"; import type { ProjectLintResult } from "../utils/lintProject.js"; import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js"; import { c } from "../ui/colors.js"; -import { withMeta } from "../utils/updateCheck.js"; +import { printDeprecationNotice, withMeta } from "../utils/updateCheck.js"; +import { + installPageFunctionGuard, + resolveCliChromeGpuMode, + seekCompositionTimeline, +} from "../capture/captureCompositionFrame.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -85,67 +95,6 @@ async function getCompositionDuration(page: import("puppeteer-core").Page): Prom }); } -async function seekTo(page: import("puppeteer-core").Page, time: number): Promise { - await waitForPreferredSeekTarget(page); - await page.evaluate((t: number) => { - // window.__player.renderSeek is exposed directly by the composition - // runtime (packages/core/src/runtime/init.ts) on every page load, and - // — unlike raw timeline.seek() — it also runs the runtime's own - // [data-start]/[data-duration] visibility sync, hiding clips outside - // their timeline window. window.__hf.seek only exists when the - // producer's render-pipeline bridge script has been injected, which - // validate's static preview server never does, so it was always - // falling through to the raw __timelines seek below and skipping that - // sync — leaving off-window elements looking fully visible to any - // check (e.g. the contrast audit) that reads computed style afterward. - const player = (window as unknown as { __player?: { renderSeek?: (t: number) => void } }) - .__player; - if (player && typeof player.renderSeek === "function") { - player.renderSeek(t); - return; - } - if (window.__hf && typeof window.__hf.seek === "function") { - window.__hf.seek(t); - return; - } - const timelines = (window as unknown as Record).__timelines as - | Record void }> - | undefined; - if (timelines) { - for (const tl of Object.values(timelines)) { - if (typeof tl.seek === "function") tl.seek(t); - } - } - }, time); - await new Promise((r) => setTimeout(r, SEEK_SETTLE_MS)); -} - -interface WaitForFunctionPage { - waitForFunction: (pageFunction: () => boolean, options: { timeout: number }) => Promise; -} - -export async function waitForPreferredSeekTarget( - page: WaitForFunctionPage, - timeoutMs = PREFERRED_SEEK_TARGET_WAIT_MS, -): Promise { - try { - await page.waitForFunction( - () => { - const w = window as unknown as { - __hf?: { seek?: unknown }; - __player?: { renderSeek?: unknown }; - }; - return typeof w.__player?.renderSeek === "function" || typeof w.__hf?.seek === "function"; - }, - { timeout: timeoutMs }, - ); - } catch { - // Older/static pages may only expose raw window.__timelines. Keep the - // legacy fallback path rather than turning a missing player API into a - // validate failure. - } -} - /** * Race a media element's `loadedmetadata`/`error` event against a deadline, * whichever comes first. Already-ready elements resolve immediately. @@ -183,7 +132,7 @@ export function raceMediaReady( * the live page to read each element's intrinsic `.duration`, which static lint * can't see. */ -async function auditClipDurations( +export async function auditClipDurations( page: import("puppeteer-core").Page, analyzeClipMediaFit: typeof import("@hyperframes/engine").analyzeClipMediaFit, extraWaitMs: number, @@ -209,7 +158,6 @@ async function auditClipDurations( nodes.map((el) => { if (Number.isFinite(el.duration) && el.duration > 0) return Promise.resolve(); return new Promise((resolve) => { - // fallow-ignore-next-line code-duplication const cleanup = () => { el.removeEventListener("loadedmetadata", onReady); el.removeEventListener("error", onReady); @@ -300,7 +248,12 @@ async function runContrastAudit(page: import("puppeteer-core").Page): Promise { @@ -559,13 +514,16 @@ function emitJsonReport( ): void { console.log( JSON.stringify( - withMeta({ - ok: errors.length === 0, - errors, - warnings, - contrast, - contrastFailures: contrastFailures.length, - }), + withMeta( + { + ok: errors.length === 0, + errors, + warnings, + contrast, + contrastFailures: contrastFailures.length, + }, + { deprecated: true }, + ), null, 2, ), @@ -612,7 +570,11 @@ function emitTextReport( function emitFailureReport(message: string, asJson: boolean): void { if (asJson) { console.log( - JSON.stringify(withMeta({ ok: false, error: message, errors: [], warnings: [] }), null, 2), + JSON.stringify( + withMeta({ ok: false, error: message, errors: [], warnings: [] }, { deprecated: true }), + null, + 2, + ), ); return; } @@ -622,7 +584,7 @@ function emitFailureReport(message: string, asJson: boolean): void { export default defineCommand({ meta: { name: "validate", - description: `Load a composition in headless Chrome and report console errors + description: `Load a composition in headless Chrome and report console errors (deprecated, use check) Examples: hyperframes validate @@ -647,6 +609,7 @@ Examples: }, }, async run({ args }) { + printDeprecationNotice("validate"); const project = resolveProject(args.dir); const timeout = parseInt(args.timeout as string, 10) || 3000; const useContrast = args.contrast ?? true; diff --git a/packages/cli/src/telemetry/config.ts b/packages/cli/src/telemetry/config.ts index 863f3cee3..f2e03915f 100644 --- a/packages/cli/src/telemetry/config.ts +++ b/packages/cli/src/telemetry/config.ts @@ -63,6 +63,8 @@ export interface HyperframesConfig { skillsOutdatedCount?: number; /** How many skills were missing (not installed) at the last check. */ skillsMissingCount?: number; + /** How many installed skills were flagged removed-upstream at the last check. */ + skillsRemovedCount?: number; /** * True once the DE parallel-router experiment ("HF_DE_PARALLEL_ROUTER") * has actually FAILED (its self-verify/generic-failure safety net fired — @@ -126,6 +128,7 @@ export function readConfig(): HyperframesConfig { skillsUpdateAvailable: parsed.skillsUpdateAvailable, skillsOutdatedCount: parsed.skillsOutdatedCount, skillsMissingCount: parsed.skillsMissingCount, + skillsRemovedCount: parsed.skillsRemovedCount, // Explicit `=== true`/typeof-number checks rather than a truthy/nullish // read — a hand-edited or corrupted config could plausibly carry a // non-boolean/non-number JSON value (e.g. the STRING "false", which is diff --git a/packages/cli/src/telemetry/events.test.ts b/packages/cli/src/telemetry/events.test.ts index 0977140fd..1cf71a245 100644 --- a/packages/cli/src/telemetry/events.test.ts +++ b/packages/cli/src/telemetry/events.test.ts @@ -12,6 +12,9 @@ vi.mock("./config.js", () => ({ })); const { + trackCommand, + trackCommandResult, + trackCheckReport, trackRenderComplete, trackRenderError, trackRenderObservation, @@ -26,6 +29,148 @@ const { identifyUser, } = await import("./events.js"); +describe("command telemetry events", () => { + beforeEach(() => { + trackEvent.mockClear(); + }); + + it("includes run_id in cli_command when a run ID is provided", () => { + trackCommand("check", "run-123"); + + expect(trackEvent).toHaveBeenCalledWith("cli_command", { + command: "check", + run_id: "run-123", + }); + }); + + it("omits run_id from cli_command when no run ID is provided", () => { + trackCommand("check"); + + const properties = trackEvent.mock.lastCall?.[1]; + expect(properties).not.toHaveProperty("run_id"); + }); + + it("includes run_id in cli_command_result when a run ID is provided", () => { + trackCommandResult({ + command: "check", + success: true, + exitCode: 0, + durationMs: 42, + runId: "run-123", + }); + + expect(trackEvent).toHaveBeenCalledWith("cli_command_result", { + command: "check", + success: true, + exit_code: 0, + duration_ms: 42, + run_id: "run-123", + }); + }); + + it("omits run_id from cli_command_result when no run ID is provided", () => { + trackCommandResult({ + command: "check", + success: false, + exitCode: 1, + durationMs: 42, + }); + + const properties = trackEvent.mock.lastCall?.[1]; + expect(properties).not.toHaveProperty("run_id"); + }); +}); + +describe("trackCheckReport", () => { + beforeEach(() => { + trackEvent.mockClear(); + }); + + it("emits the check breakdown with snake_case properties and a run ID", () => { + trackCheckReport({ + contrastGate: true, + motionGate: false, + captionZoneGate: true, + frameCheckGate: false, + snapshotsGate: true, + lintErrors: 1, + lintWarnings: 2, + runtimeErrors: 3, + runtimeWarnings: 4, + layoutErrors: 5, + layoutWarnings: 6, + motionErrors: 7, + motionWarnings: 8, + contrastErrors: 9, + contrastWarnings: 10, + launchSettleMs: 11, + seekLoopMs: 12, + contrastMs: 13, + gridPoints: 14, + contrastPoints: 15, + ok: false, + exitCode: 1, + runId: "run-123", + }); + + expect(trackEvent).toHaveBeenCalledWith("check_report", { + gate_contrast: true, + gate_motion: false, + gate_caption_zone: true, + gate_frame_check: false, + gate_snapshots: true, + lint_errors: 1, + lint_warnings: 2, + runtime_errors: 3, + runtime_warnings: 4, + layout_errors: 5, + layout_warnings: 6, + motion_errors: 7, + motion_warnings: 8, + contrast_errors: 9, + contrast_warnings: 10, + launch_settle_ms: 11, + seek_loop_ms: 12, + contrast_ms: 13, + grid_points: 14, + contrast_points: 15, + ok: false, + exit_code: 1, + run_id: "run-123", + }); + }); + + it("omits run_id when no run ID is provided", () => { + trackCheckReport({ + contrastGate: false, + motionGate: false, + captionZoneGate: false, + frameCheckGate: false, + snapshotsGate: false, + lintErrors: 0, + lintWarnings: 0, + runtimeErrors: 0, + runtimeWarnings: 0, + layoutErrors: 0, + layoutWarnings: 0, + motionErrors: 0, + motionWarnings: 0, + contrastErrors: 0, + contrastWarnings: 0, + launchSettleMs: 0, + seekLoopMs: 0, + contrastMs: 0, + gridPoints: 0, + contrastPoints: 0, + ok: true, + exitCode: 0, + }); + + const properties = trackEvent.mock.lastCall?.[1]; + expect(properties).not.toHaveProperty("run_id"); + }); +}); + describe("render telemetry events", () => { beforeEach(() => { trackEvent.mockClear(); diff --git a/packages/cli/src/telemetry/events.ts b/packages/cli/src/telemetry/events.ts index 0db7d7298..917fd988a 100644 --- a/packages/cli/src/telemetry/events.ts +++ b/packages/cli/src/telemetry/events.ts @@ -3,6 +3,12 @@ import type { SubTimelineWaitOutcome } from "@hyperframes/engine"; import { trackEvent } from "./client.js"; import { readConfig } from "./config.js"; +// run_id is attached only when the orchestrator set HYPERFRAMES_RUN_ID — an +// absent property, never null/"" (PostHog treats those as real values). +function runIdField(runId: string | undefined): { run_id?: string } { + return runId !== undefined ? { run_id: runId } : {}; +} + export interface RenderObservabilityTelemetryPayload { /** Worst sub-composition timeline wait outcome across sessions. */ subTimelineWait?: SubTimelineWaitOutcome; @@ -117,8 +123,11 @@ function redactTelemetryMessage(value: string): string { return redactTelemetryString(value); } -export function trackCommand(command: string): void { - trackEvent("cli_command", { command }); +export function trackCommand(command: string, runId?: string): void { + trackEvent("cli_command", { + command, + ...runIdField(runId), + }); } export function trackRenderComplete( @@ -567,11 +576,65 @@ export function trackCommandResult(props: { success: boolean; exitCode: number; durationMs: number; + runId?: string; }): void { trackEvent("cli_command_result", { command: props.command, success: props.success, exit_code: props.exitCode, duration_ms: props.durationMs, + ...runIdField(props.runId), + }); +} + +export function trackCheckReport(props: { + contrastGate: boolean; + motionGate: boolean; + captionZoneGate: boolean; + frameCheckGate: boolean; + snapshotsGate: boolean; + lintErrors: number; + lintWarnings: number; + runtimeErrors: number; + runtimeWarnings: number; + layoutErrors: number; + layoutWarnings: number; + motionErrors: number; + motionWarnings: number; + contrastErrors: number; + contrastWarnings: number; + launchSettleMs: number; + seekLoopMs: number; + contrastMs: number; + gridPoints: number; + contrastPoints: number; + ok: boolean; + exitCode: number; + runId?: string; +}): void { + trackEvent("check_report", { + gate_contrast: props.contrastGate, + gate_motion: props.motionGate, + gate_caption_zone: props.captionZoneGate, + gate_frame_check: props.frameCheckGate, + gate_snapshots: props.snapshotsGate, + lint_errors: props.lintErrors, + lint_warnings: props.lintWarnings, + runtime_errors: props.runtimeErrors, + runtime_warnings: props.runtimeWarnings, + layout_errors: props.layoutErrors, + layout_warnings: props.layoutWarnings, + motion_errors: props.motionErrors, + motion_warnings: props.motionWarnings, + contrast_errors: props.contrastErrors, + contrast_warnings: props.contrastWarnings, + launch_settle_ms: props.launchSettleMs, + seek_loop_ms: props.seekLoopMs, + contrast_ms: props.contrastMs, + grid_points: props.gridPoints, + contrast_points: props.contrastPoints, + ok: props.ok, + exit_code: props.exitCode, + ...runIdField(props.runId), }); } diff --git a/packages/cli/src/telemetry/runId.test.ts b/packages/cli/src/telemetry/runId.test.ts new file mode 100644 index 000000000..6a97b0726 --- /dev/null +++ b/packages/cli/src/telemetry/runId.test.ts @@ -0,0 +1,73 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const originalRunId = process.env["HYPERFRAMES_RUN_ID"]; + +async function loadGetRunId() { + const { getRunId } = await import("./runId.js"); + return getRunId; +} + +describe("getRunId", () => { + beforeEach(() => { + delete process.env["HYPERFRAMES_RUN_ID"]; + vi.resetModules(); + }); + + afterEach(() => { + if (originalRunId === undefined) delete process.env["HYPERFRAMES_RUN_ID"]; + else process.env["HYPERFRAMES_RUN_ID"] = originalRunId; + vi.resetModules(); + }); + + it("returns undefined when HYPERFRAMES_RUN_ID is unset", async () => { + const getRunId = await loadGetRunId(); + + expect(getRunId()).toBeUndefined(); + }); + + it("returns undefined when HYPERFRAMES_RUN_ID contains only whitespace", async () => { + process.env["HYPERFRAMES_RUN_ID"] = " \t\n "; + const getRunId = await loadGetRunId(); + + expect(getRunId()).toBeUndefined(); + }); + + it("returns a normal HYPERFRAMES_RUN_ID value", async () => { + process.env["HYPERFRAMES_RUN_ID"] = "run-123"; + const getRunId = await loadGetRunId(); + + expect(getRunId()).toBe("run-123"); + }); + + it("truncates HYPERFRAMES_RUN_ID to exactly 128 characters", async () => { + process.env["HYPERFRAMES_RUN_ID"] = "x".repeat(160); + const getRunId = await loadGetRunId(); + + expect(getRunId()).toBe("x".repeat(128)); + expect(getRunId()).toHaveLength(128); + }); + + it("trims whitespace around a real HYPERFRAMES_RUN_ID value", async () => { + process.env["HYPERFRAMES_RUN_ID"] = " run-123 \n"; + const getRunId = await loadGetRunId(); + + expect(getRunId()).toBe("run-123"); + }); + + it("memoizes the first environment read", async () => { + process.env["HYPERFRAMES_RUN_ID"] = "first-run"; + const getRunId = await loadGetRunId(); + + expect(getRunId()).toBe("first-run"); + process.env["HYPERFRAMES_RUN_ID"] = "second-run"; + expect(getRunId()).toBe("first-run"); + }); + + it("memoizes an initial undefined environment read", async () => { + const getRunId = await loadGetRunId(); + + expect(getRunId()).toBeUndefined(); + process.env["HYPERFRAMES_RUN_ID"] = "later-run"; + expect(getRunId()).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/telemetry/runId.ts b/packages/cli/src/telemetry/runId.ts new file mode 100644 index 000000000..5c5e307fb --- /dev/null +++ b/packages/cli/src/telemetry/runId.ts @@ -0,0 +1,12 @@ +let resolved = false; +let runId: string | undefined; + +export function getRunId(): string | undefined { + if (!resolved) { + const value = process.env["HYPERFRAMES_RUN_ID"]?.trim().slice(0, 128); + runId = value ? value : undefined; + resolved = true; + } + + return runId; +} diff --git a/packages/cli/src/templates/_shared/AGENTS.md b/packages/cli/src/templates/_shared/AGENTS.md index a30e02dbe..3e8a31aba 100644 --- a/packages/cli/src/templates/_shared/AGENTS.md +++ b/packages/cli/src/templates/_shared/AGENTS.md @@ -33,7 +33,7 @@ The domain skills (`/hyperframes-core`, `/hyperframes-animation`, `/hyperframes- ```bash npm run dev # start the preview server (long-running — keep it alive in background) -npm run check # lint + validate + inspect +npm run check # lint + runtime + layout + motion + contrast (one command) npm run render # render to MP4 npm run publish # publish and get a shareable link npx hyperframes lint --verbose # include info-level findings @@ -76,7 +76,7 @@ After creating or editing any `.html` composition, **always** run the full check npm run check ``` -Fix all errors before presenting the result. Inspect warnings should be reviewed before rendering. +Fix all errors before presenting the result. Warnings should be reviewed before rendering. ## Key Rules diff --git a/packages/cli/src/templates/_shared/CLAUDE.md b/packages/cli/src/templates/_shared/CLAUDE.md index a30e02dbe..3e8a31aba 100644 --- a/packages/cli/src/templates/_shared/CLAUDE.md +++ b/packages/cli/src/templates/_shared/CLAUDE.md @@ -33,7 +33,7 @@ The domain skills (`/hyperframes-core`, `/hyperframes-animation`, `/hyperframes- ```bash npm run dev # start the preview server (long-running — keep it alive in background) -npm run check # lint + validate + inspect +npm run check # lint + runtime + layout + motion + contrast (one command) npm run render # render to MP4 npm run publish # publish and get a shareable link npx hyperframes lint --verbose # include info-level findings @@ -76,7 +76,7 @@ After creating or editing any `.html` composition, **always** run the full check npm run check ``` -Fix all errors before presenting the result. Inspect warnings should be reviewed before rendering. +Fix all errors before presenting the result. Warnings should be reviewed before rendering. ## Key Rules diff --git a/packages/cli/src/ui/format.ts b/packages/cli/src/ui/format.ts index 0ba33827c..e76792ac3 100644 --- a/packages/cli/src/ui/format.ts +++ b/packages/cli/src/ui/format.ts @@ -46,7 +46,16 @@ export function label(name: string, value: string): string { export function errorBox(title: string, hint?: string, suggestion?: string): void { console.error(`\n${c.error("\u2717")} ${c.bold(title)}`); - if (hint) console.error(`\n ${c.dim(hint)}`); + if (hint) { + // Indent EVERY hint line, not just the first \u2014 a multi-line hint (e.g. the + // NO_TOKEN numbered setup list) otherwise had line 1 indented and the rest + // flush-left, mangling the list. Single-line hints are unchanged. + const indented = hint + .split("\n") + .map((line) => ` ${line}`) + .join("\n"); + console.error(`\n${c.dim(indented)}`); + } if (suggestion) console.error(` ${c.accent(suggestion)}`); console.error(); } diff --git a/packages/cli/src/utils/checkBrowser.test.ts b/packages/cli/src/utils/checkBrowser.test.ts new file mode 100644 index 000000000..3a2a3f0d5 --- /dev/null +++ b/packages/cli/src/utils/checkBrowser.test.ts @@ -0,0 +1,302 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + openSettledCompositionPage, + type OpenSettledCompositionPageOptions, +} from "../capture/captureCompositionFrame.js"; +import { DEFAULT_CHECK_OPTIONS, runAuditGrid } from "./checkPipeline.js"; +import { captureOverviewShot, runBrowserCheck } from "./checkBrowser.js"; +import type { ProjectDir } from "./project.js"; + +const mocks = vi.hoisted(() => ({ + serverClose: vi.fn(async () => undefined), +})); + +vi.mock("@hyperframes/core/compiler", () => ({ + bundleToSingleHtml: vi.fn(async () => ""), +})); + +vi.mock("../capture/captureCompositionFrame.js", async (importOriginal) => ({ + // Partial mock: constants (AUDIT_SEEK_OPTIONS, DEFAULT_ZOOM_*) stay real so + // they remain single-sourced; only the browser-touching functions are faked. + ...(await importOriginal()), + openSettledCompositionPage: vi.fn(), + resolveCliChromeGpuMode: vi.fn(() => "hardware"), + seekCompositionTimeline: vi.fn(async () => undefined), + waitForPreferredSeekTarget: vi.fn(async () => undefined), +})); + +vi.mock("../commands/validate.js", async (importOriginal) => ({ + // Partial mock: shouldIgnoreRequestFailure stays real; the clip audit is + // faked so tests control its findings without loading real media. + ...(await importOriginal()), + auditClipDurations: vi.fn(async () => [] as Array<{ level: "error" | "warning"; text: string }>), +})); + +vi.mock("./staticProjectServer.js", () => ({ + serveStaticProjectHtml: vi.fn(async () => ({ + url: "http://127.0.0.1:3000", + close: mocks.serverClose, + })), +})); + +const PROJECT: ProjectDir = { + dir: "/project", + name: "project", + indexPath: "/project/index.html", +}; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + document.body.innerHTML = ""; + Reflect.deleteProperty(window, "__hyperframesGeometryCandidates"); + Reflect.deleteProperty(window, "__hyperframesLayoutAudit"); + Reflect.deleteProperty(window, "__contrastAuditPrepare"); + Reflect.deleteProperty(window, "__contrastAuditFinish"); + Reflect.deleteProperty(window, "__contrastAuditRestores"); + Reflect.deleteProperty(window, "__contrastAuditRestoreIfPending"); +}); + +function installSessionMock(page: ReturnType): void { + const browser = Object.assign(Object.create(null), { + close: vi.fn(async () => undefined), + }); + vi.mocked(openSettledCompositionPage).mockImplementation( + async (_html: string, _url: string, options: OpenSettledCompositionPageOptions) => { + await options.beforeNavigate?.(page); + return { page, browser, renderReadyTimedOut: false }; + }, + ); +} + +function mountCanvasFixture(inner = ""): void { + document.body.innerHTML = ` +
${inner}
+ `; + Object.defineProperty(window, "innerWidth", { configurable: true, value: 640 }); + Object.defineProperty(window, "innerHeight", { configurable: true, value: 360 }); +} + +it("carries raw browser geometry through the page driver and pipeline", async () => { + vi.spyOn(Date, "now") + .mockReturnValueOnce(100) + .mockReturnValueOnce(160) + .mockReturnValueOnce(200) + .mockReturnValueOnce(240); + mountCanvasFixture(` +
+ +
+ `); + installRects(); + const page = fakePage(); + installSessionMock(page); + + const result = await runBrowserCheck( + PROJECT, + { ...DEFAULT_CHECK_OPTIONS, samples: 1, contrast: false, frameCheck: {} }, + { kind: "none" }, + runAuditGrid, + ); + + expect(result.layoutIssues).toEqual([ + expect.objectContaining({ + code: "frame_out_of_frame", + severity: "warning", + selector: "#hero-image", + sourceFile: "scenes/hero.html", + dataAttributes: { "data-layout-name": "hero" }, + bbox: { x: 600, y: 80, width: 200, height: 100 }, + rect: { left: 600, top: 80, right: 800, bottom: 180, width: 200, height: 100 }, + overflow: { right: 160 }, + time: 5, + }), + ]); + expect(result.timings).toEqual({ launchSettleMs: 60, seekLoopMs: 40, contrastMs: 0 }); + expect(mocks.serverClose).toHaveBeenCalledOnce(); +}); + +it("round-trips the browser script's raw contrast candidates back into finish", async () => { + // The U2 regression class: Node parses prepare's candidates for reporting, + // but must hand the UNTOUCHED objects back to __contrastAuditFinish — the + // page script samples pixels via its own bbox shape (w/h). A normalized + // candidate (width/height) makes every sample rect NaN and the audit + // silently reports zero checked elements as green. + vi.spyOn(Date, "now").mockReturnValue(100); + mountCanvasFixture(` +
Readable copy
+ `); + const root = document.querySelector("[data-composition-id]"); + const headline = document.querySelector("#headline"); + if (!root || !headline) throw new Error("Contrast fixture failed to mount"); + vi.spyOn(root, "getBoundingClientRect").mockReturnValue(new DOMRect(0, 0, 640, 360)); + vi.spyOn(headline, "getBoundingClientRect").mockReturnValue(new DOMRect(50, 50, 300, 40)); + vi.spyOn(window, "getComputedStyle").mockImplementation( + () => + ({ + display: "block", + visibility: "visible", + opacity: "1", + color: "rgb(255,255,255)", + fill: "", + backgroundColor: "rgba(0,0,0,0)", + backgroundImage: "none", + fontSize: "32px", + fontWeight: "700", + }) as unknown as CSSStyleDeclaration, + ); + + // happy-dom can't decode PNGs: stub Image (sync onload) and the canvas 2D + // context the way layout-audit.browser.test.ts's contrast harness does, so + // the REAL __contrastAuditFinish runs its sampling path end to end. + class MockImage { + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + naturalWidth = 640; + naturalHeight = 360; + + set src(_value: string) { + this.onload?.(); + } + } + vi.stubGlobal("Image", MockImage); + const getContextSpy = vi.spyOn(HTMLCanvasElement.prototype, "getContext") as unknown as { + mockReturnValue(value: CanvasRenderingContext2D): void; + }; + getContextSpy.mockReturnValue({ + drawImage() {}, + getImageData() { + return { data: new Uint8ClampedArray(640 * 360 * 4).fill(255) }; + }, + } as unknown as CanvasRenderingContext2D); + + const received: Array> = []; + const page = fakePage(); + const injectScript = page.addScriptTag; + page.addScriptTag = vi.fn(async (arg: { content: string }) => { + await injectScript(arg); + const w = window as unknown as { + __contrastAuditFinish?: ((...args: unknown[]) => Promise) & { wrapped?: boolean }; + }; + const finish = w.__contrastAuditFinish; + if (finish && !finish.wrapped) { + const wrapper = Object.assign( + async (...args: unknown[]) => { + const candidates = args[2]; + if (Array.isArray(candidates)) { + received.push(...(candidates as Array>)); + } + return finish(...args); + }, + { wrapped: true }, + ); + w.__contrastAuditFinish = wrapper; + } + }); + page.screenshot = vi.fn(async () => "c3R1Yg=="); + installSessionMock(page); + + await runBrowserCheck( + PROJECT, + { ...DEFAULT_CHECK_OPTIONS, samples: 1, contrast: true }, + { kind: "none" }, + runAuditGrid, + ); + + expect(received.length).toBeGreaterThan(0); + for (const candidate of received) { + const bbox = candidate.bbox as Record; + // The page script's own shape (w/h), not Node's envelope shape (width/height): + expect(typeof bbox.w).toBe("number"); + expect(typeof bbox.h).toBe("number"); + } +}); + +it("carries validate's clip-duration audit into the runtime findings", async () => { + vi.spyOn(Date, "now").mockReturnValue(100); + mountCanvasFixture(); + const validateModule = await import("../commands/validate.js"); + vi.mocked(validateModule.auditClipDurations).mockResolvedValue([ + { + level: "warning", + text: "Audio is 22.10s but its slot (data-duration) is 30.00s — the slot is shortened to the media length when rendered.", + }, + ]); + const page = fakePage(); + installSessionMock(page); + + const result = await runBrowserCheck( + PROJECT, + { ...DEFAULT_CHECK_OPTIONS, samples: 1, contrast: false }, + { kind: "none" }, + runAuditGrid, + ); + + expect(result.runtimeFindings).toEqual([ + expect.objectContaining({ + code: "clip_media_fit", + severity: "warning", + message: expect.stringContaining("slot is shortened"), + }), + ]); +}); + +describe("captureOverviewShot", () => { + it("injects the annotation overlay before the overview shot and removes it right after", async () => { + const calls: string[] = []; + const evaluate = vi.fn(async (fn: unknown, ...args: unknown[]) => { + calls.push("evaluate"); + return typeof fn === "function" ? Reflect.apply(fn, undefined, args) : undefined; + }); + const screenshot = vi.fn(async () => { + calls.push("screenshot"); + return "annotated-base64"; + }); + const page = Object.assign(Object.create(null), { evaluate, screenshot }); + + const result = await captureOverviewShot( + page, + [{ label: "1 clipped_text", bbox: { x: 0, y: 0, width: 10, height: 10 } }], + "measurement-base64", + ); + + // inject overlay -> take the shot -> remove overlay, in that order — + // never present while any audit (which runs before this is called) collects. + expect(calls).toEqual(["evaluate", "screenshot", "evaluate"]); + expect(result).toBe("annotated-base64"); + }); + + it("skips the overlay entirely and returns the plain screenshot when there's nothing to annotate", async () => { + const evaluate = vi.fn(); + const screenshot = vi.fn(); + const page = Object.assign(Object.create(null), { evaluate, screenshot }); + + const result = await captureOverviewShot(page, [], "measurement-base64"); + + expect(evaluate).not.toHaveBeenCalled(); + expect(screenshot).not.toHaveBeenCalled(); + expect(result).toBe("measurement-base64"); + }); +}); + +function installRects(): void { + const root = document.querySelector("[data-composition-id]"); + const image = document.querySelector("#hero-image"); + if (!root || !image) throw new Error("Geometry fixture failed to mount"); + vi.spyOn(root, "getBoundingClientRect").mockReturnValue(new DOMRect(0, 0, 640, 360)); + vi.spyOn(image, "getBoundingClientRect").mockReturnValue(new DOMRect(600, 80, 200, 100)); +} + +function fakePage() { + return Object.assign(Object.create(null), { + on: vi.fn(), + addScriptTag: vi.fn(async ({ content }: { content: string }) => { + window.eval(content); + }), + evaluate: vi.fn(async (callback: unknown, ...args: unknown[]) => { + if (typeof callback !== "function") throw new Error("Expected an evaluate callback"); + return Reflect.apply(callback, window, args); + }), + }); +} diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts new file mode 100644 index 000000000..d889e41b7 --- /dev/null +++ b/packages/cli/src/utils/checkBrowser.ts @@ -0,0 +1,1020 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { Page } from "puppeteer-core"; +import { + AUDIT_SEEK_OPTIONS, + DEFAULT_ZOOM_PADDING_PX, + DEFAULT_ZOOM_SCALE, + captureRegionCrop, + openSettledCompositionPage, + padCropRegion, + resolveCliChromeGpuMode, + seekCompositionTimeline, + waitForPreferredSeekTarget, +} from "../capture/captureCompositionFrame.js"; +import { auditClipDurations, shouldIgnoreRequestFailure } from "../commands/validate.js"; +import { loadBrowserScript } from "../commands/layout.js"; +import { normalizeErrorMessage } from "./errorMessage.js"; +import { ambiguousIssue, type MotionFrame } from "./motionAudit.js"; +import type { LayoutIssue, LayoutIssueCode, LayoutRect } from "./layoutAudit.js"; +import { serveStaticProjectHtml } from "./staticProjectServer.js"; +import { rectToBbox } from "./checkTypes.js"; +import type { + AnchoredLayoutIssue, + CheckAnchor, + CheckAnnotationBox, + CheckAuditDriver, + CheckBbox, + CheckBrowserResult, + CheckFinding, + CheckFindingCropRequest, + CheckGeometryCandidate, + CheckOptions, + CheckSeverity, + ContrastAuditEntry, + ContrastCapture, + GeometryCandidateRequest, + MotionSpecResolution, + RunAuditGrid, +} from "./checkTypes.js"; +import type { ProjectDir } from "./project.js"; + +interface RuntimeDraft { + code: string; + severity: CheckSeverity; + message: string; + time: number; + url?: string; + line?: number; +} + +interface AnchorRequest { + selector: string; + time: number; + bbox: CheckBbox; +} + +interface ContrastCandidate { + selector: string; + text: string; + fg: [number, number, number, number]; + large: boolean; + bbox: CheckBbox; +} + +interface PreparedContrast { + // The untouched candidate object from __contrastAuditPrepare. It round-trips + // back into __contrastAuditFinish verbatim — the browser script owns its + // shape (e.g. bbox uses w/h, not width/height), so Node must not normalize + // what it sends back. `candidate` is the parsed copy for Node-side reporting. + raw: unknown; + candidate: ContrastCandidate; + anchor: CheckAnchor; +} + +interface FinishedContrast { + selector: string; + text: string; + ratio: number; + wcagAA: boolean; + large: boolean; + fg: string; + bg: string; +} + +export async function runBrowserCheck( + project: ProjectDir, + options: CheckOptions, + motion: MotionSpecResolution, + runGrid: RunAuditGrid, +): Promise { + const { bundleToSingleHtml } = await import("@hyperframes/core/compiler"); + const html = await bundleToSingleHtml(project.dir); + const server = await serveStaticProjectHtml(project.dir, html, "Failed to bind check server"); + const drafts: RuntimeDraft[] = []; + let currentTime = 0; + let chromeBrowser: import("puppeteer-core").Browser | undefined; + + try { + const launchSettleStart = Date.now(); + const session = await openSettledCompositionPage(html, server.url, { + renderReadyTimeoutMs: options.timeout, + renderReadyWarningSuffix: "checking the current page state", + browserGpuMode: resolveCliChromeGpuMode(), + beforeNavigate: (page) => wireRuntimeListeners(page, drafts, () => currentTime), + }); + chromeBrowser = session.browser; + const page = session.page; + await waitForPreferredSeekTarget(page); + + const rootAnchor = await resolveRootAnchor(page); + const launchSettleMs = Date.now() - launchSettleStart; + // validate's per-media-element audit, kept in the consolidation: a clip + // whose intrinsic duration is meaningfully shorter than its data-duration + // slot silently shortens the slot at render time — invisible to lint (no + // intrinsic durations statically) and to the runtime listeners (nothing + // errors). The session is already open, so this is one extra evaluate. + const { analyzeClipMediaFit } = await import("@hyperframes/engine"); + for (const entry of await auditClipDurations(page, analyzeClipMediaFit, options.timeout)) { + drafts.push({ code: "clip_media_fit", severity: entry.level, message: entry.text, time: 0 }); + } + const driver = createPageDriver(page, (time) => { + currentTime = time; + }); + const result = await runGrid(driver, options, motion); + return { + ...result, + timings: { ...result.timings, launchSettleMs }, + runtimeFindings: drafts.map((draft) => runtimeFinding(draft, rootAnchor)), + }; + } finally { + await chromeBrowser?.close().catch(() => undefined); + await server.close(); + } +} + +/** + * `check --snapshots`'s per-finding evidence crops. Opens its own session + * (the main grid session already closed by the time findings are shaped) and + * re-seeks to each finding's sample time — renders are deterministic, so a + * fresh page at the same time reproduces the same pixels the grid audited. + */ +export async function captureFindingCrops( + project: ProjectDir, + options: CheckOptions, + requests: CheckFindingCropRequest[], +): Promise { + if (requests.length === 0) return []; + const { bundleToSingleHtml } = await import("@hyperframes/core/compiler"); + const html = await bundleToSingleHtml(project.dir); + const server = await serveStaticProjectHtml(project.dir, html, "Failed to bind check server"); + let chromeBrowser: import("puppeteer-core").Browser | undefined; + const written: string[] = []; + try { + const session = await openSettledCompositionPage(html, server.url, { + renderReadyTimeoutMs: options.timeout, + renderReadyWarningSuffix: "capturing finding crops", + browserGpuMode: resolveCliChromeGpuMode(), + }); + chromeBrowser = session.browser; + const page = session.page; + await waitForPreferredSeekTarget(page); + + const snapshotDir = join(project.dir, "snapshots"); + mkdirSync(snapshotDir, { recursive: true }); + for (const request of requests) { + await seekCompositionTimeline(page, request.time, AUDIT_SEEK_OPTIONS); + const canvas = await page.evaluate(() => ({ + width: window.innerWidth, + height: window.innerHeight, + })); + const region = padCropRegion(request.bbox, canvas, DEFAULT_ZOOM_PADDING_PX); + const buffer = await captureRegionCrop(page, region, DEFAULT_ZOOM_SCALE); + writeFileSync(join(snapshotDir, request.filename), buffer); + written.push(join("snapshots", request.filename)); + } + return written; + } finally { + await chromeBrowser?.close().catch(() => undefined); + await server.close(); + } +} + +function wireRuntimeListeners(page: Page, drafts: RuntimeDraft[], currentTime: () => number): void { + page.on("console", (message) => { + const type = message.type(); + const text = message.text(); + if (type === "error" && !text.startsWith("Failed to load resource")) { + const location = message.location(); + drafts.push({ + code: "console_error", + severity: "error", + message: text, + time: currentTime(), + url: location.url, + line: location.lineNumber, + }); + } else if (type === "warn") { + const location = message.location(); + drafts.push({ + code: "console_warning", + severity: "warning", + message: text, + time: currentTime(), + url: location.url, + line: location.lineNumber, + }); + } + }); + page.on("pageerror", (error) => { + const message = normalizeErrorMessage(error); + if (message.includes("Unexpected token '<'") || message.includes("Unexpected token '<'")) { + return; + } + drafts.push({ code: "page_error", severity: "error", message, time: currentTime() }); + }); + wireNetworkListeners(page, drafts, currentTime); +} + +function wireNetworkListeners(page: Page, drafts: RuntimeDraft[], currentTime: () => number): void { + page.on("requestfailed", (request) => { + const url = request.url(); + if (url.includes("favicon") || url.startsWith("data:")) return; + const failure = request.failure()?.errorText; + if (shouldIgnoreRequestFailure(url, failure, request.resourceType())) return; + drafts.push({ + code: "request_failed", + severity: "error", + message: `Failed to load ${urlPath(url)}: ${failure ?? "net::ERR_FAILED"}`, + time: currentTime(), + url, + }); + }); + page.on("response", (response) => { + if (response.status() < 400) return; + const url = response.url(); + if (url.includes("favicon")) return; + drafts.push({ + code: "http_error", + severity: "error", + message: `${response.status()} loading ${urlPath(url)}`, + time: currentTime(), + url, + }); + }); +} + +function createPageDriver(page: Page, setTime: (time: number) => void): CheckAuditDriver { + return { + initialize: (contrast) => injectAuditScripts(page, contrast), + getDuration: () => getCompositionDuration(page), + getTransitionBoundaries: () => collectTweenBoundaries(page), + getCanvas: () => + page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight })), + findAmbiguousSelectors: (selectors) => findAmbiguousSelectors(page, selectors), + seek: async (time) => { + setTime(time); + await seekCompositionTimeline(page, time, AUDIT_SEEK_OPTIONS); + }, + collectLayout: (time, tolerance) => collectLayout(page, time, tolerance), + collectLayoutGeometry: () => collectLayoutGeometry(page), + collectGeometryCandidates: (time, request) => collectGeometryCandidates(page, time, request), + collectMotionFrame: (time, selectors, scopes) => + collectMotionFrame(page, time, selectors, scopes), + anchorMotionIssues: (issues) => anchorLayoutIssues(page, issues), + collectContrast: (time, annotations) => collectContrast(page, time, annotations), + }; +} + +async function injectAuditScripts(page: Page, contrast: boolean): Promise { + await page.addScriptTag({ content: loadBrowserScript("layout-audit.browser.js") }); + await page.addScriptTag({ content: loadBrowserScript("motion-sample.browser.js") }); + if (contrast) { + await page.addScriptTag({ content: loadBrowserScript("contrast-audit.browser.js") }); + } +} + +async function getCompositionDuration(page: Page): Promise { + // Duration resolution is serialized into the page and must remain self-contained. + // fallow-ignore-next-line complexity + return page.evaluate(() => { + const value = (target: unknown, key: string): unknown => + typeof target === "object" && target !== null ? Reflect.get(target, key) : undefined; + const positive = (candidate: unknown): number | null => + typeof candidate === "number" && candidate > 0 ? candidate : null; + const callDuration = (target: unknown): number | null => { + const duration = value(target, "duration"); + if (typeof duration === "function") { + const result = Reflect.apply(duration, target, []); + return positive(result); + } + return positive(duration); + }; + const hfDuration = positive(value(Reflect.get(window, "__hf"), "duration")); + if (hfDuration) return hfDuration; + const playerDuration = callDuration(Reflect.get(window, "__player")); + if (playerDuration) return playerDuration; + const root = document.querySelector("[data-composition-id][data-duration]"); + const authored = root ? parseFloat(root.getAttribute("data-duration") ?? "0") : 0; + if (authored > 0) return authored; + const timelines = Reflect.get(window, "__timelines"); + if (typeof timelines !== "object" || timelines === null) return 0; + for (const key of Object.keys(timelines)) { + const duration = callDuration(Reflect.get(timelines, key)); + if (duration) return duration; + } + return 0; + }); +} + +async function collectTweenBoundaries(page: Page): Promise { + // GSAP getter binding and parent-time conversion form one serialized algorithm. + // fallow-ignore-next-line complexity + return page.evaluate(() => { + const property = (target: unknown, key: string): unknown => + (typeof target === "object" && target !== null) || typeof target === "function" + ? Reflect.get(target, key) + : undefined; + const numberCall = (target: unknown, key: string, fallback: number): number => { + const method = property(target, key); + if (typeof method !== "function") return fallback; + const result = Reflect.apply(method, target, []); + return typeof result === "number" ? result : fallback; + }; + const rootTime = (root: unknown, animation: unknown, local: number): number => { + let time = local; + let node = animation; + while (node && node !== root) { + time = numberCall(node, "startTime", 0) + time / (numberCall(node, "timeScale", 1) || 1); + node = property(node, "parent"); + } + return time; + }; + const timelines = Reflect.get(window, "__timelines"); + if (typeof timelines !== "object" || timelines === null) return []; + const boundaries: number[] = []; + for (const key of Object.keys(timelines)) { + const timeline = Reflect.get(timelines, key); + const getChildren = property(timeline, "getChildren"); + if (typeof getChildren !== "function") continue; + try { + const children = Reflect.apply(getChildren, timeline, [true, true, false]); + if (!Array.isArray(children)) continue; + for (const child of children) { + const duration = numberCall(child, "duration", Number.NaN); + if (!Number.isFinite(duration)) continue; + boundaries.push(rootTime(timeline, child, 0), rootTime(timeline, child, duration)); + } + } catch { + continue; + } + } + return boundaries.filter(Number.isFinite); + }); +} + +async function collectLayout( + page: Page, + time: number, + tolerance: number, +): Promise { + const raw = await page.evaluate( + (options: { time: number; tolerance: number }) => { + const audit = Reflect.get(window, "__hyperframesLayoutAudit"); + if (typeof audit !== "function") return []; + const result = Reflect.apply(audit, window, [options]); + return Array.isArray(result) ? result : []; + }, + { time, tolerance }, + ); + return anchorLayoutIssues(page, raw.flatMap(parseLayoutIssue)); +} + +async function collectLayoutGeometry(page: Page): Promise { + return page.evaluate(() => { + const geometry = Reflect.get(window, "__hyperframesLayoutGeometry"); + if (typeof geometry !== "function") return ""; + const result = Reflect.apply(geometry, window, []); + return typeof result === "string" ? result : ""; + }); +} + +async function collectGeometryCandidates( + page: Page, + time: number, + request: GeometryCandidateRequest, +): Promise { + try { + const raw = await page.evaluate((options: GeometryCandidateRequest) => { + const collect = Reflect.get(window, "__hyperframesGeometryCandidates"); + if (typeof collect !== "function") return []; + const result = Reflect.apply(collect, window, [options]); + return Array.isArray(result) ? result : []; + }, request); + return raw.flatMap((value) => parseGeometryCandidate(value, time)); + } catch { + return []; + } +} + +async function findAmbiguousSelectors( + page: Page, + selectors: string[], +): Promise { + const ambiguous = await page.evaluate( + (values: string[]) => + values.filter((selector) => { + try { + return document.querySelectorAll(selector).length > 1; + } catch { + return false; + } + }), + selectors, + ); + return anchorLayoutIssues(page, ambiguous.map(ambiguousIssue)); +} + +async function collectMotionFrame( + page: Page, + time: number, + selectors: string[], + livenessScopes: string[], +): Promise { + const raw = await page.evaluate( + (options: { selectors: string[]; livenessScopes: string[] }) => { + const sample = Reflect.get(window, "__hyperframesMotionSample"); + if (typeof sample !== "function") return null; + return Reflect.apply(sample, window, [options]); + }, + { selectors, livenessScopes }, + ); + return parseMotionFrame(raw, time, selectors, livenessScopes); +} + +async function anchorLayoutIssues( + page: Page, + issues: LayoutIssue[], +): Promise { + const requests = issues.map((issue) => ({ + selector: issue.selector, + time: issue.time, + bbox: rectToBbox(issue.rect), + })); + const anchors = await resolveAnchors(page, requests); + return issues.map((issue, index) => ({ + ...issue, + ...(anchors[index] ?? fallbackAnchor(requests[index])), + })); +} + +async function resolveAnchors(page: Page, requests: AnchorRequest[]): Promise { + if (requests.length === 0) return []; + return page.evaluate((values: AnchorRequest[]) => { + const root = document.querySelector("[data-composition-id]"); + return values.map((request) => { + let element: Element | null = null; + try { + element = document.querySelector(request.selector); + } catch { + element = null; + } + element ??= root; + // Clones the anchor-extraction block in prepareContrast's evaluate() below; + // both run inside separate serialized browser closures and can't share a + // Node-side helper. + // fallow-ignore-next-line code-duplication + const dataAttributes: Record = {}; + for (const attribute of Array.from(element?.attributes ?? [])) { + if (attribute.name.startsWith("data-")) dataAttributes[attribute.name] = attribute.value; + } + const source = element + ?.closest("[data-composition-file]") + ?.getAttribute("data-composition-file"); + return { + selector: element ? request.selector : "[data-composition-id]", + dataAttributes, + sourceFile: source || "index.html", + bbox: request.bbox, + time: request.time, + }; + }); + }, requests); +} + +async function resolveRootAnchor(page: Page): Promise { + const anchors = await resolveAnchors(page, [ + { selector: "[data-composition-id]", time: 0, bbox: await compositionBbox(page) }, + ]); + return anchors[0] ?? fallbackAnchor(undefined); +} + +async function compositionBbox(page: Page): Promise { + return page.evaluate(() => { + const element = document.querySelector("[data-composition-id]"); + const rect = element?.getBoundingClientRect(); + return rect + ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } + : { x: 0, y: 0, width: 0, height: 0 }; + }); +} + +async function collectContrast( + page: Page, + time: number, + layoutAnnotations: CheckAnnotationBox[] = [], +): Promise { + let prepared: PreparedContrast[] = []; + try { + prepared = parsePreparedContrast(await prepareContrast(page, time)); + // This screenshot is the one contrast math is sampled from below — it must + // stay untouched by the annotation overlay (finishContrast reads real + // painted pixels), so annotation only ever happens on a SECOND shot. + const measurementShot = await page.screenshot({ encoding: "base64", type: "png" }); + if (typeof measurementShot !== "string") throw new Error("Contrast screenshot was not base64"); + const raw = await finishContrast( + page, + measurementShot, + time, + prepared.map((entry) => entry.raw), + ); + const finished = raw.flatMap(parseFinishedContrast); + const entries = joinContrastEntries(finished, prepared); + // Contrast failures are only known once measurement above completes, so + // they're appended to the layout-derived annotations passed in by the + // pipeline rather than being requested up front. + const annotations = [ + ...layoutAnnotations, + ...contrastFailureAnnotations(entries, layoutAnnotations.length), + ]; + const pngBase64 = await captureOverviewShot(page, annotations, measurementShot); + return { entries, pngBase64 }; + } finally { + await page + .evaluate(() => { + const restore = Reflect.get(window, "__contrastAuditRestoreIfPending"); + if (typeof restore === "function") Reflect.apply(restore, window, []); + }) + .catch(() => undefined); + } +} + +function contrastFailureAnnotations( + entries: ContrastAuditEntry[], + labelOffset: number, +): CheckAnnotationBox[] { + return entries + .filter((entry) => !entry.wcagAA) + .map((entry, index) => ({ + label: `${labelOffset + index + 1} contrast_aa_failure`, + bbox: entry.bbox, + })); +} + +const ANNOTATION_OVERLAY_ID = "__hyperframesCheckAnnotations"; + +/** + * `check --snapshots`'s overview-frame annotation: every audit for this + * sample time has already run (layout/geometry findings arrive via + * `annotations`; contrast failures were just measured above), so it's safe + * to draw labeled boxes and take one more shot without perturbing anything + * audits read. No-op (returns the plain screenshot) when there's nothing to + * annotate — the common case stays exactly as before this feature existed. + */ +export async function captureOverviewShot( + page: Page, + annotations: CheckAnnotationBox[], + fallbackScreenshot: string, +): Promise { + if (annotations.length === 0) return fallbackScreenshot; + await injectAnnotationOverlay(page, annotations); + try { + const shot = await page.screenshot({ encoding: "base64", type: "png" }); + return typeof shot === "string" ? shot : fallbackScreenshot; + } finally { + await removeAnnotationOverlay(page); + } +} + +/** One small, self-contained DOM overlay: fixed-position labeled boxes over + * each finding's bbox. Injected immediately before the annotated overview + * shot and torn down immediately after (see `captureOverviewShot`), so it + * never leaks into any audit's DOM reads. */ +async function injectAnnotationOverlay(page: Page, boxes: CheckAnnotationBox[]): Promise { + await page.evaluate( + (items: CheckAnnotationBox[], overlayId: string) => { + const root = document.createElement("div"); + root.id = overlayId; + root.style.cssText = "position:fixed;inset:0;z-index:2147483647;pointer-events:none;"; + for (const item of items) { + const box = document.createElement("div"); + box.style.cssText = `position:fixed;left:${item.bbox.x}px;top:${item.bbox.y}px;width:${item.bbox.width}px;height:${item.bbox.height}px;border:2px solid #ff2d55;box-sizing:border-box;`; + const label = document.createElement("div"); + label.textContent = item.label; + label.style.cssText = + "position:absolute;top:-18px;left:0;background:#ff2d55;color:#fff;font:11px/16px monospace;padding:0 4px;white-space:nowrap;"; + box.appendChild(label); + root.appendChild(box); + } + document.body.appendChild(root); + }, + boxes, + ANNOTATION_OVERLAY_ID, + ); +} + +async function removeAnnotationOverlay(page: Page): Promise { + await page.evaluate((overlayId: string) => { + document.getElementById(overlayId)?.remove(); + }, ANNOTATION_OVERLAY_ID); +} + +async function prepareContrast(page: Page, time: number): Promise { + // Candidate-to-element provenance must be captured while the prepare restore list is live. + // fallow-ignore-next-line complexity + return page.evaluate((sampleTime: number) => { + const prepare = Reflect.get(window, "__contrastAuditPrepare"); + const candidates = typeof prepare === "function" ? Reflect.apply(prepare, window, []) : []; + if (!Array.isArray(candidates)) return []; + const restores = Reflect.get(window, "__contrastAuditRestores"); + const restoreList = Array.isArray(restores) ? restores : []; + const escape = (value: string) => + typeof CSS !== "undefined" && typeof CSS.escape === "function" + ? CSS.escape(value) + : value.replace(/[^a-zA-Z0-9_-]/g, "\\$&"); + const selectorFor = (element: Element | null, fallback: string): string => { + if (!element) return fallback; + if (element.id) return `#${escape(element.id)}`; + const parts: string[] = []; + for ( + let current: Element | null = element; + current && current !== document.body; + current = current.parentElement + ) { + const tag = current.tagName.toLowerCase(); + const siblings = current.parentElement + ? Array.from(current.parentElement.children).filter( + (item) => item.tagName === current?.tagName, + ) + : []; + parts.push( + siblings.length > 1 ? `${tag}:nth-of-type(${siblings.indexOf(current) + 1})` : tag, + ); + } + return parts.reverse().join(" > ") || fallback; + }; + // Part of the serialized evaluate body above; cannot delegate to Node helpers. + // fallow-ignore-next-line complexity + return candidates.map((candidate, index) => { + const restore = restoreList[index]; + const candidateObject = typeof candidate === "object" && candidate !== null ? candidate : {}; + const elementValue = + typeof restore === "object" && restore !== null ? Reflect.get(restore, "el") : null; + const element = elementValue instanceof Element ? elementValue : null; + const fallback = Reflect.get(candidateObject, "selector"); + const selector = selectorFor( + element, + typeof fallback === "string" ? fallback : "[data-composition-id]", + ); + const dataAttributes: Record = {}; + for (const attribute of Array.from(element?.attributes ?? [])) { + if (attribute.name.startsWith("data-")) dataAttributes[attribute.name] = attribute.value; + } + const source = element + ?.closest("[data-composition-file]") + ?.getAttribute("data-composition-file"); + return { + candidate: { ...candidateObject, selector }, + anchor: { + selector, + dataAttributes, + sourceFile: source || "index.html", + bbox: Reflect.get(candidateObject, "bbox"), + time: sampleTime, + }, + }; + }); + }, time); +} + +async function finishContrast( + page: Page, + screenshot: string, + time: number, + candidates: unknown[], +): Promise { + return page.evaluate( + async (payload: { screenshot: string; time: number; candidates: unknown[] }) => { + const finish = Reflect.get(window, "__contrastAuditFinish"); + if (typeof finish !== "function") return []; + const result = await Reflect.apply(finish, window, [ + payload.screenshot, + payload.time, + payload.candidates, + ]); + return Array.isArray(result) ? result : []; + }, + { screenshot, time, candidates }, + ); +} + +function parsePreparedContrast(raw: unknown[]): PreparedContrast[] { + return raw.flatMap((value) => { + if (!isRecord(value)) return []; + const raw = Reflect.get(value, "candidate"); + const candidate = parseContrastCandidate(raw); + const anchor = parseAnchor(Reflect.get(value, "anchor")); + return candidate && anchor ? [{ raw, candidate, anchor }] : []; + }); +} + +function parseContrastCandidate(value: unknown): ContrastCandidate | null { + if (!isRecord(value)) return null; + const selector = stringValue(value, "selector"); + const text = stringValue(value, "text"); + const fg = rgbaValue(Reflect.get(value, "fg")); + const large = booleanValue(value, "large"); + const bbox = parseBbox(Reflect.get(value, "bbox")); + return selector && text !== null && fg && large !== null && bbox + ? { selector, text, fg, large, bbox } + : null; +} + +function parseFinishedContrast(value: unknown): FinishedContrast[] { + if (!isRecord(value)) return []; + const selector = stringValue(value, "selector"); + const text = stringValue(value, "text"); + const ratio = numberValue(value, "ratio"); + const wcagAA = booleanValue(value, "wcagAA"); + const large = booleanValue(value, "large"); + const fg = stringValue(value, "fg"); + const bg = stringValue(value, "bg"); + return selector && + text !== null && + ratio !== null && + wcagAA !== null && + large !== null && + fg && + bg + ? [{ selector, text, ratio, wcagAA, large, fg, bg }] + : []; +} + +function joinContrastEntries( + finished: FinishedContrast[], + prepared: PreparedContrast[], +): ContrastAuditEntry[] { + const remaining = [...prepared]; + return finished.flatMap((entry) => { + const index = remaining.findIndex( + (candidate) => + candidate.candidate.selector === entry.selector && candidate.candidate.text === entry.text, + ); + const match = index >= 0 ? remaining.splice(index, 1)[0] : undefined; + return match ? [{ ...entry, ...match.anchor }] : []; + }); +} + +function parseLayoutIssue(value: unknown): LayoutIssue[] { + if (!isRecord(value)) return []; + const code = layoutCodeValue(Reflect.get(value, "code")); + const severity = severityValue(Reflect.get(value, "severity")); + const time = numberValue(value, "time"); + const selector = stringValue(value, "selector"); + const message = stringValue(value, "message"); + const rect = parseRect(Reflect.get(value, "rect")); + if (!code || !severity || time === null || !selector || !message || !rect) return []; + const issue: LayoutIssue = { code, severity, time, selector, message, rect }; + assignOptionalLayoutFields(issue, value); + return [issue]; +} + +function parseGeometryCandidate(value: unknown, time: number): CheckGeometryCandidate[] { + if (!isRecord(value)) return []; + const rect = parseRect(Reflect.get(value, "rect")); + const elementRect = parseRect(Reflect.get(value, "elementRect")); + if (!rect || !elementRect) return []; + const identity = parseGeometryIdentity(value); + if (!identity) return []; + const anchor = parseGeometryAnchor(value, rect, time); + if (!anchor) return []; + const candidate: CheckGeometryCandidate = { ...identity, ...anchor, rect, elementRect }; + const overflow = parseOverflow(Reflect.get(value, "overflow")); + if (overflow) candidate.overflow = overflow; + return [candidate]; +} + +function parseGeometryIdentity( + value: Record, +): Pick | null { + const kindValue = Reflect.get(value, "kind"); + const kind = kindValue === "text" || kindValue === "media" ? kindValue : null; + if (!kind) return null; + const tag = stringValue(value, "tag"); + if (!tag) return null; + const text = stringValue(value, "text"); + return text === null ? null : { kind, tag, text }; +} + +function parseGeometryAnchor( + value: Record, + rect: LayoutRect, + time: number, +): CheckAnchor | null { + const selector = stringValue(value, "selector"); + if (!selector) return null; + const sourceFile = stringValue(value, "sourceFile"); + if (!sourceFile) return null; + const dataAttributes = stringRecord(Reflect.get(value, "dataAttributes")); + return dataAttributes + ? { + selector, + sourceFile, + dataAttributes, + bbox: rectToBbox(rect), + time, + } + : null; +} + +function assignOptionalLayoutFields(issue: LayoutIssue, value: Record): void { + assignOptionalString(issue, value, "containerSelector"); + assignOptionalString(issue, value, "text"); + assignOptionalString(issue, value, "fixHint"); + const containerRect = parseRect(Reflect.get(value, "containerRect")); + if (containerRect) issue.containerRect = containerRect; + const overflow = parseOverflow(Reflect.get(value, "overflow")); + if (overflow) issue.overflow = overflow; + const coveredFraction = numberValue(value, "coveredFraction"); + if (coveredFraction !== null) issue.coveredFraction = coveredFraction; +} + +function recordField(value: unknown, key: string): Record | null { + if (!isRecord(value)) return null; + const field = Reflect.get(value, key); + return isRecord(field) ? field : null; +} + +function parseMotionFrame( + value: unknown, + time: number, + selectors: string[], + scopes: string[], +): MotionFrame { + const rawData = recordField(value, "data"); + const rawLiveness = recordField(value, "liveness"); + const data: MotionFrame["data"] = {}; + for (const selector of selectors) { + data[selector] = rawData ? parseFrameSample(Reflect.get(rawData, selector)) : null; + } + const liveness: Record = {}; + for (const scope of scopes) { + const signature = rawLiveness ? Reflect.get(rawLiveness, scope) : ""; + liveness[scope] = typeof signature === "string" ? signature : ""; + } + return { time, data, liveness }; +} + +function parseFrameSample(value: unknown): MotionFrame["data"][string] { + if (!isRecord(value)) return null; + const rect = parseRect(Reflect.get(value, "rect")); + const opacity = numberValue(value, "opacity"); + const visible = booleanValue(value, "visible"); + return rect && opacity !== null && visible !== null ? { rect, opacity, visible } : null; +} + +function parseAnchor(value: unknown): CheckAnchor | null { + if (!isRecord(value)) return null; + const selector = stringValue(value, "selector"); + const sourceFile = stringValue(value, "sourceFile"); + const time = numberValue(value, "time"); + const bbox = parseBbox(Reflect.get(value, "bbox")); + const dataAttributes = stringRecord(Reflect.get(value, "dataAttributes")); + return selector && sourceFile && time !== null && bbox && dataAttributes + ? { selector, sourceFile, time, bbox, dataAttributes } + : null; +} + +function runtimeFinding(draft: RuntimeDraft, root: CheckAnchor): CheckFinding { + return { + code: draft.code, + severity: draft.severity, + message: draft.message, + selector: root.selector, + dataAttributes: root.dataAttributes, + sourceFile: root.sourceFile, + bbox: root.bbox, + time: draft.time, + url: draft.url, + line: draft.line, + }; +} + +function fallbackAnchor(request: AnchorRequest | undefined): CheckAnchor { + return { + selector: request?.selector ?? "[data-composition-id]", + dataAttributes: {}, + sourceFile: "index.html", + bbox: request?.bbox ?? { x: 0, y: 0, width: 0, height: 0 }, + time: request?.time ?? 0, + }; +} + +function parseBbox(value: unknown): CheckBbox | null { + if (!isRecord(value)) return null; + const x = numberValue(value, "x"); + const y = numberValue(value, "y"); + const width = numberValue(value, "width") ?? numberValue(value, "w"); + const height = numberValue(value, "height") ?? numberValue(value, "h"); + return x !== null && y !== null && width !== null && height !== null + ? { x, y, width, height } + : null; +} + +function parseRect(value: unknown): LayoutRect | null { + if (!isRecord(value)) return null; + const left = numberValue(value, "left"); + const top = numberValue(value, "top"); + const right = numberValue(value, "right"); + const bottom = numberValue(value, "bottom"); + const width = numberValue(value, "width"); + const height = numberValue(value, "height"); + return left !== null && + top !== null && + right !== null && + bottom !== null && + width !== null && + height !== null + ? { left, top, right, bottom, width, height } + : null; +} + +function parseOverflow(value: unknown): LayoutIssue["overflow"] | null { + if (!isRecord(value)) return null; + const overflow: LayoutIssue["overflow"] = {}; + for (const side of ["left", "right", "top", "bottom"] as const) { + const amount = numberValue(value, side); + if (amount !== null) overflow[side] = amount; + } + return Object.keys(overflow).length > 0 ? overflow : null; +} + +const LAYOUT_ISSUE_CODES: readonly LayoutIssueCode[] = [ + "text_box_overflow", + "clipped_text", + "canvas_overflow", + "container_overflow", + "content_overlap", + "text_occluded", + "caption_zone_collision", + "frame_out_of_frame", + "motion_appears_late", + "motion_out_of_order", + "motion_off_frame", + "motion_frozen", + "motion_selector_missing", + "motion_selector_ambiguous", +]; + +function layoutCodeValue(value: unknown): LayoutIssueCode | null { + return LAYOUT_ISSUE_CODES.find((code) => code === value) ?? null; +} + +function severityValue(value: unknown): CheckSeverity | null { + return value === "error" || value === "warning" || value === "info" ? value : null; +} + +function rgbaValue(value: unknown): [number, number, number, number] | null { + if (!Array.isArray(value) || value.length < 4) return null; + const [red, green, blue, alpha] = value; + return [red, green, blue, alpha].every((channel) => typeof channel === "number") + ? [red, green, blue, alpha] + : null; +} + +function stringRecord(value: unknown): Record | null { + if (!isRecord(value)) return null; + const record: Record = {}; + for (const key of Object.keys(value)) { + const entry = Reflect.get(value, key); + if (typeof entry !== "string") return null; + record[key] = entry; + } + return record; +} + +function assignOptionalString( + issue: LayoutIssue, + source: Record, + key: "containerSelector" | "text" | "fixHint", +): void { + const value = stringValue(source, key); + if (value !== null) issue[key] = value; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stringValue(value: Record, key: string): string | null { + const entry = Reflect.get(value, key); + return typeof entry === "string" ? entry : null; +} + +function numberValue(value: Record, key: string): number | null { + const entry = Reflect.get(value, key); + return typeof entry === "number" && Number.isFinite(entry) ? entry : null; +} + +function booleanValue(value: Record, key: string): boolean | null { + const entry = Reflect.get(value, key); + return typeof entry === "boolean" ? entry : null; +} + +function urlPath(url: string): string { + try { + return decodeURIComponent(new URL(url).pathname).replace(/^\//, ""); + } catch { + return url; + } +} diff --git a/packages/cli/src/utils/checkPipeline.ts b/packages/cli/src/utils/checkPipeline.ts new file mode 100644 index 000000000..8148654b2 --- /dev/null +++ b/packages/cli/src/utils/checkPipeline.ts @@ -0,0 +1,976 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import { trackCheckReport, trackCommandFailure } from "../telemetry/events.js"; +import { getRunId } from "../telemetry/runId.js"; +import type { ProjectDir } from "./project.js"; +import { lintProject, shouldBlockRender, type ProjectLintResult } from "./lintProject.js"; +import { + buildLayoutSampleTimes, + buildTransitionSampleTimes, + collapseStaticLayoutIssues, + dedupeLayoutIssues, + limitLayoutIssues, + mergeSampleTimes, + type LayoutIssue, + type LayoutRect, +} from "./layoutAudit.js"; +import { + collectSamplingTargets, + evaluateMotion, + type Canvas, + type MotionFrame, +} from "./motionAudit.js"; +import { findMotionSpec, readMotionSpec } from "./motionSpec.js"; +import { normalizeErrorMessage } from "./errorMessage.js"; +import { + parseColorRGBA, + requiredContrastRatio, + suggestCompliantForegroundColor, + type Rgb, +} from "../commands/contrast-bg.js"; +import { rectToBbox } from "./checkTypes.js"; +import type { + AnchoredLayoutIssue, + CheckAnnotationBox, + CheckAuditDriver, + CheckBbox, + CheckBrowserResult, + CheckContrastFinding, + CheckDependencies, + CheckFinding, + CheckFindingCropRequest, + CheckGeometryCandidate, + CheckOptions, + CheckReport, + CheckScreenshot, + CheckSection, + CheckSeverity, + ContrastAuditEntry, + GeometryCandidateRequest, + MotionSpecResolution, +} from "./checkTypes.js"; + +export type { + AnchoredLayoutIssue, + CheckAnchor, + CheckAuditDriver, + CheckBrowserResult, + CheckDependencies, + CheckFinding, + CheckFindingCropRequest, + CheckOptions, + CheckReport, + CheckSection, + ContrastAuditEntry, + MotionSpecResolution, +} from "./checkTypes.js"; + +const MOTION_FPS = 20; +const MOTION_MAX_SAMPLES = 300; +const ZERO_BBOX: CheckBbox = { x: 0, y: 0, width: 0, height: 0 }; +// Ignore normal in/out slide travel; only substantive frame breaches are actionable. +const FRAME_BREACH_FLOOR_PX = 120; +const FRAME_BREACH_FLOOR_FRACTION = 0.06; + +export const DEFAULT_CHECK_OPTIONS: CheckOptions = { + samples: 9, + atTransitions: false, + maxIssues: 80, + collapseStatic: true, + tolerance: 2, + timeout: 3000, + contrast: true, + strict: false, + snapshots: false, +}; + +/** Pick at most five evenly-strided points from the already-merged layout grid. */ +export function selectContrastTimes(grid: number[]): number[] { + if (grid.length <= 5) return [...grid]; + return Array.from({ length: 5 }, (_, index) => { + const selected = Math.floor((index * (grid.length - 1)) / 4); + return grid[selected] ?? grid[0] ?? 0; + }); +} + +function buildMotionSampleTimes(duration: number): number[] { + if (!Number.isFinite(duration) || duration <= 0) return []; + const count = Math.min(MOTION_MAX_SAMPLES, Math.max(2, Math.ceil(duration * MOTION_FPS) + 1)); + const step = duration / (count - 1); + return Array.from({ length: count }, (_, index) => Math.round(index * step * 1000) / 1000); +} + +interface SampleGrid { + duration: number; + layoutSamples: number[]; + captionSamples: number[]; + frameSamples: number[]; + transitionSamples: number[]; + transitionSamplesDropped: number; + contrastSamples: number[]; +} + +function gateSampleTimes( + duration: number, + seeks: number[] | undefined, + fallback: number, +): number[] { + if (!Number.isFinite(duration) || duration <= 0) return []; + const fractions = seeks && seeks.length > 0 ? seeks : [fallback]; + return mergeSampleTimes(fractions.map((fraction) => fraction * duration)); +} + +async function buildSampleGrid( + driver: CheckAuditDriver, + options: CheckOptions, +): Promise { + const duration = await driver.getDuration(); + const baseSamples = buildLayoutSampleTimes({ + duration, + samples: options.samples, + at: options.at, + }); + const transitions = options.atTransitions + ? buildTransitionSampleTimes({ + duration, + boundaries: await driver.getTransitionBoundaries(), + cap: options.maxTransitionSamples, + }) + : { times: [], dropped: 0 }; + const captionSamples = options.captionZone + ? gateSampleTimes(duration, options.captionZone.seek, 1) + : []; + const frameSamples = options.frameCheck + ? gateSampleTimes(duration, options.frameCheck.seek, 0.5) + : []; + const auditSamples = mergeSampleTimes(baseSamples, transitions.times); + const layoutSamples = mergeSampleTimes(auditSamples, captionSamples, frameSamples); + if (layoutSamples.length === 0) { + throw new Error("Could not determine composition duration — no layout samples run"); + } + return { + duration, + layoutSamples, + captionSamples, + frameSamples, + transitionSamples: transitions.times, + transitionSamplesDropped: transitions.dropped, + contrastSamples: options.contrast ? selectContrastTimes(auditSamples) : [], + }; +} + +interface MotionPlan { + times: number[]; + selectors: string[]; + livenessScopes: string[]; + preflightIssues: AnchoredLayoutIssue[]; +} + +async function planMotionSampling( + driver: CheckAuditDriver, + motion: MotionSpecResolution, + duration: number, +): Promise { + if (motion.kind !== "valid") { + return { times: [], selectors: [], livenessScopes: [], preflightIssues: [] }; + } + const targets = collectSamplingTargets(motion.spec.assertions); + const preflightIssues = await driver.findAmbiguousSelectors(targets.selectors); + const times = + preflightIssues.length === 0 ? buildMotionSampleTimes(motion.spec.duration ?? duration) : []; + return { times, ...targets, preflightIssues }; +} + +interface GridSamples { + layoutIssues: AnchoredLayoutIssue[]; + motionFrames: MotionFrame[]; + contrastEntries: ContrastAuditEntry[]; + screenshots: CheckScreenshot[]; + contrastMs: number; + /** One geometry+opacity fingerprint per layout sample (#U10 frozen-sweep guard). */ + geometrySignatures: string[]; +} + +interface GeometrySeen { + caption: Set; + frame: Set; +} + +function geometryRequest( + time: number, + grid: SampleGrid, + options: CheckOptions, +): GeometryCandidateRequest | null { + const text = grid.captionSamples.includes(time); + const media = grid.frameSamples.includes(time); + if (!text && !media) return null; + const configuredTolerance = options.frameCheck?.tol; + const tolerance = typeof configuredTolerance === "number" ? configuredTolerance : 2; + return { text, media, tolerance }; +} + +function candidateIsSized(candidate: CheckGeometryCandidate, canvas: Canvas): boolean { + if (candidate.elementRect.width < 4 || candidate.elementRect.height < 4) return false; + return !( + candidate.elementRect.width >= 0.95 * canvas.width && + candidate.elementRect.height >= 0.95 * canvas.height + ); +} + +function geometryIssueAnchor(candidate: CheckGeometryCandidate, time: number) { + return { + selector: candidate.selector, + dataAttributes: candidate.dataAttributes, + sourceFile: candidate.sourceFile, + bbox: candidate.bbox, + time, + rect: candidate.rect, + }; +} + +function captionFinding( + candidate: CheckGeometryCandidate, + options: CheckOptions, + canvas: Canvas, + time: number, +): { key: string; issue: AnchoredLayoutIssue } | null { + const zone = options.captionZone; + if (!zone || candidate.kind !== "text" || !candidateIsSized(candidate, canvas)) return null; + const cx = candidate.rect.left + candidate.rect.width / 2; + const cy = candidate.rect.top + candidate.rect.height / 2; + const inside = + cx >= zone.x0 * canvas.width && + cx <= zone.x1 * canvas.width && + cy >= zone.y0 * canvas.height && + cy <= zone.y1 * canvas.height; + if (!inside) return null; + const text = candidate.text.slice(0, 48); + const pctFromBottom = Math.round(((canvas.height - cy) / canvas.height) * 100); + return { + key: `${candidate.tag}|${text}`, + issue: { + ...geometryIssueAnchor(candidate, time), + code: "caption_zone_collision", + severity: zone.severity === "error" ? "error" : "warning", + text, + message: `<${candidate.tag}> "${text}" is centred in the reserved caption band (~${pctFromBottom}% up from the bottom).`, + fixHint: "Keep main content outside the configured caption band.", + }, + }; +} + +function maxOverflow(candidate: CheckGeometryCandidate): number { + if (!candidate.overflow) return 0; + return Math.max( + candidate.overflow.left ?? 0, + candidate.overflow.top ?? 0, + candidate.overflow.right ?? 0, + candidate.overflow.bottom ?? 0, + ); +} + +function overflowMessage(candidate: CheckGeometryCandidate): string { + const overflow = candidate.overflow ?? {}; + const edges: string[] = []; + if (overflow.left) edges.push(`${overflow.left}px past the left`); + if (overflow.top) edges.push(`${overflow.top}px past the top`); + if (overflow.right) edges.push(`${overflow.right}px past the right`); + if (overflow.bottom) edges.push(`${overflow.bottom}px past the bottom`); + return `<${candidate.tag}> "${candidate.text.slice(0, 48)}" spills outside the frame (${edges.join(", ")}).`; +} + +function frameFinding( + candidate: CheckGeometryCandidate, + options: CheckOptions, + canvas: Canvas, + time: number, +): { key: string; issue: AnchoredLayoutIssue } | null { + if (!options.frameCheck || candidate.kind !== "media" || !candidateIsSized(candidate, canvas)) { + return null; + } + const floor = Math.max( + FRAME_BREACH_FLOOR_PX, + FRAME_BREACH_FLOOR_FRACTION * Math.min(canvas.width, canvas.height), + ); + if (maxOverflow(candidate) < floor) return null; + const text = candidate.text.slice(0, 48); + return { + key: `${candidate.tag}|${text}|${Math.round(candidate.rect.left)},${Math.round(candidate.rect.top)}`, + issue: { + ...geometryIssueAnchor(candidate, time), + code: "frame_out_of_frame", + severity: options.frameCheck.severity === "error" ? "error" : "warning", + text, + overflow: candidate.overflow, + message: overflowMessage(candidate), + fixHint: "Keep media within the composition frame's safe area.", + }, + }; +} + +function appendGeometryFinding( + result: { key: string; issue: AnchoredLayoutIssue } | null, + seen: Set, + issues: AnchoredLayoutIssue[], +): void { + if (!result || seen.has(result.key)) return; + seen.add(result.key); + issues.push(result.issue); +} + +async function collectGeometryAt( + driver: CheckAuditDriver, + options: CheckOptions, + grid: SampleGrid, + canvas: Canvas, + time: number, + seen: GeometrySeen, +): Promise { + const request = geometryRequest(time, grid, options); + if (!request) return []; + const candidates = await driver.collectGeometryCandidates(time, request); + const issues: AnchoredLayoutIssue[] = []; + for (const candidate of candidates) { + if (request.text) { + appendGeometryFinding(captionFinding(candidate, options, canvas, time), seen.caption, issues); + } + if (request.media) { + appendGeometryFinding(frameFinding(candidate, options, canvas, time), seen.frame, issues); + } + } + return issues; +} + +async function collectGridSamples( + driver: CheckAuditDriver, + options: CheckOptions, + grid: SampleGrid, + motion: MotionPlan, +): Promise { + const layoutSet = new Set(grid.layoutSamples); + const motionSet = new Set(motion.times); + const contrastSet = new Set(grid.contrastSamples); + const geometryEnabled = grid.captionSamples.length > 0 || grid.frameSamples.length > 0; + const canvas = geometryEnabled ? await driver.getCanvas() : null; + const geometrySeen: GeometrySeen = { caption: new Set(), frame: new Set() }; + const collected: GridSamples = { + layoutIssues: [], + motionFrames: [], + contrastEntries: [], + screenshots: [], + contrastMs: 0, + geometrySignatures: [], + }; + for (const time of mergeSampleTimes(grid.layoutSamples, motion.times)) { + await driver.seek(time); + // Findings collected for THIS sample time, so the overview overlay (below) + // only ever annotates a frame with defects that are actually valid at + // that render time — never a stale bbox from an earlier/later sample. + const issuesAtTime: AnchoredLayoutIssue[] = []; + if (layoutSet.has(time)) { + const layoutIssues = await driver.collectLayout(time, options.tolerance); + collected.layoutIssues.push(...layoutIssues); + issuesAtTime.push(...layoutIssues); + collected.geometrySignatures.push(await driver.collectLayoutGeometry()); + } + if (canvas) { + const geometryIssues = await collectGeometryAt( + driver, + options, + grid, + canvas, + time, + geometrySeen, + ); + collected.layoutIssues.push(...geometryIssues); + issuesAtTime.push(...geometryIssues); + } + if (motionSet.has(time)) { + collected.motionFrames.push( + await driver.collectMotionFrame(time, motion.selectors, motion.livenessScopes), + ); + } + if (contrastSet.has(time)) { + const contrastStart = Date.now(); + // Annotation is a --snapshots-only nicety — skip building it (and the + // driver's extra overlay screenshot) when nothing will use it; the call + // shape without --snapshots stays exactly what it was before this existed. + const capture = options.snapshots + ? await driver.collectContrast(time, annotationBoxesFrom(issuesAtTime)) + : await driver.collectContrast(time); + collected.contrastMs += Date.now() - contrastStart; + collected.contrastEntries.push(...capture.entries); + collected.screenshots.push({ time, pngBase64: capture.pngBase64 }); + } + } + return collected; +} + +// Frozen-sweep guard (#U10): compositions this short can legitimately hold a +// single static frame the whole time (a title card) — never flag those. +const SWEEP_STATIC_MIN_DURATION_SEC = 3; +const ZERO_LAYOUT_RECT: LayoutRect = { + left: 0, + top: 0, + right: 0, + bottom: 0, + width: 0, + height: 0, +}; + +/** + * Frozen-sweep guard (#U10): if every layout-grid sample produced the exact + * same geometry+opacity fingerprint (see layout-audit.browser.js), the seek + * never actually advanced the composition's timeline — every other green + * verdict from this run is meaningless, not just a missed defect. Skips + * short (<3s) compositions, single-sample runs (nothing to compare), and + * runs where a `motion_frozen` finding already reported the same underlying + * symptom (no double-reporting the one thing that's wrong). + */ +function detectSweepStatic( + duration: number, + geometrySignatures: string[], + motionIssues: AnchoredLayoutIssue[], +): AnchoredLayoutIssue[] { + if (duration < SWEEP_STATIC_MIN_DURATION_SEC) return []; + if (geometrySignatures.length < 2) return []; + if (motionIssues.some((issue) => issue.code === "motion_frozen")) return []; + const [first, ...rest] = geometrySignatures; + if (!first || rest.some((signature) => signature !== first)) return []; + return [ + { + code: "sweep_static", + severity: "error", + time: 0, + selector: "[data-composition-id]", + dataAttributes: {}, + sourceFile: "index.html", + bbox: ZERO_BBOX, + rect: ZERO_LAYOUT_RECT, + message: + "Timeline did not advance under seek; every green verdict on this run is unreliable.", + fixHint: + "Confirm the composition seeks a paused GSAP/CSS timeline under `data-*` timing attributes rather than only autoplaying.", + }, + ]; +} + +/** Error-severity findings with real geometry become labeled overview boxes. + * Contrast failures are annotated separately by the driver itself, since + * they're only known once contrast measurement for this sample completes. */ +function annotationBoxesFrom(issues: AnchoredLayoutIssue[]): CheckAnnotationBox[] { + return issues + .filter((issue) => issue.severity === "error" && issue.bbox.width > 0 && issue.bbox.height > 0) + .map((issue, index) => ({ label: `${index + 1} ${issue.code}`, bbox: issue.bbox })); +} + +export async function runAuditGrid( + driver: CheckAuditDriver, + options: CheckOptions, + motion: MotionSpecResolution, +): Promise { + await driver.initialize(options.contrast); + const grid = await buildSampleGrid(driver, options); + const plan = await planMotionSampling(driver, motion, grid.duration); + const seekLoopStart = Date.now(); + const collected = await collectGridSamples(driver, options, grid, plan); + const seekLoopMs = Date.now() - seekLoopStart; + + let motionIssues = plan.preflightIssues; + if (motion.kind === "valid" && motionIssues.length === 0 && collected.motionFrames.length > 0) { + const evaluated = evaluateMotion( + collected.motionFrames, + motion.spec.assertions, + await driver.getCanvas(), + ); + motionIssues = await driver.anchorMotionIssues(evaluated); + } + const sweepFindings = detectSweepStatic( + grid.duration, + collected.geometrySignatures, + motionIssues, + ); + const contrast = buildContrastResults(collected.contrastEntries); + return { + duration: grid.duration, + layoutSamples: grid.layoutSamples, + transitionSamples: grid.transitionSamples, + transitionSamplesDropped: grid.transitionSamplesDropped, + runtimeFindings: [], + layoutIssues: [...collected.layoutIssues, ...sweepFindings], + motionIssues, + motionSampleCount: collected.motionFrames.length, + contrastSamples: grid.contrastSamples, + contrastFindings: contrast.findings, + contrastChecked: collected.contrastEntries.length, + contrastPassed: contrast.passed, + screenshots: collected.screenshots, + timings: { launchSettleMs: 0, seekLoopMs, contrastMs: collected.contrastMs }, + }; +} + +export async function runCheckPipeline( + project: ProjectDir, + options: CheckOptions, + dependencies: CheckDependencies = DEFAULT_DEPENDENCIES, +): Promise { + let lintResult: ProjectLintResult; + try { + lintResult = await dependencies.lintProject(project.dir); + } catch (error) { + // The linter itself crashed (unreadable file, internal error) — distinct + // from lint findings; a runtime-failure code would send the agent hunting + // for a script problem that doesn't exist. + return failureReport(options, runtimeFailure(error, "check_lint_failure")); + } + + const lint = buildLintSection(lintResult); + if (shouldBlockRender(true, false, lintResult.totalErrors, lintResult.totalWarnings)) { + return buildReport(options, lint, emptyBrowserResult(), { kind: "none" }, [], []); + } + + const motion = dependencies.resolveMotionSpec(project.dir); + if (motion.kind === "invalid") { + const finding = findingAtRoot( + "motion_spec_invalid", + "error", + motion.message, + relative(project.dir, motion.path) || "index.motion.json", + ); + return buildReport(options, lint, emptyBrowserResult(), motion, [finding], []); + } + + let browser: CheckBrowserResult; + try { + browser = await dependencies.runBrowserCheck(project, options, motion); + } catch (error) { + browser = emptyBrowserResult(); + browser.runtimeFindings.push(runtimeFailure(error)); + } + + const snapshotFiles = options.snapshots + ? await writeContrastSnapshots(dependencies, project.dir, browser) + : []; + const report = buildReport(options, lint, browser, motion, [], snapshotFiles); + return options.snapshots + ? await withFindingCrops(dependencies, project, options, report) + : report; +} + +/** Persists the contrast pass's already-captured overview PNGs (or the + * annotated versions — see `collectContrast`'s overlay). A write failure + * becomes a runtime finding rather than aborting the whole report. */ +async function writeContrastSnapshots( + dependencies: CheckDependencies, + projectDir: string, + browser: CheckBrowserResult, +): Promise { + const files: string[] = []; + for (let index = 0; index < browser.screenshots.length; index += 1) { + const shot = browser.screenshots[index]; + if (!shot) continue; + try { + files.push(await dependencies.writeSnapshot(projectDir, index, shot.time, shot.pngBase64)); + } catch (error) { + browser.runtimeFindings.push(runtimeFailure(error, "snapshot_write_failed")); + } + } + return files; +} + +/** Finding crops are bonus evidence, not gating — no eligible finding, or a + * capture failure (e.g. a second Chrome launch failing), returns the report + * unchanged rather than sinking an otherwise-good run. */ +async function withFindingCrops( + dependencies: CheckDependencies, + project: ProjectDir, + options: CheckOptions, + report: CheckReport, +): Promise { + const cropRequests = selectFindingCropRequests(report); + if (cropRequests.length === 0) return report; + try { + const findingFiles = await dependencies.captureFindingCrops(project, options, cropRequests); + return { ...report, snapshots: { ...report.snapshots, findingFiles } }; + } catch (error) { + // Still non-gating, but observable: rollouts need the crop-failure rate + // (a second Chrome launch failing/timing out) without failing the run. + console.error(" finding crops skipped: " + normalizeErrorMessage(error)); + trackCommandFailure("check-finding-crops", error); + return report; + } +} + +const MAX_FINDING_CROPS = 12; + +/** Which error findings get a `finding-NN-.png` crop for `check --snapshots`: + * error severity, a real (non-zero) bbox, capped at 12. Pure and order-preserving + * so it's directly unit-testable without a browser. */ +export function selectFindingCropRequests(report: CheckReport): CheckFindingCropRequest[] { + const candidates: CheckFinding[] = [ + ...report.layout.findings, + ...report.motion.findings, + ...report.contrast.findings, + ...report.runtime.findings, + ]; + const requests: CheckFindingCropRequest[] = []; + for (const finding of candidates) { + if (requests.length >= MAX_FINDING_CROPS) break; + if (finding.severity !== "error" || !hasRealBbox(finding.bbox)) continue; + requests.push({ + filename: findingCropFilename(requests.length, finding.code), + time: finding.time, + bbox: finding.bbox, + }); + } + return requests; +} + +function hasRealBbox(bbox: CheckBbox): boolean { + return bbox.width > 0 && bbox.height > 0; +} + +export function findingCropFilename(index: number, code: string): string { + const safeCode = code.replace(/[^a-zA-Z0-9_-]/g, "_"); + return `finding-${String(index).padStart(2, "0")}-${safeCode}.png`; +} + +export function checkExitCode(report: CheckReport): 0 | 1 { + return report.ok ? 0 : 1; +} + +// Same persistence rule the layout findings follow: a failure observed at a +// single contrast sample is usually text caught mid-entrance/exit (its real +// background not painted yet — white-on-white at exactly 1.0 is the classic +// shape), so it demotes to warning. A failure HELD at 2+ samples for the same +// element is a real, gating defect. Single-sample sweeps can't distinguish, +// so they keep full severity. +function contrastFailureHeld( + entries: ContrastAuditEntry[], +): (entry: ContrastAuditEntry) => boolean { + const sampledTimes = new Set(entries.map((entry) => entry.time)).size; + const failureSamples = new Map>(); + for (const entry of entries) { + if (entry.wcagAA) continue; + const key = `${entry.selector}|${entry.text}`; + const times = failureSamples.get(key) ?? new Set(); + times.add(entry.time); + failureSamples.set(key, times); + } + return (entry) => + sampledTimes < 2 || (failureSamples.get(`${entry.selector}|${entry.text}`)?.size ?? 0) >= 2; +} + +function buildContrastResults(entries: ContrastAuditEntry[]): { + findings: CheckContrastFinding[]; + passed: number; +} { + const findings: CheckContrastFinding[] = []; + let passed = 0; + const isHeld = contrastFailureHeld(entries); + for (const entry of entries) { + if (entry.wcagAA) { + passed += 1; + continue; + } + const held = isHeld(entry); + const requiredRatio = requiredContrastRatio(entry.large); + findings.push({ + code: "contrast_aa_failure", + severity: held ? "error" : "warning", + message: `Contrast is ${entry.ratio}:1; WCAG AA requires ${requiredRatio}:1.`, + text: entry.text, + fg: entry.fg, + bg: entry.bg, + ratio: entry.ratio, + requiredRatio, + suggestedColor: suggestedColor(entry.fg, entry.bg, requiredRatio), + large: entry.large, + selector: entry.selector, + dataAttributes: entry.dataAttributes, + sourceFile: entry.sourceFile, + bbox: entry.bbox, + time: entry.time, + }); + } + return { findings, passed }; +} + +function suggestedColor(fg: string, bg: string, requiredRatio: number): string { + const foreground = parseColorRGBA(fg); + const background = parseColorRGBA(bg); + if (!foreground || !background) return fg; + const fgRgb: Rgb = [foreground[0], foreground[1], foreground[2]]; + const bgRgb: Rgb = [background[0], background[1], background[2]]; + const suggested = suggestCompliantForegroundColor(fgRgb, bgRgb, requiredRatio); + return `rgb(${suggested[0]},${suggested[1]},${suggested[2]})`; +} + +function buildLintSection(result: ProjectLintResult): CheckReport["lint"] { + const findings = result.results.flatMap(({ file, result: fileResult }) => + fileResult.findings.map((finding) => ({ + code: finding.code, + severity: finding.severity, + message: finding.message, + selector: + finding.selector ?? (finding.elementId ? `#${finding.elementId}` : "[data-composition-id]"), + dataAttributes: {}, + sourceFile: finding.file ?? file, + bbox: ZERO_BBOX, + time: 0, + fixHint: finding.fixHint, + })), + ); + return { ...section(findings), filesScanned: result.results.length }; +} + +function buildReport( + options: CheckOptions, + lint: CheckReport["lint"], + browser: CheckBrowserResult, + motion: MotionSpecResolution, + extraMotionFindings: CheckFinding[], + snapshotFiles: string[], +): CheckReport { + const layout = shapeLayoutSection(browser.layoutIssues, browser, options); + const shapedMotion = shapeLayoutFindings(browser.motionIssues, options); + const motionFindings: CheckFinding[] = [...shapedMotion.findings, ...extraMotionFindings]; + const runtime = section(browser.runtimeFindings); + const motionSection = section(motionFindings); + const contrastSection = section(browser.contrastFindings); + const warningCount = + lint.warningCount + + runtime.warningCount + + layout.warningCount + + motionSection.warningCount + + contrastSection.warningCount; + const errorCount = + lint.errorCount + + runtime.errorCount + + layout.errorCount + + motionSection.errorCount + + contrastSection.errorCount; + const report: CheckReport = { + ok: errorCount === 0 && (!options.strict || warningCount === 0), + strict: options.strict, + lint, + runtime, + layout, + motion: { + ...motionSection, + enabled: motion.kind !== "none", + specPath: motion.kind === "none" ? undefined : motion.path, + samples: browser.motionSampleCount, + }, + contrast: { + ...contrastSection, + enabled: options.contrast, + samples: browser.contrastSamples, + checked: browser.contrastChecked, + passed: browser.contrastPassed, + }, + snapshots: { + enabled: options.snapshots, + files: snapshotFiles, + times: options.snapshots ? browser.screenshots.map((shot) => shot.time) : [], + findingFiles: [], + }, + }; + trackCheckReport({ + contrastGate: options.contrast, + motionGate: motion.kind !== "none", + captionZoneGate: options.captionZone !== undefined, + frameCheckGate: options.frameCheck !== undefined, + snapshotsGate: options.snapshots, + lintErrors: lint.errorCount, + lintWarnings: lint.warningCount, + runtimeErrors: runtime.errorCount, + runtimeWarnings: runtime.warningCount, + layoutErrors: layout.errorCount, + layoutWarnings: layout.warningCount, + motionErrors: motionSection.errorCount, + motionWarnings: motionSection.warningCount, + contrastErrors: contrastSection.errorCount, + contrastWarnings: contrastSection.warningCount, + launchSettleMs: browser.timings.launchSettleMs, + seekLoopMs: browser.timings.seekLoopMs, + contrastMs: browser.timings.contrastMs, + gridPoints: browser.layoutSamples.length, + contrastPoints: browser.contrastChecked, + ok: report.ok, + exitCode: checkExitCode(report), + runId: getRunId(), + }); + return report; +} + +function shapeLayoutSection( + issues: AnchoredLayoutIssue[], + browser: CheckBrowserResult, + options: CheckOptions, +): CheckReport["layout"] { + const shaped = shapeLayoutFindings(issues, options, browser.layoutSamples.length); + return { + ...section(shaped.findings), + duration: browser.duration, + samples: browser.layoutSamples, + transitionSamples: browser.transitionSamples, + transitionSamplesDropped: browser.transitionSamplesDropped, + tolerance: options.tolerance, + totalIssueCount: shaped.totalIssueCount, + truncated: shaped.truncated, + }; +} + +function shapeLayoutFindings( + issues: AnchoredLayoutIssue[], + options: CheckOptions, + totalSampleCount?: number, +): { findings: AnchoredLayoutIssue[]; totalIssueCount: number; truncated: boolean } { + const deduped = dedupeLayoutIssues(issues); + const all = options.collapseStatic + ? collapseStaticLayoutIssues(deduped, totalSampleCount) + : deduped; + const limited = limitLayoutIssues(all, options.maxIssues); + return { + findings: limited.issues.map(ensureAnchoredLayoutIssue), + totalIssueCount: limited.totalIssueCount, + truncated: limited.truncated, + }; +} + +function ensureAnchoredLayoutIssue(issue: LayoutIssue): AnchoredLayoutIssue { + const sourceFile = Reflect.get(issue, "sourceFile"); + const dataAttributes = Reflect.get(issue, "dataAttributes"); + const bbox = Reflect.get(issue, "bbox"); + if (typeof sourceFile === "string" && isStringRecord(dataAttributes) && isBbox(bbox)) { + return { ...issue, sourceFile, dataAttributes, bbox }; + } + return { + ...issue, + sourceFile: "index.html", + dataAttributes: {}, + bbox: rectToBbox(issue.rect), + }; +} + +function section(findings: T[]): CheckSection { + const errorCount = findings.filter((finding) => finding.severity === "error").length; + const warningCount = findings.filter((finding) => finding.severity === "warning").length; + const infoCount = findings.filter((finding) => finding.severity === "info").length; + return { ok: errorCount === 0, errorCount, warningCount, infoCount, findings }; +} + +function emptyBrowserResult(): CheckBrowserResult { + return { + duration: 0, + layoutSamples: [], + transitionSamples: [], + transitionSamplesDropped: 0, + runtimeFindings: [], + layoutIssues: [], + motionIssues: [], + motionSampleCount: 0, + contrastSamples: [], + contrastFindings: [], + contrastChecked: 0, + contrastPassed: 0, + screenshots: [], + timings: { launchSettleMs: 0, seekLoopMs: 0, contrastMs: 0 }, + }; +} + +function runtimeFailure(error: unknown, code = "check_runtime_failure"): CheckFinding { + return findingAtRoot(code, "error", normalizeErrorMessage(error), "index.html"); +} + +function findingAtRoot( + code: string, + severity: CheckSeverity, + message: string, + sourceFile: string, +): CheckFinding { + return { + code, + severity, + message, + selector: "[data-composition-id]", + dataAttributes: {}, + sourceFile, + bbox: ZERO_BBOX, + time: 0, + }; +} + +function failureReport(options: CheckOptions, finding: CheckFinding): CheckReport { + const lint = { ...section([]), filesScanned: 0 }; + const browser = emptyBrowserResult(); + browser.runtimeFindings.push(finding); + return buildReport(options, lint, browser, { kind: "none" }, [], []); +} + +function isBbox(value: unknown): value is CheckBbox { + if (typeof value !== "object" || value === null) return false; + return ["x", "y", "width", "height"].every((key) => typeof Reflect.get(value, key) === "number"); +} + +function isStringRecord(value: unknown): value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + return Object.keys(value).every((key) => typeof Reflect.get(value, key) === "string"); +} + +function resolveMotionSpec(projectDir: string): MotionSpecResolution { + const path = findMotionSpec(projectDir); + if (!path) return { kind: "none" }; + const result = readMotionSpec(path); + return result.ok + ? { kind: "valid", path, spec: result.spec } + : { + kind: "invalid", + path, + message: `Invalid motion spec ${path}: ${result.errors.join("; ")}`, + }; +} + +async function runBrowserCheck( + project: ProjectDir, + options: CheckOptions, + motion: MotionSpecResolution, +): Promise { + const module = await import("./checkBrowser.js"); + // runAuditGrid is handed over as a callback so checkBrowser never imports + // this module back (no import cycle). + return module.runBrowserCheck(project, options, motion, runAuditGrid); +} + +async function writeSnapshot( + projectDir: string, + index: number, + time: number, + pngBase64: string, +): Promise { + const snapshotDir = join(projectDir, "snapshots"); + mkdirSync(snapshotDir, { recursive: true }); + const filename = `frame-${String(index).padStart(2, "0")}-at-${time.toFixed(1)}s.png`; + const path = join(snapshotDir, filename); + writeFileSync(path, Buffer.from(pngBase64, "base64")); + return join("snapshots", filename); +} + +async function captureFindingCrops( + project: ProjectDir, + options: CheckOptions, + requests: CheckFindingCropRequest[], +): Promise { + const module = await import("./checkBrowser.js"); + // Handed over the same way runBrowserCheck is (checkBrowser never imports this module back). + return module.captureFindingCrops(project, options, requests); +} + +const DEFAULT_DEPENDENCIES: CheckDependencies = { + lintProject, + resolveMotionSpec, + runBrowserCheck, + writeSnapshot, + captureFindingCrops, +}; diff --git a/packages/cli/src/utils/checkTypes.ts b/packages/cli/src/utils/checkTypes.ts new file mode 100644 index 000000000..02996dc9e --- /dev/null +++ b/packages/cli/src/utils/checkTypes.ts @@ -0,0 +1,246 @@ +import type { ProjectLintResult } from "./lintProject.js"; +import type { LayoutIssue, LayoutOverflow, LayoutRect } from "./layoutAudit.js"; +import type { Canvas, MotionFrame } from "./motionAudit.js"; +import type { MotionSpec } from "./motionSpec.js"; +import type { ProjectDir } from "./project.js"; + +export interface CheckOptions { + samples: number; + at?: number[]; + atTransitions: boolean; + maxTransitionSamples?: number; + maxIssues: number; + collapseStatic: boolean; + tolerance: number; + timeout: number; + contrast: boolean; + strict: boolean; + snapshots: boolean; + captionZone?: CaptionZoneOptions; + frameCheck?: FrameCheckOptions; +} + +export interface CaptionZoneOptions { + x0: number; + y0: number; + x1: number; + y1: number; + severity?: "error" | "warning"; + seek?: number[]; +} + +export interface FrameCheckOptions { + tol?: number; + severity?: "error" | "warning"; + seek?: number[]; +} + +export type CheckSeverity = "error" | "warning" | "info"; + +export interface CheckBbox { + x: number; + y: number; + width: number; + height: number; +} + +export interface CheckAnchor { + selector: string; + dataAttributes: Record; + sourceFile: string; + bbox: CheckBbox; + time: number; +} + +export interface CheckFinding extends CheckAnchor { + code: string; + severity: CheckSeverity; + message: string; + text?: string; + fixHint?: string; + url?: string; + line?: number; +} + +export interface AnchoredLayoutIssue extends LayoutIssue, CheckAnchor {} + +export interface ContrastAuditEntry extends CheckAnchor { + text: string; + ratio: number; + wcagAA: boolean; + large: boolean; + fg: string; + bg: string; +} + +export interface CheckContrastFinding extends CheckFinding { + fg: string; + bg: string; + ratio: number; + requiredRatio: number; + suggestedColor: string; + large: boolean; +} + +export interface ContrastCapture { + entries: ContrastAuditEntry[]; + pngBase64: string; +} + +export interface GeometryCandidateRequest { + text: boolean; + media: boolean; + tolerance: number; +} + +/** A labeled rectangle drawn on an overview frame's annotation overlay + * (`check --snapshots`) so one screenshot orients an agent across every + * error finding at that sample time. */ +export interface CheckAnnotationBox { + label: string; + bbox: CheckBbox; +} + +/** A single crop to capture for `check --snapshots`'s per-finding evidence + * PNGs — filename and bbox already resolved by the pipeline. */ +export interface CheckFindingCropRequest { + filename: string; + time: number; + bbox: CheckBbox; +} + +export interface CheckGeometryCandidate extends CheckAnchor { + kind: "text" | "media"; + tag: string; + text: string; + rect: LayoutRect; + elementRect: LayoutRect; + overflow?: LayoutOverflow; +} + +export type MotionSpecResolution = + | { kind: "none" } + | { kind: "valid"; path: string; spec: MotionSpec } + | { kind: "invalid"; path: string; message: string }; + +export interface CheckAuditDriver { + initialize(contrast: boolean): Promise; + getDuration(): Promise; + getTransitionBoundaries(): Promise; + getCanvas(): Promise; + findAmbiguousSelectors(selectors: string[]): Promise; + seek(time: number): Promise; + collectLayout(time: number, tolerance: number): Promise; + /** Frozen-sweep guard (#U10): an opaque per-sample geometry+opacity + * fingerprint of the current seeked state, for detecting a timeline that + * never advances under seek. See layout-audit.browser.js. */ + collectLayoutGeometry(): Promise; + collectGeometryCandidates( + time: number, + request: GeometryCandidateRequest, + ): Promise; + collectMotionFrame( + time: number, + selectors: string[], + livenessScopes: string[], + ): Promise; + anchorMotionIssues(issues: LayoutIssue[]): Promise; + collectContrast(time: number, annotations?: CheckAnnotationBox[]): Promise; +} + +export interface CheckScreenshot { + time: number; + pngBase64: string; +} + +export interface CheckTimings { + launchSettleMs: number; + seekLoopMs: number; + contrastMs: number; +} + +export interface CheckBrowserResult { + duration: number; + layoutSamples: number[]; + transitionSamples: number[]; + transitionSamplesDropped: number; + runtimeFindings: CheckFinding[]; + layoutIssues: AnchoredLayoutIssue[]; + motionIssues: AnchoredLayoutIssue[]; + motionSampleCount: number; + contrastSamples: number[]; + contrastFindings: CheckContrastFinding[]; + contrastChecked: number; + contrastPassed: number; + screenshots: CheckScreenshot[]; + timings: CheckTimings; +} + +/** The seek-grid audit loop, injected into checkBrowser so it never imports checkPipeline back. */ +export type RunAuditGrid = ( + driver: CheckAuditDriver, + options: CheckOptions, + motion: MotionSpecResolution, +) => Promise; + +export interface CheckSection { + ok: boolean; + errorCount: number; + warningCount: number; + infoCount: number; + findings: T[]; +} + +export interface CheckReport { + ok: boolean; + strict: boolean; + lint: CheckSection & { filesScanned: number }; + runtime: CheckSection; + layout: CheckSection & { + duration: number; + samples: number[]; + transitionSamples: number[]; + transitionSamplesDropped: number; + tolerance: number; + totalIssueCount: number; + truncated: boolean; + }; + motion: CheckSection & { enabled: boolean; specPath?: string; samples: number }; + contrast: CheckSection & { + enabled: boolean; + samples: number[]; + checked: number; + passed: number; + }; + snapshots: { enabled: boolean; files: string[]; times: number[]; findingFiles: string[] }; +} + +export interface CheckDependencies { + lintProject(projectDir: string): Promise; + resolveMotionSpec(projectDir: string): MotionSpecResolution; + runBrowserCheck( + project: ProjectDir, + options: CheckOptions, + motion: MotionSpecResolution, + ): Promise; + writeSnapshot( + projectDir: string, + index: number, + time: number, + pngBase64: string, + ): Promise; + captureFindingCrops( + project: ProjectDir, + options: CheckOptions, + requests: CheckFindingCropRequest[], + ): Promise; +} + +export function rectToBbox(rect: { + left: number; + top: number; + width: number; + height: number; +}): CheckBbox { + return { x: rect.left, y: rect.top, width: rect.width, height: rect.height }; +} diff --git a/packages/cli/src/utils/layoutAudit.test.ts b/packages/cli/src/utils/layoutAudit.test.ts index 6c4ffc970..ba5717103 100644 --- a/packages/cli/src/utils/layoutAudit.test.ts +++ b/packages/cli/src/utils/layoutAudit.test.ts @@ -199,6 +199,82 @@ describe("layoutAudit helpers", () => { }); }); +// #U10: held-duration severity tiering on top of the existing collapse step. +// Sample counts below (9) mirror the CLI's default grid so the "1 sample = +// entrance/exit transient, 2+ adjacent samples = held" framing in the +// approach doc lines up with the numbers used here. +describe("persistence-tiered severity (#U10)", () => { + it("demotes a content_overlap seen at only one sample among several to info", () => { + const collapsed = collapseStaticLayoutIssues( + [{ ...issue("content_overlap", "warning"), time: 3 }], + 9, + ); + + expect(collapsed).toHaveLength(1); + expect(collapsed[0]).toMatchObject({ severity: "info", occurrences: 1 }); + }); + + it("promotes content_overlap held across >= 2 adjacent samples to error", () => { + const collapsed = collapseStaticLayoutIssues( + [ + { ...issue("content_overlap", "warning"), time: 3 }, + { ...issue("content_overlap", "warning"), time: 3.6 }, + ], + 9, + ); + + expect(collapsed).toHaveLength(1); + expect(collapsed[0]).toMatchObject({ severity: "error", occurrences: 2 }); + }); + + it("does not demote a finding held at every sample — persistence, not a single hit", () => { + const collapsed = collapseStaticLayoutIssues( + [ + { ...issue("text_box_overflow", "error"), time: 1 }, + { ...issue("text_box_overflow", "error"), time: 3 }, + { ...issue("text_box_overflow", "error"), time: 5 }, + ], + 9, + ); + + expect(collapsed).toHaveLength(1); + expect(collapsed[0]).toMatchObject({ severity: "error", occurrences: 3 }); + }); + + it("only re-promotes content_overlap — other held codes keep their original severity", () => { + const collapsed = collapseStaticLayoutIssues( + [ + { ...issue("container_overflow", "warning"), time: 3 }, + { ...issue("container_overflow", "warning"), time: 3.6 }, + ], + 9, + ); + + expect(collapsed[0]).toMatchObject({ severity: "warning" }); + }); + + it("skips tiering entirely on a single-sample run — nothing to compare a transient against", () => { + const collapsed = collapseStaticLayoutIssues( + [{ ...issue("content_overlap", "warning"), time: 3 }], + 1, + ); + + expect(collapsed[0]).toMatchObject({ severity: "warning" }); + }); + + it("infers the sample count from distinct issue times when none is given", () => { + // Two distinct times among the raw issues imply a multi-sample run even + // without an explicit count, so the single-occurrence group still demotes. + const collapsed = collapseStaticLayoutIssues([ + { ...issue("content_overlap", "warning"), time: 3 }, + { ...issue("text_box_overflow", "error"), time: 5 }, + ]); + + const overlap = collapsed.find((found) => found.code === "content_overlap"); + expect(overlap).toMatchObject({ severity: "info" }); + }); +}); + function issue(code: LayoutIssue["code"], severity: LayoutIssue["severity"]): LayoutIssue { return { code, diff --git a/packages/cli/src/utils/layoutAudit.ts b/packages/cli/src/utils/layoutAudit.ts index 70b00deb9..6ba63ccb9 100644 --- a/packages/cli/src/utils/layoutAudit.ts +++ b/packages/cli/src/utils/layoutAudit.ts @@ -16,6 +16,11 @@ export type LayoutIssueCode = | "container_overflow" | "content_overlap" | "text_occluded" + | "caption_zone_collision" + | "frame_out_of_frame" + // Frozen-sweep guard (#U10) — a whole-run meta-finding, not a per-sample + // geometry observation; never persistence-tiered (see `applyPersistenceTier`). + | "sweep_static" // Motion-verification findings (#1437) — evaluated against the seeked timeline. | "motion_appears_late" | "motion_out_of_order" @@ -40,6 +45,9 @@ export interface LayoutIssue { rect: LayoutRect; containerRect?: LayoutRect; overflow?: LayoutOverflow; + /** `text_occluded` only: approximate fraction (0-1) of the occlusion probe + * grid that hit an opaque occluder — see layout-audit.browser.js. */ + coveredFraction?: number; fixHint?: string; } @@ -152,6 +160,7 @@ export function dedupeLayoutIssues(issues: LayoutIssue[]): LayoutIssue[] { issue.containerSelector ?? "", issue.text ?? "", issue.overflow ? formatOverflow(issue.overflow) : "", + framePositionKey(issue), ].join("|"); if (seen.has(key)) continue; seen.add(key); @@ -161,7 +170,44 @@ export function dedupeLayoutIssues(issues: LayoutIssue[]): LayoutIssue[] { return result; } -export function collapseStaticLayoutIssues(issues: LayoutIssue[]): LayoutIssue[] { +// Persistence-tier thresholds (#U10, adapted from Adam Rosler's visual-linter +// design). The approach doc frames these as held-duration floors — ignore +// under ~250ms, re-promote content_overlap at >= ~500ms — measured against +// the SAME firstSeen/lastSeen span this collapse step already tracks. At the +// default 9-sample grid over a multi-second composition, a single collapsed +// occurrence is held 0ms (one entrance/exit transient sample) and two +// collapsed occurrences are already >= one sample-to-sample gap, which is +// well past 500ms — so "held under 250ms" reduces to `occurrences <= 1` and +// "held >= 500ms" reduces to `occurrences >= 2`. Tiering below is written in +// those sample-count terms (the mapping the approach doc asks to document), +// with the literal ms span (CONTENT_OVERLAP_HELD_ERROR_MS) kept as a fallback +// for callers whose samples really are spaced close enough together for the +// ms floor to matter on its own (dense `--at`/`--at-transitions` runs). The +// ~250ms ignore floor needs no separate constant — see the occurrences <= 1 +// branch below. +const CONTENT_OVERLAP_HELD_ERROR_MS = 500; +const HELD_ACROSS_SAMPLES_MIN_OCCURRENCES = 2; + +// Tiering only applies to layout-audit.browser.js's own per-sample seek-grid +// findings — the ones this collapse step's firstSeen/lastSeen span was built +// to describe. `caption_zone_collision`/`frame_out_of_frame` (a different +// script, U3) and the `motion_*`/`sweep_static` codes (evaluated once over +// the whole run, not per grid sample) already carry their own singular +// dedupe/severity semantics; re-interpreting their occurrence count as a +// held-duration signal would misread it. +const PERSISTENCE_TIERED_CODES: ReadonlySet = new Set([ + "text_box_overflow", + "clipped_text", + "canvas_overflow", + "container_overflow", + "content_overlap", + "text_occluded", +]); + +export function collapseStaticLayoutIssues( + issues: LayoutIssue[], + totalSampleCount?: number, +): LayoutIssue[] { const groups = new Map< string, { @@ -190,13 +236,57 @@ export function collapseStaticLayoutIssues(issues: LayoutIssue[]): LayoutIssue[] existing.occurrences += 1; } - return [...groups.values()].map(({ issue, firstSeen, lastSeen, occurrences }) => ({ - ...issue, - time: firstSeen, - firstSeen, - lastSeen, - occurrences, - })); + // A run that only ever sampled one point in time can't distinguish a + // transient from a persistent finding — skip tiering entirely rather than + // guess (see `applyPersistenceTier`). + const sampleCount = totalSampleCount ?? new Set(issues.map((issue) => issue.time)).size; + const multiSampleRun = sampleCount > 1; + + return [...groups.values()].map(({ issue, firstSeen, lastSeen, occurrences }) => + applyPersistenceTier( + { ...issue, time: firstSeen, firstSeen, lastSeen, occurrences }, + multiSampleRun, + ), + ); +} + +/** + * Held-duration severity tiering (#U10). A finding observed at only one + * sample among several (held 0ms) is an entrance/exit transient, not a held + * defect — demote to info so it stays in the data (verbose/--json output) + * without gating the run. `content_overlap` specifically re-promotes from + * warning to error once it's held long enough to be a real, sustained + * collision rather than a crossfade/transition blip (resolves the TODO in + * layout-audit.browser.js's `overlapIssue`). A finding held at every sample + * (a genuinely static defect) is well past both thresholds and is left + * untouched either way — persistence, not the code, decides the tier. + */ +function applyPersistenceTier(issue: LayoutIssue, multiSampleRun: boolean): LayoutIssue { + if (!multiSampleRun) return issue; + if (!PERSISTENCE_TIERED_CODES.has(issue.code)) return issue; + + const occurrences = issue.occurrences ?? 1; + // A single collapsed occurrence is held 0ms by construction (firstSeen === + // lastSeen) — always under the ignore floor, so occurrences <= 1 is a + // complete (not approximate) test for "held under 250ms". + if (occurrences <= 1) { + return { ...issue, severity: "info" }; + } + if (issue.code === "content_overlap" && isContentOverlapHeldLongEnough(issue, occurrences)) { + return { ...issue, severity: "error" }; + } + return issue; +} + +// Split out of applyPersistenceTier so the two independent "held long enough" +// signals (sample count vs. wall-clock span) read as one boolean question +// instead of adding a third compound branch to the tiering ladder above. +function isContentOverlapHeldLongEnough(issue: LayoutIssue, occurrences: number): boolean { + if (occurrences >= HELD_ACROSS_SAMPLES_MIN_OCCURRENCES) return true; + const firstSeen = issue.firstSeen ?? issue.time; + const lastSeen = issue.lastSeen ?? issue.time; + const heldMs = (lastSeen - firstSeen) * 1000; + return heldMs >= CONTENT_OVERLAP_HELD_ERROR_MS; } export function limitLayoutIssues( @@ -230,9 +320,16 @@ function staticIssueKey(issue: LayoutIssue): string { issue.containerSelector ?? "", issue.text ?? "", issue.overflow ? formatOverflow(issue.overflow) : "", + framePositionKey(issue), ].join("|"); } +function framePositionKey(issue: LayoutIssue): string { + return issue.code === "frame_out_of_frame" + ? `${Math.round(issue.rect.left)},${Math.round(issue.rect.top)}` + : ""; +} + function uniqueSortedTimes(times: number[]): number[] { const rounded = times.map(roundTime); return [...new Set(rounded)].sort((a, b) => a - b); diff --git a/packages/cli/src/utils/lintProject.test.ts b/packages/cli/src/utils/lintProject.test.ts index ed5df9f36..0ce2eb0c6 100644 --- a/packages/cli/src/utils/lintProject.test.ts +++ b/packages/cli/src/utils/lintProject.test.ts @@ -914,6 +914,19 @@ describe("multiple_root_compositions", () => { expect(finding).toBeUndefined(); }); + it("ignores macOS AppleDouble HTML metadata files", async () => { + const project = makeProject(validHtml()); + writeFileSync(join(project, "._index.html"), validHtml()); + mkdirSync(join(project, "compositions"), { recursive: true }); + writeFileSync(join(project, "compositions", "._scene.html"), validHtml("scene")); + const { results } = await lintProject(project); + const finding = results[0]?.result.findings.find( + (f) => f.code === "multiple_root_compositions", + ); + expect(finding).toBeUndefined(); + expect(results.some((result) => result.file.includes("._"))).toBe(false); + }); + it("ignores HTML files without data-composition-id", async () => { const project = makeProject(validHtml()); writeFileSync(join(project, "readme.html"), "Not a composition"); diff --git a/packages/cli/src/utils/skillsManifest.test.ts b/packages/cli/src/utils/skillsManifest.test.ts index c3c7682f0..f5a56728c 100644 --- a/packages/cli/src/utils/skillsManifest.test.ts +++ b/packages/cli/src/utils/skillsManifest.test.ts @@ -1,5 +1,15 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { existsSync, mkdtempSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + chmodSync, + existsSync, + mkdtempSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -10,12 +20,33 @@ import { diffSkills, FALLBACK_CORE_SKILLS, isCoreSkill, + MANIFEST_FILE, presentSkills, + pruneOrphanedLockEntries, skillsAttributedToSource, type SkillsManifest, type SkillEntry, } from "./skillsManifest.js"; +// The retired-skill regression tests below drive `checkSkills`'s real +// `canonical: true` network path (see resolveLatestManifest) instead of an +// explicit local `source` — that's the whole point (it must NOT read a stale +// local repo manifest). Stub the two network boundaries it can reach so those +// tests stay fast and offline: `git ls-remote` (remoteHeadSha) always "fails" +// so it falls back to the branch URL, and `fetch` is stubbed per-test. `vi.mock` +// is hoisted above these imports regardless of source position. No existing +// test in this file omits `source`, so nothing else touches this mock. +vi.mock("node:child_process", () => ({ + execFile: vi.fn( + ( + _cmd: string, + _args: readonly string[], + _opts: unknown, + callback: (err: Error | null) => void, + ) => callback(new Error("no git in tests")), + ), +})); + let root: string; beforeEach(() => { @@ -551,3 +582,173 @@ describe("checkSkills removed-upstream detection", () => { expect(res.summary.removed).toBe(1); }); }); + +// Regression coverage for "variant 1" of the retired-skill bug: `updateSkills` +// (see commands/skills.ts) resolves its own targeted-install check with +// `canonical: true` specifically so it never trusts a stale local +// `skills-manifest.json` — this is the mechanism that makes that safe. +describe("checkSkills canonical bypass of the in-repo manifest shortcut", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function stubFetchedManifest(manifest: SkillsManifest): void { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true, json: async () => manifest }) as unknown as Response), + ); + } + + it("without canonical, a stale in-repo manifest wins (documented dev/CI shortcut)", async () => { + const project = join(root, "project"); + const home = join(root, "home"); + mkdirSync(project, { recursive: true }); + mkdirSync(home, { recursive: true }); + // A checked-out repo's own manifest, stale: it still lists a skill that + // has since been retired from the canonical published repo. + writeFileSync( + join(project, MANIFEST_FILE), + JSON.stringify({ + source: "heygen-com/hyperframes", + skills: { "retired-skill": { hash: "x", files: 1 } }, + }), + ); + + const res = await checkSkills({ cwd: project, home }); + expect(res.skills.map((s) => s.name)).toContain("retired-skill"); + }); + + it("with canonical:true, the same stale in-repo manifest is ignored — the fetched manifest wins", async () => { + const project = join(root, "project"); + const home = join(root, "home"); + mkdirSync(project, { recursive: true }); + mkdirSync(home, { recursive: true }); + writeFileSync( + join(project, MANIFEST_FILE), + JSON.stringify({ + source: "heygen-com/hyperframes", + skills: { "retired-skill": { hash: "x", files: 1 }, kept: { hash: "y", files: 1 } }, + }), + ); + // The canonical (fetched) manifest no longer ships `retired-skill`. + stubFetchedManifest({ + source: "heygen-com/hyperframes", + skills: { kept: { hash: "y", files: 1 } }, + }); + + const res = await checkSkills({ cwd: project, home, canonical: true }); + expect(res.skills.map((s) => s.name)).not.toContain("retired-skill"); + expect(res.skills.map((s) => s.name)).toContain("kept"); + }); + + it("canonical:true still honors an explicit local `source` override", async () => { + const project = join(root, "project"); + const home = join(root, "home"); + mkdirSync(project, { recursive: true }); + mkdirSync(home, { recursive: true }); + writeFileSync( + join(project, MANIFEST_FILE), + JSON.stringify({ source: "test", skills: { "from-repo-shortcut": { hash: "x", files: 1 } } }), + ); + const explicitSource = join(root, "explicit-manifest.json"); + writeFileSync( + explicitSource, + JSON.stringify({ + source: "test", + skills: { "from-explicit-source": { hash: "y", files: 1 } }, + }), + ); + + // An explicit `source` is a deliberate caller choice — canonical must not + // override it, only the silent in-repo shortcut. + const res = await checkSkills({ source: explicitSource, cwd: project, home, canonical: true }); + expect(res.skills.map((s) => s.name)).toEqual(["from-explicit-source"]); + }); +}); + +describe("pruneOrphanedLockEntries", () => { + function writeLock(path: string, skills: Record): void { + writeFileSync(path, JSON.stringify({ version: 1, skills, dismissed: [] })); + } + + it("removes only the given names, leaving other entries and lock fields intact", () => { + const home = join(root, "home"); + mkdirSync(join(home, ".agents"), { recursive: true }); + const lockPath = join(home, ".agents", ".skill-lock.json"); + writeLock(lockPath, { + a: { source: "heygen-com/hyperframes" }, + b: { source: "heygen-com/hyperframes" }, + c: { source: "heygen-com/hyperframes" }, + }); + + const pruned = pruneOrphanedLockEntries(["a", "b"], "global", { home }); + + expect(pruned.sort()).toEqual(["a", "b"]); + const rewritten = JSON.parse(readFileSync(lockPath, "utf8")); + expect(Object.keys(rewritten.skills)).toEqual(["c"]); + expect(rewritten.version).toBe(1); // other lock fields survive the rewrite + }); + + it("is idempotent — a second call with the same names finds nothing left and no-ops", () => { + const home = join(root, "home"); + mkdirSync(join(home, ".agents"), { recursive: true }); + const lockPath = join(home, ".agents", ".skill-lock.json"); + writeLock(lockPath, { a: { source: "heygen-com/hyperframes" } }); + + const first = pruneOrphanedLockEntries(["a"], "global", { home }); + expect(first).toEqual(["a"]); + + const before = readFileSync(lockPath, "utf8"); + const second = pruneOrphanedLockEntries(["a"], "global", { home }); + expect(second).toEqual([]); + // No entries left to touch → the file is never rewritten a second time. + expect(readFileSync(lockPath, "utf8")).toBe(before); + }); + + it("writes atomically with no trailing newline, no leftover temp file, and preserves the file mode", () => { + const home = join(root, "home-atomic"); + mkdirSync(join(home, ".agents"), { recursive: true }); + const lockPath = join(home, ".agents", ".skill-lock.json"); + writeLock(lockPath, { + a: { source: "heygen-com/hyperframes" }, + b: { source: "heygen-com/hyperframes" }, + }); + chmodSync(lockPath, 0o640); + + const pruned = pruneOrphanedLockEntries(["a"], "global", { home }); + + expect(pruned).toEqual(["a"]); + const raw = readFileSync(lockPath, "utf8"); + expect(raw.endsWith("\n")).toBe(false); + expect(JSON.parse(raw).skills).toEqual({ b: { source: "heygen-com/hyperframes" } }); + // No `.tmp` sibling left behind by the temp-file + rename. + expect(readdirSync(join(home, ".agents"))).toEqual([".skill-lock.json"]); + // Original permissions survive the rewrite (POSIX only — Windows's fs + // layer reports 0o666 regardless of the mode we set, so the bits aren't + // meaningful there). + if (process.platform !== "win32") { + expect(statSync(lockPath).mode & 0o777).toBe(0o640); + } + }); + + it("no-ops without throwing when the lock file doesn't exist", () => { + const home = join(root, "home-without-lock"); + mkdirSync(home, { recursive: true }); + expect(pruneOrphanedLockEntries(["a"], "global", { home })).toEqual([]); + }); + + it("resolves the project lock at /skills-lock.json for scope: project", () => { + const project = join(root, "project"); + mkdirSync(project, { recursive: true }); + writeLock(join(project, "skills-lock.json"), { + a: { source: "heygen-com/hyperframes" }, + b: { source: "heygen-com/hyperframes" }, + }); + + const pruned = pruneOrphanedLockEntries(["a"], "project", { cwd: project }); + + expect(pruned).toEqual(["a"]); + const rewritten = JSON.parse(readFileSync(join(project, "skills-lock.json"), "utf8")); + expect(Object.keys(rewritten.skills)).toEqual(["b"]); + }); +}); diff --git a/packages/cli/src/utils/skillsManifest.ts b/packages/cli/src/utils/skillsManifest.ts index e58883dca..5145cc034 100644 --- a/packages/cli/src/utils/skillsManifest.ts +++ b/packages/cli/src/utils/skillsManifest.ts @@ -17,7 +17,14 @@ import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; -import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { + existsSync, + readdirSync, + readFileSync, + renameSync, + statSync, + writeFileSync, +} from "node:fs"; import { homedir } from "node:os"; import { isAbsolute, join, relative, resolve, sep } from "node:path"; import { promisify } from "node:util"; @@ -515,6 +522,51 @@ function detectRemoved( return { removed, lockMissing: lock === null }; } +/** + * Remove `names` from the vercel-labs/skills lock at `scope`, writing the file + * back if anything changed. Self-heals the half of removed-upstream detection + * that `skills remove` can't: upstream's `remove` command scans ON-DISK skill + * directories to decide what's installed (see vercel-labs/skills' + * `removeCommand`), so a lock entry for a skill retired before it ever shipped + * a bundle to this machine has no on-disk dir to match. That makes `skills + * remove -g --yes` a silent no-op — it prints "No matching skills found + * for: …" and exits 0 WITHOUT touching the lock. Left alone, `detectRemoved` + * re-flags the same lock entry as "removed" on every future run, forever. + * + * Reuses the pinned lock path (see SKILLS_CLI_LOCK_PATHS_VERIFIED_AT above — + * re-check that comment before bumping the upstream version this is pinned + * against) so this writes to exactly where the upstream CLI itself reads and + * writes the lock. + * + * Idempotent by construction: only entries still present in the lock are ever + * touched, so calling this again with the same names — after the upstream + * `skills remove` no-op reported above has already run once — finds nothing + * left and returns `[]`. + */ +export function pruneOrphanedLockEntries( + names: readonly string[], + scope: "project" | "global", + opts: { cwd?: string; home?: string } = {}, +): string[] { + const path = lockPathForScope(scope, opts); + const lock = readSkillLock(path); + if (!lock?.skills) return []; + const pruned = names.filter((name) => name in lock.skills!); + if (pruned.length === 0) return []; + for (const name of pruned) delete lock.skills[name]; + // Atomic write (temp file + rename, same pattern as telemetry/autoUpdate.ts + // and utils/download.ts) so a crash mid-write can never leave a truncated + // lock behind. `path` is guaranteed to exist here (readSkillLock already + // returned a non-null lock), so preserving its mode on the temp file before + // the rename is safe. No trailing newline: matches the upstream + // vercel-labs/skills lock's on-disk shape, so a prune stays a minimal diff. + const mode = statSync(path).mode & 0o777; + const tmp = `${path}.tmp`; + writeFileSync(tmp, JSON.stringify(lock, null, 2), { mode }); + renameSync(tmp, path); + return pruned; +} + // ── Resolving the "latest" manifest ────────────────────────────────────────── /** Walk up from `cwd` to find a repo checkout that ships the manifest. */ @@ -613,17 +665,27 @@ async function fetchRemoteManifest(source?: string): Promise { * - undefined → in-repo manifest if present (dev / CI), else fetch from GitHub * - a local path to a manifest file or a repo root containing `skills/` * - an `owner/repo` slug or full URL → fetched from GitHub + * + * `canonical: true` skips the in-repo shortcut (the `!source` branch below) + * even when one is found, and always resolves over the network instead. Use + * it for any decision that must match what `skills add` actually installs + * from — the canonical published repo — never a local checkout's manifest, + * which can be stale (e.g. still listing a skill that was retired/renamed + * upstream since that checkout's last pull). An explicit local `source` + * override is a deliberate caller choice and still wins regardless of + * `canonical`. */ async function resolveLatestManifest( source?: string, cwd = process.cwd(), + opts: { canonical?: boolean } = {}, ): Promise { // A local path is a relative one (./ ../) or an absolute one — isAbsolute // covers POSIX `/…` and Windows `C:\…` / `\…` on their respective platforms. if (source && (source.startsWith(".") || isAbsolute(source))) { return resolveLocalManifest(source); } - if (!source) { + if (!source && !opts.canonical) { const repoManifest = findRepoManifest(cwd); if (repoManifest) return JSON.parse(readFileSync(repoManifest, "utf8")) as SkillsManifest; } @@ -635,9 +697,16 @@ async function resolveLatestManifest( * manifest. Pure-ish (network only via `resolveLatestManifest`). */ export async function checkSkills( - opts: { dir?: string; source?: string; cwd?: string; home?: string } = {}, + opts: { + dir?: string; + source?: string; + cwd?: string; + home?: string; + /** See resolveLatestManifest — bypass the in-repo manifest shortcut. */ + canonical?: boolean; + } = {}, ): Promise { - const latest = await resolveLatestManifest(opts.source, opts.cwd); + const latest = await resolveLatestManifest(opts.source, opts.cwd, { canonical: opts.canonical }); const skillNames = Object.keys(latest.skills); const root = locateInstall(skillNames, { dir: opts.dir, cwd: opts.cwd, home: opts.home }); const installed = root ? hashInstalled(root, skillNames) : {}; diff --git a/packages/cli/src/utils/skillsUpdateCheck.test.ts b/packages/cli/src/utils/skillsUpdateCheck.test.ts new file mode 100644 index 000000000..9028f9051 --- /dev/null +++ b/packages/cli/src/utils/skillsUpdateCheck.test.ts @@ -0,0 +1,111 @@ +// Nudge-count regression coverage: `refreshSkillsCache` must persist +// `summary.removed` (renamed/dropped skills), and the printed nudge total must +// include it — otherwise the background nudge undercounts what a plain +// `skills update` would actually reconcile (the "misleading 2 vs 3" bug). + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +type FakeConfig = Record; + +let config: FakeConfig; + +vi.mock("../telemetry/config.js", () => ({ + readConfig: () => ({ ...config }), + writeConfig: (next: FakeConfig) => { + config = { ...next }; + }, +})); + +vi.mock("./updateCheck.js", () => ({ + updateNoticesSuppressed: () => false, +})); + +const mockCheckSkills = vi.fn(); +vi.mock("./skillsManifest.js", () => ({ + checkSkills: (...args: unknown[]) => mockCheckSkills(...args), +})); + +describe("skillsUpdateCheck", () => { + beforeEach(() => { + vi.resetModules(); + config = {}; + mockCheckSkills.mockReset(); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("refreshSkillsCache persists the removed count alongside outdated/missing", async () => { + mockCheckSkills.mockResolvedValue({ + location: "/home/user/.claude/skills", + updateAvailable: true, + summary: { current: 1, outdated: 2, missing: 3, coreMissing: 1, removed: 3 }, + }); + + const { checkSkillsForUpdate } = await import("./skillsUpdateCheck.js"); + const meta = await checkSkillsForUpdate(true); + + expect(meta).toEqual({ updateAvailable: true, outdated: 2, missing: 1, removed: 3 }); + expect(config["skillsRemovedCount"]).toBe(3); + // Must resolve against the canonical upstream manifest, not a possibly + // stale in-repo skills-manifest.json, so this nudge agrees with what + // `updateSkills` would actually reconcile. + expect(mockCheckSkills).toHaveBeenCalledWith({ canonical: true }); + }); + + it("does not persist anything when no install was located (nothing meaningful to cache)", async () => { + mockCheckSkills.mockResolvedValue({ + location: null, + updateAvailable: false, + summary: { current: 0, outdated: 0, missing: 0, coreMissing: 0, removed: 0 }, + }); + + const { checkSkillsForUpdate } = await import("./skillsUpdateCheck.js"); + await checkSkillsForUpdate(true); + + expect(config["skillsRemovedCount"]).toBeUndefined(); + }); + + /** Drive printSkillsUpdateNotice from the given cache shape; returns what it wrote (if anything). */ + async function noticeTextFor(cache: FakeConfig): Promise { + config = cache; + const { printSkillsUpdateNotice } = await import("./skillsUpdateCheck.js"); + const writeSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + + printSkillsUpdateNotice(); + + if (writeSpy.mock.calls.length === 0) return null; + expect(writeSpy).toHaveBeenCalledTimes(1); + return String(writeSpy.mock.calls[0]?.[0]); + } + + it("the cached nudge total counts removed skills, not just outdated/missing", async () => { + // Cache pre-populated as if a prior refreshSkillsCache had run — only + // outdated + missing, no removed (the pre-fix shape). + const text = await noticeTextFor({ + skillsOutdatedCount: 1, + skillsMissingCount: 1, + skillsRemovedCount: 2, + }); + // 1 outdated + 1 missing + 2 removed = 4, not the pre-fix "2". + expect(text).toContain("4 HyperFrames skills out of date or missing"); + }); + + it("prints nothing when outdated, missing, and removed are all zero", async () => { + const text = await noticeTextFor({ + skillsOutdatedCount: 0, + skillsMissingCount: 0, + skillsRemovedCount: 0, + }); + expect(text).toBeNull(); + }); + + it("a removed-only count (no outdated/missing) still triggers the nudge", async () => { + const text = await noticeTextFor({ + skillsOutdatedCount: 0, + skillsMissingCount: 0, + skillsRemovedCount: 1, + }); + expect(text).toContain("1 HyperFrames skill out of date or missing"); + }); +}); diff --git a/packages/cli/src/utils/skillsUpdateCheck.ts b/packages/cli/src/utils/skillsUpdateCheck.ts index d29c2f3a0..c2cf384d2 100644 --- a/packages/cli/src/utils/skillsUpdateCheck.ts +++ b/packages/cli/src/utils/skillsUpdateCheck.ts @@ -16,6 +16,8 @@ export interface SkillsUpdateMeta { updateAvailable: boolean; outdated: number; missing: number; + /** Installed skills flagged removed-upstream (renamed/dropped) at the last check. */ + removed: number; } /** Synchronous read from cache — never fetches. */ @@ -25,6 +27,7 @@ function getSkillsUpdateMeta(): SkillsUpdateMeta { updateAvailable: config.skillsUpdateAvailable ?? false, outdated: config.skillsOutdatedCount ?? 0, missing: config.skillsMissingCount ?? 0, + removed: config.skillsRemovedCount ?? 0, }; } @@ -35,7 +38,10 @@ function cacheFresh(lastSkillsCheck: string | undefined, now: number): boolean { /** Run the real check and persist the result to the cache. */ async function refreshSkillsCache(): Promise { - const result = await checkSkills(); + // `canonical: true` so this nudge's counts agree with `updateSkills`'s + // source of truth — otherwise a stale in-repo skills-manifest.json (e.g. + // inside a hyperframes checkout) can produce a false-positive count here. + const result = await checkSkills({ canonical: true }); // Only record a meaningful check when skills were actually found. if (result.location) { const config = readConfig(); @@ -45,12 +51,18 @@ async function refreshSkillsCache(): Promise { // Core-missing only: skills that install on demand (workflows not yet // triggered on this machine) are not "missing" worth nagging about. config.skillsMissingCount = result.summary.coreMissing; + // Removed-upstream skills are just as reconcilable as outdated/missing + // ones (a plain `skills update` prunes them) — omitting them here is what + // made the nudge undercount (e.g. reporting "2 skills out of date or + // missing" while a 3rd, renamed/dropped skill sat unmentioned). + config.skillsRemovedCount = result.summary.removed; writeConfig(config); } return { updateAvailable: result.updateAvailable, outdated: result.summary.outdated, missing: result.summary.coreMissing, + removed: result.summary.removed, }; } @@ -70,9 +82,9 @@ export async function checkSkillsForUpdate(force?: boolean): Promise { it("accepts strict semver, incl. prerelease/build metadata", () => { @@ -150,6 +150,68 @@ async function checkWith(registryVersion: unknown): Promise<{ } } +/** + * U5: validate/inspect/layout are deprecated in favor of `check`. withMeta's + * optional `{ deprecated: true }` is the single place that adds `_meta.deprecated` + * to a --json envelope; every other command (check, lint, ...) calls withMeta + * with no second argument and must never see the key at all — not even `false`. + */ +describe("withMeta — deprecated flag", () => { + it("omits _meta.deprecated entirely when no options are passed (check/lint et al.)", () => { + const wrapped = withMeta({ ok: true }); + expect("deprecated" in wrapped._meta).toBe(false); + }); + + it("omits _meta.deprecated when options.deprecated is false", () => { + const wrapped = withMeta({ ok: true }, { deprecated: false }); + expect("deprecated" in wrapped._meta).toBe(false); + }); + + it("sets _meta.deprecated === true when requested (validate/inspect/layout)", () => { + const wrapped = withMeta({ ok: true }, { deprecated: true }); + expect(wrapped._meta.deprecated).toBe(true); + }); + + it("preserves the rest of the _meta envelope alongside the deprecated flag", () => { + const wrapped = withMeta({ ok: true }, { deprecated: true }); + expect(wrapped._meta.version).toEqual(expect.any(String)); + expect(typeof wrapped._meta.updateAvailable).toBe("boolean"); + }); +}); + +/** + * The stderr-only deprecation notice: printed once per invocation, never on + * stdout, so --json output stays pure JSON while humans still see the notice. + */ +describe("printDeprecationNotice", () => { + it("writes exactly one line to stderr, never stdout", () => { + const stderrWrites: string[] = []; + const stdoutWrites: string[] = []; + const origErrWrite = process.stderr.write.bind(process.stderr); + const origOutWrite = process.stdout.write.bind(process.stdout); + process.stderr.write = ((chunk: unknown) => { + stderrWrites.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + process.stdout.write = ((chunk: unknown) => { + stdoutWrites.push(String(chunk)); + return true; + }) as typeof process.stdout.write; + + try { + printDeprecationNotice("validate"); + } finally { + process.stderr.write = origErrWrite; + process.stdout.write = origOutWrite; + } + + expect(stdoutWrites).toEqual([]); + expect(stderrWrites).toHaveLength(1); + expect(stderrWrites[0]).toContain("hyperframes validate"); + expect(stderrWrites[0]).toContain("hyperframes check"); + }); +}); + describe("checkForUpdate — registry boundary guard", () => { afterEach(() => { vi.doUnmock("../telemetry/config.js"); diff --git a/packages/cli/src/utils/updateCheck.ts b/packages/cli/src/utils/updateCheck.ts index caaab0920..6a58fad52 100644 --- a/packages/cli/src/utils/updateCheck.ts +++ b/packages/cli/src/utils/updateCheck.ts @@ -40,6 +40,8 @@ export interface UpdateMeta { version: string; latestVersion?: string; updateAvailable: boolean; + /** Present (and true) only for commands superseded by `check`; absent otherwise. */ + deprecated?: boolean; } /** @@ -130,9 +132,30 @@ export function getUpdateMeta(): UpdateMeta { /** * Wrap a JSON payload with the _meta version envelope. * Use this in all --json command outputs for consistent agent-friendly metadata. + * + * Pass `{ deprecated: true }` from a command superseded by `check` (validate, + * inspect, layout) to add `_meta.deprecated: true`; every other call site is + * unaffected — the key is only ever added, never set to `false`. */ -export function withMeta(data: T): T & { _meta: UpdateMeta } { - return { ...data, _meta: getUpdateMeta() }; +export function withMeta( + data: T, + options?: { deprecated?: boolean }, +): T & { _meta: UpdateMeta } { + const meta = getUpdateMeta(); + if (options?.deprecated) meta.deprecated = true; + return { ...data, _meta: meta }; +} + +/** + * One-line deprecation notice for a command superseded by `check`. Always + * writes to stderr (never stdout), so a --json invocation's stdout stays + * pure, parseable JSON. Call once per invocation, before the command's own + * output. + */ +export function printDeprecationNotice(command: string): void { + process.stderr.write( + `'hyperframes ${command}' is deprecated and will be removed in a future release. Use 'hyperframes check' instead.\n`, + ); } /** diff --git a/packages/core/package.json b/packages/core/package.json index e4f9ea6a7..34b87d632 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/core", - "version": "0.7.46", + "version": "0.7.51", "description": "", "repository": { "type": "git", @@ -119,6 +119,12 @@ "import": "./src/runtime/positionEdits.ts", "types": "./src/runtime/positionEdits.ts" }, + "./runtime/position-edits-render": { + "bun": "./src/generated/position-edits-render-inline.ts", + "node": "./dist/generated/position-edits-render-inline.js", + "import": "./src/generated/position-edits-render-inline.ts", + "types": "./src/generated/position-edits-render-inline.ts" + }, "./runtime/lottie-readiness": { "bun": "./src/lottieReadiness.ts", "node": "./dist/lottieReadiness.js", @@ -309,6 +315,10 @@ "import": "./dist/runtime/positionEdits.js", "types": "./dist/runtime/positionEdits.d.ts" }, + "./runtime/position-edits-render": { + "import": "./dist/generated/position-edits-render-inline.js", + "types": "./dist/generated/position-edits-render-inline.d.ts" + }, "./runtime/lottie-readiness": { "import": "./dist/lottieReadiness.js", "types": "./dist/lottieReadiness.d.ts" @@ -388,13 +398,15 @@ "types": "./dist/index.d.ts" }, "scripts": { - "build": "bun run build:hyperframes-runtime && tsc && tsx scripts/rewrite-esm-extensions.ts", - "test": "vitest run", + "build": "bun run build:hyperframes-runtime && bun run build:position-edits-render && tsc && tsx scripts/rewrite-esm-extensions.ts", + "test": "bun run check:position-edits-render && vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "typecheck": "tsc --noEmit", "lint:runtime-preview-guards": "tsx scripts/lint-runtime-preview-guards.ts", "build:hyperframes-runtime": "tsx scripts/build-hyperframes-runtime-artifact.ts", + "build:position-edits-render": "tsx scripts/build-position-edits-render.ts", + "check:position-edits-render": "bun run build:position-edits-render && git diff --exit-code -- src/generated/position-edits-render-inline.ts", "build:hyperframes-runtime:modular": "SANDBOX_RUNTIME_VARIANT=modular tsx scripts/build-hyperframes-runtime-artifact.ts", "build:hyperframe-runtime": "tsx scripts/build-hyperframes-runtime-artifact.ts", "test:hyperframe-runtime-contract": "tsx scripts/test-hyperframe-runtime-contract.ts", diff --git a/packages/core/scripts/build-position-edits-render.ts b/packages/core/scripts/build-position-edits-render.ts new file mode 100644 index 000000000..e4ed0ef98 --- /dev/null +++ b/packages/core/scripts/build-position-edits-render.ts @@ -0,0 +1,52 @@ +/** Build the injectable position-edits render artifact from the canonical runtime. */ + +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { buildSync } from "esbuild"; +import { execFileSync } from "node:child_process"; + +const thisDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(thisDir, ".."); +const entry = resolve(repoRoot, "stubs/position-edits-render-entry.ts"); +const generatedDir = resolve(repoRoot, "src/generated"); +const outPath = resolve(generatedDir, "position-edits-render-inline.ts"); + +const result = buildSync({ + entryPoints: [entry], + bundle: true, + write: false, + platform: "browser", + format: "iife", + target: ["es2020"], + minify: true, + legalComments: "none", +}); +const iife = result.outputFiles[0]?.text ?? ""; +if (!iife) throw new Error("esbuild produced no output for position-edits-render-entry.ts"); + +mkdirSync(generatedDir, { recursive: true }); +writeFileSync( + outPath, + [ + "// AUTO-GENERATED by scripts/build-position-edits-render.ts - do not edit", + `const POSITION_EDITS_RENDER_IIFE: string = ${JSON.stringify(iife)};`, + "", + "/** Returns the pre-built position-edits render IIFE as a string constant. */", + "export function getPositionEditsRenderScript(): string {", + " return POSITION_EDITS_RENDER_IIFE;", + "}", + "", + ].join("\n"), + "utf8", +); + +try { + execFileSync("bun", ["x", "oxfmt", outPath], { stdio: "ignore" }); +} catch { + // Formatting is best effort when the generator runs in a minimal environment. +} + +console.log( + JSON.stringify({ event: "position_edits_render_generated", outPath, bytes: iife.length }), +); diff --git a/packages/core/src/compiler/timingCompiler.test.ts b/packages/core/src/compiler/timingCompiler.test.ts index 86e7dfe7c..cab293ebb 100644 --- a/packages/core/src/compiler/timingCompiler.test.ts +++ b/packages/core/src/compiler/timingCompiler.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; import { describe, it, expect } from "vitest"; import { compileTimingAttrs, @@ -6,6 +8,15 @@ import { clampDurations, } from "./timingCompiler.js"; +// Raw 0x00 bytes in the HFMASK delimiters shipped once and broke every render +// under Bun's transpiler while behaving fine under Node (issue #2139) — only a +// byte-level check catches that, so keep the delimiters as \x00 escapes. +it("source contains no raw NUL bytes", () => { + const testPath = expect.getState().testPath ?? ""; + const src = readFileSync(join(dirname(testPath), "timingCompiler.ts"), "latin1"); + expect(src.includes("\x00")).toBe(false); +}); + describe("compileTimingAttrs", () => { it("adds data-end when data-start and data-duration are present on a video", () => { const html = '