diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b272516 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 26 + cache: npm + # The e2e test checks the recorded videos with ffprobe on the host. + - run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg + - run: npm ci + - run: npm run typecheck + - run: npm test + # Builds the image, then records the fixture demo twice in it. + - run: npm run test:e2e + - if: failure() + uses: actions/upload-artifact@v4 + with: + name: e2e-out + path: test/e2e/out/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2e78b3d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +test/e2e/out/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..014f975 --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# flow-reel + +Record reproducible feature demos from a short Playwright script. A coding agent (or a person) explores the app, +writes the script, and `flow-reel` replays it in a sandbox on a virtual display while recording the screen. The same +script gives the same video, step for step. + +This release records **silent** video with step timings. Narration, captions, voice, cursor and zoom polish, and sound +effects come in later releases. + +## Requirements + +- Node.js 22.18 or later (scripts are TypeScript, run with Node's type stripping). +- Docker or Podman. The recording runs in the `ghcr.io/codeonym-oss/flow-reel:` image; when it can't be + pulled, `flow-reel` builds it locally once (a few minutes). +- About 2.5 free CPU cores while recording. On an overloaded machine the recording stops with an error rather than + save a video whose timing is off. + +## Install + +```sh +npm install --save-dev flow-reel +``` + +flow-reel isn't published yet. Until it is, build a package from a clone (`npm ci && npm run build && npm pack`) and +install the `.tgz` it writes. + +## Write a demo + +A demo is a TypeScript file whose default export is `demo(options, body)`. Each `step(narration, action)` is one unit +of the demo: the sentence that will be spoken, and the Playwright actions it describes. + +```ts +// demos/settings.ts +import { demo } from "flow-reel"; + +export default demo({ url: "http://localhost:4173" }, async ({ page, step }) => { + await step("Open the settings page.", () => page.getByRole("link", { name: "Settings" }).click()); + await step("Turn on dark mode.", () => page.getByLabel("Dark mode").check()); +}); +``` + +- `url`: where the demo starts. `localhost` reaches the app running on your machine. +- `viewport`: CSS pixels, `[1920, 1080]` by default. +- `page` is a Playwright `Page`. Clicks, hovers, `check`, `fill`, `type`, `selectOption` and `page.mouse.wheel` move + the mouse visibly to their target and type at a readable speed. Other calls that act on the page, such as + `page.evaluate` or `locator.dispatchEvent`, run as they are, without any mouse motion, and print a warning once. +- Write erasable TypeScript only: no `enum`, `namespace` or parameter properties. Import only from `flow-reel` and + from files in the same folder as the script. + +## Record + +```sh +npx flow-reel record demos/settings.ts +``` + +Next to the script: + +| File | What it is | +|---|---| +| `settings.mp4` | The silent demo, 1920×1080, 30 fps. | +| `settings.transcript.json` | Each step's `startMs`, `actionEndMs` and `endMs` on the video's timeline (schema version 1). | +| `settings.events.json` | Every move, click, keystroke run and scroll the runner made, timed the same way (schema version 1). | + +In `.flow-reel/settings/`: the 2560×1440 master the polish steps will render from. The `.flow-reel/` folder ignores +itself in git; the files next to the script are yours to commit or not. + +To keep runs the same, the runner fixes the viewport, device scale, `prefers-reduced-motion`, the page's clock +(`2026-01-05T09:00:00Z`) and `Math.random`'s seed, and waits for the network to go idle before each step. Two runs of +a script have the same steps, each starting within ±150 ms. + +## Platforms + +- **Linux** (Docker or Podman): the container uses the host's network, so `localhost` is your machine. Tested. +- **macOS and Windows** (Docker Desktop): `localhost` in the script is mapped to `host.docker.internal`. Not yet + tested in CI. + +## Known gaps + +- No narration, captions or audio yet. +- No cursor drawn in the video yet: the runner logs the motion in `events.json` for the polish step. +- Playwright calls the runner doesn't cover run without any mouse motion (the warning names them). + +## Develop + +```sh +npm ci +npm run typecheck +npm test # unit tests +npm run test:e2e # builds the image and records a fixture app twice (needs Docker and ffmpeg) +``` + +Planning lives in `planning/` (see `groundwork.md`). + +## License + +MIT diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..c3944a4 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,15 @@ +# The recording sandbox (GH-2 decision 004): Playwright's image, plus a virtual display and ffmpeg. +FROM mcr.microsoft.com/playwright:v1.64.1-noble + +RUN apt-get update \ + && apt-get install -y --no-install-recommends xvfb x11-utils ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /opt/flow-reel +COPY package.json ./ +RUN npm install --omit=dev --no-fund --no-audit +COPY dist ./dist + +ENV FLOW_REEL_IN_CONTAINER=1 HOME=/tmp +USER pwuser +ENTRYPOINT ["node", "/opt/flow-reel/dist/cli.js"] diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c8223ac --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1581 @@ +{ + "name": "flow-reel", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "flow-reel", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "playwright": "1.64.1" + }, + "bin": { + "flow-reel": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^26.6.6", + "typescript": "^7.0.2", + "vitest": "^5.1.0" + }, + "engines": { + "node": ">=22.18" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.156.0.tgz", + "integrity": "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.16.tgz", + "integrity": "sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.16.tgz", + "integrity": "sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.16.tgz", + "integrity": "sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.16.tgz", + "integrity": "sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.16.tgz", + "integrity": "sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.16.tgz", + "integrity": "sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.16.tgz", + "integrity": "sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.16.tgz", + "integrity": "sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.16.tgz", + "integrity": "sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.16.tgz", + "integrity": "sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.16.tgz", + "integrity": "sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.16.tgz", + "integrity": "sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.16.tgz", + "integrity": "sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.16.tgz", + "integrity": "sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.16.tgz", + "integrity": "sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz", + "integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz", + "integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.1.0.tgz", + "integrity": "sha512-5umB9/jlWcqVS6fsrFmv5Gdnpu6wAUio/lEOwAmvB7v+lFo/IW2B9FhAD0p19GCULTgMzLCdUmuCDCnAzWgppQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.1.0", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.1.0.tgz", + "integrity": "sha512-2DPO5OMHWKrBngC+fCmTQqemNF0gidRhp3lxbBmRhW+M1XJrfgPpqNqWVevXtn2f2ufD5ZaR1B6B641aAlcX5A==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.5.tgz", + "integrity": "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.16.tgz", + "integrity": "sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@oxc-project/types": "=0.156.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.16", + "@rolldown/binding-android-arm64": "1.2.16", + "@rolldown/binding-darwin-arm64": "1.2.16", + "@rolldown/binding-darwin-x64": "1.2.16", + "@rolldown/binding-freebsd-x64": "1.2.16", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.16", + "@rolldown/binding-linux-arm64-gnu": "1.2.16", + "@rolldown/binding-linux-arm64-musl": "1.2.16", + "@rolldown/binding-linux-ppc64-gnu": "1.2.16", + "@rolldown/binding-linux-s390x-gnu": "1.2.16", + "@rolldown/binding-linux-x64-gnu": "1.2.16", + "@rolldown/binding-linux-x64-musl": "1.2.16", + "@rolldown/binding-openharmony-arm64": "1.2.16", + "@rolldown/binding-win32-arm64-msvc": "1.2.16", + "@rolldown/binding-win32-x64-msvc": "1.2.16" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.3.tgz", + "integrity": "sha512-Al01C2JIBnO2CXCAPbpAl6sUiOZHeThGtHYNTa51JRw9PyWOCdr+9Ong05R4qg/Sk3+H645E8ov1h2c58ityNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", + "integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.30", + "rolldown": "~1.2.14", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1 || ^0.8.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.1.0.tgz", + "integrity": "sha512-MzmNFnrwa8v8lF1bLxw3UJ4EREwVOVb75VqJZmUDRjJ6EluK/x9th7qTbaeDk41WxE1GxWqmTXcdsfobfH5sOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.1.0", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.1.0", + "@vitest/browser-preview": "5.1.0", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.1.0", + "@vitest/coverage-v8": "5.1.0", + "@vitest/ui": "5.1.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..40519c9 --- /dev/null +++ b/package.json @@ -0,0 +1,37 @@ +{ + "name": "flow-reel", + "version": "0.1.0", + "description": "Record narrated, reproducible feature demos from an agent-written Playwright script.", + "license": "MIT", + "type": "module", + "bin": { + "flow-reel": "dist/cli.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "docker" + ], + "engines": { + "node": ">=22.18" + }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run test/unit", + "test:e2e": "npm run build && vitest run test/e2e --testTimeout=900000 --hookTimeout=900000" + }, + "dependencies": { + "playwright": "1.64.1" + }, + "devDependencies": { + "@types/node": "^26.6.6", + "typescript": "^7.0.2", + "vitest": "^5.1.0" + } +} diff --git a/planning/GH-1-narrated-reproducible-feature-demos/decisions/012-polish-in-this-story.md b/planning/GH-1-narrated-reproducible-feature-demos/decisions/012-polish-in-this-story.md index d19b956..fc4b5f5 100644 --- a/planning/GH-1-narrated-reproducible-feature-demos/decisions/012-polish-in-this-story.md +++ b/planning/GH-1-narrated-reproducible-feature-demos/decisions/012-polish-in-this-story.md @@ -1,5 +1,7 @@ # 012: Polish (cursor, click ripple, zoom on click, follow cursor, SFX) is part of this story +**Partly superseded by GH-2 decision 005** (the master is 1440p at device scale 4/3, not 4K at scale 2). + **Date:** 2026-11-02 (GH-2 ticket pass, Q2; asked: "i need something good, lets a tool that offers SFX , zoom on click, follow cursor etc ....", agreed: "ok") diff --git a/planning/GH-1-narrated-reproducible-feature-demos/follow-ups/2026-11-02_1643_master-4k-option.md b/planning/GH-1-narrated-reproducible-feature-demos/follow-ups/2026-11-02_1643_master-4k-option.md new file mode 100644 index 0000000..2fc5762 --- /dev/null +++ b/planning/GH-1-narrated-reproducible-feature-demos/follow-ups/2026-11-02_1643_master-4k-option.md @@ -0,0 +1,13 @@ +--- +status: open +from: person +found-while: GH-2 +resolved-by: +--- + +# Offer a 4K master on machines that can encode it in real time + +GH-2 decision 005 set the master to 1440p because 4K x264 can't keep up on a 4-core CPU while Chromium renders +(0.63× real time). A `--master 4k` option, and later a GPU encoder (VAAPI, NVENC) choosing 4K by default when present, +would give sharp 2× zooms (GH-7) where the hardware allows. The runner's real-time check (master no more than 3 % +shorter than wall-clock) already guards a machine that can't keep up. Best picked up with or after GH-7. diff --git a/planning/GH-1-narrated-reproducible-feature-demos/tickets/GH-2-silent-replay-recording-sandbox/decisions/001-xvfb-ffmpeg-recorder.md b/planning/GH-1-narrated-reproducible-feature-demos/tickets/GH-2-silent-replay-recording-sandbox/decisions/001-xvfb-ffmpeg-recorder.md index 790e880..b5fbbea 100644 --- a/planning/GH-1-narrated-reproducible-feature-demos/tickets/GH-2-silent-replay-recording-sandbox/decisions/001-xvfb-ffmpeg-recorder.md +++ b/planning/GH-1-narrated-reproducible-feature-demos/tickets/GH-2-silent-replay-recording-sandbox/decisions/001-xvfb-ffmpeg-recorder.md @@ -1,5 +1,7 @@ # 001: Record with Xvfb + ffmpeg x11grab into a 4K near-lossless master +**Superseded by 005** (resolution and encoder settings). + **Date:** 2026-11-02 (ticket pass, Q1; agreed: "ok") **Question:** which recorder captures the video, given that polish (zoom, follow cursor) is rendered in post diff --git a/planning/GH-1-narrated-reproducible-feature-demos/tickets/GH-2-silent-replay-recording-sandbox/decisions/005-1440p-master.md b/planning/GH-1-narrated-reproducible-feature-demos/tickets/GH-2-silent-replay-recording-sandbox/decisions/005-1440p-master.md new file mode 100644 index 0000000..02cfea3 --- /dev/null +++ b/planning/GH-1-narrated-reproducible-feature-demos/tickets/GH-2-silent-replay-recording-sandbox/decisions/005-1440p-master.md @@ -0,0 +1,28 @@ +# 005: The master is 1440p (device scale 4/3), x264 ultrafast CRF 10 + +**Date:** 2026-11-02 (building, measured on the e2e run; agreed: "ok") + +**Supersedes 001** (its 4K resolution and CRF ~10 `veryfast` settings; Xvfb + ffmpeg `x11grab` and the constant +30 fps stand). + +**Question:** the 4K master can't be encoded in real time while Chromium renders 4K in software: the capture falls +behind and the video ends before the demo does. What should the master be? + +**Decision:** **a 2560×1440 master: the CSS viewport stays 1920×1080, drawn at device scale 4/3, captured by +x11grab and encoded with x264 `ultrafast` CRF 10.** The runner fails a recording whose master is more than 3 % shorter +than the wall-clock recording time, so a machine that can't keep up never yields a lagging video silently. + +Measured with Chromium scrolling an animated page, AMD Ryzen 5 3500U (4 cores, 8 threads), in the image: + +| Master | Encoder | Speed | Size | +|---|---|---|---| +| 4K | x264 ultrafast CRF 10 | 0.63× | 9 MB / 6 s | +| 4K | utvideo (lossless) | 0.79× | 260 MB / 6 s | +| 1440p | x264 ultrafast CRF 10 | 0.99× | 19 MB / 6 s | +| 1440p | x264 veryfast CRF 12 | 0.53× | 5 MB / 6 s | + +- **Why:** the only measured setting that keeps up, here and on CI runners of the same size; scripts and + `events.json` stay in 1920×1080 CSS pixels. +- **Not chosen:** a per-machine master with `--master 4k` now (two configurations to test; kept as a follow-up); + recording 4K at half speed (the app's own animations and video would play at double speed). +- **Cost accepted:** zooms stay fully sharp up to 1.33×; a 2× zoom (GH-7) upscales by 1.5×. diff --git a/planning/GH-1-narrated-reproducible-feature-demos/tickets/GH-2-silent-replay-recording-sandbox/decisions/006-intermediates-in-flow-reel-folder.md b/planning/GH-1-narrated-reproducible-feature-demos/tickets/GH-2-silent-replay-recording-sandbox/decisions/006-intermediates-in-flow-reel-folder.md new file mode 100644 index 0000000..0fa1910 --- /dev/null +++ b/planning/GH-1-narrated-reproducible-feature-demos/tickets/GH-2-silent-replay-recording-sandbox/decisions/006-intermediates-in-flow-reel-folder.md @@ -0,0 +1,18 @@ +# 006: Intermediate files go in a self-ignoring `.flow-reel/` folder beside the script + +**Date:** 2026-11-03 (building, raised by a review of the outputs; agreed: "ok, option 1") + +**Question:** a master is large (about 3 MB per second at 1440p) and lands in the app's repository, beside the +script, where it is easy to commit by accident. Where do the outputs go? + +**Decision:** **intermediates go in `.flow-reel//` beside the script; deliverables stay next to the script.** +For `demo.ts`: `.flow-reel/demo/master.mp4` (and, in later tickets, audio clips and renders), while `demo.mp4`, +`demo.transcript.json`, `demo.events.json` and later captions sit beside `demo.ts`. On first use the runner writes +`.flow-reel/.gitignore` holding `*`, so git ignores the folder without touching the app's own `.gitignore`. + +- **Why:** large files are safe by default, and the outputs people want stay where they look. +- **Not chosen:** everything in `.flow-reel/` (the demo video would be hidden and ignored; sharing it means copying + it out); everything beside the script with the agent skill (GH-6) adding an ignore rule (unsafe until GH-6 ships, + and for anyone not using the skill). +- **Cost accepted:** deliverables can still be committed; they are small (a 1080p demo is about 5-15 MB a minute), + so committing them is a choice. diff --git a/planning/GH-1-narrated-reproducible-feature-demos/tickets/GH-2-silent-replay-recording-sandbox/decisions/007-fail-clearly-when-overloaded.md b/planning/GH-1-narrated-reproducible-feature-demos/tickets/GH-2-silent-replay-recording-sandbox/decisions/007-fail-clearly-when-overloaded.md new file mode 100644 index 0000000..e634af2 --- /dev/null +++ b/planning/GH-1-narrated-reproducible-feature-demos/tickets/GH-2-silent-replay-recording-sandbox/decisions/007-fail-clearly-when-overloaded.md @@ -0,0 +1,29 @@ +# 007: A recording on an overloaded machine fails with a clear error; no fallback or retry + +**Date:** 2026-11-09 (building, raised by a review of failed e2e runs; agreed: "ok") + +**Question:** two of three e2e runs failed decision 005's real-time check while other work on the machine held the +load average at 11-13 on 8 threads. Should the runner capture differently, fall back, retry, or fail? + +**Decision:** **keep decision 005's capture and fail, with an error that says how far the capture fell behind and +what to do (close other apps, use a bigger machine).** The GPU encoder in the story's `master-4k-option` follow-up is +the fix for heavy load. + +Measured: CPU used by ffmpeg while recording 6 s of Chromium scrolling an animated page at 1440p, load average 7-9, +AMD Ryzen 5 3500U (4 cores, 8 threads), two rounds each: + +| Master capture | CPU | Disk for 6 s | +|---|---|---| +| x264 ultrafast CRF 10 (decision 005) | 2.27 cores | 20 MB | +| utvideo yuv420p | 1.93 cores | 154 MB | +| utvideo gbrp | 3.08 cores | 292 MB | + +Every setting kept up (6.00 s of video for 6 s). A live capture runs at 1× at most, so decision 005's "0.99×" means +it kept up, not that it was at its limit; the headroom is in the CPU it uses. + +- **Why:** the capture needs about 2.3 cores, which an idle 4-vCPU CI runner has; failures came only when the machine + was overloaded, where any setting drops frames. +- **Not chosen:** capturing with utvideo and compressing afterwards (15 % less CPU for 7-8× the disk and about 7 s of + compressing per 6 s recorded); falling back to a 1080p master after a failure (two configurations, zooms not sharp, + and the time doubles on each failure); retrying once (helps with spikes only). +- **Cost accepted:** on an overloaded machine, recording fails until the load drops. diff --git a/planning/GH-1-narrated-reproducible-feature-demos/tickets/GH-2-silent-replay-recording-sandbox/draft.md b/planning/GH-1-narrated-reproducible-feature-demos/tickets/GH-2-silent-replay-recording-sandbox/draft.md index a3428ee..6a6ec31 100644 --- a/planning/GH-1-narrated-reproducible-feature-demos/tickets/GH-2-silent-replay-recording-sandbox/draft.md +++ b/planning/GH-1-narrated-reproducible-feature-demos/tickets/GH-2-silent-replay-recording-sandbox/draft.md @@ -12,7 +12,8 @@ OBS on a person's desktop. `flow-reel record demo.ts` starts the image with the project mounted; inside, it replays the script and records. - The runner's stabilisation from story decision 009: fixed viewport and device scale, `prefers-reduced-motion`, fixed clock and seed, network-idle wait before each step. -- Recording with Xvfb + ffmpeg into a 4K near-lossless master at 30 fps, plus a silent 1080p `demo.mp4` (decision 001). +- Recording with Xvfb + ffmpeg into a 1440p near-lossless master at 30 fps in a self-ignoring `.flow-reel/` folder, + plus a silent 1080p `demo.mp4` beside the script (decisions 001, 005, 006). - A wrapped `page` that eases the mouse to each target and types visibly, so later polish can draw the cursor (story decision 012, this ticket's decision 002). - `transcript.json` with `video` and each step's `index`, `startMs`, `actionEndMs`, `endMs` (story decision 011), and @@ -27,11 +28,11 @@ Narration, captions, audio (GH-3 to GH-5); drawing the cursor, zoom and SFX (lat ## Done when -- `flow-reel record` on a sample app writes a 3840×2160 30 fps master, a silent 1920×1080 `demo.mp4`, and a - `transcript.json` and `events.json` that validate against version 1. +- `flow-reel record` on a sample app writes a 2560×1440 30 fps master in `.flow-reel/`, a silent 1920×1080 + `demo.mp4`, and a `demo.transcript.json` and `demo.events.json` that validate against version 1. - Two runs of the same script have the same steps, every start within ±150 ms. - A step's logged `startMs` matches the frame where it visibly starts, within ±2 frames. ## Decisions -This ticket's decisions 001-004 (recorder, wrapped page, reaching the host, image). +This ticket's decisions 001-006 (recorder, wrapped page, reaching the host, image, 1440p master, output folder). diff --git a/planning/GH-1-narrated-reproducible-feature-demos/tickets/GH-2-silent-replay-recording-sandbox/history/2026-11-02_1546_meta.md b/planning/GH-1-narrated-reproducible-feature-demos/tickets/GH-2-silent-replay-recording-sandbox/history/2026-11-02_1546_meta.md new file mode 100644 index 0000000..7b0defd --- /dev/null +++ b/planning/GH-1-narrated-reproducible-feature-demos/tickets/GH-2-silent-replay-recording-sandbox/history/2026-11-02_1546_meta.md @@ -0,0 +1,12 @@ +--- +kind: ticket +title: Replay a demo script on a virtual display and write a silent MP4 with step timings +key: GH-2 +parent: GH-1 +status: planned +branch: +tracker-url: https://github.com/codeonym-oss/flow-reel/issues/2 +mr: +created: 2026-11-02 +updated: 2026-11-02 +--- diff --git a/planning/GH-1-narrated-reproducible-feature-demos/tickets/GH-2-silent-replay-recording-sandbox/history/2026-11-03_1532_draft.md b/planning/GH-1-narrated-reproducible-feature-demos/tickets/GH-2-silent-replay-recording-sandbox/history/2026-11-03_1532_draft.md new file mode 100644 index 0000000..a3428ee --- /dev/null +++ b/planning/GH-1-narrated-reproducible-feature-demos/tickets/GH-2-silent-replay-recording-sandbox/history/2026-11-03_1532_draft.md @@ -0,0 +1,37 @@ +# Replay a demo script on a virtual display and write a silent MP4 with step timings + +## Problem + +There is no way to record an agent's demo headlessly and reproducibly: the existing `screen-recording` skill assumes +OBS on a person's desktop. + +## Change + +- The container image: virtual display, Chromium, Playwright, ffmpeg. +- The npm package `flow-reel` with the `demo()` / `step()` API (story decision 010) and the CLI: on the host, + `flow-reel record demo.ts` starts the image with the project mounted; inside, it replays the script and records. +- The runner's stabilisation from story decision 009: fixed viewport and device scale, `prefers-reduced-motion`, fixed + clock and seed, network-idle wait before each step. +- Recording with Xvfb + ffmpeg into a 4K near-lossless master at 30 fps, plus a silent 1080p `demo.mp4` (decision 001). +- A wrapped `page` that eases the mouse to each target and types visibly, so later polish can draw the cursor + (story decision 012, this ticket's decision 002). +- `transcript.json` with `video` and each step's `index`, `startMs`, `actionEndMs`, `endMs` (story decision 011), and + `events.json` with the runner's moves, clicks, typing and scrolls (story decision 013). +- Reaching an app on the host's `localhost` on Linux and Docker Desktop (decision 003); the image tagged with the npm + version, built locally when it can't be pulled (decision 004). + +## Out of scope + +Narration, captions, audio (GH-3 to GH-5); drawing the cursor, zoom and SFX (later tickets); publishing images +(the release ticket). + +## Done when + +- `flow-reel record` on a sample app writes a 3840×2160 30 fps master, a silent 1920×1080 `demo.mp4`, and a + `transcript.json` and `events.json` that validate against version 1. +- Two runs of the same script have the same steps, every start within ±150 ms. +- A step's logged `startMs` matches the frame where it visibly starts, within ±2 frames. + +## Decisions + +This ticket's decisions 001-004 (recorder, wrapped page, reaching the host, image). diff --git a/planning/GH-1-narrated-reproducible-feature-demos/tickets/GH-2-silent-replay-recording-sandbox/history/2026-11-03_1532_impl.md b/planning/GH-1-narrated-reproducible-feature-demos/tickets/GH-2-silent-replay-recording-sandbox/history/2026-11-03_1532_impl.md new file mode 100644 index 0000000..dd9b680 --- /dev/null +++ b/planning/GH-1-narrated-reproducible-feature-demos/tickets/GH-2-silent-replay-recording-sandbox/history/2026-11-03_1532_impl.md @@ -0,0 +1,135 @@ +# Implementation: Replay a demo script on a virtual display and write a silent MP4 with step timings + +Planned 2026-11-02 against an empty repository (no commits): this ticket starts the codebase. Settled by story +decisions 002, 008, 009, 010, 011, 012, 013 and this ticket's 001-004. + +## Read first + +- Story decisions `../../decisions/009-what-counts-as-the-same-video.md`, `010-demo-script-api.md`, + `011-transcript-json-schema.md`, `013-events-json-schema.md`: the contracts this ticket writes. +- This ticket's `decisions/001`-`004`. +- Playwright docs: `page.clock`, `page.mouse.move({ steps })`, `addInitScript`, `chromium.launch({ args })`, Docker + image tags (`mcr.microsoft.com/playwright:v-noble`). +- Node docs: `module.register` (resolve hooks) and type stripping, used to load `demo.ts` without a build step. + +## Layout + +``` +package.json name flow-reel, type module, bin flow-reel, exports "." → dist/index.js +tsconfig.json strict, NodeNext, erasable syntax only (so demo.ts runs with Node type stripping) +src/index.ts public API: demo(), types +src/cli.ts `flow-reel record + + diff --git a/test/e2e/record.test.ts b/test/e2e/record.test.ts new file mode 100644 index 0000000..56f6d2d --- /dev/null +++ b/test/e2e/record.test.ts @@ -0,0 +1,102 @@ +import { execFile, execFileSync } from "node:child_process"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { type Server, createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { type EventLog, validateEvents } from "../../src/schema/events.ts"; +import { type Transcript, validateTranscript } from "../../src/schema/transcript.ts"; + +// The whole recorder, in Docker, against a fixture app served from the host's 127.0.0.1 (GH-2 impl.md step 12). +const OUT = resolve("test/e2e/out"); +const html = readFileSync(resolve("test/e2e/app/index.html")); +let server: Server; +let port: number; + +const script = (p: number) => `import { demo } from "flow-reel"; + +export default demo({ url: "http://localhost:${p}/" }, async ({ page, step }) => { + await step("Type a name.", () => page.getByRole("textbox", { name: "Name" }).fill("Ada")); + await step("Scroll down and save.", () => page.getByRole("button", { name: "Save" }).click()); + await step("Open the settings.", () => page.getByRole("link", { name: "Settings" }).click()); +}); +`; + +interface Run { dir: string; transcript: Transcript; events: EventLog } + +// Async: the fixture server lives in this process, so the event loop must stay free while the recording runs. +async function recordOnce(name: string): Promise { + const dir = join(OUT, name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "demo.ts"), script(port)); + const { stderr } = await promisify(execFile)(process.execPath, ["dist/cli.js", "record", join(dir, "demo.ts")]); + if (stderr) console.error(stderr); + return { + dir, + transcript: JSON.parse(readFileSync(join(dir, "demo.transcript.json"), "utf8")), + events: JSON.parse(readFileSync(join(dir, "demo.events.json"), "utf8")), + }; +} + +function probe(file: string) { + const out = execFileSync("ffprobe", ["-v", "error", "-select_streams", "v:0", "-count_packets", + "-show_entries", "stream=width,height,r_frame_rate,nb_read_packets:format=duration", "-of", "json", file], { encoding: "utf8" }); + const { streams: [s], format } = JSON.parse(out); + return { width: s.width, height: s.height, fps: s.r_frame_rate, frames: Number(s.nb_read_packets), seconds: Number(format.duration) }; +} + +/** The time of the first frame whose top-left corner is magenta: when the marker click painted. */ +function firstMagentaMs(file: string): number { + const raw = execFileSync("ffmpeg", ["-v", "error", "-i", file, "-vf", "crop=64:64:0:0,scale=1:1,format=rgb24", + "-f", "rawvideo", "-"], { maxBuffer: 1 << 26 }); + for (let i = 0; i + 2 < raw.length; i += 3) { + if (raw[i] > 200 && raw[i + 1] < 60 && raw[i + 2] > 200) return ((i / 3) * 1000) / 30; + } + throw new Error("no magenta frame"); +} + +let a: Run; +let b: Run; + +beforeAll(async () => { + rmSync(OUT, { recursive: true, force: true }); + const { version } = JSON.parse(readFileSync("package.json", "utf8")); + execFileSync("docker", ["build", "-q", "-t", `ghcr.io/codeonym-oss/flow-reel:${version}`, "-f", "docker/Dockerfile", "."], { stdio: "inherit" }); + server = createServer((_req, res) => res.writeHead(200, { "content-type": "text/html" }).end(html)); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + port = (server.address() as AddressInfo).port; + a = await recordOnce("a"); + b = await recordOnce("b"); +}, 900_000); + +afterAll(() => server?.close()); + +describe("flow-reel record", () => { + it("writes a 1440p 30 fps master and a 1080p copy, dropping no frames", () => { + const master = probe(join(a.dir, ".flow-reel/demo/master.mp4")); + expect(master).toMatchObject({ width: 2560, height: 1440, fps: "30/1" }); + expect(Math.abs(master.frames - master.seconds * 30)).toBeLessThanOrEqual(master.seconds * 30 * 0.01 + 1); + expect(probe(join(a.dir, "demo.mp4"))).toMatchObject({ width: 1920, height: 1080, fps: "30/1" }); + expect(readFileSync(join(a.dir, ".flow-reel/.gitignore"), "utf8")).toBe("*\n"); + }); + + it("writes a valid transcript and event log", () => { + expect(validateTranscript(a.transcript)).toEqual([]); + expect(validateEvents(a.events)).toEqual([]); + expect(a.transcript.steps).toHaveLength(3); + expect(a.events.events.map((e) => e.type)).toEqual(["move", "click", "type", "scroll", "move", "click", "scroll", "move", "click"]); + }); + + it("gives the same steps on two runs, every start within ±150 ms (story decision 009)", () => { + expect(b.transcript.steps).toHaveLength(a.transcript.steps.length); + a.transcript.steps.forEach((s, i) => { + expect(Math.abs(s.startMs - b.transcript.steps[i].startMs)).toBeLessThanOrEqual(150); + }); + }); + + it("times events on the video's own clock: the marker click shows within 2 frames of its t", () => { + const click = a.events.events.filter((e) => e.type === "click").at(-1)!; + expect(Math.abs(firstMagentaMs(join(a.dir, "demo.mp4")) - click.t)).toBeLessThanOrEqual(2 * 1000 / 30); + }); +}); diff --git a/test/fixtures/demo.ts b/test/fixtures/demo.ts new file mode 100644 index 0000000..827a6d0 --- /dev/null +++ b/test/fixtures/demo.ts @@ -0,0 +1,6 @@ +import { demo } from "flow-reel"; + +export default demo({ url: "http://localhost:4173" }, async ({ page, step }) => { + await step("Open the settings page.", () => page.getByRole("link", { name: "Settings" }).click()); + await step("Turn on dark mode.", () => page.getByLabel("Dark mode").check()); +}); diff --git a/test/unit/api.test.ts b/test/unit/api.test.ts new file mode 100644 index 0000000..fb38d25 --- /dev/null +++ b/test/unit/api.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { demo, type Step } from "../../src/index.ts"; + +describe("demo", () => { + it("returns the options, with the default viewport, and the body, without running it", () => { + let ran = false; + const body = async () => { ran = true; }; + const d = demo({ url: "http://localhost:3000" }, body); + expect(d.options).toEqual({ url: "http://localhost:3000", viewport: [1920, 1080] }); + expect(d.body).toBe(body); + expect(ran).toBe(false); + }); + + it("keeps a viewport given by the script", () => { + expect(demo({ url: "http://x", viewport: [1280, 720] }, async () => {}).options.viewport).toEqual([1280, 720]); + }); + + it("only takes narration text as a step's first argument", () => { + const step: Step = async () => {}; + // @ts-expect-error narration must be a string + void step(42, async () => {}); + expect(true).toBe(true); + }); +}); diff --git a/test/unit/easing.test.ts b/test/unit/easing.test.ts new file mode 100644 index 0000000..158a0f0 --- /dev/null +++ b/test/unit/easing.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { easeInOutCubic, moveDuration, pathPoints } from "../../src/runner/easing.ts"; + +describe("easeInOutCubic", () => { + it("starts at 0, is halfway at the midpoint, ends at 1", () => { + expect(easeInOutCubic(0)).toBe(0); + expect(easeInOutCubic(0.5)).toBe(0.5); + expect(easeInOutCubic(1)).toBe(1); + }); + + it("is slow at the ends: a quarter of the time covers 1/16 of the way", () => { + expect(easeInOutCubic(0.25)).toBeCloseTo(0.0625); + }); +}); + +describe("moveDuration", () => { + it("takes 300 ms for no distance and 700 ms across the whole 1920×1080 viewport", () => { + expect(moveDuration([10, 10], [10, 10])).toBe(300); + expect(moveDuration([0, 0], [1920, 1080])).toBe(700); + }); + + it("never exceeds 700 ms, even beyond the viewport", () => { + expect(moveDuration([0, 0], [5000, 5000])).toBe(700); + }); +}); + +describe("pathPoints", () => { + it("gives one point per frame, ending exactly on the target", () => { + const points = pathPoints([0, 0], [300, 150], 500, 30); + expect(points).toHaveLength(15); + expect(points.at(-1)).toEqual([300, 150]); + }); + + it("only moves forward", () => { + const xs = pathPoints([0, 0], [300, 0], 500, 30).map(([x]) => x); + expect(xs).toEqual([...xs].sort((a, b) => a - b)); + }); +}); diff --git a/test/unit/launch.test.ts b/test/unit/launch.test.ts new file mode 100644 index 0000000..cd3a410 --- /dev/null +++ b/test/unit/launch.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { buildRunArgs, imageFor } from "../../src/host/launch.ts"; + +const base = { image: "ghcr.io/codeonym-oss/flow-reel:0.1.0", workDir: "/home/me/app", script: "demo.ts", uid: 1000, gid: 1000 }; + +describe("imageFor", () => { + it("tags the image with the package version", () => { + expect(imageFor("0.1.0")).toBe("ghcr.io/codeonym-oss/flow-reel:0.1.0"); + }); +}); + +describe("buildRunArgs", () => { + it("Linux Docker: host network, the host's uid, the script's folder at /work", () => { + expect(buildRunArgs({ ...base, engine: "docker", desktop: false })).toEqual([ + "run", "--rm", "--init", "--shm-size=1g", + "-v", "/home/me/app:/work", "-w", "/work", + "--user", "1000:1000", + "--network", "host", + "ghcr.io/codeonym-oss/flow-reel:0.1.0", "record", "/work/demo.ts", + ]); + }); + + it("Docker Desktop: no host network; Chromium maps localhost to the host instead", () => { + const args = buildRunArgs({ ...base, engine: "docker", desktop: true }); + expect(args).not.toContain("host"); + expect(args).toEqual(expect.arrayContaining(["-e", "FLOW_REEL_HOST_MAP=1"])); + }); + + it("Podman: keeps the host's uid through the user namespace instead of --user", () => { + const args = buildRunArgs({ ...base, engine: "podman", desktop: false }); + expect(args).toContain("--userns=keep-id"); + expect(args).not.toContain("--user"); + expect(args).toEqual(expect.arrayContaining(["--network", "host"])); + }); +}); diff --git a/test/unit/load.test.ts b/test/unit/load.test.ts new file mode 100644 index 0000000..a93c288 --- /dev/null +++ b/test/unit/load.test.ts @@ -0,0 +1,35 @@ +import { execFileSync } from "node:child_process"; +import { copyFileSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +// Vitest runs import() through its own module runner, so the loader is exercised in a real Node process. +function loadInNode(script: string): string { + const loader = resolve("src/runner/load.ts"); + const code = `const { loadDemo } = await import(${JSON.stringify(loader)}); +const d = await loadDemo(${JSON.stringify(script)}); +console.log(JSON.stringify({ options: d.options, body: typeof d.body }));`; + try { + return execFileSync(process.execPath, ["--input-type=module", "-e", code], { encoding: "utf8", stdio: "pipe" }).trim(); + } catch (e) { + throw new Error((e as { stderr: string }).stderr); + } +} + +describe("loadDemo", () => { + it("loads a TypeScript script that imports flow-reel from a folder with no node_modules", () => { + const dir = mkdtempSync(join(tmpdir(), "flow-reel-load-")); + copyFileSync("test/fixtures/demo.ts", join(dir, "demo.ts")); + expect(JSON.parse(loadInNode(join(dir, "demo.ts")))).toEqual({ + options: { url: "http://localhost:4173", viewport: [1920, 1080] }, + body: "function", + }); + }); + + it("says what is wrong when the script has no demo() default export", () => { + const dir = mkdtempSync(join(tmpdir(), "flow-reel-load-")); + writeFileSync(join(dir, "demo.ts"), "export const x: number = 1;\n"); + expect(() => loadInNode(join(dir, "demo.ts"))).toThrow(/default export of demo\(options, body\)/); + }); +}); diff --git a/test/unit/network.test.ts b/test/unit/network.test.ts new file mode 100644 index 0000000..9cf1ea5 --- /dev/null +++ b/test/unit/network.test.ts @@ -0,0 +1,56 @@ +import { EventEmitter } from "node:events"; +import { describe, expect, it } from "vitest"; +import type { Page } from "playwright"; +import { trackNetwork } from "../../src/runner/network.ts"; + +// A page that only emits request events, at times scheduled on a fake clock that sleep() moves forward. +// Requests are fetches unless a test says otherwise; a finished or failed event settles the first open one. +const fetch = { resourceType: () => "fetch" }; + +function fakePage(schedule: [at: number, event: "request" | "requestfinished" | "requestfailed"][]) { + const page = new EventEmitter(); + let now = 0; + const sleep = async (ms: number) => { + const until = now + ms; + for (const [at, event] of schedule.filter(([at]) => at > now && at <= until)) { + now = at; + page.emit(event, fetch); + } + now = until; + }; + return { page, clock: { now: () => now }, sleep }; +} + +describe("trackNetwork", () => { + it("is idle at once when nothing is loading", async () => { + const { page, clock, sleep } = fakePage([]); + const network = trackNetwork(page as unknown as Page, { clock, sleep }); + expect(await network.idle()).toBe(true); + expect(clock.now()).toBe(0); + }); + + it("waits for requests a step started, then 500 ms of quiet", async () => { + const { page, clock, sleep } = fakePage([[300, "requestfinished"], [400, "request"], [900, "requestfailed"]]); + const network = trackNetwork(page as unknown as Page, { clock, sleep }); + page.emit("request", fetch); + expect(await network.idle()).toBe(true); + expect(clock.now()).toBeGreaterThanOrEqual(1400); + expect(clock.now()).toBeLessThan(1500); + }); + + it("gives up after 10 s of activity and says so", async () => { + const { page, clock, sleep } = fakePage([]); + const network = trackNetwork(page as unknown as Page, { clock, sleep }); + page.emit("request", fetch); + expect(await network.idle()).toBe(false); + expect(clock.now()).toBeGreaterThanOrEqual(10_000); + }); + + it("ignores server-sent event streams, which stay open for the life of the page", async () => { + const { page, clock, sleep } = fakePage([]); + const network = trackNetwork(page as unknown as Page, { clock, sleep }); + page.emit("request", { resourceType: () => "eventsource" }); + expect(await network.idle()).toBe(true); + expect(clock.now()).toBe(0); + }); +}); diff --git a/test/unit/schema.test.ts b/test/unit/schema.test.ts new file mode 100644 index 0000000..10bfc80 --- /dev/null +++ b/test/unit/schema.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { validateEvents } from "../../src/schema/events.ts"; +import { validateTranscript } from "../../src/schema/transcript.ts"; + +// The examples of story decisions 011 and 013, verbatim. +const transcript = { + version: 1, + script: "demo.ts", + video: { file: "demo.mp4", width: 1920, height: 1080, fps: 30, durationMs: 14820 }, + voice: { adapter: "kokoro", voice: "af_heart", seed: 0 }, + steps: [ + { + index: 0, narration: "Open the settings page.", + startMs: 0, actionEndMs: 820, endMs: 2410, + audio: { file: "audio/000.wav", durationMs: 2310, sha256: "…" }, + }, + ], +}; + +const events = { + version: 1, + events: [ + { t: 1200, type: "move", from: [960, 540], to: [1412, 288], durationMs: 450, easing: "easeInOutCubic" }, + { t: 1650, type: "click", at: [1412, 288], button: "left", target: 'link "Settings"' }, + { t: 2400, type: "type", text: "hello", charDelayMs: 60, target: 'textbox "Name"' }, + { t: 3100, type: "scroll", delta: [0, 600], durationMs: 400 }, + ], +}; + +describe("validateTranscript", () => { + it("accepts the decision 011 example", () => { + expect(validateTranscript(transcript)).toEqual([]); + }); + + it("accepts what GH-2 writes: no voice, no narration, no audio", () => { + expect(validateTranscript({ + version: 1, script: "demo.ts", + video: { file: "demo.mp4", width: 1920, height: 1080, fps: 30, durationMs: 900 }, + steps: [{ index: 0, startMs: 0, actionEndMs: 820, endMs: 820 }], + })).toEqual([]); + }); + + it("rejects a missing version", () => { + const { version: _, ...rest } = transcript; + expect(validateTranscript(rest)).toContain("version must be 1"); + }); + + it("rejects a step that ends before it starts", () => { + const bad = { ...transcript, steps: [{ ...transcript.steps[0], startMs: 500, actionEndMs: 400, endMs: 400 }] }; + expect(validateTranscript(bad)).toContain("steps[0]: actionEndMs must be >= startMs"); + }); +}); + +describe("validateEvents", () => { + it("accepts the decision 013 example", () => { + expect(validateEvents(events)).toEqual([]); + }); + + it("rejects a negative t", () => { + const bad = { version: 1, events: [{ ...events.events[1], t: -5 }] }; + expect(validateEvents(bad)).toContain("events[0]: t must be a number >= 0"); + }); + + it("rejects an unknown event type", () => { + const bad = { version: 1, events: [{ t: 0, type: "teleport" }] }; + expect(validateEvents(bad)).toContain('events[0]: unknown type "teleport"'); + }); +}); diff --git a/test/unit/wrap.test.ts b/test/unit/wrap.test.ts new file mode 100644 index 0000000..4e383fc --- /dev/null +++ b/test/unit/wrap.test.ts @@ -0,0 +1,153 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import type { Page } from "playwright"; +import type { RunnerEvent } from "../../src/schema/events.ts"; +import { wrapPage } from "../../src/runner/wrap.ts"; + +// A fake of the Playwright surface the wrapper touches: the page, its mouse and locators. Every call lands in `calls`. +interface Box { x: number; y: number; width: number; height: number } + +function fakePage(boxes: Record, boxLatencyMs = 0, clickLatencyMs = 0) { + const calls: string[] = []; + let scrollY = 0; + const locator = (name: string): Record => ({ + toString: () => name, + boundingBox: async () => { + now += boxLatencyMs; + const b = boxes[name]; + return b && { ...b, y: b.y - scrollY }; + }, + click: async () => { now += clickLatencyMs; calls.push(`click ${name}`); }, + hover: async () => void calls.push(`hover ${name}`), + fill: async (v: string) => void calls.push(`fill ${name} "${v}"`), + pressSequentially: async (v: string, o: { delay: number }) => + void calls.push(`pressSequentially ${name} "${v}" ${o.delay}`), + first: () => locator(`${name}.first()`), + dispatchEvent: async () => void calls.push(`dispatchEvent ${name}`), + }); + boxes["link Settings.first()"] = boxes["link Settings"]; + const page = { + viewportSize: () => ({ width: 1920, height: 1080 }), + getByRole: (role: string, o: { name: string }) => locator(`${role} ${o.name}`), + locator: (selector: string) => locator(selector), + evaluate: async () => { calls.push("evaluate"); return 42; }, + mouse: { + move: async (x: number, y: number) => void calls.push(`move ${Math.round(x)},${Math.round(y)}`), + wheel: async (_dx: number, dy: number) => { scrollY += dy; calls.push(`wheel ${Math.round(dy)}`); }, + }, + }; + return { page: page as unknown as Page, calls }; +} + +let now = 0; // The fake clock; sleep() and the fake page's latency move it. +const clock = { now: () => now }; +const sleep = async (ms: number) => { now += ms; }; + +let events: RunnerEvent[]; +let warnings: string[]; +beforeEach(() => { now = 0; events = []; warnings = []; }); + +const wrap = (page: Page) => wrapPage(page, { clock, events, sleep, warn: (m) => warnings.push(m) }); + +describe("wrapPage", () => { + it("eases the mouse to a target's centre, then clicks it, and logs both", async () => { + const { page, calls } = fakePage({ "link Settings": { x: 1400, y: 280, width: 24, height: 16 } }); + await wrap(page).getByRole("link", { name: "Settings" }).click(); + + const moves = calls.filter((c) => c.startsWith("move")); + expect(moves.length).toBeGreaterThan(5); + expect(moves.at(-1)).toBe("move 1412,288"); + expect(calls.at(-1)).toBe("click link Settings"); + // Locating takes its 150 ms budget; the 517.5 px move lasts 394 ms, plus its 30 ms tail. + expect(events).toEqual([ + { t: 150, type: "move", from: [960, 540], to: [1412, 288], durationMs: 394, easing: "easeInOutCubic" }, + { t: 574, type: "click", at: [1412, 288], button: "left", target: "link Settings" }, + ]); + }); + + it("logs a click when Playwright's click returns, after its checks, not when it is called", async () => { + const { page } = fakePage({ "link Settings": { x: 1400, y: 280, width: 24, height: 16 } }, 0, 90); + await wrap(page).getByRole("link", { name: "Settings" }).click(); + expect(events.at(-1)).toMatchObject({ t: 664, type: "click" }); + }); + + it("starts the next move where the last one ended", async () => { + const { page } = fakePage({ "link A": { x: 90, y: 90, width: 20, height: 20 }, "link B": { x: 190, y: 90, width: 20, height: 20 } }); + const p = wrap(page); + await p.getByRole("link", { name: "A" }).hover(); + await p.getByRole("link", { name: "B" }).hover(); + const moves = events.filter((e) => e.type === "move"); + expect(moves[1]).toMatchObject({ from: [100, 100], to: [200, 100] }); + }); + + it("types visibly: clicks the field, clears it, then types 60 ms per character", async () => { + const { page, calls } = fakePage({ "textbox Name": { x: 100, y: 100, width: 200, height: 30 } }); + await wrap(page).getByRole("textbox", { name: "Name" }).fill("hello"); + expect(calls.slice(-3)).toEqual([ + "click textbox Name", + 'fill textbox Name ""', + 'pressSequentially textbox Name "hello" 60', + ]); + expect(events.at(-1)).toMatchObject({ type: "type", text: "hello", charDelayMs: 60, target: "textbox Name" }); + }); + + it("wraps chained locators too", async () => { + const { page, calls } = fakePage({ "link Settings": { x: 1400, y: 280, width: 24, height: 16 } }); + await wrap(page).getByRole("link", { name: "Settings" }).first().click(); + expect(calls.at(-1)).toBe("click link Settings.first()"); + expect(events.map((e) => e.type)).toEqual(["move", "click"]); + }); + + it("scrolls an off-screen target into view with the wheel before moving to it", async () => { + const { page, calls } = fakePage({ "button Save": { x: 900, y: 2500, width: 120, height: 40 } }); + await wrap(page).getByRole("button", { name: "Save" }).click(); + expect(calls.some((c) => c.startsWith("wheel"))).toBe(true); + expect(events.map((e) => e.type)).toEqual(["scroll", "move", "click"]); + expect(events[0]).toMatchObject({ delta: [0, 1980] }); + expect(calls.filter((c) => c.startsWith("move")).at(-1)).toBe("move 960,540"); + }); + + it("eases page-level actions too, through the selector's locator", async () => { + const { page, calls } = fakePage({ "#save": { x: 1400, y: 280, width: 24, height: 16 } }); + await wrap(page).click("#save"); + expect(calls.at(-1)).toBe("click #save"); + expect(events.map((e) => e.type)).toEqual(["move", "click"]); + expect(warnings).toEqual([]); + }); + + it("logs page.mouse.wheel calls as scrolls", async () => { + const { page } = fakePage({}); + await wrap(page).mouse.wheel(0, 600); + expect(events).toEqual([{ t: 0, type: "scroll", delta: [0, 600], durationMs: expect.any(Number) }]); + }); + + it("runs calls it doesn't cover unchanged, and warns once per method", async () => { + const { page, calls } = fakePage({ "link Settings": { x: 0, y: 0, width: 1, height: 1 } }); + const p = wrap(page); + expect(await p.evaluate(() => 1)).toBe(42); + await p.evaluate(() => 1); + await p.getByRole("link", { name: "Settings" }).dispatchEvent("click"); + expect(calls).toEqual(["evaluate", "evaluate", "dispatchEvent link Settings"]); + expect(warnings).toEqual([ + "page.evaluate() runs without cursor motion", + "locator.dispatchEvent() runs without cursor motion", + ]); + expect(events).toEqual([]); + }); + + it("keeps events on the same times however long Playwright's round-trips take", async () => { + const times = []; + for (const latency of [10, 80]) { + now = 0; + events = []; + const { page } = fakePage({ + "button Save": { x: 900, y: 2500, width: 120, height: 40 }, + "textbox Name": { x: 100, y: 100, width: 200, height: 30 }, + }, latency); + const p = wrap(page); + await p.getByRole("button", { name: "Save" }).click(); + await p.getByRole("textbox", { name: "Name" }).fill("Ada"); + times.push({ events: events.map((e) => e.t), end: now }); + } + expect(times[1]).toEqual(times[0]); + }); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..d4d1771 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "rootDir": "src", "noEmit": false }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4fafce7 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "declaration": true, + "outDir": "dist", + "rootDir": ".", + "skipLibCheck": true, + "types": [ + "node" + ], + "paths": { + "flow-reel": [ + "./src/index.ts" + ] + } + }, + "include": [ + "src", + "test" + ] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..8a37a91 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { testTimeout: 10_000 }, +});