diff --git a/AGENTS.md b/AGENTS.md index 7264b1adb8..1c1cd9eff3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,7 @@ After creating or editing any `.html` composition: ```bash npx hyperframes lint # Static HTML structure check -npx hyperframes validate # Runtime check (headless Chrome — catches JS errors, missing assets) +npx hyperframes check # Browser gate (headless Chrome — runtime errors, layout, motion, WCAG contrast) ``` Both must pass before previewing or considering work complete. diff --git a/CLAUDE.md b/CLAUDE.md index 7c895db16d..abd8a019f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,7 +39,7 @@ Atomic capabilities the creation workflows compose against — pull one when you - `/hyperframes-keyframes` — seek-safe keyframe authoring across runtimes: GSAP timelines, CSS keyframes, Anime.js, WAAPI, FLIP, paths, masks, SVG morph/draw, text trails, 3D depth; plus `hyperframes keyframes` diagnostics for surfacing and verifying rendered motion. - `/hyperframes-creative` — non-animation creative direction: `frame.md` / `design.md` handling, palettes, typography, narration, beat planning, audio-reactive visuals, composition patterns. - `/media-use` — the media OS: resolve any media need (BGM, SFX, image, icon, logo, voice, color grade, LUT) into a frozen local file or paste-ready block + ledger record; generate via TTS / music / image models when the catalog misses; transcribe, caption, remove backgrounds, and reuse assets across projects. One shared `scripts/audio.mjs` engine + manifest tracking; keeps search noise on disk. -- `/hyperframes-cli` — CLI dev loop: `init`, `add`, `lint`, `validate`, `inspect`, `preview`, `render`, `publish`, `doctor`, `lambda` (AWS Lambda cloud rendering). +- `/hyperframes-cli` — CLI dev loop: `init`, `add`, `lint`, `check`, `snapshot`, `preview`, `render`, `publish`, `doctor`, `lambda` (AWS Lambda cloud rendering). - `/hyperframes-registry` — install and wire registry blocks and components into compositions via `hyperframes add`. Covers authoring a new block or component to contribute upstream. - `/figma` — import Figma assets, tokens, components, and storyboard sections → reconstructed motion (frames read as states, not slides) (REST/CLI) plus Motion animations (MCP) and shaders (MCP source / native export) into a composition. @@ -81,7 +81,7 @@ After creating or editing any `.html` composition: ```bash npx hyperframes lint # Static HTML structure check -npx hyperframes validate # Runtime check (headless Chrome — catches JS errors, missing assets) +npx hyperframes check # Browser gate (headless Chrome — runtime errors, layout, motion, WCAG contrast) ``` Both must pass before previewing or considering work complete. diff --git a/README.md b/README.md index e6b2a4a7bb..d12516acef 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ Atomic capabilities the creation workflows compose against — pull one when you | `/hyperframes-keyframes` | Seek-safe keyframe authoring across runtimes — GSAP timelines, CSS keyframes, Anime.js, WAAPI, FLIP, paths, masks, SVG morph/draw, 3D depth — plus `hyperframes keyframes` diagnostics for rendered motion. | | `/hyperframes-creative` | Non-animation creative direction — `frame.md` / `design.md`, palettes, typography, narration, beat planning, audio-reactive visuals, composition patterns. | | `/media-use` | The media OS — resolve any media need (BGM, SFX, image, icon, logo, voice, color grade, LUT) into a frozen local file or paste-ready block + ledger record, generate via TTS/music/image models when the catalog misses, transcribe, caption, remove backgrounds, and reuse assets across projects. One shared audio engine + manifest tracking. | -| `/hyperframes-cli` | CLI dev loop — `init`, `lint`, `validate`, `inspect`, `preview`, `render`, `publish`, `doctor`, plus AWS Lambda cloud rendering (`lambda deploy / render / progress`). | +| `/hyperframes-cli` | CLI dev loop — `init`, `lint`, `check`, `snapshot`, `preview`, `render`, `publish`, `doctor`, plus AWS Lambda cloud rendering (`lambda deploy / render / progress`). | | `/hyperframes-registry` | Install and wire registry blocks and components into compositions via `hyperframes add`. Authoring a new block or component to contribute upstream. | | `/figma` | Import Figma assets, tokens, components, and storyboard sections → reconstructed motion (frames read as states, not slides) (REST/CLI) plus Motion animations (MCP) and shaders (MCP source / native export) into a composition. | diff --git a/docs/contributing/catalog.mdx b/docs/contributing/catalog.mdx index a630a41e23..be61d7f571 100644 --- a/docs/contributing/catalog.mdx +++ b/docs/contributing/catalog.mdx @@ -141,7 +141,7 @@ Not everything belongs in the registry. The bar is production quality. ```bash hyperframes lint - hyperframes validate + hyperframes check npx oxfmt your-block.html ``` diff --git a/docs/guides/pipeline.mdx b/docs/guides/pipeline.mdx index c701ddb7ac..8298adf986 100644 --- a/docs/guides/pipeline.mdx +++ b/docs/guides/pipeline.mdx @@ -165,7 +165,7 @@ Three checks before delivery: ```bash npx hyperframes lint # static HTML structure checks -npx hyperframes validate # loads in headless Chrome, catches runtime errors +npx hyperframes check # one browser session: runtime errors, layout, motion, contrast npx hyperframes snapshot my-video --at 2.9,10.4 # PNGs at beat midpoints ``` diff --git a/docs/guides/prompting.mdx b/docs/guides/prompting.mdx index 49907ccc6a..fe78e69047 100644 --- a/docs/guides/prompting.mdx +++ b/docs/guides/prompting.mdx @@ -268,7 +268,7 @@ Things that cause friction (or wrong output): - **Don't ask for React / Vue components.** Hyperframes compositions are plain HTML with `data-*` attributes and a GSAP timeline. Asking for "a React component for the intro" forces the agent to translate later. - **Don't ask for 4K or 60fps unless you need it.** Defaults (1920×1080, 30fps) render fast and look great. Higher specs slow rendering meaningfully. - **Don't skip the slash command.** Without `/hyperframes`, the agent may guess at HTML video conventions instead of using the framework's actual rules (`class="clip"` on timed elements, `window.__timelines` registration, etc.). -- **Don't paste long error logs into the prompt without context.** Run `npx hyperframes lint` and `npx hyperframes validate` first — lint catches structural issues, validate catches runtime errors (JS exceptions, missing assets, contrast problems). +- **Don't paste long error logs into the prompt without context.** Run `npx hyperframes check` first — lint catches structural issues, validate catches runtime errors (JS exceptions, missing assets, contrast problems). - **Don't assume the agent knows your assets.** Mention file paths explicitly (`assets/intro.mp4`, `assets/logo.png`) — the agent will check what's there but a hint speeds it up. ## Recommended workflow diff --git a/docs/guides/skills.mdx b/docs/guides/skills.mdx index 9579f7df62..254d85aea3 100644 --- a/docs/guides/skills.mdx +++ b/docs/guides/skills.mdx @@ -99,7 +99,7 @@ Atomic capabilities the creation workflows compose against — pull one when you | `/hyperframes-keyframes` | Seek-safe keyframe authoring across runtimes — GSAP timelines, CSS keyframes, Anime.js, WAAPI, FLIP, paths, masks, SVG morph/draw, 3D depth — plus `hyperframes keyframes` diagnostics for rendered motion. | | `/hyperframes-creative` | Non-animation creative direction — `frame.md` / `design.md`, palettes, typography, narration, beat planning, audio-reactive visuals, composition patterns. | | `/media-use` | The media OS — resolve any media need (BGM, SFX, image, icon, logo, voice, color grade, LUT) into a frozen local file or paste-ready block + ledger record, generate via TTS/music/image models when the catalog misses, transcribe, caption, remove backgrounds, and reuse assets across projects. One shared audio engine + manifest tracking. | -| `/hyperframes-cli` | CLI dev loop — `init`, `lint`, `validate`, `inspect`, `preview`, `render`, `publish`, `doctor`, plus AWS Lambda cloud rendering (`lambda deploy / render / progress / destroy / policies`). | +| `/hyperframes-cli` | CLI dev loop — `init`, `lint`, `check`, `snapshot`, `preview`, `render`, `publish`, `doctor`, plus AWS Lambda cloud rendering (`lambda deploy / render / progress / destroy / policies`). | | `/hyperframes-registry` | Install and wire registry blocks and components into compositions via `hyperframes add`. Authoring a new block or component to contribute upstream. | | `/figma` | Import Figma assets, tokens, components, and storyboard sections → reconstructed motion (frames read as states, not slides) (REST/CLI) plus Motion animations (MCP) and shaders (MCP source / native export) into a composition. | diff --git a/docs/guides/video-editor-cheatsheet.mdx b/docs/guides/video-editor-cheatsheet.mdx index e9216f6bf9..16e3431ab6 100644 --- a/docs/guides/video-editor-cheatsheet.mdx +++ b/docs/guides/video-editor-cheatsheet.mdx @@ -27,7 +27,7 @@ Before showing or rendering a project: ```bash npx hyperframes lint -npx hyperframes validate +npx hyperframes check npx hyperframes render --quality standard --output review.mp4 ``` @@ -174,8 +174,8 @@ I moved the hero title and resized the CTA manually in Studio. Inspect the chang | `npx hyperframes capture https://example.com` | Capture a website as source material for a video | | `npx hyperframes preview` | Open the live Studio preview | | `npx hyperframes lint` | Catch structural mistakes before preview or render | -| `npx hyperframes validate` | Run the composition in headless Chrome to catch runtime errors | -| `npx hyperframes inspect` | Find text overflow and layout problems across the timeline | +| `npx hyperframes check` | Run the composition in headless Chrome to catch runtime errors | +| `npx hyperframes check` | Find text overflow and layout problems across the timeline | | `npx hyperframes snapshot --at 1,3,5` | Save PNG checks at exact timestamps | | `npx hyperframes render --output final.mp4` | Render the video | | `npx hyperframes publish` | Upload the project and get a shareable HyperFrames URL | @@ -278,9 +278,9 @@ For editor-facing changes, keep `npx hyperframes preview` running, then have the | --- | --- | | Preview will not start | `npx hyperframes doctor` | | Port already in use | `npx hyperframes preview --port 4567` | -| Render fails | `npx hyperframes lint` then `npx hyperframes validate` | +| Render fails | `npx hyperframes lint` then `npx hyperframes check` | | Need exact frame checks | `npx hyperframes snapshot --at 1,2.5,5` | -| Text overflows in the frame | `npx hyperframes inspect` | +| Text overflows in the frame | `npx hyperframes check` | | Final render is too slow | Try `--quality draft`, reduce image sizes, or lower `--fps` | | Need to share editable project | `npx hyperframes publish` | diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index bd48396259..91094b0ed2 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -520,6 +520,34 @@ Word-level transcripts (whisper output) are grouped into readable caption cues o The linter detects missing attributes, missing adapter libraries (GSAP, Lottie, Three.js), structural problems, and more. See [Common Mistakes](/guides/common-mistakes) for details on each rule. + ### `check` + + The browser verification gate: everything the old `validate` → `inspect` → `snapshot` loop did, in **one** command with one browser session: + + ```bash + npx hyperframes check [dir] + npx hyperframes check [dir] --json # {ok, lint, runtime, layout, motion, contrast, snapshots} + npx hyperframes check [dir] --snapshots # annotated overview frames + per-finding crops + npx hyperframes check [dir] --at 1.5,4,7.25 + npx hyperframes check [dir] --strict # exit non-zero on warnings too + ``` + + `check` runs the linter first (browser skipped entirely on lint errors), then loads the bundled composition once and sweeps one seek grid running every audit per sample: runtime console errors and failed requests, layout defects (overflow, clipping, held overlaps, occlusion), `*.motion.json` sidecar assertions, and WCAG AA contrast. + + | Flag | Description | + |------|-------------| + | `--json` | Aggregated machine-readable envelope; every finding carries selector, `data-*` identity, source file, bbox, and sample time | + | `--snapshots` | Write overview frames (annotated with labeled finding boxes when there are errors) plus `finding-NN-.png` crops | + | `--samples` / `--at` / `--at-transitions` | Control the seek grid (default 9 samples; `--at-transitions` adds tween boundaries) | + | `--tolerance` | Allowed overflow in px before reporting (default 2) | + | `--timeout` | Initial settle budget in ms (default 3000) | + | `--no-contrast` | Skip the WCAG audit while iterating | + | `--strict` | Exit non-zero on warnings too (default: only errors) | + | `--caption-zone ""` | Opt-in band gate: flags content whose center sits inside the fractional band (optional `severity`, `seek`) | + | `--frame-check` | Opt-in media out-of-frame detection (img/svg/video/canvas) | + + Contrast failures are **errors** and include the sampled fg/bg colors, measured vs required ratio, and a suggested compliant color. Severity is persistence-aware: single-sample transients demote to info, held findings gate the exit code, and a frozen timeline on a 3s+ composition fails with `sweep_static`. + ### `beats` Detect the beats in a composition's music track and write them to a beat file the Studio uses to draw beat guides on the timeline: @@ -549,6 +577,8 @@ Word-level transcripts (whisper output) are grouped into readable caption cues o ### `inspect` + Deprecated: use [`check`](#check) — it covers this layout sweep plus runtime, motion, and contrast in one browser session. `inspect` keeps working and marks `_meta.deprecated: true` in JSON output. + Inspect rendered visual layout across the composition timeline: ```bash diff --git a/packages/cli/src/capture/captureCompositionFrame.test.ts b/packages/cli/src/capture/captureCompositionFrame.test.ts index cf942565c9..c2e83396cd 100644 --- a/packages/cli/src/capture/captureCompositionFrame.test.ts +++ b/packages/cli/src/capture/captureCompositionFrame.test.ts @@ -1,13 +1,316 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { runFfmpegOnce } from "./captureCompositionFrame.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + captureRegionCrop, + clampCropRegion, + installPageFunctionGuard, + padCropRegion, + parseZoomTarget, + resolveCliChromeGpuMode, + resolveCropRegion, + runFfmpegOnce, + seekCompositionTimeline, + type CompositionSeekPage, +} from "./captureCompositionFrame.js"; function tempDir(): string { return mkdtempSync(join(tmpdir(), "hf-capture-frame-test-")); } +function fakeSeekPage() { + const evaluate = vi.fn( + async ( + _pageFunction: Parameters[0], + _value?: number, + _fallbackToBridgeAndTimelines?: boolean, + ): Promise => undefined, + ); + const waitForFunction = vi.fn( + async (_pageFunction: () => boolean, _options: { timeout: number }): Promise => + undefined, + ); + const page: CompositionSeekPage = { evaluate, waitForFunction }; + return { page, evaluate, waitForFunction }; +} + +function runBrowserSeek(evaluate: ReturnType["evaluate"]): void { + const seekInBrowser = evaluate.mock.calls[0]?.[0]; + if (typeof seekInBrowser !== "function") throw new Error("Expected a browser seek function"); + Reflect.apply(seekInBrowser, undefined, evaluate.mock.calls[0]?.slice(1) ?? []); +} + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("seekCompositionTimeline", () => { + it("keeps the existing raced double-frame settle as the default", async () => { + const { page, evaluate, waitForFunction } = fakeSeekPage(); + + await seekCompositionTimeline(page, 1.25); + + expect(waitForFunction).not.toHaveBeenCalled(); + expect(evaluate).toHaveBeenCalledTimes(2); + expect(evaluate).toHaveBeenNthCalledWith(1, expect.any(Function), 1.25, false); + expect(evaluate.mock.calls[1]?.[0]).toContain("window.setTimeout(finish, 100)"); + }); + + it("prefers renderSeek so the runtime synchronizes clip visibility", async () => { + const { page, evaluate } = fakeSeekPage(); + const renderSeek = vi.fn(); + const bridgeSeek = vi.fn(); + const playerSeek = vi.fn(); + const timelineSeek = vi.fn(); + vi.stubGlobal("window", { + __player: { renderSeek, seek: playerSeek }, + __hf: { seek: bridgeSeek }, + __timelines: { main: { seek: timelineSeek } }, + }); + + await seekCompositionTimeline(page, 2.25); + runBrowserSeek(evaluate); + + expect(renderSeek).toHaveBeenCalledWith(2.25); + expect(bridgeSeek).not.toHaveBeenCalled(); + expect(playerSeek).not.toHaveBeenCalled(); + expect(timelineSeek).not.toHaveBeenCalled(); + }); + + function fakeBridgeOnlySeekPage() { + const { page, evaluate } = fakeSeekPage(); + const bridgeSeek = vi.fn(); + const tickerTick = vi.fn(); + vi.stubGlobal("window", { __hf: { seek: bridgeSeek }, gsap: { ticker: { tick: tickerTick } } }); + return { page, evaluate, bridgeSeek, tickerTick }; + } + + it("keeps bridge and raw fallbacks disabled for default capture callers", async () => { + const { page, evaluate, bridgeSeek, tickerTick } = fakeBridgeOnlySeekPage(); + + await seekCompositionTimeline(page, 2.5); + runBrowserSeek(evaluate); + + expect(bridgeSeek).not.toHaveBeenCalled(); + expect(tickerTick).not.toHaveBeenCalled(); + }); + + it("opts into the bridge before player and raw timeline fallbacks", async () => { + const { page, evaluate, bridgeSeek, tickerTick } = fakeBridgeOnlySeekPage(); + + await seekCompositionTimeline(page, 2.5, { fallbackToBridgeAndTimelines: true }); + runBrowserSeek(evaluate); + + expect(bridgeSeek).toHaveBeenCalledWith(2.5); + expect(tickerTick).toHaveBeenCalledOnce(); + }); + + it("opts into pausing and seeking raw timelines when no preferred target exists", async () => { + const { page, evaluate } = fakeSeekPage(); + const pause = vi.fn(); + const seek = vi.fn(); + vi.stubGlobal("window", { __timelines: { main: { pause, seek } } }); + + await seekCompositionTimeline(page, 1.75, { fallbackToBridgeAndTimelines: true }); + runBrowserSeek(evaluate); + + expect(pause).toHaveBeenCalledOnce(); + expect(seek).toHaveBeenCalledWith(1.75); + }); + + it("supports validate settling without adding an animation-frame or font wait", async () => { + vi.useFakeTimers(); + const { page, evaluate, waitForFunction } = fakeSeekPage(); + + const pending = seekCompositionTimeline(page, 3, { + fallbackToBridgeAndTimelines: true, + waitForPreferredSeekTargetMs: 500, + animationFrameSettle: "none", + settleMs: 150, + }); + await vi.advanceTimersByTimeAsync(150); + await pending; + + expect(waitForFunction).toHaveBeenCalledWith(expect.any(Function), { timeout: 500 }); + expect(evaluate).toHaveBeenCalledTimes(1); + expect(evaluate).toHaveBeenCalledWith(expect.any(Function), 3, true); + }); + + it("supports layout's ordered double-frame, bounded font, and sleep settles", async () => { + vi.useFakeTimers(); + const { page, evaluate } = fakeSeekPage(); + + const pending = seekCompositionTimeline(page, 4, { + fallbackToBridgeAndTimelines: true, + animationFrameSettle: "double", + waitForFontsMs: 500, + settleMs: 120, + }); + await vi.advanceTimersByTimeAsync(120); + await pending; + + expect(evaluate).toHaveBeenCalledTimes(3); + expect(evaluate).toHaveBeenNthCalledWith(1, expect.any(Function), 4, true); + expect(evaluate).toHaveBeenNthCalledWith(2, expect.any(Function)); + expect(evaluate).toHaveBeenNthCalledWith(3, expect.any(Function), 500); + }); +}); + +describe("resolveCliChromeGpuMode", () => { + it("preserves validate's software-only opt-in mapping", () => { + expect(resolveCliChromeGpuMode("software")).toBe("software"); + expect(resolveCliChromeGpuMode("hardware")).toBe("hardware"); + expect(resolveCliChromeGpuMode("auto")).toBe("hardware"); + expect(resolveCliChromeGpuMode("")).toBe("hardware"); + }); +}); + +describe("screenshot Chrome arguments", () => { + it("leaves shared capture and layout on the engine's software default", () => { + const defaultScreenshotArgs = + /args:\s*buildChromeArgs\(\s*\{[^}]*captureMode:\s*"screenshot"[^}]*\}\s*\),/; + const captureSource = readFileSync( + new URL("./captureCompositionFrame.ts", import.meta.url), + "utf8", + ); + const layoutSource = readFileSync(new URL("../commands/layout.ts", import.meta.url), "utf8"); + + // openSettledCompositionPage threads the caller's optional browserGpuMode; + // callers that omit it (snapshot, compare) fall through to the engine's + // software default for screenshot capture. + expect(captureSource).toMatch( + /args:\s*buildChromeArgs\(\s*\{[^}]*captureMode:\s*"screenshot"[^}]*\},\s*\{\s*browserGpuMode:\s*options\.browserGpuMode\s*\},?\s*\),/, + ); + expect(layoutSource).toMatch(defaultScreenshotArgs); + }); +}); + +describe("parseZoomTarget", () => { + it("parses four comma-separated numbers as an exact region", () => { + expect(parseZoomTarget("100,50,400,300")).toEqual({ + kind: "region", + region: { x: 100, y: 50, width: 400, height: 300 }, + }); + }); + + it("treats anything else as a CSS selector", () => { + expect(parseZoomTarget("#headline")).toEqual({ kind: "selector", selector: "#headline" }); + expect(parseZoomTarget(".card:nth-of-type(2)")).toEqual({ + kind: "selector", + selector: ".card:nth-of-type(2)", + }); + }); +}); + +describe("clampCropRegion / padCropRegion", () => { + it("clamps a region to the canvas bounds", () => { + expect( + clampCropRegion({ x: -10, y: -10, width: 50, height: 50 }, { width: 30, height: 30 }), + ).toEqual({ + x: 0, + y: 0, + width: 30, + height: 30, + }); + }); + + it("pads a region on every side when it fits within the canvas", () => { + expect( + padCropRegion({ x: 500, y: 500, width: 100, height: 40 }, { width: 1920, height: 1080 }, 24), + ).toEqual({ x: 476, y: 476, width: 148, height: 88 }); + }); + + it("pads then clamps when padding would spill outside the canvas", () => { + expect( + padCropRegion({ x: 10, y: 10, width: 20, height: 20 }, { width: 200, height: 200 }, 24), + ).toEqual({ x: 0, y: 0, width: 54, height: 54 }); + }); +}); + +describe("resolveCropRegion", () => { + it("resolves a selector to its bbox, padded 24px and clamped", async () => { + const page = { evaluate: vi.fn(async () => ({ x: 500, y: 500, width: 100, height: 40 })) }; + + const region = await resolveCropRegion( + page, + { kind: "selector", selector: "#headline" }, + { width: 1920, height: 1080 }, + ); + + expect(region).toEqual({ x: 476, y: 476, width: 148, height: 88 }); + }); + + it("crops a region exactly, without padding, when it already fits the canvas", async () => { + const page = { evaluate: vi.fn() }; + + const region = await resolveCropRegion( + page, + { kind: "region", region: { x: 100, y: 50, width: 400, height: 300 } }, + { width: 1920, height: 1080 }, + ); + + expect(region).toEqual({ x: 100, y: 50, width: 400, height: 300 }); + expect(page.evaluate).not.toHaveBeenCalled(); + }); + + it("throws a clear, loud error when the selector matches nothing", async () => { + const page = { evaluate: vi.fn(async () => null) }; + + await expect( + resolveCropRegion( + page, + { kind: "selector", selector: "#missing" }, + { width: 640, height: 360 }, + ), + ).rejects.toThrow("--zoom selector matched no element: #missing"); + }); + + it("returns null when the clamped region is a sliver (element animated off-canvas)", async () => { + // Element slid past the right edge: raw bbox is large, but clamping the + // padded region to the canvas leaves ~1px — a useless crop. + const page = { evaluate: vi.fn(async () => ({ x: 2500, y: 400, width: 600, height: 250 })) }; + + const region = await resolveCropRegion( + page, + { kind: "selector", selector: "#gone-by-now" }, + { width: 1920, height: 1080 }, + ); + + expect(region).toBeNull(); + }); +}); + +describe("captureRegionCrop", () => { + it("raises deviceScaleFactor for the clip shot, then restores the original viewport", async () => { + const original = { width: 1920, height: 1080, deviceScaleFactor: 1 }; + const setViewport = vi.fn(async () => undefined); + const screenshot = vi.fn(async () => new Uint8Array([1, 2, 3])); + const page = { viewport: () => original, setViewport, screenshot }; + const region = { x: 10, y: 20, width: 100, height: 50 }; + + const buffer = await captureRegionCrop(page, region, 3); + + expect(setViewport).toHaveBeenNthCalledWith(1, { ...original, deviceScaleFactor: 3 }); + expect(screenshot).toHaveBeenCalledWith({ clip: region, type: "png" }); + expect(setViewport).toHaveBeenNthCalledWith(2, original); + expect(buffer).toBeInstanceOf(Buffer); + expect(Array.from(buffer)).toEqual([1, 2, 3]); + }); + + it("honors an explicit scale other than the default", async () => { + const original = { width: 800, height: 600 }; + const setViewport = vi.fn(async () => undefined); + const screenshot = vi.fn(async () => new Uint8Array()); + const page = { viewport: () => original, setViewport, screenshot }; + + await captureRegionCrop(page, { x: 0, y: 0, width: 10, height: 10 }, 2); + + expect(setViewport).toHaveBeenNthCalledWith(1, { ...original, deviceScaleFactor: 2 }); + }); +}); + describe("runFfmpegOnce", () => { it("returns the process exit code and collected stderr", async () => { const dir = tempDir(); @@ -37,3 +340,21 @@ describe("runFfmpegOnce", () => { } }); }); + +describe("installPageFunctionGuard", () => { + it("defines the keepNames __name shim in the page before any script runs", async () => { + const evaluateOnNewDocument = vi.fn(async (_source: string) => undefined); + + await installPageFunctionGuard({ evaluateOnNewDocument }); + + expect(evaluateOnNewDocument).toHaveBeenCalledOnce(); + const source = evaluateOnNewDocument.mock.calls[0]?.[0] ?? ""; + expect(source).toContain("self.__name"); + // The shim must be a no-op passthrough so wrapped functions stay callable. + const shim = new Function(`const self = {}; ${source}; return self.__name;`)() as ( + fn: unknown, + ) => unknown; + const marker = () => 42; + expect(shim(marker)).toBe(marker); + }); +}); diff --git a/packages/cli/src/capture/captureCompositionFrame.ts b/packages/cli/src/capture/captureCompositionFrame.ts index fbc15331a5..2b0d164094 100644 --- a/packages/cli/src/capture/captureCompositionFrame.ts +++ b/packages/cli/src/capture/captureCompositionFrame.ts @@ -3,17 +3,44 @@ import type { Browser, Page } from "puppeteer-core"; import { c } from "../ui/colors.js"; import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js"; -const CHROME_LAUNCH_ARGS = [ - "--no-sandbox", - "--disable-gpu", - "--disable-dev-shm-usage", - "--enable-webgl", - "--use-gl=angle", - "--use-angle=swiftshader", -]; - const SHADER_TRANSITIONS_TIMEOUT_MS = 90_000; const CAPTURE_SETTLE_MS = 1500; +const PREFERRED_SEEK_TARGET_WAIT_MS = 500; + +// The audit-grade seek tuning shared by check and the deprecated inspect/layout: +// bridge+timeline fallback, ordered double-rAF settle, bounded font wait, sleep. +export const AUDIT_SEEK_OPTIONS = { + fallbackToBridgeAndTimelines: true, + animationFrameSettle: "double", + waitForFontsMs: 500, + settleMs: 120, +} as const; + +export interface SeekCompositionTimelineOptions { + fallbackToBridgeAndTimelines?: boolean; + waitForPreferredSeekTargetMs?: number; + animationFrameSettle?: "race" | "double" | "none"; + waitForFontsMs?: number; + settleMs?: number; +} + +type CompositionPageFunction = + | string + | (() => unknown) + | ((value: number) => unknown) + | ((value: number, fallbackToBridgeAndTimelines: boolean) => unknown); + +export interface CompositionEvaluationPage { + evaluate( + pageFunction: CompositionPageFunction, + value?: number, + fallbackToBridgeAndTimelines?: boolean, + ): Promise; +} + +export interface CompositionSeekPage extends CompositionEvaluationPage { + waitForFunction?(pageFunction: () => boolean, options: { timeout: number }): Promise; +} export interface SettledCompositionPage { browser: Browser; @@ -26,6 +53,12 @@ export interface SettledCompositionPage { export interface OpenSettledCompositionPageOptions { renderReadyTimeoutMs: number; renderReadyWarningSuffix: string; + // Screenshot paths take the engine's software-GPU default; validate/check + // thread the PRODUCER_BROWSER_GPU_MODE opt-in through here. + browserGpuMode?: "software" | "hardware"; + // Runs after the page exists but before page.goto, so console/pageerror/ + // request listeners can attach without missing load-time events. + beforeNavigate?: (page: Page) => void | Promise; } export interface FfmpegRunResult { @@ -34,6 +67,12 @@ export interface FfmpegRunResult { timedOut: boolean; } +export function resolveCliChromeGpuMode( + envMode = process.env.PRODUCER_BROWSER_GPU_MODE, +): "software" | "hardware" { + return envMode === "software" ? "software" : "hardware"; +} + function compositionRuntimeReadyInBrowser(): boolean { return Boolean(Reflect.get(window, "__renderReady")); } @@ -92,25 +131,45 @@ async function waitForCompositionSettle( return runtimeReady; } +// tsx/esbuild-style dev transpilers run with keepNames, which rewrites named +// inner functions in serialized page closures into __name(...) calls — a +// helper that exists in the Node bundle but not in the browser realm, so any +// page.evaluate/waitForFunction whose closure defines a named function throws +// "__name is not defined" when the CLI runs from source. Defining a no-op in +// the page before any script runs immunizes every serialized closure, current +// and future, regardless of how the CLI was built. +export async function installPageFunctionGuard(page: { + evaluateOnNewDocument(source: string): Promise; +}): Promise { + await page.evaluateOnNewDocument("self.__name = self.__name || ((fn) => fn);"); +} + export async function openSettledCompositionPage( html: string, url: string, options: OpenSettledCompositionPageOptions, ): Promise { + const viewport = resolveCompositionViewportFromHtml(html); const { ensureBrowser } = await import("../browser/manager.js"); const browser = await ensureBrowser(); const puppeteer = await import("puppeteer-core"); + const { buildChromeArgs } = await import("@hyperframes/engine"); let chromeBrowser: Browser | undefined; try { chromeBrowser = await puppeteer.default.launch({ headless: true, executablePath: browser.executablePath, - args: CHROME_LAUNCH_ARGS, + args: buildChromeArgs( + { ...viewport, captureMode: "screenshot" }, + { browserGpuMode: options.browserGpuMode }, + ), }); const page = await chromeBrowser.newPage(); - await page.setViewport(resolveCompositionViewportFromHtml(html)); + await installPageFunctionGuard(page); + await page.setViewport(viewport); + await options.beforeNavigate?.(page); await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 }); const renderReadyTimedOut = !(await waitForCompositionSettle(page, options)); return { browser: chromeBrowser, page, renderReadyTimedOut }; @@ -121,29 +180,271 @@ export async function openSettledCompositionPage( } export async function seekCompositionTimeline( - page: Pick, + page: CompositionSeekPage, timeSeconds: number, + options: SeekCompositionTimelineOptions = {}, ): Promise { - await page.evaluate((t: number) => { - const player = (window as any).__player; - if (!player) return; - const safe = Math.max(0, Number(t) || 0); - if (typeof player.renderSeek === "function") { - player.renderSeek(safe); - } else if (typeof player.seek === "function") { - player.seek(safe); - } - if ((window as any).gsap?.ticker?.tick) { - (window as any).gsap.ticker.tick(); - } - }, timeSeconds); - - 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); }); - })`); + if (options.waitForPreferredSeekTargetMs !== undefined) { + await waitForPreferredSeekTarget(page, options.waitForPreferredSeekTargetMs); + } + + await page.evaluate( + // Serialized into the page; the seek-target cascade must stay one function. + // fallow-ignore-next-line complexity + (t: number, fallbackToBridgeAndTimelines: boolean) => { + const getProperty = (target: unknown, key: string): unknown => { + if ((typeof target !== "object" || target === null) && typeof target !== "function") { + return undefined; + } + return Reflect.get(target, key); + }; + const call = (fn: unknown, receiver: unknown, args: unknown[]): boolean => { + if (typeof fn !== "function") return false; + Reflect.apply(fn, receiver, args); + return true; + }; + + const player = Reflect.get(window, "__player"); + if (!player && !fallbackToBridgeAndTimelines) return; + + const safe = Math.max(0, Number(t) || 0); + const renderSeek = getProperty(player, "renderSeek"); + const playerSeek = getProperty(player, "seek"); + const hf = Reflect.get(window, "__hf"); + const bridgeSeek = getProperty(hf, "seek"); + + // Prefer renderSeek because it also runs the runtime's data-start/data-duration + // visibility sync; raw timeline seeks leave off-window clips visible to audits. + if (call(renderSeek, player, [safe])) { + // Preferred runtime target handled the seek. + } else if (fallbackToBridgeAndTimelines && call(bridgeSeek, hf, [safe])) { + // Producer bridge handled the seek. + } else if (call(playerSeek, player, [safe])) { + // Legacy player target handled the seek. + } else if (fallbackToBridgeAndTimelines) { + const timelines = Reflect.get(window, "__timelines"); + if (typeof timelines === "object" && timelines !== null) { + for (const key of Object.keys(timelines)) { + const timeline = Reflect.get(timelines, key); + call(getProperty(timeline, "pause"), timeline, []); + call(getProperty(timeline, "seek"), timeline, [safe]); + } + } + } + + const gsap = Reflect.get(window, "gsap"); + const ticker = getProperty(gsap, "ticker"); + call(getProperty(ticker, "tick"), ticker, []); + }, + timeSeconds, + options.fallbackToBridgeAndTimelines === true, + ); + + const animationFrameSettle = options.animationFrameSettle ?? "race"; + if (animationFrameSettle === "race") { + await page.evaluate(`new Promise(function(r) { + var settled = false; + function finish() { if (settled) return; settled = true; r(); } + window.setTimeout(finish, 100); + requestAnimationFrame(function() { requestAnimationFrame(finish); }); + })`); + } else if (animationFrameSettle === "double") { + await page.evaluate( + () => + new Promise((resolveFrame) => + requestAnimationFrame(() => requestAnimationFrame(() => resolveFrame())), + ), + ); + } + + if (options.waitForFontsMs !== undefined) { + await waitForCompositionFonts(page, options.waitForFontsMs); + } + if (options.settleMs !== undefined) { + const settleMs = Math.max(0, options.settleMs); + await new Promise((resolveSettle) => setTimeout(resolveSettle, settleMs)); + } +} + +export async function waitForPreferredSeekTarget( + page: Pick, + timeoutMs = PREFERRED_SEEK_TARGET_WAIT_MS, +): Promise { + if (!page.waitForFunction) return; + try { + await page.waitForFunction( + () => { + const player = Reflect.get(window, "__player"); + const hf = Reflect.get(window, "__hf"); + const renderSeek = + typeof player === "object" && player !== null + ? Reflect.get(player, "renderSeek") + : undefined; + const bridgeSeek = + typeof hf === "object" && hf !== null ? Reflect.get(hf, "seek") : undefined; + return typeof renderSeek === "function" || typeof bridgeSeek === "function"; + }, + { timeout: timeoutMs }, + ); + } catch { + // Legacy/static pages may only expose raw timelines; keep that fallback available. + } +} + +export async function waitForCompositionFonts( + page: CompositionEvaluationPage, + timeoutMs: number, +): Promise { + await page + .evaluate((ms: number) => { + const fonts = Reflect.get(document, "fonts"); + if (typeof fonts !== "object" || fonts === null) return Promise.resolve(); + const ready = Reflect.get(fonts, "ready"); + if (!ready) return Promise.resolve(); + return Promise.race([ + Promise.resolve(ready).then(() => undefined), + new Promise((resolve) => setTimeout(resolve, ms)), + ]); + }, timeoutMs) + .catch(() => {}); +} + +export interface CropRegion { + x: number; + y: number; + width: number; + height: number; +} + +export interface CropCanvas { + width: number; + height: number; +} + +export type ZoomTarget = + | { kind: "selector"; selector: string } + | { kind: "region"; region: CropRegion }; + +// Four bare comma-separated numbers is unambiguous — no valid CSS selector +// parses as that shape — so it always means "exact pixel region". +const ZOOM_REGION_PATTERN = /^-?\d+(?:\.\d+)?(?:,-?\d+(?:\.\d+)?){3}$/; +export const DEFAULT_ZOOM_PADDING_PX = 24; +// One knob for every zoom/crop consumer (snapshot --zoom-scale default, check's +// finding crops): density of the captured pixels relative to CSS pixels. +export const DEFAULT_ZOOM_SCALE = 3; + +/** Parse `snapshot --zoom` into either a CSS selector or an exact pixel region "x,y,w,h". */ +export function parseZoomTarget(value: string): ZoomTarget { + const trimmed = value.trim(); + if (ZOOM_REGION_PATTERN.test(trimmed)) { + const [x, y, width, height] = trimmed.split(",").map(Number) as [ + number, + number, + number, + number, + ]; + return { kind: "region", region: { x, y, width, height } }; + } + return { kind: "selector", selector: trimmed }; +} + +/** Clamp a region to the canvas bounds — Puppeteer's clip screenshot rejects a + * region that spills outside the viewport. Keeps at least 1px on each side. */ +export function clampCropRegion(region: CropRegion, canvas: CropCanvas): CropRegion { + const x = Math.max(0, Math.min(region.x, canvas.width)); + const y = Math.max(0, Math.min(region.y, canvas.height)); + const x2 = Math.max(x + 1, Math.min(region.x + region.width, canvas.width)); + const y2 = Math.max(y + 1, Math.min(region.y + region.height, canvas.height)); + return { x, y, width: x2 - x, height: y2 - y }; +} + +/** Pad a region on every side (context around a zoomed element), then clamp. */ +export function padCropRegion( + region: CropRegion, + canvas: CropCanvas, + paddingPx: number, +): CropRegion { + return clampCropRegion( + { + x: region.x - paddingPx, + y: region.y - paddingPx, + width: region.width + paddingPx * 2, + height: region.height + paddingPx * 2, + }, + canvas, + ); +} + +export interface ZoomSelectorPage { + evaluate( + pageFunction: (selector: string) => CropRegion | null, + selector: string, + ): Promise; +} + +/** + * Resolve a `--zoom` target to a concrete crop region. A selector resolves to + * its live bbox (padded ~24px, then clamped); an explicit region is used + * as-is (clamped only, never padded — region form crops exactly). A selector + * matching nothing throws: a loud error beats a silent full-frame fallback. + */ +// A selector can match an element whose visible box is gone at the sampled +// time — collapsed (display:none mid-timeline) or animated off-canvas, where +// clamping leaves a pixel-wide remnant. Either way the crop would be a sliver +// that tells an agent nothing, so the final clamped region is what's guarded +// and callers skip the frame on null. Explicit x,y,w,h regions stay literal. +const MIN_CROP_REGION_PX = 8; + +export async function resolveCropRegion( + page: ZoomSelectorPage, + target: ZoomTarget, + canvas: CropCanvas, + paddingPx = DEFAULT_ZOOM_PADDING_PX, +): Promise { + if (target.kind === "region") return clampCropRegion(target.region, canvas); + const bbox = await page.evaluate((selector) => { + const element = document.querySelector(selector); + if (!element) return null; + const rect = element.getBoundingClientRect(); + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; + }, target.selector); + if (!bbox) throw new Error(`--zoom selector matched no element: ${target.selector}`); + const region = padCropRegion(bbox, canvas, paddingPx); + if (region.width < MIN_CROP_REGION_PX || region.height < MIN_CROP_REGION_PX) return null; + return region; +} + +export interface CropCapturePage { + viewport(): { width: number; height: number; deviceScaleFactor?: number } | null; + setViewport(viewport: { + width: number; + height: number; + deviceScaleFactor?: number; + }): Promise; + screenshot(options: { clip: CropRegion; type: "png" }): Promise; +} + +/** + * Capture a high-density crop of `region`: raise `deviceScaleFactor` to + * `scale`, take a clip screenshot, then restore the original viewport. + * Deliberately NOT CSS zoom or a viewport resize — DSF only changes how + * densely Chrome rasterizes the existing CSS-pixel layout, so the + * composition's layout (and its render determinism) is untouched. The PNG + * comes out at `region.width * scale` real device pixels, not an upscale. + */ +export async function captureRegionCrop( + page: CropCapturePage, + region: CropRegion, + scale: number, +): Promise { + const original = page.viewport(); + if (original) await page.setViewport({ ...original, deviceScaleFactor: scale }); + try { + const shot = await page.screenshot({ clip: region, type: "png" }); + return Buffer.isBuffer(shot) ? shot : Buffer.from(shot); + } finally { + if (original) await page.setViewport(original); + } } export async function runFfmpegOnce( diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index b98ad6d445..69c7281d74 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -101,6 +101,7 @@ try { import { defineCommand, runMain } from "citty"; import type { ArgsDef, CommandDef } from "citty"; +import { getRunId } from "./telemetry/runId.js"; import { reportCommandFailure, trackCommandFailures } from "./utils/command-failure-tracking.js"; const isHelp = process.argv.includes("--help") || process.argv.includes("-h"); @@ -119,6 +120,7 @@ const commandLoaders = { publish: () => import("./commands/publish.js").then((m) => m.default), render: () => import("./commands/render.js").then((m) => m.default), lint: () => import("./commands/lint.js").then((m) => m.default), + check: () => import("./commands/check.js").then((m) => m.default), beats: () => import("./commands/beats.js").then((m) => m.default), inspect: () => import("./commands/inspect.js").then((m) => m.default), keyframes: () => import("./commands/keyframes.js").then((m) => m.default), @@ -195,7 +197,13 @@ let _trackCliError: }) => void) | undefined; let _trackCommandResult: - | ((props: { command: string; success: boolean; exitCode: number; durationMs: number }) => void) + | ((props: { + command: string; + success: boolean; + exitCode: number; + durationMs: number; + runId?: string; + }) => void) | undefined; let _printUpdateNotice: (() => void) | undefined; let _printSkillsUpdateNotice: (() => void) | undefined; @@ -210,7 +218,7 @@ 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(); }); } @@ -241,6 +249,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 +273,7 @@ process.on("exit", (code) => { success: code === 0 && !commandFailed, exitCode: code, durationMs: Date.now() - commandStart, + runId, }); _flushSync?.(); }); diff --git a/packages/cli/src/commands/check.test.ts b/packages/cli/src/commands/check.test.ts new file mode 100644 index 0000000000..06af01eb14 --- /dev/null +++ b/packages/cli/src/commands/check.test.ts @@ -0,0 +1,1331 @@ +import { runCommand } from "citty"; +import { readFileSync } from "node:fs"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const trackCheckReport = vi.fn(); +vi.mock("../telemetry/events.js", () => ({ + trackCheckReport: (...args: unknown[]) => trackCheckReport(...args), + trackCommandFailure: vi.fn(), +})); + +import { contrastRatio, parseColorRGBA } from "./contrast-bg.js"; +import { createCheckCommand } from "./check.js"; +import { + DEFAULT_CHECK_OPTIONS, + checkExitCode, + findingCropFilename, + runAuditGrid, + runCheckPipeline, + selectContrastTimes, + selectFindingCropRequests, + type AnchoredLayoutIssue, + type CheckAnchor, + type CheckAuditDriver, + type CheckBrowserResult, + type CheckDependencies, + type CheckFinding, + type CheckFindingCropRequest, + type CheckOptions, + type CheckReport, + type ContrastAuditEntry, + type MotionSpecResolution, +} from "../utils/checkPipeline.js"; +import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js"; +import type { ProjectLintResult } from "../utils/lintProject.js"; +import type { + LayoutIssue, + LayoutIssueCode, + LayoutOverflow, + LayoutRect, +} from "../utils/layoutAudit.js"; +import type { ProjectDir } from "../utils/project.js"; + +const PROJECT: ProjectDir = { + dir: "/project", + name: "project", + indexPath: "/project/index.html", +}; +const PNG_BASE64 = Buffer.from("png-bytes").toString("base64"); +const ORIGINAL_EXIT_CODE = process.exitCode; + +afterEach(() => { + process.exitCode = ORIGINAL_EXIT_CODE; + trackCheckReport.mockClear(); + vi.restoreAllMocks(); +}); + +function cleanLint(): ProjectLintResult { + return { + results: [ + { + file: "index.html", + result: { + ok: true, + errorCount: 0, + warningCount: 0, + infoCount: 0, + findings: [], + }, + }, + ], + totalErrors: 0, + totalWarnings: 0, + totalInfos: 0, + }; +} + +function lintWith( + severity: "error" | "warning" | "info", + code: string, + message: string, +): ProjectLintResult { + return { + results: [ + { + file: "index.html", + result: { + ok: severity !== "error", + errorCount: severity === "error" ? 1 : 0, + warningCount: severity === "warning" ? 1 : 0, + infoCount: severity === "info" ? 1 : 0, + findings: [{ severity, code, message }], + }, + }, + ], + totalErrors: severity === "error" ? 1 : 0, + totalWarnings: severity === "warning" ? 1 : 0, + totalInfos: severity === "info" ? 1 : 0, + }; +} + +function anchor(selector: string, time: number): CheckAnchor { + return { + selector, + dataAttributes: { "data-layout-name": "hero" }, + sourceFile: "compositions/scene.html", + bbox: { x: 10, y: 20, width: 300, height: 80 }, + time, + }; +} + +function layoutIssue( + severity: "error" | "warning" | "info" = "error", + overrides: { time?: number; code?: AnchoredLayoutIssue["code"] } = {}, +): AnchoredLayoutIssue { + const time = overrides.time ?? 0.5; + return { + ...anchor("#hero", time), + code: overrides.code ?? (severity === "warning" ? "content_overlap" : "clipped_text"), + severity, + text: "Hero", + message: severity === "warning" ? "Text may overlap." : "Text is clipped.", + rect: { left: 10, top: 20, right: 310, bottom: 100, width: 300, height: 80 }, + }; +} + +function contrastEntry(overrides: Partial = {}): ContrastAuditEntry { + return { + ...anchor("#hero", 0.5), + text: "Body text", + ratio: 2.5, + wcagAA: false, + large: false, + fg: "rgb(110,110,110)", + bg: "rgb(30,30,30)", + ...overrides, + }; +} + +function fakeDriver(overrides: Partial = {}): CheckAuditDriver { + // A distinct string per call so the frozen-sweep guard (#U10) never fires + // by accident in unrelated scenarios — tests that want it force a constant + // via `collectLayoutGeometry: vi.fn(async () => "same")`. + let geometryCallCount = 0; + return { + initialize: vi.fn(async (_contrast: boolean) => undefined), + getDuration: vi.fn(async () => 9), + getTransitionBoundaries: vi.fn(async () => []), + getCanvas: vi.fn(async () => ({ width: 1920, height: 1080 })), + findAmbiguousSelectors: vi.fn(async (_selectors: string[]) => []), + seek: vi.fn(async (_time: number) => undefined), + collectLayout: vi.fn(async (_time: number, _tolerance: number) => []), + collectLayoutGeometry: vi.fn(async () => `geometry-${geometryCallCount++}`), + collectGeometryCandidates: vi.fn(async () => []), + collectMotionFrame: vi.fn(async (time: number) => ({ time, data: {}, liveness: {} })), + anchorMotionIssues: vi.fn(async (issues: LayoutIssue[]) => + issues.map((issue) => ({ + ...issue, + ...anchor(issue.selector, issue.time), + })), + ), + collectContrast: vi.fn(async (_time: number) => ({ entries: [], pngBase64: PNG_BASE64 })), + ...overrides, + }; +} + +interface GeometryFixture { + kind: "text" | "media"; + tag: string; + text: string; + selector: string; + rect: LayoutRect; + elementRect?: LayoutRect; + time: number; + overflow?: LayoutOverflow; +} + +function geometryCandidate(fixture: GeometryFixture) { + return { + ...anchor(fixture.selector, fixture.time), + kind: fixture.kind, + tag: fixture.tag, + text: fixture.text, + rect: fixture.rect, + elementRect: fixture.elementRect ?? fixture.rect, + bbox: { + x: fixture.rect.left, + y: fixture.rect.top, + width: fixture.rect.width, + height: fixture.rect.height, + }, + overflow: fixture.overflow, + }; +} + +function fixtureRect(left: number, top: number, width: number, height: number): LayoutRect { + return { left, top, right: left + width, bottom: top + height, width, height }; +} + +function checkBrowserSource(): string { + return readFileSync(new URL("../utils/checkBrowser.ts", import.meta.url), "utf8"); +} + +async function gateCandidates( + time: number, + request: { text: boolean; media: boolean; tolerance: number }, +) { + const candidates = []; + if (request.text) { + candidates.push( + geometryCandidate({ + kind: "text", + tag: "h2", + text: "Repeated heading", + selector: time === 2 ? "#first-heading" : "#later-heading", + rect: fixtureRect(800, 880, 320, 60), + time, + }), + ); + } + if (request.media) { + candidates.push( + geometryCandidate({ + kind: "media", + tag: "video", + text: "video", + selector: "#midpoint-video", + rect: fixtureRect(-140, 100, 100, 100), + overflow: { left: 140 }, + time, + }), + ); + } + return candidates; +} + +function noMotion(): MotionSpecResolution { + return { kind: "none" }; +} + +function heroMotionFrame(time: number, visibleAt: (time: number) => boolean) { + return { + time, + data: { + "#hero": { + rect: { left: 10, top: 20, right: 310, bottom: 100, width: 300, height: 80 }, + opacity: visibleAt(time) ? 1 : 0, + visible: visibleAt(time), + }, + }, + liveness: {}, + }; +} + +function dependencies( + driver: CheckAuditDriver, + options: { + lint?: ProjectLintResult; + motion?: MotionSpecResolution; + runtime?: CheckFinding[]; + writeSnapshot?: CheckDependencies["writeSnapshot"]; + captureFindingCrops?: CheckDependencies["captureFindingCrops"]; + } = {}, +): { deps: CheckDependencies; runBrowserCheck: ReturnType } { + const runBrowserCheck = vi.fn( + async ( + _project: ProjectDir, + checkOptions: CheckOptions, + motion: MotionSpecResolution, + ): Promise => { + const result = await runAuditGrid(driver, checkOptions, motion); + return { ...result, runtimeFindings: options.runtime ?? [] }; + }, + ); + const deps: CheckDependencies = { + lintProject: vi.fn(async () => options.lint ?? cleanLint()), + resolveMotionSpec: vi.fn(() => options.motion ?? noMotion()), + runBrowserCheck, + writeSnapshot: + options.writeSnapshot ?? + vi.fn((_projectDir: string, index: number, time: number, _pngBase64: string) => + Promise.resolve( + `snapshots/frame-${String(index).padStart(2, "0")}-at-${time.toFixed(1)}s.png`, + ), + ), + captureFindingCrops: options.captureFindingCrops ?? vi.fn(async () => []), + }; + return { deps, runBrowserCheck }; +} + +async function runScenario( + driver: CheckAuditDriver, + optionOverrides: Partial = {}, + dependencyOverrides: Parameters[1] = {}, +): Promise<{ report: CheckReport; deps: CheckDependencies; browser: ReturnType }> { + const { deps, runBrowserCheck } = dependencies(driver, dependencyOverrides); + const report = await runCheckPipeline( + PROJECT, + { ...DEFAULT_CHECK_OPTIONS, ...optionOverrides }, + deps, + ); + return { report, deps, browser: runBrowserCheck }; +} + +function runtimeError(): CheckFinding { + return { + code: "console_error", + severity: "error", + message: "boom", + ...anchor("[data-composition-id]", 0), + }; +} + +describe("contrast sample selection", () => { + it("chooses five evenly distributed grid points including both ends", () => { + expect(selectContrastTimes([0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5])).toEqual([ + 0.5, 2.5, 4.5, 6.5, 8.5, + ]); + expect(selectContrastTimes([1, 2, 3])).toEqual([1, 2, 3]); + }); +}); + +it("parses the caption-zone grammar and enables the frame gate", async () => { + const { report } = await runScenario(fakeDriver()); + const runPipeline = vi.fn(async (_project: ProjectDir, _options: CheckOptions) => report); + vi.spyOn(console, "log").mockImplementation(() => undefined); + const command = createCheckCommand({ + resolveProject: () => PROJECT, + runPipeline, + withMeta: (value) => value, + }); + + await runCommand(command, { + rawArgs: [ + "--json", + "--caption-zone", + "x0=0;y0=.82;x1=1;y1=1;severity=error;seek=.25,1", + "--frame-check", + ], + }); + + expect(runPipeline).toHaveBeenCalledWith( + PROJECT, + expect.objectContaining({ + captionZone: { + x0: 0, + y0: 0.82, + x1: 1, + y1: 1, + severity: "error", + seek: [0.25, 1], + }, + frameCheck: {}, + }), + ); +}); + +it("rejects malformed caption-zone specs instead of silently disabling the gate", async () => { + const { report } = await runScenario(fakeDriver()); + const runPipeline = vi.fn(async () => report); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const command = createCheckCommand({ + resolveProject: () => PROJECT, + runPipeline, + withMeta: (value) => ({ ...value, _meta: { version: "test" } }), + }); + + await runCommand(command, { + rawArgs: ["--json", "--caption-zone", "x0=0;y0=.8;x1=1;y1=1.2"], + }); + + expect(runPipeline).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(log).toHaveBeenCalledTimes(1); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + ok: false, + error: expect.stringContaining("Invalid --caption-zone"), + _meta: { version: "test" }, + }); +}); + +it("flags only text whose center is inside the caption band at the default end seek", async () => { + const collectGeometryCandidates = vi.fn(async (time: number) => [ + geometryCandidate({ + kind: "text", + tag: "div", + text: "Centered title", + selector: "#centered", + rect: fixtureRect(860, 870, 200, 60), + time, + }), + geometryCandidate({ + kind: "text", + tag: "div", + text: "Overlap only", + selector: "#overlap-only", + rect: fixtureRect(860, 830, 200, 60), + time, + }), + ]); + const { report } = await runScenario( + fakeDriver({ + getDuration: vi.fn(async () => 10), + collectGeometryCandidates, + }), + { + samples: 1, + contrast: false, + captionZone: { x0: 0, y0: 0.8, x1: 1, y1: 0.9 }, + }, + ); + + expect(collectGeometryCandidates).toHaveBeenCalledTimes(1); + expect(collectGeometryCandidates).toHaveBeenCalledWith(10, { + text: true, + media: false, + tolerance: 2, + }); + expect(report.layout.samples).toEqual([5, 10]); + expect(report.layout.findings).toEqual([ + expect.objectContaining({ + code: "caption_zone_collision", + severity: "warning", + selector: "#centered", + text: "Centered title", + time: 10, + }), + ]); + expect(report.ok).toBe(true); +}); + +it("filters caption candidates by the element box while centering the text rect", async () => { + const collectGeometryCandidates = vi.fn(async (time: number) => [ + geometryCandidate({ + kind: "text", + tag: "div", + text: "Full-frame wrapper copy", + selector: "#full-frame-wrapper", + rect: fixtureRect(860, 870, 200, 60), + elementRect: fixtureRect(0, 0, 1920, 1080), + time, + }), + geometryCandidate({ + kind: "text", + tag: "span", + text: "Tiny wrapper copy", + selector: "#tiny-wrapper", + rect: fixtureRect(860, 870, 200, 60), + elementRect: fixtureRect(860, 870, 3, 3), + time, + }), + ]); + const { report } = await runScenario(fakeDriver({ collectGeometryCandidates }), { + contrast: false, + captionZone: { x0: 0, y0: 0.8, x1: 1, y1: 0.9 }, + }); + + expect(report.layout.findings).toEqual([]); +}); + +it("checks media overflow at the default midpoint and applies warning severity", async () => { + const collectGeometryCandidates = vi.fn(async (time: number) => [ + geometryCandidate({ + kind: "media", + tag: "img", + text: "img", + selector: "#hero-image", + rect: fixtureRect(1840, 100, 220, 200), + overflow: { right: 140 }, + time, + }), + ]); + const { report } = await runScenario( + fakeDriver({ getDuration: vi.fn(async () => 10), collectGeometryCandidates }), + { samples: 1, contrast: false, frameCheck: {} }, + ); + + expect(collectGeometryCandidates).toHaveBeenCalledWith(5, { + text: false, + media: true, + tolerance: 2, + }); + expect(report.layout.findings[0]).toMatchObject({ + code: "frame_out_of_frame", + severity: "warning", + selector: "#hero-image", + overflow: { right: 140 }, + time: 5, + }); +}); + +it("converts progress seeks to time, gates each collector, and keeps the first caption hit", async () => { + const collectGeometryCandidates = vi.fn(gateCandidates); + const { report } = await runScenario( + fakeDriver({ + getDuration: vi.fn(async () => 8), + collectGeometryCandidates, + }), + { + samples: 1, + contrast: false, + captionZone: { + x0: 0, + y0: 0.8, + x1: 1, + y1: 0.9, + severity: "error", + seek: [0.25, 0.75], + }, + frameCheck: { severity: "error" }, + }, + ); + + expect(report.layout.samples).toEqual([2, 4, 6]); + expect(collectGeometryCandidates.mock.calls).toEqual([ + [2, { text: true, media: false, tolerance: 2 }], + [4, { text: false, media: true, tolerance: 2 }], + [6, { text: true, media: false, tolerance: 2 }], + ]); + expect(report.layout.findings).toEqual([ + expect.objectContaining({ + code: "caption_zone_collision", + severity: "error", + selector: "#first-heading", + time: 2, + }), + expect.objectContaining({ code: "frame_out_of_frame", severity: "error", time: 4 }), + ]); + expect(report.ok).toBe(false); +}); + +it("does not collect or emit opt-in geometry findings when both flags are off", async () => { + const collectGeometryCandidates = vi.fn(async () => [ + geometryCandidate({ + kind: "media", + tag: "video", + text: "video", + selector: "#video", + rect: fixtureRect(1900, 0, 200, 200), + overflow: { right: 180 }, + time: 4.5, + }), + ]); + const { report } = await runScenario(fakeDriver({ collectGeometryCandidates }), { + contrast: false, + }); + + expect(collectGeometryCandidates).not.toHaveBeenCalled(); + expect(JSON.stringify(report)).not.toContain("caption_zone_collision"); + expect(JSON.stringify(report)).not.toContain("frame_out_of_frame"); +}); + +it("computes caption bands from a portrait composition viewport", async () => { + const viewport = resolveCompositionViewportFromHtml( + '
', + ); + const collectGeometryCandidates = vi.fn(async (time: number) => [ + geometryCandidate({ + kind: "text", + tag: "p", + text: "Portrait caption collision", + selector: "#portrait-copy", + rect: fixtureRect(480, 1570, 120, 60), + time, + }), + ]); + const getCanvas = vi.fn(async () => viewport); + const { report } = await runScenario( + fakeDriver({ + getDuration: vi.fn(async () => 4), + getCanvas, + collectGeometryCandidates, + }), + { + samples: 1, + contrast: false, + captionZone: { x0: 0.4, y0: 0.8, x1: 0.6, y1: 0.9 }, + }, + ); + + expect(viewport).toEqual({ width: 1080, height: 1920 }); + expect(getCanvas).toHaveBeenCalled(); + expect(report.layout.findings).toEqual([ + expect.objectContaining({ code: "caption_zone_collision", selector: "#portrait-copy" }), + ]); +}); + +it("suppresses frame breaches below the per-canvas floor and reports those above it", async () => { + const collectGeometryCandidates = vi.fn(async (time: number) => [ + geometryCandidate({ + kind: "media", + tag: "canvas", + text: "canvas", + selector: "#under-floor", + rect: fixtureRect(3980, 100, 199, 100), + overflow: { right: 179 }, + time, + }), + geometryCandidate({ + kind: "media", + tag: "canvas", + text: "canvas", + selector: "#over-floor", + rect: fixtureRect(3980, 300, 201, 100), + overflow: { right: 181 }, + time, + }), + ]); + const { report } = await runScenario( + fakeDriver({ + getCanvas: vi.fn(async () => ({ width: 4000, height: 3000 })), + collectGeometryCandidates, + }), + { + contrast: false, + frameCheck: {}, + }, + ); + + expect(report.layout.findings).toEqual([ + expect.objectContaining({ + code: "frame_out_of_frame", + selector: "#over-floor", + overflow: { right: 181 }, + }), + ]); +}); + +it("keeps frame findings at distinct rounded positions across requested seeks", async () => { + const collectGeometryCandidates = vi.fn(async (time: number) => [ + geometryCandidate({ + kind: "media", + tag: "img", + text: "img", + selector: "#moving-image", + rect: fixtureRect(1920, time === 2 ? 100 : 300, 130, 100), + overflow: { right: 130 }, + time, + }), + ]); + const { report } = await runScenario( + fakeDriver({ + getDuration: vi.fn(async () => 8), + collectGeometryCandidates, + }), + { + samples: 1, + contrast: false, + frameCheck: { seek: [0.25, 0.75] }, + }, + ); + + expect(report.layout.findings).toEqual([ + expect.objectContaining({ code: "frame_out_of_frame", time: 2 }), + expect.objectContaining({ code: "frame_out_of_frame", time: 6 }), + ]); +}); + +it("keeps contrast and snapshot sampling on the pre-gate layout grid", async () => { + const collectContrast = vi.fn(async () => ({ entries: [], pngBase64: PNG_BASE64 })); + const { report } = await runScenario( + fakeDriver({ + getDuration: vi.fn(async () => 10), + collectContrast, + }), + { + samples: 1, + captionZone: { x0: 0, y0: 0.8, x1: 1, y1: 1 }, + }, + ); + + expect(report.layout.samples).toEqual([5, 10]); + expect(report.contrast.samples).toEqual([5]); + expect(collectContrast).toHaveBeenCalledTimes(1); + expect(collectContrast).toHaveBeenCalledWith(5); +}); + +function layoutFindingOf( + code: LayoutIssueCode, + severity: "error" | "warning" | "info", + bbox: { x: number; y: number; width: number; height: number }, + time = 1, +): AnchoredLayoutIssue { + return { + code, + severity, + message: code, + ...anchor("#el", time), + bbox, + rect: { + left: bbox.x, + top: bbox.y, + right: bbox.x + bbox.width, + bottom: bbox.y + bbox.height, + ...bbox, + }, + }; +} + +function checkFindingOf( + code: string, + severity: "error" | "warning" | "info", + bbox: { x: number; y: number; width: number; height: number }, + time = 1, +): CheckFinding { + return { code, severity, message: code, ...anchor("#el", time), bbox }; +} + +function emptySection(findings: T[] = []) { + return { ok: true, errorCount: 0, warningCount: 0, infoCount: 0, findings }; +} + +function reportWithFindings(overrides: Partial = {}): CheckReport { + return { + ok: true, + strict: false, + lint: { ...emptySection(), filesScanned: 0 }, + runtime: emptySection(), + layout: { + ...emptySection(), + duration: 10, + samples: [], + transitionSamples: [], + transitionSamplesDropped: 0, + tolerance: 2, + totalIssueCount: 0, + truncated: false, + }, + motion: { ...emptySection(), enabled: false, samples: 0 }, + contrast: { ...emptySection(), enabled: true, samples: [], checked: 0, passed: 0 }, + snapshots: { enabled: false, files: [], times: [], findingFiles: [] }, + ...overrides, + }; +} + +describe("selectFindingCropRequests", () => { + const NON_ZERO_BBOX = { x: 10, y: 20, width: 100, height: 50 }; + const ZERO_BBOX = { x: 0, y: 0, width: 0, height: 0 }; + + it("filenames a request finding-NN-.png with the finding's time and bbox", () => { + const report = reportWithFindings({ + layout: { + ...reportWithFindings().layout, + findings: [layoutFindingOf("clipped_text", "error", NON_ZERO_BBOX, 2.5)], + }, + }); + + expect(selectFindingCropRequests(report)).toEqual([ + { filename: "finding-00-clipped_text.png", time: 2.5, bbox: NON_ZERO_BBOX }, + ]); + }); + + it("skips warnings/info and findings without a real bbox", () => { + const report = reportWithFindings({ + layout: { + ...reportWithFindings().layout, + findings: [ + layoutFindingOf("content_overlap", "warning", NON_ZERO_BBOX), + layoutFindingOf("clipped_text", "error", ZERO_BBOX), + ], + }, + runtime: { ...emptySection([checkFindingOf("console_error", "info", NON_ZERO_BBOX)]) }, + }); + + expect(selectFindingCropRequests(report)).toEqual([]); + }); + + it("caps at 12 requests across sections", () => { + const findings = Array.from({ length: 15 }, (_, index) => + checkFindingOf(`code_${index}`, "error", NON_ZERO_BBOX, index), + ); + const report = reportWithFindings({ + runtime: { ...emptySection(findings) }, + }); + + const requests = selectFindingCropRequests(report); + expect(requests).toHaveLength(12); + expect(requests[0]?.filename).toBe(findingCropFilename(0, "code_0")); + expect(requests[11]?.filename).toBe(findingCropFilename(11, "code_11")); + }); + + it("sanitizes unusual characters out of the code when building a filename", () => { + expect(findingCropFilename(3, "weird code/name")).toBe("finding-03-weird_code_name.png"); + }); +}); + +describe("check pipeline", () => { + const originalExitCode = process.exitCode; + + afterEach(() => { + process.exitCode = originalExitCode; + vi.restoreAllMocks(); + }); + + it("emits one clean JSON envelope with every section and exit 0", async () => { + const { report } = await runScenario(fakeDriver()); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const command = createCheckCommand({ + resolveProject: () => PROJECT, + runPipeline: vi.fn(async () => report), + withMeta: (value) => ({ ...value, _meta: { version: "test" } }), + }); + + await runCommand(command, { rawArgs: ["--json"] }); + + expect(report.ok).toBe(true); + expect(checkExitCode(report)).toBe(0); + expect(process.exitCode).toBe(0); + expect(log).toHaveBeenCalledTimes(1); + const output = log.mock.calls[0]?.[0]; + expect(typeof output).toBe("string"); + if (typeof output !== "string") throw new Error("expected JSON output"); + const envelope = JSON.parse(output); + expect(envelope).toMatchObject({ + ok: true, + lint: { ok: true }, + runtime: { ok: true }, + layout: { ok: true }, + motion: { ok: true }, + contrast: { ok: true }, + snapshots: { enabled: false }, + _meta: { version: "test" }, + }); + }); + + it("short-circuits on lint errors without launching a browser", async () => { + const lint = lintWith( + "error", + "root_missing_composition_id", + "Root element needs data-composition-id.", + ); + const { report, browser } = await runScenario(fakeDriver(), {}, { lint }); + + expect(report.ok).toBe(false); + expect(checkExitCode(report)).toBe(1); + expect(report.lint.findings).toHaveLength(1); + expect(browser).not.toHaveBeenCalled(); + }); + + it("gates AA contrast failures and --no-contrast skips the pass", async () => { + const failingContrast = vi.fn(async (time: number) => ({ + entries: time === 0.5 ? [contrastEntry()] : [], + pngBase64: PNG_BASE64, + })); + const { report } = await runScenario(fakeDriver({ collectContrast: failingContrast })); + expect(report.ok).toBe(false); + expect(checkExitCode(report)).toBe(1); + expect(report.contrast.errorCount).toBe(1); + + const skippedContrast = vi.fn(async () => ({ + entries: [contrastEntry()], + pngBase64: PNG_BASE64, + })); + const skipped = await runScenario(fakeDriver({ collectContrast: skippedContrast }), { + contrast: false, + }); + expect(skipped.report.ok).toBe(true); + expect(checkExitCode(skipped.report)).toBe(0); + expect(skipped.report.contrast.enabled).toBe(false); + expect(skippedContrast).not.toHaveBeenCalled(); + }); + + it("includes measured colors, thresholds, and a passing palette-direction suggestion", async () => { + const { report } = await runScenario( + fakeDriver({ + collectContrast: vi.fn(async () => ({ + entries: [contrastEntry()], + pngBase64: PNG_BASE64, + })), + }), + ); + const finding = report.contrast.findings[0]; + expect(finding).toMatchObject({ + fg: "rgb(110,110,110)", + bg: "rgb(30,30,30)", + ratio: 2.5, + requiredRatio: 4.5, + }); + if (!finding) throw new Error("expected contrast finding"); + const suggested = parseColorRGBA(finding.suggestedColor); + const background = parseColorRGBA(finding.bg); + expect(suggested).not.toBeNull(); + expect(background).not.toBeNull(); + if (!suggested || !background) throw new Error("expected parseable colors"); + expect( + contrastRatio( + [suggested[0], suggested[1], suggested[2]], + [background[0], background[1], background[2]], + ), + ).toBeGreaterThanOrEqual(finding.requiredRatio); + expect(suggested[0]).toBeGreaterThan(110); + }); + + it("preserves a resolving selector, source file, identity, bbox, and sample time", async () => { + const { report } = await runScenario( + fakeDriver({ + collectLayout: vi.fn(async (time: number) => [layoutIssue("error", { time })]), + }), + ); + expect(report.layout.findings[0]).toMatchObject({ + selector: "#hero", + dataAttributes: { "data-layout-name": "hero" }, + sourceFile: "compositions/scene.html", + bbox: { x: 10, y: 20, width: 300, height: 80 }, + time: 0.5, + }); + }); + + it("reports layout and runtime errors from one browser session", async () => { + const { report, browser } = await runScenario( + fakeDriver({ + collectLayout: vi.fn(async (time: number) => [layoutIssue("error", { time })]), + }), + {}, + { runtime: [runtimeError()] }, + ); + expect(report.runtime.errorCount).toBe(1); + expect(report.layout.errorCount).toBe(1); + expect(browser).toHaveBeenCalledTimes(1); + }); + + it("reports a failing appearsBy sidecar as motion_appears_late", async () => { + const motion: MotionSpecResolution = { + kind: "valid", + path: "/project/index.motion.json", + spec: { assertions: [{ kind: "appearsBy", selector: "#hero", bySec: 0.2 }] }, + }; + const driver = fakeDriver({ + getDuration: vi.fn(async () => 1), + collectMotionFrame: vi.fn(async (time: number) => heroMotionFrame(time, (t) => t >= 0.5)), + }); + const { report } = await runScenario(driver, {}, { motion }); + + expect(report.motion.findings).toEqual([ + expect.objectContaining({ + code: "motion_appears_late", + severity: "error", + selector: "#hero", + }), + ]); + expect(report.ok).toBe(false); + }); + + it("writes cached contrast PNGs only with --snapshots at the contrast timestamps", async () => { + const writer = vi.fn( + async (_projectDir: string, index: number, time: number, _pngBase64: string) => + `snapshots/frame-${String(index).padStart(2, "0")}-at-${time.toFixed(1)}s.png`, + ); + const captured = fakeDriver({ + collectContrast: vi.fn(async () => ({ entries: [], pngBase64: PNG_BASE64 })), + }); + const { report } = await runScenario(captured, { snapshots: true }, { writeSnapshot: writer }); + + expect(report.snapshots.times).toEqual([0.5, 2.5, 4.5, 6.5, 8.5]); + expect(report.snapshots.files).toEqual([ + "snapshots/frame-00-at-0.5s.png", + "snapshots/frame-01-at-2.5s.png", + "snapshots/frame-02-at-4.5s.png", + "snapshots/frame-03-at-6.5s.png", + "snapshots/frame-04-at-8.5s.png", + ]); + expect(writer).toHaveBeenCalledTimes(5); + + const absentWriter = vi.fn(async () => "unused.png"); + await runScenario(fakeDriver(), { snapshots: false }, { writeSnapshot: absentWriter }); + expect(absentWriter).not.toHaveBeenCalled(); + }); + + it("captures finding crops for error findings with bboxes only when --snapshots is set", async () => { + const capture = vi.fn( + async ( + _project: ProjectDir, + _options: CheckOptions, + _requests: CheckFindingCropRequest[], + ) => ["snapshots/finding-00-clipped_text.png"], + ); + const { report } = await runScenario( + fakeDriver({ + collectLayout: vi.fn(async (time: number) => [layoutIssue("error", { time })]), + }), + { snapshots: true }, + { captureFindingCrops: capture }, + ); + + expect(capture).toHaveBeenCalledTimes(1); + expect(capture.mock.calls[0]?.[2]).toEqual([ + { + filename: "finding-00-clipped_text.png", + time: 0.5, + bbox: { x: 10, y: 20, width: 300, height: 80 }, + }, + ]); + expect(report.snapshots.findingFiles).toEqual(["snapshots/finding-00-clipped_text.png"]); + + const withoutSnapshots = vi.fn(async () => ["unused.png"]); + await runScenario( + fakeDriver({ + collectLayout: vi.fn(async (time: number) => [layoutIssue("error", { time })]), + }), + { snapshots: false }, + { captureFindingCrops: withoutSnapshots }, + ); + expect(withoutSnapshots).not.toHaveBeenCalled(); + + const noErrors = vi.fn(async () => ["unused.png"]); + await runScenario( + fakeDriver({ + collectLayout: vi.fn( + async (time: number) => + // container_overflow, not content_overlap: this fixture wants a plain + // warning-severity finding held across the whole run, unaffected by + // content_overlap's #U10 held-duration re-promotion to error. + [layoutIssue("warning", { time, code: "container_overflow" })], + ), + }), + { snapshots: true }, + { captureFindingCrops: noErrors }, + ); + expect(noErrors).not.toHaveBeenCalled(); + }); + + it("--strict flips a warnings-only result from exit 0 to exit 1", async () => { + const warningDriver = () => + fakeDriver({ + collectLayout: vi.fn( + async (time: number) => + // container_overflow, not content_overlap: this fixture wants a plain + // warning-severity finding held across the whole run, unaffected by + // content_overlap's #U10 held-duration re-promotion to error. + [layoutIssue("warning", { time, code: "container_overflow" })], + ), + }); + const normal = await runScenario(warningDriver(), { strict: false }); + const strict = await runScenario(warningDriver(), { strict: true }); + + expect(checkExitCode(normal.report)).toBe(0); + expect(checkExitCode(strict.report)).toBe(1); + }); + + it("fails clearly without samples when no timeline duration is available, without hanging", async () => { + const driver = fakeDriver({ getDuration: vi.fn(async () => 0) }); + await expect(runAuditGrid(driver, DEFAULT_CHECK_OPTIONS, noMotion())).rejects.toThrow( + "Could not determine composition duration — no layout samples run", + ); + await expect( + runAuditGrid( + driver, + { + ...DEFAULT_CHECK_OPTIONS, + captionZone: { x0: 0, y0: 0.8, x1: 1, y1: 1 }, + }, + noMotion(), + ), + ).rejects.toThrow("Could not determine composition duration — no layout samples run"); + + const { report, browser } = await runScenario(driver); + expect(browser).toHaveBeenCalledTimes(1); + expect(report.runtime.findings[0]?.message).toContain( + "Could not determine composition duration — no layout samples run", + ); + expect(checkExitCode(report)).toBe(1); + }); + + describe("frozen-sweep guard (#U10)", () => { + it("fails with sweep_static when a 6s composition's geometry never changes across samples", async () => { + const driver = fakeDriver({ + getDuration: vi.fn(async () => 6), + collectLayoutGeometry: vi.fn(async () => "frozen"), + }); + const { report } = await runScenario(driver); + + expect(report.ok).toBe(false); + expect( + report.layout.findings.some( + (finding) => + finding.code === "sweep_static" && + finding.severity === "error" && + finding.message.includes("did not advance"), + ), + ).toBe(true); + }); + + it("does not flag a 1.5s static title card — too short for the guard to apply", async () => { + const driver = fakeDriver({ + getDuration: vi.fn(async () => 1.5), + collectLayoutGeometry: vi.fn(async () => "frozen"), + }); + const { report } = await runScenario(driver); + + expect(report.layout.findings.some((finding) => finding.code === "sweep_static")).toBe(false); + }); + + it("does not double-report when a motion_frozen finding already covers the same symptom", async () => { + const motion: MotionSpecResolution = { + kind: "valid", + path: "/project/index.motion.json", + spec: { assertions: [{ kind: "keepsMoving" }] }, + }; + const driver = fakeDriver({ + getDuration: vi.fn(async () => 6), + collectLayoutGeometry: vi.fn(async () => "frozen"), + collectMotionFrame: vi.fn(async (time: number) => ({ + time, + data: {}, + liveness: { "*": "unchanging" }, + })), + }); + const { report } = await runScenario(driver, {}, { motion }); + + expect(report.motion.findings.some((finding) => finding.code === "motion_frozen")).toBe(true); + expect(report.layout.findings.some((finding) => finding.code === "sweep_static")).toBe(false); + }); + }); +}); + +describe("frame-check flag grammar", () => { + it("keeps bare --frame-check on defaults and parses the value form", async () => { + const { parseFrameCheck } = await import("./check.js"); + expect(parseFrameCheck(undefined)).toBeUndefined(); + expect(parseFrameCheck(true)).toEqual({}); + expect(parseFrameCheck("")).toEqual({}); + expect(parseFrameCheck("severity=error;seek=.25,.75;tol=4")).toEqual({ + severity: "error", + seek: [0.25, 0.75], + tol: 4, + }); + expect(() => parseFrameCheck("bogus=1")).toThrow("Invalid --frame-check"); + expect(() => parseFrameCheck("tol=-2")).toThrow("Invalid --frame-check"); + }); +}); + +describe("contrast persistence", () => { + it("demotes a single-sample contrast failure to warning but gates held failures", async () => { + const driver = fakeDriver({ + collectContrast: vi.fn(async (time: number) => ({ + entries: [ + // #hero fails at every sample: held, stays an error. + contrastEntry({ time, selector: "#hero" }), + // #entrance fails only at the first sample (mid-entrance): demoted. + ...(time < 1 + ? [contrastEntry({ time, selector: "#entrance", text: "Fading in" })] + : [ + contrastEntry({ + time, + selector: "#entrance", + text: "Fading in", + ratio: 8, + wcagAA: true, + }), + ]), + ], + pngBase64: PNG_BASE64, + })), + }); + const { report } = await runScenario(driver); + + const bySelector = new Map( + report.contrast.findings.map((finding) => [finding.selector, finding.severity]), + ); + expect(bySelector.get("#hero")).toBe("error"); + expect(bySelector.get("#entrance")).toBe("warning"); + expect(checkExitCode(report)).toBe(1); + }); + + it("keeps full severity when only one sample time exists", async () => { + const driver = fakeDriver({ + collectContrast: vi.fn(async (time: number) => ({ + entries: [contrastEntry({ time })], + pngBase64: PNG_BASE64, + })), + }); + const { report } = await runScenario(driver, { samples: 1, at: [2] }); + + expect(report.contrast.findings[0]?.severity).toBe("error"); + }); +}); + +describe("check report telemetry", () => { + it("reports one clean run with every gate and sampled-point count", async () => { + const motion: MotionSpecResolution = { + kind: "valid", + path: "/project/index.motion.json", + spec: { duration: 1, assertions: [{ kind: "appearsBy", selector: "#hero", bySec: 1 }] }, + }; + const driver = fakeDriver({ + getDuration: vi.fn(async () => 1), + collectMotionFrame: vi.fn(async (time: number) => heroMotionFrame(time, () => true)), + collectContrast: vi.fn(async (time: number) => ({ + entries: [contrastEntry({ time, ratio: 7, wcagAA: true })], + pngBase64: PNG_BASE64, + })), + }); + + const { report } = await runScenario( + driver, + { + samples: 1, + captionZone: { x0: 0, y0: 0.8, x1: 1, y1: 1 }, + frameCheck: {}, + snapshots: true, + }, + { motion }, + ); + + expect(trackCheckReport).toHaveBeenCalledTimes(1); + expect(trackCheckReport).toHaveBeenCalledWith( + expect.objectContaining({ + contrastGate: true, + motionGate: true, + captionZoneGate: true, + frameCheckGate: true, + snapshotsGate: true, + gridPoints: 2, + contrastPoints: 1, + ok: true, + exitCode: 0, + }), + ); + expect(report.ok).toBe(true); + }); + + it("reports one failing contrast run with its section error count", async () => { + const collectContrast = vi.fn(async (time: number) => ({ + entries: time === 0.5 ? [contrastEntry()] : [], + pngBase64: PNG_BASE64, + })); + + const { report } = await runScenario(fakeDriver({ collectContrast })); + + expect(trackCheckReport).toHaveBeenCalledTimes(1); + expect(trackCheckReport).toHaveBeenCalledWith( + expect.objectContaining({ + ok: false, + exitCode: 1, + contrastErrors: report.contrast.errorCount, + }), + ); + expect(report.contrast.errorCount).toBe(1); + }); + + it("reports zero browser samples and timings after a lint short circuit", async () => { + const lint = lintWith( + "error", + "root_missing_composition_id", + "Root element needs data-composition-id.", + ); + + const { report, browser } = await runScenario(fakeDriver(), {}, { lint }); + + expect(browser).not.toHaveBeenCalled(); + expect(trackCheckReport).toHaveBeenCalledTimes(1); + expect(trackCheckReport).toHaveBeenCalledWith( + expect.objectContaining({ + ok: false, + exitCode: 1, + gridPoints: 0, + contrastPoints: 0, + launchSettleMs: 0, + seekLoopMs: 0, + contrastMs: 0, + }), + ); + expect(report.ok).toBe(false); + }); + + it("matches report counts for mixed findings across classes", async () => { + const lint = lintWith("warning", "lint_warning", "Lint warning."); + const driver = fakeDriver({ + collectLayout: vi.fn(async () => [layoutIssue(), layoutIssue("warning")]), + collectContrast: vi.fn(async () => ({ + entries: [contrastEntry()], + pngBase64: PNG_BASE64, + })), + }); + + const { report } = await runScenario( + driver, + { samples: 1 }, + { lint, runtime: [runtimeError()] }, + ); + + expect(trackCheckReport).toHaveBeenCalledTimes(1); + expect(trackCheckReport).toHaveBeenCalledWith( + expect.objectContaining({ + lintErrors: report.lint.errorCount, + lintWarnings: report.lint.warningCount, + runtimeErrors: report.runtime.errorCount, + runtimeWarnings: report.runtime.warningCount, + layoutErrors: report.layout.errorCount, + layoutWarnings: report.layout.warningCount, + motionErrors: report.motion.errorCount, + motionWarnings: report.motion.warningCount, + contrastErrors: report.contrast.errorCount, + contrastWarnings: report.contrast.warningCount, + }), + ); + expect(report.lint.warningCount).toBe(1); + expect(report.runtime.errorCount).toBe(1); + expect(report.layout.errorCount).toBe(1); + expect(report.layout.warningCount).toBe(1); + }); + + it("measures contrast work inside the overall seek loop", async () => { + vi.spyOn(Date, "now") + .mockReturnValueOnce(100) + .mockReturnValueOnce(105) + .mockReturnValueOnce(110) + .mockReturnValueOnce(120); + + const result = await runAuditGrid( + fakeDriver(), + { ...DEFAULT_CHECK_OPTIONS, samples: 1 }, + noMotion(), + ); + + expect(result.timings).toEqual({ launchSettleMs: 0, seekLoopMs: 20, contrastMs: 5 }); + }); +}); + +describe("contrast candidate round-trip", () => { + it("passes the browser script's raw candidates back to finish, never the normalized copies", () => { + const source = checkBrowserSource(); + + // __contrastAuditFinish samples pixels via the page script's own bbox + // shape ({x, y, w, h}); sending the Node-normalized candidate + // ({width, height}) makes every sample rect NaN and the audit silently + // reports zero checked elements. The raw object must round-trip verbatim. + expect(source).toMatch(/prepared\.map\(\(entry\) => entry\.raw\)/); + expect(source).toMatch(/raw: unknown;/); + expect(source).not.toMatch(/prepared\.map\(\(entry\) => entry\.candidate\)/); + }); +}); diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts new file mode 100644 index 0000000000..a0adf37678 --- /dev/null +++ b/packages/cli/src/commands/check.ts @@ -0,0 +1,409 @@ +import { defineCommand } from "citty"; +import type { Example } from "./_examples.js"; +import { parseAt } from "./layout.js"; +import { c } from "../ui/colors.js"; +import { normalizeErrorMessage } from "../utils/errorMessage.js"; +import { formatLayoutIssue } from "../utils/layoutAudit.js"; +import { resolveProject, type ProjectDir } from "../utils/project.js"; +import { withMeta } from "../utils/updateCheck.js"; +import { + DEFAULT_CHECK_OPTIONS, + checkExitCode, + runCheckPipeline, + type CheckFinding, + type CheckOptions, + type CheckReport, + type CheckSection, +} from "../utils/checkPipeline.js"; +import type { CaptionZoneOptions, FrameCheckOptions } from "../utils/checkTypes.js"; + +export const examples: Example[] = [ + ["Run the full verification gate", "hyperframes check"], + ["Output one agent-readable envelope", "hyperframes check --json"], + ["Persist the five audited contrast frames", "hyperframes check --snapshots"], + ["Also fail on warnings", "hyperframes check --strict"], +]; + +export interface CheckCommandDependencies { + resolveProject(dir: string | undefined): ProjectDir; + runPipeline(project: ProjectDir, options: CheckOptions): Promise; + withMeta(value: object): object; +} + +const DEFAULT_COMMAND_DEPENDENCIES: CheckCommandDependencies = { + resolveProject, + runPipeline: runCheckPipeline, + withMeta, +}; + +export function createCheckCommand( + dependencies: CheckCommandDependencies = DEFAULT_COMMAND_DEPENDENCIES, +) { + return defineCommand({ + meta: { + name: "check", + description: + "Run lint, runtime, layout, motion, and WCAG contrast verification in one browser session", + }, + args: { + dir: { type: "positional", description: "Project directory", required: false }, + json: { type: "boolean", description: "Output agent-readable JSON", default: false }, + samples: { + type: "string", + description: "Number of midpoint samples across the duration (default: 9)", + default: "9", + }, + at: { + type: "string", + description: "Comma-separated timestamps in seconds (e.g., --at 1.5,4,7.25)", + }, + "at-transitions": { + type: "boolean", + description: + "Also sample at every tween start/end boundary (plus segment midpoints) to catch transient overlaps at transition seams", + default: false, + }, + "max-transition-samples": { + type: "string", + description: + "Optional cap on transition-derived samples; when it truncates, the omitted count is reported (default: unlimited)", + }, + "max-issues": { + type: "string", + description: "Maximum issues to print or return after static collapse (default: 80)", + default: "80", + }, + "collapse-static": { + type: "boolean", + description: "Collapse repeated static issues across samples (default: true)", + default: true, + }, + tolerance: { + type: "string", + description: "Allowed pixel overflow before reporting an issue (default: 2)", + default: "2", + }, + timeout: { + type: "string", + description: "Ms to wait for scripts and media to settle initially (default: 3000)", + default: "3000", + }, + contrast: { + type: "boolean", + description: "Run the WCAG AA contrast pass (enabled by default)", + default: true, + }, + strict: { + type: "boolean", + description: "Exit non-zero on warnings too", + default: false, + }, + snapshots: { + type: "boolean", + description: "Save the five contrast-pass PNGs under snapshots/", + default: false, + }, + "caption-zone": { + type: "string", + description: + 'Caption band "x0=0;y0=.82;x1=1;y1=1[;severity=warning|error][;seek=.5,1]" (fractions 0-1; defaults: warning, seek=1)', + }, + "frame-check": { + type: "string", + description: + 'Bare --frame-check uses defaults (tol=2px, severity=warning, seek=.5; breach floor=max(120px, 6% of shorter canvas edge)); or pass "severity=error;seek=.25,.75;tol=4" to tune', + }, + }, + async run({ args }) { + const asJson = args.json === true; + + try { + const project = dependencies.resolveProject(args.dir); + const options = parseCheckOptions(args); + if (!asJson) { + console.log(`${c.accent("◆")} Checking ${c.accent(project.name)}`); + } + const report = await dependencies.runPipeline(project, options); + if (asJson) { + console.log(JSON.stringify(dependencies.withMeta(report), null, 2)); + } else { + printHumanReport(report); + } + process.exitCode = checkExitCode(report); + } catch (error) { + const message = normalizeErrorMessage(error); + if (asJson) { + console.log( + JSON.stringify(dependencies.withMeta({ ok: false, error: message }), null, 2), + ); + } else { + console.error(`${c.error("✗")} Check failed: ${message}`); + } + process.exitCode = 1; + } + }, + }); +} + +function parseCheckOptions(args: Record): CheckOptions { + const maxTransitionSamples = positiveInteger(args["max-transition-samples"], 0); + return { + samples: positiveInteger(args.samples, DEFAULT_CHECK_OPTIONS.samples), + at: parseAt(args.at), + atTransitions: args["at-transitions"] === true, + maxTransitionSamples: maxTransitionSamples > 0 ? maxTransitionSamples : undefined, + maxIssues: positiveInteger(args["max-issues"], DEFAULT_CHECK_OPTIONS.maxIssues), + collapseStatic: args["collapse-static"] !== false, + tolerance: nonNegativeNumber(args.tolerance, DEFAULT_CHECK_OPTIONS.tolerance), + timeout: Math.max(500, positiveInteger(args.timeout, DEFAULT_CHECK_OPTIONS.timeout)), + contrast: args.contrast !== false, + strict: args.strict === true, + snapshots: args.snapshots === true, + captionZone: parseCaptionZone(args["caption-zone"]), + frameCheck: parseFrameCheck(args["frame-check"]), + }; +} + +const CAPTION_ZONE_FIELDS = new Set(["x0", "y0", "x1", "y1", "severity", "seek"]); + +const FRAME_CHECK_FIELDS = new Set(["severity", "seek", "tol"]); + +// Mirrors --caption-zone's spec grammar so the EF bridge's severity/seek/tol +// options survive the migration instead of being silently dropped by a +// boolean flag (bare --frame-check keeps today's defaults). +export function parseFrameCheck(value: unknown): FrameCheckOptions | undefined { + if (value === undefined || value === null || value === false) return undefined; + if (value === true || value === "") return {}; + if (typeof value !== "string") throw frameCheckError(); + const fields = parseFrameCheckFields(value); + const severity = captionSeverity(fields.get("severity")); + const seek = captionSeeks(fields.get("seek")); + const tol = parseFrameCheckTolerance(fields.get("tol")); + return { + ...(severity ? { severity } : {}), + ...(seek ? { seek } : {}), + ...(tol !== undefined ? { tol } : {}), + }; +} + +function parseFrameCheckFields(value: string): Map { + const fields = new Map(); + for (const part of value.split(";")) { + const { key, entry } = parseCaptionField(part); + if (!FRAME_CHECK_FIELDS.has(key) || fields.has(key)) throw frameCheckError(); + fields.set(key, entry); + } + return fields; +} + +function parseFrameCheckTolerance(raw: string | undefined): number | undefined { + if (raw === undefined) return undefined; + const tol = Number.parseFloat(raw); + if (!Number.isFinite(tol) || tol < 0) throw frameCheckError(); + return tol; +} + +function frameCheckError(): Error { + return new Error( + 'Invalid --frame-check: use bare --frame-check or "severity=warning|error;seek=.25,.75;tol=4" (all fields optional)', + ); +} + +function parseCaptionZone(value: unknown): CaptionZoneOptions | undefined { + if (value === undefined || value === null) return undefined; + const fields = parseCaptionFields(captionZoneString(value)); + const { x0, y0, x1, y1 } = parseCaptionBounds(fields); + const severity = captionSeverity(fields.get("severity")); + const seek = captionSeeks(fields.get("seek")); + return { + x0, + y0, + x1, + y1, + ...(severity ? { severity } : {}), + ...(seek ? { seek } : {}), + }; +} + +function captionZoneString(value: unknown): string { + if (typeof value !== "string" || value.trim() === "") throw captionZoneError(); + return value; +} + +function parseCaptionFields(value: string): Map { + const fields = new Map(); + for (const part of value.split(";")) { + const { key, entry } = parseCaptionField(part); + if (!CAPTION_ZONE_FIELDS.has(key) || fields.has(key)) throw captionZoneError(); + fields.set(key, entry); + } + return fields; +} + +function parseCaptionField(part: string): { key: string; entry: string } { + const separator = part.indexOf("="); + if (separator <= 0) throw captionZoneError(); + return { + key: part.slice(0, separator).trim(), + entry: part.slice(separator + 1).trim(), + }; +} + +function parseCaptionBounds(fields: Map): { + x0: number; + y0: number; + x1: number; + y1: number; +} { + const x0 = requiredCaptionFraction(fields, "x0"); + const y0 = requiredCaptionFraction(fields, "y0"); + const x1 = requiredCaptionFraction(fields, "x1"); + const y1 = requiredCaptionFraction(fields, "y1"); + if (x0 > x1 || y0 > y1) throw captionZoneError(); + return { x0, y0, x1, y1 }; +} + +function requiredCaptionFraction(fields: Map, key: string): number { + const value = captionFraction(fields.get(key)); + if (value === null) throw captionZoneError(); + return value; +} + +function captionFraction(value: string | undefined): number | null { + if (value === undefined || value === "") return null; + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 ? parsed : null; +} + +function captionSeverity(value: string | undefined): "error" | "warning" | undefined { + if (value === undefined) return undefined; + if (value === "error" || value === "warning") return value; + throw captionZoneError(); +} + +function captionSeeks(value: string | undefined): number[] | undefined { + if (value === undefined) return undefined; + if (value === "") return []; + const values = value.split(",").map(captionFraction); + if (values.some((entry) => entry === null)) throw captionZoneError(); + return values.flatMap((entry) => (entry === null ? [] : entry)); +} + +function captionZoneError(): Error { + return new Error( + 'Invalid --caption-zone; use "x0=0;y0=.82;x1=1;y1=1[;severity=warning|error][;seek=.5,1]" with fractions from 0 to 1.', + ); +} + +function positiveInteger(value: unknown, fallback: number): number { + const parsed = parseInt(String(value ?? ""), 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function nonNegativeNumber(value: unknown, fallback: number): number { + const parsed = parseFloat(String(value ?? "")); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; +} + +function printHumanReport(report: CheckReport): void { + printSection("Lint", report.lint); + printSection("Runtime", report.runtime); + printLayoutSection("Layout", report.layout); + printSection("Motion", report.motion); + printContrastSection(report); + printSnapshotSection(report); + console.log(); + const label = report.ok ? c.success("Check passed") : c.error("Check failed"); + console.log(`${report.ok ? c.success("◇") : c.error("◇")} ${label}`); +} + +function printSection(title: string, section: CheckSection): void { + console.log(); + console.log(c.bold(title)); + if (section.findings.length === 0) { + console.log(` ${c.success("◇")} 0 errors, 0 warnings`); + return; + } + for (const finding of section.findings) printFinding(finding); + printCounts(section); +} + +function printLayoutSection(title: string, section: CheckReport["layout"]): void { + console.log(); + console.log(c.bold(title)); + if (section.findings.length === 0) { + console.log(` ${c.success("◇")} 0 issues across ${section.samples.length} sample(s)`); + } else { + for (const finding of section.findings) { + const formatted = formatLayoutIssue(finding).replace(/\n/g, "\n "); + console.log(` ${findingIcon(finding)} ${formatted}`); + } + printCounts(section); + } + if (section.transitionSamplesDropped > 0) { + console.log( + ` ${c.warn("⚠")} ${section.transitionSamplesDropped} transition sample(s) omitted`, + ); + } +} + +function printContrastSection(report: CheckReport): void { + const section = report.contrast; + console.log(); + console.log(c.bold("Contrast")); + if (!section.enabled) { + console.log(` ${c.dim("◇")} skipped`); + return; + } + if (section.findings.length === 0) { + console.log( + ` ${c.success("◇")} ${section.passed}/${section.checked} text checks pass WCAG AA`, + ); + return; + } + for (const finding of section.findings) { + console.log( + ` ${c.error("✗")} ${finding.selector} ${finding.ratio}:1 (need ${finding.requiredRatio}:1, t=${finding.time}s)`, + ); + console.log(` ${c.dim(`Try ${finding.suggestedColor}; source ${finding.sourceFile}`)}`); + } + printCounts(section); +} + +function printSnapshotSection(report: CheckReport): void { + console.log(); + console.log(c.bold("Snapshots")); + if (!report.snapshots.enabled) { + console.log(` ${c.dim("◇")} disabled`); + } else { + console.log(` ${c.success("◇")} ${report.snapshots.files.length} PNG(s) saved`); + for (const file of report.snapshots.files) console.log(` ${c.dim(file)}`); + if (report.snapshots.findingFiles.length > 0) { + console.log( + ` ${c.success("◇")} ${report.snapshots.findingFiles.length} finding crop(s) saved`, + ); + for (const file of report.snapshots.findingFiles) console.log(` ${c.dim(file)}`); + } + } +} + +function printFinding(finding: CheckFinding): void { + const where = `${finding.sourceFile} ${finding.selector} t=${finding.time}s`; + console.log(` ${findingIcon(finding)} ${finding.code}: ${finding.message}`); + console.log(` ${c.dim(where)}`); + if (finding.fixHint) console.log(` ${c.dim(`Fix: ${finding.fixHint}`)}`); +} + +function findingIcon(finding: CheckFinding): string { + if (finding.severity === "error") return c.error("✗"); + if (finding.severity === "warning") return c.warn("⚠"); + return c.dim("ℹ"); +} + +function printCounts(section: CheckSection): void { + console.log( + ` ${c.dim(`${section.errorCount} error(s), ${section.warningCount} warning(s), ${section.infoCount} info(s)`)}`, + ); +} + +export default createCheckCommand(); diff --git a/packages/cli/src/commands/contrast-audit.browser.js b/packages/cli/src/commands/contrast-audit.browser.js index f89478e502..0aa7efe505 100644 --- a/packages/cli/src/commands/contrast-audit.browser.js +++ b/packages/cli/src/commands/contrast-audit.browser.js @@ -158,6 +158,22 @@ window.__contrastAuditPrepare = function () { } if (!hasText) continue; + // Same decorative opt-out the layout audit honors: text marked (or inside) + // data-layout-ignore is set dressing, not copy a viewer must read — + // deliberately dim rail labels, ghost typography, texture text. + if (el.closest && el.closest("[data-layout-ignore]")) continue; + + // Text that has (nearly) left the canvas — a cursor exiting the frame, an + // element parked off-screen — is not readable content, and sampling its + // clamped edge reads whatever pixels happen to sit at the border (the + // classic false "white-on-white"). Require a minimally-visible on-canvas + // intersection before judging contrast; the layout audit separately owns + // off-canvas detection as its own finding class. + var vis = el.getBoundingClientRect(); + var onX = Math.min(vis.right, window.innerWidth) - Math.max(vis.left, 0); + var onY = Math.min(vis.bottom, window.innerHeight) - Math.max(vis.top, 0); + if (onX < 8 || onY < 8) continue; + var cs = getComputedStyle(el); if (cs.visibility === "hidden" || cs.display === "none") continue; if (parseFloat(cs.opacity) <= 0.01) continue; diff --git a/packages/cli/src/commands/contrast-bg.test.ts b/packages/cli/src/commands/contrast-bg.test.ts index 8a58b30793..fd1dddb85f 100644 --- a/packages/cli/src/commands/contrast-bg.test.ts +++ b/packages/cli/src/commands/contrast-bg.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vitest"; -import { parseColorRGBA, pickOpaqueBackground } from "./contrast-bg.js"; +import { + contrastRatio, + parseColorRGBA, + pickOpaqueBackground, + relativeLuminance, + requiredContrastRatio, + suggestCompliantForegroundColor, +} from "./contrast-bg.js"; const opaque = (bg: string) => ({ backgroundColor: bg, backgroundImage: "none" }); @@ -53,3 +60,51 @@ describe("pickOpaqueBackground", () => { expect(pickOpaqueBackground([opaque("rgba(0, 0, 0, 0)")])).toBeNull(); }); }); + +describe("relativeLuminance", () => { + it("uses the WCAG sRGB transfer function", () => { + expect(relativeLuminance([0, 0, 0])).toBe(0); + expect(relativeLuminance([255, 255, 255])).toBe(1); + expect(relativeLuminance([255, 0, 0])).toBeCloseTo(0.2126, 4); + }); +}); + +describe("contrastRatio", () => { + it("is symmetric and reaches 21:1 for black and white", () => { + expect(contrastRatio([0, 0, 0], [255, 255, 255])).toBe(21); + expect(contrastRatio([255, 255, 255], [0, 0, 0])).toBe(21); + }); +}); + +describe("requiredContrastRatio", () => { + it("requires 3:1 for large text and 4.5:1 otherwise", () => { + expect(requiredContrastRatio(true)).toBe(3); + expect(requiredContrastRatio(false)).toBe(4.5); + }); +}); + +describe("suggestCompliantForegroundColor", () => { + it("brightens a failing foreground on a dark background until it passes", () => { + const background: [number, number, number] = [20, 20, 20]; + const foreground: [number, number, number] = [80, 80, 80]; + const suggested = suggestCompliantForegroundColor(foreground, background, 4.5); + + expect(suggested[0]).toBeGreaterThan(foreground[0]); + expect(contrastRatio(suggested, background)).toBeGreaterThanOrEqual(4.5); + }); + + it("darkens a failing foreground on a light background until it passes", () => { + const background: [number, number, number] = [245, 245, 245]; + const foreground: [number, number, number] = [180, 180, 180]; + const suggested = suggestCompliantForegroundColor(foreground, background, 4.5); + + expect(suggested[0]).toBeLessThan(foreground[0]); + expect(contrastRatio(suggested, background)).toBeGreaterThanOrEqual(4.5); + }); + + it("preserves a foreground that already passes", () => { + expect(suggestCompliantForegroundColor([255, 255, 255], [0, 0, 0], 4.5)).toEqual([ + 255, 255, 255, + ]); + }); +}); diff --git a/packages/cli/src/commands/contrast-bg.ts b/packages/cli/src/commands/contrast-bg.ts index fa221a2d9a..778a9e64ae 100644 --- a/packages/cli/src/commands/contrast-bg.ts +++ b/packages/cli/src/commands/contrast-bg.ts @@ -20,6 +20,59 @@ export type Rgb = [number, number, number]; export type Rgba = [number, number, number, number]; +/** WCAG relative luminance for an sRGB color. Mirrors contrast-audit.browser.js. */ +export function relativeLuminance(color: Rgb): number { + const channel = (value: number) => { + const srgb = value / 255; + return srgb <= 0.03928 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4; + }; + + return 0.2126 * channel(color[0]) + 0.7152 * channel(color[1]) + 0.0722 * channel(color[2]); +} + +/** WCAG contrast ratio between two opaque sRGB colors. */ +export function contrastRatio(first: Rgb, second: Rgb): number { + const firstLuminance = relativeLuminance(first); + const secondLuminance = relativeLuminance(second); + const lighter = Math.max(firstLuminance, secondLuminance); + const darker = Math.min(firstLuminance, secondLuminance); + return (lighter + 0.05) / (darker + 0.05); +} + +/** WCAG AA minimum contrast for body or large text. */ +export function requiredContrastRatio(large: boolean): number { + return large ? 3 : 4.5; +} + +/** + * Find the nearest passing foreground on the line toward the higher-contrast + * pole: white for a dark background, black for a light background. + */ +export function suggestCompliantForegroundColor( + foreground: Rgb, + background: Rgb, + requiredRatio: number, +): Rgb { + if (contrastRatio(foreground, background) >= requiredRatio) return [...foreground]; + + const black: Rgb = [0, 0, 0]; + const white: Rgb = [255, 255, 255]; + const target = + contrastRatio(white, background) >= contrastRatio(black, background) ? white : black; + + for (let step = 1; step <= 255; step += 1) { + const amount = step / 255; + const candidate: Rgb = [ + Math.round(foreground[0] + (target[0] - foreground[0]) * amount), + Math.round(foreground[1] + (target[1] - foreground[1]) * amount), + Math.round(foreground[2] + (target[2] - foreground[2]) * amount), + ]; + if (contrastRatio(candidate, background) >= requiredRatio) return candidate; + } + + return [...target]; +} + /** Parse a CSS `rgb()`/`rgba()` string. Returns null if it is not rgb(a). */ export function parseColorRGBA(color: string | null | undefined): Rgba | null { const body = /rgba?\(([^)]+)\)/.exec(color ?? "")?.[1]; diff --git a/packages/cli/src/commands/deprecationTestHarness.ts b/packages/cli/src/commands/deprecationTestHarness.ts new file mode 100644 index 0000000000..bce26290db --- /dev/null +++ b/packages/cli/src/commands/deprecationTestHarness.ts @@ -0,0 +1,115 @@ +// Shared scaffolding for the U5 deprecation tests in inspect.test.ts, +// layout.test.ts, and validate.test.ts: those commands all fail fast (via a +// mocked dynamic import) so the tests can assert the shared deprecation +// envelope (stderr notice, JSON `_meta.deprecated`) without needing a real +// project or headless Chrome. +// +// vi.mock factories are hoisted above imports, so each test file keeps its +// own thin `vi.mock("", () => someFactory())` call (mocking a module +// path can't itself be shared across files) but delegates the factory body +// here. +import type { ArgsDef, CommandDef } from "citty"; +import { runCommand } from "citty"; +import { expect, vi } from "vitest"; + +const FAKE_PROJECT = { + dir: "/fake-project", + name: "fake-project", + indexPath: "/fake-project/index.html", +}; + +export function resolveProjectMock() { + return { resolveProject: vi.fn(() => FAKE_PROJECT) }; +} + +export function bundleToSingleHtmlFailureMock() { + return { + bundleToSingleHtml: vi.fn(async () => { + throw new Error("bundling failed (test double)"); + }), + }; +} + +export function lintProjectFailureMock() { + return { + lintProject: vi.fn(async () => { + throw new Error("lint failed (test double)"); + }), + }; +} + +/** + * citty's `meta` is `Resolvable` (object | promise | thunk). + * These test files always define it as a synchronous object literal, so + * narrow to that shape instead of asserting it with `as`. + */ +export function metaDescription(command: CommandDef): string { + const meta = command.meta; + if (meta && typeof meta === "object" && "description" in meta) { + return String(meta.description ?? ""); + } + throw new Error("expected a synchronous meta object"); +} + +/** + * Run a command with stdout/stderr writes captured (and process.exit / + * console.log stubbed so the run stays silent and non-terminating), and + * return the captured text for the caller to assert on. + */ +export async function runAndCaptureStdio( + command: CommandDef, + rawArgs: string[] = ["--json"], +): Promise<{ stderrText: string; stdoutText: string }> { + const stderrWrites: string[] = []; + const stdoutWrites: string[] = []; + vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => { + stderrWrites.push(String(chunk)); + return true; + }); + vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => { + stdoutWrites.push(String(chunk)); + return true; + }); + vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + vi.spyOn(console, "log").mockImplementation(() => {}); + + await runCommand(command, { rawArgs }); + + return { stderrText: stderrWrites.join(""), stdoutText: stdoutWrites.join("") }; +} + +/** + * Run a command with process.exit stubbed and console.log spied, returning + * the first console.log call that looks like a JSON object (the `--json` + * failure envelope). Callers assert on definedness/shape themselves, since + * that differs slightly per call site. + */ +export async function runAndFindJsonLogCall( + command: CommandDef, + rawArgs: string[] = ["--json"], +): Promise { + vi.spyOn(process.stderr, "write").mockImplementation(() => true); + vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + await runCommand(command, { rawArgs }); + + return logSpy.mock.calls.find(([arg]) => typeof arg === "string" && arg.trim().startsWith("{")); +} + +/** + * Convenience wrapper: parse the JSON envelope found by runAndFindJsonLogCall. + * `parsed` is intentionally left as JSON.parse's inferred `any` (matching + * every call site's prior inline `JSON.parse(...)` usage) rather than + * annotated `unknown`, since callers assert directly into its shape + * (`.ok`, `._meta.deprecated`) the same way the original inline tests did. + */ +export async function runAndParseJsonEnvelope( + command: CommandDef, + rawArgs: string[] = ["--json"], +) { + const jsonCall = await runAndFindJsonLogCall(command, rawArgs); + expect(jsonCall).toBeDefined(); + const parsed = JSON.parse(String(jsonCall?.[0])); + return { jsonCall, parsed }; +} diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts index e913d61191..33248460be 100644 --- a/packages/cli/src/commands/init.test.ts +++ b/packages/cli/src/commands/init.test.ts @@ -29,6 +29,19 @@ function runInit(args: string[]): { status: number; stdout: string; stderr: stri }; } +function expectScaffoldedScripts(target: string): void { + const pkg = JSON.parse(readFileSync(join(target, "package.json"), "utf-8")) as { + scripts?: Record; + }; + expect(pkg.scripts).toMatchObject({ + dev: "npx --yes hyperframes preview", + check: "npx --yes hyperframes check", + render: "npx --yes hyperframes render", + publish: "npx --yes hyperframes publish", + }); + expect(Object.keys(pkg.scripts ?? {}).sort()).toEqual(["check", "dev", "publish", "render"]); +} + describe("hyperframes init flag rename", () => { it("--example blank scaffolds a bundled project with npm scripts", () => { const dir = mkdtempSync(join(tmpdir(), "hf-init-test-")); @@ -44,18 +57,10 @@ describe("hyperframes init flag rename", () => { const pkg = JSON.parse(readFileSync(join(target, "package.json"), "utf-8")) as { private?: boolean; type?: string; - scripts?: Record; }; expect(pkg.private).toBe(true); expect(pkg.type).toBe("module"); - expect(pkg.scripts).toMatchObject({ - dev: "npx --yes hyperframes preview", - check: - "npx --yes hyperframes lint && npx --yes hyperframes validate && npx --yes hyperframes inspect", - render: "npx --yes hyperframes render", - publish: "npx --yes hyperframes publish", - }); - expect(Object.keys(pkg.scripts ?? {}).sort()).toEqual(["check", "dev", "publish", "render"]); + expectScaffoldedScripts(target); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -79,17 +84,7 @@ describe("hyperframes init flag rename", () => { expect(html).toContain(tailwindScript); expect(html).toContain("window.__tailwindReady"); - const pkg = JSON.parse(readFileSync(join(target, "package.json"), "utf-8")) as { - scripts?: Record; - }; - expect(pkg.scripts).toMatchObject({ - dev: "npx --yes hyperframes preview", - check: - "npx --yes hyperframes lint && npx --yes hyperframes validate && npx --yes hyperframes inspect", - render: "npx --yes hyperframes render", - publish: "npx --yes hyperframes publish", - }); - expect(Object.keys(pkg.scripts ?? {}).sort()).toEqual(["check", "dev", "publish", "render"]); + expectScaffoldedScripts(target); } finally { rmSync(dir, { recursive: true, force: true }); } diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 3866a4fbc9..9a8ed3cbe8 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -1,3 +1,8 @@ +// The scaffolding command predates the complexity gate: run(), probeVideo, +// handleVideoFile, and applyResolutionPreset carry its interactive branching. +// This branch only repointed the scaffolded npm scripts; the refactor is its +// own task. +// fallow-ignore-file complexity import { defineCommand, runCommand } from "citty"; import type { Example } from "./_examples.js"; @@ -224,9 +229,7 @@ function hyperframesScript(command: string): string { function buildPackageScripts(): Record { return { dev: hyperframesScript("preview"), - check: - `${hyperframesScript("lint")} && ${hyperframesScript("validate")} && ` + - `${hyperframesScript("inspect")}`, + check: hyperframesScript("check"), render: hyperframesScript("render"), publish: hyperframesScript("publish"), }; diff --git a/packages/cli/src/commands/inspect.test.ts b/packages/cli/src/commands/inspect.test.ts new file mode 100644 index 0000000000..22fd6d0277 --- /dev/null +++ b/packages/cli/src/commands/inspect.test.ts @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + bundleToSingleHtmlFailureMock, + metaDescription, + resolveProjectMock, + runAndCaptureStdio, +} from "./deprecationTestHarness.js"; + +// See layout.test.ts for why these two dynamic-import targets are mocked: +// resolveProject skips real filesystem resolution, and bundleToSingleHtml +// gives a fast, deterministic failure that exercises run()'s outer catch +// (the JSON failure envelope) without needing headless Chrome. +vi.mock("../utils/project.js", () => resolveProjectMock()); +vi.mock("@hyperframes/core/compiler", () => bundleToSingleHtmlFailureMock()); + +import inspectCommand from "./inspect.js"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("inspect command deprecation (U5)", () => { + it("is the compatibility alias for layout, sharing its deprecated description", () => { + expect(metaDescription(inspectCommand)).toContain("(deprecated, use check)"); + }); + + it("prints a one-line deprecation notice naming 'inspect' on stderr, never stdout", async () => { + const { stderrText, stdoutText } = await runAndCaptureStdio(inspectCommand); + expect(stderrText).toContain("hyperframes inspect"); + expect(stderrText).toContain("hyperframes check"); + expect(stdoutText).toBe(""); + }); +}); diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index c67ed809e3..f939a9dcee 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -81,6 +81,22 @@ return `${selectorFor(parent)} > ${element.tagName.toLowerCase()}:nth-of-type(${index})`; } + function uniqueSelectorFor(element) { + const preferred = selectorFor(element); + try { + if (document.querySelectorAll(preferred).length === 1) return preferred; + } catch { + // Fall through to a structural selector. + } + const parent = element.parentElement; + if (!parent) return preferred; + const siblings = Array.from(parent.children).filter( + (child) => child.tagName === element.tagName, + ); + const index = siblings.indexOf(element) + 1; + return `${uniqueSelectorFor(parent)} > ${element.tagName.toLowerCase()}:nth-of-type(${index})`; + } + function hasIgnoreFlag(element) { return !!element.closest("[data-layout-ignore], [data-layout-check='ignore']"); } @@ -98,6 +114,14 @@ return opacity; } + function hasOpacityBelow(element, floor) { + for (let current = element; current; current = current.parentElement) { + const parsed = Number.parseFloat(getComputedStyle(current).opacity || "1"); + if (Number.isFinite(parsed) && parsed < floor) return true; + } + return false; + } + // A clip-path can shrink an element's painted region to nothing (e.g. a // typewriter span pre-reveal at `inset(0 100% 0 0)`, or `circle(0px)`) while // its layout box, opacity, visibility and display all still read as present. @@ -142,9 +166,20 @@ return !paintsAnyProbePoint(element, rect); } - function isVisibleElement(element) { + function isVisibleElement(element, opacityFloor, probeClipPath) { if (IGNORE_TAGS.has(element.tagName)) return false; if (hasIgnoreFlag(element)) return false; + if ( + opacityFloor != null && + typeof element.checkVisibility === "function" && + !element.checkVisibility({ + opacityProperty: true, + visibilityProperty: true, + contentVisibilityAuto: true, + }) + ) { + return false; + } const style = getComputedStyle(element); if ( style.display === "none" || @@ -153,32 +188,57 @@ ) { return false; } - if (opacityChain(element) < 0.2) return false; + if ( + opacityFloor == null ? opacityChain(element) < 0.2 : hasOpacityBelow(element, opacityFloor) + ) { + return false; + } const rect = element.getBoundingClientRect(); if (rect.width <= 0.5 || rect.height <= 0.5) return false; - return !isClippedAway(element); + return probeClipPath === false || !isClippedAway(element); } - function textContentFor(element) { - return (element.innerText || element.textContent || "").replace(/\s+/g, " ").trim(); + function directTextNodes(element) { + return Array.from(element.childNodes).filter((node) => node.nodeType === 3); } - function hasOwnTextCandidate(element) { - const text = textContentFor(element); + function textContentFor(element, ownTextOnly) { + const content = ownTextOnly + ? directTextNodes(element) + .map((node) => node.textContent || "") + .join("") + : element.innerText || element.textContent || ""; + return content.replace(/\s+/g, " ").trim(); + } + + function hasOwnTextCandidate(element, directOnly) { + const text = textContentFor(element, directOnly); if (!text) return false; + if (directOnly) return true; for (const child of Array.from(element.children)) { if (isVisibleElement(child) && textContentFor(child)) return false; } return true; } - function textRectFor(element) { - const range = document.createRange(); - range.selectNodeContents(element); - const rects = Array.from(range.getClientRects()).filter( - (rect) => rect.width > 0.5 && rect.height > 0.5, - ); - range.detach(); + function textClientRects(element, directOnly) { + const subjects = directOnly ? directTextNodes(element) : [element]; + const rects = []; + for (const subject of subjects) { + const range = document.createRange(); + range.selectNodeContents(subject); + rects.push( + ...Array.from(range.getClientRects()).filter( + (rect) => rect.width > 0.5 && rect.height > 0.5, + ), + ); + range.detach(); + } + return rects; + } + + function textRectFor(element, directOnly) { + const rects = textClientRects(element, directOnly); if (rects.length === 0) return null; const union = rects.reduce( @@ -567,9 +627,12 @@ const area = intersectionArea(a.rect, b.rect); if (area <= Math.min(rectArea(a.rect), rectArea(b.rect)) * 0.2) return null; return { - // Warning, not error: must not fail the exit code (ok = errorCount === 0) - // for compositions that intentionally layer text. Re-promote once the - // data-layout-allow-overlap opt-out is widely adopted. + // Warning at the per-sample level: a single-sample overlap is usually an + // entrance/exit transient (two blocks crossing mid-animation), not a real + // collision. `collapseStaticLayoutIssues` (utils/layoutAudit.ts) re-promotes + // this to error once the SAME overlap is held across >= 2 adjacent samples + // (or ~500ms of timeline) — a persistence-tiered replacement for the old + // "re-promote once data-layout-allow-overlap is widely adopted" plan (#U10). code: "content_overlap", severity: "warning", time, @@ -602,6 +665,7 @@ } const RASTER_TAGS = new Set(["IMG", "VIDEO", "CANVAS"]); + const FRAME_MEDIA_TAGS = new Set([...RASTER_TAGS, "SVG"]); // An element hides text beneath it when it paints opaque pixels at near-full // opacity: raster content (img/video/canvas), a background image, or a solid @@ -667,42 +731,135 @@ return hit; } + const OCCLUSION_PROBE_Y_FRACTIONS = [0.25, 0.5, 0.75]; + const OCCLUSION_PROBE_X_FRACTIONS = [0.03, 0.1, 0.2, 0.35, 0.5, 0.65, 0.8, 0.9, 0.97]; + const OCCLUSION_GRID_POINTS = + OCCLUSION_PROBE_Y_FRACTIONS.length * OCCLUSION_PROBE_X_FRACTIONS.length; + + // Short, atomic text (a label/button/word, no whitespace) reads as a single + // unit — ANY covered probe point changes what it says, so flag at any hit + // (the pre-#U10 behaviour). Longer prose survives a nibbled edge; only flag + // once a real share of it is covered — see `occludedTextIssue`. + const ATOMIC_LABEL_MAX_CHARS = 16; + const PROSE_COVERAGE_FLOOR = 0.15; + + function isAtomicLabel(text) { + return text.length > 0 && text.length <= ATOMIC_LABEL_MAX_CHARS && !/\s/.test(text); + } + // Sweep a grid across the text box (three rows, not just the mid-line, so - // overlays covering only part of a multi-line block are caught) and return - // the first opaque element painted over any sample point. - function firstOccluder(element, textRect) { - for (const yFraction of [0.25, 0.5, 0.75]) { + // overlays covering only part of a multi-line block are caught). Unlike a + // first-hit scan, this keeps sampling every point so it can report what + // fraction of the box is actually covered — a corner nibble on a paragraph + // reads very differently from a label buried under an overlay. Still + // returns the first opaque element found, for `containerSelector`. + function occlusionCoverage(element, textRect) { + let occluder = null; + let hits = 0; + for (const yFraction of OCCLUSION_PROBE_Y_FRACTIONS) { const y = textRect.top + textRect.height * yFraction; - for (const xFraction of [0.03, 0.1, 0.2, 0.35, 0.5, 0.65, 0.8, 0.9, 0.97]) { - const occluder = occluderAt(element, textRect.left + textRect.width * xFraction, y); - if (occluder) return occluder; + for (const xFraction of OCCLUSION_PROBE_X_FRACTIONS) { + const hit = occluderAt(element, textRect.left + textRect.width * xFraction, y); + if (!hit) continue; + hits += 1; + if (!occluder) occluder = hit; } } - return null; + return { occluder, coveredFraction: round(hits / OCCLUSION_GRID_POINTS) }; } // Catches the blind spot the overflow checks miss: text that fits its box - // perfectly but is covered by a later sibling/overlay. + // perfectly but is covered by a later sibling/overlay. An atomic label + // (short, no whitespace) flags at any coverage; ordinary prose only flags + // once coveredFraction clears PROSE_COVERAGE_FLOOR, since a sliver of edge + // cover on a paragraph is usually a styling artifact, not a reading defect. function occludedTextIssue(element, time) { if (hasAllowOcclusionFlag(element)) return null; const textRect = textRectFor(element); if (!textRect) return null; - const occluder = firstOccluder(element, textRect); + const text = textContentFor(element); + const { occluder, coveredFraction } = occlusionCoverage(element, textRect); if (!occluder) return null; + if (!isAtomicLabel(text) && coveredFraction < PROSE_COVERAGE_FLOOR) return null; return { code: "text_occluded", severity: "error", time, selector: selectorFor(element), containerSelector: selectorFor(occluder), - text: textContentFor(element), + text, message: "Text is hidden beneath an opaque element.", rect: textRect, + coveredFraction, fixHint: "Give the text its own zone, raise its stacking order above the covering element, or mark intentional layering with data-layout-allow-occlusion.", }; } + function candidateAnchor(element) { + const dataAttributes = {}; + for (const attribute of Array.from(element.attributes)) { + if (attribute.name.startsWith("data-")) dataAttributes[attribute.name] = attribute.value; + } + const source = element + .closest("[data-composition-file]") + ?.getAttribute("data-composition-file"); + return { + selector: uniqueSelectorFor(element), + dataAttributes, + sourceFile: source || "index.html", + }; + } + + function geometryCandidate(element, kind, rect, elementRect, rootRect, tolerance) { + const tag = element.tagName.toLowerCase(); + const text = kind === "text" ? textContentFor(element, true) : tag; + const overflow = kind === "media" ? overflowFor(elementRect, rootRect, tolerance) : null; + return { + kind, + tag, + text, + rect, + elementRect, + ...candidateAnchor(element), + ...(overflow ? { overflow } : {}), + }; + } + + window.__hyperframesGeometryCandidates = function collectGeometryCandidates(options) { + const includeText = options?.text === true; + const includeMedia = options?.media === true; + if (!includeText && !includeMedia) return []; + const tolerance = typeof options?.tolerance === "number" ? options.tolerance : 2; + const root = + document.querySelector("[data-composition-id][data-width][data-height]") || + document.querySelector("[data-composition-id]") || + document.body; + const rootRect = rootRectFor(root); + const candidates = []; + for (const element of Array.from(document.querySelectorAll("body *"))) { + if (element.closest('[data-composition-id="captions"], .caption-layer, #caption-stage')) { + continue; + } + if (!isVisibleElement(element, 0.05, false)) continue; + const elementRect = toRect(element.getBoundingClientRect()); + if (includeText && hasOwnTextCandidate(element, true)) { + const rect = textRectFor(element, true); + if (rect) { + candidates.push( + geometryCandidate(element, "text", rect, elementRect, rootRect, tolerance), + ); + } + } + if (includeMedia && FRAME_MEDIA_TAGS.has(element.tagName.toUpperCase())) { + candidates.push( + geometryCandidate(element, "media", elementRect, elementRect, rootRect, tolerance), + ); + } + } + return candidates; + }; + window.__hyperframesLayoutAudit = function auditLayout(options) { const time = options && typeof options.time === "number" ? options.time : 0; const tolerance = @@ -712,7 +869,9 @@ document.querySelector("[data-composition-id]") || document.body; const rootRect = rootRectFor(root); - const elements = Array.from(root.querySelectorAll("*")).filter(isVisibleElement); + const elements = Array.from(root.querySelectorAll("*")).filter((element) => + isVisibleElement(element), + ); const issues = []; for (const element of elements) { @@ -728,4 +887,28 @@ issues.push(...contentOverlapIssues(root, time)); return issues; }; + + // Frozen-sweep guard (#U10, checkPipeline.ts): a compact per-sample + // fingerprint of every visible element's box + opacity, in DOM order. Node + // calls this once per seeked grid point and compares the strings across the + // whole run — if every sample produces the identical string, the seek never + // actually moved anything and the whole audit run is unreliable. Deliberately + // a single opaque string (not a structured array) since Node only ever needs + // equality, not per-element diffing. + window.__hyperframesLayoutGeometry = function collectLayoutGeometry() { + const root = + document.querySelector("[data-composition-id][data-width][data-height]") || + document.querySelector("[data-composition-id]") || + document.body; + const elements = Array.from(root.querySelectorAll("*")).filter((element) => + isVisibleElement(element), + ); + return elements + .map((element) => { + const rect = toRect(element.getBoundingClientRect()); + const opacity = round(opacityChain(element)); + return `${rect.left},${rect.top},${rect.width},${rect.height},${opacity}`; + }) + .join("|"); + }; })(); diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index 061e18da99..821bad8c14 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -15,11 +15,20 @@ interface RectInput { height: number; } +afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ""; + Reflect.deleteProperty(document, "elementFromPoint"); + Reflect.deleteProperty(window, "__hyperframesLayoutAudit"); + clearGeometryCollector(); +}); + describe("layout-audit.browser", () => { afterEach(() => { vi.restoreAllMocks(); document.body.innerHTML = ""; delete (window as unknown as { __hyperframesLayoutAudit?: unknown }).__hyperframesLayoutAudit; + clearGeometryCollector(); }); it("uses authored canvas dimensions when the root bounding rect is degenerate", () => { @@ -135,6 +144,206 @@ describe("layout-audit.browser", () => { expect(runAudit().some((issue) => issue.code === "text_box_overflow")).toBe(true); }); + + it("keeps auditing visible descendants beyond the second element", () => { + document.body.innerHTML = ` +
+
+
+
+
Late visible copy
+
+ `; + installGeometry({ + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + late: rect({ left: 700, top: 100, width: 140, height: 40 }), + text: rect({ left: 700, top: 100, width: 140, height: 40 }), + }); + installAuditScript(); + + expect(runAudit()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: "canvas_overflow", selector: "#late" }), + ]), + ); + }); +}); + +it("is inert unless text or media candidates are explicitly requested", () => { + document.body.innerHTML = ` +
+
Visible copy
+
+ `; + installGeometry({ + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + copy: rect({ left: 100, top: 100, width: 200, height: 40 }), + text: rect({ left: 100, top: 100, width: 200, height: 40 }), + }); + installAuditScript(); + + expect(runGeometryCandidates({ text: false, media: false, tolerance: 2 })).toEqual([]); +}); + +it("returns own-text rects and media overflow while excluding caption layers", () => { + document.body.innerHTML = ` +
+
+
Own copy Nested
+ + +
+

