mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-14 08:13:11 +00:00
Merge remote-tracking branch 'origin/main' into de-parallel-router-failure-telemetry
# Conflicts: # packages/cli/src/telemetry/config.ts
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
+3
-1
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -8,6 +8,194 @@ Recent HyperFrames releases, including user-facing features, fixes, and migratio
|
||||
|
||||
{/* New release entries are prepended by `bun run changelog:draft <version> --write`. */}
|
||||
|
||||
<Update
|
||||
label="HyperFrames v0.7.51"
|
||||
description="Released - 2026-07-11"
|
||||
tags={["Release", "Media Use", "CLI"]}
|
||||
>
|
||||
`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).
|
||||
</Update>
|
||||
|
||||
<Update
|
||||
label="HyperFrames v0.7.50"
|
||||
description="Released - 2026-07-10"
|
||||
tags={["Release", "CLI", "Core", "Cli,skills"]}
|
||||
>
|
||||
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).
|
||||
</Update>
|
||||
|
||||
<Update
|
||||
label="HyperFrames v0.7.49"
|
||||
description="Released - 2026-07-10"
|
||||
tags={["Release", "Skills", "Figma", "Core,cli"]}
|
||||
>
|
||||
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).
|
||||
</Update>
|
||||
|
||||
<Update
|
||||
label="HyperFrames v0.7.48"
|
||||
description="Released - 2026-07-10"
|
||||
tags={["Release", "CLI"]}
|
||||
>
|
||||
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).
|
||||
</Update>
|
||||
|
||||
<Update
|
||||
label="HyperFrames v0.7.47"
|
||||
description="Released - 2026-07-10"
|
||||
tags={["Release", "Media Use", "CLI", "Studio"]}
|
||||
>
|
||||
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).
|
||||
</Update>
|
||||
|
||||
<Update
|
||||
label="HyperFrames v0.7.46"
|
||||
description="Released - 2026-07-09"
|
||||
|
||||
@@ -141,7 +141,7 @@ Not everything belongs in the registry. The bar is production quality.
|
||||
<Step title="Validate">
|
||||
```bash
|
||||
hyperframes lint
|
||||
hyperframes validate
|
||||
hyperframes check
|
||||
npx oxfmt your-block.html
|
||||
```
|
||||
</Step>
|
||||
|
||||
+29
-15
@@ -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.
|
||||
|
||||
<Steps>
|
||||
<Step title="Mint a token">
|
||||
In Figma: **Settings → Security → Personal access tokens → Generate new token.**
|
||||
</Step>
|
||||
<Step title="Pick read-only scopes">
|
||||
The integration never writes to Figma — read-only is all it ever needs:
|
||||
<Step title="Check these scopes">
|
||||
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.
|
||||
</Step>
|
||||
<Step title="Export it">
|
||||
```bash
|
||||
@@ -46,7 +56,11 @@ The CLI paths need a Figma personal access token in the `FIGMA_TOKEN` environmen
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
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) |
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
|
||||
@@ -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-<code>.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 "<x0=..;y0=..;x1=..;y1=..>"` | 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`
|
||||
|
||||
<Warning>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.</Warning>
|
||||
|
||||
Inspect rendered visual layout across the composition timeline:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string>;
|
||||
/** map of dir path -> entries returned by readdirSync */
|
||||
dirs?: Record<string, string[]>;
|
||||
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({
|
||||
|
||||
@@ -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<T>(
|
||||
fn: () => Promise<T>,
|
||||
timeoutMs = INSTALL_LOCK_TIMEOUT_MS,
|
||||
pollMs = INSTALL_LOCK_POLL_MS,
|
||||
timings: InstallLockTimings = INSTALL_LOCK_TIMINGS,
|
||||
): Promise<T> {
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<CompositionSeekPage["evaluate"]>[0],
|
||||
_value?: number,
|
||||
_fallbackToBridgeAndTimelines?: boolean,
|
||||
): Promise<unknown> => undefined,
|
||||
);
|
||||
const waitForFunction = vi.fn(
|
||||
async (_pageFunction: () => boolean, _options: { timeout: number }): Promise<unknown> =>
|
||||
undefined,
|
||||
);
|
||||
const page: CompositionSeekPage = { evaluate, waitForFunction };
|
||||
return { page, evaluate, waitForFunction };
|
||||
}
|
||||
|
||||
function runBrowserSeek(evaluate: ReturnType<typeof fakeSeekPage>["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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<unknown>;
|
||||
}
|
||||
|
||||
export interface CompositionSeekPage extends CompositionEvaluationPage {
|
||||
waitForFunction?(pageFunction: () => boolean, options: { timeout: number }): Promise<unknown>;
|
||||
}
|
||||
|
||||
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<void>;
|
||||
}
|
||||
|
||||
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<unknown>;
|
||||
}): Promise<void> {
|
||||
await page.evaluateOnNewDocument("self.__name = self.__name || ((fn) => fn);");
|
||||
}
|
||||
|
||||
export async function openSettledCompositionPage(
|
||||
html: string,
|
||||
url: string,
|
||||
options: OpenSettledCompositionPageOptions,
|
||||
): Promise<SettledCompositionPage> {
|
||||
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, "evaluate">,
|
||||
page: CompositionSeekPage,
|
||||
timeSeconds: number,
|
||||
options: SeekCompositionTimelineOptions = {},
|
||||
): Promise<void> {
|
||||
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<void>((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<CompositionSeekPage, "waitForFunction">,
|
||||
timeoutMs = PREFERRED_SEEK_TARGET_WAIT_MS,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<void>((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<CropRegion | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<CropRegion | null> {
|
||||
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<void>;
|
||||
screenshot(options: { clip: CropRegion; type: "png" }): Promise<Uint8Array>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Buffer> {
|
||||
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(
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
|
||||
+25
-3
@@ -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?.();
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<CheckReport>;
|
||||
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<string, unknown>): 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<string, string> {
|
||||
const fields = new Map<string, string>();
|
||||
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<string, string> {
|
||||
const fields = new Map<string, string>();
|
||||
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<string, string>): {
|
||||
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<string, string>, 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();
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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("<path>", () => 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<CommandMeta>` (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<T extends ArgsDef = ArgsDef>(command: CommandDef<T>): 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<T extends ArgsDef = ArgsDef>(
|
||||
command: CommandDef<T>,
|
||||
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<T extends ArgsDef = ArgsDef>(
|
||||
command: CommandDef<T>,
|
||||
rawArgs: string[] = ["--json"],
|
||||
): Promise<unknown[] | undefined> {
|
||||
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<T extends ArgsDef = ArgsDef>(
|
||||
command: CommandDef<T>,
|
||||
rawArgs: string[] = ["--json"],
|
||||
) {
|
||||
const jsonCall = await runAndFindJsonLogCall(command, rawArgs);
|
||||
expect(jsonCall).toBeDefined();
|
||||
const parsed = JSON.parse(String(jsonCall?.[0]));
|
||||
return { jsonCall, parsed };
|
||||
}
|
||||
@@ -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> = {}): 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> = {}): 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)
|
||||
|
||||
@@ -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<AssetImportResult> {
|
||||
/**
|
||||
* 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<AssetImportResult> {
|
||||
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<AssetImportResult> {
|
||||
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<AssetImportResult[]> {
|
||||
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,
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -41,6 +41,20 @@ const SVG = new TextEncoder().encode("<svg/>");
|
||||
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([]),
|
||||
|
||||
@@ -15,6 +15,7 @@ afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
||||
function client(overrides: Partial<FigmaClient>): 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)),
|
||||
|
||||
@@ -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})`);
|
||||
|
||||
@@ -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<string, string>;
|
||||
};
|
||||
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<string, string>;
|
||||
};
|
||||
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<string, string>;
|
||||
};
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -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<string, string> {
|
||||
return {
|
||||
dev: hyperframesScript("preview"),
|
||||
check:
|
||||
`${hyperframesScript("lint")} && ${hyperframesScript("validate")} && ` +
|
||||
`${hyperframesScript("inspect")}`,
|
||||
check: hyperframesScript("check"),
|
||||
render: hyperframesScript("render"),
|
||||
publish: hyperframesScript("publish"),
|
||||
};
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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("|");
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -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 = `
|
||||
<div id="root" data-composition-id="main" data-width="640" data-height="360">
|
||||
<div id="first"></div>
|
||||
<div id="second"></div>
|
||||
<div id="third"></div>
|
||||
<div id="late">Late visible copy</div>
|
||||
</div>
|
||||
`;
|
||||
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 = `
|
||||
<div id="root" data-composition-id="main" data-width="640" data-height="360">
|
||||
<div id="copy">Visible copy</div>
|
||||
</div>
|
||||
`;
|
||||
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 = `
|
||||
<div id="root" data-composition-id="main" data-width="640" data-height="360">
|
||||
<section data-composition-file="scenes/hero.html">
|
||||
<div id="copy" data-layout-name="copy">Own copy <span id="nested">Nested</span></div>
|
||||
<img id="image" src="data:image/png;base64,AA==" />
|
||||
<svg id="vector"></svg>
|
||||
</section>
|
||||
<div class="caption-layer"><p id="caption">Authored captions</p></div>
|
||||
</div>
|
||||
`;
|
||||
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 = `
|
||||
<canvas id="boundary" data-composition-id="background" data-width="640" data-height="360"></canvas>
|
||||
<div id="root" data-composition-id="main" data-width="640" data-height="360">
|
||||
<p id="portal-copy">Portal copy</p>
|
||||
</div>
|
||||
<img id="portal-image" src="data:image/png;base64,AA==" />
|
||||
`;
|
||||
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 = `
|
||||
<div id="root" data-composition-id="main" data-width="640" data-height="360">
|
||||
<img class="tile" src="data:image/png;base64,AA==" />
|
||||
<img class="tile" src="data:image/png;base64,AA==" />
|
||||
</div>
|
||||
`;
|
||||
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 = `
|
||||
<div id="root" data-composition-id="main" data-width="640" data-height="360">
|
||||
<p id="clipped-copy">Visible clipped copy</p>
|
||||
</div>
|
||||
`;
|
||||
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 = `
|
||||
<div id="root" data-composition-id="main" data-width="640" data-height="360">
|
||||
<div id="faint-parent"><p id="hidden-copy">Hidden copy</p></div>
|
||||
<div id="soft-parent"><p id="visible-copy">Visible copy</p></div>
|
||||
<div id="stacked-parent"><p id="stacked-copy">Stacked opacity copy</p></div>
|
||||
</div>
|
||||
`;
|
||||
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 = `
|
||||
<div id="root" data-composition-id="main" data-width="640" data-height="360">
|
||||
<div data-layout-ignore>
|
||||
<div id="rail-label">SHAPE</div>
|
||||
</div>
|
||||
<div id="headline">Readable copy</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<div id="root" data-composition-id="main" data-width="640" data-height="360">
|
||||
<div id="exited">You</div>
|
||||
<div id="headline">Readable copy</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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<typeof runAudit> {
|
||||
const textRect = { left: 200, top: 500, width: 600, height: 80 };
|
||||
document.body.innerHTML = `
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div id="headline">${options.text}</div>
|
||||
<div id="overlay"></div>
|
||||
</div>
|
||||
`;
|
||||
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<Record<string, string>>;
|
||||
@@ -601,28 +974,31 @@ async function runContrastAudit(): Promise<Array<Record<string, unknown>>> {
|
||||
return w.__contrastAuditFinish("stub", 0, candidates);
|
||||
}
|
||||
|
||||
function runAudit(): Array<{
|
||||
interface AuditIssue {
|
||||
code: string;
|
||||
selector: string;
|
||||
containerSelector?: string;
|
||||
overflow?: Record<string, number>;
|
||||
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<string, number>;
|
||||
message?: string;
|
||||
}>;
|
||||
__hyperframesLayoutAudit: (options: { time: number; tolerance: number }) => AuditIssue[];
|
||||
}
|
||||
).__hyperframesLayoutAudit;
|
||||
return audit({ time: 1, tolerance: 2 });
|
||||
}
|
||||
|
||||
function installGeometry(rects: Record<string, DOMRect>): void {
|
||||
function installGeometry(
|
||||
rects: Record<string, DOMRect>,
|
||||
styleOverrides: Record<string, Partial<CSSStyleDeclaration>> = {},
|
||||
): 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<string, DOMRect>): 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<string, DOMRect>): void {
|
||||
});
|
||||
}
|
||||
|
||||
interface GeometryCandidateResult {
|
||||
kind: "text" | "media";
|
||||
tag: string;
|
||||
text: string;
|
||||
selector: string;
|
||||
sourceFile: string;
|
||||
rect: Record<string, number>;
|
||||
elementRect: Record<string, number>;
|
||||
overflow?: Record<string, number>;
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<number> {
|
||||
// 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<void> {
|
||||
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<void>((resolve) => setTimeout(resolve, ms)),
|
||||
]);
|
||||
}, timeoutMs)
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
async function seekTo(page: import("puppeteer-core").Page, time: number): Promise<void> {
|
||||
await page.evaluate((t: number) => {
|
||||
const win = window as unknown as {
|
||||
__hf?: { seek?: (time: number) => void };
|
||||
__player?: { seek?: (time: number) => void };
|
||||
__timelines?: Record<string, { pause?: () => 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<void>((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<LayoutAuditResult> {
|
||||
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<MotionFrame[]> {
|
||||
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,
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <names>", () => {
|
||||
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");
|
||||
|
||||
@@ -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}`));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string[]> {
|
||||
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) {
|
||||
|
||||
@@ -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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
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<string, unknown>).__timelines as
|
||||
| Record<string, { seek: (t: number) => 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<unknown>;
|
||||
}
|
||||
|
||||
export async function waitForPreferredSeekTarget(
|
||||
page: WaitForFunctionPage,
|
||||
timeoutMs = PREFERRED_SEEK_TARGET_WAIT_MS,
|
||||
): Promise<void> {
|
||||
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<void>((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<Co
|
||||
const results: ContrastEntry[] = [];
|
||||
for (let i = 0; i < CONTRAST_SAMPLES; i++) {
|
||||
const t = +(((i + 0.5) / CONTRAST_SAMPLES) * duration).toFixed(3);
|
||||
await seekTo(page, t);
|
||||
await seekCompositionTimeline(page, t, {
|
||||
fallbackToBridgeAndTimelines: true,
|
||||
waitForPreferredSeekTargetMs: PREFERRED_SEEK_TARGET_WAIT_MS,
|
||||
animationFrameSettle: "none",
|
||||
settleMs: SEEK_SETTLE_MS,
|
||||
});
|
||||
|
||||
try {
|
||||
// __contrastAuditPrepare() hides each candidate text element's own
|
||||
@@ -459,15 +412,17 @@ async function validateInBrowser(
|
||||
const browser = await ensureBrowser();
|
||||
const puppeteer = await import("puppeteer-core");
|
||||
const { buildChromeArgs, analyzeClipMediaFit } = await import("@hyperframes/engine");
|
||||
const browserGpuMode =
|
||||
process.env.PRODUCER_BROWSER_GPU_MODE === "software" ? "software" : "hardware";
|
||||
const chromeBrowser = await puppeteer.default.launch({
|
||||
headless: true,
|
||||
executablePath: browser.executablePath,
|
||||
args: buildChromeArgs({ ...viewport, captureMode: "screenshot" }, { browserGpuMode }),
|
||||
args: buildChromeArgs(
|
||||
{ ...viewport, captureMode: "screenshot" },
|
||||
{ browserGpuMode: resolveCliChromeGpuMode() },
|
||||
),
|
||||
});
|
||||
|
||||
const page = await chromeBrowser.newPage();
|
||||
await installPageFunctionGuard(page);
|
||||
await page.setViewport(viewport);
|
||||
|
||||
page.on("console", (msg) => {
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 () => "<html></html>"),
|
||||
}));
|
||||
|
||||
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<typeof import("../capture/captureCompositionFrame.js")>()),
|
||||
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<typeof import("../commands/validate.js")>()),
|
||||
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<typeof fakePage>): 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 = `
|
||||
<div data-composition-id="main" data-duration="10" data-width="640" data-height="360">${inner}</div>
|
||||
`;
|
||||
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(`
|
||||
<section data-composition-file="scenes/hero.html">
|
||||
<img id="hero-image" data-layout-name="hero" src="data:image/png;base64,AA==" />
|
||||
</section>
|
||||
`);
|
||||
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(`
|
||||
<div id="headline">Readable copy</div>
|
||||
`);
|
||||
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<Record<string, unknown>> = [];
|
||||
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<unknown>) & { 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<Record<string, unknown>>));
|
||||
}
|
||||
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<string, unknown>;
|
||||
// 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);
|
||||
}),
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<SampleGrid> {
|
||||
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<MotionPlan> {
|
||||
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<string>;
|
||||
frame: Set<string>;
|
||||
}
|
||||
|
||||
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<string>,
|
||||
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<AnchoredLayoutIssue[]> {
|
||||
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<GridSamples> {
|
||||
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<CheckBrowserResult> {
|
||||
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<CheckReport> {
|
||||
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<string[]> {
|
||||
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<CheckReport> {
|
||||
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-<code>.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<string, Set<number>>();
|
||||
for (const entry of entries) {
|
||||
if (entry.wcagAA) continue;
|
||||
const key = `${entry.selector}|${entry.text}`;
|
||||
const times = failureSamples.get(key) ?? new Set<number>();
|
||||
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<T extends CheckFinding>(findings: T[]): CheckSection<T> {
|
||||
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<string, string> {
|
||||
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<CheckBrowserResult> {
|
||||
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<string> {
|
||||
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<string[]> {
|
||||
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,
|
||||
};
|
||||
@@ -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<string, string>;
|
||||
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<void>;
|
||||
getDuration(): Promise<number>;
|
||||
getTransitionBoundaries(): Promise<number[]>;
|
||||
getCanvas(): Promise<Canvas>;
|
||||
findAmbiguousSelectors(selectors: string[]): Promise<AnchoredLayoutIssue[]>;
|
||||
seek(time: number): Promise<void>;
|
||||
collectLayout(time: number, tolerance: number): Promise<AnchoredLayoutIssue[]>;
|
||||
/** 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<string>;
|
||||
collectGeometryCandidates(
|
||||
time: number,
|
||||
request: GeometryCandidateRequest,
|
||||
): Promise<CheckGeometryCandidate[]>;
|
||||
collectMotionFrame(
|
||||
time: number,
|
||||
selectors: string[],
|
||||
livenessScopes: string[],
|
||||
): Promise<MotionFrame>;
|
||||
anchorMotionIssues(issues: LayoutIssue[]): Promise<AnchoredLayoutIssue[]>;
|
||||
collectContrast(time: number, annotations?: CheckAnnotationBox[]): Promise<ContrastCapture>;
|
||||
}
|
||||
|
||||
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<CheckBrowserResult>;
|
||||
|
||||
export interface CheckSection<T extends CheckFinding = CheckFinding> {
|
||||
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<AnchoredLayoutIssue> & {
|
||||
duration: number;
|
||||
samples: number[];
|
||||
transitionSamples: number[];
|
||||
transitionSamplesDropped: number;
|
||||
tolerance: number;
|
||||
totalIssueCount: number;
|
||||
truncated: boolean;
|
||||
};
|
||||
motion: CheckSection & { enabled: boolean; specPath?: string; samples: number };
|
||||
contrast: CheckSection<CheckContrastFinding> & {
|
||||
enabled: boolean;
|
||||
samples: number[];
|
||||
checked: number;
|
||||
passed: number;
|
||||
};
|
||||
snapshots: { enabled: boolean; files: string[]; times: number[]; findingFiles: string[] };
|
||||
}
|
||||
|
||||
export interface CheckDependencies {
|
||||
lintProject(projectDir: string): Promise<ProjectLintResult>;
|
||||
resolveMotionSpec(projectDir: string): MotionSpecResolution;
|
||||
runBrowserCheck(
|
||||
project: ProjectDir,
|
||||
options: CheckOptions,
|
||||
motion: MotionSpecResolution,
|
||||
): Promise<CheckBrowserResult>;
|
||||
writeSnapshot(
|
||||
projectDir: string,
|
||||
index: number,
|
||||
time: number,
|
||||
pngBase64: string,
|
||||
): Promise<string>;
|
||||
captureFindingCrops(
|
||||
project: ProjectDir,
|
||||
options: CheckOptions,
|
||||
requests: CheckFindingCropRequest[],
|
||||
): Promise<string[]>;
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<LayoutIssueCode> = 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);
|
||||
|
||||
@@ -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"), "<html><body>Not a composition</body></html>");
|
||||
|
||||
@@ -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<string, { source: string }>): 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 <cwd>/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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 <name> -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<SkillsManifest> {
|
||||
* - 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<SkillsManifest> {
|
||||
// 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<SkillsCheckResult> {
|
||||
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) : {};
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
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<string | null> {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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<SkillsUpdateMeta> {
|
||||
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<SkillsUpdateMeta> {
|
||||
// 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<SkillsUpdat
|
||||
}
|
||||
}
|
||||
|
||||
/** The stale-skills nudge text, or null when nothing is outdated or missing. */
|
||||
/** The stale-skills nudge text, or null when nothing is outdated, missing, or removed. */
|
||||
function skillsNoticeText(meta: SkillsUpdateMeta): string | null {
|
||||
const total = meta.outdated + meta.missing;
|
||||
const total = meta.outdated + meta.missing + meta.removed;
|
||||
if (total < 1) return null;
|
||||
const noun = total === 1 ? "skill" : "skills";
|
||||
return `\n ${total} HyperFrames ${noun} out of date or missing.\n Run: npx hyperframes skills update\n\n`;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { isSafeVersion } from "./updateCheck.js";
|
||||
import { isSafeVersion, printDeprecationNotice, withMeta } from "./updateCheck.js";
|
||||
|
||||
describe("isSafeVersion", () => {
|
||||
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");
|
||||
|
||||
@@ -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<T extends object>(data: T): T & { _meta: UpdateMeta } {
|
||||
return { ...data, _meta: getUpdateMeta() };
|
||||
export function withMeta<T extends object>(
|
||||
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`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
@@ -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 = '<video id="v1" src="a.mp4" data-start="2" data-duration="5">';
|
||||
|
||||
Binary file not shown.
@@ -106,14 +106,18 @@ describe("variables", () => {
|
||||
});
|
||||
|
||||
describe("error mapping", () => {
|
||||
it("maps 429 to RATE_LIMITED and 401 to BAD_TOKEN", async () => {
|
||||
it("maps 429 to RATE_LIMITED (after retries) and 401 to BAD_TOKEN", async () => {
|
||||
const stub = fetchStub(() => jsonResponse(429, {}));
|
||||
const c429 = createFigmaClient({
|
||||
token: "t",
|
||||
fetch: fetchStub(() => jsonResponse(429, {})).fetch,
|
||||
fetch: stub.fetch,
|
||||
sleep: () => Promise.resolve(),
|
||||
});
|
||||
await expect(c429.styles("F")).rejects.toThrowError(
|
||||
expect.objectContaining({ code: "RATE_LIMITED" }),
|
||||
);
|
||||
// 1 initial + 3 retries = 4 attempts
|
||||
expect(stub.calls).toHaveLength(4);
|
||||
const c401 = createFigmaClient({
|
||||
token: "t",
|
||||
fetch: fetchStub(() => jsonResponse(401, {})).fetch,
|
||||
@@ -123,6 +127,179 @@ describe("error mapping", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("retries 429 and succeeds when the limit clears", async () => {
|
||||
let n = 0;
|
||||
const waits: number[] = [];
|
||||
const client = createFigmaClient({
|
||||
token: "t",
|
||||
fetch: (() => {
|
||||
n += 1;
|
||||
return Promise.resolve(
|
||||
n < 3
|
||||
? jsonResponse(429, {})
|
||||
: jsonResponse(200, {
|
||||
meta: { styles: [{ key: "k", name: "P", style_type: "FILL" }] },
|
||||
}),
|
||||
);
|
||||
}) as FigmaFetch,
|
||||
sleep: (ms) => {
|
||||
waits.push(ms);
|
||||
return Promise.resolve();
|
||||
},
|
||||
});
|
||||
const styles = await client.styles("F");
|
||||
expect(styles[0]?.key).toBe("k");
|
||||
expect(n).toBe(3); // two 429s then success
|
||||
expect(waits).toEqual([1000, 2000]); // exponential backoff
|
||||
});
|
||||
|
||||
it("caps an oversized Retry-After at 60s so the CLI can't block for an hour", async () => {
|
||||
let n = 0;
|
||||
const waits: number[] = [];
|
||||
const client = createFigmaClient({
|
||||
token: "t",
|
||||
fetch: (() => {
|
||||
n += 1;
|
||||
return Promise.resolve(
|
||||
n === 1
|
||||
? new Response("{}", { status: 429, headers: { "retry-after": "3600" } })
|
||||
: jsonResponse(200, { meta: { styles: [] } }),
|
||||
);
|
||||
}) as FigmaFetch,
|
||||
sleep: (ms) => {
|
||||
waits.push(ms);
|
||||
return Promise.resolve();
|
||||
},
|
||||
});
|
||||
await client.styles("F");
|
||||
expect(waits).toEqual([60_000]); // 3600s clamped, not 3_600_000
|
||||
});
|
||||
|
||||
it("retries 429 on non-styles endpoints too (retry lives in the shared get)", async () => {
|
||||
let n = 0;
|
||||
const client = createFigmaClient({
|
||||
token: "t",
|
||||
fetch: (() => {
|
||||
n += 1;
|
||||
return Promise.resolve(
|
||||
n < 2
|
||||
? jsonResponse(429, {})
|
||||
: jsonResponse(200, { images: { "1:2": "https://cdn/a.png" } }),
|
||||
);
|
||||
}) as FigmaFetch,
|
||||
sleep: () => Promise.resolve(),
|
||||
});
|
||||
const out = await client.renderNodes("F", ["1:2"], { format: "png" });
|
||||
expect(out[0]?.url).toBe("https://cdn/a.png");
|
||||
expect(n).toBe(2); // one 429 then success
|
||||
});
|
||||
|
||||
it("honors Retry-After (seconds) over the backoff default", async () => {
|
||||
let n = 0;
|
||||
const waits: number[] = [];
|
||||
const client = createFigmaClient({
|
||||
token: "t",
|
||||
fetch: (() => {
|
||||
n += 1;
|
||||
return Promise.resolve(
|
||||
n === 1
|
||||
? new Response("{}", { status: 429, headers: { "retry-after": "5" } })
|
||||
: jsonResponse(200, { meta: { styles: [] } }),
|
||||
);
|
||||
}) as FigmaFetch,
|
||||
sleep: (ms) => {
|
||||
waits.push(ms);
|
||||
return Promise.resolve();
|
||||
},
|
||||
});
|
||||
await client.styles("F");
|
||||
expect(waits).toEqual([5000]);
|
||||
});
|
||||
|
||||
it("names the endpoint scope in the styles 403 when the body is silent", async () => {
|
||||
const client = createFigmaClient({
|
||||
token: "t",
|
||||
fetch: fetchStub(() => jsonResponse(403, { message: "no" })).fetch,
|
||||
});
|
||||
await expect(client.styles("F")).rejects.toThrowError(
|
||||
expect.objectContaining({
|
||||
code: "FORBIDDEN",
|
||||
message: expect.stringContaining("library_content:read"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces figma's own scope diagnosis verbatim from the 403 body (err field)", async () => {
|
||||
const client = createFigmaClient({
|
||||
token: "t",
|
||||
fetch: fetchStub(() =>
|
||||
jsonResponse(403, {
|
||||
err: "Invalid scope(s): file_content:read, file_metadata:read. This endpoint requires the library_content:read scope",
|
||||
}),
|
||||
).fetch,
|
||||
});
|
||||
await expect(client.styles("F")).rejects.toThrowError(
|
||||
expect.objectContaining({
|
||||
code: "FORBIDDEN",
|
||||
message: expect.stringContaining("requires the library_content:read scope"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reclassifies a 403 'Invalid token' body as BAD_TOKEN, not a scope problem", async () => {
|
||||
// figma returns 403 (not 401) for bad PATs on file endpoints — verified live
|
||||
const client = createFigmaClient({
|
||||
token: "t",
|
||||
fetch: fetchStub(() => jsonResponse(403, { err: "Invalid token" })).fetch,
|
||||
});
|
||||
const err = await client.styles("F").catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(FigmaClientError);
|
||||
if (err instanceof FigmaClientError) {
|
||||
expect(err.code).toBe("BAD_TOKEN");
|
||||
expect(err.message).toContain("Re-mint");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps REQUIRES_ENTERPRISE for a scopeless variables 403", async () => {
|
||||
const client = createFigmaClient({
|
||||
token: "t",
|
||||
fetch: fetchStub(() => jsonResponse(403, { message: "no" })).fetch,
|
||||
});
|
||||
await expect(client.variables("F")).rejects.toThrowError(
|
||||
expect.objectContaining({ code: "REQUIRES_ENTERPRISE" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderNodes (batch)", () => {
|
||||
it("fetches many nodes in ONE /v1/images call and maps each url", async () => {
|
||||
const stub = fetchStub(() =>
|
||||
jsonResponse(200, {
|
||||
images: { "1:2": "https://cdn/a.png", "3:4": "https://cdn/b.png" },
|
||||
}),
|
||||
);
|
||||
const client = createFigmaClient({ token: "t", fetch: stub.fetch });
|
||||
const out = await client.renderNodes("F", ["1:2", "3:4"], { format: "png" });
|
||||
expect(stub.calls).toHaveLength(1);
|
||||
expect(stub.calls[0]).toContain("ids=1%3A2%2C3%3A4"); // "1:2,3:4" url-encoded
|
||||
expect(out).toEqual([
|
||||
{ nodeId: "1:2", url: "https://cdn/a.png", ext: "png" },
|
||||
{ nodeId: "3:4", url: "https://cdn/b.png", ext: "png" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns url:null for a node figma couldn't render, without failing the batch", async () => {
|
||||
const client = createFigmaClient({
|
||||
token: "t",
|
||||
fetch: fetchStub(() =>
|
||||
jsonResponse(200, { images: { "1:2": "https://cdn/a.png", "3:4": null } }),
|
||||
).fetch,
|
||||
});
|
||||
const out = await client.renderNodes("F", ["1:2", "3:4"], { format: "svg" });
|
||||
expect(out[0]?.url).toBe("https://cdn/a.png");
|
||||
expect(out[1]?.url).toBeNull();
|
||||
});
|
||||
|
||||
it("wraps other failures as HTTP_ERROR with status", async () => {
|
||||
const client = createFigmaClient({
|
||||
token: "t",
|
||||
|
||||
@@ -76,8 +76,24 @@ export interface FigmaFileVersion {
|
||||
lastModified: string;
|
||||
}
|
||||
|
||||
/** One batch render result — url is null when figma couldn't render that
|
||||
* node (a bad node id in the batch shouldn't fail the whole call). */
|
||||
export interface BatchRenderedNode {
|
||||
nodeId: string;
|
||||
url: string | null;
|
||||
ext: FigmaAssetFormat;
|
||||
}
|
||||
|
||||
export interface FigmaClient {
|
||||
renderNode(ref: FigmaRef, opts: RenderNodeOptions): Promise<RenderedNode>;
|
||||
/** Batch render many nodes of ONE file in a single /v1/images call — the
|
||||
* documented rate-limit workaround (comma-separated ids). Per-node
|
||||
* failures come back as url:null rather than throwing the batch. */
|
||||
renderNodes(
|
||||
fileKey: string,
|
||||
nodeIds: string[],
|
||||
opts: RenderNodeOptions,
|
||||
): Promise<BatchRenderedNode[]>;
|
||||
imageFills(fileKey: string): Promise<Map<string, string>>;
|
||||
variables(fileKey: string): Promise<FigmaVariablesResult>;
|
||||
styles(fileKey: string): Promise<FigmaStyleMeta[]>;
|
||||
@@ -89,6 +105,63 @@ export interface FigmaClientOptions {
|
||||
token: string;
|
||||
fetch?: FigmaFetch;
|
||||
baseUrl?: string;
|
||||
/** Injectable delay for 429 backoff — tests pass a no-op so retries don't
|
||||
* actually wait. Defaults to a real timer. */
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
/** Max 429 retries before giving up. Default 3. */
|
||||
maxRetries?: number;
|
||||
}
|
||||
|
||||
/** Read scope each endpoint needs, named exactly as figma's PAT settings UI
|
||||
* lists them — so a 403 tells the user which checkbox to tick, not just
|
||||
* "some read scope". The styles endpoint's `library_content:read` is the one
|
||||
* the setup docs used to omit (it 403s even with file content + metadata). */
|
||||
const SCOPE_HINTS = {
|
||||
fileContent: "File content: Read-only",
|
||||
fileMetadata: "File metadata: Read-only",
|
||||
libraryContent: "Library content: Read-only (library_content:read)",
|
||||
} as const;
|
||||
|
||||
/** Longest we'll auto-wait on a single Retry-After before giving up — past a
|
||||
* minute the user is better off cancelling and reducing batch size (the
|
||||
* RATE_LIMITED message says so) than watching the CLI block silently. */
|
||||
const MAX_RETRY_WAIT_MS = 60_000;
|
||||
|
||||
/** Parse a Retry-After header (figma sends integer seconds; the HTTP spec
|
||||
* also allows a date) into ms, capped at MAX_RETRY_WAIT_MS, or null when
|
||||
* absent/unparseable. The cap keeps a spec-legal `Retry-After: 3600` (tier
|
||||
* quota exhaustion) from silently blocking the CLI for an hour. */
|
||||
function retryAfterMs(res: Response): number | null {
|
||||
const raw = res.headers.get("retry-after");
|
||||
if (raw === null) return null;
|
||||
const secs = Number(raw);
|
||||
if (Number.isFinite(secs)) return Math.min(MAX_RETRY_WAIT_MS, Math.max(0, secs * 1000));
|
||||
const date = Date.parse(raw);
|
||||
if (Number.isNaN(date)) return null;
|
||||
return Math.min(MAX_RETRY_WAIT_MS, Math.max(0, date - Date.now()));
|
||||
}
|
||||
|
||||
/** Figma's error bodies are precise — "Invalid token", or "Invalid scope(s):
|
||||
* … requires the X scope" — and worth surfacing verbatim instead of a
|
||||
* generic guess. The message lives under `err` on most endpoints but
|
||||
* `message` on /variables; read both. Returns null when unparseable. */
|
||||
async function readFigmaErrorMessage(res: Response): Promise<string | null> {
|
||||
let text: string;
|
||||
try {
|
||||
text = await res.text();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const body: unknown = JSON.parse(text);
|
||||
if (isRecord(body)) {
|
||||
if (typeof body.err === "string") return body.err;
|
||||
if (typeof body.message === "string") return body.message;
|
||||
}
|
||||
} catch {
|
||||
// non-JSON body — fall through
|
||||
}
|
||||
return text.trim() === "" ? null : text.trim();
|
||||
}
|
||||
|
||||
function requireNodeId(ref: FigmaRef): string {
|
||||
@@ -125,6 +198,7 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
|
||||
" 1. figma.com/settings → Security → Personal access tokens → Generate new token",
|
||||
" 2. Scopes (read-only is all this integration ever needs — it never writes to figma):",
|
||||
" File content: Read-only · File metadata: Read-only",
|
||||
" Library content: Read-only (needed for the `tokens` published-styles fallback)",
|
||||
" Variables: Read-only (optional — brand variables, requires figma Enterprise;",
|
||||
" without it `tokens` falls back to published styles)",
|
||||
' 3. export FIGMA_TOKEN="figd_…" — add it to your shell profile or the project .env',
|
||||
@@ -135,62 +209,125 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
|
||||
}
|
||||
const doFetch: FigmaFetch = options.fetch ?? ((url, init) => fetch(url, init));
|
||||
const base = options.baseUrl ?? "https://api.figma.com";
|
||||
const sleep = options.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms)));
|
||||
const maxRetries = options.maxRetries ?? 3;
|
||||
|
||||
async function get(path: string, enterpriseGated = false): Promise<unknown> {
|
||||
const res = await doFetch(`${base}${path}`, {
|
||||
headers: { "X-Figma-Token": token },
|
||||
});
|
||||
interface GetOptions {
|
||||
/** 403 → REQUIRES_ENTERPRISE (variables) rather than FORBIDDEN. */
|
||||
enterpriseGated?: boolean;
|
||||
/** scope named in a FORBIDDEN message so the user knows which to add. */
|
||||
scopeHint?: string;
|
||||
}
|
||||
|
||||
/** Map a 403 to the right typed error using figma's own response body:
|
||||
* "Invalid token" is a bad PAT (figma returns 403, NOT 401, for these on
|
||||
* file endpoints), "Invalid scope(s) … requires X" is a missing scope
|
||||
* surfaced verbatim. Falls back to the endpoint's scopeHint when the body
|
||||
* is silent. */
|
||||
function forbiddenError(body: string | null, opts: GetOptions): FigmaClientError {
|
||||
// Every branch RETURNS the error (the caller throws once) — no mixed
|
||||
// throw/return, so a future caller that wraps the result gets consistent
|
||||
// behavior across all three cases.
|
||||
if (body && /invalid token/i.test(body))
|
||||
return new FigmaClientError(
|
||||
"BAD_TOKEN",
|
||||
"figma rejected the token (403 Invalid token) — it is invalid, expired, or revoked. Re-mint at figma.com/settings → Security, then update FIGMA_TOKEN.",
|
||||
403,
|
||||
);
|
||||
if (opts.enterpriseGated)
|
||||
return new FigmaClientError(
|
||||
"REQUIRES_ENTERPRISE",
|
||||
"figma variables require an Enterprise plan (403) — fall back to styles",
|
||||
403,
|
||||
);
|
||||
if (body && /scope/i.test(body))
|
||||
return new FigmaClientError(
|
||||
"FORBIDDEN",
|
||||
`figma denied access (403): ${body} — add the named scope at figma.com/settings → Security → Personal access tokens.`,
|
||||
403,
|
||||
);
|
||||
const scopeLine = opts.scopeHint
|
||||
? `This endpoint needs the "${opts.scopeHint}" scope — add it at figma.com/settings → Security → Personal access tokens.`
|
||||
: "The token is missing a read scope, or your account can't view this file. Check File content: Read-only + File metadata: Read-only at figma.com/settings → Security.";
|
||||
return new FigmaClientError(
|
||||
"FORBIDDEN",
|
||||
`figma denied access (403). ${scopeLine} Also confirm the file is visible to your account.`,
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
/** Throw the typed error for a non-ok response (no-op when res.ok). */
|
||||
async function throwForStatus(res: Response, path: string, opts: GetOptions): Promise<void> {
|
||||
if (res.ok) return;
|
||||
if (res.status === 401)
|
||||
throw new FigmaClientError(
|
||||
"BAD_TOKEN",
|
||||
"figma rejected the token (401) — it is expired or revoked. Re-mint at figma.com/settings → Security, then update FIGMA_TOKEN.",
|
||||
401,
|
||||
);
|
||||
if (res.status === 403 && enterpriseGated)
|
||||
throw new FigmaClientError(
|
||||
"REQUIRES_ENTERPRISE",
|
||||
"figma variables require an Enterprise plan (403) — fall back to styles",
|
||||
403,
|
||||
);
|
||||
if (res.status === 403)
|
||||
throw new FigmaClientError(
|
||||
"FORBIDDEN",
|
||||
"figma denied access (403) — the token is missing a read scope, or your account can't view this file. Check the token has File content: Read-only + File metadata: Read-only (figma.com/settings → Security) and that the file is visible to your account.",
|
||||
403,
|
||||
);
|
||||
if (res.status === 403) throw forbiddenError(await readFigmaErrorMessage(res), opts);
|
||||
if (res.status === 429)
|
||||
throw new FigmaClientError(
|
||||
"RATE_LIMITED",
|
||||
"figma rate limit hit (429) — back off and retry",
|
||||
`figma rate limit hit (429) and still limited after ${maxRetries} retries — wait a minute and re-run, or import fewer nodes per call.`,
|
||||
429,
|
||||
);
|
||||
if (!res.ok)
|
||||
throw new FigmaClientError(
|
||||
"HTTP_ERROR",
|
||||
`figma request failed: HTTP ${res.status} ${path}`,
|
||||
res.status,
|
||||
);
|
||||
throw new FigmaClientError(
|
||||
"HTTP_ERROR",
|
||||
`figma request failed: HTTP ${res.status} ${path}`,
|
||||
res.status,
|
||||
);
|
||||
}
|
||||
|
||||
async function get(path: string, opts: GetOptions = {}): Promise<unknown> {
|
||||
// Retry 429 with backoff before surfacing RATE_LIMITED — figma's limit is
|
||||
// per-minute, so a couple of imports in quick succession hit it and a
|
||||
// short wait clears it. Honor Retry-After when present, else exponential.
|
||||
let res: Response;
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
res = await doFetch(`${base}${path}`, { headers: { "X-Figma-Token": token } });
|
||||
if (res.status !== 429 || attempt >= maxRetries) break;
|
||||
const wait = retryAfterMs(res) ?? 1000 * 2 ** attempt;
|
||||
await sleep(wait);
|
||||
}
|
||||
await throwForStatus(res, path, opts);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
return {
|
||||
async renderNode(ref, opts) {
|
||||
const nodeId = requireNodeId(ref);
|
||||
const params = new URLSearchParams({ ids: nodeId, format: opts.format });
|
||||
if (opts.scale !== undefined) params.set("scale", String(opts.scale));
|
||||
const body = await get(`/v1/images/${ref.fileKey}?${params}`);
|
||||
const images = isRecord(body) && isRecord(body.images) ? body.images : {};
|
||||
const url = images[nodeId];
|
||||
if (typeof url !== "string" || url === "")
|
||||
const [result] = await this.renderNodes(ref.fileKey, [nodeId], opts);
|
||||
if (!result || result.url === null)
|
||||
throw new FigmaClientError(
|
||||
"RENDER_FAILED",
|
||||
`figma could not render node ${nodeId} as ${opts.format}`,
|
||||
);
|
||||
return { url, ext: opts.format };
|
||||
return { url: result.url, ext: opts.format };
|
||||
},
|
||||
|
||||
async renderNodes(fileKey, nodeIds, opts) {
|
||||
if (nodeIds.length === 0) return [];
|
||||
// /v1/images accepts comma-separated ids — one call for the whole batch,
|
||||
// which is figma's own answer to the per-minute rate limit.
|
||||
const params = new URLSearchParams({ ids: nodeIds.join(","), format: opts.format });
|
||||
if (opts.scale !== undefined) params.set("scale", String(opts.scale));
|
||||
const body = await get(`/v1/images/${fileKey}?${params}`, {
|
||||
scopeHint: SCOPE_HINTS.fileContent,
|
||||
});
|
||||
const images = isRecord(body) && isRecord(body.images) ? body.images : {};
|
||||
return nodeIds.map((nodeId) => {
|
||||
const url = images[nodeId];
|
||||
return {
|
||||
nodeId,
|
||||
url: typeof url === "string" && url !== "" ? url : null,
|
||||
ext: opts.format,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
async imageFills(fileKey) {
|
||||
const body = await get(`/v1/files/${fileKey}/images`);
|
||||
const body = await get(`/v1/files/${fileKey}/images`, { scopeHint: SCOPE_HINTS.fileContent });
|
||||
const meta = isRecord(body) && isRecord(body.meta) ? body.meta : {};
|
||||
const images = isRecord(meta.images) ? meta.images : {};
|
||||
const out = new Map<string, string>();
|
||||
@@ -201,7 +338,7 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
|
||||
},
|
||||
|
||||
async variables(fileKey) {
|
||||
const body = await get(`/v1/files/${fileKey}/variables/local`, true);
|
||||
const body = await get(`/v1/files/${fileKey}/variables/local`, { enterpriseGated: true });
|
||||
const meta = isRecord(body) && isRecord(body.meta) ? body.meta : {};
|
||||
const variables = isRecord(meta.variables) ? meta.variables : {};
|
||||
const collections = isRecord(meta.variableCollections) ? meta.variableCollections : {};
|
||||
@@ -214,7 +351,9 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
|
||||
},
|
||||
|
||||
async styles(fileKey) {
|
||||
const body = await get(`/v1/files/${fileKey}/styles`);
|
||||
const body = await get(`/v1/files/${fileKey}/styles`, {
|
||||
scopeHint: SCOPE_HINTS.libraryContent,
|
||||
});
|
||||
const meta = isRecord(body) && isRecord(body.meta) ? body.meta : {};
|
||||
const styles = Array.isArray(meta.styles) ? meta.styles : [];
|
||||
return styles.filter(
|
||||
@@ -229,7 +368,9 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
|
||||
async nodeTree(ref) {
|
||||
const nodeId = requireNodeId(ref);
|
||||
const params = new URLSearchParams({ ids: nodeId, geometry: "paths" });
|
||||
const body = await get(`/v1/files/${ref.fileKey}/nodes?${params}`);
|
||||
const body = await get(`/v1/files/${ref.fileKey}/nodes?${params}`, {
|
||||
scopeHint: SCOPE_HINTS.fileContent,
|
||||
});
|
||||
const nodes = isRecord(body) && isRecord(body.nodes) ? body.nodes : {};
|
||||
const entry = nodes[nodeId];
|
||||
const doc = isRecord(entry) ? entry.document : undefined;
|
||||
@@ -244,7 +385,9 @@ export function createFigmaClient(options: FigmaClientOptions): FigmaClient {
|
||||
},
|
||||
|
||||
async fileVersion(fileKey) {
|
||||
const body = await get(`/v1/files/${fileKey}?depth=1`);
|
||||
const body = await get(`/v1/files/${fileKey}?depth=1`, {
|
||||
scopeHint: SCOPE_HINTS.fileMetadata,
|
||||
});
|
||||
const version = isRecord(body) && typeof body.version === "string" ? body.version : "";
|
||||
const lastModified =
|
||||
isRecord(body) && typeof body.lastModified === "string" ? body.lastModified : "";
|
||||
|
||||
@@ -234,6 +234,47 @@ describe("nodeToHtml", () => {
|
||||
expect(out.html).toContain("<img");
|
||||
});
|
||||
|
||||
it("routes IMAGE fills to the rasterize list regardless of node.type", () => {
|
||||
const out = nodeToHtml(
|
||||
frame([
|
||||
{
|
||||
id: "1:8",
|
||||
name: "Sneaker Photo",
|
||||
type: "RECTANGLE",
|
||||
absoluteBoundingBox: BOX(120, 220, 200, 200),
|
||||
fills: [{ type: "IMAGE", imageRef: "abc123" }],
|
||||
},
|
||||
]),
|
||||
{ resolved: [], unresolved: [] },
|
||||
);
|
||||
expect(out.rasterize).toEqual([
|
||||
{ nodeId: "1:8", name: "Sneaker Photo", slug: "sneaker-photo" },
|
||||
]);
|
||||
expect(out.html).toContain('data-figma-rasterize="1:8"');
|
||||
expect(out.html).toContain("<img");
|
||||
});
|
||||
|
||||
it("does not double-paint a rasterized node's own fill/corner-radius onto its img", () => {
|
||||
const out = nodeToHtml(
|
||||
frame([
|
||||
{
|
||||
id: "1:9",
|
||||
name: "Blob",
|
||||
type: "VECTOR",
|
||||
absoluteBoundingBox: BOX(120, 220, 64, 64),
|
||||
fills: [SOLID_BLUE],
|
||||
cornerRadius: 12,
|
||||
opacity: 0.5,
|
||||
},
|
||||
]),
|
||||
{ resolved: [], unresolved: [] },
|
||||
);
|
||||
expect(out.html).not.toContain("background-color: #0066FF");
|
||||
expect(out.html).not.toContain("border-radius: 12px");
|
||||
// opacity is compositing, not shape — still applies on top of the export
|
||||
expect(out.html).toContain("opacity: 0.5");
|
||||
});
|
||||
|
||||
it("skips invisible nodes and invisible fills (respects visible:false)", () => {
|
||||
const out = nodeToHtml(
|
||||
frame([
|
||||
|
||||
@@ -7,8 +7,11 @@
|
||||
* - CSS where CSS is faithful: solid/linear-gradient fills, corner radius,
|
||||
* opacity, drop shadow, blur, text styles.
|
||||
* - Everything CSS can't match faithfully (vectors, boolean ops, exotic
|
||||
* paint) routes to the rasterize list — the caller exports those nodes as
|
||||
* images (Phase 1) and fills in the placeholder src.
|
||||
* paint, IMAGE fills) routes to the rasterize list — the caller exports
|
||||
* those nodes as images (Phase 1) and fills in the placeholder src. A
|
||||
* rasterized node's own fill/corner-radius CSS is never emitted — the
|
||||
* exported image already contains it; adding both double-paints (a flat
|
||||
* color block behind/around the real art).
|
||||
* - Bindings (§7.1): resolved sites emit var(--slug, literal) so a brand
|
||||
* refresh propagates; unresolved sites bake the literal and carry a
|
||||
* data-figma-unresolved flag. Never a dangling var().
|
||||
@@ -113,6 +116,13 @@ function fillCss(node: FigmaNodeDocument): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** IMAGE fills (photos, icons pasted as bitmaps) have no CSS equivalent —
|
||||
* route to rasterize like vectors, regardless of node.type (a plain
|
||||
* RECTANGLE/FRAME carries the fill just as often as a dedicated image node). */
|
||||
function hasImageFill(node: FigmaNodeDocument): boolean {
|
||||
return firstVisibleFill(node)?.type === "IMAGE";
|
||||
}
|
||||
|
||||
function dropShadowCss(effect: Record<string, unknown>): string | null {
|
||||
if (!isRecord(effect.offset)) return null;
|
||||
const color = figmaColorToCss(effect.color);
|
||||
@@ -234,33 +244,47 @@ function geometryCss(node: FigmaNodeDocument, parentBox: Box, isRoot: boolean):
|
||||
return styles;
|
||||
}
|
||||
|
||||
function shapeCss(node: FigmaNodeDocument, styles: string[]): void {
|
||||
/** Corner-radius + clip describe the node's OWN shape — meaningless once
|
||||
* that shape has already been baked into a rasterized image (see
|
||||
* decorationCss). Opacity stays separate: it's compositing, still correct
|
||||
* to apply on top of a raster/vector export. */
|
||||
function cornerAndClipCss(node: FigmaNodeDocument, styles: string[]): void {
|
||||
if (node.type === "ELLIPSE") {
|
||||
styles.push("border-radius: 50%");
|
||||
} else if (typeof node.cornerRadius === "number" && node.cornerRadius > 0) {
|
||||
styles.push(`border-radius: ${round(node.cornerRadius)}px`);
|
||||
}
|
||||
if (node.clipsContent === true) styles.push("overflow: hidden");
|
||||
}
|
||||
|
||||
function opacityCss(node: FigmaNodeDocument, styles: string[]): void {
|
||||
if (typeof node.opacity === "number" && node.opacity < 1)
|
||||
styles.push(`opacity: ${round(node.opacity)}`);
|
||||
}
|
||||
|
||||
function decorationCss(node: FigmaNodeDocument, ctx: RenderContext): string[] {
|
||||
function decorationCss(node: FigmaNodeDocument, ctx: RenderContext, rasterized: boolean): string[] {
|
||||
const styles: string[] = [];
|
||||
// backgroundValue is the binding-aware path (var(--slug, literal)) — TEXT
|
||||
// color goes through it too, so a token-bound text fill keeps its link.
|
||||
const bg = backgroundValue(node, ctx);
|
||||
if (node.type === "TEXT") {
|
||||
if (bg !== null) styles.push(`color: ${bg}`);
|
||||
textCss(node, styles);
|
||||
} else if (bg !== null) {
|
||||
// background-color (longhand) for solid fills, never the shorthand: GSAP
|
||||
// backgroundColor tweens can't read a var() through the shorthand (its
|
||||
// pending-substitution longhands serialize empty), so .from/.to on an
|
||||
// imported node would settle on transparent instead of the token color.
|
||||
styles.push(bg.includes("gradient(") ? `background: ${bg}` : `background-color: ${bg}`);
|
||||
// A rasterized node's fill/shape is already baked into the exported image
|
||||
// — background-color/border-radius on top of it would double-paint (a
|
||||
// flat color block behind or around the real art). Opacity and effects
|
||||
// (shadow/blur) aren't baked by the export, so those still apply.
|
||||
if (!rasterized) {
|
||||
// backgroundValue is the binding-aware path (var(--slug, literal)) — TEXT
|
||||
// color goes through it too, so a token-bound text fill keeps its link.
|
||||
const bg = backgroundValue(node, ctx);
|
||||
if (node.type === "TEXT") {
|
||||
if (bg !== null) styles.push(`color: ${bg}`);
|
||||
textCss(node, styles);
|
||||
} else if (bg !== null) {
|
||||
// background-color (longhand) for solid fills, never the shorthand: GSAP
|
||||
// backgroundColor tweens can't read a var() through the shorthand (its
|
||||
// pending-substitution longhands serialize empty), so .from/.to on an
|
||||
// imported node would settle on transparent instead of the token color.
|
||||
styles.push(bg.includes("gradient(") ? `background: ${bg}` : `background-color: ${bg}`);
|
||||
}
|
||||
cornerAndClipCss(node, styles);
|
||||
}
|
||||
shapeCss(node, styles);
|
||||
opacityCss(node, styles);
|
||||
effectsCss(node, styles);
|
||||
return styles;
|
||||
}
|
||||
@@ -292,15 +316,16 @@ function renderNodeHtml(
|
||||
): string {
|
||||
if (node.visible === false || depth > MAX_DEPTH) return "";
|
||||
const slug = uniqueSlug(ctx, node.name);
|
||||
const rasterized = RASTERIZE_TYPES.has(node.type) || hasImageFill(node);
|
||||
const style = escapeHtml(
|
||||
[...geometryCss(node, parentBox, isRoot), ...decorationCss(node, ctx)].join("; "),
|
||||
[...geometryCss(node, parentBox, isRoot), ...decorationCss(node, ctx, rasterized)].join("; "),
|
||||
);
|
||||
// data-hf-snippet marks the file as a mountable fragment, not a standalone
|
||||
// composition — the project linter skips composition-root rules for it.
|
||||
const snippetAttr = isRoot ? ' data-hf-snippet=""' : "";
|
||||
const idAttrs = `id="${slug}"${snippetAttr} data-figma-id="${escapeHtml(node.id)}"${unresolvedAttr(node, ctx)}`;
|
||||
|
||||
if (RASTERIZE_TYPES.has(node.type)) {
|
||||
if (rasterized) {
|
||||
ctx.rasterize.push({ nodeId: node.id, name: node.name, slug });
|
||||
return `<img ${idAttrs} data-figma-rasterize="${escapeHtml(node.id)}" alt="${escapeHtml(node.name)}" style="${style}" />`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getPositionEditsRenderScript } from "./position-edits-render-inline";
|
||||
|
||||
describe("getPositionEditsRenderScript", () => {
|
||||
it("returns a non-empty IIFE string built from the real algorithm", () => {
|
||||
const script = getPositionEditsRenderScript();
|
||||
expect(script.length).toBeGreaterThan(0);
|
||||
expect(script).toContain("data-hf-edit-base-x");
|
||||
});
|
||||
|
||||
it("is a safe no-op without position-edit markers", () => {
|
||||
document.body.innerHTML = "<h1>no edits</h1>";
|
||||
expect(() => new Function(getPositionEditsRenderScript())()).not.toThrow();
|
||||
expect(document.querySelector("h1")?.style.getPropertyValue("translate")).toBe("");
|
||||
});
|
||||
|
||||
it("applies the translate delta when markers are present", () => {
|
||||
document.body.innerHTML =
|
||||
'<h1 data-x="10" data-y="0" data-hf-edit-base-x="0" data-hf-edit-base-y="0">hi</h1>';
|
||||
new Function(getPositionEditsRenderScript())();
|
||||
expect(document.querySelector("h1")?.style.getPropertyValue("translate")).toBe("10px 0px");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
// AUTO-GENERATED by scripts/build-position-edits-render.ts - do not edit
|
||||
const POSITION_EDITS_RENDER_IIFE: string =
|
||||
'"use strict";(()=>{function v(){return globalThis}function A(e,t){if(typeof window>"u")return;let o=v(),r=o.__hf?.onSwallowed;if(r)try{r({label:e,error:t})}catch(i){}(o.__hfDebug||o.__HYPERFRAMES_DEBUG)&&console.debug(`[hyperframes] ${e} swallowed:`,t)}var P=null;function T(e,t){if(P)try{P({source:"hf-preview",type:"analytics",event:e,properties:t??{}})}catch(o){A("runtime.analytics.site1",o)}}var w="data-hf-edit-base-x",y="data-hf-edit-base-y",_="data-hf-edit-original-translate",g=e=>{let t=parseFloat(e??"");return Number.isFinite(t)?t:0},H=e=>{let t=[],o=0,r="";for(let i of e.trim())i==="("&&(o+=1),i===")"&&(o=Math.max(0,o-1)),/\\s/.test(i)&&o===0?(r&&t.push(r),r=""):r+=i;return r&&t.push(r),t},R=/^-?(?:\\d+(?:\\.\\d+)?|\\.\\d+)px$/,b=(e,t)=>R.test(e)&&R.test(t)?`${parseFloat(e)+parseFloat(t)}px`:`calc(${e} + ${t})`,I=(e,t,o)=>{if(!e||e==="none")return`${t} ${o}`;let[r,i,a]=H(e);if(r===void 0)return`${t} ${o}`;if(i===void 0)return`${b(r,t)} ${o}`;let c=a===void 0?"":` ${a}`;return`${b(r,t)} ${b(i,o)}${c}`},O=e=>{try{e.ownerDocument.defaultView?.gsap?.getProperty?.(e,"x")}catch{}},j=e=>{let t=e.style.getPropertyValue("translate").trim();if(t)return t==="none"?"":t;try{let o=e.ownerDocument.defaultView,r=o?o.getComputedStyle(e).getPropertyValue("translate").trim():"";return r==="none"?"":r}catch{return""}},x=new WeakMap;function V(e,t){let o=x.get(e);if(!t?.force&&o!==void 0&&e.style.getPropertyValue("translate")!==o){T("position_edit_fold_skipped",{hfId:e.getAttribute("data-hf-id")});return}let r=g(e.getAttribute("data-x"))-g(e.getAttribute(w)),i=g(e.getAttribute("data-y"))-g(e.getAttribute(y));e.hasAttribute(_)||e.setAttribute(_,j(e)),o===void 0&&O(e);let a=e.getAttribute(_)??"",c=I(a,`${r}px`,`${i}px`);e.style.setProperty("translate",c),x.set(e,e.style.getPropertyValue("translate"))}function m(e){let t=e.querySelectorAll(`[${w}], [${y}]`),o=e.defaultView?.HTMLElement,r=0;for(let i=0;i<t.length;i++){let a=t[i];(o?a instanceof o:typeof a.style?.setProperty=="function")&&(V(a),r+=1)}return r}var D="__hfPositionEditsSeekReapplyWrapped",$=new WeakSet,F=new WeakMap,L=new WeakMap;function M(e){let t=e,o=()=>{try{m(t.document)}catch{}},r=n=>typeof n=="function"&&($.has(n)||!!n[D]),i=n=>{$.add(n);try{Object.defineProperty(n,D,{value:!0})}catch{}},a=n=>{if(typeof n!="function"||r(n))return n;let s=function(...u){let d=n.apply(this,u);return o(),d};return i(s),s},c=(n,s)=>{let u=F.get(n);if(u?.has(s))return!0;let d=Object.getOwnPropertyDescriptor(n,s);if(d?.configurable===!1){let l=n[s];return typeof l=="function"&&(n[s]=a(l),o()),!1}let p=n[s],f=d?.set;return Object.defineProperty(n,s,{configurable:!0,enumerable:d?.enumerable??!0,get:()=>p,set:l=>{p=a(l),f?.call(n,l)}}),p=a(p),u??(u=new Set),u.add(s),F.set(n,u),o(),!0},k=(n,s)=>{let u=L.get(t),d=Object.getOwnPropertyDescriptor(t,n);if(!u?.has(n)){if(d?.configurable===!1){let l=t[n];return l?c(l,s):!1}let f=t[n];Object.defineProperty(t,n,{configurable:!0,enumerable:d?.enumerable??!0,get:()=>f,set:l=>{f=l,f&&c(f,s)}}),u??(u=new Set),u.add(n),L.set(t,u)}let p=t[n];return p?c(p,s):!1},E=()=>{let n=k("__hf","seek"),s=k("__player","renderSeek");return n&&s};if(E())return;let S=120,h=t.setInterval(()=>{if(E()){t.clearInterval(h);return}S-=1,S<=0&&t.clearInterval(h)},50)}function W(){document.querySelector(`[${w}], [${y}]`)&&(m(document),M(window))}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",W,{once:!0}):W();})();\n';
|
||||
|
||||
/** Returns the pre-built position-edits render IIFE as a string constant. */
|
||||
export function getPositionEditsRenderScript(): string {
|
||||
return POSITION_EDITS_RENDER_IIFE;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { installPositionEditsSeekReapply } from "./positionEdits";
|
||||
|
||||
describe("init.ts per-seek position-edit parity", () => {
|
||||
it("recomputes the position edit after renderSeek", () => {
|
||||
document.body.innerHTML =
|
||||
'<h1 data-x="20" data-y="0" data-hf-edit-base-x="0" data-hf-edit-base-y="0">hi</h1>';
|
||||
const h1 = document.querySelector("h1");
|
||||
if (!(h1 instanceof HTMLElement)) throw new Error("test element missing");
|
||||
|
||||
// @ts-expect-error test global
|
||||
window.__player = {
|
||||
renderSeek: () => h1.setAttribute("data-x", "40"),
|
||||
};
|
||||
installPositionEditsSeekReapply(window as Window & typeof globalThis);
|
||||
// @ts-expect-error test global
|
||||
window.__player.renderSeek(1);
|
||||
|
||||
expect(h1.style.getPropertyValue("translate")).toBe("40px 0px");
|
||||
// @ts-expect-error test global
|
||||
delete window.__player;
|
||||
h1.remove();
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,7 @@ import { createRuntimeStartTimeResolver } from "./startResolver";
|
||||
import { createClipTree } from "./clipTree";
|
||||
import { loadExternalCompositions, loadInlineTemplateCompositions } from "./compositionLoader";
|
||||
import { applyCaptionOverrides } from "./captionOverrides";
|
||||
import { applyPositionEdits } from "./positionEdits";
|
||||
import { applyPositionEdits, installPositionEditsSeekReapply } from "./positionEdits";
|
||||
import { applyVariableBindings } from "./applyVariableBindings";
|
||||
import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorGrading";
|
||||
import { TransportClock } from "./clock";
|
||||
@@ -2987,6 +2987,8 @@ export function initSandboxRuntimeModular(): void {
|
||||
}
|
||||
}
|
||||
|
||||
installPositionEditsSeekReapply(window as Window & typeof globalThis);
|
||||
|
||||
// Start the rAF tick loop
|
||||
state.transportRafId = window.requestAnimationFrame(transportTick);
|
||||
postTimeline();
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/** @vitest-environment jsdom */
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { probeAndCacheElementVolume } from "./mediaVolumeEnvelope";
|
||||
|
||||
describe("probeAndCacheElementVolume", () => {
|
||||
it("restores the timeline playhead after sampling volume automation", () => {
|
||||
const audio = document.createElement("audio");
|
||||
audio.dataset.volume = "1";
|
||||
document.body.append(audio);
|
||||
|
||||
let playhead = 0.75;
|
||||
const timeline = {
|
||||
totalTime(next?: number) {
|
||||
if (next !== undefined) {
|
||||
playhead = next;
|
||||
audio.volume = next >= 1 ? 0 : 1;
|
||||
}
|
||||
return playhead;
|
||||
},
|
||||
};
|
||||
const cache = new WeakMap<HTMLMediaElement, { time: number; volume: number }[]>();
|
||||
|
||||
probeAndCacheElementVolume(audio, timeline, 1, cache);
|
||||
|
||||
expect(playhead).toBe(0.75);
|
||||
expect(audio.volume).toBe(1);
|
||||
expect(cache.get(audio)).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ volume: 0 })]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -126,8 +126,8 @@ export function probeElementVolumeKeyframes(
|
||||
}
|
||||
|
||||
export interface RuntimeTimelineRef {
|
||||
totalTime?: ((t: number, suppressEvents?: boolean) => unknown) | undefined;
|
||||
seek?: ((t: number, suppressEvents?: boolean) => unknown) | undefined;
|
||||
totalTime?: ((t?: number, suppressEvents?: boolean) => unknown) | undefined;
|
||||
seek?: ((t?: number, suppressEvents?: boolean) => unknown) | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,8 +155,17 @@ export function probeAndCacheElementVolume(
|
||||
// ignore seek failures during probe
|
||||
}
|
||||
};
|
||||
|
||||
// Sampling seeks the live timeline through the entire composition. Preserve
|
||||
// its playhead so the probe cannot perturb the first rendered frame (or any
|
||||
// user scrub in the preview).
|
||||
const originalTime =
|
||||
typeof timeline.totalTime === "function"
|
||||
? Number(timeline.totalTime())
|
||||
: typeof timeline.seek === "function"
|
||||
? Number(timeline.seek())
|
||||
: 0;
|
||||
const keyframes = probeElementVolumeKeyframes(mediaEl, seekFn, compositionDuration, 60);
|
||||
if (Number.isFinite(originalTime)) seekFn(originalTime);
|
||||
if (keyframes) {
|
||||
cache.set(mediaEl, keyframes);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
EDIT_BASE_X_ATTR,
|
||||
EDIT_BASE_Y_ATTR,
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
applyPositionEditToElement,
|
||||
applyPositionEdits,
|
||||
composeTranslate,
|
||||
installPositionEditsSeekReapply,
|
||||
} from "./positionEdits";
|
||||
|
||||
function makeElement(attrs: Record<string, string>, style = ""): HTMLElement {
|
||||
@@ -174,3 +175,67 @@ describe("applyPositionEdits", () => {
|
||||
el.remove();
|
||||
});
|
||||
});
|
||||
|
||||
describe("installPositionEditsSeekReapply", () => {
|
||||
it("wraps __player.renderSeek so each call reapplies position edits", () => {
|
||||
const el = makeElement({ "data-x": "10", "data-y": "0", "data-hf-edit-base-x": "0" });
|
||||
const calls: number[] = [];
|
||||
// @ts-expect-error test global
|
||||
window.__player = { renderSeek: (time: number) => calls.push(time) };
|
||||
|
||||
installPositionEditsSeekReapply(window as Window & typeof globalThis);
|
||||
// @ts-expect-error test global
|
||||
window.__player.renderSeek(1.5);
|
||||
|
||||
expect(calls).toEqual([1.5]);
|
||||
expect(el.style.getPropertyValue("translate")).toBe("10px 0px");
|
||||
// @ts-expect-error test global
|
||||
delete window.__player;
|
||||
el.remove();
|
||||
});
|
||||
|
||||
it("is idempotent when installed twice", () => {
|
||||
const el = makeElement({ "data-x": "5", "data-y": "0", "data-hf-edit-base-x": "0" });
|
||||
const calls: number[] = [];
|
||||
// @ts-expect-error test global
|
||||
window.__player = { renderSeek: (time: number) => calls.push(time) };
|
||||
|
||||
installPositionEditsSeekReapply(window as Window & typeof globalThis);
|
||||
installPositionEditsSeekReapply(window as Window & typeof globalThis);
|
||||
// @ts-expect-error test global
|
||||
window.__player.renderSeek(2);
|
||||
|
||||
expect(calls).toEqual([2]);
|
||||
// @ts-expect-error test global
|
||||
delete window.__player;
|
||||
el.remove();
|
||||
});
|
||||
|
||||
it("wraps __hf.seek and a seek function assigned after installation", () => {
|
||||
vi.useFakeTimers();
|
||||
const el = makeElement({ "data-x": "8", "data-y": "0", "data-hf-edit-base-x": "0" });
|
||||
const calls: number[] = [];
|
||||
// @ts-expect-error test global
|
||||
window.__hf = {};
|
||||
|
||||
installPositionEditsSeekReapply(window as Window & typeof globalThis);
|
||||
// @ts-expect-error test global
|
||||
window.__hf.seek = (time: number) => calls.push(time);
|
||||
vi.advanceTimersByTime(50);
|
||||
// @ts-expect-error test global
|
||||
window.__hf.seek(3);
|
||||
|
||||
expect(calls).toEqual([3]);
|
||||
expect(el.style.getPropertyValue("translate")).toBe("8px 0px");
|
||||
// @ts-expect-error test global
|
||||
delete window.__hf;
|
||||
el.remove();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not throw when neither seek global exists", () => {
|
||||
expect(() =>
|
||||
installPositionEditsSeekReapply(window as Window & typeof globalThis),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -175,3 +175,130 @@ export function applyPositionEdits(doc: Document): number {
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
const SEEK_REAPPLY_WRAPPED = "__hfPositionEditsSeekReapplyWrapped";
|
||||
type SeekFunction = (...args: unknown[]) => unknown;
|
||||
const wrappedSeekFunctions = new WeakSet<SeekFunction>();
|
||||
const observedSeekProperties = new WeakMap<object, Set<string>>();
|
||||
const observedGlobalProperties = new WeakMap<object, Set<string>>();
|
||||
|
||||
type SeekWindow = Window &
|
||||
typeof globalThis & {
|
||||
__hf?: { seek?: (...args: unknown[]) => unknown };
|
||||
__player?: { renderSeek?: (...args: unknown[]) => unknown };
|
||||
};
|
||||
|
||||
/** Reapply SDK position edits after every render seek, including late-bound seeks. */
|
||||
export function installPositionEditsSeekReapply(win: Window & typeof globalThis): void {
|
||||
const target = win as SeekWindow;
|
||||
const reapply = (): void => {
|
||||
try {
|
||||
applyPositionEdits(target.document);
|
||||
} catch {
|
||||
// A position edit must never break the render seek path.
|
||||
}
|
||||
};
|
||||
|
||||
const isWrapped = (fn: unknown): fn is SeekFunction =>
|
||||
typeof fn === "function" &&
|
||||
(wrappedSeekFunctions.has(fn as SeekFunction) ||
|
||||
Boolean((fn as { [SEEK_REAPPLY_WRAPPED]?: boolean })[SEEK_REAPPLY_WRAPPED]));
|
||||
|
||||
const markWrapped = (fn: SeekFunction): void => {
|
||||
wrappedSeekFunctions.add(fn);
|
||||
try {
|
||||
Object.defineProperty(fn, SEEK_REAPPLY_WRAPPED, { value: true });
|
||||
} catch {
|
||||
// The WeakSet keeps frozen functions from being wrapped repeatedly.
|
||||
}
|
||||
};
|
||||
|
||||
const wrapFunction = (fn: unknown): unknown => {
|
||||
if (typeof fn !== "function" || isWrapped(fn)) return fn;
|
||||
const wrapped: SeekFunction = function (this: unknown, ...args: unknown[]): unknown {
|
||||
const result = fn.apply(this, args);
|
||||
reapply();
|
||||
return result;
|
||||
};
|
||||
markWrapped(wrapped);
|
||||
return wrapped;
|
||||
};
|
||||
|
||||
const observeSeekProperty = (container: object, property: string): boolean => {
|
||||
let observed = observedSeekProperties.get(container);
|
||||
if (observed?.has(property)) return true;
|
||||
const descriptor = Object.getOwnPropertyDescriptor(container, property);
|
||||
if (descriptor?.configurable === false) {
|
||||
const current = (container as Record<string, unknown>)[property];
|
||||
if (typeof current === "function") {
|
||||
(container as Record<string, unknown>)[property] = wrapFunction(current);
|
||||
reapply();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
let current = (container as Record<string, unknown>)[property];
|
||||
const originalSetter = descriptor?.set;
|
||||
Object.defineProperty(container, property, {
|
||||
configurable: true,
|
||||
enumerable: descriptor?.enumerable ?? true,
|
||||
get: () => current,
|
||||
set: (value: unknown) => {
|
||||
current = wrapFunction(value);
|
||||
originalSetter?.call(container, value);
|
||||
},
|
||||
});
|
||||
current = wrapFunction(current);
|
||||
observed ??= new Set<string>();
|
||||
observed.add(property);
|
||||
observedSeekProperties.set(container, observed);
|
||||
reapply();
|
||||
return true;
|
||||
};
|
||||
|
||||
const observeGlobalContainer = (
|
||||
name: "__hf" | "__player",
|
||||
property: "seek" | "renderSeek",
|
||||
): boolean => {
|
||||
let globals = observedGlobalProperties.get(target);
|
||||
const descriptor = Object.getOwnPropertyDescriptor(target, name);
|
||||
if (!globals?.has(name)) {
|
||||
if (descriptor?.configurable === false) {
|
||||
const current = target[name];
|
||||
return current ? observeSeekProperty(current, property) : false;
|
||||
}
|
||||
let value = target[name];
|
||||
Object.defineProperty(target, name, {
|
||||
configurable: true,
|
||||
enumerable: descriptor?.enumerable ?? true,
|
||||
get: () => value,
|
||||
set: (next: unknown) => {
|
||||
value = next as typeof value;
|
||||
if (value) observeSeekProperty(value, property);
|
||||
},
|
||||
});
|
||||
globals ??= new Set<string>();
|
||||
globals.add(name);
|
||||
observedGlobalProperties.set(target, globals);
|
||||
}
|
||||
const current = target[name];
|
||||
return current ? observeSeekProperty(current, property) : false;
|
||||
};
|
||||
|
||||
const wrapAll = (): boolean => {
|
||||
const hfObserved = observeGlobalContainer("__hf", "seek");
|
||||
const playerObserved = observeGlobalContainer("__player", "renderSeek");
|
||||
return hfObserved && playerObserved;
|
||||
};
|
||||
|
||||
if (wrapAll()) return;
|
||||
let remaining = 120;
|
||||
const interval = target.setInterval(() => {
|
||||
if (wrapAll()) {
|
||||
target.clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
remaining -= 1;
|
||||
if (remaining <= 0) target.clearInterval(interval);
|
||||
}, 50);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import {
|
||||
EDIT_BASE_X_ATTR,
|
||||
EDIT_BASE_Y_ATTR,
|
||||
applyPositionEdits,
|
||||
installPositionEditsSeekReapply,
|
||||
} from "../src/runtime/positionEdits";
|
||||
|
||||
function start(): void {
|
||||
if (!document.querySelector(`[${EDIT_BASE_X_ATTR}], [${EDIT_BASE_Y_ATTR}]`)) {
|
||||
return;
|
||||
}
|
||||
applyPositionEdits(document);
|
||||
installPositionEditsSeekReapply(window);
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", start, { once: true });
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hyperframes/engine",
|
||||
"version": "0.7.46",
|
||||
"version": "0.7.51",
|
||||
"description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hyperframes/gcp-cloud-run",
|
||||
"version": "0.7.46",
|
||||
"version": "0.7.51",
|
||||
"description": "Google Cloud Run + Workflows adapter for HyperFrames distributed rendering — request handler, client-side SDK, and Terraform module.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hyperframes/lint",
|
||||
"version": "0.7.46",
|
||||
"version": "0.7.51",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/heygen-com/hyperframes",
|
||||
|
||||
@@ -200,7 +200,9 @@ export async function lintProject(projectDir: string): Promise<ProjectLintResult
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const relPath = rel ? `${rel}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) out.push(...collectHtmlFiles(join(dir, entry.name), relPath));
|
||||
else if (entry.isFile() && entry.name.endsWith(".html")) out.push(relPath);
|
||||
else if (entry.isFile() && entry.name.endsWith(".html") && !entry.name.startsWith("._")) {
|
||||
out.push(relPath);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
@@ -442,7 +444,9 @@ function lintTextureMaskAssetNotFound(
|
||||
function lintMultipleRootCompositions(projectDir: string): HyperframeLintFinding[] {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
try {
|
||||
const rootHtmlFiles = readdirSync(projectDir).filter((f) => f.endsWith(".html"));
|
||||
const rootHtmlFiles = readdirSync(projectDir).filter(
|
||||
(file) => file.endsWith(".html") && !file.startsWith("._"),
|
||||
);
|
||||
const rootCompositions: string[] = [];
|
||||
for (const file of rootHtmlFiles) {
|
||||
if (file === "caption-skin.html") continue;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hyperframes/parsers",
|
||||
"version": "0.7.46",
|
||||
"version": "0.7.51",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/heygen-com/hyperframes",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hyperframes/player",
|
||||
"version": "0.7.46",
|
||||
"version": "0.7.51",
|
||||
"description": "Embeddable web component for HyperFrames compositions",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hyperframes/producer",
|
||||
"version": "0.7.46",
|
||||
"version": "0.7.51",
|
||||
"description": "HTML-to-video rendering engine using Chrome's BeginFrame API",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,13 +1,59 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createConsoleLogger, defaultLogger } from "./logger.js";
|
||||
import type { LogLevel, ProducerLogger } from "./logger.js";
|
||||
|
||||
// `isLevelEnabled` is optional on ProducerLogger, so every call site guards
|
||||
// it with `?.`; pulled out once so the loops below stay single-branch.
|
||||
function isLevelEnabledSafe(
|
||||
log: Pick<ProducerLogger, "isLevelEnabled">,
|
||||
level: LogLevel,
|
||||
): boolean | undefined {
|
||||
return log.isLevelEnabled?.(level);
|
||||
}
|
||||
|
||||
// Shared by the isLevelEnabled matrix cases below: assert a threshold's
|
||||
// enabled levels report true and its disabled levels report false.
|
||||
function assertLevelEnabledMatrix(
|
||||
log: Pick<ProducerLogger, "isLevelEnabled">,
|
||||
enabled: ReadonlyArray<LogLevel>,
|
||||
disabled: ReadonlyArray<LogLevel>,
|
||||
): void {
|
||||
for (const lvl of enabled) {
|
||||
expect(isLevelEnabledSafe(log, lvl)).toBe(true);
|
||||
}
|
||||
for (const lvl of disabled) {
|
||||
expect(isLevelEnabledSafe(log, lvl)).toBe(false);
|
||||
}
|
||||
}
|
||||
|
||||
// The `isLevelEnabled?.("debug") ?? true` call-site gate pattern itself,
|
||||
// isolated so runGatedDebugLoop's own branch count stays at "loop + if".
|
||||
function isDebugGated(log: Pick<ProducerLogger, "isLevelEnabled">): boolean {
|
||||
return log.isLevelEnabled?.("debug") ?? true;
|
||||
}
|
||||
|
||||
// Shared by the call-site gate cases below: run the `isLevelEnabled?.("debug")
|
||||
// ?? true` pattern callers use to skip expensive meta construction, the
|
||||
// exact number of times the test needs, so each test asserts only the
|
||||
// pattern's outcome (buildCount / logged calls) and not the loop mechanics.
|
||||
function runGatedDebugLoop(
|
||||
log: Pick<ProducerLogger, "debug" | "isLevelEnabled">,
|
||||
iterations: number,
|
||||
buildMeta: () => Record<string, unknown>,
|
||||
): void {
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
if (isDebugGated(log)) {
|
||||
log.debug("evt", buildMeta());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("createConsoleLogger", () => {
|
||||
// We capture calls to console.{log,warn,error} via `mock` so we can
|
||||
// We capture calls to console.{log,warn,error} via `vi.fn` so we can
|
||||
// assert what would have been printed without polluting test output.
|
||||
let logSpy: ReturnType<typeof mock>;
|
||||
let warnSpy: ReturnType<typeof mock>;
|
||||
let errorSpy: ReturnType<typeof mock>;
|
||||
let logSpy: ReturnType<typeof vi.fn>;
|
||||
let warnSpy: ReturnType<typeof vi.fn>;
|
||||
let errorSpy: ReturnType<typeof vi.fn>;
|
||||
let origLog: typeof console.log;
|
||||
let origWarn: typeof console.warn;
|
||||
let origError: typeof console.error;
|
||||
@@ -16,9 +62,9 @@ describe("createConsoleLogger", () => {
|
||||
origLog = console.log;
|
||||
origWarn = console.warn;
|
||||
origError = console.error;
|
||||
logSpy = mock(() => {});
|
||||
warnSpy = mock(() => {});
|
||||
errorSpy = mock(() => {});
|
||||
logSpy = vi.fn();
|
||||
warnSpy = vi.fn();
|
||||
errorSpy = vi.fn();
|
||||
console.log = logSpy as unknown as typeof console.log;
|
||||
console.warn = warnSpy as unknown as typeof console.warn;
|
||||
console.error = errorSpy as unknown as typeof console.error;
|
||||
@@ -30,6 +76,43 @@ describe("createConsoleLogger", () => {
|
||||
console.error = origError;
|
||||
});
|
||||
|
||||
// All four levels are stderr-bound (console.error/console.warn); console.log
|
||||
// (stdout) must never be touched, since producer runs inside CLI commands
|
||||
// whose stdout is a machine-readable contract (e.g. `validate --json`).
|
||||
describe("stdout/stderr routing", () => {
|
||||
it("info and debug write to console.error (stderr), never console.log (stdout)", () => {
|
||||
const log = createConsoleLogger("debug");
|
||||
log.info("info-msg");
|
||||
log.debug("debug-msg");
|
||||
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
expect(errorSpy.mock.calls.map((c) => c[0])).toEqual([
|
||||
"[INFO] info-msg",
|
||||
"[DEBUG] debug-msg",
|
||||
]);
|
||||
});
|
||||
|
||||
it("warn and error keep their pre-existing channels (console.warn / console.error)", () => {
|
||||
const log = createConsoleLogger("debug");
|
||||
log.warn("warn-msg");
|
||||
log.error("error-msg");
|
||||
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
expect(warnSpy.mock.calls[0]?.[0]).toBe("[WARN] warn-msg");
|
||||
expect(errorSpy.mock.calls[0]?.[0]).toBe("[ERROR] error-msg");
|
||||
});
|
||||
|
||||
it("console.log is never called at any level", () => {
|
||||
const log = createConsoleLogger("debug");
|
||||
log.debug("d");
|
||||
log.info("i");
|
||||
log.warn("w");
|
||||
log.error("e");
|
||||
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("level filtering", () => {
|
||||
it("level=info drops debug, keeps info/warn/error", () => {
|
||||
const log = createConsoleLogger("info");
|
||||
@@ -38,12 +121,12 @@ describe("createConsoleLogger", () => {
|
||||
log.warn("warn-msg");
|
||||
log.error("error-msg");
|
||||
|
||||
expect(logSpy.mock.calls.length).toBe(1);
|
||||
expect(logSpy.mock.calls[0]?.[0]).toBe("[INFO] info-msg");
|
||||
// info + error both route to console.error now (info: stderr routing, error: always stderr).
|
||||
expect(errorSpy.mock.calls.length).toBe(2);
|
||||
expect(errorSpy.mock.calls[0]?.[0]).toBe("[INFO] info-msg");
|
||||
expect(errorSpy.mock.calls[1]?.[0]).toBe("[ERROR] error-msg");
|
||||
expect(warnSpy.mock.calls.length).toBe(1);
|
||||
expect(warnSpy.mock.calls[0]?.[0]).toBe("[WARN] warn-msg");
|
||||
expect(errorSpy.mock.calls.length).toBe(1);
|
||||
expect(errorSpy.mock.calls[0]?.[0]).toBe("[ERROR] error-msg");
|
||||
});
|
||||
|
||||
it("level=debug keeps all four levels", () => {
|
||||
@@ -53,12 +136,12 @@ describe("createConsoleLogger", () => {
|
||||
log.warn("w");
|
||||
log.error("e");
|
||||
|
||||
// info + debug both go to console.log
|
||||
expect(logSpy.mock.calls.length).toBe(2);
|
||||
expect(logSpy.mock.calls[0]?.[0]).toBe("[DEBUG] d");
|
||||
expect(logSpy.mock.calls[1]?.[0]).toBe("[INFO] i");
|
||||
// debug + info + error all go to console.error
|
||||
expect(errorSpy.mock.calls.length).toBe(3);
|
||||
expect(errorSpy.mock.calls[0]?.[0]).toBe("[DEBUG] d");
|
||||
expect(errorSpy.mock.calls[1]?.[0]).toBe("[INFO] i");
|
||||
expect(errorSpy.mock.calls[2]?.[0]).toBe("[ERROR] e");
|
||||
expect(warnSpy.mock.calls.length).toBe(1);
|
||||
expect(errorSpy.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it("level=warn drops info and debug, keeps warn/error", () => {
|
||||
@@ -68,9 +151,9 @@ describe("createConsoleLogger", () => {
|
||||
log.warn("w");
|
||||
log.error("e");
|
||||
|
||||
expect(logSpy.mock.calls.length).toBe(0);
|
||||
expect(warnSpy.mock.calls.length).toBe(1);
|
||||
expect(errorSpy.mock.calls.length).toBe(1);
|
||||
expect(errorSpy.mock.calls[0]?.[0]).toBe("[ERROR] e");
|
||||
expect(warnSpy.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it("level=error drops everything except error", () => {
|
||||
@@ -80,7 +163,6 @@ describe("createConsoleLogger", () => {
|
||||
log.warn("w");
|
||||
log.error("e");
|
||||
|
||||
expect(logSpy.mock.calls.length).toBe(0);
|
||||
expect(warnSpy.mock.calls.length).toBe(0);
|
||||
expect(errorSpy.mock.calls.length).toBe(1);
|
||||
});
|
||||
@@ -90,8 +172,8 @@ describe("createConsoleLogger", () => {
|
||||
log.debug("d");
|
||||
log.info("i");
|
||||
|
||||
expect(logSpy.mock.calls.length).toBe(1);
|
||||
expect(logSpy.mock.calls[0]?.[0]).toBe("[INFO] i");
|
||||
expect(errorSpy.mock.calls.length).toBe(1);
|
||||
expect(errorSpy.mock.calls[0]?.[0]).toBe("[INFO] i");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,14 +182,14 @@ describe("createConsoleLogger", () => {
|
||||
const log = createConsoleLogger("info");
|
||||
log.info("hello", { a: 1, b: "two" });
|
||||
|
||||
expect(logSpy.mock.calls[0]?.[0]).toBe('[INFO] hello {"a":1,"b":"two"}');
|
||||
expect(errorSpy.mock.calls[0]?.[0]).toBe('[INFO] hello {"a":1,"b":"two"}');
|
||||
});
|
||||
|
||||
it("emits message only when meta is omitted", () => {
|
||||
const log = createConsoleLogger("info");
|
||||
log.info("plain");
|
||||
|
||||
expect(logSpy.mock.calls[0]?.[0]).toBe("[INFO] plain");
|
||||
expect(errorSpy.mock.calls[0]?.[0]).toBe("[INFO] plain");
|
||||
});
|
||||
|
||||
it("does not invoke JSON.stringify when level is filtered out", () => {
|
||||
@@ -123,7 +205,7 @@ describe("createConsoleLogger", () => {
|
||||
};
|
||||
// Should not throw — debug is below the info threshold.
|
||||
log.debug("trap", trap as unknown as Record<string, unknown>);
|
||||
expect(logSpy.mock.calls.length).toBe(0);
|
||||
expect(errorSpy.mock.calls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,12 +240,7 @@ describe("createConsoleLogger", () => {
|
||||
for (const { threshold, enabled, disabled } of cases) {
|
||||
it(`level=${threshold} reports enabled levels correctly`, () => {
|
||||
const log = createConsoleLogger(threshold);
|
||||
for (const lvl of enabled) {
|
||||
expect(log.isLevelEnabled?.(lvl)).toBe(true);
|
||||
}
|
||||
for (const lvl of disabled) {
|
||||
expect(log.isLevelEnabled?.(lvl)).toBe(false);
|
||||
}
|
||||
assertLevelEnabledMatrix(log, enabled, disabled);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -178,14 +255,10 @@ describe("createConsoleLogger", () => {
|
||||
return { expensive: true };
|
||||
};
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
if (log.isLevelEnabled?.("debug") ?? true) {
|
||||
log.debug("hot-loop", buildMeta());
|
||||
}
|
||||
}
|
||||
runGatedDebugLoop(log, 100, buildMeta);
|
||||
|
||||
expect(buildCount).toBe(0);
|
||||
expect(logSpy.mock.calls.length).toBe(0);
|
||||
expect(errorSpy.mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it("call-site gate runs the meta builder when debug is enabled", () => {
|
||||
@@ -196,14 +269,10 @@ describe("createConsoleLogger", () => {
|
||||
return { iter: buildCount };
|
||||
};
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (log.isLevelEnabled?.("debug") ?? true) {
|
||||
log.debug("loop", buildMeta());
|
||||
}
|
||||
}
|
||||
runGatedDebugLoop(log, 5, buildMeta);
|
||||
|
||||
expect(buildCount).toBe(5);
|
||||
expect(logSpy.mock.calls.length).toBe(5);
|
||||
expect(errorSpy.mock.calls.length).toBe(5);
|
||||
});
|
||||
|
||||
it("custom logger without isLevelEnabled falls back to running the meta builder (`?? true`)", () => {
|
||||
@@ -224,11 +293,7 @@ describe("createConsoleLogger", () => {
|
||||
return { i: buildCount };
|
||||
};
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (customLog.isLevelEnabled?.("debug") ?? true) {
|
||||
customLog.debug("evt", buildMeta());
|
||||
}
|
||||
}
|
||||
runGatedDebugLoop(customLog, 3, buildMeta);
|
||||
|
||||
expect(buildCount).toBe(3);
|
||||
expect(calls).toHaveLength(3);
|
||||
@@ -242,8 +307,8 @@ describe("createConsoleLogger", () => {
|
||||
defaultLogger.info("default-info");
|
||||
defaultLogger.debug("default-debug");
|
||||
|
||||
expect(logSpy.mock.calls.length).toBe(1);
|
||||
expect(logSpy.mock.calls[0]?.[0]).toBe("[INFO] default-info");
|
||||
expect(errorSpy.mock.calls.length).toBe(1);
|
||||
expect(errorSpy.mock.calls[0]?.[0]).toBe("[INFO] default-info");
|
||||
});
|
||||
|
||||
it("exposes isLevelEnabled gating debug at info threshold", () => {
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
* Lightweight pluggable logger with zero dependencies.
|
||||
* Default implementation writes to console with level filtering.
|
||||
*
|
||||
* All levels write to stderr (console.error/console.warn), never stdout —
|
||||
* producer runs inside CLI commands whose stdout is a machine-readable
|
||||
* contract (e.g. `validate --json`, `check --json`); an info/debug line on
|
||||
* stdout would corrupt that output. There is no diagnostic use case that
|
||||
* needs these lines on stdout specifically, so the whole logger is stderr-only.
|
||||
*
|
||||
* Users can provide their own logger (e.g. Winston, Pino) by
|
||||
* implementing the ProducerLogger interface.
|
||||
*/
|
||||
@@ -71,12 +77,12 @@ export function createConsoleLogger(level: LogLevel = "info"): ProducerLogger {
|
||||
},
|
||||
info(message, meta) {
|
||||
if (shouldLog("info")) {
|
||||
console.log(`[INFO] ${message}${formatMeta(meta)}`);
|
||||
console.error(`[INFO] ${message}${formatMeta(meta)}`);
|
||||
}
|
||||
},
|
||||
debug(message, meta) {
|
||||
if (shouldLog("debug")) {
|
||||
console.log(`[DEBUG] ${message}${formatMeta(meta)}`);
|
||||
console.error(`[DEBUG] ${message}${formatMeta(meta)}`);
|
||||
}
|
||||
},
|
||||
isLevelEnabled(msgLevel) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { parseHTML } from "linkedom";
|
||||
import {
|
||||
collectExternalAssets,
|
||||
compileForRender,
|
||||
injectSdkPositionEditsRenderScript,
|
||||
detectRenderModeHints,
|
||||
detectShaderTransitionUsage,
|
||||
detectThreeDTransformUsage,
|
||||
@@ -19,6 +20,28 @@ import {
|
||||
} from "./htmlCompiler.js";
|
||||
import { validateNoSystemFonts } from "./render/planValidation.js";
|
||||
|
||||
describe("injectSdkPositionEditsRenderScript", () => {
|
||||
it("injects before </body> when SDK position-edit markers are present", () => {
|
||||
const html =
|
||||
'<html><body><h1 data-x="-231" data-y="-139" data-hf-edit-base-x="0" data-hf-edit-base-y="0">Hi</h1></body></html>';
|
||||
const out = injectSdkPositionEditsRenderScript(html);
|
||||
expect(out).toContain("<script>");
|
||||
expect(out.indexOf("<script>")).toBeLessThan(out.indexOf("</body>"));
|
||||
expect(out).toContain("data-hf-edit-base-x");
|
||||
});
|
||||
|
||||
it("appends the script when there is no </body> tag", () => {
|
||||
const out = injectSdkPositionEditsRenderScript('<div data-hf-edit-base-y="0"></div>');
|
||||
expect(out.startsWith('<div data-hf-edit-base-y="0"></div>')).toBe(true);
|
||||
expect(out).toContain("<script>");
|
||||
});
|
||||
|
||||
it("is a no-op for style/text-only HTML", () => {
|
||||
const html = '<html><body><h1 style="color:#f00">Hi</h1></body></html>';
|
||||
expect(injectSdkPositionEditsRenderScript(html)).toBe(html);
|
||||
});
|
||||
});
|
||||
|
||||
// ── collectExternalAssets ──────────────────────────────────────────────────
|
||||
|
||||
describe("collectExternalAssets", () => {
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
} from "./deterministicFonts.js";
|
||||
import { prepareAnimatedGifInputs } from "./animatedGifPrep.js";
|
||||
import { createStudioPositionSeekReapplyScript } from "@hyperframes/studio-server/manual-edits-render-script";
|
||||
import { getPositionEditsRenderScript } from "@hyperframes/core/runtime/position-edits-render";
|
||||
import { defaultLogger, type ProducerLogger } from "../logger.js";
|
||||
|
||||
export interface CompiledComposition {
|
||||
@@ -84,6 +85,17 @@ function parseSubCompHtmlForValidity(html: string): ParsableDocumentLike {
|
||||
return parseHTML(html).document as unknown as ParsableDocumentLike;
|
||||
}
|
||||
|
||||
export function injectSdkPositionEditsRenderScript(html: string): string {
|
||||
if (!html.includes("data-hf-edit-base-x") && !html.includes("data-hf-edit-base-y")) {
|
||||
return html;
|
||||
}
|
||||
const scriptBody = getPositionEditsRenderScript().replace(/<\/script/gi, "<\\/script");
|
||||
const script = `<script>${scriptBody}</script>`;
|
||||
const bodyClose = html.search(/<\/body\s*>/i);
|
||||
if (bodyClose < 0) return `${html}${script}`;
|
||||
return `${html.slice(0, bodyClose)}${script}${html.slice(bodyClose)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by {@link assertSubCompositionsUsable} when one or more
|
||||
* `data-composition-src` references resolve to a missing, empty, or
|
||||
@@ -1730,6 +1742,7 @@ export async function compileForRender(
|
||||
`<script>${createStudioPositionSeekReapplyScript()}</script></body>`,
|
||||
)
|
||||
: assembledHtml;
|
||||
const htmlWithSdkPositionScript = injectSdkPositionEditsRenderScript(htmlWithPositionScript);
|
||||
|
||||
// Download remote <video> and <audio> sources to compiledDir and rewrite the
|
||||
// src attributes so the renderer reads from localhost. Remote S3 URLs cause
|
||||
@@ -1737,7 +1750,7 @@ export async function compileForRender(
|
||||
// over the network; any that don't reach readyState >= 2 in time render as
|
||||
// blank black frames. Localising them eliminates the race.
|
||||
const { html: htmlWithLocalMedia, remoteMediaAssets } = await localizeRemoteMediaSources(
|
||||
htmlWithPositionScript,
|
||||
htmlWithSdkPositionScript,
|
||||
downloadDir,
|
||||
);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user