diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e00243..08b648a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,11 +11,16 @@ permissions: jobs: typecheck-and-unit: runs-on: ubuntu-latest + strategy: + matrix: + # exercise the advertised engines floor (>=20) alongside current LTS, so + # a Node-20-incompatible API can't slip in against the stated support + node-version: [20, 22] steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: 22 + node-version: ${{ matrix.node-version }} cache: npm - run: npm ci - run: npm run typecheck diff --git a/README.md b/README.md index 3a2b8df..9e88fdb 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,8 @@ Procedural palettes (generated at render time, no asset): `aurora`, `midnight`, ## 🎡 Music -Videos are silent by default. `--music` (on `render` and `generate`) muxes a looped, +`render` is silent by default; on `generate` the AI director picks the bundled track +matching your app's look. `--music` (on `render` and `generate`) muxes a looped, loudness-normalized track with fade-in/out under the video β€” never re-encoding the video and never changing its length: @@ -216,14 +217,15 @@ supercut render --take out/take --music path/to/your-track.mp3 # your own fi Bundled tracks (in `assets/music/` β€” original instrumentals made for supercut; provenance in `assets/music/CREDITS.md`): -| track | vibe | -| ---------- | ------------ | -| `pulse` | minimal-tech | -| `daybreak` | warm | -| `midnight` | cinematic | -| `momentum` | energetic | +| track | vibe | +| ---------- | ----------------------- | +| `pulse` | minimal tech-house | +| `daybreak` | bright melodic house | +| `midnight` | dark synthwave/techno | +| `momentum` | driving minimal techno | -`--music off` (or omitting the flag) keeps the video silent. +`--music off` forces a silent cut; on `render`, omitting the flag does too. `--music` +always outranks the director's pick on `generate`. ## πŸ”’ Privacy diff --git a/assets/music/CREDITS.md b/assets/music/CREDITS.md index c363bce..ea44668 100644 --- a/assets/music/CREDITS.md +++ b/assets/music/CREDITS.md @@ -1,18 +1,30 @@ # Bundled music β€” provenance & license -All four tracks are **original instrumental works produced for supercut**. -They were generated with MiniMax Music 2.5 from original style prompts written -for this project, then post-processed with ffmpeg (crossfade extension to -~90–110s beds and loudness normalization to βˆ’16 LUFS). No pre-existing songs, -samples, or melodies were referenced or used as input. +All four tracks are **original instrumental works produced for supercut**, +synthesized from pure oscillators (sub bass, drum machine, filtered noise +hats, saw-wave pads, arpeggios) by `tools/synth-music.py` β€” there is no vocal +source, no sample, and no pre-existing song anywhere in the signal chain, so +the tracks cannot contain vocals. + +**Reproducing the beds end-to-end.** `tools/synth-music.py` regenerates +equivalent beds (not necessarily bit-identical) from scratch. It needs: + + * Python packages `numpy` and `scipy` β€” `pip install numpy scipy` + * `ffmpeg` on your PATH + +Run `python3 tools/synth-music.py assets/music`. For each mood it synthesizes a +short WAV loop, then ffmpeg self-crossfades that loop to ~92s +(`acrossfade=d=1` Γ—3 β†’ `atrim=0:92`), loudness-normalizes it +(`loudnorm=I=-15:TP=-1.5:LRA=9`), and encodes a 192 kbit/s 44.1 kHz stereo MP3 β€” +the exact pipeline that produced the checked-in beds. To the extent the maintainers hold any rights in these recordings, they are dedicated to the public domain under [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/). Use them in your videos β€” commercial or not β€” with no attribution required. -| track | vibe | length | bpm | -| -------------- | ---------------------------- | ------ | ---- | -| `pulse.mp3` | minimal tech, sleek | 100s | ~104 | -| `daybreak.mp3` | warm piano, optimistic | 93s | ~92 | -| `midnight.mp3` | cinematic ambient, premium | 110s | ~80 | -| `momentum.mp3` | driving electronic, punchy | 100s | ~122 | +| track | vibe | length | bpm | +| -------------- | ----------------------------- | ------ | ---- | +| `pulse.mp3` | minimal tech-house, sleek | 95s | ~104 | +| `daybreak.mp3` | bright melodic house, upbeat | 95s | ~110 | +| `midnight.mp3` | dark synthwave/techno, premium| 100s | ~100 | +| `momentum.mp3` | driving minimal techno | 95s | ~122 | diff --git a/assets/music/daybreak.mp3 b/assets/music/daybreak.mp3 index aed80b1..7961ecd 100644 Binary files a/assets/music/daybreak.mp3 and b/assets/music/daybreak.mp3 differ diff --git a/assets/music/midnight.mp3 b/assets/music/midnight.mp3 index 745859b..30b5e90 100644 Binary files a/assets/music/midnight.mp3 and b/assets/music/midnight.mp3 differ diff --git a/assets/music/momentum.mp3 b/assets/music/momentum.mp3 index 12f6f3b..58ef6c1 100644 Binary files a/assets/music/momentum.mp3 and b/assets/music/momentum.mp3 differ diff --git a/assets/music/pulse.mp3 b/assets/music/pulse.mp3 index 9602d44..1f42c3f 100644 Binary files a/assets/music/pulse.mp3 and b/assets/music/pulse.mp3 differ diff --git a/examples/demo.recipe.json b/examples/demo.recipe.json index e278c3c..190d623 100644 --- a/examples/demo.recipe.json +++ b/examples/demo.recipe.json @@ -1,30 +1,29 @@ { "version": 0, "app_url": "http://127.0.0.1:4173", - "music_track": "institutional-01", + "music_track": "daybreak", "scenes": [ { - "name": "landing-cta", + "name": "the-pitch-and-signup", "priority": 1, "entry": { "url": "http://127.0.0.1:4173/", "prelude": [] }, "depends_on": [], "actions": [ - { "kind": "click", "selector": "#cta", "duration_ms": 1800 }, - { "kind": "type", "selector": "#email", "text": "ada@lumon.dev", "duration_ms": 2200 }, - { "kind": "click", "selector": "#join", "duration_ms": 1400 } + { "kind": "click", "selector": "#cta", "duration_ms": 1400 }, + { "kind": "type", "selector": "#email", "text": "ada@lumon.dev", "duration_ms": 1900 }, + { "kind": "click", "selector": "#join", "focus_selector": "#signup", "duration_ms": 1500 } ], - "hold_ms": 800 + "hold_ms": 900 }, { - "name": "dashboard", + "name": "the-live-product", "priority": 2, - "entry": { "url": "http://127.0.0.1:4173/dash", "prelude": [] }, + "entry": { "url": "http://127.0.0.1:4173/dash/", "prelude": [] }, "depends_on": [], "actions": [ - { "kind": "hover", "selector": "#task-ship", "duration_ms": 1600 }, - { "kind": "wait", "duration_ms": 1200 } + { "kind": "hover", "selector": "#task-ship", "focus_selector": "#tasks", "duration_ms": 1600 } ], - "hold_ms": 600 + "hold_ms": 1000 } ] } diff --git a/examples/pandora-demo.recipe.json b/examples/pandora-demo.recipe.json index afb2c75..3f4f408 100644 --- a/examples/pandora-demo.recipe.json +++ b/examples/pandora-demo.recipe.json @@ -1,7 +1,7 @@ { "version": 0, "app_url": "http://127.0.0.1:8455", - "music_track": "institutional-01", + "music_track": "pulse", "scenes": [ { "name": "trace-one-company", diff --git a/examples/pulse-demo.recipe.json b/examples/pulse-demo.recipe.json new file mode 100644 index 0000000..1e2f4d0 --- /dev/null +++ b/examples/pulse-demo.recipe.json @@ -0,0 +1,20 @@ +{ + "version": 0, + "app_url": "http://127.0.0.1:4100", + "music_track": "midnight", + "scenes": [ + { + "name": "triage-the-fleet", + "priority": 1, + "entry": { "url": "http://127.0.0.1:4100/", "prelude": [] }, + "depends_on": [], + "actions": [ + { "kind": "click", "selector": "[data-testid=\"service-search\"]", "duration_ms": 900 }, + { "kind": "type", "selector": "[data-testid=\"service-search\"]", "text": "payments", "submit": true, "focus_selector": "[data-testid=\"metrics-panel\"]", "duration_ms": 1700 }, + { "kind": "click", "selector": ":nth-match([data-testid=\"service-item\"], 1)", "focus_selector": "[data-testid=\"kpi-row\"]", "duration_ms": 1500 }, + { "kind": "click", "selector": ":nth-match([data-testid=\"service-item\"], 3)", "focus_selector": "[data-testid=\"metrics-panel\"]", "duration_ms": 1500 } + ], + "hold_ms": 900 + } + ] +} diff --git a/src/director/analyze.ts b/src/director/analyze.ts index 7df29c3..97d09d8 100644 --- a/src/director/analyze.ts +++ b/src/director/analyze.ts @@ -10,10 +10,17 @@ import { extractJson, type ChatPart, type LlmClient } from "./llm.js"; import type { PageDigest } from "./inventory.js"; import { redactForPrompt } from "../security/redaction.js"; +/** the bundled soundtrack library (assets/music/) β€” the director picks one as + * part of understanding the product, so every generate run ships with music */ +export const MUSIC_TRACKS = ["pulse", "daybreak", "midnight", "momentum"] as const; + export const appAnalysis = z.object({ product_summary: z.string().min(10).max(600), - /** the brand/product name for the title + close cards (e.g. "Meridian") */ + /** the brand/product name for the title + close cards (e.g. "Acme") */ product_name: z.string().min(2).max(40), + /** bundled track matching the app's look/energy β€” enum here so a made-up + * track name bounces back at validation, never reaching the render */ + music_track: z.enum(MUSIC_TRACKS), /** the launch HOOK β€” the problem/promise the video opens on, in the * customer's words, not a feature ("Three of your sites bleed cash. Which?"). * This is what removes ambiguity about what the video is selling. */ @@ -40,27 +47,73 @@ export const appAnalysis = z.object({ export type AppAnalysis = z.infer; +/** + * Heal a selector the model copied with trailing junk. Inventory lines read + * `` `` [tag] "text" ``; a model that grabs past the closing backtick + * appends the ` [tag]…` annotation (e.g. `:nth-match(…, 1) [button]`). Selectors + * themselves contain `]`/`)`/quotes, so we can't regex-strip safely β€” instead we + * accept the LONGEST real inventory selector that `raw` starts with AND whose + * remainder is only the display annotation. A bare `startsWith` heals too much: + * `#cta-danger` starts with `#cta`, so prefix-healing would silently rewrite a + * hallucinated sibling into a real selector and bypass the whitelist gate. We + * only heal when what follows the matched selector is whitespace-then-`[tag]` + * (the annotation shape) or nothing β€” never a selector-continuation character. + */ +const ANNOTATION_TAIL_RE = /^\s+\[[a-z0-9-]+\]/i; + +export function coerceSelector(raw: string, valid: Set): string { + const s = raw.trim(); + if (valid.has(s)) return s; + let best = ""; + for (const v of valid) { + if (!s.startsWith(v) || v.length <= best.length) continue; + const rest = s.slice(v.length); + // `#cta-danger` / `#cta2` have a real-selector remainder β†’ leave untouched so + // the gate rejects them; only annotation junk or pure whitespace heals + if (rest.trim() === "" || ANNOTATION_TAIL_RE.test(rest)) best = v; + } + return best || s; +} + export function validateAnalysis(raw: unknown, digests: PageDigest[]): AppAnalysis { const parsed = appAnalysis.parse(raw); const byPage = new Map(digests.map((d) => [d.url, new Set(d.inventory.map((i) => i.selector))])); // Models often answer with a relative path ("/setup") instead of the full // crawled URL. Coerce by pathname match so a correct beat isn't rejected on a - // formatting nit β€” downstream (script.ts) needs the full crawled URL. - const byPathname = new Map(); + // formatting nit β€” downstream (script.ts) needs the full crawled URL. The + // crawler keys pages on pathname+search, so ONE pathname can map to several + // distinct crawled URLs (/results?view=chart vs ?view=table). Track all + // candidates per pathname: a lone candidate coerces; MULTIPLE means a bare + // path is ambiguous and must NOT be silently rewritten onto the wrong page. + const byPathname = new Map(); for (const d of digests) { - try { byPathname.set(new URL(d.url).pathname.replace(/\/$/, "") || "/", d.url); } catch { /* skip */ } + try { + const key = new URL(d.url).pathname.replace(/\/$/, "") || "/"; + const list = byPathname.get(key) ?? []; + if (!list.includes(d.url)) list.push(d.url); + byPathname.set(key, list); + } catch { /* skip */ } } for (const moment of parsed.money_moments) { if (!byPage.has(moment.page_url)) { let key = moment.page_url; try { key = new URL(moment.page_url, digests[0]?.url ?? "http://localhost").pathname; } catch { /* keep */ } - const full = byPathname.get((key.replace(/\/$/, "") || "/")); - if (full) moment.page_url = full; + const candidates = byPathname.get((key.replace(/\/$/, "") || "/")); + if (candidates && candidates.length > 1) { + throw new Error( + `money moment "${moment.title}" page_url "${moment.page_url}" is ambiguous β€” ` + + `${candidates.length} crawled pages share that pathname (${candidates.join(", ")}); ` + + `use the full URL INCLUDING its query string to pick one`, + ); + } + if (candidates && candidates.length === 1) moment.page_url = candidates[0]!; } const selectors = byPage.get(moment.page_url); if (!selectors) { throw new Error(`money moment "${moment.title}" page_url "${moment.page_url}" is not a crawled page`); } + // heal an appended ` [tag]` annotation in-place before the whitelist check + moment.elements = moment.elements.map((sel) => coerceSelector(sel, selectors)); for (const selector of moment.elements) { if (!selectors.has(selector)) { throw new Error(`money moment "${moment.title}" selector "${selector}" is not in the inventory for ${moment.page_url}`); @@ -72,12 +125,23 @@ export function validateAnalysis(raw: unknown, digests: PageDigest[]): AppAnalys function digestText(d: PageDigest): string { const inv = d.inventory - .map((i) => ` ${i.selector} [${i.tag}] "${redactForPrompt(i.text)}"${i.href ? ` β†’ ${redactForPrompt(i.href)}` : ""}${i.hidden ? " (HIDDEN until revealed)" : ""}`) + .map((i) => ` \`${i.selector}\` [${i.tag}] "${redactForPrompt(i.text)}"${i.href ? ` β†’ ${redactForPrompt(i.href)}` : ""}${i.hidden ? " (HIDDEN until revealed)" : ""}`) .join("\n"); - return `PAGE ${d.url}\ntitle: ${d.title}\nheadings: ${d.headings.join(" | ")}\nelements:\n${inv}`; + // look signal: lets a text-only model ground vibe/music choices in the + // page's actual appearance, not just its copy + const look = d.theme ? `\ntheme: ${d.theme}${d.accentColor ? ` (accent ${d.accentColor})` : ""}` : ""; + // title/headings are egress and display-only, so redact them β€” a secret in a + // page title reaches the provider otherwise. The URL is NOT redacted here: it + // is a validation KEY (scene.entry.url must round-trip exactly against the raw + // crawled URL), so a redacted URL would break the recipe gate. Pages whose URL + // itself carries a secret are dropped upstream in crawlApp, so no token-URL + // reaches this prompt to leak. + const title = redactForPrompt(d.title); + const headings = d.headings.map(redactForPrompt).join(" | "); + return `PAGE ${d.url}\ntitle: ${title}${look}\nheadings: ${headings}\nelements:\n${inv}`; } -const SYSTEM = `You are the director AND copywriter of a 60-second product launch video (Screen-Studio / ChatGPT-launch style), not a website tour. You study a web product and turn it into a PERSUASIVE STORY with a crystal-clear message: a viewer must understand within seconds what problem it solves and why it's good. Ambiguity is failure. +const SYSTEM = `You are the director AND copywriter of a 60-second product launch video β€” a polished launch film, not a website tour. You study a web product and turn it into a PERSUASIVE STORY with a crystal-clear message: a viewer must understand within seconds what problem it solves and why it's good. Ambiguity is failure. Write the story as a problem β†’ solution β†’ payoff arc: - headline: the HOOK. Open on the customer's PAIN or the promise, in their words β€” not a feature. ("You run 12 sites. Three bleed cash β€” which?") This single line must make the whole video unambiguous. @@ -85,36 +149,46 @@ Write the story as a problem β†’ solution β†’ payoff arc: 1. hook beat: the first move that starts solving the problem 2. proof beat: the core workflow / differentiator 3. payoff beat: the most visual result β€” the moment the value lands +- Prefer beats where the UI VISIBLY RESPONDS β€” a panel switches, results appear, a form confirms. A beat that only points at static content films as dead air. - For EACH beat write a "caption": ONE short benefit line (≀52 chars) in outcome voice β€” what the viewer GAINS, never a feature label. "Record a location" is a label (BAD). "Drop in every site in seconds" is a caption (GOOD). "See ranked revenue" is a label (BAD). "Your weakest sites, surfaced instantly" is a caption (GOOD). - product_name: the brand name for the title/close cards. tagline: the closing line under it. +- music_track: the bundled soundtrack matching the app's LOOK and energy. "pulse" = sleek, minimal tools and dev products; "daybreak" = bright, friendly consumer/marketing SaaS; "midnight" = dark-themed, premium data/infra products; "momentum" = fast, energetic, action-heavy products. Ground the choice in each page's "theme:" line (dark/light + accent), the copy, and the screenshots when provided β€” a dark dashboard with a bright cheerful track (or the reverse) feels wrong. + +Prefer beats with visible payoff (something appears, changes, completes). The "title" field stays a short internal label; the "caption" is the on-screen copy and must be benefit-framed. -Prefer beats with visible payoff (something appears, changes, completes). The "title" field stays a short internal label; the "caption" is the on-screen copy and must be benefit-framed. Respond ONLY with a JSON object matching: -{ "product_summary": string, "product_name": string, "headline": string, "tagline": string, "money_moments": [{ "title": string, "caption": string, "why": string, "page_url": string (one crawled URL), "elements": [selector strings COPIED EXACTLY from the inventory] }] }`; +Each inventory line is: \`\` [tag] "text". In "elements", copy ONLY the exact text INSIDE the backticks β€” never the [tag] or the "text" that follows it. Respond ONLY with a JSON object matching: +{ "product_summary": string, "product_name": string, "headline": string, "tagline": string, "music_track": "pulse"|"daybreak"|"midnight"|"momentum", "money_moments": [{ "title": string, "caption": string, "why": string, "page_url": string (one crawled URL), "elements": [selectors copied from between the backticks] }] }`; export async function analyzeApp( llm: LlmClient, digests: PageDigest[], repoNotes?: string, ): Promise { - const parts: ChatPart[] = []; - parts.push({ + const textPart: ChatPart = { type: "text", text: (repoNotes ? `REPO NOTES:\n${repoNotes.slice(0, 4000)}\n\n` : "") + digests.map(digestText).join("\n\n"), - }); + }; + const imageParts: ChatPart[] = []; for (const d of digests) { if (d.screenshotB64) { - parts.push({ type: "text", text: `screenshot of ${d.url}:` }); - parts.push({ type: "image", dataUrl: `data:image/jpeg;base64,${d.screenshotB64}` }); + imageParts.push({ type: "text", text: `screenshot of ${d.url}:` }); + imageParts.push({ type: "image", dataUrl: `data:image/jpeg;base64,${d.screenshotB64}` }); } } let feedback = ""; for (let attempt = 0; attempt < 3; attempt++) { + // Screenshots are sent ONCE, on attempt 0. A schema-retry resends the text + // digest + the corrective feedback but NOT the images β€” each stateless call + // that re-uploads every JPEG would multiply the vision-token bill for a + // formatting fix the text feedback already pinpoints. Tradeoff: the retry + // reasons from the DOM digest, not the pixels; acceptable because the digest + // carries the selectors/labels a correction needs. const user: ChatPart[] = feedback - ? [...parts, { type: "text", text: `Your previous response was invalid: ${feedback}. Return corrected JSON only.` }] - : parts; + ? [textPart, { type: "text", text: `Your previous response was invalid: ${feedback}. Return corrected JSON only.` }] + : [textPart, ...imageParts]; // generous budget: a richer source-seeded crawl (many pages) means a bigger // prompt AND a bigger response; 4k truncated mid-JSON on real apps const raw = await llm.chat({ system: SYSTEM, user, json: true, maxTokens: 8000 }); diff --git a/src/director/generate.ts b/src/director/generate.ts index ef81f9e..284fae3 100644 --- a/src/director/generate.ts +++ b/src/director/generate.ts @@ -122,6 +122,56 @@ async function preflight(url: string, allowPrivateNetwork: boolean): Promise the director's + * recipe pick > silent. An unresolvable director track degrades to a warning + * and a silent cut β€” a music nit must NEVER fail a run after LLM/capture spend. + */ +export function pickMusic( + cliMusic: string | undefined, + recipeTrack: string, + resolve: (spec: string | undefined) => string | null = resolveMusicTrack, +): MusicChoice { + if (cliMusic !== undefined) { + // a bad --music is normally caught at preflight, but this exported function + // must never throw post-spend β€” mirror the director branch and degrade to a + // warned silent cut if the resolver throws. + try { + return resolve(cliMusic) + ? { spec: cliMusic, source: "cli", label: `${cliMusic} (cli)` } + : { spec: undefined, source: "none", label: "none" }; // --music off + } catch { + return { + spec: undefined, + source: "none", + label: "none", + warning: `--music "${cliMusic}" is not a bundled track or audio file β€” rendering silent`, + }; + } + } + try { + return resolve(recipeTrack) + ? { spec: recipeTrack, source: "director", label: `${recipeTrack} (director)` } + : { spec: undefined, source: "none", label: "none" }; // director chose "off" + } catch { + return { + spec: undefined, + source: "none", + label: "none", + warning: `recipe music_track "${recipeTrack}" is not a bundled track or audio file β€” rendering silent`, + }; + } +} + function repoNotes(repoPath: string): string | undefined { for (const f of ["README.md", "readme.md", "package.json"]) { const p = join(repoPath, f); @@ -268,6 +318,8 @@ export async function generate(opts: GenerateOptions): Promise { log("β‘€ render…"); const outFile = join(opts.outDir, "final.mp4"); + const music = pickMusic(opts.music, recipe.music_track); + if (music.warning) log(` warning: ${music.warning}`); // NO on-screen text. supercut is a pure product demo β€” the product is the // whole story. The cinematic camera (zoom-to-action, frame-the-result) carries // it; nothing is ever drawn over the app. (The director still writes copy in @@ -276,9 +328,9 @@ export async function generate(opts: GenerateOptions): Promise { takeDir, outFile, ...(opts.background ? { background: opts.background } : {}), - ...(opts.music ? { music: opts.music } : {}), + ...(music.spec ? { music: music.spec } : {}), }); - log(`done: ${outFile} (${renderRes.frames} frames, ${(renderRes.encodedBytes / 1048576).toFixed(1)}MB)`); + log(`done: ${outFile} (${renderRes.frames} frames, ${(renderRes.encodedBytes / 1048576).toFixed(1)}MB, music ${music.label})`); writeFileSync( join(opts.outDir, "director-report.json"), diff --git a/src/director/inventory.ts b/src/director/inventory.ts index e02d826..f060abd 100644 --- a/src/director/inventory.ts +++ b/src/director/inventory.ts @@ -6,6 +6,17 @@ */ import { chromium, type Browser, type Page } from "playwright"; import { assertSafeNavigationUrl, navigationRequestAllowed, resolveAndPinHost } from "../security/url-policy.js"; +import { redactForPrompt } from "../security/redaction.js"; + +/** + * True when a page URL carries a secret (token/key/JWT) in its path or query. + * A crawled URL is a validation KEY the director must echo back verbatim, so it + * can't be redacted in the prompt β€” instead we drop the whole page (never film a + * page whose URL is itself a credential), so the secret never egresses. + */ +export function pageUrlHasSecret(url: string): boolean { + return redactForPrompt(url) !== url; +} export interface InventoryItem { /** Playwright-compatible selector, verified to resolve on the page */ @@ -35,6 +46,13 @@ export interface PageDigest { url: string; title: string; headings: string[]; + /** effective page background tone β€” grounds the director's vibe/music + * choices even when the model is text-only (no screenshots). Optional so + * hand-built digests stay valid; the crawler always sets it. */ + theme?: "dark" | "light"; + /** accent hint: the first visible button's background color, when one has a + * real (non-transparent) background. Advisory only. */ + accentColor?: string; inventory: InventoryItem[]; /** framable result/content regions (focus_selector candidates) */ regions: RegionItem[]; @@ -47,6 +65,10 @@ export interface PageDigest { const cssEscape = (s: string) => s.replace(/["\\]/g, "\\$&"); +/** ceiling on distinct :nth-match entries per duplicated base selector β€” six + * rows are plenty to tell a switch-between-items story */ +const MAX_SIBLINGS_PER_BASE = 6; + /** * Fail-safe-by-default destructive-action lexicon. The director scripts clicks * and typing on the LIVE app, so a prompt-injected page (or just an unlucky @@ -64,13 +86,29 @@ const cssEscape = (s: string) => s.replace(/["\\]/g, "\\$&"); * film against a disposable/staging environment, not production data. */ // Lexicon criterion: match a verb when firing it by accident on a live app is -// costly (loses data/state/access, moves money, goes public) even if some -// apps use it reversibly β€” false-drop is loud (logged with an opt-in flag), -// false-fire is a real mutation. We still do NOT match the hero-action words -// that carry most demos (send, save, submit, search, publish-adjacent create): -// silently dropping a chat app's "Send" would gut the video. +// costly AND hard to undo (loses data/state/access, moves money) β€” false-drop is +// loud (logged with an opt-in flag), false-fire is a real, often irreversible +// mutation. We do NOT match the hero-action words that carry most demos (send, +// save, submit, search): dropping a chat app's "Send" would gut the video. +// "publish" is deliberately OUT: it is the payoff beat for CMS/blog/deploy apps +// and is reversible (unpublish exists), so a `` must stay +// filmable by default. export const DESTRUCTIVE_RE = - /\b(delete|remove|reset|deactivate|disable|archive|erase|wipe|destroy|unsubscribe|close\s+account|cancel\s+(subscription|account|plan)|pay|purchase|buy\s+now|checkout|place\s+order|withdraw|confirm\s+(payment|order)|revoke|publish|transfer\s+(funds|money|ownership|account|domain)|regenerate|suspend|terminate|downgrade)\b/i; + /\b(delete|remove|reset|deactivate|disable|archive|erase|wipe|destroy|unsubscribe|close\s+account|cancel\s+(subscription|account|plan)|pay|purchase|buy\s+now|checkout|place\s+order|withdraw|confirm\s+(payment|order)|revoke|transfer\s+(funds|money|ownership|account|domain)|regenerate|suspend|terminate|downgrade)\b/i; + +/** + * PLAIN lexicon match: a label is destructive if it CONTAINS any DESTRUCTIVE_RE + * verb β€” no border exemption. `_` is a regex word char, so `\b` never fires at + * an underscore seam ("reset_config"); we normalize `_`β†’space first so + * slug-joined verbs still match. This is deliberately over-inclusive: whether a + * slug-shaped CONTENT name ("checkout-api") is kept or excluded is decided at + * the filter site (a passive content row survives; an action control never + * does), NOT here β€” a lexicon test alone can't tell a Delete button from a + * service row. + */ +export function isDestructiveLabel(s: string): boolean { + return DESTRUCTIVE_RE.test(s.replace(/_/g, " ")); +} // links the crawler must NOT navigate to: file downloads (PDF/zip/images/docs), // and non-http protocols. Navigating to a PDF triggers a download that crashes @@ -127,8 +165,94 @@ async function collectRegions(page: Page): Promise { return out.sort((a, b) => b.bbox.w * b.bbox.h - a.bbox.w * a.bbox.h).slice(0, 6); } +/** background luminance below this reads as a dark UI. Real dark themes sit + * near 0; ambiguous mid-grays fall through to the safer "light" default. */ +const DARK_LUMINANCE_MAX = 0.35; + +/** a background must cover at least this fraction of the viewport to count as a + * dominant surface β€” below it we're looking at a card/hero, not the ground */ +const SURFACE_COVER_MIN = 0.6; +/** cap the element scan so the probe stays cheap on huge DOMs */ +const SURFACE_SCAN_LIMIT = 400; + +/** + * Cheap look probe: the DOMINANT visible background β†’ relative luminance β†’ + * dark/light, plus the first visible button's background as an accent hint. + * Many React/Next apps leave body/html transparent (or white) and paint the + * real surface on #root/main/a full-bleed wrapper, so a bodyβ†’html-only walk + * misreads them "light". We instead take the background of the LARGEST + * viewport-covering element, with body/html as the fallback floor. Advisory + * only β€” any failure defaults to "light" rather than blocking the crawl. + */ +async function probeTheme(page: Page): Promise<{ theme: "dark" | "light"; accentColor?: string }> { + try { + const probe = await page.evaluate(({ darkMax, coverMin, scanLimit }) => { + const parse = (c: string): [number, number, number, number] | null => { + const m = c.match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:[,\s/]+([\d.]+))?\s*\)/); + return m ? [Number(m[1]), Number(m[2]), Number(m[3]), m[4] === undefined ? 1 : Number(m[4])] : null; + }; + const vw = window.innerWidth, vh = window.innerHeight; + const vArea = Math.max(1, vw * vh); + const coverage = (el: Element): number => { + const r = el.getBoundingClientRect(); + const w = Math.max(0, Math.min(r.right, vw) - Math.max(r.left, 0)); + const h = Math.max(0, Math.min(r.bottom, vh) - Math.max(r.top, 0)); + return (w * h) / vArea; + }; + // dominant ground: largest-covered element with a non-transparent bg. + // body/html carry a small bias DOWN so a full-bleed painted wrapper wins + // ties over a transparent/white body (the misread this fix targets). + let bestRgb: [number, number, number] | null = null; + let bestScore = -Infinity; + const consider = (el: Element | null, fallbackBias: number): void => { + if (!el) return; + const c = parse(getComputedStyle(el).backgroundColor); + // Skip anything not fully opaque: a full-viewport modal backdrop + // (rgba(0,0,0,.5)) can out-cover the page and falsely report "dark" on a + // light app. A translucent layer is not the page ground β€” fall through to + // the largest OPAQUE covering element (body/html floor). + if (!c || c[3] < 1) return; + const score = coverage(el) - fallbackBias; + if (score > bestScore) { bestScore = score; bestRgb = [c[0], c[1], c[2]]; } + }; + consider(document.body, 0.002); + consider(document.documentElement, 0.002); + for (const el of Array.from(document.querySelectorAll("*")).slice(0, scanLimit)) { + if (coverage(el) >= coverMin) consider(el, 0); + } + // WCAG relative luminance β€” perceptual, so #16161a and #0b0e14 both read dark + const luminance = ([r, g, b]: [number, number, number]): number => { + const lin = (n: number): number => { + const s = n / 255; + return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); + }; + return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); + }; + const rgb = bestRgb; + const theme = rgb && luminance(rgb) < darkMax ? "dark" : "light"; + let accent: string | null = null; + for (const el of Array.from(document.querySelectorAll("button, [role=button], input[type=submit]"))) { + const box = el.getBoundingClientRect(); + if (box.width < 8 || box.height < 8) continue; + const c = parse(getComputedStyle(el).backgroundColor); + if (!c || c[3] === 0) continue; + accent = `rgb(${c[0]}, ${c[1]}, ${c[2]})`; + break; + } + return { theme, accent }; + }, { darkMax: DARK_LUMINANCE_MAX, coverMin: SURFACE_COVER_MIN, scanLimit: SURFACE_SCAN_LIMIT }); + return { + theme: probe.theme === "dark" ? "dark" : "light", + ...(probe.accent ? { accentColor: probe.accent } : {}), + }; + } catch { + return { theme: "light" }; + } +} + async function digestPage(page: Page, withScreenshot: boolean, allowDestructive = false): Promise { const title = await page.title(); + const { theme, accentColor } = await probeTheme(page); const headings: string[] = []; const hs = page.locator("h1, h2, h3"); @@ -141,6 +265,8 @@ async function digestPage(page: Page, withScreenshot: boolean, allowDestructive const inventory: InventoryItem[] = []; const excludedDestructive: string[] = []; const seen = new Set(); + // distinct :nth-match entries already inventoried per duplicated base selector + const siblingCount = new Map(); const els = page.locator( "a[href], button, input, textarea, select, [role=button], [role=tab], " + // clickable-without-semantics patterns real apps are full of: @@ -158,6 +284,7 @@ async function digestPage(page: Page, withScreenshot: boolean, allowDestructive const tag = (await el.evaluate((n) => n.tagName).catch(() => "")).toLowerCase(); if (!tag) continue; const id = await el.getAttribute("id").catch(() => null); + const testid = await el.getAttribute("data-testid").catch(() => null); const aria = await el.getAttribute("aria-label").catch(() => null); const placeholder = await el.getAttribute("placeholder").catch(() => null); const value = await el.getAttribute("value").catch(() => null); @@ -168,27 +295,45 @@ async function digestPage(page: Page, withScreenshot: boolean, allowDestructive placeholder || aria || "" ).trim().replace(/\s+/g, " ").slice(0, 80); - // fail-safe: never put a destructive/irreversible control into the inventory + // Fail-safe: never put a destructive/irreversible control into the inventory // (so the director can't script a click/type on it) unless explicitly opted - // in. Checks visible text, aria-label, and value (input buttons). - if (!allowDestructive && [text, aria, value].some((s) => s && DESTRUCTIVE_RE.test(s))) { + // in. Checks visible text, aria-label, and value (input buttons) on EVERY + // crawled candidate. There is NO reliable way to prove an element has no + // click handler from page context β€” addEventListener bindings are invisible + // to the DOM and getEventListeners is devtools-only β€” so any destructive- + // lexicon hit is excluded outright. Losing a passive row that merely SHARES a + // name with a verb ("checkout-api") is a small price for never scripting a + // real Delete/Pay; --allow-destructive re-includes them. + const labels = [text, aria, value].filter((s): s is string => Boolean(s)); + if (!allowDestructive && labels.some((s) => isDestructiveLabel(s))) { if (text) excludedDestructive.push(text); continue; } + // data-testid outranks aria/placeholder/text: it survives live-updating + // copy (ticking metrics invalidate a :has-text selector between digest and + // verification) and gives same-testid siblings a shared base that the + // :nth-match pass below splits into distinct per-row entries. let selector: string; if (id) selector = `#${id}`; + else if (testid) selector = `[data-testid="${cssEscape(testid)}"]`; else if (aria) selector = `[aria-label="${cssEscape(aria)}"]`; else if (placeholder) selector = `[placeholder="${cssEscape(placeholder)}"]`; else if (text) selector = `${tag}:has-text("${cssEscape(text.slice(0, 40))}")`; else continue; // nothing stable to target β€” skip rather than guess // verify the selector actually resolves to THIS kind of element, and - // disambiguate duplicates with :nth-match + // disambiguate duplicates with :nth-match β€” every same-base sibling gets + // its OWN entry (bbox + text), because a dashboard story needs "click row + // 2, then row 4"; a base whose rows all collapsed to one selector starves + // the script of anything to switch between + const base = selector; const matches = await page.locator(selector).count().catch(() => 0); if (matches === 0) continue; if (matches > 1) { if (!box) continue; // can't disambiguate a hidden duplicate β€” skip, don't guess + // cap per base so one long table can't crowd out the rest of the page + if ((siblingCount.get(base) ?? 0) >= MAX_SIBLINGS_PER_BASE) continue; // Pick the closest nth-match; a strict Β±2px test can miss // on sub-pixel rendering and silently fall back to nth=1 = wrong element). // Cap the accepted distance so we never inventory a wildly-off element. @@ -207,6 +352,7 @@ async function digestPage(page: Page, withScreenshot: boolean, allowDestructive if (seen.has(selector)) continue; seen.add(selector); + if (matches > 1) siblingCount.set(base, (siblingCount.get(base) ?? 0) + 1); inventory.push({ selector, tag, text, bbox: box @@ -226,7 +372,8 @@ async function digestPage(page: Page, withScreenshot: boolean, allowDestructive } return { - url: page.url(), title, headings, inventory, regions, + url: page.url(), title, headings, theme, inventory, regions, + ...(accentColor ? { accentColor } : {}), ...(excludedDestructive.length ? { excludedDestructive } : {}), ...(screenshotB64 ? { screenshotB64 } : {}), }; @@ -324,6 +471,22 @@ export async function crawlApp( continue; } const digest = await digestPage(page, screenshots, allowDestructive); + // never film a page whose settled URL is itself a credential β€” the URL is + // an un-redactable validation key, so drop the page rather than leak it + if (pageUrlHasSecret(digest.url)) { + if (digests.length === 0 && queue.length === 0) { + throw new Error( + "the target URL contains a secret in its path or query (a token/key/JWT); " + + "supercut won't film a page whose URL is itself a credential β€” point --url at a " + + "token-free URL (film against a local/staging environment)", + ); + } + console.error( + ` skipped ${new URL(digest.url).pathname} β€” its URL contains a secret ` + + `(won't film a page whose URL is a credential)`, + ); + continue; + } digests.push(digest); for (const item of digest.inventory) { diff --git a/src/director/script.ts b/src/director/script.ts index 131aa44..cbcd45a 100644 --- a/src/director/script.ts +++ b/src/director/script.ts @@ -10,16 +10,22 @@ */ import { parseRecipe, type Recipe } from "../schema/index.js"; import { extractJson, type ChatPart, type LlmClient } from "./llm.js"; -import type { AppAnalysis } from "./analyze.js"; +import { MUSIC_TRACKS, coerceSelector, type AppAnalysis } from "./analyze.js"; import type { PageDigest } from "./inventory.js"; import { redactForPrompt } from "../security/redaction.js"; +// the enum gate lives HERE, not in the recipe schema: hand-written recipes +// (record path) legitimately carry free-form strings, but the DIRECTOR must +// name a real bundled track (or "off") so generate never ships a track that +// doesn't exist +const DIRECTOR_TRACKS = new Set([...MUSIC_TRACKS, "off"]); + const SYSTEM = `You write filming scripts ("recipes") for supercut, which records a REAL web app with a browser robot and renders a cinematic 60-second launch video. Respond ONLY with a JSON recipe: { "version": 0, "app_url": string, - "music_track": "institutional-01", + "music_track": one of "pulse" | "daybreak" | "midnight" | "momentum" | "off", "scenes": [{ "name": kebab-case string, "priority": 1..N (1 = most important, cut last), @@ -31,17 +37,18 @@ const SYSTEM = `You write filming scripts ("recipes") for supercut, which record } HARD RULES: -- selectors: COPY EXACTLY from the provided element inventory. Never invent or modify one. +- selectors: each inventory line is \`\` [tag] "text". COPY ONLY the exact text INSIDE the backticks β€” never the [tag] or the "text". Never invent or modify a selector. - entry.url: only crawled page URLs. - Create EXACTLY one scene per STORYBOARD beat, in the same order. Do not add a generic site-tour scene. - Each scene's entry.url must equal that beat's page_url and must include at least one of that beat's money selectors. - Do not use mid-scene "goto" actions; each scene starts from its entry.url so selector validation and capture stay coherent. - SHOW THE PAYOFF. A product video that types into a box but never reveals the result is worthless. When a "type" goes into a search/query/command field that runs on Enter, set "submit": true so the app actually produces its output (results, a graph, a detail view). - FRAME THE RESULT. When an action produces a visible result, set "focus_selector" to the FRAMABLE REGION where that result appears (from the page's regions list). The camera then holds on the payoff (the graph/results), not the input box. Use a region selector ONLY in focus_selector, never as an action "selector". -- 2-4 scenes, 2-4 actions each, action duration_ms 1200-4000, hold_ms 600-3000. Give the FINAL payoff scene a long hold (2000-3000) so the result breathes; earlier scenes stay 600-1400. +- EVERY ACTION MUST VISIBLY CHANGE THE SCREEN. An action with no visible reaction films as lag. Prefer clicks that switch views, open panels, or select different items (selecting item A, then item B, re-renders the detail β€” that IS the story), and types that submit and produce results. Use "hover" ONLY on elements that visibly react to it (menus, rows with hover states), never as a scene's main beat. No "wait" actions unless the app genuinely needs load time. +- PACING: 2-4 scenes, 2-4 actions each. action duration_ms 900-2500 ("type" may go to 3000 for realistic typing speed). hold_ms 600-1400 for earlier scenes; ONLY the final payoff scene holds longer (1500-2500) so the result breathes. Beats must chain β€” consecutive actions in a scene flow into each other with no dead multi-second pauses. - total of all durations + holds ≀ 50000 (one minute video with headroom). - "type" actions need realistic short text (an email, a search term β€” match the field). For a search/query field, PREFER a value the app itself suggests β€” a placeholder example, an example hint near the field, or a visible chip/tag label β€” so the query is one the product recognizes and actually returns a result for. Do not invent an exotic value the demo may not have data for. -- Order scenes as a Screen-Studio story: hook β†’ proof/depth β†’ payoff. End on the most visual screen. +- Order scenes as a launch story: hook β†’ proof/depth β†’ payoff. End on the most visual screen, and make the LAST action of the final scene the one that leaves the most impressive state on screen. - depends_on only when a later scene NEEDS an earlier scene's state. - (HIDDEN until revealed) elements: only use them AFTER an earlier action in the SAME scene reveals them (e.g. click the button that opens the form, then type into its field).`; @@ -83,12 +90,15 @@ export async function writeRecipe( const inventoryText = digests .map((d) => { const els = d.inventory - .map((i) => ` ${i.selector} [${i.tag}] "${redactForPrompt(i.text)}"${i.hidden ? " (HIDDEN until revealed)" : ""}`) + .map((i) => ` \`${i.selector}\` [${i.tag}] "${redactForPrompt(i.text)}"${i.hidden ? " (HIDDEN until revealed)" : ""}`) .join("\n"); const regions = (d.regions ?? []).length ? `\n FRAMABLE REGIONS (focus_selector only β€” hold the camera here to show a result):\n` + - d.regions.map((r) => ` ${r.selector} [${r.tag}] "${redactForPrompt(r.text)}"`).join("\n") + d.regions.map((r) => ` \`${r.selector}\` [${r.tag}] "${redactForPrompt(r.text)}"`).join("\n") : ""; + // URLs are validation KEYS (entry.url must round-trip against the raw + // crawled URL), so they stay unredacted; pages whose URL carries a secret + // are dropped in crawlApp, so none reaches this prompt. return `PAGE ${d.url}\n${els}${regions}`; }) .join("\n\n"); @@ -105,6 +115,7 @@ export async function writeRecipe( analysis.money_moments .map((m, i) => `${i + 1}. ${i === 0 ? "HOOK" : i === analysis.money_moments.length - 1 ? "PAYOFF" : "PROOF"} β€” ${m.title} @ ${m.page_url}; scene must use one of: ${m.elements.join(", ")}`) .join("\n") + + `\n\nMUSIC: set "music_track" to "${analysis.music_track}" (picked to match the app's look) unless you have a strong reason to choose another bundled track.` + `\n\nELEMENT INVENTORY (the ONLY selectors you may use):\n${inventoryText}`, }, ]; @@ -118,6 +129,12 @@ export async function writeRecipe( try { const recipe = parseRecipe(extractJson(raw)); + if (!DIRECTOR_TRACKS.has(recipe.music_track)) { + throw new Error( + `music_track "${recipe.music_track}" is not a bundled track β€” use one of ` + + `${MUSIC_TRACKS.map((t) => `"${t}"`).join(", ")}, or "off"`, + ); + } if (recipe.scenes.length !== storyboard.length) { throw new Error( `recipe has ${recipe.scenes.length} scene(s), but storyboard requires exactly ${storyboard.length} scene(s) ` + @@ -143,10 +160,16 @@ export async function writeRecipe( // selectors already targeted by EARLIER actions in this scene β€” any one // of them is a plausible revealer for a later hidden element (B5 review) const priorSelectors = new Set(); + // union of every valid selector on this page (interactables + framable + // regions) β€” the coercion target for a selector copied with trailing junk + const pageAllSelectors = new Set([...pageSelectors.keys(), ...pageRegions]); for (const a of [...scene.entry.prelude, ...scene.actions]) { if (a.kind === "goto") { throw new Error(`scene "${scene.name}" uses a mid-scene goto; use a new scene entry.url instead`); } + // heal an appended ` [tag]` annotation before the whitelist checks + if (a.selector) a.selector = coerceSelector(a.selector, pageAllSelectors); + if (a.focus_selector) a.focus_selector = coerceSelector(a.focus_selector, pageAllSelectors); if (a.selector && !pageSelectors.has(a.selector)) { throw new Error( `selector "${a.selector}" in scene "${scene.name}" is not on its entry page ${scene.entry.url} β€” ` + diff --git a/src/render/plan.ts b/src/render/plan.ts index c59c0ba..904d58e 100644 --- a/src/render/plan.ts +++ b/src/render/plan.ts @@ -148,12 +148,12 @@ interface CameraSegment { /** MAXIMUM punch-in, reached only for small widgets β€” a plain bbox is inflated * to a context region and fit-zoomed, so large targets zoom far less */ -const ZOOM_TARGET = 1.48; +const ZOOM_TARGET = 1.42; const ZOOM_LEAD_MS = 600; // camera starts moving before the click lands -const ZOOM_DWELL_MS = 1500; // stays on target after the event +const ZOOM_DWELL_MS = 1200; // stays on target after the event /** each scene opens wide: this long at z=1 so the viewer reads the whole page * before the first punch-in (Screen-Studio establishing shot) */ -const ESTABLISH_MS = 1200; +const ESTABLISH_MS = 900; /** a plain interaction bbox is inflated to at least this fraction of the * viewport before fit-zooming β€” the framed shot always keeps page context, * and a full-width hero gets no punch at all */ @@ -161,7 +161,7 @@ const MIN_CONTEXT_FRAC = 0.55; /** bridge nearby punch-ins only when their targets are NEAR: beyond this * fraction of the content diagonal the camera widens between beats instead * of dragging a tight crop across the page */ -const MERGE_DIST_FRAC = 0.35; +const MERGE_DIST_FRAC = 0.5; /** scene-boundary crossfade length (last pre-nav frame β†’ first post-nav * frame) β€” a deliberate dissolve instead of a freeze-then-snap */ const CROSSFADE_MS = 350; @@ -177,13 +177,13 @@ const RESIDUAL_BLEND_MAX_MS = 500; const NAV_MARKER_SLACK_MS = 1000; /** a framed RESULT (focus_bbox) is the payoff β€” hold on it longer than a plain * interaction so the viewer reads the graph/results before the camera moves */ -const FOCUS_DWELL_MS = 4200; +const FOCUS_DWELL_MS = 2400; /** a result region should FILL the frame, not be punched-into and cropped: * fit it to this fraction of the viewport (the rest is breathing room) */ const FOCUS_FILL = 0.88; /** segments closer than this bridge into ONE held zoom β€” the camera glides * between targets instead of pumping out/in per click. */ -const MERGE_GAP_MS = 2600; +const MERGE_GAP_MS = 3400; /** between two scenes (a gap too wide to fully merge) the camera relaxes to this * gentle floor instead of snapping all the way back to z=1 β€” so it glides scene * to scene rather than pumping fully out then punching back in (which read as a diff --git a/src/schema/recipe.ts b/src/schema/recipe.ts index a50f503..b62627d 100644 --- a/src/schema/recipe.ts +++ b/src/schema/recipe.ts @@ -85,6 +85,9 @@ export const scene = z.object({ export const recipe = z.object({ version: z.literal(0), app_url: httpUrl, + /** deliberately a free string, NOT an enum: hand-written recipes (record + * path) may name custom audio files or anything else for back-compat. The + * bundled-track enum is enforced on the DIRECTOR's output in script.ts. */ music_track: z.string().min(1), scenes: z.array(scene).min(1), }).strict(); diff --git a/src/security/url-policy.ts b/src/security/url-policy.ts index 6455b73..85cce3c 100644 --- a/src/security/url-policy.ts +++ b/src/security/url-policy.ts @@ -75,7 +75,9 @@ function isPrivateHostname(hostname: string): boolean { h = normalizeHostToIPv4(h); if (h === "localhost" || h.endsWith(".localhost")) return true; if (h === "0.0.0.0") return true; - if (isIP(h) === 6) return h === "::1" || h.startsWith("fc") || h.startsWith("fd") || h.startsWith("fe80:"); + // "::" is the unspecified address (equivalent to 0.0.0.0) β€” routers/servers + // bound to it accept loopback traffic, so treat it as private too. + if (isIP(h) === 6) return h === "::1" || h === "::" || h.startsWith("fc") || h.startsWith("fd") || h.startsWith("fe80:"); if (isIP(h) === 4) { return ( inCidr(h, "10.0.0.0", 8) || diff --git a/test/director-validation.test.ts b/test/director-validation.test.ts index c141bdb..ace339e 100644 --- a/test/director-validation.test.ts +++ b/test/director-validation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { validateAnalysis } from "../src/director/analyze.js"; +import { coerceSelector, validateAnalysis } from "../src/director/analyze.js"; import type { PageDigest } from "../src/director/inventory.js"; const digests: PageDigest[] = [ @@ -13,40 +13,155 @@ const digests: PageDigest[] = [ }, ]; +// a fully-valid analysis; tests override single fields to isolate each gate +function validAnalysis(): Record { + return { + product_summary: "A useful product with dashboard analytics.", + product_name: "Acme", + headline: "See your dashboard the moment you arrive", + tagline: "Analytics, instantly", + music_track: "daybreak", + money_moments: [ + { title: "Arrive", caption: "Land on insight", why: "first moment", page_url: "http://127.0.0.1:9999/", elements: ["#cta"] }, + { title: "Start", caption: "Get going", why: "real moment", page_url: "http://127.0.0.1:9999/", elements: ["#cta"] }, + ], + }; +} + describe("analysis validation", () => { it("rejects money moments for non-crawled pages", () => { - expect(() => - validateAnalysis( - { - product_summary: "A useful product with dashboard analytics.", - product_name: "Acme", - headline: "See your dashboard the moment you arrive", - tagline: "Analytics, instantly", - money_moments: [ - { title: "Fake", caption: "Off-page", why: "not crawled", page_url: "http://127.0.0.1:9999/admin", elements: ["#cta"] }, - { title: "Start", caption: "Get going", why: "real moment", page_url: "http://127.0.0.1:9999/", elements: ["#cta"] }, - ], - }, - digests, - ), - ).toThrow(/not a crawled page/i); + const bad = validAnalysis(); + (bad.money_moments as { page_url: string }[])[0]!.page_url = "http://127.0.0.1:9999/admin"; + expect(() => validateAnalysis(bad, digests)).toThrow(/not a crawled page/i); }); it("rejects selectors not inventoried on the referenced page", () => { - expect(() => - validateAnalysis( - { - product_summary: "A useful product with dashboard analytics.", - product_name: "Acme", - headline: "See your dashboard the moment you arrive", - tagline: "Analytics, instantly", - money_moments: [ - { title: "Fake", caption: "Bad selector", why: "fake selector", page_url: "http://127.0.0.1:9999/", elements: ["#missing"] }, - { title: "Start", caption: "Get going", why: "real moment", page_url: "http://127.0.0.1:9999/", elements: ["#cta"] }, - ], - }, - digests, - ), - ).toThrow(/not in the inventory/i); + const bad = validAnalysis(); + (bad.money_moments as { elements: string[] }[])[0]!.elements = ["#missing"]; + expect(() => validateAnalysis(bad, digests)).toThrow(/not in the inventory/i); + }); + + it("music_track: accepts every bundled track", () => { + for (const track of ["pulse", "daybreak", "midnight", "momentum"]) { + const a = validateAnalysis({ ...validAnalysis(), music_track: track }, digests); + expect(a.music_track).toBe(track); + } + }); + + it("music_track: rejects tracks outside the bundled library (analysis must pick a vibe)", () => { + expect(() => validateAnalysis({ ...validAnalysis(), music_track: "institutional-01" }, digests)).toThrow(); + expect(() => validateAnalysis({ ...validAnalysis(), music_track: "off" }, digests)).toThrow(); + const missing = validAnalysis(); + delete missing.music_track; // required β€” the director always ships a pick + expect(() => validateAnalysis(missing, digests)).toThrow(); + }); + + it("heals a selector copied with the trailing [tag] annotation instead of rejecting it", () => { + const valid = new Set(['#cta', ':nth-match([data-testid="service-item"], 1)']); + // the model grabbed past the closing backtick and appended the display tag + expect(coerceSelector(':nth-match([data-testid="service-item"], 1) [button]', valid)) + .toBe(':nth-match([data-testid="service-item"], 1)'); + expect(coerceSelector("#cta [button]", valid)).toBe("#cta"); + expect(coerceSelector("#cta [input]", valid)).toBe("#cta"); + // an actual hallucination has no inventory prefix β†’ returned as-is β†’ fails the gate + expect(coerceSelector("#totally-invented", valid)).toBe("#totally-invented"); + }); + + it("does NOT prefix-heal a real-selector remainder β€” a hallucinated sibling stays rejected", () => { + const valid = new Set(["#cta"]); + // #cta-danger / #cta2 start with the valid #cta but the tail is selector + // continuation, not display annotation β†’ returned as-is β†’ the gate rejects them + expect(coerceSelector("#cta-danger", valid)).toBe("#cta-danger"); + expect(coerceSelector("#cta2", valid)).toBe("#cta2"); + expect(coerceSelector("#cta_alt", valid)).toBe("#cta_alt"); + // but the annotation tail on the same base still heals + expect(coerceSelector("#cta [button]", valid)).toBe("#cta"); + }); + + it("validateAnalysis accepts elements that carry the appended annotation (self-heals)", () => { + const a = validAnalysis(); + (a.money_moments as { elements: string[] }[])[0]!.elements = ['#cta [button] "Start"']; + const parsed = validateAnalysis(a, digests); + expect(parsed.money_moments[0]!.elements).toEqual(["#cta"]); + }); +}); + +describe("relative page_url coercion (query-distinct pages)", () => { + // two crawled URLs sharing a pathname but different search β€” the crawler keys + // pages on pathname+search, so these are two distinct digests + const ambiguous: PageDigest[] = [ + { url: "http://127.0.0.1:9999/results?view=chart", title: "Chart", headings: ["Chart"], + inventory: [{ selector: "#chart", tag: "div", text: "chart", bbox: { x: 1, y: 2, w: 3, h: 4 } }] }, + { url: "http://127.0.0.1:9999/results?view=table", title: "Table", headings: ["Table"], + inventory: [{ selector: "#table", tag: "div", text: "table", bbox: { x: 1, y: 2, w: 3, h: 4 } }] }, + ]; + + function analysisFor(pageUrl: string): Record { + return { + product_summary: "A results explorer with chart and table views.", + product_name: "Acme", + headline: "See your results the way you think", + tagline: "Results, your way", + music_track: "daybreak", + money_moments: [ + { title: "Chart", caption: "See the shape", why: "the hook", page_url: pageUrl, elements: ["#chart"] }, + { title: "Table", caption: "See the rows", why: "the payoff", page_url: pageUrl, elements: ["#table"] }, + ], + }; + } + + it("throws (never silently rewrites) when a bare relative path is ambiguous across query-distinct pages", () => { + // "/results" maps to BOTH crawled URLs β€” coercing would pick one at random + expect(() => validateAnalysis(analysisFor("/results"), ambiguous)).toThrow(/ambiguous/i); + }); + + it("still coerces a bare relative path when its pathname is unique", () => { + const unique: PageDigest[] = [ + { url: "http://127.0.0.1:9999/setup", title: "Setup", headings: ["Setup"], + inventory: [{ selector: "#go", tag: "button", text: "Go", bbox: { x: 1, y: 2, w: 3, h: 4 } }] }, + ]; + const a = { + product_summary: "A setup flow that gets teams live fast.", + product_name: "Acme", + headline: "Be live in two minutes flat", + tagline: "Setup, done", + music_track: "daybreak", + money_moments: [ + { title: "Land", caption: "Ship it now", why: "the hook", page_url: "/setup", elements: ["#go"] }, + { title: "Ship", caption: "Ship it now", why: "the payoff", page_url: "/setup", elements: ["#go"] }, + ], + }; + const parsed = validateAnalysis(a, unique); + // the relative "/setup" was rewritten to the full crawled URL + expect(parsed.money_moments[0]!.page_url).toBe("http://127.0.0.1:9999/setup"); + }); +}); + +describe("coerceSelector never heals into a non-whitelisted selector", () => { + it("heals a longest-prefix tie to the longest whitelisted selector, never a shorter sibling", () => { + const valid = new Set(["#cta", "#cta-menu"]); + // "#cta-menu [button]" prefixes BOTH "#cta" and "#cta-menu"; only the longer, + // exact match has an annotation-only remainder β†’ heals to the real selector + expect(coerceSelector("#cta-menu [button]", valid)).toBe("#cta-menu"); + // the shorter sibling with a real-selector remainder is left untouched + expect(coerceSelector("#cta-menu", new Set(["#cta"]))).toBe("#cta-menu"); + }); + + it("never heals a descendant-combinator remainder into its ancestor", () => { + const valid = new Set(["main"]); + // these are DIFFERENT elements (a descendant), not "main" + a display + // annotation β€” the '='/'>'/class continuation breaks the annotation shape, + // so they are returned as-is and the whitelist gate rejects them + expect(coerceSelector("main [role=button]", valid)).toBe("main [role=button]"); + expect(coerceSelector("main > .row", valid)).toBe("main > .row"); + expect(coerceSelector("main .title", valid)).toBe("main .title"); + }); + + it("only ever returns a whitelist member or the original raw string", () => { + const valid = new Set(["#a", ".card", ':nth-match([data-testid="x"], 2)']); + for (const raw of ["#a-evil", "#a [button]", ".card .child", "#totally-new", ".card [div]", ':nth-match([data-testid="x"], 2) [li]']) { + const out = coerceSelector(raw, valid); + expect(valid.has(out) || out === raw.trim(), `coerce("${raw}") = "${out}"`).toBe(true); + } }); }); diff --git a/test/director.test.ts b/test/director.test.ts index 1d46523..a126022 100644 --- a/test/director.test.ts +++ b/test/director.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from "vitest"; import { BudgetedLlmClient, TokenBudgetExceededError, extractJson, type ChatOptions, type LlmClient } from "../src/director/llm.js"; -import { DESTRUCTIVE_RE } from "../src/director/inventory.js"; +import { DESTRUCTIVE_RE, isDestructiveLabel, pageUrlHasSecret } from "../src/director/inventory.js"; +import { pickMusic } from "../src/director/generate.js"; import { writeRecipe } from "../src/director/script.js"; import { applyVerdicts, deterministicChecks, qcReport } from "../src/director/qc.js"; -import type { AppAnalysis } from "../src/director/analyze.js"; +import { analyzeApp, type AppAnalysis } from "../src/director/analyze.js"; import type { PageDigest } from "../src/director/inventory.js"; import type { RecordResult } from "../src/capture/executor.js"; import type { Recipe } from "../src/schema/index.js"; @@ -46,6 +47,7 @@ const analysis: AppAnalysis = { product_name: "Lumon", headline: "Your metrics, the moment you sign up", tagline: "Numbers without the setup", + music_track: "daybreak", money_moments: [ { title: "Instant signup", caption: "Sign up in one click", why: "shows zero friction", page_url: "http://127.0.0.1:9999/", elements: ["#cta"] }, { title: "Typed email", caption: "Your dashboard, instantly", why: "form payoff", page_url: "http://127.0.0.1:9999/", elements: ["#email"] }, @@ -56,7 +58,7 @@ function validRecipeJson(selector: string): string { return JSON.stringify({ version: 0, app_url: "http://127.0.0.1:9999", - music_track: "institutional-01", + music_track: "daybreak", scenes: [ { name: "signup", @@ -159,6 +161,35 @@ describe("script stage β€” the anti-hallucination gates", () => { ); }); + it("rejects a made-up music track with a corrective error and accepts the retry", async () => { + const fake = JSON.parse(validRecipeJson("#cta")) as { music_track: string }; + fake.music_track = "synthwave-99"; + const llm = new StubLlm([JSON.stringify(fake), validRecipeJson("#cta")]); + const { recipe, attempts } = await writeRecipe(llm, analysis, digests, "http://127.0.0.1:9999"); + expect(attempts).toBe(2); + expect(recipe.music_track).toBe("daybreak"); + // retry prompt names the bad track AND the real library so the model can fix it + const retryText = llm.prompts[1]!.user.map((p) => (p.type === "text" ? p.text : "")).join(" "); + expect(retryText).toContain("synthwave-99"); + expect(retryText).toMatch(/"pulse", "daybreak", "midnight", "momentum"/); + }); + + it('accepts "off" as an explicit silent choice', async () => { + const silent = JSON.parse(validRecipeJson("#cta")) as { music_track: string }; + silent.music_track = "off"; + const llm = new StubLlm([JSON.stringify(silent)]); + const { recipe, attempts } = await writeRecipe(llm, analysis, digests, "http://127.0.0.1:9999"); + expect(attempts).toBe(1); + expect(recipe.music_track).toBe("off"); + }); + + it("passes the analysis's music pick into the script prompt", async () => { + const llm = new StubLlm([validRecipeJson("#cta")]); + await writeRecipe(llm, analysis, digests, "http://127.0.0.1:9999"); + const promptText = llm.prompts[0]!.user.map((p) => (p.type === "text" ? p.text : "")).join(" "); + expect(promptText).toContain('MUSIC: set "music_track" to "daybreak"'); + }); + it("rejects a selector that exists on another page but not the scene's entry page", async () => { // #task-ship is real β€” but only on /dash. Using it in a scene whose // entry.url is "/" must fail per-page validation (PR #2 review). @@ -193,6 +224,7 @@ describe("hidden-element reveal order (B5)", () => { product_name: "Reveal", headline: "Reveal", tagline: "Reveal", + music_track: "pulse", money_moments: [ { title: "Open the form", caption: "one click", why: "reveal", page_url: "http://127.0.0.1:9999/", elements: ["#open", "#field"] }, ], @@ -202,7 +234,7 @@ describe("hidden-element reveal order (B5)", () => { return JSON.stringify({ version: 0, app_url: "http://127.0.0.1:9999", - music_track: "institutional-01", + music_track: "midnight", scenes: [ { name: "reveal", @@ -271,8 +303,6 @@ describe("destructive-action guard (H1)", () => { "Confirm payment", "Revoke access", // B4 (review): conservatively broadened β€” irreversible / high-blast-radius - "Publish", - "Publish to production", "Transfer funds", "Transfer ownership", "Regenerate API key", @@ -325,6 +355,11 @@ describe("destructive-action guard (H1)", () => { "Send", "Send message", "Search flights", + // "publish" is reversible (unpublish exists) and is the payoff beat for + // CMS/blog/deploy apps β€” filmable by default, not in the lexicon + "Publish", + "Publish to production", + "Publish post", // "transfer" is narrowed to money/ownership phrases β€” benign transfers stay filmable: "Transfer to list", "Transfer ticket", @@ -338,11 +373,215 @@ describe("destructive-action guard (H1)", () => { // mirrors inventory.ts: an element is excluded when it matches and // allowDestructive is false; included when allowDestructive is true. const accepted = (text: string, allowDestructive: boolean) => - allowDestructive || !DESTRUCTIVE_RE.test(text); + allowDestructive || !isDestructiveLabel(text); expect(accepted("Delete account", false)).toBe(false); // excluded by default expect(accepted("Delete account", true)).toBe(true); // included on opt-in expect(accepted("Sign in", false)).toBe(true); // benign always kept }); + + it("isDestructiveLabel: PLAIN lexicon match β€” fires on any label containing a verb, slug-joined or not", () => { + // standalone verbs/phrases + for (const label of ["Delete account", "Delete", "Remove", "Pay $49", "Cancel subscription", "checkout"]) { + expect(isDestructiveLabel(label), `expected "${label}" to be destructive`).toBe(true); + } + // hyphen/underscore-joined verbs match too. A service NAME sharing a word + // with a verb ("checkout-api") also matches and IS excluded: there's no way + // to prove an element has no click handler from page context, so any + // lexicon hit is fail-safe excluded rather than kept on a guess. + for (const label of [ + "Delete-all", + "reset_config", + "checkout-api", + "checkout-api 160ms", + "delete-log-2024", + "archive-service Operational", + "reset_password_flow", + "checkout-api Delete", + ]) { + expect(isDestructiveLabel(label), `expected "${label}" to be destructive`).toBe(true); + } + // labels with no lexicon verb at all stay benign + for (const label of ["payments-worker", "auth-gateway", "Sign in", "Search services"]) { + expect(isDestructiveLabel(label), `expected "${label}" NOT to be destructive`).toBe(false); + } + }); +}); + +describe("generate music priority (cli > director > none)", () => { + // resolver stub shaped like resolveMusicTrack: null for off, path for known, + // throw for unknown β€” pickMusic must never let the throw escape + const resolve = (spec: string | undefined): string | null => { + if (!spec || spec.trim().toLowerCase() === "off") return null; + if (["pulse", "daybreak", "midnight", "momentum"].includes(spec)) return `/assets/music/${spec}.mp3`; + throw new Error(`unknown track ${spec}`); + }; + + it("an explicit --music beats the director's pick", () => { + expect(pickMusic("daybreak", "midnight", resolve)).toMatchObject({ + spec: "daybreak", source: "cli", label: "daybreak (cli)", + }); + }); + + it("--music off silences even when the director picked a track", () => { + expect(pickMusic("off", "midnight", resolve)).toMatchObject({ spec: undefined, source: "none", label: "none" }); + }); + + it("no --music β†’ the director's track", () => { + expect(pickMusic(undefined, "midnight", resolve)).toMatchObject({ + spec: "midnight", source: "director", label: "midnight (director)", + }); + }); + + it('a director "off" β†’ silent, no warning', () => { + const choice = pickMusic(undefined, "off", resolve); + expect(choice).toMatchObject({ spec: undefined, source: "none" }); + expect(choice.warning).toBeUndefined(); + }); + + it("an unresolvable director track degrades to a warned silent cut, never a throw", () => { + const choice = pickMusic(undefined, "synthwave-99", resolve); + expect(choice.spec).toBeUndefined(); + expect(choice.source).toBe("none"); + expect(choice.warning).toMatch(/synthwave-99/); + }); + + it("a throwing resolver on the --music (cli) path also degrades to silent, never a throw", () => { + // parity with the director branch: the exported function must never let a + // resolver throw escape post-spend, even though preflight normally catches + // a bad --music first + const choice = pickMusic("synthwave-99", "midnight", resolve); + expect(choice.spec).toBeUndefined(); + expect(choice.source).toBe("none"); + expect(choice.warning).toMatch(/synthwave-99/); + }); +}); + +describe("LLM prompt egress redaction + retry payload", () => { + // a full 3-part JWT β€” the query-string secret the crawler is designed to reach + const jwt = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + + it("redacts title/heading/element secrets but keeps the URL intact as a validation key in the analyze prompt", async () => { + // secret-URL pages are dropped upstream in crawlApp (see pageUrlHasSecret + + // the e2e drop test), so here the URL is a normal query URL that must + // round-trip UNREDACTED (it is the key the recipe's entry.url validates + // against); only the display fields (title/headings/element text) are redacted. + const url = "http://127.0.0.1:9999/dash?view=chart"; + const digests: PageDigest[] = [ + { + url, + title: "Ops console admin@corp.com", + headings: ["token=supersecretvalue123456"], + theme: "light", + inventory: [ + { selector: "#cta", tag: "button", text: "Get started", bbox: { x: 1, y: 2, w: 3, h: 4 } }, + { selector: "#go", tag: "button", text: "View report", bbox: { x: 1, y: 2, w: 3, h: 4 } }, + ], + }, + ]; + const good = JSON.stringify({ + product_summary: "A metrics dashboard for busy teams.", + product_name: "Lumon", + headline: "See your numbers instantly", + tagline: "Metrics, live", + music_track: "daybreak", + money_moments: [ + { title: "Land", caption: "Insight now", why: "first moment", page_url: url, elements: ["#cta"] }, + { title: "Report", caption: "See it move", why: "the payoff", page_url: url, elements: ["#go"] }, + ], + }); + const llm = new StubLlm([good]); + await analyzeApp(llm, digests); + const prompt = llm.prompts[0]!.user.map((p) => (p.type === "text" ? p.text : "")).join(" "); + expect(prompt).not.toContain("admin@corp.com"); + expect(prompt).not.toContain("supersecretvalue123456"); + expect(prompt).toContain("[REDACTED_EMAIL]"); // the email in the title + expect(prompt).toContain("token=[REDACTED]"); // the assignment in the heading + expect(prompt).toContain(url); // the URL round-trips unredacted (it is a key) + }); + + it("keeps the URL intact so the recipe round-trips, while element text stays redacted, in the script prompt", async () => { + const url = "http://127.0.0.1:9999/dash?view=chart"; + const digests: PageDigest[] = [ + { url, title: "Dash", headings: ["Live"], inventory: [ + { selector: "#cta", tag: "button", text: `open ${jwt}`, bbox: { x: 1, y: 2, w: 3, h: 4 } }, + ] }, + ]; + const analysis: AppAnalysis = { + product_summary: "A dashboard product for teams.", + product_name: "Lumon", + headline: "See it live now", + tagline: "Numbers, live", + music_track: "daybreak", + money_moments: [ + { title: "Hook", caption: "Land here", why: "the hook beat", page_url: url, elements: ["#cta"] }, + { title: "Payoff", caption: "See it move", why: "the payoff", page_url: url, elements: ["#cta"] }, + ], + }; + const recipe = JSON.stringify({ + version: 0, app_url: url, music_track: "daybreak", + scenes: [ + { name: "hook", priority: 1, entry: { url, prelude: [] }, depends_on: [], actions: [{ kind: "click", selector: "#cta", duration_ms: 1500 }], hold_ms: 400 }, + { name: "payoff", priority: 2, entry: { url, prelude: [] }, depends_on: [], actions: [{ kind: "click", selector: "#cta", duration_ms: 1500 }], hold_ms: 600 }, + ], + }); + const llm = new StubLlm([recipe]); + // the recipe echoes the raw URL as entry.url and passes validation (round-trip) + await writeRecipe(llm, analysis, digests, url); + const prompt = llm.prompts[0]!.user.map((p) => (p.type === "text" ? p.text : "")).join(" "); + expect(prompt).toContain(url); // URL is a key: unredacted, round-trips + expect(prompt).not.toContain(jwt); // element TEXT is still redacted + expect(prompt).toContain("[REDACTED_TOKEN]"); + }); + + it("pageUrlHasSecret flags a URL carrying a token/key/JWT, not a normal query URL", () => { + expect(pageUrlHasSecret(`http://app/dash?session=${jwt}`)).toBe(true); + expect(pageUrlHasSecret("http://app/dash?token=supersecretvalue123456")).toBe(true); + expect(pageUrlHasSecret("http://app/dash?view=chart&tab=latency")).toBe(false); + expect(pageUrlHasSecret("http://127.0.0.1:4100/")).toBe(false); + }); + + it("sends page screenshots only on the first attempt, never on a schema retry", async () => { + const shotDigests: PageDigest[] = [ + { + url: "http://127.0.0.1:9999/", + title: "Home", + headings: ["Home"], + theme: "light", + screenshotB64: "AAAA", // stand-in JPEG payload β€” resent would triple cost + inventory: [ + { selector: "#cta", tag: "button", text: "Start", bbox: { x: 1, y: 2, w: 3, h: 4 } }, + { selector: "#go", tag: "button", text: "Go", bbox: { x: 1, y: 2, w: 3, h: 4 } }, + ], + }, + ]; + const base = { + product_summary: "A metrics dashboard for busy teams.", + product_name: "Lumon", + headline: "See your numbers instantly", + tagline: "Metrics, live", + music_track: "daybreak", + }; + const invalid = JSON.stringify({ + ...base, + money_moments: [ + { title: "Land", caption: "Insight now", why: "first moment", page_url: "http://127.0.0.1:9999/", elements: ["#missing"] }, + { title: "Ship", caption: "See it move", why: "the payoff", page_url: "http://127.0.0.1:9999/", elements: ["#go"] }, + ], + }); + const valid = JSON.stringify({ + ...base, + money_moments: [ + { title: "Land", caption: "Insight now", why: "first moment", page_url: "http://127.0.0.1:9999/", elements: ["#cta"] }, + { title: "Ship", caption: "See it move", why: "the payoff", page_url: "http://127.0.0.1:9999/", elements: ["#go"] }, + ], + }); + const llm = new StubLlm([invalid, valid]); + await analyzeApp(llm, shotDigests); + const hasImage = (call: ChatOptions) => call.user.some((p) => p.type === "image"); + expect(hasImage(llm.prompts[0]!)).toBe(true); // attempt 0 carries the screenshot + expect(hasImage(llm.prompts[1]!)).toBe(false); // the retry is text + feedback only + }); }); describe("LLM token budget guard", () => { diff --git a/test/fixtures/demo-app/server.ts b/test/fixtures/demo-app/server.ts index 61ebe4e..fe79613 100644 --- a/test/fixtures/demo-app/server.ts +++ b/test/fixtures/demo-app/server.ts @@ -123,6 +123,81 @@ const PANEL = `Lumon β€” </script> </body></html>`; +/** fourth route: a DARK service-fleet dashboard shaped like real ops tools β€” + * six PASSIVE rows sharing one data-testid (each must survive as a distinct + * :nth-match entry), live-ticking latencies (text-based selectors self- + * invalidate), and destructive controls that must ALL be excluded: a <button>, + * an inline-onclick div, a bare <button>Delete-all</button>, a PASSIVE + * destructive-slug row (<li>delete-log-2024</li> β€” we can't prove it's inert, + * so it's fail-safe excluded), and a framework-wired "danger-row" div whose + * click is bound via addEventListener with NO onclick attr and NO cursor/ + * tabindex/role signal at all β€” excluded purely by its destructive label. The + * dark surface is painted on a #root wrapper while body/html stay transparent β€” + * the React/Next shape a body-only theme probe misreads "light", so this + * exercises the largest-surface probe. */ +const FLEET = `<!doctype html><html><head><meta charset="utf-8"><title>Lumon β€” Fleet +
+
+

