diff --git a/README.md b/README.md index 70b7a1266..999546f0c 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,13 @@ built-harness Chrome regression, every public edit operation, and generated media/audio rendering. Real-GPU effects remain an explicit operator/release gate because hosted PR CI does not provide a portable WebGPU adapter. +### QA Gates + +Every PR to `staging` runs the full QA gate set (head binding, static checks, +provenance, consumer smoke, browser QA, redaction) and posts a canonical QA +report; a PR stays DRAFT until a trusted judge returns PASS. See +[docs/qa/README.md](docs/qa/README.md). + ### Performance Checks - `npm run dev` is best for correctness and iteration, but includes React/Vite dev overhead, HMR, and debug instrumentation. diff --git a/docs/qa/README.md b/docs/qa/README.md new file mode 100644 index 000000000..a6c5edf14 --- /dev/null +++ b/docs/qa/README.md @@ -0,0 +1,97 @@ +# FreeCut QA Gates + +FreeCut's QA gate set mirrors the CodePress delivery gates. Every PR to +`staging` must run the full gate set at its **exact head** and post a +[canonical QA report](./canonical-report.md) as a PR comment. A PR stays +**DRAFT** until a trusted judge returns PASS — no self-merge, no report +comment or evaluator may mark a PR ready. + +## Docs-first rule + +Before touching any area, read its doc: `docs/` for packaging/provenance and +render decomposition, `packages/freecut-editor/README.md` for the published +editor surface, `headless/` sources for the browser harness, and this +directory for the QA gates themselves. Cross-feature couplings that break +silently are recorded in [verification-graph.md](./verification-graph.md) — +check your blast radius there before and after a change. + +## The gate set + +Run everything at the PR head, from a clean tree, in this order: + +| # | Gate | Command | Notes | +|---|------|---------|-------| +| 1 | Head binding | `npm run qa:binding -- --check` | Emits/validates the exact base/head + dirty-tree block for the report | +| 2 | Type check | `npm run check` | Focused: `npx vp check --no-fmt ` | +| 3 | Lint | `npm run lint` | Focused: `npx vp lint `. Needs `packages/freecut-editor/dist` — run `npm run build:editor-surface` first on a fresh tree, else a pre-existing TS2307 on the self-referencing `@quantfive/freecut-editor-surface` import (present at base too) | +| 4 | Unit tests | `npm run test:run` | Selective: `npx vp test run ` | +| 5 | Build | `npm run build` | | +| 6 | Feature boundaries | `npm run check:boundaries` | | +| 7 | Deps contract boundaries | `npm run check:deps-contracts` | | +| 8 | Legacy lib imports | `npm run check:legacy-lib-imports` | | +| 9 | Deps wrapper health | `npm run check:deps-wrapper-health` | | +| 10 | Unused exports | `npm run check:unused-exports` | Allowlists are ratchet baselines — never bulk-fix | +| 11 | Unused class members | `npm run check:unused-class-members` | Same ratchet rule | +| 12 | Changed-health | `npm run check:changed-health` | | +| 13 | Edge budgets | `npm run check:edge-budgets` | | +| 14 | Provenance/reproducibility | `npm run verify:provenance` | Verifies baseline manifest, license/notices, dependency + asset inventory SHA256s. `npm run package:reproducible` additionally rebuilds and writes a deterministic tarball (never publish from QA) | +| 15 | Editor-surface package build | `npm run build:editor-surface` | | +| 16 | Installed consumer smoke | `npm run test:editor-surface:consumer` | Packs `@quantfive/freecut-editor-surface` into a tarball, installs it into a fresh temp consumer project, and runs the consumer test — no publish | +| 17 | Headless contract tests (Node) | `npm run headless:test:node` | | +| 18 | Browser QA | `npm run qa:browser -- --skip-build` | Discovers a browser session (system Chrome, then Playwright chromium), renders a frame, writes screenshot/log artifacts + manifest to `artifacts/qa/`. Exit 3 = BLOCKED (no browser) — see below | +| 19 | Full headless browser suite | `npm run headless:test:chrome` | Render/edit/frame/layout contract checks in a real browser | +| 20 | Redaction | `npm run check:qa-redaction` | QA docs + artifacts must carry no secrets, tokens, absolute local paths, or embedded media bytes; the command also runs the fail-closed self-test (`scripts/qa-redaction-check.test.mjs`) proving the gate rejects the negative fixtures | + +`npm run verify` aggregates gates 2 and 4–13 (type check, unit tests, build, +and all boundary/deps/fallow/edge gates) plus the portable headless suite +(`headless:test:portable` = gates 17 + 19 plus the media harness tests). Lint +(gate 3) and the remaining gates run on demand. + +## Browser QA and the BLOCKED rule + +Gates 18–19 need a real browser. Discovery order: system Chrome +(`channel: 'chrome'`), then the Playwright-bundled chromium. If neither +exists, `qa:browser` exits **3** and prints a BLOCKED notice — that is a hard +environment blocker, not a failure of the code, but it also means **no +browser evidence exists**. A QA report must record the gate as `⚠️ BLOCKED` +with the exact reason; it must never claim browser evidence that was not +produced, and a missing browser session never waives the gate — it blocks +readiness until re-run in an environment that has one. GPU tuning goes +through `FREECUT_CHROME_ARGS` / `FREECUT_CHROME_ARGS_REPLACE` (see +`headless/lib/cli.mjs`). + +## Baseline failures are disclosed, never hidden + +If a gate fails on `staging` before your change (an inherited baseline +failure — e.g. full-suite jsdom/localStorage failures), record it as FAIL in +the report with the note `inherited from base ` and reproduce it at the +base SHA. Never edit allowlists, skip lists, or config to make an inherited +failure disappear. + +## Draft-to-ready discipline + +1. Open the PR as **DRAFT** at the exact head you verified. +2. Run the full gate set at that head from a clean tree. +3. Post the canonical QA report (format: [canonical-report.md](./canonical-report.md)) + as a PR comment, including the revision-binding block from `qa:binding`. +4. Any new commit re-arms every gate: re-run and re-post. +5. Only a **trusted judge** PASS transitions the PR out of draft. Verdicts + are binary and fail closed. The verifier's strongest self-verdict is + `PASS — PENDING JUDGE`; only the judge may say `READY TO MERGE`. + +## Privacy + +QA artifacts and reports contain no secrets, tokens, cookies, raw absolute +local paths (use repo-relative paths), or embedded media bytes. Screenshots +and logs live in gitignored `artifacts/qa/` and are referenced by +repo-relative path or uploaded to a durable URL before being cited. +`npm run check:qa-redaction` enforces this over `docs/qa` and `artifacts/qa`: +a generic absolute-path pattern (any Unix root or Windows drive, not a list of +known roots), any media data URI regardless of payload length, and the secret +patterns. Browser logs are sanitized with the same generic path pattern before +being written. Intentional examples in docs may carry the per-line marker +`qa-redaction:allow` (secrets are never allowlisted). The gate is proven +fail-closed by `scripts/qa-redaction-check.test.mjs`: the negative fixtures in +`scripts/fixtures/qa-redaction/` (a tmp path, a generic absolute path, a short +media data URI, a Windows path) must each fail the gate, and the clean fixture +set must pass. diff --git a/docs/qa/canonical-report.md b/docs/qa/canonical-report.md new file mode 100644 index 000000000..abf692919 --- /dev/null +++ b/docs/qa/canonical-report.md @@ -0,0 +1,52 @@ +# Canonical QA Report + +Posted as a PR comment once the full gate set has run at the exact head. +Verdicts per gate: `✅ PASS`, `❌ FAIL`, `⚠️ BLOCKED` (BLOCKED is reserved for +hard environment blockers — missing browser session, missing credentials, +outage; anything else is FAIL). + +Template: + +``` +## Canonical QA Report + +### Verifier revision binding +base: <40-hex> (origin/staging) +head: <40-hex> +git rev-parse HEAD -> <40-hex> +git status --porcelain -> (empty) + +### Gate results +| # | Gate | Result | Evidence | +|---|------|--------|----------| +| 1 | Head binding | ✅ PASS | clean tree, head descends from base | +| ... | ... | ... | one line each: command + key output | + +### Inherited baseline failures +- : FAIL at base with — reproduced at base, not caused by this PR + +### Environment blockers +- : BLOCKED — + +### Visual / browser artifacts +- artifacts/qa/browser-/manifest.json (+ frame.png, harness.png, console.log) + +### Overall +✅ PASS — PENDING JUDGE (or: ❌ FAIL — NOT READY TO MERGE) + +No merge until trusted judge PASS. +``` + +Rules: + +- The revision-binding block comes verbatim from `npm run qa:binding -- --check`. + A dirty tree forces FAIL/BLOCKED; there are no exemptions. +- Evidence lines are bounded (one line per gate) and redacted — no tokens, + no absolute local paths, no media bytes (`npm run check:qa-redaction`). +- The verifier never writes `READY TO MERGE`. The strongest self-verdict is + `PASS — PENDING JUDGE`. The judge's verdict is binary and fails closed: + `READY TO MERGE` (PASS) or `NOT READY TO MERGE` (FAIL). +- The report is valid only for the exact head in the binding block. Any new + commit re-arms the gate set; the report must be regenerated and re-posted. +- Inherited baseline failures are disclosed in their own section and + reproduced at the base SHA — never hidden by allowlist or config edits. diff --git a/docs/qa/verification-graph.md b/docs/qa/verification-graph.md new file mode 100644 index 000000000..0a034172f --- /dev/null +++ b/docs/qa/verification-graph.md @@ -0,0 +1,57 @@ +# FreeCut verification graph + +Cross-feature couplings that break **silently** — i.e. not caught by the +typechecker or imports alone. Format per edge: +` -> | mechanism | how it breaks | check that catches it`. +Blast radius of an area: grep its name below. When a change touches any edge, +run the named check at the PR head. + +## Edges + +- `packages/freecut-editor/src -> src/features/editor/host` | the published + package re-exports the real host surface (`FreeCutEditorSurface`, + `EditorHostProvider`, `EditorHost` contract); a signature drift compiles in + the repo but breaks installed consumers | `npm run test:editor-surface:consumer` +- `packages/freecut-editor package.json exports -> vite.editor-package.config.ts` + | `main`/`module`/`types`/`./style.css` point at `dist/` names produced by + the package build; renaming an output breaks consumers without any in-repo + error | `npm run build:editor-surface && npm run test:editor-surface:consumer` +- `standalone app -> host surface` | the same editor surface runs with the + standalone bootstrap (router, workspace, local project storage) that the + package deliberately excludes; a surface change that silently depends on + the bootstrap breaks the package, and vice versa | `npm run build` + + `npm run build:editor-surface` + consumer smoke +- `src/headless (window.freecut API) -> headless/*.mjs harness` | the harness + waits on `window.freecut.ready` and calls `renderTimeline`, `renderProject`, + `renderFrame`, `dumpLayout`, `editProject`, `probeMedia` by name; a rename + or readiness-change times out with no compile error | `npm run headless:test:chrome`, + `npm run qa:browser` +- `codec capability override -> headless tests` | tests force codec fallback + via the `globalThis.__freecutSupportedCodecsOverride` seam; removing or + renaming the seam makes fallback coverage silently untestable | + `node headless/test.mjs` +- `project schemaVersion -> project JSON fixtures` | saved projects, headless + fixtures, and the consumer smoke fixture carry `schemaVersion`; a migration + that changes semantics without bumping deserializes stale projects without + error | `npm run test:run` + `node headless/test.mjs` +- `provenance/freecut-baseline.json -> package.json + package-lock.json + + LICENSE + notices + tracked assets` | the manifest pins SHA256s of the + upstream revision, dependency inventory, license/notice files, and asset + roots; any drift fails only when the provenance gate runs | + `npm run verify:provenance` +- `packages/freecut-editor consumer-smoke fixtures -> vite.editor-package.test.config.ts` + | `scripts/test-editor-surface-consumer.mjs` copies the smoke + test/setup/style-declaration plus the test vite config into a temp consumer; + renaming any of those files breaks the smoke with a missing-file error only + at run time | `npm run test:editor-surface:consumer` +- `headless/server.mjs harness contract -> headless tests + qa:browser` | + `createHarnessServer({ distDir, resolveMedia })` returns + `harnessUrl`/`mediaUrl(id)`; tests and the browser QA script both consume + that shape | `npm run headless:test:node` + `npm run qa:browser` +- `fallow allowlists (scripts/*.allowlist.json) -> unused-export/member gates` + | the allowlists are ratchet baselines; editing them to silence a new + finding hides real dead code | `npm run check:unused-exports`, + `npm run check:unused-class-members` +- `feature-edge budgets -> feature boundaries` | `check:edge-budgets` caps + cross-feature import edges; growing an edge count past the budget fails + only when the budget gate runs | `npm run check:edge-budgets` diff --git a/package.json b/package.json index 5e5d5cdb4..1f84b65e2 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,10 @@ "package:reproducible": "node scripts/package-reproducible.mjs", "package:editor-surface": "node scripts/package-editor-surface.mjs", "build:perf": "vp build --mode perf", - "lint": "vp lint src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts scripts/package-editor-surface.mjs scripts/test-editor-surface-consumer.mjs headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts", - "lint:fix": "vp lint src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts scripts/package-editor-surface.mjs scripts/test-editor-surface-consumer.mjs headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts --fix", - "format": "vp fmt src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts scripts/package-editor-surface.mjs scripts/test-editor-surface-consumer.mjs headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts package.json packages/freecut-editor/package.json .oxlintrc.json .oxfmtrc.json", - "format:check": "vp fmt src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts scripts/package-editor-surface.mjs scripts/test-editor-surface-consumer.mjs headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts package.json packages/freecut-editor/package.json .oxlintrc.json .oxfmtrc.json --check", + "lint": "vp lint src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts scripts/package-editor-surface.mjs scripts/test-editor-surface-consumer.mjs scripts/qa-head-binding.mjs scripts/qa-browser-check.mjs scripts/qa-redaction-check.mjs scripts/qa-redaction-check.test.mjs headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts", + "lint:fix": "vp lint src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts scripts/package-editor-surface.mjs scripts/test-editor-surface-consumer.mjs scripts/qa-head-binding.mjs scripts/qa-browser-check.mjs scripts/qa-redaction-check.mjs scripts/qa-redaction-check.test.mjs headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts --fix", + "format": "vp fmt src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts scripts/package-editor-surface.mjs scripts/test-editor-surface-consumer.mjs scripts/qa-head-binding.mjs scripts/qa-browser-check.mjs scripts/qa-redaction-check.mjs scripts/qa-redaction-check.test.mjs headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts package.json packages/freecut-editor/package.json .oxlintrc.json .oxfmtrc.json", + "format:check": "vp fmt src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts scripts/package-editor-surface.mjs scripts/test-editor-surface-consumer.mjs scripts/qa-head-binding.mjs scripts/qa-browser-check.mjs scripts/qa-redaction-check.mjs scripts/qa-redaction-check.test.mjs headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts package.json packages/freecut-editor/package.json .oxlintrc.json .oxfmtrc.json --check", "check:boundaries": "node scripts/check-feature-boundaries.mjs", "check:deps-contracts": "node scripts/check-deps-contract-boundaries.mjs", "check:legacy-lib-imports": "node scripts/check-legacy-lib-imports.mjs", @@ -27,6 +27,9 @@ "check:changed-health": "node scripts/check-fallow-changed-health.mjs", "report:deps-wrapper-health:json": "node scripts/check-deps-wrapper-health.mjs --json", "check:edge-budgets": "node scripts/check-feature-edge-budgets.mjs", + "qa:binding": "node scripts/qa-head-binding.mjs", + "qa:browser": "node scripts/qa-browser-check.mjs", + "check:qa-redaction": "node scripts/qa-redaction-check.mjs && node --test scripts/qa-redaction-check.test.mjs", "report:feature-edges": "node scripts/report-feature-edges.mjs", "report:feature-edges:json": "node scripts/report-feature-edges.mjs --json", "check": "vp check --no-fmt src packages/freecut-editor/src headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts", diff --git a/provenance/dependency-inventory.json b/provenance/dependency-inventory.json index a9efc1877..01634da1d 100644 --- a/provenance/dependency-inventory.json +++ b/provenance/dependency-inventory.json @@ -3,7 +3,7 @@ "generatedFrom": "package.json", "packageName": "freecut", "packageVersion": "0.0.0", - "packageJsonSha256": "331cb27e2fa2e50dbf9759e625e6d2b9c97db2205ee5870c8157ffbb2b14f971", + "packageJsonSha256": "d585b800f4a701f45a5415e588c282fbb9067cbebc78316add16397052a6a493", "lockfile": { "path": "package-lock.json", "lockfileVersion": 3, diff --git a/provenance/freecut-baseline.json b/provenance/freecut-baseline.json index 5346dde91..421eb09c2 100644 --- a/provenance/freecut-baseline.json +++ b/provenance/freecut-baseline.json @@ -34,7 +34,7 @@ ], "dependencies": { "packageJson": "package.json", - "packageJsonSha256": "331cb27e2fa2e50dbf9759e625e6d2b9c97db2205ee5870c8157ffbb2b14f971", + "packageJsonSha256": "d585b800f4a701f45a5415e588c282fbb9067cbebc78316add16397052a6a493", "lockfile": "package-lock.json", "lockfileVersion": 3, "lockfileSha256": "b4a86741ce7891da1f63df01b6fdd4ed507e8887d5097c6a0f93fc2ea6f3420e", @@ -79,14 +79,7 @@ "packageCommand": "npm run package:reproducible", "artifactPattern": "artifacts/freecut-.tar.gz", "archiveFormat": "deterministic tar.gz with sorted paths, zeroed mtimes, and uid/gid 0", - "contents": [ - "dist/", - "LICENSE", - "notices/", - "package.json", - "package-lock.json", - "provenance/" - ] + "contents": ["dist/", "LICENSE", "notices/", "package.json", "package-lock.json", "provenance/"] }, "ciVerification": { "workflow": ".github/workflows/reproducible-package.yml", diff --git a/scripts/fixtures/qa-redaction/clean/example.md b/scripts/fixtures/qa-redaction/clean/example.md new file mode 100644 index 000000000..d24fba91e --- /dev/null +++ b/scripts/fixtures/qa-redaction/clean/example.md @@ -0,0 +1,11 @@ +# Clean fixture + +QA artifacts are referenced by repo-relative path only: +`artifacts/qa/browser-/manifest.json` and `docs/qa/README.md`. + +URLs are not local paths: https://example.com/qa/artifacts/manifest.json is fine. +Single-segment URL paths like /headless.html are fine too. + +Intentional examples can be allowlisted per line. The next line demonstrates a +path example that a doc may legitimately show: +Run the checker against /tmp/example-output/ to see a finding. qa-redaction:allow diff --git a/scripts/fixtures/qa-redaction/negative-generic-path.txt b/scripts/fixtures/qa-redaction/negative-generic-path.txt new file mode 100644 index 000000000..9a7c74dc9 --- /dev/null +++ b/scripts/fixtures/qa-redaction/negative-generic-path.txt @@ -0,0 +1 @@ +Pipeline config loaded from /opt/acme/pipeline/config.yaml at startup. diff --git a/scripts/fixtures/qa-redaction/negative-short-data-uri.txt b/scripts/fixtures/qa-redaction/negative-short-data-uri.txt new file mode 100644 index 000000000..0f488c2c9 --- /dev/null +++ b/scripts/fixtures/qa-redaction/negative-short-data-uri.txt @@ -0,0 +1 @@ +Inline icon: data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP/// diff --git a/scripts/fixtures/qa-redaction/negative-tmp-path.txt b/scripts/fixtures/qa-redaction/negative-tmp-path.txt new file mode 100644 index 000000000..3c0d871ff --- /dev/null +++ b/scripts/fixtures/qa-redaction/negative-tmp-path.txt @@ -0,0 +1 @@ +QA scratch log was written to /tmp/freecut-qa-run-9f2c/output.log before cleanup. diff --git a/scripts/fixtures/qa-redaction/negative-windows-forward-slash.txt b/scripts/fixtures/qa-redaction/negative-windows-forward-slash.txt new file mode 100644 index 000000000..34608a85c --- /dev/null +++ b/scripts/fixtures/qa-redaction/negative-windows-forward-slash.txt @@ -0,0 +1 @@ +Build output at C:/Users/builder/freecut/dist/bundle.js was archived. diff --git a/scripts/fixtures/qa-redaction/negative-windows-path.txt b/scripts/fixtures/qa-redaction/negative-windows-path.txt new file mode 100644 index 000000000..8fcf86676 --- /dev/null +++ b/scripts/fixtures/qa-redaction/negative-windows-path.txt @@ -0,0 +1 @@ +Build output at C:\Users\builder\freecut\dist\bundle.js was archived. diff --git a/scripts/qa-browser-check.mjs b/scripts/qa-browser-check.mjs new file mode 100644 index 000000000..40bf1cabc --- /dev/null +++ b/scripts/qa-browser-check.mjs @@ -0,0 +1,254 @@ +// Browser QA gate. Discovers a usable browser session, exercises the headless +// render harness in it, and writes screenshot/log artifacts plus a manifest to +// artifacts/qa/browser-/ (gitignored). +// +// Exit codes: +// 0 PASS — artifacts written +// 1 FAIL — a check failed +// 3 BLOCKED — no browser session available in this environment +// +// BLOCKED is reserved for hard environment blockers (no browser binary). A +// BLOCKED result never counts as browser evidence; the QA report must say so. +// +// Run: node scripts/qa-browser-check.mjs [--skip-build] +import { chromium } from 'playwright' +import { execFileSync, execSync } from 'node:child_process' +import crypto from 'node:crypto' +import { fileURLToPath } from 'node:url' +import fs from 'node:fs' +import path from 'node:path' +import { createHarnessServer } from '../headless/server.mjs' +import { chromeLaunchArgs } from '../headless/lib/cli.mjs' + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') + +const FRAME_PROJECT = { + id: 'qa-browser-project', + name: 'QA Browser', + description: '', + createdAt: 1735689600000, + updatedAt: 1735689600000, + duration: 30, + schemaVersion: 10, + metadata: { width: 640, height: 360, fps: 30, backgroundColor: '#101418' }, + timeline: { + masterBusDb: 0, + tracks: [ + { + id: 'track-1', + name: 'V1', + kind: 'video', + height: 60, + locked: false, + syncLock: true, + visible: true, + muted: false, + solo: false, + order: 0, + items: [], + }, + ], + items: [ + { + id: 'text-1', + trackId: 'track-1', + from: 0, + durationInFrames: 30, + label: 'Title', + type: 'text', + text: 'qa-browser', + color: '#ffffff', + fontSize: 64, + fontWeight: 'bold', + textAlign: 'center', + verticalAlign: 'middle', + transform: {}, + }, + ], + transitions: [], + keyframes: [], + compositions: [], + }, +} + +function blocked(message) { + console.error(`[qa-browser] BLOCKED: ${message}`) + console.error('[qa-browser] no browser evidence was produced; the QA report must record this gate as BLOCKED') + process.exit(3) +} + +function sha256File(filePath) { + return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex') +} + +function describeLaunchError(label, error) { + return `${label}: ${String(error?.message ?? error).split('\n')[0]}` +} + +async function tryLaunch({ label, options }) { + const browser = await chromium.launch({ + ...options, + headless: true, + args: chromeLaunchArgs(), + }) + console.log(`[qa-browser] browser session: ${label} (${browser.version()})`) + return { browser, label } +} + +async function launchBrowser() { + const attempts = [ + { label: 'system Chrome (channel: chrome)', options: { channel: 'chrome' } }, + { label: 'Playwright bundled chromium', options: {} }, + ] + const errors = [] + for (const attempt of attempts) { + const launched = await tryLaunch(attempt).catch((error) => { + errors.push(describeLaunchError(attempt.label, error)) + return null + }) + if (launched) return launched + } + blocked(`browser discovery found no available browser session — ${errors.join(' | ')}`) +} + +async function ensureBuild() { + if (!process.argv.includes('--skip-build')) { + execSync('npm run build', { cwd: ROOT, stdio: 'inherit' }) + return + } + if (!fs.existsSync(path.join(ROOT, 'dist', 'headless.html'))) { + throw new Error('dist/headless.html missing — run npm run build first or drop --skip-build') + } +} + +function prepareOutDir(head) { + const outDir = path.join(ROOT, 'artifacts', 'qa', `browser-${head.slice(0, 12)}`) + fs.rmSync(outDir, { recursive: true, force: true }) + fs.mkdirSync(outDir, { recursive: true }) + return outDir +} + +function report(failures, name, condition, detail) { + if (condition) { + console.log(` PASS ${name}`) + return + } + failures.push(name) + console.error(` FAIL ${name}${detail ? ` — ${detail}` : ''}`) +} + +function pngSize(filePath) { + if (!fs.existsSync(filePath)) return 0 + return fs.statSync(filePath).size +} + +async function grabFrame(page, outDir, failures) { + const frameDownloadPromise = page.waitForEvent('download', { timeout: 60_000 }) + frameDownloadPromise.catch(() => {}) + const frameSummary = await page.evaluate((input) => window.freecut.renderFrame(input), { + project: FRAME_PROJECT, + atSeconds: 0.5, + }) + const framePath = path.join(outDir, 'frame.png') + const frameDownload = await frameDownloadPromise + await frameDownload.saveAs(framePath) + report(failures, 'renderFrame returns ok', frameSummary.ok === true) + report(failures, 'frame matches project width', frameSummary.width === 640, `got ${frameSummary.width}`) + report( + failures, + 'frame PNG has real pixels (>1KB)', + pngSize(framePath) > 1000, + `${pngSize(framePath)} bytes`, + ) +} + +async function runPageChecks(browser, server, outDir) { + const failures = [] + const consoleLines = [] + const context = await browser.newContext({ acceptDownloads: true }) + const page = await context.newPage() + page.on('console', (message) => consoleLines.push(`[console:${message.type()}] ${message.text()}`)) + page.on('pageerror', (error) => { + failures.push('page error') + consoleLines.push(`[pageerror] ${error.message}`) + console.error(' FAIL page error —', error.message) + }) + + await page.goto(server.harnessUrl, { waitUntil: 'load', timeout: 60_000 }) + await page.waitForFunction(() => Boolean(window.freecut?.ready), { timeout: 30_000 }) + report(failures, 'harness reports ready', true) + + await grabFrame(page, outDir, failures) + await page.screenshot({ path: path.join(outDir, 'harness.png') }) + return { failures, consoleLines } +} + +// Generic absolute local path (same contract as qa-redaction-check): any Unix +// absolute path of 2+ segments not embedded in a URL, or any Windows drive +// path with either separator. Sanitization must not depend on knowing the +// specific roots in advance. +const ABSOLUTE_PATH = + /(?-])(?:[A-Za-z]:[\\/][^\s'"<>|]*|\/(?:[A-Za-z0-9._~-]+\/)+[A-Za-z0-9._~-]*)/g + +function sanitizeLogText(text) { + return text.replace(ABSOLUTE_PATH, '') +} + +function writeConsoleLog(outDir, consoleLines) { + // Defensive redaction: no absolute local path may reach the log artifact, + // regardless of which root it lives under. + const logText = sanitizeLogText(consoleLines.join('\n')) + fs.writeFileSync(path.join(outDir, 'console.log'), `${logText}\n`) +} + +function writeManifest(outDir, { head, label, version, failures }) { + const artifacts = ['frame.png', 'harness.png', 'console.log'] + const manifest = { + schema: 'freecut-qa-browser/v1', + head, + browser: { label, version }, + generatedAt: new Date().toISOString(), + artifacts: Object.fromEntries( + artifacts.map((name) => [ + name, + { bytes: fs.statSync(path.join(outDir, name)).size, sha256: sha256File(path.join(outDir, name)) }, + ]), + ), + result: failures.length === 0 ? 'PASS' : 'FAIL', + } + fs.writeFileSync(path.join(outDir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`) + console.log( + `[qa-browser] artifacts: artifacts/qa/browser-${head.slice(0, 12)}/ (${artifacts.join(', ')}, manifest.json)`, + ) +} + +function reportOutcome(failures) { + if (failures.length > 0) { + console.error(`[qa-browser] ${failures.length} check(s) FAILED`) + process.exit(1) + } + console.log('[qa-browser] PASS') +} + +async function main() { + await ensureBuild() + const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: ROOT, encoding: 'utf8' }).trim() + const outDir = prepareOutDir(head) + + const { browser, label } = await launchBrowser() + const server = await createHarnessServer({ distDir: path.join(ROOT, 'dist'), resolveMedia: () => null }) + try { + const { failures, consoleLines } = await runPageChecks(browser, server, outDir) + writeConsoleLog(outDir, consoleLines) + writeManifest(outDir, { head, label, version: browser.version(), failures }) + reportOutcome(failures) + } finally { + await browser.close() + await server.close() + } +} + +main().catch((error) => { + console.error(`[qa-browser] crashed: ${error?.message ?? error}`) + process.exit(1) +}) diff --git a/scripts/qa-head-binding.mjs b/scripts/qa-head-binding.mjs new file mode 100644 index 000000000..ef7d6c5c0 --- /dev/null +++ b/scripts/qa-head-binding.mjs @@ -0,0 +1,84 @@ +// QA head-binding gate. Emits (and with --check, validates) the exact +// base/head revision-binding block that every canonical QA report must carry. +// +// A QA report is only valid for one exact head SHA on top of one exact base +// SHA, produced from a clean working tree. Any new commit re-arms the gate: +// the binding must be re-emitted and every gate re-run at the new head. +// +// Usage: +// node scripts/qa-head-binding.mjs [--base ] print the block +// node scripts/qa-head-binding.mjs --check [--base ] +// exit 1 if the tree is dirty, HEAD is not a descendant of base, +// or base cannot be resolved +// +// Output contains only SHAs and repo-relative state — never local paths. +import { execFileSync, spawnSync } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const DEFAULT_BASE_REF = 'origin/staging' + +function fail(message) { + console.error(`[qa-head-binding] ${message}`) + process.exit(1) +} + +function git(args, { allowFailure = false } = {}) { + const result = spawnSync('git', args, { cwd: ROOT, encoding: 'utf8' }) + if (result.status !== 0) { + if (allowFailure) return null + fail(`git ${args.join(' ')} failed: ${(result.stderr ?? '').trim()}`) + } + return result.stdout.trim() +} + +function readFlag(name) { + const index = process.argv.indexOf(name) + if (index < 0) return null + const value = process.argv[index + 1] + if (!value || value.startsWith('--')) fail(`${name} requires a value`) + return value +} + +function resolveSha(ref, label) { + const sha = git(['rev-parse', '--verify', `${ref}^{commit}`], { allowFailure: true }) + if (!sha) fail(`could not resolve ${label} ref: ${ref}`) + return sha +} + +function emitBindingBlock({ base, baseRef, head, porcelain, clean }) { + console.log('### Verifier revision binding') + console.log(`base: ${base} (${baseRef})`) + console.log(`head: ${head}`) + console.log(`git rev-parse HEAD -> ${head}`) + console.log(`git status --porcelain -> ${clean ? '(empty)' : '\n' + porcelain}`) +} + +function validateBinding({ check, clean, descendant, base, head }) { + if (!check) return + if (!clean) fail('working tree is dirty — QA evidence must come from a clean tree') + if (!descendant) fail(`head ${head} is not a descendant of base ${base}`) + console.log('[qa-head-binding] OK: clean tree, head descends from base') +} + +function resolveBase(baseRef) { + if (/^[0-9a-f]{40}$/.test(baseRef)) return baseRef + return resolveSha(baseRef, 'base') +} + +function main() { + const check = process.argv.includes('--check') + const baseRef = readFlag('--base') ?? DEFAULT_BASE_REF + const base = resolveBase(baseRef) + const head = git(['rev-parse', 'HEAD']) + const porcelain = git(['status', '--porcelain']) + const clean = porcelain.length === 0 + const descendant = + spawnSync('git', ['merge-base', '--is-ancestor', base, head], { cwd: ROOT }).status === 0 + + emitBindingBlock({ base, baseRef, head, porcelain, clean }) + validateBinding({ check, clean, descendant, base, head }) +} + +main() diff --git a/scripts/qa-redaction-check.mjs b/scripts/qa-redaction-check.mjs new file mode 100644 index 000000000..b73d4f24f --- /dev/null +++ b/scripts/qa-redaction-check.mjs @@ -0,0 +1,134 @@ +// QA privacy/redaction gate. Scans QA docs and artifacts for content that must +// never appear in committed files, PR bodies, or QA reports: secrets/tokens, +// raw absolute local paths (any Unix root or Windows drive path, matched +// generically — not a list of known roots), and embedded media bytes (any +// image/video/audio data URI, regardless of payload length). +// +// A line carrying the marker `qa-redaction:allow` is exempt from the +// path/data-URI patterns only, for intentional documentation examples. +// +// Usage: node scripts/qa-redaction-check.mjs [path ...] +// Defaults: docs/qa and artifacts/qa (missing paths are skipped with a note). +// Exits 1 on any finding, 0 when clean. +// Fail-closed proof: scripts/qa-redaction-check.test.mjs (negative fixtures). +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') + +const TEXT_EXTENSIONS = new Set(['.md', '.txt', '.log', '.json', '.mjs', '.ts', '.tsx', '.html']) +const ALLOWED_BINARY_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webm']) + +// Lines carrying this marker are skipped by the path/data-URI patterns. It +// exists ONLY for intentional examples in documentation (e.g. a doc that must +// show what a violating line looks like); secrets are never allowlisted. +const ALLOW_MARKER = 'qa-redaction:allow' + +// Generic absolute local path: any Unix absolute path of 2+ segments that is +// not part of a URL (lookbehind rejects matches preceded by a scheme or host +// character), or any Windows drive path with either separator. Covers /Users, +// /home, /tmp, /var, /opt, /mnt, /private, and every other root — there is no +// exhaustive list. +const ABSOLUTE_PATH = + /(?-])(?:[A-Za-z]:[\\/][^\s'"<>|]*|\/(?:[A-Za-z0-9._~-]+\/)+[A-Za-z0-9._~-]*)/ + +const SECRET_PATTERNS = [ + { name: 'GitHub token', regex: /\b(ghp|gho|ghs|ghr)_[A-Za-z0-9]{20,}\b|github_pat_[A-Za-z0-9_]{20,}\b/ }, + { name: 'npm token', regex: /\bnpm_[A-Za-z0-9]{30,}\b/ }, + { name: 'OpenAI-style key', regex: /\bsk-[A-Za-z0-9_-]{20,}\b/ }, + { name: 'Slack token', regex: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/ }, + { name: 'AWS access key', regex: /\bAKIA[0-9A-Z]{16}\b/ }, + { name: 'private key block', regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ }, + { name: 'bearer credential', regex: /\bBearer [A-Za-z0-9._-]{20,}\b/ }, +] + +const PATH_PATTERNS = [ + { name: 'absolute local path', regex: ABSOLUTE_PATH }, + // Any embedded media data URI, regardless of payload length. + { name: 'embedded media data URI', regex: /data:(image|video|audio)\/[A-Za-z0-9.+-]*;base64,/i }, +] + +function collectFiles(target, files) { + const stat = fs.statSync(target) + if (stat.isDirectory()) { + for (const entry of fs.readdirSync(target)) collectFiles(path.join(target, entry), files) + return + } + files.push(target) +} + +function collectTargetFiles(args) { + const targets = args.length > 0 ? args : ['docs/qa', 'artifacts/qa'] + const files = [] + for (const target of targets) { + const absolute = path.resolve(ROOT, target) + if (!fs.existsSync(absolute)) { + console.log(`[qa-redaction] note: ${target} does not exist, skipped`) + continue + } + collectFiles(absolute, files) + } + return files +} + +function scanBinaryArtifact(relative, extension) { + if (ALLOWED_BINARY_EXTENSIONS.has(extension)) return [] + return [`${relative}: unexpected binary artifact type "${extension}"`] +} + +function secretFindings(relative, lineNumber, line) { + const findings = [] + for (const { name, regex } of SECRET_PATTERNS) { + if (regex.test(line)) findings.push(`${relative}:${lineNumber}: ${name}`) + } + return findings +} + +function pathFindings(relative, lineNumber, line) { + if (line.includes(ALLOW_MARKER)) return [] + const findings = [] + for (const { name, regex } of PATH_PATTERNS) { + if (regex.test(line)) findings.push(`${relative}:${lineNumber}: ${name}`) + } + return findings +} + +function lineFindings(relative, lineNumber, line) { + return [ + ...secretFindings(relative, lineNumber, line), + ...pathFindings(relative, lineNumber, line), + ] +} + +function scanTextFile(file, relative) { + const lines = fs.readFileSync(file, 'utf8').split('\n') + return lines.flatMap((line, index) => lineFindings(relative, index + 1, line)) +} + +function scanFile(file) { + const relative = path.relative(ROOT, file) + const extension = path.extname(file).toLowerCase() + if (!TEXT_EXTENSIONS.has(extension)) return scanBinaryArtifact(relative, extension) + return scanTextFile(file, relative) +} + +function reportFindings(findings, fileCount) { + if (findings.length > 0) { + for (const finding of findings) console.error(`[qa-redaction] FAIL ${finding}`) + console.error( + `[qa-redaction] ${findings.length} finding(s) — remove secrets, local paths, and embedded media before publishing`, + ) + process.exit(1) + } + console.log(`[qa-redaction] OK: ${fileCount} file(s) scanned, no findings`) +} + +function main() { + const args = process.argv.slice(2).filter((arg) => !arg.startsWith('--')) + const files = collectTargetFiles(args) + const findings = files.flatMap(scanFile) + reportFindings(findings, files.length) +} + +main() diff --git a/scripts/qa-redaction-check.test.mjs b/scripts/qa-redaction-check.test.mjs new file mode 100644 index 000000000..e48ed2ef3 --- /dev/null +++ b/scripts/qa-redaction-check.test.mjs @@ -0,0 +1,37 @@ +// Fail-closed proof for the QA redaction gate (scripts/qa-redaction-check.mjs). +// Each negative fixture must make the gate exit non-zero; the clean fixture set +// must exit zero. Wired into the gate itself via `npm run check:qa-redaction`. +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { test } from 'node:test' + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const CHECKER = path.join(ROOT, 'scripts', 'qa-redaction-check.mjs') +const FIXTURES = path.join(ROOT, 'scripts', 'fixtures', 'qa-redaction') + +function runChecker(target) { + return spawnSync(process.execPath, [CHECKER, target], { cwd: ROOT, encoding: 'utf8' }) +} + +const NEGATIVE_FIXTURES = [ + 'negative-tmp-path.txt', + 'negative-generic-path.txt', + 'negative-short-data-uri.txt', + 'negative-windows-path.txt', + 'negative-windows-forward-slash.txt', +] + +for (const name of NEGATIVE_FIXTURES) { + test(`redaction gate FAILS (fail-closed) on ${name}`, () => { + const result = runChecker(path.join(FIXTURES, name)) + assert.notEqual(result.status, 0, `expected non-zero exit, got 0: ${result.stdout}`) + assert.match(result.stderr, /\[qa-redaction\] FAIL/) + }) +} + +test('redaction gate PASSES on the clean fixture set (incl. allow-marker line)', () => { + const result = runChecker(path.join(FIXTURES, 'clean')) + assert.equal(result.status, 0, `expected zero exit: ${result.stderr}`) +})