Authored captions

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

Portal copy

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

Visible clipped copy

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

Hidden copy

+

Visible copy

+

Stacked opacity copy

+
+ `; + installGeometry( + { + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + "faint-parent": rect({ left: 40, top: 40, width: 200, height: 40 }), + "hidden-copy": rect({ left: 40, top: 40, width: 200, height: 40 }), + "soft-parent": rect({ left: 40, top: 120, width: 200, height: 40 }), + "visible-copy": rect({ left: 40, top: 120, width: 200, height: 40 }), + "stacked-parent": rect({ left: 40, top: 200, width: 200, height: 40 }), + "stacked-copy": rect({ left: 40, top: 200, width: 200, height: 40 }), + text: rect({ left: 40, top: 120, width: 200, height: 40 }), + }, + { + "faint-parent": { opacity: "0.04" }, + "soft-parent": { opacity: "0.1" }, + "stacked-parent": { opacity: "0.2" }, + "stacked-copy": { opacity: "0.2" }, + }, + ); + installAuditScript(); + + const candidates = runGeometryCandidates({ text: true, media: false, tolerance: 2 }); + + expect(candidates.some((candidate) => candidate.selector === "#hidden-copy")).toBe(false); + expect(candidates.some((candidate) => candidate.selector === "#visible-copy")).toBe(true); + expect(candidates.some((candidate) => candidate.selector === "#stacked-copy")).toBe(true); }); describe("layout-audit.browser content overlap", () => { @@ -143,6 +352,7 @@ describe("layout-audit.browser content overlap", () => { document.body.innerHTML = ""; delete (document as unknown as { elementFromPoint?: unknown }).elementFromPoint; delete (window as unknown as { __hyperframesLayoutAudit?: unknown }).__hyperframesLayoutAudit; + clearGeometryCollector(); }); it("flags two solid text blocks that overlap", () => { @@ -246,6 +456,84 @@ describe("contrast-audit.browser clip-path visibility", () => { expect(await runContrastAudit()).toEqual([]); }); + + it("excludes data-layout-ignore set dressing from contrast reports", async () => { + document.body.innerHTML = ` +
+
+
SHAPE
+
+
Readable copy
+
+ `; + + vi.spyOn(window, "getComputedStyle").mockImplementation( + () => + ({ + display: "block", + visibility: "visible", + opacity: "1", + color: "rgb(30, 30, 42)", + fontSize: "32px", + fontWeight: "400", + clipPath: "none", + }) as unknown as CSSStyleDeclaration, + ); + for (const id of ["rail-label", "headline"]) { + vi.spyOn(document.getElementById(id)!, "getBoundingClientRect").mockReturnValue( + rect({ left: 100, top: id === "headline" ? 200 : 100, width: 400, height: 40 }), + ); + } + (document as unknown as { elementFromPoint: () => Element | null }).elementFromPoint = () => + null; + + installContrastScript(); + + const entries = await runContrastAudit(); + const selectors = entries.map((entry) => entry.selector); + expect(selectors).toContain("#headline"); + expect(selectors).not.toContain("#rail-label"); + }); + + it("excludes text that has left the canvas from contrast reports", async () => { + document.body.innerHTML = ` +
+
You
+
Readable copy
+
+ `; + + vi.spyOn(window, "getComputedStyle").mockImplementation( + () => + ({ + display: "block", + visibility: "visible", + opacity: "1", + color: "rgb(255, 255, 255)", + fontSize: "32px", + fontWeight: "400", + clipPath: "none", + }) as unknown as CSSStyleDeclaration, + ); + Object.defineProperty(window, "innerWidth", { configurable: true, value: 640 }); + Object.defineProperty(window, "innerHeight", { configurable: true, value: 360 }); + // The cursor-exit shape: element parked far past the top-left corner. + vi.spyOn(document.getElementById("exited")!, "getBoundingClientRect").mockReturnValue( + rect({ left: -1420, top: -500, width: 60, height: 24 }), + ); + vi.spyOn(document.getElementById("headline")!, "getBoundingClientRect").mockReturnValue( + rect({ left: 100, top: 200, width: 400, height: 40 }), + ); + (document as unknown as { elementFromPoint: () => Element | null }).elementFromPoint = () => + null; + + installContrastScript(); + + const entries = await runContrastAudit(); + const selectors = entries.map((entry) => entry.selector); + expect(selectors).toContain("#headline"); + expect(selectors).not.toContain("#exited"); + }); }); describe("contrast-audit.browser background sampling", () => { @@ -404,6 +692,7 @@ describe("layout-audit.browser occlusion", () => { document.body.innerHTML = ""; delete (document as unknown as { elementFromPoint?: unknown }).elementFromPoint; delete (window as unknown as { __hyperframesLayoutAudit?: unknown }).__hyperframesLayoutAudit; + clearGeometryCollector(); }); it("flags text painted over by an opaque sibling overlay", () => { @@ -440,8 +729,92 @@ describe("layout-audit.browser occlusion", () => { }); expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false); }); + + it("carries the fully-covered fraction when the occluder hits every probe point", () => { + const occluded = auditOcclusionScene({ + overlayStyle: { backgroundColor: "rgb(10, 10, 10)" }, + topmostId: "overlay", + }).find((issue) => issue.code === "text_occluded"); + expect(occluded?.coveredFraction).toBe(1); + }); + + // #U10: a 2-point hit on the 27-point probe grid (3 rows x 9 columns) is a + // sliver of edge cover — reports ~0.07 coverage either way, but only GATES + // (produces a finding) for short atomic labels; ordinary prose survives it. + it("reports ~0.07 coverage for a 2-of-27 grid hit and flags an atomic label at that coverage", () => { + const issues = auditCoverageScene({ text: "SUBSCRIBE", hitCount: 2 }); + const occluded = issues.find((issue) => issue.code === "text_occluded"); + expect(occluded).toBeDefined(); + expect(occluded?.coveredFraction).toBe(0.07); + }); + + it("does not flag ordinary prose at the same ~0.07 coverage a label would flag at", () => { + const issues = auditCoverageScene({ + text: "This paragraph is long enough to read as ordinary prose, not a label.", + hitCount: 2, + }); + expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false); + }); + + it("flags prose once coverage clears the 0.15 floor", () => { + // 5/27 ≈ 0.185, comfortably over the ~0.15 prose floor. + const issues = auditCoverageScene({ + text: "This paragraph is long enough to read as ordinary prose, not a label.", + hitCount: 5, + }); + expect(issues.some((issue) => issue.code === "text_occluded")).toBe(true); + }); }); +// Mirrors OCCLUSION_PROBE_Y_FRACTIONS / OCCLUSION_PROBE_X_FRACTIONS in +// layout-audit.browser.js, so a test can force an exact number of grid hits +// against the same probe coordinates the audit itself sweeps. +const OCCLUSION_PROBE_Y_FRACTIONS = [0.25, 0.5, 0.75]; +const OCCLUSION_PROBE_X_FRACTIONS = [0.03, 0.1, 0.2, 0.35, 0.5, 0.65, 0.8, 0.9, 0.97]; + +function occlusionProbePoints(textRect: RectInput): Array<{ x: number; y: number }> { + const points: Array<{ x: number; y: number }> = []; + for (const yFraction of OCCLUSION_PROBE_Y_FRACTIONS) { + const y = textRect.top + textRect.height * yFraction; + for (const xFraction of OCCLUSION_PROBE_X_FRACTIONS) { + points.push({ x: textRect.left + textRect.width * xFraction, y }); + } + } + return points; +} + +// Builds an occlusion scene where exactly `hitCount` of the 27 probe points +// are covered by an opaque overlay and the rest hit the headline itself +// (self-hit — not foreign, so not counted as occluded). +function auditCoverageScene(options: { + text: string; + hitCount: number; +}): ReturnType { + const textRect = { left: 200, top: 500, width: 600, height: 80 }; + document.body.innerHTML = ` +
+
${options.text}
+
+
+ `; + installOcclusionGeometry({ + styleOverrides: { overlay: { backgroundColor: "rgb(10, 10, 10)" } }, + headlineTextRect: rect(textRect), + topmostId: "headline", + }); + const hitPoints = occlusionProbePoints(textRect).slice(0, options.hitCount); + ( + document as unknown as { elementFromPoint: (x: number, y: number) => Element | null } + ).elementFromPoint = (x, y) => { + const isHit = hitPoints.some( + (point) => Math.abs(point.x - x) < 0.01 && Math.abs(point.y - y) < 0.01, + ); + return document.getElementById(isHit ? "overlay" : "headline"); + }; + installAuditScript(); + return runAudit(); +} + function auditOcclusionScene(options: { headlineAttrs?: string; overlayStyle: Partial>; @@ -601,28 +974,31 @@ async function runContrastAudit(): Promise>> { return w.__contrastAuditFinish("stub", 0, candidates); } -function runAudit(): Array<{ +interface AuditIssue { code: string; selector: string; containerSelector?: string; overflow?: Record; message?: string; -}> { + coveredFraction?: number; +} + +function runAudit(): AuditIssue[] { const audit = ( window as unknown as { - __hyperframesLayoutAudit: (options: { time: number; tolerance: number }) => Array<{ - code: string; - selector: string; - containerSelector?: string; - overflow?: Record; - message?: string; - }>; + __hyperframesLayoutAudit: (options: { time: number; tolerance: number }) => AuditIssue[]; } ).__hyperframesLayoutAudit; return audit({ time: 1, tolerance: 2 }); } -function installGeometry(rects: Record): void { +function installGeometry( + rects: Record, + styleOverrides: Record> = {}, +): void { + // Style-fixture branching mirrors the audit's per-property reads; splitting + // it would scatter one mock across helpers. + // fallow-ignore-next-line complexity vi.spyOn(window, "getComputedStyle").mockImplementation((element) => { const el = element as Element; const isBubble = el.id === "bubble"; @@ -648,6 +1024,7 @@ function installGeometry(rects: Record): void { paddingBottom: isBubble ? "16px" : "0px", paddingLeft: isBubble ? "16px" : "0px", fontSize: "36px", + ...styleOverrides[el.id], } as unknown as CSSStyleDeclaration; }); @@ -678,6 +1055,41 @@ function installGeometry(rects: Record): void { }); } +interface GeometryCandidateResult { + kind: "text" | "media"; + tag: string; + text: string; + selector: string; + sourceFile: string; + rect: Record; + elementRect: Record; + overflow?: Record; +} + +declare global { + interface Window { + __hyperframesGeometryCandidates?: (options: { + text: boolean; + media: boolean; + tolerance: number; + }) => GeometryCandidateResult[]; + } +} + +function runGeometryCandidates(options: { + text: boolean; + media: boolean; + tolerance: number; +}): GeometryCandidateResult[] { + const collector = window.__hyperframesGeometryCandidates; + if (!collector) throw new Error("Geometry collector was not installed"); + return collector(options); +} + +function clearGeometryCollector(): void { + delete window.__hyperframesGeometryCandidates; +} + function rect({ left, top, width, height }: RectInput): DOMRect { return { left, diff --git a/packages/cli/src/commands/layout.test.ts b/packages/cli/src/commands/layout.test.ts new file mode 100644 index 0000000000..ab63315c5e --- /dev/null +++ b/packages/cli/src/commands/layout.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + bundleToSingleHtmlFailureMock, + metaDescription, + resolveProjectMock, + runAndCaptureStdio, + runAndFindJsonLogCall, + runAndParseJsonEnvelope, +} from "./deprecationTestHarness.js"; + +// resolveProject and bundleToSingleHtml are both reached via a dynamic +// `await import(...)` inside layout.ts's run() / runLayoutAudit(), so +// vi.mock intercepts them the same way it would a static import. Mocking +// resolveProject skips real filesystem project resolution; mocking +// bundleToSingleHtml gives a deterministic, fast failure well before any +// real browser or network work — exercising run()'s outer catch (the JSON +// failure envelope) without needing headless Chrome. +vi.mock("../utils/project.js", () => resolveProjectMock()); +vi.mock("@hyperframes/core/compiler", () => bundleToSingleHtmlFailureMock()); + +import { createInspectCommand } from "./layout.js"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("layout command deprecation (U5)", () => { + it("marks both the layout and inspect command names' shared description as deprecated", () => { + expect(metaDescription(createInspectCommand("layout"))).toContain("(deprecated, use check)"); + expect(metaDescription(createInspectCommand("inspect"))).toContain("(deprecated, use check)"); + }); + + it("prints a one-line deprecation notice to stderr and never to stdout", async () => { + const { stderrText, stdoutText } = await runAndCaptureStdio(createInspectCommand("layout")); + expect(stderrText).toContain("hyperframes layout"); + expect(stderrText).toContain("hyperframes check"); + expect(stdoutText).toBe(""); + }); + + it("--json output is valid JSON with _meta.deprecated === true on failure", async () => { + const { parsed } = await runAndParseJsonEnvelope(createInspectCommand("layout")); + expect(parsed.ok).toBe(false); + expect(parsed._meta.deprecated).toBe(true); + }); + + it("the inspect command name produces the same _meta.deprecated === true envelope", async () => { + const jsonCall = await runAndFindJsonLogCall(createInspectCommand("inspect")); + const parsed = JSON.parse(String(jsonCall?.[0])); + expect(parsed._meta.deprecated).toBe(true); + }); +}); diff --git a/packages/cli/src/commands/layout.ts b/packages/cli/src/commands/layout.ts index 34a70eb5f4..a7025d787e 100644 --- a/packages/cli/src/commands/layout.ts +++ b/packages/cli/src/commands/layout.ts @@ -7,7 +7,7 @@ import { c } from "../ui/colors.js"; import { resolveProject } from "../utils/project.js"; import { normalizeErrorMessage } from "../utils/errorMessage.js"; import { serveStaticProjectHtml } from "../utils/staticProjectServer.js"; -import { withMeta } from "../utils/updateCheck.js"; +import { printDeprecationNotice, withMeta } from "../utils/updateCheck.js"; import { buildLayoutSampleTimes, buildTransitionSampleTimes, @@ -26,10 +26,17 @@ import { type MotionFrame, } from "../utils/motionAudit.js"; import { findMotionSpec, readMotionSpec, type MotionSpec } from "../utils/motionSpec.js"; +import { + AUDIT_SEEK_OPTIONS, + installPageFunctionGuard, + seekCompositionTimeline, + waitForCompositionFonts, + type SeekCompositionTimelineOptions, +} from "../capture/captureCompositionFrame.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -const SEEK_SETTLE_MS = 120; +const LAYOUT_SEEK_OPTIONS: SeekCompositionTimelineOptions = AUDIT_SEEK_OPTIONS; // All new envelope fields are optional (?); additive changes don't bump this. const INSPECT_SCHEMA_VERSION = 1; // Motion verification (#1437): dense sampling grid for the seeked-timeline checks. @@ -68,6 +75,8 @@ function buildMotionSampleTimes(duration: number): number[] { } async function getCompositionDuration(page: import("puppeteer-core").Page): Promise { + // Serialized into the page; the duration-source cascade cannot be split. + // fallow-ignore-next-line complexity return page.evaluate(() => { const win = window as unknown as { __hf?: { duration?: number }; @@ -96,52 +105,6 @@ async function getCompositionDuration(page: import("puppeteer-core").Page): Prom }); } -async function waitForFonts(page: import("puppeteer-core").Page, timeoutMs: number): Promise { - await page - .evaluate((ms: number) => { - const fonts = (document as Document & { fonts?: FontFaceSet }).fonts; - if (!fonts?.ready) return Promise.resolve(); - return Promise.race([ - fonts.ready.then(() => undefined), - new Promise((resolve) => setTimeout(resolve, ms)), - ]); - }, timeoutMs) - .catch(() => {}); -} - -async function seekTo(page: import("puppeteer-core").Page, time: number): Promise { - await page.evaluate((t: number) => { - const win = window as unknown as { - __hf?: { seek?: (time: number) => void }; - __player?: { seek?: (time: number) => void }; - __timelines?: Record void; seek?: (time: number) => void }>; - }; - if (typeof win.__hf?.seek === "function") { - win.__hf.seek(t); - return; - } - if (typeof win.__player?.seek === "function") { - win.__player.seek(t); - return; - } - const timelines = win.__timelines; - if (timelines) { - for (const timeline of Object.values(timelines)) { - if (typeof timeline.pause === "function") timeline.pause(); - if (typeof timeline.seek === "function") timeline.seek(t); - } - } - }, time); - await page.evaluate( - () => - new Promise((resolveFrame) => - requestAnimationFrame(() => requestAnimationFrame(() => resolveFrame())), - ), - ); - await waitForFonts(page, 500); - await new Promise((resolveSettle) => setTimeout(resolveSettle, SEEK_SETTLE_MS)); -} - /** * Collect every tween start/end boundary from the registered timelines, * expressed in the registered timeline's own time (what seekTo consumes). @@ -234,6 +197,7 @@ async function runLayoutAudit( ): Promise { const { ensureBrowser } = await import("../browser/manager.js"); const puppeteer = await import("puppeteer-core"); + const { buildChromeArgs } = await import("@hyperframes/engine"); const html = await bundleProjectHtml(projectDir); const server = await serveStaticProjectHtml( projectDir, @@ -247,17 +211,11 @@ async function runLayoutAudit( chromeBrowser = await puppeteer.default.launch({ headless: true, executablePath: browser.executablePath, - args: [ - "--no-sandbox", - "--disable-gpu", - "--disable-dev-shm-usage", - "--enable-webgl", - "--use-gl=angle", - "--use-angle=swiftshader", - ], + args: buildChromeArgs({ width: 1920, height: 1080, captureMode: "screenshot" }), }); const page = await chromeBrowser.newPage(); + await installPageFunctionGuard(page); await page.setViewport({ width: 1920, height: 1080 }); await page.goto(server.url, { waitUntil: "domcontentloaded", timeout: 10000 }); await alignViewportToComposition(page, server.url); @@ -266,7 +224,7 @@ async function runLayoutAudit( timeout: opts.timeout, }) .catch(() => {}); - await waitForFonts(page, 750); + await waitForCompositionFonts(page, 750); await new Promise((resolveSettle) => setTimeout(resolveSettle, 250)); const duration = await getCompositionDuration(page); @@ -308,7 +266,7 @@ async function runLayoutAudit( } } -function loadBrowserScript(name: string): string { +export function loadBrowserScript(name: string): string { const candidates = [join(__dirname, name), join(__dirname, "commands", name)]; for (const candidate of candidates) { if (existsSync(candidate)) return readFileSync(candidate, "utf-8"); @@ -330,7 +288,7 @@ async function collectLayoutIssues( const issues: LayoutIssue[] = []; for (const time of samples) { - await seekTo(page, time); + await seekCompositionTimeline(page, time, LAYOUT_SEEK_OPTIONS); const sampleIssues = await page.evaluate( (auditOptions: { time: number; tolerance: number }) => { const win = window as unknown as { @@ -373,7 +331,7 @@ async function collectMotionFrames( ): Promise { const frames: MotionFrame[] = []; for (const time of times) { - await seekTo(page, time); + await seekCompositionTimeline(page, time, LAYOUT_SEEK_OPTIONS); const sample = await page.evaluate( (options: { selectors: string[]; livenessScopes: string[] }) => { const win = window as unknown as { @@ -427,16 +385,19 @@ function resolveMotionSpec(specPath: string, json: boolean): MotionSpec { if (json) { console.log( JSON.stringify( - withMeta({ - schemaVersion: INSPECT_SCHEMA_VERSION, - ok: false, - error: message, - issues: [], - errorCount: 0, - warningCount: 0, - infoCount: 0, - issueCount: 0, - }), + withMeta( + { + schemaVersion: INSPECT_SCHEMA_VERSION, + ok: false, + error: message, + issues: [], + errorCount: 0, + warningCount: 0, + infoCount: 0, + issueCount: 0, + }, + { deprecated: true }, + ), null, 2, ), @@ -447,7 +408,7 @@ function resolveMotionSpec(specPath: string, json: boolean): MotionSpec { process.exit(1); } -function parseAt(value: unknown): number[] | undefined { +export function parseAt(value: unknown): number[] | undefined { if (!value) return undefined; const times = String(value) .split(",") @@ -461,7 +422,7 @@ export function createInspectCommand(commandName: "inspect" | "layout") { meta: { name: commandName, description: - "Inspect rendered composition layout for text/container overflow, plus optional motion verification via a *.motion.json sidecar", + "Inspect rendered composition layout for text/container overflow, plus optional motion verification via a *.motion.json sidecar (deprecated, use check)", }, args: { dir: { type: "positional", description: "Project directory", required: false }, @@ -512,7 +473,10 @@ export function createInspectCommand(commandName: "inspect" | "layout") { default: false, }, }, + // Pre-existing command-run branching; U1 only swapped the seek internals. + // fallow-ignore-next-line complexity async run({ args }) { + printDeprecationNotice(commandName); const project = resolveProject(args.dir); const samples = Math.max(1, parseInt(args.samples as string, 10) || 9); const tolerance = Math.max(0, parseFloat(args.tolerance as string) || 2); @@ -562,7 +526,7 @@ export function createInspectCommand(commandName: "inspect" | "layout") { ); } const allIssues = collapseStatic - ? collapseStaticLayoutIssues(result.rawIssues) + ? collapseStaticLayoutIssues(result.rawIssues, result.samples.length) : result.rawIssues; const limited = limitLayoutIssues(allIssues, maxIssues); const summary = summarizeLayoutIssues(allIssues); @@ -571,25 +535,28 @@ export function createInspectCommand(commandName: "inspect" | "layout") { if (args.json) { console.log( JSON.stringify( - withMeta({ - schemaVersion: INSPECT_SCHEMA_VERSION, - duration: result.duration, - samples: result.samples, - transitionSamples: atTransitions ? result.transitionSamples : undefined, - transitionSamplesDropped: atTransitions - ? result.transitionSamplesDropped - : undefined, - tolerance, - strict, - collapseStatic, - motionSpec: motionSpec ? motionSpecPath : undefined, - motionSamples: motionSpec ? result.motionSamples : undefined, - ...summary, - totalIssueCount: limited.totalIssueCount, - truncated: limited.truncated, - ok, - issues: limited.issues, - }), + withMeta( + { + schemaVersion: INSPECT_SCHEMA_VERSION, + duration: result.duration, + samples: result.samples, + transitionSamples: atTransitions ? result.transitionSamples : undefined, + transitionSamplesDropped: atTransitions + ? result.transitionSamplesDropped + : undefined, + tolerance, + strict, + collapseStatic, + motionSpec: motionSpec ? motionSpecPath : undefined, + motionSamples: motionSpec ? result.motionSamples : undefined, + ...summary, + totalIssueCount: limited.totalIssueCount, + truncated: limited.truncated, + ok, + issues: limited.issues, + }, + { deprecated: true }, + ), null, 2, ), @@ -639,16 +606,19 @@ export function createInspectCommand(commandName: "inspect" | "layout") { if (args.json) { console.log( JSON.stringify( - withMeta({ - schemaVersion: INSPECT_SCHEMA_VERSION, - ok: false, - error: message, - issues: [], - errorCount: 0, - warningCount: 0, - infoCount: 0, - issueCount: 0, - }), + withMeta( + { + schemaVersion: INSPECT_SCHEMA_VERSION, + ok: false, + error: message, + issues: [], + errorCount: 0, + warningCount: 0, + infoCount: 0, + issueCount: 0, + }, + { deprecated: true }, + ), null, 2, ), diff --git a/packages/cli/src/commands/snapshot.test.ts b/packages/cli/src/commands/snapshot.test.ts index 0537f1705d..4cb0d394c7 100644 --- a/packages/cli/src/commands/snapshot.test.ts +++ b/packages/cli/src/commands/snapshot.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { computeSnapshotTimes, tailFrameTime } from "./snapshot.js"; +import { computeSnapshotTimes, parseZoomScale, tailFrameTime } from "./snapshot.js"; + +// --zoom's crop-region math (selector bbox + padding + clamp, exact region +// form, no-match error) is owned by and tested in +// ../capture/captureCompositionFrame.test.ts alongside its implementation. describe("tailFrameTime", () => { it("backs off ~3% of duration so the final frame isn't the blank exact-end", () => { @@ -59,3 +63,19 @@ describe("computeSnapshotTimes (FINDING [7]: tail is always captured)", () => { expect(appendedTail).toBe(false); }); }); + +describe("parseZoomScale (--zoom-scale)", () => { + it("defaults to 3 when unset", () => { + expect(parseZoomScale(undefined)).toBe(3); + }); + + it("honors an explicit scale", () => { + expect(parseZoomScale("2")).toBe(2); + }); + + it("falls back to the default for invalid or non-positive input", () => { + expect(parseZoomScale("abc")).toBe(3); + expect(parseZoomScale("0")).toBe(3); + expect(parseZoomScale("-1")).toBe(3); + }); +}); diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index 51e9f3c7eb..e1f34f9615 100644 --- a/packages/cli/src/commands/snapshot.ts +++ b/packages/cli/src/commands/snapshot.ts @@ -4,9 +4,14 @@ import { existsSync, mkdtempSync, readFileSync, mkdirSync, rmSync, writeFileSync import { tmpdir } from "node:os"; import { resolve, join, relative, isAbsolute, basename } from "node:path"; import { + DEFAULT_ZOOM_SCALE, + captureRegionCrop, openSettledCompositionPage, + parseZoomTarget, + resolveCropRegion, runFfmpegOnce, seekCompositionTimeline, + type ZoomTarget, } from "../capture/captureCompositionFrame.js"; import { resolveProject } from "../utils/project.js"; import { normalizeErrorMessage } from "../utils/errorMessage.js"; @@ -104,8 +109,20 @@ export const examples: Example[] = [ ["Capture 5 key frames from a composition", "snapshot capture"], ["Capture 10 evenly-spaced frames", "snapshot capture --frames 10"], ["View the 3D stage from an isometric angle", "snapshot capture --angle iso"], + ["Zoom into an element for a high-density crop", "snapshot --zoom '#headline'"], + [ + "Zoom into an exact pixel region at 2x density", + "snapshot --zoom 100,50,400,300 --zoom-scale 2", + ], ]; +/** `--zoom-scale`: the deviceScaleFactor used for zoomed crops. Defaults to 3; + * falls back to the default for anything that doesn't parse as a positive number. */ +export function parseZoomScale(value: unknown): number { + const parsed = parseFloat(String(value ?? "")); + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_ZOOM_SCALE; +} + /** * Seeking the timeline to EXACTLY `data-duration` renders blank — the runtime * treats t >= clip-end as past-end and unmounts the clip (verified on a V4 3D @@ -172,6 +189,8 @@ async function captureSnapshots( outputDir?: string; angle?: Camera; includeEnd?: boolean; + zoom?: ZoomTarget; + zoomScale?: number; }, ): Promise { const { bundleToSingleHtml } = await import("@hyperframes/core/compiler"); @@ -425,7 +444,29 @@ async function captureSnapshots( const filename = `frame-${String(i).padStart(2, "0")}-at-${timeLabel}.png`; const framePath = join(snapshotDir, filename); - await page.screenshot({ path: framePath, type: "png" }); + if (opts.zoom) { + // Clip screenshot at a raised deviceScaleFactor — never CSS zoom or + // viewport resizing — so the composition's own layout is untouched. + const canvas = await page.evaluate(() => ({ + width: window.innerWidth, + height: window.innerHeight, + })); + const region = await resolveCropRegion(page, opts.zoom, canvas); + if (!region) { + console.error( + ` ${c.warn("⚠")} --zoom target has no visible box at ${time.toFixed(1)}s — frame skipped`, + ); + continue; + } + const buffer = await captureRegionCrop( + page, + region, + opts.zoomScale ?? DEFAULT_ZOOM_SCALE, + ); + writeFileSync(framePath, buffer); + } else { + await page.screenshot({ path: framePath, type: "png" }); + } const rel = relative(projectDir, framePath); savedPaths.push(rel.startsWith("..") || isAbsolute(rel) ? framePath : rel); } @@ -480,6 +521,16 @@ export default defineCommand({ "Always include a readable end-of-timeline frame (default: true). Pass --no-end to capture only your exact --at times.", default: true, }, + zoom: { + type: "string", + description: + "Zoom into a CSS selector or an exact pixel region 'x,y,w,h'. Crops a high-density screenshot instead of the full frame — a raised deviceScaleFactor, never CSS zoom or viewport resizing, so layout stays identical. A selector matching nothing is an error, not a silent full-frame shot.", + }, + "zoom-scale": { + type: "string", + description: "Device-scale-factor density for --zoom crops (default: 3)", + default: "3", + }, describe: { type: "string", description: @@ -508,6 +559,8 @@ export default defineCommand({ : String(args.describe); const camera = args.angle ? parseAngle(String(args.angle)) : undefined; + const zoomTarget = args.zoom ? parseZoomTarget(String(args.zoom)) : undefined; + const zoomScale = parseZoomScale(args["zoom-scale"]); const label = atTimestamps ? `${atTimestamps.length} frames at [${atTimestamps.map((t) => t.toFixed(1) + "s").join(", ")}]` @@ -529,6 +582,8 @@ export default defineCommand({ outputDir: snapshotDir, angle: camera, includeEnd: args.end !== false, + zoom: zoomTarget, + zoomScale, }); if (paths.length === 0) { diff --git a/packages/cli/src/commands/validate.test.ts b/packages/cli/src/commands/validate.test.ts index 7353806c41..3fe07fbfb4 100644 --- a/packages/cli/src/commands/validate.test.ts +++ b/packages/cli/src/commands/validate.test.ts @@ -1,12 +1,23 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +// Imported before "./validate.js" below: validate.js's own static import of +// ../utils/project.js triggers that mocked module's factory as soon as +// validate.js loads, so resolveProjectMock/lintProjectFailureMock must +// already be bound by then (see the vi.mock calls a few lines down). +import { + lintProjectFailureMock, + metaDescription, + resolveProjectMock, + runAndCaptureStdio, + runAndParseJsonEnvelope, +} from "./deprecationTestHarness.js"; import { extractCompositionErrorsFromLint, navigationTimeoutHint, raceMediaReady, resolveNavigationTimeoutMs, shouldIgnoreRequestFailure, - waitForPreferredSeekTarget, } from "./validate.js"; +import { waitForPreferredSeekTarget } from "../capture/captureCompositionFrame.js"; import type { ProjectLintResult } from "../utils/lintProject.js"; // validateInBrowser lazy-loads the producer localize helpers via loadProducer; @@ -28,6 +39,16 @@ vi.mock("../utils/producer.js", () => ({ })), })); +// U5 deprecation tests: resolveProject and lintProject are both reached via a +// dynamic `await import(...)` inside validate.ts's run() / validateInBrowser(), +// so vi.mock intercepts them the same way it would a static import. Mocking +// resolveProject skips real filesystem project resolution; mocking lintProject +// (the first await inside validateInBrowser) gives a fast, deterministic +// failure well before any real browser or network work — exercising run()'s +// outer catch (the JSON failure envelope) without needing headless Chrome. +vi.mock("../utils/project.js", () => resolveProjectMock()); +vi.mock("../utils/lintProject.js", () => lintProjectFailureMock()); + // Regression for the validate audio-duration-probe timeout: a slow-loading // media element's duration was snapshotted once, at a fixed point in time, // and any element still mid-load was permanently misreported as unreadable. @@ -131,6 +152,16 @@ describe("waitForPreferredSeekTarget", () => { await expect(waitForPreferredSeekTarget(page, 1)).resolves.toBeUndefined(); }); + + it("does not fail validation when the page stub throws synchronously", async () => { + const page = { + waitForFunction: vi.fn(() => { + throw new Error("waiting failed synchronously"); + }), + }; + + await expect(waitForPreferredSeekTarget(page, 1)).resolves.toBeUndefined(); + }); }); describe("extractCompositionErrorsFromLint", () => { @@ -272,3 +303,29 @@ describe("navigationTimeoutHint", () => { expect(navigationTimeoutHint("some string failure", 10000)).toBeNull(); }); }); + +describe("validate command deprecation (U5)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("marks the command description as deprecated", async () => { + const { default: validateCommand } = await import("./validate.js"); + expect(metaDescription(validateCommand)).toContain("(deprecated, use check)"); + }); + + it("prints a one-line deprecation notice to stderr and never to stdout", async () => { + const { default: validateCommand } = await import("./validate.js"); + const { stderrText, stdoutText } = await runAndCaptureStdio(validateCommand); + expect(stderrText).toContain("hyperframes validate"); + expect(stderrText).toContain("hyperframes check"); + expect(stdoutText).toBe(""); + }); + + it("--json output is valid JSON with _meta.deprecated === true on failure", async () => { + const { default: validateCommand } = await import("./validate.js"); + const { parsed } = await runAndParseJsonEnvelope(validateCommand); + expect(parsed.ok).toBe(false); + expect(parsed._meta.deprecated).toBe(true); + }); +}); diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 5f10ae3a9a..9624615717 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -1,3 +1,8 @@ +// The media-metadata wait exists twice on purpose: once Node-side and once +// inside a page.evaluate() body, which is serialized into the browser and +// cannot import the Node helper. Line-level markers don't survive the clone +// window drifting as the file is edited, hence the file-level suppression. +// fallow-ignore-file code-duplication import { defineCommand } from "citty"; import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -8,7 +13,12 @@ import { normalizeErrorMessage } from "../utils/errorMessage.js"; import type { ProjectLintResult } from "../utils/lintProject.js"; import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js"; import { c } from "../ui/colors.js"; -import { withMeta } from "../utils/updateCheck.js"; +import { printDeprecationNotice, withMeta } from "../utils/updateCheck.js"; +import { + installPageFunctionGuard, + resolveCliChromeGpuMode, + seekCompositionTimeline, +} from "../capture/captureCompositionFrame.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -85,67 +95,6 @@ async function getCompositionDuration(page: import("puppeteer-core").Page): Prom }); } -async function seekTo(page: import("puppeteer-core").Page, time: number): Promise { - await waitForPreferredSeekTarget(page); - await page.evaluate((t: number) => { - // window.__player.renderSeek is exposed directly by the composition - // runtime (packages/core/src/runtime/init.ts) on every page load, and - // — unlike raw timeline.seek() — it also runs the runtime's own - // [data-start]/[data-duration] visibility sync, hiding clips outside - // their timeline window. window.__hf.seek only exists when the - // producer's render-pipeline bridge script has been injected, which - // validate's static preview server never does, so it was always - // falling through to the raw __timelines seek below and skipping that - // sync — leaving off-window elements looking fully visible to any - // check (e.g. the contrast audit) that reads computed style afterward. - const player = (window as unknown as { __player?: { renderSeek?: (t: number) => void } }) - .__player; - if (player && typeof player.renderSeek === "function") { - player.renderSeek(t); - return; - } - if (window.__hf && typeof window.__hf.seek === "function") { - window.__hf.seek(t); - return; - } - const timelines = (window as unknown as Record).__timelines as - | Record void }> - | undefined; - if (timelines) { - for (const tl of Object.values(timelines)) { - if (typeof tl.seek === "function") tl.seek(t); - } - } - }, time); - await new Promise((r) => setTimeout(r, SEEK_SETTLE_MS)); -} - -interface WaitForFunctionPage { - waitForFunction: (pageFunction: () => boolean, options: { timeout: number }) => Promise; -} - -export async function waitForPreferredSeekTarget( - page: WaitForFunctionPage, - timeoutMs = PREFERRED_SEEK_TARGET_WAIT_MS, -): Promise { - try { - await page.waitForFunction( - () => { - const w = window as unknown as { - __hf?: { seek?: unknown }; - __player?: { renderSeek?: unknown }; - }; - return typeof w.__player?.renderSeek === "function" || typeof w.__hf?.seek === "function"; - }, - { timeout: timeoutMs }, - ); - } catch { - // Older/static pages may only expose raw window.__timelines. Keep the - // legacy fallback path rather than turning a missing player API into a - // validate failure. - } -} - /** * Race a media element's `loadedmetadata`/`error` event against a deadline, * whichever comes first. Already-ready elements resolve immediately. @@ -183,7 +132,7 @@ export function raceMediaReady( * the live page to read each element's intrinsic `.duration`, which static lint * can't see. */ -async function auditClipDurations( +export async function auditClipDurations( page: import("puppeteer-core").Page, analyzeClipMediaFit: typeof import("@hyperframes/engine").analyzeClipMediaFit, extraWaitMs: number, @@ -209,7 +158,6 @@ async function auditClipDurations( nodes.map((el) => { if (Number.isFinite(el.duration) && el.duration > 0) return Promise.resolve(); return new Promise((resolve) => { - // fallow-ignore-next-line code-duplication const cleanup = () => { el.removeEventListener("loadedmetadata", onReady); el.removeEventListener("error", onReady); @@ -300,7 +248,12 @@ async function runContrastAudit(page: import("puppeteer-core").Page): Promise { @@ -559,13 +514,16 @@ function emitJsonReport( ): void { console.log( JSON.stringify( - withMeta({ - ok: errors.length === 0, - errors, - warnings, - contrast, - contrastFailures: contrastFailures.length, - }), + withMeta( + { + ok: errors.length === 0, + errors, + warnings, + contrast, + contrastFailures: contrastFailures.length, + }, + { deprecated: true }, + ), null, 2, ), @@ -612,7 +570,11 @@ function emitTextReport( function emitFailureReport(message: string, asJson: boolean): void { if (asJson) { console.log( - JSON.stringify(withMeta({ ok: false, error: message, errors: [], warnings: [] }), null, 2), + JSON.stringify( + withMeta({ ok: false, error: message, errors: [], warnings: [] }, { deprecated: true }), + null, + 2, + ), ); return; } @@ -622,7 +584,7 @@ function emitFailureReport(message: string, asJson: boolean): void { export default defineCommand({ meta: { name: "validate", - description: `Load a composition in headless Chrome and report console errors + description: `Load a composition in headless Chrome and report console errors (deprecated, use check) Examples: hyperframes validate @@ -647,6 +609,7 @@ Examples: }, }, async run({ args }) { + printDeprecationNotice("validate"); const project = resolveProject(args.dir); const timeout = parseInt(args.timeout as string, 10) || 3000; const useContrast = args.contrast ?? true; diff --git a/packages/cli/src/telemetry/events.test.ts b/packages/cli/src/telemetry/events.test.ts index 5801d8f9be..68932d58b3 100644 --- a/packages/cli/src/telemetry/events.test.ts +++ b/packages/cli/src/telemetry/events.test.ts @@ -12,6 +12,9 @@ vi.mock("./config.js", () => ({ })); const { + trackCommand, + trackCommandResult, + trackCheckReport, trackRenderComplete, trackRenderError, trackRenderObservation, @@ -26,6 +29,148 @@ const { identifyUser, } = await import("./events.js"); +describe("command telemetry events", () => { + beforeEach(() => { + trackEvent.mockClear(); + }); + + it("includes run_id in cli_command when a run ID is provided", () => { + trackCommand("check", "run-123"); + + expect(trackEvent).toHaveBeenCalledWith("cli_command", { + command: "check", + run_id: "run-123", + }); + }); + + it("omits run_id from cli_command when no run ID is provided", () => { + trackCommand("check"); + + const properties = trackEvent.mock.lastCall?.[1]; + expect(properties).not.toHaveProperty("run_id"); + }); + + it("includes run_id in cli_command_result when a run ID is provided", () => { + trackCommandResult({ + command: "check", + success: true, + exitCode: 0, + durationMs: 42, + runId: "run-123", + }); + + expect(trackEvent).toHaveBeenCalledWith("cli_command_result", { + command: "check", + success: true, + exit_code: 0, + duration_ms: 42, + run_id: "run-123", + }); + }); + + it("omits run_id from cli_command_result when no run ID is provided", () => { + trackCommandResult({ + command: "check", + success: false, + exitCode: 1, + durationMs: 42, + }); + + const properties = trackEvent.mock.lastCall?.[1]; + expect(properties).not.toHaveProperty("run_id"); + }); +}); + +describe("trackCheckReport", () => { + beforeEach(() => { + trackEvent.mockClear(); + }); + + it("emits the check breakdown with snake_case properties and a run ID", () => { + trackCheckReport({ + contrastGate: true, + motionGate: false, + captionZoneGate: true, + frameCheckGate: false, + snapshotsGate: true, + lintErrors: 1, + lintWarnings: 2, + runtimeErrors: 3, + runtimeWarnings: 4, + layoutErrors: 5, + layoutWarnings: 6, + motionErrors: 7, + motionWarnings: 8, + contrastErrors: 9, + contrastWarnings: 10, + launchSettleMs: 11, + seekLoopMs: 12, + contrastMs: 13, + gridPoints: 14, + contrastPoints: 15, + ok: false, + exitCode: 1, + runId: "run-123", + }); + + expect(trackEvent).toHaveBeenCalledWith("check_report", { + gate_contrast: true, + gate_motion: false, + gate_caption_zone: true, + gate_frame_check: false, + gate_snapshots: true, + lint_errors: 1, + lint_warnings: 2, + runtime_errors: 3, + runtime_warnings: 4, + layout_errors: 5, + layout_warnings: 6, + motion_errors: 7, + motion_warnings: 8, + contrast_errors: 9, + contrast_warnings: 10, + launch_settle_ms: 11, + seek_loop_ms: 12, + contrast_ms: 13, + grid_points: 14, + contrast_points: 15, + ok: false, + exit_code: 1, + run_id: "run-123", + }); + }); + + it("omits run_id when no run ID is provided", () => { + trackCheckReport({ + contrastGate: false, + motionGate: false, + captionZoneGate: false, + frameCheckGate: false, + snapshotsGate: false, + lintErrors: 0, + lintWarnings: 0, + runtimeErrors: 0, + runtimeWarnings: 0, + layoutErrors: 0, + layoutWarnings: 0, + motionErrors: 0, + motionWarnings: 0, + contrastErrors: 0, + contrastWarnings: 0, + launchSettleMs: 0, + seekLoopMs: 0, + contrastMs: 0, + gridPoints: 0, + contrastPoints: 0, + ok: true, + exitCode: 0, + }); + + const properties = trackEvent.mock.lastCall?.[1]; + expect(properties).not.toHaveProperty("run_id"); + }); +}); + describe("render telemetry events", () => { beforeEach(() => { trackEvent.mockClear(); diff --git a/packages/cli/src/telemetry/events.ts b/packages/cli/src/telemetry/events.ts index 89462246ae..9a43538114 100644 --- a/packages/cli/src/telemetry/events.ts +++ b/packages/cli/src/telemetry/events.ts @@ -3,6 +3,12 @@ import type { SubTimelineWaitOutcome } from "@hyperframes/engine"; import { trackEvent } from "./client.js"; import { readConfig } from "./config.js"; +// run_id is attached only when the orchestrator set HYPERFRAMES_RUN_ID — an +// absent property, never null/"" (PostHog treats those as real values). +function runIdField(runId: string | undefined): { run_id?: string } { + return runId !== undefined ? { run_id: runId } : {}; +} + export interface RenderObservabilityTelemetryPayload { /** Worst sub-composition timeline wait outcome across sessions. */ subTimelineWait?: SubTimelineWaitOutcome; @@ -98,8 +104,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( @@ -544,11 +553,65 @@ export function trackCommandResult(props: { success: boolean; exitCode: number; durationMs: number; + runId?: string; }): void { trackEvent("cli_command_result", { command: props.command, success: props.success, exit_code: props.exitCode, duration_ms: props.durationMs, + ...runIdField(props.runId), + }); +} + +export function trackCheckReport(props: { + contrastGate: boolean; + motionGate: boolean; + captionZoneGate: boolean; + frameCheckGate: boolean; + snapshotsGate: boolean; + lintErrors: number; + lintWarnings: number; + runtimeErrors: number; + runtimeWarnings: number; + layoutErrors: number; + layoutWarnings: number; + motionErrors: number; + motionWarnings: number; + contrastErrors: number; + contrastWarnings: number; + launchSettleMs: number; + seekLoopMs: number; + contrastMs: number; + gridPoints: number; + contrastPoints: number; + ok: boolean; + exitCode: number; + runId?: string; +}): void { + trackEvent("check_report", { + gate_contrast: props.contrastGate, + gate_motion: props.motionGate, + gate_caption_zone: props.captionZoneGate, + gate_frame_check: props.frameCheckGate, + gate_snapshots: props.snapshotsGate, + lint_errors: props.lintErrors, + lint_warnings: props.lintWarnings, + runtime_errors: props.runtimeErrors, + runtime_warnings: props.runtimeWarnings, + layout_errors: props.layoutErrors, + layout_warnings: props.layoutWarnings, + motion_errors: props.motionErrors, + motion_warnings: props.motionWarnings, + contrast_errors: props.contrastErrors, + contrast_warnings: props.contrastWarnings, + launch_settle_ms: props.launchSettleMs, + seek_loop_ms: props.seekLoopMs, + contrast_ms: props.contrastMs, + grid_points: props.gridPoints, + contrast_points: props.contrastPoints, + ok: props.ok, + exit_code: props.exitCode, + ...runIdField(props.runId), }); } diff --git a/packages/cli/src/telemetry/runId.test.ts b/packages/cli/src/telemetry/runId.test.ts new file mode 100644 index 0000000000..6a97b0726b --- /dev/null +++ b/packages/cli/src/telemetry/runId.test.ts @@ -0,0 +1,73 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const originalRunId = process.env["HYPERFRAMES_RUN_ID"]; + +async function loadGetRunId() { + const { getRunId } = await import("./runId.js"); + return getRunId; +} + +describe("getRunId", () => { + beforeEach(() => { + delete process.env["HYPERFRAMES_RUN_ID"]; + vi.resetModules(); + }); + + afterEach(() => { + if (originalRunId === undefined) delete process.env["HYPERFRAMES_RUN_ID"]; + else process.env["HYPERFRAMES_RUN_ID"] = originalRunId; + vi.resetModules(); + }); + + it("returns undefined when HYPERFRAMES_RUN_ID is unset", async () => { + const getRunId = await loadGetRunId(); + + expect(getRunId()).toBeUndefined(); + }); + + it("returns undefined when HYPERFRAMES_RUN_ID contains only whitespace", async () => { + process.env["HYPERFRAMES_RUN_ID"] = " \t\n "; + const getRunId = await loadGetRunId(); + + expect(getRunId()).toBeUndefined(); + }); + + it("returns a normal HYPERFRAMES_RUN_ID value", async () => { + process.env["HYPERFRAMES_RUN_ID"] = "run-123"; + const getRunId = await loadGetRunId(); + + expect(getRunId()).toBe("run-123"); + }); + + it("truncates HYPERFRAMES_RUN_ID to exactly 128 characters", async () => { + process.env["HYPERFRAMES_RUN_ID"] = "x".repeat(160); + const getRunId = await loadGetRunId(); + + expect(getRunId()).toBe("x".repeat(128)); + expect(getRunId()).toHaveLength(128); + }); + + it("trims whitespace around a real HYPERFRAMES_RUN_ID value", async () => { + process.env["HYPERFRAMES_RUN_ID"] = " run-123 \n"; + const getRunId = await loadGetRunId(); + + expect(getRunId()).toBe("run-123"); + }); + + it("memoizes the first environment read", async () => { + process.env["HYPERFRAMES_RUN_ID"] = "first-run"; + const getRunId = await loadGetRunId(); + + expect(getRunId()).toBe("first-run"); + process.env["HYPERFRAMES_RUN_ID"] = "second-run"; + expect(getRunId()).toBe("first-run"); + }); + + it("memoizes an initial undefined environment read", async () => { + const getRunId = await loadGetRunId(); + + expect(getRunId()).toBeUndefined(); + process.env["HYPERFRAMES_RUN_ID"] = "later-run"; + expect(getRunId()).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/telemetry/runId.ts b/packages/cli/src/telemetry/runId.ts new file mode 100644 index 0000000000..5c5e307fb1 --- /dev/null +++ b/packages/cli/src/telemetry/runId.ts @@ -0,0 +1,12 @@ +let resolved = false; +let runId: string | undefined; + +export function getRunId(): string | undefined { + if (!resolved) { + const value = process.env["HYPERFRAMES_RUN_ID"]?.trim().slice(0, 128); + runId = value ? value : undefined; + resolved = true; + } + + return runId; +} diff --git a/packages/cli/src/templates/_shared/AGENTS.md b/packages/cli/src/templates/_shared/AGENTS.md index a30e02dbe4..3e8a31aba6 100644 --- a/packages/cli/src/templates/_shared/AGENTS.md +++ b/packages/cli/src/templates/_shared/AGENTS.md @@ -33,7 +33,7 @@ The domain skills (`/hyperframes-core`, `/hyperframes-animation`, `/hyperframes- ```bash npm run dev # start the preview server (long-running — keep it alive in background) -npm run check # lint + validate + inspect +npm run check # lint + runtime + layout + motion + contrast (one command) npm run render # render to MP4 npm run publish # publish and get a shareable link npx hyperframes lint --verbose # include info-level findings @@ -76,7 +76,7 @@ After creating or editing any `.html` composition, **always** run the full check npm run check ``` -Fix all errors before presenting the result. Inspect warnings should be reviewed before rendering. +Fix all errors before presenting the result. Warnings should be reviewed before rendering. ## Key Rules diff --git a/packages/cli/src/templates/_shared/CLAUDE.md b/packages/cli/src/templates/_shared/CLAUDE.md index a30e02dbe4..3e8a31aba6 100644 --- a/packages/cli/src/templates/_shared/CLAUDE.md +++ b/packages/cli/src/templates/_shared/CLAUDE.md @@ -33,7 +33,7 @@ The domain skills (`/hyperframes-core`, `/hyperframes-animation`, `/hyperframes- ```bash npm run dev # start the preview server (long-running — keep it alive in background) -npm run check # lint + validate + inspect +npm run check # lint + runtime + layout + motion + contrast (one command) npm run render # render to MP4 npm run publish # publish and get a shareable link npx hyperframes lint --verbose # include info-level findings @@ -76,7 +76,7 @@ After creating or editing any `.html` composition, **always** run the full check npm run check ``` -Fix all errors before presenting the result. Inspect warnings should be reviewed before rendering. +Fix all errors before presenting the result. Warnings should be reviewed before rendering. ## Key Rules diff --git a/packages/cli/src/utils/checkBrowser.test.ts b/packages/cli/src/utils/checkBrowser.test.ts new file mode 100644 index 0000000000..3a2a3f0d50 --- /dev/null +++ b/packages/cli/src/utils/checkBrowser.test.ts @@ -0,0 +1,302 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + openSettledCompositionPage, + type OpenSettledCompositionPageOptions, +} from "../capture/captureCompositionFrame.js"; +import { DEFAULT_CHECK_OPTIONS, runAuditGrid } from "./checkPipeline.js"; +import { captureOverviewShot, runBrowserCheck } from "./checkBrowser.js"; +import type { ProjectDir } from "./project.js"; + +const mocks = vi.hoisted(() => ({ + serverClose: vi.fn(async () => undefined), +})); + +vi.mock("@hyperframes/core/compiler", () => ({ + bundleToSingleHtml: vi.fn(async () => ""), +})); + +vi.mock("../capture/captureCompositionFrame.js", async (importOriginal) => ({ + // Partial mock: constants (AUDIT_SEEK_OPTIONS, DEFAULT_ZOOM_*) stay real so + // they remain single-sourced; only the browser-touching functions are faked. + ...(await importOriginal()), + openSettledCompositionPage: vi.fn(), + resolveCliChromeGpuMode: vi.fn(() => "hardware"), + seekCompositionTimeline: vi.fn(async () => undefined), + waitForPreferredSeekTarget: vi.fn(async () => undefined), +})); + +vi.mock("../commands/validate.js", async (importOriginal) => ({ + // Partial mock: shouldIgnoreRequestFailure stays real; the clip audit is + // faked so tests control its findings without loading real media. + ...(await importOriginal()), + auditClipDurations: vi.fn(async () => [] as Array<{ level: "error" | "warning"; text: string }>), +})); + +vi.mock("./staticProjectServer.js", () => ({ + serveStaticProjectHtml: vi.fn(async () => ({ + url: "http://127.0.0.1:3000", + close: mocks.serverClose, + })), +})); + +const PROJECT: ProjectDir = { + dir: "/project", + name: "project", + indexPath: "/project/index.html", +}; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + document.body.innerHTML = ""; + Reflect.deleteProperty(window, "__hyperframesGeometryCandidates"); + Reflect.deleteProperty(window, "__hyperframesLayoutAudit"); + Reflect.deleteProperty(window, "__contrastAuditPrepare"); + Reflect.deleteProperty(window, "__contrastAuditFinish"); + Reflect.deleteProperty(window, "__contrastAuditRestores"); + Reflect.deleteProperty(window, "__contrastAuditRestoreIfPending"); +}); + +function installSessionMock(page: ReturnType): void { + const browser = Object.assign(Object.create(null), { + close: vi.fn(async () => undefined), + }); + vi.mocked(openSettledCompositionPage).mockImplementation( + async (_html: string, _url: string, options: OpenSettledCompositionPageOptions) => { + await options.beforeNavigate?.(page); + return { page, browser, renderReadyTimedOut: false }; + }, + ); +} + +function mountCanvasFixture(inner = ""): void { + document.body.innerHTML = ` +
${inner}
+ `; + Object.defineProperty(window, "innerWidth", { configurable: true, value: 640 }); + Object.defineProperty(window, "innerHeight", { configurable: true, value: 360 }); +} + +it("carries raw browser geometry through the page driver and pipeline", async () => { + vi.spyOn(Date, "now") + .mockReturnValueOnce(100) + .mockReturnValueOnce(160) + .mockReturnValueOnce(200) + .mockReturnValueOnce(240); + mountCanvasFixture(` +
+ +
+ `); + installRects(); + const page = fakePage(); + installSessionMock(page); + + const result = await runBrowserCheck( + PROJECT, + { ...DEFAULT_CHECK_OPTIONS, samples: 1, contrast: false, frameCheck: {} }, + { kind: "none" }, + runAuditGrid, + ); + + expect(result.layoutIssues).toEqual([ + expect.objectContaining({ + code: "frame_out_of_frame", + severity: "warning", + selector: "#hero-image", + sourceFile: "scenes/hero.html", + dataAttributes: { "data-layout-name": "hero" }, + bbox: { x: 600, y: 80, width: 200, height: 100 }, + rect: { left: 600, top: 80, right: 800, bottom: 180, width: 200, height: 100 }, + overflow: { right: 160 }, + time: 5, + }), + ]); + expect(result.timings).toEqual({ launchSettleMs: 60, seekLoopMs: 40, contrastMs: 0 }); + expect(mocks.serverClose).toHaveBeenCalledOnce(); +}); + +it("round-trips the browser script's raw contrast candidates back into finish", async () => { + // The U2 regression class: Node parses prepare's candidates for reporting, + // but must hand the UNTOUCHED objects back to __contrastAuditFinish — the + // page script samples pixels via its own bbox shape (w/h). A normalized + // candidate (width/height) makes every sample rect NaN and the audit + // silently reports zero checked elements as green. + vi.spyOn(Date, "now").mockReturnValue(100); + mountCanvasFixture(` +
Readable copy
+ `); + const root = document.querySelector("[data-composition-id]"); + const headline = document.querySelector("#headline"); + if (!root || !headline) throw new Error("Contrast fixture failed to mount"); + vi.spyOn(root, "getBoundingClientRect").mockReturnValue(new DOMRect(0, 0, 640, 360)); + vi.spyOn(headline, "getBoundingClientRect").mockReturnValue(new DOMRect(50, 50, 300, 40)); + vi.spyOn(window, "getComputedStyle").mockImplementation( + () => + ({ + display: "block", + visibility: "visible", + opacity: "1", + color: "rgb(255,255,255)", + fill: "", + backgroundColor: "rgba(0,0,0,0)", + backgroundImage: "none", + fontSize: "32px", + fontWeight: "700", + }) as unknown as CSSStyleDeclaration, + ); + + // happy-dom can't decode PNGs: stub Image (sync onload) and the canvas 2D + // context the way layout-audit.browser.test.ts's contrast harness does, so + // the REAL __contrastAuditFinish runs its sampling path end to end. + class MockImage { + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + naturalWidth = 640; + naturalHeight = 360; + + set src(_value: string) { + this.onload?.(); + } + } + vi.stubGlobal("Image", MockImage); + const getContextSpy = vi.spyOn(HTMLCanvasElement.prototype, "getContext") as unknown as { + mockReturnValue(value: CanvasRenderingContext2D): void; + }; + getContextSpy.mockReturnValue({ + drawImage() {}, + getImageData() { + return { data: new Uint8ClampedArray(640 * 360 * 4).fill(255) }; + }, + } as unknown as CanvasRenderingContext2D); + + const received: Array> = []; + const page = fakePage(); + const injectScript = page.addScriptTag; + page.addScriptTag = vi.fn(async (arg: { content: string }) => { + await injectScript(arg); + const w = window as unknown as { + __contrastAuditFinish?: ((...args: unknown[]) => Promise) & { wrapped?: boolean }; + }; + const finish = w.__contrastAuditFinish; + if (finish && !finish.wrapped) { + const wrapper = Object.assign( + async (...args: unknown[]) => { + const candidates = args[2]; + if (Array.isArray(candidates)) { + received.push(...(candidates as Array>)); + } + return finish(...args); + }, + { wrapped: true }, + ); + w.__contrastAuditFinish = wrapper; + } + }); + page.screenshot = vi.fn(async () => "c3R1Yg=="); + installSessionMock(page); + + await runBrowserCheck( + PROJECT, + { ...DEFAULT_CHECK_OPTIONS, samples: 1, contrast: true }, + { kind: "none" }, + runAuditGrid, + ); + + expect(received.length).toBeGreaterThan(0); + for (const candidate of received) { + const bbox = candidate.bbox as Record; + // The page script's own shape (w/h), not Node's envelope shape (width/height): + expect(typeof bbox.w).toBe("number"); + expect(typeof bbox.h).toBe("number"); + } +}); + +it("carries validate's clip-duration audit into the runtime findings", async () => { + vi.spyOn(Date, "now").mockReturnValue(100); + mountCanvasFixture(); + const validateModule = await import("../commands/validate.js"); + vi.mocked(validateModule.auditClipDurations).mockResolvedValue([ + { + level: "warning", + text: "Audio is 22.10s but its slot (data-duration) is 30.00s — the slot is shortened to the media length when rendered.", + }, + ]); + const page = fakePage(); + installSessionMock(page); + + const result = await runBrowserCheck( + PROJECT, + { ...DEFAULT_CHECK_OPTIONS, samples: 1, contrast: false }, + { kind: "none" }, + runAuditGrid, + ); + + expect(result.runtimeFindings).toEqual([ + expect.objectContaining({ + code: "clip_media_fit", + severity: "warning", + message: expect.stringContaining("slot is shortened"), + }), + ]); +}); + +describe("captureOverviewShot", () => { + it("injects the annotation overlay before the overview shot and removes it right after", async () => { + const calls: string[] = []; + const evaluate = vi.fn(async (fn: unknown, ...args: unknown[]) => { + calls.push("evaluate"); + return typeof fn === "function" ? Reflect.apply(fn, undefined, args) : undefined; + }); + const screenshot = vi.fn(async () => { + calls.push("screenshot"); + return "annotated-base64"; + }); + const page = Object.assign(Object.create(null), { evaluate, screenshot }); + + const result = await captureOverviewShot( + page, + [{ label: "1 clipped_text", bbox: { x: 0, y: 0, width: 10, height: 10 } }], + "measurement-base64", + ); + + // inject overlay -> take the shot -> remove overlay, in that order — + // never present while any audit (which runs before this is called) collects. + expect(calls).toEqual(["evaluate", "screenshot", "evaluate"]); + expect(result).toBe("annotated-base64"); + }); + + it("skips the overlay entirely and returns the plain screenshot when there's nothing to annotate", async () => { + const evaluate = vi.fn(); + const screenshot = vi.fn(); + const page = Object.assign(Object.create(null), { evaluate, screenshot }); + + const result = await captureOverviewShot(page, [], "measurement-base64"); + + expect(evaluate).not.toHaveBeenCalled(); + expect(screenshot).not.toHaveBeenCalled(); + expect(result).toBe("measurement-base64"); + }); +}); + +function installRects(): void { + const root = document.querySelector("[data-composition-id]"); + const image = document.querySelector("#hero-image"); + if (!root || !image) throw new Error("Geometry fixture failed to mount"); + vi.spyOn(root, "getBoundingClientRect").mockReturnValue(new DOMRect(0, 0, 640, 360)); + vi.spyOn(image, "getBoundingClientRect").mockReturnValue(new DOMRect(600, 80, 200, 100)); +} + +function fakePage() { + return Object.assign(Object.create(null), { + on: vi.fn(), + addScriptTag: vi.fn(async ({ content }: { content: string }) => { + window.eval(content); + }), + evaluate: vi.fn(async (callback: unknown, ...args: unknown[]) => { + if (typeof callback !== "function") throw new Error("Expected an evaluate callback"); + return Reflect.apply(callback, window, args); + }), + }); +} diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts new file mode 100644 index 0000000000..d889e41b73 --- /dev/null +++ b/packages/cli/src/utils/checkBrowser.ts @@ -0,0 +1,1020 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { Page } from "puppeteer-core"; +import { + AUDIT_SEEK_OPTIONS, + DEFAULT_ZOOM_PADDING_PX, + DEFAULT_ZOOM_SCALE, + captureRegionCrop, + openSettledCompositionPage, + padCropRegion, + resolveCliChromeGpuMode, + seekCompositionTimeline, + waitForPreferredSeekTarget, +} from "../capture/captureCompositionFrame.js"; +import { auditClipDurations, shouldIgnoreRequestFailure } from "../commands/validate.js"; +import { loadBrowserScript } from "../commands/layout.js"; +import { normalizeErrorMessage } from "./errorMessage.js"; +import { ambiguousIssue, type MotionFrame } from "./motionAudit.js"; +import type { LayoutIssue, LayoutIssueCode, LayoutRect } from "./layoutAudit.js"; +import { serveStaticProjectHtml } from "./staticProjectServer.js"; +import { rectToBbox } from "./checkTypes.js"; +import type { + AnchoredLayoutIssue, + CheckAnchor, + CheckAnnotationBox, + CheckAuditDriver, + CheckBbox, + CheckBrowserResult, + CheckFinding, + CheckFindingCropRequest, + CheckGeometryCandidate, + CheckOptions, + CheckSeverity, + ContrastAuditEntry, + ContrastCapture, + GeometryCandidateRequest, + MotionSpecResolution, + RunAuditGrid, +} from "./checkTypes.js"; +import type { ProjectDir } from "./project.js"; + +interface RuntimeDraft { + code: string; + severity: CheckSeverity; + message: string; + time: number; + url?: string; + line?: number; +} + +interface AnchorRequest { + selector: string; + time: number; + bbox: CheckBbox; +} + +interface ContrastCandidate { + selector: string; + text: string; + fg: [number, number, number, number]; + large: boolean; + bbox: CheckBbox; +} + +interface PreparedContrast { + // The untouched candidate object from __contrastAuditPrepare. It round-trips + // back into __contrastAuditFinish verbatim — the browser script owns its + // shape (e.g. bbox uses w/h, not width/height), so Node must not normalize + // what it sends back. `candidate` is the parsed copy for Node-side reporting. + raw: unknown; + candidate: ContrastCandidate; + anchor: CheckAnchor; +} + +interface FinishedContrast { + selector: string; + text: string; + ratio: number; + wcagAA: boolean; + large: boolean; + fg: string; + bg: string; +} + +export async function runBrowserCheck( + project: ProjectDir, + options: CheckOptions, + motion: MotionSpecResolution, + runGrid: RunAuditGrid, +): Promise { + const { bundleToSingleHtml } = await import("@hyperframes/core/compiler"); + const html = await bundleToSingleHtml(project.dir); + const server = await serveStaticProjectHtml(project.dir, html, "Failed to bind check server"); + const drafts: RuntimeDraft[] = []; + let currentTime = 0; + let chromeBrowser: import("puppeteer-core").Browser | undefined; + + try { + const launchSettleStart = Date.now(); + const session = await openSettledCompositionPage(html, server.url, { + renderReadyTimeoutMs: options.timeout, + renderReadyWarningSuffix: "checking the current page state", + browserGpuMode: resolveCliChromeGpuMode(), + beforeNavigate: (page) => wireRuntimeListeners(page, drafts, () => currentTime), + }); + chromeBrowser = session.browser; + const page = session.page; + await waitForPreferredSeekTarget(page); + + const rootAnchor = await resolveRootAnchor(page); + const launchSettleMs = Date.now() - launchSettleStart; + // validate's per-media-element audit, kept in the consolidation: a clip + // whose intrinsic duration is meaningfully shorter than its data-duration + // slot silently shortens the slot at render time — invisible to lint (no + // intrinsic durations statically) and to the runtime listeners (nothing + // errors). The session is already open, so this is one extra evaluate. + const { analyzeClipMediaFit } = await import("@hyperframes/engine"); + for (const entry of await auditClipDurations(page, analyzeClipMediaFit, options.timeout)) { + drafts.push({ code: "clip_media_fit", severity: entry.level, message: entry.text, time: 0 }); + } + const driver = createPageDriver(page, (time) => { + currentTime = time; + }); + const result = await runGrid(driver, options, motion); + return { + ...result, + timings: { ...result.timings, launchSettleMs }, + runtimeFindings: drafts.map((draft) => runtimeFinding(draft, rootAnchor)), + }; + } finally { + await chromeBrowser?.close().catch(() => undefined); + await server.close(); + } +} + +/** + * `check --snapshots`'s per-finding evidence crops. Opens its own session + * (the main grid session already closed by the time findings are shaped) and + * re-seeks to each finding's sample time — renders are deterministic, so a + * fresh page at the same time reproduces the same pixels the grid audited. + */ +export async function captureFindingCrops( + project: ProjectDir, + options: CheckOptions, + requests: CheckFindingCropRequest[], +): Promise { + if (requests.length === 0) return []; + const { bundleToSingleHtml } = await import("@hyperframes/core/compiler"); + const html = await bundleToSingleHtml(project.dir); + const server = await serveStaticProjectHtml(project.dir, html, "Failed to bind check server"); + let chromeBrowser: import("puppeteer-core").Browser | undefined; + const written: string[] = []; + try { + const session = await openSettledCompositionPage(html, server.url, { + renderReadyTimeoutMs: options.timeout, + renderReadyWarningSuffix: "capturing finding crops", + browserGpuMode: resolveCliChromeGpuMode(), + }); + chromeBrowser = session.browser; + const page = session.page; + await waitForPreferredSeekTarget(page); + + const snapshotDir = join(project.dir, "snapshots"); + mkdirSync(snapshotDir, { recursive: true }); + for (const request of requests) { + await seekCompositionTimeline(page, request.time, AUDIT_SEEK_OPTIONS); + const canvas = await page.evaluate(() => ({ + width: window.innerWidth, + height: window.innerHeight, + })); + const region = padCropRegion(request.bbox, canvas, DEFAULT_ZOOM_PADDING_PX); + const buffer = await captureRegionCrop(page, region, DEFAULT_ZOOM_SCALE); + writeFileSync(join(snapshotDir, request.filename), buffer); + written.push(join("snapshots", request.filename)); + } + return written; + } finally { + await chromeBrowser?.close().catch(() => undefined); + await server.close(); + } +} + +function wireRuntimeListeners(page: Page, drafts: RuntimeDraft[], currentTime: () => number): void { + page.on("console", (message) => { + const type = message.type(); + const text = message.text(); + if (type === "error" && !text.startsWith("Failed to load resource")) { + const location = message.location(); + drafts.push({ + code: "console_error", + severity: "error", + message: text, + time: currentTime(), + url: location.url, + line: location.lineNumber, + }); + } else if (type === "warn") { + const location = message.location(); + drafts.push({ + code: "console_warning", + severity: "warning", + message: text, + time: currentTime(), + url: location.url, + line: location.lineNumber, + }); + } + }); + page.on("pageerror", (error) => { + const message = normalizeErrorMessage(error); + if (message.includes("Unexpected token '<'") || message.includes("Unexpected token '<'")) { + return; + } + drafts.push({ code: "page_error", severity: "error", message, time: currentTime() }); + }); + wireNetworkListeners(page, drafts, currentTime); +} + +function wireNetworkListeners(page: Page, drafts: RuntimeDraft[], currentTime: () => number): void { + page.on("requestfailed", (request) => { + const url = request.url(); + if (url.includes("favicon") || url.startsWith("data:")) return; + const failure = request.failure()?.errorText; + if (shouldIgnoreRequestFailure(url, failure, request.resourceType())) return; + drafts.push({ + code: "request_failed", + severity: "error", + message: `Failed to load ${urlPath(url)}: ${failure ?? "net::ERR_FAILED"}`, + time: currentTime(), + url, + }); + }); + page.on("response", (response) => { + if (response.status() < 400) return; + const url = response.url(); + if (url.includes("favicon")) return; + drafts.push({ + code: "http_error", + severity: "error", + message: `${response.status()} loading ${urlPath(url)}`, + time: currentTime(), + url, + }); + }); +} + +function createPageDriver(page: Page, setTime: (time: number) => void): CheckAuditDriver { + return { + initialize: (contrast) => injectAuditScripts(page, contrast), + getDuration: () => getCompositionDuration(page), + getTransitionBoundaries: () => collectTweenBoundaries(page), + getCanvas: () => + page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight })), + findAmbiguousSelectors: (selectors) => findAmbiguousSelectors(page, selectors), + seek: async (time) => { + setTime(time); + await seekCompositionTimeline(page, time, AUDIT_SEEK_OPTIONS); + }, + collectLayout: (time, tolerance) => collectLayout(page, time, tolerance), + collectLayoutGeometry: () => collectLayoutGeometry(page), + collectGeometryCandidates: (time, request) => collectGeometryCandidates(page, time, request), + collectMotionFrame: (time, selectors, scopes) => + collectMotionFrame(page, time, selectors, scopes), + anchorMotionIssues: (issues) => anchorLayoutIssues(page, issues), + collectContrast: (time, annotations) => collectContrast(page, time, annotations), + }; +} + +async function injectAuditScripts(page: Page, contrast: boolean): Promise { + await page.addScriptTag({ content: loadBrowserScript("layout-audit.browser.js") }); + await page.addScriptTag({ content: loadBrowserScript("motion-sample.browser.js") }); + if (contrast) { + await page.addScriptTag({ content: loadBrowserScript("contrast-audit.browser.js") }); + } +} + +async function getCompositionDuration(page: Page): Promise { + // Duration resolution is serialized into the page and must remain self-contained. + // fallow-ignore-next-line complexity + return page.evaluate(() => { + const value = (target: unknown, key: string): unknown => + typeof target === "object" && target !== null ? Reflect.get(target, key) : undefined; + const positive = (candidate: unknown): number | null => + typeof candidate === "number" && candidate > 0 ? candidate : null; + const callDuration = (target: unknown): number | null => { + const duration = value(target, "duration"); + if (typeof duration === "function") { + const result = Reflect.apply(duration, target, []); + return positive(result); + } + return positive(duration); + }; + const hfDuration = positive(value(Reflect.get(window, "__hf"), "duration")); + if (hfDuration) return hfDuration; + const playerDuration = callDuration(Reflect.get(window, "__player")); + if (playerDuration) return playerDuration; + const root = document.querySelector("[data-composition-id][data-duration]"); + const authored = root ? parseFloat(root.getAttribute("data-duration") ?? "0") : 0; + if (authored > 0) return authored; + const timelines = Reflect.get(window, "__timelines"); + if (typeof timelines !== "object" || timelines === null) return 0; + for (const key of Object.keys(timelines)) { + const duration = callDuration(Reflect.get(timelines, key)); + if (duration) return duration; + } + return 0; + }); +} + +async function collectTweenBoundaries(page: Page): Promise { + // GSAP getter binding and parent-time conversion form one serialized algorithm. + // fallow-ignore-next-line complexity + return page.evaluate(() => { + const property = (target: unknown, key: string): unknown => + (typeof target === "object" && target !== null) || typeof target === "function" + ? Reflect.get(target, key) + : undefined; + const numberCall = (target: unknown, key: string, fallback: number): number => { + const method = property(target, key); + if (typeof method !== "function") return fallback; + const result = Reflect.apply(method, target, []); + return typeof result === "number" ? result : fallback; + }; + const rootTime = (root: unknown, animation: unknown, local: number): number => { + let time = local; + let node = animation; + while (node && node !== root) { + time = numberCall(node, "startTime", 0) + time / (numberCall(node, "timeScale", 1) || 1); + node = property(node, "parent"); + } + return time; + }; + const timelines = Reflect.get(window, "__timelines"); + if (typeof timelines !== "object" || timelines === null) return []; + const boundaries: number[] = []; + for (const key of Object.keys(timelines)) { + const timeline = Reflect.get(timelines, key); + const getChildren = property(timeline, "getChildren"); + if (typeof getChildren !== "function") continue; + try { + const children = Reflect.apply(getChildren, timeline, [true, true, false]); + if (!Array.isArray(children)) continue; + for (const child of children) { + const duration = numberCall(child, "duration", Number.NaN); + if (!Number.isFinite(duration)) continue; + boundaries.push(rootTime(timeline, child, 0), rootTime(timeline, child, duration)); + } + } catch { + continue; + } + } + return boundaries.filter(Number.isFinite); + }); +} + +async function collectLayout( + page: Page, + time: number, + tolerance: number, +): Promise { + const raw = await page.evaluate( + (options: { time: number; tolerance: number }) => { + const audit = Reflect.get(window, "__hyperframesLayoutAudit"); + if (typeof audit !== "function") return []; + const result = Reflect.apply(audit, window, [options]); + return Array.isArray(result) ? result : []; + }, + { time, tolerance }, + ); + return anchorLayoutIssues(page, raw.flatMap(parseLayoutIssue)); +} + +async function collectLayoutGeometry(page: Page): Promise { + return page.evaluate(() => { + const geometry = Reflect.get(window, "__hyperframesLayoutGeometry"); + if (typeof geometry !== "function") return ""; + const result = Reflect.apply(geometry, window, []); + return typeof result === "string" ? result : ""; + }); +} + +async function collectGeometryCandidates( + page: Page, + time: number, + request: GeometryCandidateRequest, +): Promise { + try { + const raw = await page.evaluate((options: GeometryCandidateRequest) => { + const collect = Reflect.get(window, "__hyperframesGeometryCandidates"); + if (typeof collect !== "function") return []; + const result = Reflect.apply(collect, window, [options]); + return Array.isArray(result) ? result : []; + }, request); + return raw.flatMap((value) => parseGeometryCandidate(value, time)); + } catch { + return []; + } +} + +async function findAmbiguousSelectors( + page: Page, + selectors: string[], +): Promise { + const ambiguous = await page.evaluate( + (values: string[]) => + values.filter((selector) => { + try { + return document.querySelectorAll(selector).length > 1; + } catch { + return false; + } + }), + selectors, + ); + return anchorLayoutIssues(page, ambiguous.map(ambiguousIssue)); +} + +async function collectMotionFrame( + page: Page, + time: number, + selectors: string[], + livenessScopes: string[], +): Promise { + const raw = await page.evaluate( + (options: { selectors: string[]; livenessScopes: string[] }) => { + const sample = Reflect.get(window, "__hyperframesMotionSample"); + if (typeof sample !== "function") return null; + return Reflect.apply(sample, window, [options]); + }, + { selectors, livenessScopes }, + ); + return parseMotionFrame(raw, time, selectors, livenessScopes); +} + +async function anchorLayoutIssues( + page: Page, + issues: LayoutIssue[], +): Promise { + const requests = issues.map((issue) => ({ + selector: issue.selector, + time: issue.time, + bbox: rectToBbox(issue.rect), + })); + const anchors = await resolveAnchors(page, requests); + return issues.map((issue, index) => ({ + ...issue, + ...(anchors[index] ?? fallbackAnchor(requests[index])), + })); +} + +async function resolveAnchors(page: Page, requests: AnchorRequest[]): Promise { + if (requests.length === 0) return []; + return page.evaluate((values: AnchorRequest[]) => { + const root = document.querySelector("[data-composition-id]"); + return values.map((request) => { + let element: Element | null = null; + try { + element = document.querySelector(request.selector); + } catch { + element = null; + } + element ??= root; + // Clones the anchor-extraction block in prepareContrast's evaluate() below; + // both run inside separate serialized browser closures and can't share a + // Node-side helper. + // fallow-ignore-next-line code-duplication + const dataAttributes: Record = {}; + for (const attribute of Array.from(element?.attributes ?? [])) { + if (attribute.name.startsWith("data-")) dataAttributes[attribute.name] = attribute.value; + } + const source = element + ?.closest("[data-composition-file]") + ?.getAttribute("data-composition-file"); + return { + selector: element ? request.selector : "[data-composition-id]", + dataAttributes, + sourceFile: source || "index.html", + bbox: request.bbox, + time: request.time, + }; + }); + }, requests); +} + +async function resolveRootAnchor(page: Page): Promise { + const anchors = await resolveAnchors(page, [ + { selector: "[data-composition-id]", time: 0, bbox: await compositionBbox(page) }, + ]); + return anchors[0] ?? fallbackAnchor(undefined); +} + +async function compositionBbox(page: Page): Promise { + return page.evaluate(() => { + const element = document.querySelector("[data-composition-id]"); + const rect = element?.getBoundingClientRect(); + return rect + ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } + : { x: 0, y: 0, width: 0, height: 0 }; + }); +} + +async function collectContrast( + page: Page, + time: number, + layoutAnnotations: CheckAnnotationBox[] = [], +): Promise { + let prepared: PreparedContrast[] = []; + try { + prepared = parsePreparedContrast(await prepareContrast(page, time)); + // This screenshot is the one contrast math is sampled from below — it must + // stay untouched by the annotation overlay (finishContrast reads real + // painted pixels), so annotation only ever happens on a SECOND shot. + const measurementShot = await page.screenshot({ encoding: "base64", type: "png" }); + if (typeof measurementShot !== "string") throw new Error("Contrast screenshot was not base64"); + const raw = await finishContrast( + page, + measurementShot, + time, + prepared.map((entry) => entry.raw), + ); + const finished = raw.flatMap(parseFinishedContrast); + const entries = joinContrastEntries(finished, prepared); + // Contrast failures are only known once measurement above completes, so + // they're appended to the layout-derived annotations passed in by the + // pipeline rather than being requested up front. + const annotations = [ + ...layoutAnnotations, + ...contrastFailureAnnotations(entries, layoutAnnotations.length), + ]; + const pngBase64 = await captureOverviewShot(page, annotations, measurementShot); + return { entries, pngBase64 }; + } finally { + await page + .evaluate(() => { + const restore = Reflect.get(window, "__contrastAuditRestoreIfPending"); + if (typeof restore === "function") Reflect.apply(restore, window, []); + }) + .catch(() => undefined); + } +} + +function contrastFailureAnnotations( + entries: ContrastAuditEntry[], + labelOffset: number, +): CheckAnnotationBox[] { + return entries + .filter((entry) => !entry.wcagAA) + .map((entry, index) => ({ + label: `${labelOffset + index + 1} contrast_aa_failure`, + bbox: entry.bbox, + })); +} + +const ANNOTATION_OVERLAY_ID = "__hyperframesCheckAnnotations"; + +/** + * `check --snapshots`'s overview-frame annotation: every audit for this + * sample time has already run (layout/geometry findings arrive via + * `annotations`; contrast failures were just measured above), so it's safe + * to draw labeled boxes and take one more shot without perturbing anything + * audits read. No-op (returns the plain screenshot) when there's nothing to + * annotate — the common case stays exactly as before this feature existed. + */ +export async function captureOverviewShot( + page: Page, + annotations: CheckAnnotationBox[], + fallbackScreenshot: string, +): Promise { + if (annotations.length === 0) return fallbackScreenshot; + await injectAnnotationOverlay(page, annotations); + try { + const shot = await page.screenshot({ encoding: "base64", type: "png" }); + return typeof shot === "string" ? shot : fallbackScreenshot; + } finally { + await removeAnnotationOverlay(page); + } +} + +/** One small, self-contained DOM overlay: fixed-position labeled boxes over + * each finding's bbox. Injected immediately before the annotated overview + * shot and torn down immediately after (see `captureOverviewShot`), so it + * never leaks into any audit's DOM reads. */ +async function injectAnnotationOverlay(page: Page, boxes: CheckAnnotationBox[]): Promise { + await page.evaluate( + (items: CheckAnnotationBox[], overlayId: string) => { + const root = document.createElement("div"); + root.id = overlayId; + root.style.cssText = "position:fixed;inset:0;z-index:2147483647;pointer-events:none;"; + for (const item of items) { + const box = document.createElement("div"); + box.style.cssText = `position:fixed;left:${item.bbox.x}px;top:${item.bbox.y}px;width:${item.bbox.width}px;height:${item.bbox.height}px;border:2px solid #ff2d55;box-sizing:border-box;`; + const label = document.createElement("div"); + label.textContent = item.label; + label.style.cssText = + "position:absolute;top:-18px;left:0;background:#ff2d55;color:#fff;font:11px/16px monospace;padding:0 4px;white-space:nowrap;"; + box.appendChild(label); + root.appendChild(box); + } + document.body.appendChild(root); + }, + boxes, + ANNOTATION_OVERLAY_ID, + ); +} + +async function removeAnnotationOverlay(page: Page): Promise { + await page.evaluate((overlayId: string) => { + document.getElementById(overlayId)?.remove(); + }, ANNOTATION_OVERLAY_ID); +} + +async function prepareContrast(page: Page, time: number): Promise { + // Candidate-to-element provenance must be captured while the prepare restore list is live. + // fallow-ignore-next-line complexity + return page.evaluate((sampleTime: number) => { + const prepare = Reflect.get(window, "__contrastAuditPrepare"); + const candidates = typeof prepare === "function" ? Reflect.apply(prepare, window, []) : []; + if (!Array.isArray(candidates)) return []; + const restores = Reflect.get(window, "__contrastAuditRestores"); + const restoreList = Array.isArray(restores) ? restores : []; + const escape = (value: string) => + typeof CSS !== "undefined" && typeof CSS.escape === "function" + ? CSS.escape(value) + : value.replace(/[^a-zA-Z0-9_-]/g, "\\$&"); + const selectorFor = (element: Element | null, fallback: string): string => { + if (!element) return fallback; + if (element.id) return `#${escape(element.id)}`; + const parts: string[] = []; + for ( + let current: Element | null = element; + current && current !== document.body; + current = current.parentElement + ) { + const tag = current.tagName.toLowerCase(); + const siblings = current.parentElement + ? Array.from(current.parentElement.children).filter( + (item) => item.tagName === current?.tagName, + ) + : []; + parts.push( + siblings.length > 1 ? `${tag}:nth-of-type(${siblings.indexOf(current) + 1})` : tag, + ); + } + return parts.reverse().join(" > ") || fallback; + }; + // Part of the serialized evaluate body above; cannot delegate to Node helpers. + // fallow-ignore-next-line complexity + return candidates.map((candidate, index) => { + const restore = restoreList[index]; + const candidateObject = typeof candidate === "object" && candidate !== null ? candidate : {}; + const elementValue = + typeof restore === "object" && restore !== null ? Reflect.get(restore, "el") : null; + const element = elementValue instanceof Element ? elementValue : null; + const fallback = Reflect.get(candidateObject, "selector"); + const selector = selectorFor( + element, + typeof fallback === "string" ? fallback : "[data-composition-id]", + ); + const dataAttributes: Record = {}; + for (const attribute of Array.from(element?.attributes ?? [])) { + if (attribute.name.startsWith("data-")) dataAttributes[attribute.name] = attribute.value; + } + const source = element + ?.closest("[data-composition-file]") + ?.getAttribute("data-composition-file"); + return { + candidate: { ...candidateObject, selector }, + anchor: { + selector, + dataAttributes, + sourceFile: source || "index.html", + bbox: Reflect.get(candidateObject, "bbox"), + time: sampleTime, + }, + }; + }); + }, time); +} + +async function finishContrast( + page: Page, + screenshot: string, + time: number, + candidates: unknown[], +): Promise { + return page.evaluate( + async (payload: { screenshot: string; time: number; candidates: unknown[] }) => { + const finish = Reflect.get(window, "__contrastAuditFinish"); + if (typeof finish !== "function") return []; + const result = await Reflect.apply(finish, window, [ + payload.screenshot, + payload.time, + payload.candidates, + ]); + return Array.isArray(result) ? result : []; + }, + { screenshot, time, candidates }, + ); +} + +function parsePreparedContrast(raw: unknown[]): PreparedContrast[] { + return raw.flatMap((value) => { + if (!isRecord(value)) return []; + const raw = Reflect.get(value, "candidate"); + const candidate = parseContrastCandidate(raw); + const anchor = parseAnchor(Reflect.get(value, "anchor")); + return candidate && anchor ? [{ raw, candidate, anchor }] : []; + }); +} + +function parseContrastCandidate(value: unknown): ContrastCandidate | null { + if (!isRecord(value)) return null; + const selector = stringValue(value, "selector"); + const text = stringValue(value, "text"); + const fg = rgbaValue(Reflect.get(value, "fg")); + const large = booleanValue(value, "large"); + const bbox = parseBbox(Reflect.get(value, "bbox")); + return selector && text !== null && fg && large !== null && bbox + ? { selector, text, fg, large, bbox } + : null; +} + +function parseFinishedContrast(value: unknown): FinishedContrast[] { + if (!isRecord(value)) return []; + const selector = stringValue(value, "selector"); + const text = stringValue(value, "text"); + const ratio = numberValue(value, "ratio"); + const wcagAA = booleanValue(value, "wcagAA"); + const large = booleanValue(value, "large"); + const fg = stringValue(value, "fg"); + const bg = stringValue(value, "bg"); + return selector && + text !== null && + ratio !== null && + wcagAA !== null && + large !== null && + fg && + bg + ? [{ selector, text, ratio, wcagAA, large, fg, bg }] + : []; +} + +function joinContrastEntries( + finished: FinishedContrast[], + prepared: PreparedContrast[], +): ContrastAuditEntry[] { + const remaining = [...prepared]; + return finished.flatMap((entry) => { + const index = remaining.findIndex( + (candidate) => + candidate.candidate.selector === entry.selector && candidate.candidate.text === entry.text, + ); + const match = index >= 0 ? remaining.splice(index, 1)[0] : undefined; + return match ? [{ ...entry, ...match.anchor }] : []; + }); +} + +function parseLayoutIssue(value: unknown): LayoutIssue[] { + if (!isRecord(value)) return []; + const code = layoutCodeValue(Reflect.get(value, "code")); + const severity = severityValue(Reflect.get(value, "severity")); + const time = numberValue(value, "time"); + const selector = stringValue(value, "selector"); + const message = stringValue(value, "message"); + const rect = parseRect(Reflect.get(value, "rect")); + if (!code || !severity || time === null || !selector || !message || !rect) return []; + const issue: LayoutIssue = { code, severity, time, selector, message, rect }; + assignOptionalLayoutFields(issue, value); + return [issue]; +} + +function parseGeometryCandidate(value: unknown, time: number): CheckGeometryCandidate[] { + if (!isRecord(value)) return []; + const rect = parseRect(Reflect.get(value, "rect")); + const elementRect = parseRect(Reflect.get(value, "elementRect")); + if (!rect || !elementRect) return []; + const identity = parseGeometryIdentity(value); + if (!identity) return []; + const anchor = parseGeometryAnchor(value, rect, time); + if (!anchor) return []; + const candidate: CheckGeometryCandidate = { ...identity, ...anchor, rect, elementRect }; + const overflow = parseOverflow(Reflect.get(value, "overflow")); + if (overflow) candidate.overflow = overflow; + return [candidate]; +} + +function parseGeometryIdentity( + value: Record, +): Pick | null { + const kindValue = Reflect.get(value, "kind"); + const kind = kindValue === "text" || kindValue === "media" ? kindValue : null; + if (!kind) return null; + const tag = stringValue(value, "tag"); + if (!tag) return null; + const text = stringValue(value, "text"); + return text === null ? null : { kind, tag, text }; +} + +function parseGeometryAnchor( + value: Record, + rect: LayoutRect, + time: number, +): CheckAnchor | null { + const selector = stringValue(value, "selector"); + if (!selector) return null; + const sourceFile = stringValue(value, "sourceFile"); + if (!sourceFile) return null; + const dataAttributes = stringRecord(Reflect.get(value, "dataAttributes")); + return dataAttributes + ? { + selector, + sourceFile, + dataAttributes, + bbox: rectToBbox(rect), + time, + } + : null; +} + +function assignOptionalLayoutFields(issue: LayoutIssue, value: Record): void { + assignOptionalString(issue, value, "containerSelector"); + assignOptionalString(issue, value, "text"); + assignOptionalString(issue, value, "fixHint"); + const containerRect = parseRect(Reflect.get(value, "containerRect")); + if (containerRect) issue.containerRect = containerRect; + const overflow = parseOverflow(Reflect.get(value, "overflow")); + if (overflow) issue.overflow = overflow; + const coveredFraction = numberValue(value, "coveredFraction"); + if (coveredFraction !== null) issue.coveredFraction = coveredFraction; +} + +function recordField(value: unknown, key: string): Record | null { + if (!isRecord(value)) return null; + const field = Reflect.get(value, key); + return isRecord(field) ? field : null; +} + +function parseMotionFrame( + value: unknown, + time: number, + selectors: string[], + scopes: string[], +): MotionFrame { + const rawData = recordField(value, "data"); + const rawLiveness = recordField(value, "liveness"); + const data: MotionFrame["data"] = {}; + for (const selector of selectors) { + data[selector] = rawData ? parseFrameSample(Reflect.get(rawData, selector)) : null; + } + const liveness: Record = {}; + for (const scope of scopes) { + const signature = rawLiveness ? Reflect.get(rawLiveness, scope) : ""; + liveness[scope] = typeof signature === "string" ? signature : ""; + } + return { time, data, liveness }; +} + +function parseFrameSample(value: unknown): MotionFrame["data"][string] { + if (!isRecord(value)) return null; + const rect = parseRect(Reflect.get(value, "rect")); + const opacity = numberValue(value, "opacity"); + const visible = booleanValue(value, "visible"); + return rect && opacity !== null && visible !== null ? { rect, opacity, visible } : null; +} + +function parseAnchor(value: unknown): CheckAnchor | null { + if (!isRecord(value)) return null; + const selector = stringValue(value, "selector"); + const sourceFile = stringValue(value, "sourceFile"); + const time = numberValue(value, "time"); + const bbox = parseBbox(Reflect.get(value, "bbox")); + const dataAttributes = stringRecord(Reflect.get(value, "dataAttributes")); + return selector && sourceFile && time !== null && bbox && dataAttributes + ? { selector, sourceFile, time, bbox, dataAttributes } + : null; +} + +function runtimeFinding(draft: RuntimeDraft, root: CheckAnchor): CheckFinding { + return { + code: draft.code, + severity: draft.severity, + message: draft.message, + selector: root.selector, + dataAttributes: root.dataAttributes, + sourceFile: root.sourceFile, + bbox: root.bbox, + time: draft.time, + url: draft.url, + line: draft.line, + }; +} + +function fallbackAnchor(request: AnchorRequest | undefined): CheckAnchor { + return { + selector: request?.selector ?? "[data-composition-id]", + dataAttributes: {}, + sourceFile: "index.html", + bbox: request?.bbox ?? { x: 0, y: 0, width: 0, height: 0 }, + time: request?.time ?? 0, + }; +} + +function parseBbox(value: unknown): CheckBbox | null { + if (!isRecord(value)) return null; + const x = numberValue(value, "x"); + const y = numberValue(value, "y"); + const width = numberValue(value, "width") ?? numberValue(value, "w"); + const height = numberValue(value, "height") ?? numberValue(value, "h"); + return x !== null && y !== null && width !== null && height !== null + ? { x, y, width, height } + : null; +} + +function parseRect(value: unknown): LayoutRect | null { + if (!isRecord(value)) return null; + const left = numberValue(value, "left"); + const top = numberValue(value, "top"); + const right = numberValue(value, "right"); + const bottom = numberValue(value, "bottom"); + const width = numberValue(value, "width"); + const height = numberValue(value, "height"); + return left !== null && + top !== null && + right !== null && + bottom !== null && + width !== null && + height !== null + ? { left, top, right, bottom, width, height } + : null; +} + +function parseOverflow(value: unknown): LayoutIssue["overflow"] | null { + if (!isRecord(value)) return null; + const overflow: LayoutIssue["overflow"] = {}; + for (const side of ["left", "right", "top", "bottom"] as const) { + const amount = numberValue(value, side); + if (amount !== null) overflow[side] = amount; + } + return Object.keys(overflow).length > 0 ? overflow : null; +} + +const LAYOUT_ISSUE_CODES: readonly LayoutIssueCode[] = [ + "text_box_overflow", + "clipped_text", + "canvas_overflow", + "container_overflow", + "content_overlap", + "text_occluded", + "caption_zone_collision", + "frame_out_of_frame", + "motion_appears_late", + "motion_out_of_order", + "motion_off_frame", + "motion_frozen", + "motion_selector_missing", + "motion_selector_ambiguous", +]; + +function layoutCodeValue(value: unknown): LayoutIssueCode | null { + return LAYOUT_ISSUE_CODES.find((code) => code === value) ?? null; +} + +function severityValue(value: unknown): CheckSeverity | null { + return value === "error" || value === "warning" || value === "info" ? value : null; +} + +function rgbaValue(value: unknown): [number, number, number, number] | null { + if (!Array.isArray(value) || value.length < 4) return null; + const [red, green, blue, alpha] = value; + return [red, green, blue, alpha].every((channel) => typeof channel === "number") + ? [red, green, blue, alpha] + : null; +} + +function stringRecord(value: unknown): Record | null { + if (!isRecord(value)) return null; + const record: Record = {}; + for (const key of Object.keys(value)) { + const entry = Reflect.get(value, key); + if (typeof entry !== "string") return null; + record[key] = entry; + } + return record; +} + +function assignOptionalString( + issue: LayoutIssue, + source: Record, + key: "containerSelector" | "text" | "fixHint", +): void { + const value = stringValue(source, key); + if (value !== null) issue[key] = value; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stringValue(value: Record, key: string): string | null { + const entry = Reflect.get(value, key); + return typeof entry === "string" ? entry : null; +} + +function numberValue(value: Record, key: string): number | null { + const entry = Reflect.get(value, key); + return typeof entry === "number" && Number.isFinite(entry) ? entry : null; +} + +function booleanValue(value: Record, key: string): boolean | null { + const entry = Reflect.get(value, key); + return typeof entry === "boolean" ? entry : null; +} + +function urlPath(url: string): string { + try { + return decodeURIComponent(new URL(url).pathname).replace(/^\//, ""); + } catch { + return url; + } +} diff --git a/packages/cli/src/utils/checkPipeline.ts b/packages/cli/src/utils/checkPipeline.ts new file mode 100644 index 0000000000..8148654b24 --- /dev/null +++ b/packages/cli/src/utils/checkPipeline.ts @@ -0,0 +1,976 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import { trackCheckReport, trackCommandFailure } from "../telemetry/events.js"; +import { getRunId } from "../telemetry/runId.js"; +import type { ProjectDir } from "./project.js"; +import { lintProject, shouldBlockRender, type ProjectLintResult } from "./lintProject.js"; +import { + buildLayoutSampleTimes, + buildTransitionSampleTimes, + collapseStaticLayoutIssues, + dedupeLayoutIssues, + limitLayoutIssues, + mergeSampleTimes, + type LayoutIssue, + type LayoutRect, +} from "./layoutAudit.js"; +import { + collectSamplingTargets, + evaluateMotion, + type Canvas, + type MotionFrame, +} from "./motionAudit.js"; +import { findMotionSpec, readMotionSpec } from "./motionSpec.js"; +import { normalizeErrorMessage } from "./errorMessage.js"; +import { + parseColorRGBA, + requiredContrastRatio, + suggestCompliantForegroundColor, + type Rgb, +} from "../commands/contrast-bg.js"; +import { rectToBbox } from "./checkTypes.js"; +import type { + AnchoredLayoutIssue, + CheckAnnotationBox, + CheckAuditDriver, + CheckBbox, + CheckBrowserResult, + CheckContrastFinding, + CheckDependencies, + CheckFinding, + CheckFindingCropRequest, + CheckGeometryCandidate, + CheckOptions, + CheckReport, + CheckScreenshot, + CheckSection, + CheckSeverity, + ContrastAuditEntry, + GeometryCandidateRequest, + MotionSpecResolution, +} from "./checkTypes.js"; + +export type { + AnchoredLayoutIssue, + CheckAnchor, + CheckAuditDriver, + CheckBrowserResult, + CheckDependencies, + CheckFinding, + CheckFindingCropRequest, + CheckOptions, + CheckReport, + CheckSection, + ContrastAuditEntry, + MotionSpecResolution, +} from "./checkTypes.js"; + +const MOTION_FPS = 20; +const MOTION_MAX_SAMPLES = 300; +const ZERO_BBOX: CheckBbox = { x: 0, y: 0, width: 0, height: 0 }; +// Ignore normal in/out slide travel; only substantive frame breaches are actionable. +const FRAME_BREACH_FLOOR_PX = 120; +const FRAME_BREACH_FLOOR_FRACTION = 0.06; + +export const DEFAULT_CHECK_OPTIONS: CheckOptions = { + samples: 9, + atTransitions: false, + maxIssues: 80, + collapseStatic: true, + tolerance: 2, + timeout: 3000, + contrast: true, + strict: false, + snapshots: false, +}; + +/** Pick at most five evenly-strided points from the already-merged layout grid. */ +export function selectContrastTimes(grid: number[]): number[] { + if (grid.length <= 5) return [...grid]; + return Array.from({ length: 5 }, (_, index) => { + const selected = Math.floor((index * (grid.length - 1)) / 4); + return grid[selected] ?? grid[0] ?? 0; + }); +} + +function buildMotionSampleTimes(duration: number): number[] { + if (!Number.isFinite(duration) || duration <= 0) return []; + const count = Math.min(MOTION_MAX_SAMPLES, Math.max(2, Math.ceil(duration * MOTION_FPS) + 1)); + const step = duration / (count - 1); + return Array.from({ length: count }, (_, index) => Math.round(index * step * 1000) / 1000); +} + +interface SampleGrid { + duration: number; + layoutSamples: number[]; + captionSamples: number[]; + frameSamples: number[]; + transitionSamples: number[]; + transitionSamplesDropped: number; + contrastSamples: number[]; +} + +function gateSampleTimes( + duration: number, + seeks: number[] | undefined, + fallback: number, +): number[] { + if (!Number.isFinite(duration) || duration <= 0) return []; + const fractions = seeks && seeks.length > 0 ? seeks : [fallback]; + return mergeSampleTimes(fractions.map((fraction) => fraction * duration)); +} + +async function buildSampleGrid( + driver: CheckAuditDriver, + options: CheckOptions, +): Promise { + const duration = await driver.getDuration(); + const baseSamples = buildLayoutSampleTimes({ + duration, + samples: options.samples, + at: options.at, + }); + const transitions = options.atTransitions + ? buildTransitionSampleTimes({ + duration, + boundaries: await driver.getTransitionBoundaries(), + cap: options.maxTransitionSamples, + }) + : { times: [], dropped: 0 }; + const captionSamples = options.captionZone + ? gateSampleTimes(duration, options.captionZone.seek, 1) + : []; + const frameSamples = options.frameCheck + ? gateSampleTimes(duration, options.frameCheck.seek, 0.5) + : []; + const auditSamples = mergeSampleTimes(baseSamples, transitions.times); + const layoutSamples = mergeSampleTimes(auditSamples, captionSamples, frameSamples); + if (layoutSamples.length === 0) { + throw new Error("Could not determine composition duration — no layout samples run"); + } + return { + duration, + layoutSamples, + captionSamples, + frameSamples, + transitionSamples: transitions.times, + transitionSamplesDropped: transitions.dropped, + contrastSamples: options.contrast ? selectContrastTimes(auditSamples) : [], + }; +} + +interface MotionPlan { + times: number[]; + selectors: string[]; + livenessScopes: string[]; + preflightIssues: AnchoredLayoutIssue[]; +} + +async function planMotionSampling( + driver: CheckAuditDriver, + motion: MotionSpecResolution, + duration: number, +): Promise { + if (motion.kind !== "valid") { + return { times: [], selectors: [], livenessScopes: [], preflightIssues: [] }; + } + const targets = collectSamplingTargets(motion.spec.assertions); + const preflightIssues = await driver.findAmbiguousSelectors(targets.selectors); + const times = + preflightIssues.length === 0 ? buildMotionSampleTimes(motion.spec.duration ?? duration) : []; + return { times, ...targets, preflightIssues }; +} + +interface GridSamples { + layoutIssues: AnchoredLayoutIssue[]; + motionFrames: MotionFrame[]; + contrastEntries: ContrastAuditEntry[]; + screenshots: CheckScreenshot[]; + contrastMs: number; + /** One geometry+opacity fingerprint per layout sample (#U10 frozen-sweep guard). */ + geometrySignatures: string[]; +} + +interface GeometrySeen { + caption: Set; + frame: Set; +} + +function geometryRequest( + time: number, + grid: SampleGrid, + options: CheckOptions, +): GeometryCandidateRequest | null { + const text = grid.captionSamples.includes(time); + const media = grid.frameSamples.includes(time); + if (!text && !media) return null; + const configuredTolerance = options.frameCheck?.tol; + const tolerance = typeof configuredTolerance === "number" ? configuredTolerance : 2; + return { text, media, tolerance }; +} + +function candidateIsSized(candidate: CheckGeometryCandidate, canvas: Canvas): boolean { + if (candidate.elementRect.width < 4 || candidate.elementRect.height < 4) return false; + return !( + candidate.elementRect.width >= 0.95 * canvas.width && + candidate.elementRect.height >= 0.95 * canvas.height + ); +} + +function geometryIssueAnchor(candidate: CheckGeometryCandidate, time: number) { + return { + selector: candidate.selector, + dataAttributes: candidate.dataAttributes, + sourceFile: candidate.sourceFile, + bbox: candidate.bbox, + time, + rect: candidate.rect, + }; +} + +function captionFinding( + candidate: CheckGeometryCandidate, + options: CheckOptions, + canvas: Canvas, + time: number, +): { key: string; issue: AnchoredLayoutIssue } | null { + const zone = options.captionZone; + if (!zone || candidate.kind !== "text" || !candidateIsSized(candidate, canvas)) return null; + const cx = candidate.rect.left + candidate.rect.width / 2; + const cy = candidate.rect.top + candidate.rect.height / 2; + const inside = + cx >= zone.x0 * canvas.width && + cx <= zone.x1 * canvas.width && + cy >= zone.y0 * canvas.height && + cy <= zone.y1 * canvas.height; + if (!inside) return null; + const text = candidate.text.slice(0, 48); + const pctFromBottom = Math.round(((canvas.height - cy) / canvas.height) * 100); + return { + key: `${candidate.tag}|${text}`, + issue: { + ...geometryIssueAnchor(candidate, time), + code: "caption_zone_collision", + severity: zone.severity === "error" ? "error" : "warning", + text, + message: `<${candidate.tag}> "${text}" is centred in the reserved caption band (~${pctFromBottom}% up from the bottom).`, + fixHint: "Keep main content outside the configured caption band.", + }, + }; +} + +function maxOverflow(candidate: CheckGeometryCandidate): number { + if (!candidate.overflow) return 0; + return Math.max( + candidate.overflow.left ?? 0, + candidate.overflow.top ?? 0, + candidate.overflow.right ?? 0, + candidate.overflow.bottom ?? 0, + ); +} + +function overflowMessage(candidate: CheckGeometryCandidate): string { + const overflow = candidate.overflow ?? {}; + const edges: string[] = []; + if (overflow.left) edges.push(`${overflow.left}px past the left`); + if (overflow.top) edges.push(`${overflow.top}px past the top`); + if (overflow.right) edges.push(`${overflow.right}px past the right`); + if (overflow.bottom) edges.push(`${overflow.bottom}px past the bottom`); + return `<${candidate.tag}> "${candidate.text.slice(0, 48)}" spills outside the frame (${edges.join(", ")}).`; +} + +function frameFinding( + candidate: CheckGeometryCandidate, + options: CheckOptions, + canvas: Canvas, + time: number, +): { key: string; issue: AnchoredLayoutIssue } | null { + if (!options.frameCheck || candidate.kind !== "media" || !candidateIsSized(candidate, canvas)) { + return null; + } + const floor = Math.max( + FRAME_BREACH_FLOOR_PX, + FRAME_BREACH_FLOOR_FRACTION * Math.min(canvas.width, canvas.height), + ); + if (maxOverflow(candidate) < floor) return null; + const text = candidate.text.slice(0, 48); + return { + key: `${candidate.tag}|${text}|${Math.round(candidate.rect.left)},${Math.round(candidate.rect.top)}`, + issue: { + ...geometryIssueAnchor(candidate, time), + code: "frame_out_of_frame", + severity: options.frameCheck.severity === "error" ? "error" : "warning", + text, + overflow: candidate.overflow, + message: overflowMessage(candidate), + fixHint: "Keep media within the composition frame's safe area.", + }, + }; +} + +function appendGeometryFinding( + result: { key: string; issue: AnchoredLayoutIssue } | null, + seen: Set, + issues: AnchoredLayoutIssue[], +): void { + if (!result || seen.has(result.key)) return; + seen.add(result.key); + issues.push(result.issue); +} + +async function collectGeometryAt( + driver: CheckAuditDriver, + options: CheckOptions, + grid: SampleGrid, + canvas: Canvas, + time: number, + seen: GeometrySeen, +): Promise { + const request = geometryRequest(time, grid, options); + if (!request) return []; + const candidates = await driver.collectGeometryCandidates(time, request); + const issues: AnchoredLayoutIssue[] = []; + for (const candidate of candidates) { + if (request.text) { + appendGeometryFinding(captionFinding(candidate, options, canvas, time), seen.caption, issues); + } + if (request.media) { + appendGeometryFinding(frameFinding(candidate, options, canvas, time), seen.frame, issues); + } + } + return issues; +} + +async function collectGridSamples( + driver: CheckAuditDriver, + options: CheckOptions, + grid: SampleGrid, + motion: MotionPlan, +): Promise { + const layoutSet = new Set(grid.layoutSamples); + const motionSet = new Set(motion.times); + const contrastSet = new Set(grid.contrastSamples); + const geometryEnabled = grid.captionSamples.length > 0 || grid.frameSamples.length > 0; + const canvas = geometryEnabled ? await driver.getCanvas() : null; + const geometrySeen: GeometrySeen = { caption: new Set(), frame: new Set() }; + const collected: GridSamples = { + layoutIssues: [], + motionFrames: [], + contrastEntries: [], + screenshots: [], + contrastMs: 0, + geometrySignatures: [], + }; + for (const time of mergeSampleTimes(grid.layoutSamples, motion.times)) { + await driver.seek(time); + // Findings collected for THIS sample time, so the overview overlay (below) + // only ever annotates a frame with defects that are actually valid at + // that render time — never a stale bbox from an earlier/later sample. + const issuesAtTime: AnchoredLayoutIssue[] = []; + if (layoutSet.has(time)) { + const layoutIssues = await driver.collectLayout(time, options.tolerance); + collected.layoutIssues.push(...layoutIssues); + issuesAtTime.push(...layoutIssues); + collected.geometrySignatures.push(await driver.collectLayoutGeometry()); + } + if (canvas) { + const geometryIssues = await collectGeometryAt( + driver, + options, + grid, + canvas, + time, + geometrySeen, + ); + collected.layoutIssues.push(...geometryIssues); + issuesAtTime.push(...geometryIssues); + } + if (motionSet.has(time)) { + collected.motionFrames.push( + await driver.collectMotionFrame(time, motion.selectors, motion.livenessScopes), + ); + } + if (contrastSet.has(time)) { + const contrastStart = Date.now(); + // Annotation is a --snapshots-only nicety — skip building it (and the + // driver's extra overlay screenshot) when nothing will use it; the call + // shape without --snapshots stays exactly what it was before this existed. + const capture = options.snapshots + ? await driver.collectContrast(time, annotationBoxesFrom(issuesAtTime)) + : await driver.collectContrast(time); + collected.contrastMs += Date.now() - contrastStart; + collected.contrastEntries.push(...capture.entries); + collected.screenshots.push({ time, pngBase64: capture.pngBase64 }); + } + } + return collected; +} + +// Frozen-sweep guard (#U10): compositions this short can legitimately hold a +// single static frame the whole time (a title card) — never flag those. +const SWEEP_STATIC_MIN_DURATION_SEC = 3; +const ZERO_LAYOUT_RECT: LayoutRect = { + left: 0, + top: 0, + right: 0, + bottom: 0, + width: 0, + height: 0, +}; + +/** + * Frozen-sweep guard (#U10): if every layout-grid sample produced the exact + * same geometry+opacity fingerprint (see layout-audit.browser.js), the seek + * never actually advanced the composition's timeline — every other green + * verdict from this run is meaningless, not just a missed defect. Skips + * short (<3s) compositions, single-sample runs (nothing to compare), and + * runs where a `motion_frozen` finding already reported the same underlying + * symptom (no double-reporting the one thing that's wrong). + */ +function detectSweepStatic( + duration: number, + geometrySignatures: string[], + motionIssues: AnchoredLayoutIssue[], +): AnchoredLayoutIssue[] { + if (duration < SWEEP_STATIC_MIN_DURATION_SEC) return []; + if (geometrySignatures.length < 2) return []; + if (motionIssues.some((issue) => issue.code === "motion_frozen")) return []; + const [first, ...rest] = geometrySignatures; + if (!first || rest.some((signature) => signature !== first)) return []; + return [ + { + code: "sweep_static", + severity: "error", + time: 0, + selector: "[data-composition-id]", + dataAttributes: {}, + sourceFile: "index.html", + bbox: ZERO_BBOX, + rect: ZERO_LAYOUT_RECT, + message: + "Timeline did not advance under seek; every green verdict on this run is unreliable.", + fixHint: + "Confirm the composition seeks a paused GSAP/CSS timeline under `data-*` timing attributes rather than only autoplaying.", + }, + ]; +} + +/** Error-severity findings with real geometry become labeled overview boxes. + * Contrast failures are annotated separately by the driver itself, since + * they're only known once contrast measurement for this sample completes. */ +function annotationBoxesFrom(issues: AnchoredLayoutIssue[]): CheckAnnotationBox[] { + return issues + .filter((issue) => issue.severity === "error" && issue.bbox.width > 0 && issue.bbox.height > 0) + .map((issue, index) => ({ label: `${index + 1} ${issue.code}`, bbox: issue.bbox })); +} + +export async function runAuditGrid( + driver: CheckAuditDriver, + options: CheckOptions, + motion: MotionSpecResolution, +): Promise { + await driver.initialize(options.contrast); + const grid = await buildSampleGrid(driver, options); + const plan = await planMotionSampling(driver, motion, grid.duration); + const seekLoopStart = Date.now(); + const collected = await collectGridSamples(driver, options, grid, plan); + const seekLoopMs = Date.now() - seekLoopStart; + + let motionIssues = plan.preflightIssues; + if (motion.kind === "valid" && motionIssues.length === 0 && collected.motionFrames.length > 0) { + const evaluated = evaluateMotion( + collected.motionFrames, + motion.spec.assertions, + await driver.getCanvas(), + ); + motionIssues = await driver.anchorMotionIssues(evaluated); + } + const sweepFindings = detectSweepStatic( + grid.duration, + collected.geometrySignatures, + motionIssues, + ); + const contrast = buildContrastResults(collected.contrastEntries); + return { + duration: grid.duration, + layoutSamples: grid.layoutSamples, + transitionSamples: grid.transitionSamples, + transitionSamplesDropped: grid.transitionSamplesDropped, + runtimeFindings: [], + layoutIssues: [...collected.layoutIssues, ...sweepFindings], + motionIssues, + motionSampleCount: collected.motionFrames.length, + contrastSamples: grid.contrastSamples, + contrastFindings: contrast.findings, + contrastChecked: collected.contrastEntries.length, + contrastPassed: contrast.passed, + screenshots: collected.screenshots, + timings: { launchSettleMs: 0, seekLoopMs, contrastMs: collected.contrastMs }, + }; +} + +export async function runCheckPipeline( + project: ProjectDir, + options: CheckOptions, + dependencies: CheckDependencies = DEFAULT_DEPENDENCIES, +): Promise { + let lintResult: ProjectLintResult; + try { + lintResult = await dependencies.lintProject(project.dir); + } catch (error) { + // The linter itself crashed (unreadable file, internal error) — distinct + // from lint findings; a runtime-failure code would send the agent hunting + // for a script problem that doesn't exist. + return failureReport(options, runtimeFailure(error, "check_lint_failure")); + } + + const lint = buildLintSection(lintResult); + if (shouldBlockRender(true, false, lintResult.totalErrors, lintResult.totalWarnings)) { + return buildReport(options, lint, emptyBrowserResult(), { kind: "none" }, [], []); + } + + const motion = dependencies.resolveMotionSpec(project.dir); + if (motion.kind === "invalid") { + const finding = findingAtRoot( + "motion_spec_invalid", + "error", + motion.message, + relative(project.dir, motion.path) || "index.motion.json", + ); + return buildReport(options, lint, emptyBrowserResult(), motion, [finding], []); + } + + let browser: CheckBrowserResult; + try { + browser = await dependencies.runBrowserCheck(project, options, motion); + } catch (error) { + browser = emptyBrowserResult(); + browser.runtimeFindings.push(runtimeFailure(error)); + } + + const snapshotFiles = options.snapshots + ? await writeContrastSnapshots(dependencies, project.dir, browser) + : []; + const report = buildReport(options, lint, browser, motion, [], snapshotFiles); + return options.snapshots + ? await withFindingCrops(dependencies, project, options, report) + : report; +} + +/** Persists the contrast pass's already-captured overview PNGs (or the + * annotated versions — see `collectContrast`'s overlay). A write failure + * becomes a runtime finding rather than aborting the whole report. */ +async function writeContrastSnapshots( + dependencies: CheckDependencies, + projectDir: string, + browser: CheckBrowserResult, +): Promise { + const files: string[] = []; + for (let index = 0; index < browser.screenshots.length; index += 1) { + const shot = browser.screenshots[index]; + if (!shot) continue; + try { + files.push(await dependencies.writeSnapshot(projectDir, index, shot.time, shot.pngBase64)); + } catch (error) { + browser.runtimeFindings.push(runtimeFailure(error, "snapshot_write_failed")); + } + } + return files; +} + +/** Finding crops are bonus evidence, not gating — no eligible finding, or a + * capture failure (e.g. a second Chrome launch failing), returns the report + * unchanged rather than sinking an otherwise-good run. */ +async function withFindingCrops( + dependencies: CheckDependencies, + project: ProjectDir, + options: CheckOptions, + report: CheckReport, +): Promise { + const cropRequests = selectFindingCropRequests(report); + if (cropRequests.length === 0) return report; + try { + const findingFiles = await dependencies.captureFindingCrops(project, options, cropRequests); + return { ...report, snapshots: { ...report.snapshots, findingFiles } }; + } catch (error) { + // Still non-gating, but observable: rollouts need the crop-failure rate + // (a second Chrome launch failing/timing out) without failing the run. + console.error(" finding crops skipped: " + normalizeErrorMessage(error)); + trackCommandFailure("check-finding-crops", error); + return report; + } +} + +const MAX_FINDING_CROPS = 12; + +/** Which error findings get a `finding-NN-.png` crop for `check --snapshots`: + * error severity, a real (non-zero) bbox, capped at 12. Pure and order-preserving + * so it's directly unit-testable without a browser. */ +export function selectFindingCropRequests(report: CheckReport): CheckFindingCropRequest[] { + const candidates: CheckFinding[] = [ + ...report.layout.findings, + ...report.motion.findings, + ...report.contrast.findings, + ...report.runtime.findings, + ]; + const requests: CheckFindingCropRequest[] = []; + for (const finding of candidates) { + if (requests.length >= MAX_FINDING_CROPS) break; + if (finding.severity !== "error" || !hasRealBbox(finding.bbox)) continue; + requests.push({ + filename: findingCropFilename(requests.length, finding.code), + time: finding.time, + bbox: finding.bbox, + }); + } + return requests; +} + +function hasRealBbox(bbox: CheckBbox): boolean { + return bbox.width > 0 && bbox.height > 0; +} + +export function findingCropFilename(index: number, code: string): string { + const safeCode = code.replace(/[^a-zA-Z0-9_-]/g, "_"); + return `finding-${String(index).padStart(2, "0")}-${safeCode}.png`; +} + +export function checkExitCode(report: CheckReport): 0 | 1 { + return report.ok ? 0 : 1; +} + +// Same persistence rule the layout findings follow: a failure observed at a +// single contrast sample is usually text caught mid-entrance/exit (its real +// background not painted yet — white-on-white at exactly 1.0 is the classic +// shape), so it demotes to warning. A failure HELD at 2+ samples for the same +// element is a real, gating defect. Single-sample sweeps can't distinguish, +// so they keep full severity. +function contrastFailureHeld( + entries: ContrastAuditEntry[], +): (entry: ContrastAuditEntry) => boolean { + const sampledTimes = new Set(entries.map((entry) => entry.time)).size; + const failureSamples = new Map>(); + for (const entry of entries) { + if (entry.wcagAA) continue; + const key = `${entry.selector}|${entry.text}`; + const times = failureSamples.get(key) ?? new Set(); + times.add(entry.time); + failureSamples.set(key, times); + } + return (entry) => + sampledTimes < 2 || (failureSamples.get(`${entry.selector}|${entry.text}`)?.size ?? 0) >= 2; +} + +function buildContrastResults(entries: ContrastAuditEntry[]): { + findings: CheckContrastFinding[]; + passed: number; +} { + const findings: CheckContrastFinding[] = []; + let passed = 0; + const isHeld = contrastFailureHeld(entries); + for (const entry of entries) { + if (entry.wcagAA) { + passed += 1; + continue; + } + const held = isHeld(entry); + const requiredRatio = requiredContrastRatio(entry.large); + findings.push({ + code: "contrast_aa_failure", + severity: held ? "error" : "warning", + message: `Contrast is ${entry.ratio}:1; WCAG AA requires ${requiredRatio}:1.`, + text: entry.text, + fg: entry.fg, + bg: entry.bg, + ratio: entry.ratio, + requiredRatio, + suggestedColor: suggestedColor(entry.fg, entry.bg, requiredRatio), + large: entry.large, + selector: entry.selector, + dataAttributes: entry.dataAttributes, + sourceFile: entry.sourceFile, + bbox: entry.bbox, + time: entry.time, + }); + } + return { findings, passed }; +} + +function suggestedColor(fg: string, bg: string, requiredRatio: number): string { + const foreground = parseColorRGBA(fg); + const background = parseColorRGBA(bg); + if (!foreground || !background) return fg; + const fgRgb: Rgb = [foreground[0], foreground[1], foreground[2]]; + const bgRgb: Rgb = [background[0], background[1], background[2]]; + const suggested = suggestCompliantForegroundColor(fgRgb, bgRgb, requiredRatio); + return `rgb(${suggested[0]},${suggested[1]},${suggested[2]})`; +} + +function buildLintSection(result: ProjectLintResult): CheckReport["lint"] { + const findings = result.results.flatMap(({ file, result: fileResult }) => + fileResult.findings.map((finding) => ({ + code: finding.code, + severity: finding.severity, + message: finding.message, + selector: + finding.selector ?? (finding.elementId ? `#${finding.elementId}` : "[data-composition-id]"), + dataAttributes: {}, + sourceFile: finding.file ?? file, + bbox: ZERO_BBOX, + time: 0, + fixHint: finding.fixHint, + })), + ); + return { ...section(findings), filesScanned: result.results.length }; +} + +function buildReport( + options: CheckOptions, + lint: CheckReport["lint"], + browser: CheckBrowserResult, + motion: MotionSpecResolution, + extraMotionFindings: CheckFinding[], + snapshotFiles: string[], +): CheckReport { + const layout = shapeLayoutSection(browser.layoutIssues, browser, options); + const shapedMotion = shapeLayoutFindings(browser.motionIssues, options); + const motionFindings: CheckFinding[] = [...shapedMotion.findings, ...extraMotionFindings]; + const runtime = section(browser.runtimeFindings); + const motionSection = section(motionFindings); + const contrastSection = section(browser.contrastFindings); + const warningCount = + lint.warningCount + + runtime.warningCount + + layout.warningCount + + motionSection.warningCount + + contrastSection.warningCount; + const errorCount = + lint.errorCount + + runtime.errorCount + + layout.errorCount + + motionSection.errorCount + + contrastSection.errorCount; + const report: CheckReport = { + ok: errorCount === 0 && (!options.strict || warningCount === 0), + strict: options.strict, + lint, + runtime, + layout, + motion: { + ...motionSection, + enabled: motion.kind !== "none", + specPath: motion.kind === "none" ? undefined : motion.path, + samples: browser.motionSampleCount, + }, + contrast: { + ...contrastSection, + enabled: options.contrast, + samples: browser.contrastSamples, + checked: browser.contrastChecked, + passed: browser.contrastPassed, + }, + snapshots: { + enabled: options.snapshots, + files: snapshotFiles, + times: options.snapshots ? browser.screenshots.map((shot) => shot.time) : [], + findingFiles: [], + }, + }; + trackCheckReport({ + contrastGate: options.contrast, + motionGate: motion.kind !== "none", + captionZoneGate: options.captionZone !== undefined, + frameCheckGate: options.frameCheck !== undefined, + snapshotsGate: options.snapshots, + lintErrors: lint.errorCount, + lintWarnings: lint.warningCount, + runtimeErrors: runtime.errorCount, + runtimeWarnings: runtime.warningCount, + layoutErrors: layout.errorCount, + layoutWarnings: layout.warningCount, + motionErrors: motionSection.errorCount, + motionWarnings: motionSection.warningCount, + contrastErrors: contrastSection.errorCount, + contrastWarnings: contrastSection.warningCount, + launchSettleMs: browser.timings.launchSettleMs, + seekLoopMs: browser.timings.seekLoopMs, + contrastMs: browser.timings.contrastMs, + gridPoints: browser.layoutSamples.length, + contrastPoints: browser.contrastChecked, + ok: report.ok, + exitCode: checkExitCode(report), + runId: getRunId(), + }); + return report; +} + +function shapeLayoutSection( + issues: AnchoredLayoutIssue[], + browser: CheckBrowserResult, + options: CheckOptions, +): CheckReport["layout"] { + const shaped = shapeLayoutFindings(issues, options, browser.layoutSamples.length); + return { + ...section(shaped.findings), + duration: browser.duration, + samples: browser.layoutSamples, + transitionSamples: browser.transitionSamples, + transitionSamplesDropped: browser.transitionSamplesDropped, + tolerance: options.tolerance, + totalIssueCount: shaped.totalIssueCount, + truncated: shaped.truncated, + }; +} + +function shapeLayoutFindings( + issues: AnchoredLayoutIssue[], + options: CheckOptions, + totalSampleCount?: number, +): { findings: AnchoredLayoutIssue[]; totalIssueCount: number; truncated: boolean } { + const deduped = dedupeLayoutIssues(issues); + const all = options.collapseStatic + ? collapseStaticLayoutIssues(deduped, totalSampleCount) + : deduped; + const limited = limitLayoutIssues(all, options.maxIssues); + return { + findings: limited.issues.map(ensureAnchoredLayoutIssue), + totalIssueCount: limited.totalIssueCount, + truncated: limited.truncated, + }; +} + +function ensureAnchoredLayoutIssue(issue: LayoutIssue): AnchoredLayoutIssue { + const sourceFile = Reflect.get(issue, "sourceFile"); + const dataAttributes = Reflect.get(issue, "dataAttributes"); + const bbox = Reflect.get(issue, "bbox"); + if (typeof sourceFile === "string" && isStringRecord(dataAttributes) && isBbox(bbox)) { + return { ...issue, sourceFile, dataAttributes, bbox }; + } + return { + ...issue, + sourceFile: "index.html", + dataAttributes: {}, + bbox: rectToBbox(issue.rect), + }; +} + +function section(findings: T[]): CheckSection { + const errorCount = findings.filter((finding) => finding.severity === "error").length; + const warningCount = findings.filter((finding) => finding.severity === "warning").length; + const infoCount = findings.filter((finding) => finding.severity === "info").length; + return { ok: errorCount === 0, errorCount, warningCount, infoCount, findings }; +} + +function emptyBrowserResult(): CheckBrowserResult { + return { + duration: 0, + layoutSamples: [], + transitionSamples: [], + transitionSamplesDropped: 0, + runtimeFindings: [], + layoutIssues: [], + motionIssues: [], + motionSampleCount: 0, + contrastSamples: [], + contrastFindings: [], + contrastChecked: 0, + contrastPassed: 0, + screenshots: [], + timings: { launchSettleMs: 0, seekLoopMs: 0, contrastMs: 0 }, + }; +} + +function runtimeFailure(error: unknown, code = "check_runtime_failure"): CheckFinding { + return findingAtRoot(code, "error", normalizeErrorMessage(error), "index.html"); +} + +function findingAtRoot( + code: string, + severity: CheckSeverity, + message: string, + sourceFile: string, +): CheckFinding { + return { + code, + severity, + message, + selector: "[data-composition-id]", + dataAttributes: {}, + sourceFile, + bbox: ZERO_BBOX, + time: 0, + }; +} + +function failureReport(options: CheckOptions, finding: CheckFinding): CheckReport { + const lint = { ...section([]), filesScanned: 0 }; + const browser = emptyBrowserResult(); + browser.runtimeFindings.push(finding); + return buildReport(options, lint, browser, { kind: "none" }, [], []); +} + +function isBbox(value: unknown): value is CheckBbox { + if (typeof value !== "object" || value === null) return false; + return ["x", "y", "width", "height"].every((key) => typeof Reflect.get(value, key) === "number"); +} + +function isStringRecord(value: unknown): value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + return Object.keys(value).every((key) => typeof Reflect.get(value, key) === "string"); +} + +function resolveMotionSpec(projectDir: string): MotionSpecResolution { + const path = findMotionSpec(projectDir); + if (!path) return { kind: "none" }; + const result = readMotionSpec(path); + return result.ok + ? { kind: "valid", path, spec: result.spec } + : { + kind: "invalid", + path, + message: `Invalid motion spec ${path}: ${result.errors.join("; ")}`, + }; +} + +async function runBrowserCheck( + project: ProjectDir, + options: CheckOptions, + motion: MotionSpecResolution, +): Promise { + const module = await import("./checkBrowser.js"); + // runAuditGrid is handed over as a callback so checkBrowser never imports + // this module back (no import cycle). + return module.runBrowserCheck(project, options, motion, runAuditGrid); +} + +async function writeSnapshot( + projectDir: string, + index: number, + time: number, + pngBase64: string, +): Promise { + const snapshotDir = join(projectDir, "snapshots"); + mkdirSync(snapshotDir, { recursive: true }); + const filename = `frame-${String(index).padStart(2, "0")}-at-${time.toFixed(1)}s.png`; + const path = join(snapshotDir, filename); + writeFileSync(path, Buffer.from(pngBase64, "base64")); + return join("snapshots", filename); +} + +async function captureFindingCrops( + project: ProjectDir, + options: CheckOptions, + requests: CheckFindingCropRequest[], +): Promise { + const module = await import("./checkBrowser.js"); + // Handed over the same way runBrowserCheck is (checkBrowser never imports this module back). + return module.captureFindingCrops(project, options, requests); +} + +const DEFAULT_DEPENDENCIES: CheckDependencies = { + lintProject, + resolveMotionSpec, + runBrowserCheck, + writeSnapshot, + captureFindingCrops, +}; diff --git a/packages/cli/src/utils/checkTypes.ts b/packages/cli/src/utils/checkTypes.ts new file mode 100644 index 0000000000..02996dc9e5 --- /dev/null +++ b/packages/cli/src/utils/checkTypes.ts @@ -0,0 +1,246 @@ +import type { ProjectLintResult } from "./lintProject.js"; +import type { LayoutIssue, LayoutOverflow, LayoutRect } from "./layoutAudit.js"; +import type { Canvas, MotionFrame } from "./motionAudit.js"; +import type { MotionSpec } from "./motionSpec.js"; +import type { ProjectDir } from "./project.js"; + +export interface CheckOptions { + samples: number; + at?: number[]; + atTransitions: boolean; + maxTransitionSamples?: number; + maxIssues: number; + collapseStatic: boolean; + tolerance: number; + timeout: number; + contrast: boolean; + strict: boolean; + snapshots: boolean; + captionZone?: CaptionZoneOptions; + frameCheck?: FrameCheckOptions; +} + +export interface CaptionZoneOptions { + x0: number; + y0: number; + x1: number; + y1: number; + severity?: "error" | "warning"; + seek?: number[]; +} + +export interface FrameCheckOptions { + tol?: number; + severity?: "error" | "warning"; + seek?: number[]; +} + +export type CheckSeverity = "error" | "warning" | "info"; + +export interface CheckBbox { + x: number; + y: number; + width: number; + height: number; +} + +export interface CheckAnchor { + selector: string; + dataAttributes: Record; + sourceFile: string; + bbox: CheckBbox; + time: number; +} + +export interface CheckFinding extends CheckAnchor { + code: string; + severity: CheckSeverity; + message: string; + text?: string; + fixHint?: string; + url?: string; + line?: number; +} + +export interface AnchoredLayoutIssue extends LayoutIssue, CheckAnchor {} + +export interface ContrastAuditEntry extends CheckAnchor { + text: string; + ratio: number; + wcagAA: boolean; + large: boolean; + fg: string; + bg: string; +} + +export interface CheckContrastFinding extends CheckFinding { + fg: string; + bg: string; + ratio: number; + requiredRatio: number; + suggestedColor: string; + large: boolean; +} + +export interface ContrastCapture { + entries: ContrastAuditEntry[]; + pngBase64: string; +} + +export interface GeometryCandidateRequest { + text: boolean; + media: boolean; + tolerance: number; +} + +/** A labeled rectangle drawn on an overview frame's annotation overlay + * (`check --snapshots`) so one screenshot orients an agent across every + * error finding at that sample time. */ +export interface CheckAnnotationBox { + label: string; + bbox: CheckBbox; +} + +/** A single crop to capture for `check --snapshots`'s per-finding evidence + * PNGs — filename and bbox already resolved by the pipeline. */ +export interface CheckFindingCropRequest { + filename: string; + time: number; + bbox: CheckBbox; +} + +export interface CheckGeometryCandidate extends CheckAnchor { + kind: "text" | "media"; + tag: string; + text: string; + rect: LayoutRect; + elementRect: LayoutRect; + overflow?: LayoutOverflow; +} + +export type MotionSpecResolution = + | { kind: "none" } + | { kind: "valid"; path: string; spec: MotionSpec } + | { kind: "invalid"; path: string; message: string }; + +export interface CheckAuditDriver { + initialize(contrast: boolean): Promise; + getDuration(): Promise; + getTransitionBoundaries(): Promise; + getCanvas(): Promise; + findAmbiguousSelectors(selectors: string[]): Promise; + seek(time: number): Promise; + collectLayout(time: number, tolerance: number): Promise; + /** Frozen-sweep guard (#U10): an opaque per-sample geometry+opacity + * fingerprint of the current seeked state, for detecting a timeline that + * never advances under seek. See layout-audit.browser.js. */ + collectLayoutGeometry(): Promise; + collectGeometryCandidates( + time: number, + request: GeometryCandidateRequest, + ): Promise; + collectMotionFrame( + time: number, + selectors: string[], + livenessScopes: string[], + ): Promise; + anchorMotionIssues(issues: LayoutIssue[]): Promise; + collectContrast(time: number, annotations?: CheckAnnotationBox[]): Promise; +} + +export interface CheckScreenshot { + time: number; + pngBase64: string; +} + +export interface CheckTimings { + launchSettleMs: number; + seekLoopMs: number; + contrastMs: number; +} + +export interface CheckBrowserResult { + duration: number; + layoutSamples: number[]; + transitionSamples: number[]; + transitionSamplesDropped: number; + runtimeFindings: CheckFinding[]; + layoutIssues: AnchoredLayoutIssue[]; + motionIssues: AnchoredLayoutIssue[]; + motionSampleCount: number; + contrastSamples: number[]; + contrastFindings: CheckContrastFinding[]; + contrastChecked: number; + contrastPassed: number; + screenshots: CheckScreenshot[]; + timings: CheckTimings; +} + +/** The seek-grid audit loop, injected into checkBrowser so it never imports checkPipeline back. */ +export type RunAuditGrid = ( + driver: CheckAuditDriver, + options: CheckOptions, + motion: MotionSpecResolution, +) => Promise; + +export interface CheckSection { + ok: boolean; + errorCount: number; + warningCount: number; + infoCount: number; + findings: T[]; +} + +export interface CheckReport { + ok: boolean; + strict: boolean; + lint: CheckSection & { filesScanned: number }; + runtime: CheckSection; + layout: CheckSection & { + duration: number; + samples: number[]; + transitionSamples: number[]; + transitionSamplesDropped: number; + tolerance: number; + totalIssueCount: number; + truncated: boolean; + }; + motion: CheckSection & { enabled: boolean; specPath?: string; samples: number }; + contrast: CheckSection & { + enabled: boolean; + samples: number[]; + checked: number; + passed: number; + }; + snapshots: { enabled: boolean; files: string[]; times: number[]; findingFiles: string[] }; +} + +export interface CheckDependencies { + lintProject(projectDir: string): Promise; + resolveMotionSpec(projectDir: string): MotionSpecResolution; + runBrowserCheck( + project: ProjectDir, + options: CheckOptions, + motion: MotionSpecResolution, + ): Promise; + writeSnapshot( + projectDir: string, + index: number, + time: number, + pngBase64: string, + ): Promise; + captureFindingCrops( + project: ProjectDir, + options: CheckOptions, + requests: CheckFindingCropRequest[], + ): Promise; +} + +export function rectToBbox(rect: { + left: number; + top: number; + width: number; + height: number; +}): CheckBbox { + return { x: rect.left, y: rect.top, width: rect.width, height: rect.height }; +} diff --git a/packages/cli/src/utils/layoutAudit.test.ts b/packages/cli/src/utils/layoutAudit.test.ts index 6c4ffc9704..ba57171033 100644 --- a/packages/cli/src/utils/layoutAudit.test.ts +++ b/packages/cli/src/utils/layoutAudit.test.ts @@ -199,6 +199,82 @@ describe("layoutAudit helpers", () => { }); }); +// #U10: held-duration severity tiering on top of the existing collapse step. +// Sample counts below (9) mirror the CLI's default grid so the "1 sample = +// entrance/exit transient, 2+ adjacent samples = held" framing in the +// approach doc lines up with the numbers used here. +describe("persistence-tiered severity (#U10)", () => { + it("demotes a content_overlap seen at only one sample among several to info", () => { + const collapsed = collapseStaticLayoutIssues( + [{ ...issue("content_overlap", "warning"), time: 3 }], + 9, + ); + + expect(collapsed).toHaveLength(1); + expect(collapsed[0]).toMatchObject({ severity: "info", occurrences: 1 }); + }); + + it("promotes content_overlap held across >= 2 adjacent samples to error", () => { + const collapsed = collapseStaticLayoutIssues( + [ + { ...issue("content_overlap", "warning"), time: 3 }, + { ...issue("content_overlap", "warning"), time: 3.6 }, + ], + 9, + ); + + expect(collapsed).toHaveLength(1); + expect(collapsed[0]).toMatchObject({ severity: "error", occurrences: 2 }); + }); + + it("does not demote a finding held at every sample — persistence, not a single hit", () => { + const collapsed = collapseStaticLayoutIssues( + [ + { ...issue("text_box_overflow", "error"), time: 1 }, + { ...issue("text_box_overflow", "error"), time: 3 }, + { ...issue("text_box_overflow", "error"), time: 5 }, + ], + 9, + ); + + expect(collapsed).toHaveLength(1); + expect(collapsed[0]).toMatchObject({ severity: "error", occurrences: 3 }); + }); + + it("only re-promotes content_overlap — other held codes keep their original severity", () => { + const collapsed = collapseStaticLayoutIssues( + [ + { ...issue("container_overflow", "warning"), time: 3 }, + { ...issue("container_overflow", "warning"), time: 3.6 }, + ], + 9, + ); + + expect(collapsed[0]).toMatchObject({ severity: "warning" }); + }); + + it("skips tiering entirely on a single-sample run — nothing to compare a transient against", () => { + const collapsed = collapseStaticLayoutIssues( + [{ ...issue("content_overlap", "warning"), time: 3 }], + 1, + ); + + expect(collapsed[0]).toMatchObject({ severity: "warning" }); + }); + + it("infers the sample count from distinct issue times when none is given", () => { + // Two distinct times among the raw issues imply a multi-sample run even + // without an explicit count, so the single-occurrence group still demotes. + const collapsed = collapseStaticLayoutIssues([ + { ...issue("content_overlap", "warning"), time: 3 }, + { ...issue("text_box_overflow", "error"), time: 5 }, + ]); + + const overlap = collapsed.find((found) => found.code === "content_overlap"); + expect(overlap).toMatchObject({ severity: "info" }); + }); +}); + function issue(code: LayoutIssue["code"], severity: LayoutIssue["severity"]): LayoutIssue { return { code, diff --git a/packages/cli/src/utils/layoutAudit.ts b/packages/cli/src/utils/layoutAudit.ts index 70b00deb9c..6ba63ccb93 100644 --- a/packages/cli/src/utils/layoutAudit.ts +++ b/packages/cli/src/utils/layoutAudit.ts @@ -16,6 +16,11 @@ export type LayoutIssueCode = | "container_overflow" | "content_overlap" | "text_occluded" + | "caption_zone_collision" + | "frame_out_of_frame" + // Frozen-sweep guard (#U10) — a whole-run meta-finding, not a per-sample + // geometry observation; never persistence-tiered (see `applyPersistenceTier`). + | "sweep_static" // Motion-verification findings (#1437) — evaluated against the seeked timeline. | "motion_appears_late" | "motion_out_of_order" @@ -40,6 +45,9 @@ export interface LayoutIssue { rect: LayoutRect; containerRect?: LayoutRect; overflow?: LayoutOverflow; + /** `text_occluded` only: approximate fraction (0-1) of the occlusion probe + * grid that hit an opaque occluder — see layout-audit.browser.js. */ + coveredFraction?: number; fixHint?: string; } @@ -152,6 +160,7 @@ export function dedupeLayoutIssues(issues: LayoutIssue[]): LayoutIssue[] { issue.containerSelector ?? "", issue.text ?? "", issue.overflow ? formatOverflow(issue.overflow) : "", + framePositionKey(issue), ].join("|"); if (seen.has(key)) continue; seen.add(key); @@ -161,7 +170,44 @@ export function dedupeLayoutIssues(issues: LayoutIssue[]): LayoutIssue[] { return result; } -export function collapseStaticLayoutIssues(issues: LayoutIssue[]): LayoutIssue[] { +// Persistence-tier thresholds (#U10, adapted from Adam Rosler's visual-linter +// design). The approach doc frames these as held-duration floors — ignore +// under ~250ms, re-promote content_overlap at >= ~500ms — measured against +// the SAME firstSeen/lastSeen span this collapse step already tracks. At the +// default 9-sample grid over a multi-second composition, a single collapsed +// occurrence is held 0ms (one entrance/exit transient sample) and two +// collapsed occurrences are already >= one sample-to-sample gap, which is +// well past 500ms — so "held under 250ms" reduces to `occurrences <= 1` and +// "held >= 500ms" reduces to `occurrences >= 2`. Tiering below is written in +// those sample-count terms (the mapping the approach doc asks to document), +// with the literal ms span (CONTENT_OVERLAP_HELD_ERROR_MS) kept as a fallback +// for callers whose samples really are spaced close enough together for the +// ms floor to matter on its own (dense `--at`/`--at-transitions` runs). The +// ~250ms ignore floor needs no separate constant — see the occurrences <= 1 +// branch below. +const CONTENT_OVERLAP_HELD_ERROR_MS = 500; +const HELD_ACROSS_SAMPLES_MIN_OCCURRENCES = 2; + +// Tiering only applies to layout-audit.browser.js's own per-sample seek-grid +// findings — the ones this collapse step's firstSeen/lastSeen span was built +// to describe. `caption_zone_collision`/`frame_out_of_frame` (a different +// script, U3) and the `motion_*`/`sweep_static` codes (evaluated once over +// the whole run, not per grid sample) already carry their own singular +// dedupe/severity semantics; re-interpreting their occurrence count as a +// held-duration signal would misread it. +const PERSISTENCE_TIERED_CODES: ReadonlySet = new Set([ + "text_box_overflow", + "clipped_text", + "canvas_overflow", + "container_overflow", + "content_overlap", + "text_occluded", +]); + +export function collapseStaticLayoutIssues( + issues: LayoutIssue[], + totalSampleCount?: number, +): LayoutIssue[] { const groups = new Map< string, { @@ -190,13 +236,57 @@ export function collapseStaticLayoutIssues(issues: LayoutIssue[]): LayoutIssue[] existing.occurrences += 1; } - return [...groups.values()].map(({ issue, firstSeen, lastSeen, occurrences }) => ({ - ...issue, - time: firstSeen, - firstSeen, - lastSeen, - occurrences, - })); + // A run that only ever sampled one point in time can't distinguish a + // transient from a persistent finding — skip tiering entirely rather than + // guess (see `applyPersistenceTier`). + const sampleCount = totalSampleCount ?? new Set(issues.map((issue) => issue.time)).size; + const multiSampleRun = sampleCount > 1; + + return [...groups.values()].map(({ issue, firstSeen, lastSeen, occurrences }) => + applyPersistenceTier( + { ...issue, time: firstSeen, firstSeen, lastSeen, occurrences }, + multiSampleRun, + ), + ); +} + +/** + * Held-duration severity tiering (#U10). A finding observed at only one + * sample among several (held 0ms) is an entrance/exit transient, not a held + * defect — demote to info so it stays in the data (verbose/--json output) + * without gating the run. `content_overlap` specifically re-promotes from + * warning to error once it's held long enough to be a real, sustained + * collision rather than a crossfade/transition blip (resolves the TODO in + * layout-audit.browser.js's `overlapIssue`). A finding held at every sample + * (a genuinely static defect) is well past both thresholds and is left + * untouched either way — persistence, not the code, decides the tier. + */ +function applyPersistenceTier(issue: LayoutIssue, multiSampleRun: boolean): LayoutIssue { + if (!multiSampleRun) return issue; + if (!PERSISTENCE_TIERED_CODES.has(issue.code)) return issue; + + const occurrences = issue.occurrences ?? 1; + // A single collapsed occurrence is held 0ms by construction (firstSeen === + // lastSeen) — always under the ignore floor, so occurrences <= 1 is a + // complete (not approximate) test for "held under 250ms". + if (occurrences <= 1) { + return { ...issue, severity: "info" }; + } + if (issue.code === "content_overlap" && isContentOverlapHeldLongEnough(issue, occurrences)) { + return { ...issue, severity: "error" }; + } + return issue; +} + +// Split out of applyPersistenceTier so the two independent "held long enough" +// signals (sample count vs. wall-clock span) read as one boolean question +// instead of adding a third compound branch to the tiering ladder above. +function isContentOverlapHeldLongEnough(issue: LayoutIssue, occurrences: number): boolean { + if (occurrences >= HELD_ACROSS_SAMPLES_MIN_OCCURRENCES) return true; + const firstSeen = issue.firstSeen ?? issue.time; + const lastSeen = issue.lastSeen ?? issue.time; + const heldMs = (lastSeen - firstSeen) * 1000; + return heldMs >= CONTENT_OVERLAP_HELD_ERROR_MS; } export function limitLayoutIssues( @@ -230,9 +320,16 @@ function staticIssueKey(issue: LayoutIssue): string { issue.containerSelector ?? "", issue.text ?? "", issue.overflow ? formatOverflow(issue.overflow) : "", + framePositionKey(issue), ].join("|"); } +function framePositionKey(issue: LayoutIssue): string { + return issue.code === "frame_out_of_frame" + ? `${Math.round(issue.rect.left)},${Math.round(issue.rect.top)}` + : ""; +} + function uniqueSortedTimes(times: number[]): number[] { const rounded = times.map(roundTime); return [...new Set(rounded)].sort((a, b) => a - b); diff --git a/packages/cli/src/utils/updateCheck.test.ts b/packages/cli/src/utils/updateCheck.test.ts index 1de5d35bcb..9a9daf28ce 100644 --- a/packages/cli/src/utils/updateCheck.test.ts +++ b/packages/cli/src/utils/updateCheck.test.ts @@ -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"); diff --git a/packages/cli/src/utils/updateCheck.ts b/packages/cli/src/utils/updateCheck.ts index caaab09201..6a58fad521 100644 --- a/packages/cli/src/utils/updateCheck.ts +++ b/packages/cli/src/utils/updateCheck.ts @@ -40,6 +40,8 @@ export interface UpdateMeta { version: string; latestVersion?: string; updateAvailable: boolean; + /** Present (and true) only for commands superseded by `check`; absent otherwise. */ + deprecated?: boolean; } /** @@ -130,9 +132,30 @@ export function getUpdateMeta(): UpdateMeta { /** * Wrap a JSON payload with the _meta version envelope. * Use this in all --json command outputs for consistent agent-friendly metadata. + * + * Pass `{ deprecated: true }` from a command superseded by `check` (validate, + * inspect, layout) to add `_meta.deprecated: true`; every other call site is + * unaffected — the key is only ever added, never set to `false`. + */ +export function withMeta( + data: T, + 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 withMeta(data: T): T & { _meta: UpdateMeta } { - return { ...data, _meta: getUpdateMeta() }; +export function printDeprecationNotice(command: string): void { + process.stderr.write( + `'hyperframes ${command}' is deprecated and will be removed in a future release. Use 'hyperframes check' instead.\n`, + ); } /** diff --git a/packages/producer/src/logger.test.ts b/packages/producer/src/logger.test.ts index f4d1f66c9f..90b6797a2f 100644 --- a/packages/producer/src/logger.test.ts +++ b/packages/producer/src/logger.test.ts @@ -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, + 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, + enabled: ReadonlyArray, + disabled: ReadonlyArray, +): 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): 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, + iterations: number, + buildMeta: () => Record, +): 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; - let warnSpy: ReturnType; - let errorSpy: ReturnType; + let logSpy: ReturnType; + let warnSpy: ReturnType; + let errorSpy: ReturnType; 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); - 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", () => { diff --git a/packages/producer/src/logger.ts b/packages/producer/src/logger.ts index d0fa8b77fd..845d321e58 100644 --- a/packages/producer/src/logger.ts +++ b/packages/producer/src/logger.ts @@ -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) { diff --git a/registry/examples/decision-tree/index.html b/registry/examples/decision-tree/index.html index 4695aacaaf..3f48fe10dd 100644 --- a/registry/examples/decision-tree/index.html +++ b/registry/examples/decision-tree/index.html @@ -31,7 +31,7 @@ id="root" data-composition-id="main" data-start="0" - data-duration="15" + data-duration="10" data-width="1920" data-height="1080" > @@ -40,7 +40,7 @@ data-composition-id="decision-tree" data-composition-src="compositions/decision_tree.html" data-start="0" - data-duration="15" + data-duration="10" data-track-index="0" data-width="1920" data-height="1080" diff --git a/registry/examples/motion-blur/index.html b/registry/examples/motion-blur/index.html index 557ae0cb88..034cc42bf7 100644 --- a/registry/examples/motion-blur/index.html +++ b/registry/examples/motion-blur/index.html @@ -192,16 +192,18 @@ data-start="0" data-duration="4" > -
+
Motion Blur — Velocity Showcase feGaussianBlur + scaleX · blur ∝ velocity
-
3.0seconds
-
shape
-
text
+
+ 3.0seconds +
+
shape
+
text
@@ -220,14 +222,16 @@ > DRIFT
-
slow
+
slow
-
1.5seconds
-
shape
-
text
+
+ 1.5seconds +
+
shape
+
text
@@ -246,14 +250,16 @@ > GLIDE
-
medium
+
medium
-
0.7seconds
-
shape
-
text
+
+ 0.7seconds +
+
shape
+
text
@@ -272,16 +278,16 @@ > RUSH
-
fast
+
fast
-
+
0.35seconds
-
shape
-
text
+
shape
+
text
@@ -300,14 +306,16 @@ > BLAST
-
faster
+
faster
-
0.2seconds
-
shape
-
text
+
+ 0.2seconds +
+
shape
+
text
@@ -326,7 +334,7 @@ > WARP
-
extreme
+
extreme