Service fleet

+ +
    +
  • orders-api160ms
  • +
  • payments-worker84ms
  • +
  • auth-gateway41ms
  • +
  • search-indexer203ms
  • +
  • media-transcoder330ms
  • +
  • notification-hub57ms
  • +
+
  • delete-log-2024
+ +
Delete account
+
delete-worker
+ +
+
+ +`; + +/** fifth route: a LIGHT app with a full-viewport translucent modal backdrop + * (rgba(0,0,0,.55) over β‰₯60% of the screen). The backdrop out-covers the body + * but is NOT the page ground β€” the theme probe must skip non-opaque layers and + * still report "light", not be fooled "dark" by the overlay. */ +const OVERLAY = `Lumon β€” Overlay +

Your workspace is ready.

One dashboard for every metric that matters.

+
+ +`; + export interface DemoApp { url: string; close: () => Promise; @@ -130,7 +205,11 @@ export interface DemoApp { export async function startDemoApp(port = 0): Promise { const server: Server = createServer((req, res) => { - const body = req.url?.startsWith("/dash") ? DASH : req.url?.startsWith("/panel") ? PANEL : LANDING; + const body = req.url?.startsWith("/dash") ? DASH + : req.url?.startsWith("/panel") ? PANEL + : req.url?.startsWith("/fleet") ? FLEET + : req.url?.startsWith("/overlay") ? OVERLAY + : LANDING; res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); res.end(body); }); diff --git a/test/generate.e2e.test.ts b/test/generate.e2e.test.ts index 87ae003..d989e41 100644 --- a/test/generate.e2e.test.ts +++ b/test/generate.e2e.test.ts @@ -1,12 +1,25 @@ +import { execFile } from "node:child_process"; import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { promisify } from "node:util"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { crawlApp } from "../src/director/inventory.js"; import { generate } from "../src/director/generate.js"; import type { ChatOptions, LlmClient } from "../src/director/llm.js"; import { startDemoApp, type DemoApp } from "./fixtures/demo-app/server.js"; +const exec = promisify(execFile); + +/** codec_type:codec_name per stream, sorted β€” the music-mux assertion shape */ +async function probeStreams(mp4: string): Promise { + const { stdout } = await exec("ffprobe", [ + "-v", "quiet", "-print_format", "json", "-show_streams", mp4, + ]); + const probe = JSON.parse(stdout) as { streams: { codec_type: string; codec_name: string }[] }; + return probe.streams.map((s) => `${s.codec_type}:${s.codec_name}`).sort(); +} + /** * The full-pipeline eval with a stubbed director brain: analyze β†’ script β†’ * record β†’ QC β†’ render against the fixture app, no API key anywhere. @@ -54,6 +67,65 @@ describe("inventory crawler on the fixture app", () => { if (!item.hidden) expect(item.bbox.w).toBeGreaterThan(0); expect(item.text.length).toBeGreaterThan(0); } + // theme probe: the landing page is a light SaaS with a blue primary button + expect(digests[0]!.theme).toBe("light"); + expect(digests[0]!.accentColor).toBe("rgb(37, 99, 235)"); + }, 60_000); + + it("keeps a multi-row dashboard filmable while fail-safe excluding every destructive-labelled element", async () => { + const digests = await crawlApp(`${app.url}/fleet`, { maxPages: 1, screenshots: false, allowPrivateNetwork: true }); + const fleet = digests[0]!; + + // dark ops dashboard β†’ the look probe must say so + expect(fleet.theme).toBe("dark"); + + // six identical-testid rows (all non-destructive names) β†’ six DISTINCT + // :nth-match entries with their own text; a story needs "click row 2, then 4" + const rows = fleet.inventory.filter((i) => i.selector.includes('[data-testid="service-item"]')); + expect(rows).toHaveLength(6); + expect(new Set(rows.map((r) => r.selector)).size).toBe(6); + for (const row of rows) expect(row.selector).toMatch(/^:nth-match\(/); + + // the search box resolves via data-testid, immune to ticking metrics text + expect(fleet.inventory.some((i) => i.selector === '[data-testid="service-search"]')).toBe(true); + + // every genuine destructive control stays out, loudly β€” including a clickable + // div[onclick] (not scoped to