diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..5fff064 --- /dev/null +++ b/.npmrc @@ -0,0 +1,11 @@ +# .npmrc — supply-chain hardening +# Required by BrowserStack Supply Chain Security audit (PER-10291 / SC-12282, Enigma). +# Tech spec: ENG > Supply Chain Security Enhancements. + +ignore-scripts=true # block malicious dependency lifecycle scripts +strict-ssl=true # enforce TLS to the registry +save-exact=true # pin exact versions (no caret ranges) +audit-level=high # fail on high+ severity advisories +engine-strict=true # refuse incompatible Node engines +legacy-peer-deps=false # preserve npm 7+ peer-dependency resolution +access=restricted # private-package default (private repo) diff --git a/README.md b/README.md index 171e0f7..97664c5 100644 --- a/README.md +++ b/README.md @@ -1,80 +1,182 @@ # percy-feature-testbed -One command that populates a Percy environment with **real renderer-backed** builds -exercising **every Percy feature** — for manual QA. A human then logs into the -pre-populated accounts and inspects each feature in the dashboard. +One command that seeds a Percy environment with **real builds of every kind** — +baseline / changed / new / removed, visual-git / A-B, recurring diff, AI, regions, +approval automation — so QA can log in and inspect each feature in the dashboard. +The flow, proven end-to-end against Percy: **create a project → fetch its token → +create builds** — `percy snapshot` serves generated fixture pages and renders them in a +real browser, so builds carry actual DOM (headings, tables, carousel, banner, ad slot) +rather than flat colour blocks. + +--- + +## Prerequisites + +- **Node ≥ 18.19** and npm. +- A **BrowserStack username + access key** for the account whose Percy org you want to + seed (this is the Basic-auth credential Percy's API uses to create projects — there is + no bearer "user token" for project creation). +- A **non-production** target Percy environment (`local` / `staging` / `canary`). The CLI + **refuses production by design** — to seed a prod project, use the manual recipe below. + +```bash +npm install ``` -seed-testbed --profile [--only ] + +--- + +## Run it (the CLI) + +```bash +# 1. Make a profile for your target env (copy the example, keep secrets out of it) +cp profiles/canary.example.js profiles/canary.js # edit baseUrl if needed + +# 2. Export the credentials the profile references (never commit these) +export BROWSERSTACK_USERNAME= +export BROWSERSTACK_ACCESS_KEY= + +# 3. Run — full pass: a project per account tier, each with the build-type spread +npm run cli -- --profile canary +# or, after `npm run build`: seed-testbed --profile canary ``` -- Full pass (default): creates orgs/projects and builds across the account matrix - (free / paid / ent_global / ent_team / ai_off) for Web + App Percy, covering core - review states, visual-git / A-B, recurring diff, AI, approval automation, and regions. -- `--only `: a **coarse** filter for cheap retry/debug — one feature or - one tier. (It is *not* a per-scenario catalog.) +On completion it prints a **run summary**: each build labeled with the feature it +demonstrates + a dashboard deep-link. -**Design & plan:** `../docs/plans/2026-07-27-001-feat-percy-feature-testbed-plan.md` -(origin brainstorm: `../docs/brainstorms/2026-07-27-percy-feature-testbed-requirements.md`). +### Scope it down with `--only` / `--tier` + +```bash +seed-testbed --profile canary --only paid # all features, just the "paid" tier +seed-testbed --profile canary --only recurring-diff # one feature, across all tiers +seed-testbed --profile canary --only visual-git --tier paid # one feature, one project +``` -> **Status: scaffold only (plan Unit 1).** The CLI parses and routes; the orchestrator -> and generators are not built yet. This is **not** a thin wrapper over proven parts — -> the capture path is an unrun upstream scaffold, auto-approve is net-new, and the -> shared-env credential/provisioning path is unvalidated. A **Phase-0 validation** -> (plan Unit 0) must precede the capture units, and it needs live credentials -> (see below). +- **Features:** `core`, `visual-git`, `recurring-diff`, `ai`, `approval`, `regions`, `app-percy` +- **Tiers:** `free`, `paid`, `ent_global`, `ent_team`, `ai_off` -## `--only` keys +### Per-feature shortcuts -| Features | Tiers | +Each feature is a one-liner that seeds just that suite's builds in a **single project**: + +| Command | Seeds | |---|---| -| `core`, `visual-git`, `recurring-diff`, `ai`, `approval`, `regions`, `app-percy` | `free`, `paid`, `ent_global`, `ent_team`, `ai_off` | +| `npm run core` | new / changed / unchanged / removed | +| `npm run ab` | **A/B** variant comparison (visual-git / target-branch) | +| `npm run recurring-diff` | recurring diff | +| `npm run ai` | AI review over the upstream `test_bed/ai` pages | +| `npm run regions` | `ignore` + `layout` region rules, each with a `standard` control | +| `npm run intelli-ignore` | IntelliIgnore + sensitivity sweep, with a `standard` control | +| `npm run approval` | auto-finalization, supersede, auto-approve | +| `npm run app` | App Percy *(not yet wired)* | +| `npm run seed:all` | the full matrix (all tiers × all features) | + +Each defaults to `--profile canary --tier paid`; override with env vars: + +```bash +PROFILE=staging TIER=free npm run ai +``` -## Depends on upstreams (not vendored) +(You still need a `profiles/.js` file and the creds exported — see above.) -The testbed **shells out to** its upstream repos in place — it never copies them, so -nothing drifts: +--- -- `percy-api-seed-accounts` — the account rake + `capture.js` / `app_capture.py`. -- `BStackAutomation-vra/percy/percy_playwright` — snapshot YAML + build helpers. +## What gets created -By default these are resolved as **siblings** of this repo (the `~/Desktop/percy` hub). -Override for non-default checkouts: +| Build type | How it's produced | +|---|---| +| **baseline / unchanged / changed / new / removed** | `percy snapshot` of a generated fixture set; adding/removing a page produces NEW/REMOVED, and `changed` moves the Growth plan price ($99 -> $129) against an approved `master` baseline | +| **visual-git / A-B** | two branches + `PERCY_TARGET_BRANCH` (variant-A baseline vs variant-B head) | +| **recurring diff** | two consecutive changed builds vs the master baseline | +| **approval** | auto-finalization, supersede (same branch + `skipCache`), and auto-approve (sets a branch rule via the project API) | +| **ai** | renders the upstream `percy_playwright/test_bed/ai` pages in place (8 baseline/changed pairs + reduce-diff) — real content for the classifier to work on | +| **regions / intelli-ignore** | region rules (`standard` / `layout` / `ignore` / `intelliignore`) attached per snapshot via a generated Percy config; each rule build is paired with a `standard` **control** over the same fixture pair | -- `PERCY_TESTBED_SEED_ACCOUNTS_DIR` -- `PERCY_TESTBED_PERCY_PLAYWRIGHT_DIR` +Every capture always sets `PERCY_CLIENT_API_URL` to the target env — otherwise the Percy +CLI defaults to `api.percy.io` (prod) and builds land there silently. -See `src/upstream.ts`. +--- -## Profiles & secrets +## How it works -Each environment is a pluggable profile under `profiles/`. Copy an `*.example.js` -template to `profiles/.js` and fill in **secret references only** (the names of -env vars that carry each secret) — never inline tokens/passwords/cookies. Real -`profiles/*.js` files are gitignored; only the `*.example.js` templates are tracked. +``` +seed-testbed --profile + └─ for each account tier: + create project → POST /api/v1/projects (Basic auth = BS user:key) + fetch tokens → GET /api/v1/projects//tokens (write_only + read_only) + for each feature: + render fixture pages → `npx @percy/cli snapshot --config ` + poll the build to finished (read token), approve the baseline where needed + └─ print a feature-labeled run summary +``` -- Runs only against a **non-prod allow-list** (`local` / `staging` / `canary`); - preprod is gated and prod is never a valid target. -- The loader always injects `PERCY_CLIENT_API_URL = /api/v1` on captures - (unset ⇒ builds silently land on prod). -- Project mutations, baseline approval, and region/AI ops require a **user-level - principal**, not a project token; polling uses a read token. App Percy needs - valid **prod-hub** BrowserStack creds + a pre-uploaded `BS_APP_ID`. +--- -## Credentials needed before capture (Phase-0 onward) +## Running against production (manual) -The scaffold needs nothing. Unit 0 (validation) and every capture unit need Percy + -BrowserStack tokens configured in your shell (`/stack:percy-token-setup`, then restart -the shell so the exports load), plus access to the target env's renderer. +The CLI won't target prod. If you deliberately want to seed a **prod** project (it's real +customer-facing data — create a clearly-named project and archive it after), run the same +primitives directly: -## Development +```bash +U=; K= +# 1) create a project (org inferred from the creds) +FS=$(curl -sS -X POST https://percy.io/api/v1/projects -u "$U:$K" \ + -H 'Content-Type: application/vnd.api+json' \ + -d '{"data":{"type":"projects","attributes":{"name":"my-testbed","type":"web"}}}' \ + | python3 -c "import sys,json;print(json.load(sys.stdin)['data']['attributes']['full-slug'])") + +# 2) fetch the write token +WT=$(curl -sS "https://percy.io/api/v1/projects/$FS/tokens" -u "$U:$K" \ + | python3 -c "import sys,json;print(next(t['attributes']['token'] for t in json.load(sys.stdin)['data'] if t['attributes']['role']=='write_only'))") + +# 3) upload images in ./snapshots as a build (add PERCY_TARGET_BRANCH=master for a diff) +PERCY_TOKEN="$WT" PERCY_CLIENT_API_URL=https://percy.io/api/v1 PERCY_BRANCH=master \ + npx -y @percy/cli upload ./snapshots ``` -npm install -npm test # node --test via tsx -npm run typecheck -npm run cli -- --profile canary --only regions # prints what it *would* run -npm run build # emit dist/ (bin: seed-testbed) + +--- + +## Profiles & secrets + +- Each env is a profile under `profiles/`. Copy an `*.example.js` to `profiles/.js`; + fill in **secret references** (env-var names) only — never inline tokens/keys. Real + `profiles/*.js` are gitignored; only `*.example.js` templates are tracked. +- The loader always derives `PERCY_CLIENT_API_URL = /api/v1` and enforces the + **non-prod allow-list** (prod refused; preprod gated). + +--- + +## Limitations (known) + +- **Region-rule outcomes are not yet verified.** The fixtures render, the config is + accepted, and `algorithm` demonstrably changes results (the same layout-shift pair gives + 0 diffs under `standard` and 4 under `layout`). But an `ignore` region on the carousel did + **not** reduce the build's diff count, and IntelliIgnore did not measurably suppress the + noise zones. `total-comparisons-diff` is per comparison, not per region, so it cannot tell + you whether a rule fired — verifying these properly needs per-region comparison data. + Treat the current expectations in the run summary as *intended*, not *confirmed*. +- **AI** builds now use real pages, but AI output quality is not asserted (and the + requirements doc exempts AI from determinism). +- **App Percy (mobile)** isn't wired to a working capture yet (needs BrowserStack app + + a pre-uploaded `BS_APP_ID`). +- **Fixtures are a fixed 1280px layout**, so snapshots are pinned to that width. At Percy's + default 375px the price table falls outside the render and a real change produces no diff. +- **`npm run build` output is not runnable** — `tsc` emits extensionless ESM imports, so + `dist/main.js` (the declared `bin`) fails to resolve. Everything runs through `tsx`. +- **Delete:** the public API has no project delete — use `PATCH is-enabled=false` to archive + via API, or the Project Settings UI for a permanent delete. + +--- + +## Development + +```bash +npm test # 80 unit tests (mocked HTTP + shell — nothing hits the network) +npm run typecheck # tsc --noEmit +npm run build # emit dist/ (bin: seed-testbed) ``` -Requires Node ≥ 20. +Design docs (in the percy hub): `docs/brainstorms/2026-07-27-percy-feature-testbed-requirements.md` +and `docs/plans/2026-07-27-001-feat-percy-feature-testbed-plan.md`. diff --git a/package-lock.json b/package-lock.json index 32955e9..2d0c2c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "percy-feature-testbed", "version": "0.0.1", "license": "UNLICENSED", + "dependencies": { + "@percy/cli": "^1.32.4" + }, "bin": { "seed-testbed": "dist/main.js" }, @@ -17,7 +20,30 @@ "typescript": "^5.4.0" }, "engines": { - "node": ">=20" + "node": ">=18.19" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" } }, "node_modules/@esbuild/aix-ppc64": { @@ -462,112 +488,1931 @@ "node": ">=18" } }, - "node_modules/@types/node": { - "version": "20.19.43", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", - "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", - "dev": true, + "node_modules/@grpc/grpc-js": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" } }, - "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "dev": true, - "hasInstallScript": true, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@percy/cli": { + "version": "1.32.4", + "resolved": "https://registry.npmjs.org/@percy/cli/-/cli-1.32.4.tgz", + "integrity": "sha512-xBj2ksdzhyJkB4pZb8QvYu4h88T2mZHKuG2VyVYlbd9l1Fx0WwHWy4dYmw+38nXyqXTAUZCujJQ19jqHDDad6A==", "license": "MIT", + "dependencies": { + "@percy/cli-app": "1.32.4", + "@percy/cli-build": "1.32.4", + "@percy/cli-command": "1.32.4", + "@percy/cli-config": "1.32.4", + "@percy/cli-doctor": "1.32.4", + "@percy/cli-exec": "1.32.4", + "@percy/cli-snapshot": "1.32.4", + "@percy/cli-upload": "1.32.4", + "@percy/client": "1.32.4", + "@percy/logger": "1.32.4" + }, "bin": { - "esbuild": "bin/esbuild" + "percy": "bin/run.cjs" }, "engines": { - "node": ">=18" + "node": ">=14" + } + }, + "node_modules/@percy/cli-app": { + "version": "1.32.4", + "resolved": "https://registry.npmjs.org/@percy/cli-app/-/cli-app-1.32.4.tgz", + "integrity": "sha512-9z59xE+IbCtEDm028465M8vkpnyPNkBI4yr/8V7yG6mYpr1gEo8xxZjno1KqQLSjEjZn/mqRAsaXfs8wd/3oHw==", + "license": "MIT", + "dependencies": { + "@percy/cli-command": "1.32.4", + "@percy/cli-exec": "1.32.4" }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" + "engines": { + "node": ">=14" } }, - "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, + "node_modules/@percy/cli-build": { + "version": "1.32.4", + "resolved": "https://registry.npmjs.org/@percy/cli-build/-/cli-build-1.32.4.tgz", + "integrity": "sha512-f1dFYhjd7WlrI0NT+zl7i97dzuWMTP6zYI7RpZuS05GNZMhnGzCUYfhhW3NftsRGPHwY9N9kAxX9YLPYl0SmHQ==", "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "@percy/cli-command": "1.32.4" + }, "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=14" } }, - "node_modules/tsx": { - "version": "4.23.1", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", - "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", - "dev": true, + "node_modules/@percy/cli-command": { + "version": "1.32.4", + "resolved": "https://registry.npmjs.org/@percy/cli-command/-/cli-command-1.32.4.tgz", + "integrity": "sha512-zc6Vl8DoF6Bf6+ureM8FOyQoVOhwp6+hPpeOMrJY68XEjUd0yRzmkm/WFmQEdYvzTg2df7V4A2F1N66p7Xc+Rw==", "license": "MIT", "dependencies": { - "esbuild": "~0.28.0" + "@percy/config": "1.32.4", + "@percy/core": "1.32.4", + "@percy/logger": "1.32.4" }, "bin": { - "tsx": "dist/cli.mjs" + "percy-cli-readme": "bin/readme.js" }, "engines": { - "node": ">=18.0.0" + "node": ">=14" + } + }, + "node_modules/@percy/cli-config": { + "version": "1.32.4", + "resolved": "https://registry.npmjs.org/@percy/cli-config/-/cli-config-1.32.4.tgz", + "integrity": "sha512-vcT/P7UN/F2c5UzTG3d+NIH1xeZ0ZvQG2/c1J6A0PBsmE1ePj/Ad5VjzxYH7lvT+ljeWpA6GFpzVm1IxCzcGkA==", + "license": "MIT", + "dependencies": { + "@percy/cli-command": "1.32.4" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/cli-doctor": { + "version": "1.32.4", + "resolved": "https://registry.npmjs.org/@percy/cli-doctor/-/cli-doctor-1.32.4.tgz", + "integrity": "sha512-I7RZeyVmRepwfcLc3j+Wjie54TvEC7C+HD8CEQ1+PxOUZfyrRa69OQSafRJA9vgyGOcnDr2fkDFI4czhKwvafQ==", + "license": "MIT", + "dependencies": { + "@percy/cli-command": "1.32.4", + "@percy/client": "1.32.4", + "@percy/config": "1.32.4", + "@percy/core": "1.32.4", + "@percy/env": "1.32.4", + "@percy/logger": "1.32.4", + "@percy/monitoring": "1.32.4", + "minimatch": "^9.0.0", + "ws": "^8.17.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/cli-exec": { + "version": "1.32.4", + "resolved": "https://registry.npmjs.org/@percy/cli-exec/-/cli-exec-1.32.4.tgz", + "integrity": "sha512-5SH/4bG8YuTLobJ36aExrR4HP+DwPWfHiKohBuTKeW+4VG1v87tdbBVSjMimA1Lj5Np2Ii9iVY+e282PZqj+pg==", + "license": "MIT", + "dependencies": { + "@percy/cli-command": "1.32.4", + "@percy/logger": "1.32.4", + "cross-spawn": "^7.0.3", + "which": "^2.0.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/cli-snapshot": { + "version": "1.32.4", + "resolved": "https://registry.npmjs.org/@percy/cli-snapshot/-/cli-snapshot-1.32.4.tgz", + "integrity": "sha512-5bwwnOdc9LPuFZlwgnmRvPIWl3ROZceKEZw9zgGqkT5i3FllHkSZzu0KtRoP2AVIH5tEn4IxBfz0HKVglRrH+A==", + "license": "MIT", + "dependencies": { + "@percy/cli-command": "1.32.4", + "yaml": "^2.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/cli-upload": { + "version": "1.32.4", + "resolved": "https://registry.npmjs.org/@percy/cli-upload/-/cli-upload-1.32.4.tgz", + "integrity": "sha512-nyzkzSK16WvY6iQUgUEk5h7CH9SgESdXfsyPzqBYBz6gEfkluJrLsADtzRj0oVsao1XItBCNZW3rx/qR/hrcIA==", + "license": "MIT", + "dependencies": { + "@percy/cli-command": "1.32.4", + "fast-glob": "^3.2.11", + "image-size": "~1.0.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/client": { + "version": "1.32.4", + "resolved": "https://registry.npmjs.org/@percy/client/-/client-1.32.4.tgz", + "integrity": "sha512-PfMlG5c0gs9cDVXURmqYSgeNmDcyzrBKcLwTvm8XGrqwaKXmWJndmMq198SF0fwwvZOg/aUFETEcTh66igQeWg==", + "license": "MIT", + "dependencies": { + "@percy/config": "1.32.4", + "@percy/env": "1.32.4", + "@percy/logger": "1.32.4", + "pac-proxy-agent": "^7.0.2", + "pako": "^2.1.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/config": { + "version": "1.32.4", + "resolved": "https://registry.npmjs.org/@percy/config/-/config-1.32.4.tgz", + "integrity": "sha512-vl2CYsBQVq/S8sqBzlugPbZPEFPqdrCtB9TpkHVuYDH/Xpey7pS5tFxGlqgKtmffFL8hSyVJcOn67t/SRwm+xQ==", + "license": "MIT", + "dependencies": { + "@percy/logger": "1.32.4", + "ajv": "^8.6.2", + "cosmiconfig": "^8.0.0", + "yaml": "^2.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/core": { + "version": "1.32.4", + "resolved": "https://registry.npmjs.org/@percy/core/-/core-1.32.4.tgz", + "integrity": "sha512-fLu8Va2IHvzs1qJZaJvJnFGUrFBJio9Wyj7suwKPaLDwD7iwV9DwgJ2kYCpdmubwNwr0vJ9WOJjElxrm4b01BA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@grpc/grpc-js": "^1.14.3", + "@grpc/proto-loader": "^0.8.0", + "@percy/client": "1.32.4", + "@percy/config": "1.32.4", + "@percy/dom": "1.32.4", + "@percy/logger": "1.32.4", + "@percy/monitoring": "1.32.4", + "@percy/webdriver-utils": "1.32.4", + "busboy": "^1.6.0", + "content-disposition": "^0.5.4", + "cross-spawn": "^7.0.3", + "extract-zip": "^2.0.1", + "fast-glob": "^3.2.11", + "fast-xml-parser": "^4.4.1", + "micromatch": "^4.0.8", + "mime-types": "^2.1.34", + "pako": "^2.1.0", + "path-to-regexp": "^6.3.0", + "rimraf": "^3.0.2", + "ws": "^8.17.1", + "yaml": "^2.4.1" + }, + "engines": { + "node": ">=14" }, "optionalDependencies": { - "fsevents": "~2.3.3" + "@percy/cli-doctor": "1.32.4" } }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "node_modules/@percy/dom": { + "version": "1.32.4", + "resolved": "https://registry.npmjs.org/@percy/dom/-/dom-1.32.4.tgz", + "integrity": "sha512-xyaZXETCJKBxSmh9j45B54S6IEFBlQ8sYU3AEGGexKlbeW0SneHhOAP+eoSEEf9w9mGo67umGjK728nr/CkpFw==", + "license": "MIT" + }, + "node_modules/@percy/env": { + "version": "1.32.4", + "resolved": "https://registry.npmjs.org/@percy/env/-/env-1.32.4.tgz", + "integrity": "sha512-O1NPkzPWSYpG01YSgczns+B8ABfN/kEqNOaJyRfmWZUkIlhgiAVneh2U4c/dIin7XoN14s9hk6YmkwqR0Eb8dw==", + "license": "MIT", + "dependencies": { + "@percy/logger": "1.32.4" }, "engines": { - "node": ">=14.17" + "node": ">=14" } }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, + "node_modules/@percy/logger": { + "version": "1.32.4", + "resolved": "https://registry.npmjs.org/@percy/logger/-/logger-1.32.4.tgz", + "integrity": "sha512-pDO93er9jPtjkhputKbO7CniXoVO0fuihj8fMiXP12QqO54k/DyRx4s6VQOL6p1rXZsUqaUYkUIBQeoO4s1AUw==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/monitoring": { + "version": "1.32.4", + "resolved": "https://registry.npmjs.org/@percy/monitoring/-/monitoring-1.32.4.tgz", + "integrity": "sha512-JYGqV1S1Sy49iQMmG6FIhdnRsJQ2D/55UZJffxvo8OYUDDUaVKFImzunkS6OewGtL49QlYaKd9cpIc+bL343fA==", + "license": "MIT", + "dependencies": { + "@percy/config": "1.32.4", + "@percy/logger": "1.32.4", + "@percy/sdk-utils": "1.32.4", + "systeminformation": "^5.25.11" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/sdk-utils": { + "version": "1.32.4", + "resolved": "https://registry.npmjs.org/@percy/sdk-utils/-/sdk-utils-1.32.4.tgz", + "integrity": "sha512-l0mFEsIff0nbA2XSctjAToFHL7DXJ6EPZdIWDCRLrsyATd/SdiPceVpyeV5gqR2AjVbcwdouKBJlJDdFZtUFDA==", + "license": "MIT", + "dependencies": { + "pac-proxy-agent": "^7.0.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@percy/webdriver-utils": { + "version": "1.32.4", + "resolved": "https://registry.npmjs.org/@percy/webdriver-utils/-/webdriver-utils-1.32.4.tgz", + "integrity": "sha512-hntGKk/hnZ9n7UwLoz6Mwgo1nzoUgvgbugMWacL0mWuPSXeL87s+69EUf7jngAd/rYJVxsNTCIOqUl7/Q+Y9ug==", + "license": "MIT", + "dependencies": { + "@percy/config": "1.32.4", + "@percy/sdk-utils": "1.32.4" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" + }, + "node_modules/basic-ftp": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", + "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-xml-parser": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.7.tgz", + "integrity": "sha512-a6Qh1RMCNbSrU1+sAyAAZH3rTe+OaWJbNZIq0S+ifZciUUOQtlVxBJwoTUE2bYhysmG/RYyI5WJFIKdBahJdrQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^1.0.5" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "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" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/image-size": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.0.2.tgz", + "integrity": "sha512-xfOoWjceHntRb3qFCrh5ZFORYH8XCdYpASltMhZ/Q0KZiOwjdE/Yl2QCiWdwD+lygV5bMCvauzgu5PxBX/Yerg==", + "license": "MIT", + "dependencies": { + "queue": "6.0.2" + }, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz", + "integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/netmask": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", + "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "license": "MIT", + "dependencies": { + "inherits": "~2.0.3" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strnum": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz", + "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/systeminformation": { + "version": "5.33.1", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.33.1.tgz", + "integrity": "sha512-DEN6ICHk3Tk0Uf/hrAHh7xlt7iL5CJFBtPZinA0H62DrGG/KPKqq/Nzj6lCXPS4Ay/sf/14zNnk9LpqKzBIc+w==", + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32", + "freebsd", + "openbsd", + "netbsd", + "sunos", + "android" + ], + "bin": { + "systeminformation": "lib/cli.js" + }, + "engines": { + "node": ">=10.0.0" + }, + "funding": { + "type": "Buy me a coffee", + "url": "https://www.buymeacoffee.com/systeminfo" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } } } } diff --git a/package.json b/package.json index cc62e10..df7f7fe 100644 --- a/package.json +++ b/package.json @@ -9,11 +9,23 @@ "scripts": { "build": "tsc", "cli": "tsx src/main.ts", - "test": "node --import tsx --test src/*.test.ts", - "typecheck": "tsc --noEmit" + "core": "tsx src/main.ts --profile ${PROFILE:-canary} --only core --tier ${TIER:-paid}", + "ab": "tsx src/main.ts --profile ${PROFILE:-canary} --only visual-git --tier ${TIER:-paid}", + "recurring-diff": "tsx src/main.ts --profile ${PROFILE:-canary} --only recurring-diff --tier ${TIER:-paid}", + "ai": "tsx src/main.ts --profile ${PROFILE:-canary} --only ai --tier ${TIER:-paid}", + "regions": "tsx src/main.ts --profile ${PROFILE:-canary} --only regions --tier ${TIER:-paid}", + "approval": "tsx src/main.ts --profile ${PROFILE:-canary} --only approval --tier ${TIER:-paid}", + "app": "tsx src/main.ts --profile ${PROFILE:-canary} --only app-percy --tier ${TIER:-paid}", + "seed:all": "tsx src/main.ts --profile ${PROFILE:-canary}", + "test": "find src -name '*.test.ts' -print0 | xargs -0 node --import tsx --test", + "typecheck": "tsc --noEmit", + "intelli-ignore": "tsx src/main.ts --profile ${PROFILE:-canary} --only intelli-ignore --tier ${TIER:-paid}" }, "engines": { - "node": ">=20" + "node": ">=18.19" + }, + "dependencies": { + "@percy/cli": "^1.32.4" }, "devDependencies": { "@types/node": "^20.11.0", diff --git a/src/cli.test.ts b/src/cli.test.ts index 48bf378..e3e3918 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -11,7 +11,7 @@ import { // Happy path: profile with no --only means a full pass. test('parses --profile with no --only as a full pass', () => { const cfg = parseCliArgs(['--profile', 'canary']); - assert.deepEqual(cfg, { profile: 'canary', only: undefined }); + assert.deepEqual(cfg, { profile: 'canary', only: undefined, tier: undefined }); }); // Verification: routes to dispatch without running a real pass. @@ -22,7 +22,7 @@ test('run() routes to the injected dispatch and returns the config', async () => received = c; }, }); - assert.deepEqual(cfg, { profile: 'canary', only: 'regions' }); + assert.deepEqual(cfg, { profile: 'canary', only: 'regions', tier: undefined }); assert.deepEqual(received, cfg); }); @@ -57,3 +57,14 @@ test('accepts a valid feature key and a valid tier key for --only', () => { assert.equal(parseCliArgs(['--profile', 'canary', '--only', 'regions']).only, 'regions'); assert.equal(parseCliArgs(['--profile', 'canary', '--only', 'paid']).only, 'paid'); }); + +// --tier scopes to one account tier and combines with --only. +test('accepts a valid --tier and combines with --only', () => { + const cfg = parseCliArgs(['--profile', 'canary', '--only', 'visual-git', '--tier', 'paid']); + assert.equal(cfg.only, 'visual-git'); + assert.equal(cfg.tier, 'paid'); +}); + +test('rejects an unknown --tier', () => { + assert.throws(() => parseCliArgs(['--profile', 'canary', '--tier', 'bogus']), UsageError); +}); diff --git a/src/cli.ts b/src/cli.ts index 8954c91..ff04863 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -23,7 +23,8 @@ export const FEATURE_KEYS = [ 'recurring-diff', // R11 'ai', // R12 — AI diff / bug-classification / build summary 'approval', // R13 — auto-approve + finalization + supersede - 'regions', // R14 + 'regions', // R14 — ignore / layout region rules, each with a standard control + 'intelli-ignore', // R14b — the intelliignore rule + sensitivity sweep 'app-percy', // R15 — App Percy (mobile) ] as const; @@ -39,10 +40,16 @@ export interface CliConfig { profile: string; /** Optional coarse filter: run a single feature or a single tier. */ only?: OnlyKey; + /** Optional: restrict to a single account tier (one project). Combines with `only`. */ + tier?: TierKey; } export function usage(): string { - return 'Usage: seed-testbed --profile [--only ]'; + return 'Usage: seed-testbed --profile [--only ] [--tier ]'; +} + +function isTier(value: string): value is TierKey { + return (TIER_KEYS as readonly string[]).includes(value); } function isKnownOnly(value: string): value is OnlyKey { @@ -66,6 +73,7 @@ export function parseCliArgs(argv: string[]): CliConfig { options: { profile: { type: 'string' }, only: { type: 'string' }, + tier: { type: 'string' }, }, allowPositionals: false, strict: true, @@ -74,7 +82,7 @@ export function parseCliArgs(argv: string[]): CliConfig { throw new UsageError((err as Error).message); } - const { profile, only } = parsed.values; + const { profile, only, tier } = parsed.values; if (!profile) { throw new UsageError('Missing required --profile .'); @@ -86,8 +94,11 @@ export function parseCliArgs(argv: string[]): CliConfig { `Valid tiers: ${TIER_KEYS.join(', ')}.`, ); } + if (tier !== undefined && !isTier(tier)) { + throw new UsageError(`Unknown --tier "${tier}". Valid tiers: ${TIER_KEYS.join(', ')}.`); + } - return { profile, only: only as OnlyKey | undefined }; + return { profile, only: only as OnlyKey | undefined, tier: tier as TierKey | undefined }; } /** A run dispatcher — injected so the CLI is testable without a real pass. */ diff --git a/src/exec.ts b/src/exec.ts new file mode 100644 index 0000000..42b7bf0 --- /dev/null +++ b/src/exec.ts @@ -0,0 +1,52 @@ +/** + * Injectable shell runner for CLI shell-outs (percy exec/snapshot/app:exec, the + * seed rake). Tests inject a fake so generators are verifiable without running any CLI. + */ +import { spawn } from 'node:child_process'; + +export interface ExecResult { + stdout: string; + stderr: string; + code: number | null; +} + +export interface ExecOptions { + env?: NodeJS.ProcessEnv; + cwd?: string; + timeoutMs?: number; +} + +export type Runner = (command: string, args: string[], opts?: ExecOptions) => Promise; + +export const spawnRunner: Runner = (command, args, opts = {}) => + new Promise((resolve, reject) => { + const child = spawn(command, args, { + env: { ...process.env, ...(opts.env ?? {}) }, + cwd: opts.cwd, + }); + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', (d) => (stdout += d.toString())); + child.stderr?.on('data', (d) => (stderr += d.toString())); + let timer: ReturnType | undefined; + if (opts.timeoutMs) timer = setTimeout(() => child.kill('SIGTERM'), opts.timeoutMs); + child.on('error', (e) => { + if (timer) clearTimeout(timer); + reject(e); + }); + child.on('close', (code) => { + if (timer) clearTimeout(timer); + resolve({ stdout, stderr, code }); + }); + }); + +/** Parse the Percy CLI's `Finalized build #N: /builds/` line. */ +export function parseFinalizedBuild( + logs: string, +): { buildNumber?: number; url?: string; id?: string } | null { + const m = logs.match(/Finalized build #(\d+):\s*(\S+)/i); + if (!m) return null; + const url = m[2]; + const id = url.match(/builds\/(\d+)/)?.[1]; + return { buildNumber: Number(m[1]), url, id }; +} diff --git a/src/fixtures/pages.test.ts b/src/fixtures/pages.test.ts new file mode 100644 index 0000000..9465f50 --- /dev/null +++ b/src/fixtures/pages.test.ts @@ -0,0 +1,66 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { renderStorefront, ZONES } from './pages'; + +/** Marker strings that identify each zone's two states. */ +const NOISE_MARKERS = [ + ['Spring release is live', 'Meet the new dashboard'], // carousel + ['Ship with confidence', 'Scale your test suite'], // ad + ['Scheduled maintenance', 'region rules are now available'], // banner + ['Last updated 09:14', 'Last updated 17:42'], // timestamp +]; +const PRICE_A = '$99'; +const PRICE_B = '$129'; + +test('noise variant changes every noise zone and leaves the price table alone', () => { + const base = renderStorefront('baseline'); + const noise = renderStorefront('noise'); + + for (const [a, b] of NOISE_MARKERS) { + assert.ok(base.includes(a), `baseline has "${a}"`); + assert.ok(noise.includes(b), `noise has "${b}"`); + assert.ok(!noise.includes(a), `noise no longer has "${a}"`); + } + // The signal must be untouched — otherwise the IntelliIgnore scenario is meaningless. + assert.ok(base.includes(PRICE_A) && noise.includes(PRICE_A)); + assert.ok(!noise.includes(PRICE_B)); +}); + +test('signal variant changes only the price table', () => { + const base = renderStorefront('baseline'); + const signal = renderStorefront('signal'); + + assert.ok(signal.includes(PRICE_B), 'the price rise is present'); + assert.ok(!signal.includes(PRICE_A)); + for (const [a] of NOISE_MARKERS) { + assert.ok(signal.includes(a), `noise zone "${a}" is unchanged`); + } + assert.notEqual(base, signal); +}); + +test('noise-signal variant changes both', () => { + const both = renderStorefront('noise-signal'); + assert.ok(both.includes(PRICE_B)); + for (const [, b] of NOISE_MARKERS) assert.ok(both.includes(b)); +}); + +test('layout-shift moves the sidebar without altering any content', () => { + const base = renderStorefront('baseline'); + const shifted = renderStorefront('layout-shift'); + + assert.ok(shifted.includes('margin-top:96px'), 'the sidebar is displaced'); + // Strip the one style difference: everything else must be byte-identical. + assert.equal(shifted.replace(' style="margin-top:96px"', ''), base); +}); + +test('rendering is deterministic, so a re-run reproduces the same pixels', () => { + assert.equal(renderStorefront('noise', 'abc'), renderStorefront('noise', 'abc')); + assert.notEqual(renderStorefront('noise', 'abc'), renderStorefront('noise', 'xyz')); +}); + +test('every zone selector regions target is actually present in the page', () => { + const html = renderStorefront('baseline'); + for (const selector of Object.values(ZONES)) { + assert.ok(html.includes(`id="${selector.slice(1)}"`), `page carries ${selector}`); + } +}); diff --git a/src/fixtures/pages.ts b/src/fixtures/pages.ts new file mode 100644 index 0000000..5923eba --- /dev/null +++ b/src/fixtures/pages.ts @@ -0,0 +1,327 @@ +/** + * Rendered-DOM fixtures (replaces the solid-colour PNG path). + * + * Every page is generated from one source so the variants cannot drift apart, and + * every difference between variants is baked in — no `Date.now()`, no randomness — + * so a re-run reproduces the same pixels and QA can trust a diff to mean something. + * + * Zones exist to match what Percy's region `algorithmConfiguration` actually knows + * about (carouselsEnabled / adsEnabled / bannersEnabled), so an IntelliIgnore build + * has real carousel/ad/banner noise to suppress and a real content change to keep. + */ + +/** Stable selectors regions target. Exported so region config and fixtures can't disagree. */ +export const ZONES = { + carousel: '#promo-carousel', + ad: '#ad-slot', + banner: '#announcement-banner', + timestamp: '#last-updated', + priceTable: '#price-table', + sidebar: '#sidebar', +} as const; + +export type ZoneKey = keyof typeof ZONES; + +/** + * How one page differs from the baseline. + * - `noise` — carousel/ad/banner/timestamp change; the price table does NOT. + * - `signal` — only the price table changes (must survive IntelliIgnore). + * - `noise-signal` — both, so QA sees the rule keep the signal and drop the noise. + * - `layout-shift` — identical content, moved down the page (for the `layout` rule). + */ +export type PageVariant = + | 'baseline' + | 'noise' + | 'signal' + | 'noise-signal' + | 'layout-shift' + /** Only the carousel differs — isolates one zone so an ignore test is decisive. */ + | 'carousel-only' + /** + * Classic visual BUGS, for AI bug classification. Percy marks a region + * `visual_quality: 'irregularity'` with a reason, so the diff has to look like + * something actually broke — not a benign content edit. Five distinct defects, + * one per zone, so a classifier has separate things to name. + */ + | 'visual-bugs'; + +const CSS = ` + * { margin: 0; padding: 0; box-sizing: border-box; } + body { background: #f6f8fa; font-family: -apple-system, "Segoe UI", Arial, sans-serif; color: #14212b; } + .page { width: 1280px; margin: 0 auto; padding: 40px 48px; } + .top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; } + .brand { font-size: 20px; font-weight: 800; color: #6b3df0; letter-spacing: .6px; } + .stamp { font-size: 13px; color: #7a8896; font-variant-numeric: tabular-nums; } + .banner { border-radius: 10px; padding: 14px 18px; font-size: 16px; font-weight: 600; margin-bottom: 20px; } + .layout { display: flex; gap: 24px; align-items: flex-start; } + .main { flex: 1; } + .side { width: 280px; } + .carousel { border-radius: 12px; padding: 28px; color: #fff; margin-bottom: 20px; } + .carousel h2 { font-size: 26px; margin-bottom: 6px; } + .carousel p { font-size: 15px; opacity: .92; } + .dots { margin-top: 16px; display: flex; gap: 7px; } + .dot { width: 8px; height: 8px; border-radius: 50%; background: rgba(255,255,255,.45); } + .dot.on { background: #fff; } + .panel { background: #fff; border: 1px solid #e2e8ef; border-radius: 12px; padding: 24px; margin-bottom: 20px; } + .panel h3 { font-size: 19px; margin-bottom: 14px; } + table { width: 100%; border-collapse: collapse; } + th, td { text-align: left; padding: 11px 8px; font-size: 15px; border-bottom: 1px solid #eef2f6; } + th { color: #6b7a89; font-size: 13px; text-transform: uppercase; letter-spacing: .4px; } + td.num { text-align: right; font-variant-numeric: tabular-nums; font-weight: 600; } + .ad { border: 1px dashed #c9d3de; border-radius: 12px; padding: 22px; text-align: center; } + .ad .tag { font-size: 11px; color: #93a1b0; letter-spacing: 1px; margin-bottom: 10px; } + .ad h4 { font-size: 18px; margin-bottom: 6px; } + .ad p { font-size: 14px; color: #5d6b7a; } + .nonce { position: fixed; top: 3px; left: 3px; font: 11px/11px monospace; color: #cdd4db; } +`; + +interface Carousel { title: string; body: string; color: string; active: number } +interface Ad { tag: string; head: string; body: string } + +const CAROUSEL: Record<'a' | 'b', Carousel> = { + a: { title: 'Spring release is live', body: 'Twelve new integrations, shipped this week.', color: '#3b5bdb', active: 0 }, + b: { title: 'Meet the new dashboard', body: 'Faster filters and saved views for every project.', color: '#0b7285', active: 2 }, +}; + +const AD: Record<'a' | 'b', Ad> = { + a: { tag: 'SPONSORED', head: 'Ship with confidence', body: 'Catch visual bugs before your users do.' }, + b: { tag: 'SPONSORED', head: 'Scale your test suite', body: 'Parallel runs on 3,000+ real devices.' }, +}; + +const BANNER = { + a: { text: 'Scheduled maintenance on Sunday 02:00–04:00 UTC.', bg: '#fff4e5', fg: '#8a5300', bd: '#ffd8a8' }, + b: { text: 'New: region rules are now available on all plans.', bg: '#e7f5ff', fg: '#0b5e8a', bd: '#a5d8ff' }, +}; + +const STAMP = { a: 'Last updated 09:14', b: 'Last updated 17:42' }; + +/** The price table — the SIGNAL. A change here must survive IntelliIgnore. */ +const PRICES = { + a: [ + ['Starter', '5,000', '$29'], + ['Growth', '25,000', '$99'], + ['Scale', '120,000', '$349'], + ], + b: [ + ['Starter', '5,000', '$29'], + ['Growth', '25,000', '$129'], // price rise — the change QA must still see + ['Scale', '120,000', '$349'], + ], +}; + +function esc(s: string): string { + return s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' })[c] as string); +} + +function carouselHtml(c: Carousel): string { + const dots = [0, 1, 2] + .map((i) => ``) + .join(''); + return ``; +} + +function priceTableHtml(rows: string[][]): string { + const body = rows + .map((r) => `${esc(r[0])}${esc(r[1])}${esc(r[2])}`) + .join('\n '); + return `
+

Plans & pricing

+ + + ${body} +
PlanScreenshots / moPrice
+
`; +} + +function adHtml(a: Ad): string { + return `
+
${esc(a.tag)}
+

${esc(a.head)}

+

${esc(a.body)}

+
`; +} + +/** + * The storefront page — the fixture every region rule is demonstrated against. + * `nonce` renders a near-invisible corner marker so a re-run's pixels are unique + * (defeats Percy's image-based auto-approve carry-forward). It is identical in the + * baseline and head of a given run, so it never registers as a diff itself. + */ +export function renderStorefront(variant: PageVariant = 'baseline', nonce = ''): string { + // `carousel-only` changes exactly one zone. That is what makes an ignore/region + // test decisive: with four zones changed, ignoring one still leaves three, the + // snapshot still differs, and the diff count cannot move — so the result is + // inconclusive whether the rule worked or not. Isolating the change means + // `ignore` on that zone should take the build to zero diffs. + const carouselOnly = variant === 'carousel-only'; + const noisy = variant === 'noise' || variant === 'noise-signal' || carouselOnly; + const changed = variant === 'signal' || variant === 'noise-signal'; + const bugged = variant === 'visual-bugs'; + + const car = noisy ? CAROUSEL.b : CAROUSEL.a; + const ad = noisy && !carouselOnly ? AD.b : AD.a; + const ban = noisy && !carouselOnly ? BANNER.b : BANNER.a; + const stamp = noisy && !carouselOnly ? STAMP.b : STAMP.a; + const prices = changed ? PRICES.b : PRICES.a; + + // layout-shift moves the sidebar down without altering a single character of it, + // so the `layout` rule has a pure position change to reason about. + const sideStyle = variant === 'layout-shift' ? ' style="margin-top:96px"' : ''; + + if (bugged) return renderBuggedStorefront(nonce); + + return ` + + + +Storefront + + + +
+
+
PERCY STORE
+
${esc(stamp)}
+
+ + + +
+
+ ${carouselHtml(car)} + ${priceTableHtml(prices)} +
+ +
+
+
${esc(nonce)}
+ +`; +} + +/** + * The same storefront with five deliberate, visually obvious defects — one per zone, + * each a different failure class so bug classification has distinct things to name: + * + * carousel — text unreadable, near-invisible on its background (contrast) + * banner — copy overflows its box and is clipped mid-word + * price tbl — a row's columns misalign and a value overlaps the next cell + * ad slot — image fails to load, leaving a broken placeholder + * sidebar — overlaps the main content instead of sitting beside it + * + * Structure and ids are unchanged from the baseline so the diff is the breakage. + */ +function renderBuggedStorefront(nonce = ''): string { + const c = CAROUSEL.a; + const ban = BANNER.a; + return ` + + + +Storefront + + + +
+
+
PERCY STORE
+
${esc(STAMP.a)}
+
+ + + + +
+
+ + + + +
+

Plans & pricing

+ + + + + + + + + +
PlanScreenshots / moPrice
Starter5,000$29
Growth25,000$99
Scale120,000$349
+
+
+ + + +
+
+
${esc(nonce)}
+ +`; +} + +/** A second, quieter page so builds contain more than one snapshot. */ +export function renderPricing(nonce = ''): string { + return ` + + + +Pricing + + + +
+
PERCY STORE
+ ${priceTableHtml(PRICES.a)} +

Frequently asked

+

Annual billing saves two months on every plan.

+
+
+
${esc(nonce)}
+ +`; +} + +/** An extra page, present only in the `new` set, to produce a NEW snapshot. */ +export function renderAbout(nonce = ''): string { + return ` + + + +About + + + +
+
PERCY STORE
+

About us

+

We have been shipping visual testing since 2015.

+
+
+
${esc(nonce)}
+ +`; +} diff --git a/src/fixtures/rules.test.ts b/src/fixtures/rules.test.ts new file mode 100644 index 0000000..f3cc444 --- /dev/null +++ b/src/fixtures/rules.test.ts @@ -0,0 +1,61 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildPercyConfig, NOISE_ZONES, INTELLI_NOISE_CONFIG, FIXTURE_WIDTH } from './rules'; +import { ZONES } from './pages'; +import { HOME } from './sets'; + +test('no rules produces a config with no snapshot overrides', () => { + assert.deepEqual(buildPercyConfig([]), { version: 2, snapshot: { widths: [FIXTURE_WIDTH] } }); +}); + +test('width is pinned so the fixed-width fixtures fully render', () => { + // At Percy's default 375px the price table falls outside the render and a real + // change produces no diff — the signal vanishes without any error. + const cfg = buildPercyConfig([{ zones: ['carousel'], algorithm: 'ignore' }]) as any; + assert.deepEqual(cfg.snapshot.widths, [FIXTURE_WIDTH]); +}); + +test('a rule becomes one region per zone, scoped to the storefront page', () => { + const cfg = buildPercyConfig([ + { zones: NOISE_ZONES, algorithm: 'intelliignore', configuration: INTELLI_NOISE_CONFIG }, + ]) as any; + + assert.equal(cfg.static.options[0].include, `/${HOME}`); + + const regions = cfg.static.options[0].regions; + assert.equal(regions.length, NOISE_ZONES.length); + assert.deepEqual( + regions.map((r: any) => r.elementSelector.elementCSS), + NOISE_ZONES.map((z) => ZONES[z]), + ); + assert.ok(regions.every((r: any) => r.algorithm === 'intelliignore')); +}); + +test('IntelliIgnore turns on the noise classes it actually knows about', () => { + const cfg = buildPercyConfig([ + { zones: ['carousel'], algorithm: 'intelliignore', configuration: INTELLI_NOISE_CONFIG }, + ]) as any; + const { configuration } = cfg.static.options[0].regions[0]; + + assert.equal(configuration.carouselsEnabled, true); + assert.equal(configuration.bannersEnabled, true); + assert.equal(configuration.adsEnabled, true); +}); + +test('a rule without configuration omits the key rather than sending an empty object', () => { + const cfg = buildPercyConfig([{ zones: ['carousel'], algorithm: 'ignore' }]) as any; + assert.ok(!('configuration' in cfg.static.options[0].regions[0])); +}); + +test('multiple rules coexist in one config', () => { + const cfg = buildPercyConfig([ + { zones: ['carousel'], algorithm: 'ignore' }, + { zones: ['sidebar'], algorithm: 'layout' }, + ]) as any; + + const regions = cfg.static.options[0].regions; + assert.deepEqual( + regions.map((r: any) => r.algorithm), + ['ignore', 'layout'], + ); +}); diff --git a/src/fixtures/rules.ts b/src/fixtures/rules.ts new file mode 100644 index 0000000..1d16dec --- /dev/null +++ b/src/fixtures/rules.ts @@ -0,0 +1,75 @@ +/** + * Region-rule config for a capture. + * + * Percy accepts all four rules declaratively per snapshot — no SDK, no browser driver: + * regions[].algorithm: standard | layout | ignore | intelliignore + * regions[].configuration: diffSensitivity 0-4, imageIgnoreThreshold 0-1, + * carouselsEnabled, bannersEnabled, adsEnabled + * We write these into a generated Percy config consumed via `percy snapshot --config`. + */ +import { ZONES, type ZoneKey } from './pages'; +import { HOME } from './sets'; + +/** The fixtures are authored at this width; snapshots are pinned to it. */ +export const FIXTURE_WIDTH = 1280; + +export type RegionAlgorithm = 'standard' | 'layout' | 'ignore' | 'intelliignore'; + +export interface RuleConfiguration { + diffSensitivity?: number; // 0-4 + imageIgnoreThreshold?: number; // 0-1 + carouselsEnabled?: boolean; + bannersEnabled?: boolean; + adsEnabled?: boolean; +} + +export interface RegionRule { + /** zones this rule applies to, by fixture zone key */ + zones: ZoneKey[]; + algorithm: RegionAlgorithm; + configuration?: RuleConfiguration; +} + +/** The noise zones IntelliIgnore is meant to suppress. */ +export const NOISE_ZONES: ZoneKey[] = ['carousel', 'ad', 'banner', 'timestamp']; + +/** Turn on exactly the noise classes IntelliIgnore knows about. */ +export const INTELLI_NOISE_CONFIG: RuleConfiguration = { + carouselsEnabled: true, + bannersEnabled: true, + adsEnabled: true, +}; + +interface PercyRegion { + elementSelector: { elementCSS: string }; + algorithm: RegionAlgorithm; + configuration?: RuleConfiguration; +} + +/** + * Build the Percy config object for a capture. Rules are scoped to the storefront + * page — the only page carrying the zones — so the other snapshots stay plain + * comparisons and act as a sanity check that the build itself worked. + */ +export function buildPercyConfig(rules: RegionRule[]): Record { + const regions: PercyRegion[] = []; + for (const rule of rules) { + for (const zone of rule.zones) { + regions.push({ + elementSelector: { elementCSS: ZONES[zone] }, + algorithm: rule.algorithm, + ...(rule.configuration ? { configuration: rule.configuration } : {}), + }); + } + } + + // Pin the width. The fixtures are a fixed 1280px layout, so at Percy's default + // 375px the price table and sidebar fall outside the rendered area and a real + // change registers no diff — half the signal silently disappears. One width also + // keeps region selectors mapping to exactly what QA sees. + const config: Record = { version: 2, snapshot: { widths: [FIXTURE_WIDTH] } }; + if (regions.length) { + config.static = { options: [{ include: `/${HOME}`, regions }] }; + } + return config; +} diff --git a/src/fixtures/sets.test.ts b/src/fixtures/sets.test.ts new file mode 100644 index 0000000..8eaa9ee --- /dev/null +++ b/src/fixtures/sets.test.ts @@ -0,0 +1,41 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, readdirSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { writeFixtureSet, HOME, PRICING, ABOUT } from './sets'; + +function seed(kind: Parameters[1]): { dir: string; files: string[] } { + const dir = mkdtempSync(join(tmpdir(), 'set-')); + writeFixtureSet(dir, kind); + return { dir, files: readdirSync(dir).sort() }; +} + +test('the new set adds a page and the removed set drops one', () => { + assert.deepEqual(seed('baseline').files, [HOME, PRICING].sort()); + assert.deepEqual(seed('new').files, [ABOUT, HOME, PRICING].sort()); + assert.deepEqual(seed('removed').files, [HOME]); +}); + +test('filenames are stable across sets so snapshots line up build to build', () => { + for (const kind of ['baseline', 'changed', 'noise', 'noise-signal', 'layout-shift'] as const) { + assert.ok(seed(kind).files.includes(HOME), `${kind} still has ${HOME}`); + } +}); + +test('the set kind selects the storefront variant', () => { + const changed = seed('changed'); + assert.ok(readFileSync(join(changed.dir, HOME), 'utf8').includes('$129')); + + const baseline = seed('baseline'); + assert.ok(readFileSync(join(baseline.dir, HOME), 'utf8').includes('$99')); +}); + +test('unchanged renders identically to baseline', () => { + const a = seed('baseline'); + const b = seed('unchanged'); + assert.equal( + readFileSync(join(a.dir, HOME), 'utf8'), + readFileSync(join(b.dir, HOME), 'utf8'), + ); +}); diff --git a/src/fixtures/sets.ts b/src/fixtures/sets.ts new file mode 100644 index 0000000..33938c0 --- /dev/null +++ b/src/fixtures/sets.ts @@ -0,0 +1,73 @@ +/** + * Fixture sets — which pages exist in a build, and which variant each renders. + * + * `percy snapshot ` turns one file into one snapshot, so adding/removing a file + * is what produces NEW / REMOVED review states, and changing a page's variant is what + * produces CHANGED. Filenames are stable across sets so snapshots line up build to build. + */ +import { writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { renderStorefront, renderPricing, renderAbout, type PageVariant } from './pages'; + +/** + * The review-state sets (`baseline`…`removed`) mirror the old image path, so the core + * generators keep working. The rest exist for the region rules. + */ +export type FixtureKind = + | 'baseline' + | 'unchanged' + | 'changed' + | 'new' + | 'removed' + | 'noise' + | 'noise-signal' + | 'layout-shift' + | 'carousel-only' + | 'visual-bugs'; + +/** Snapshot names, derived from these filenames by the Percy static server. */ +export const HOME = 'home.html'; +export const PRICING = 'pricing.html'; +export const ABOUT = 'about.html'; + +const STOREFRONT_VARIANT: Record = { + baseline: 'baseline', + unchanged: 'baseline', + changed: 'signal', + new: 'baseline', + removed: 'baseline', + noise: 'noise', + 'noise-signal': 'noise-signal', + 'layout-shift': 'layout-shift', + 'carousel-only': 'carousel-only', + 'visual-bugs': 'visual-bugs', +}; + +/** Which files a set contains. `new` gains a page; `removed` drops one. */ +function filesFor(kind: FixtureKind): string[] { + if (kind === 'new') return [HOME, PRICING, ABOUT]; + if (kind === 'removed') return [HOME]; + return [HOME, PRICING]; +} + +/** + * Render a set into `dir`, creating it if needed. Returns the written paths. + * `nonce` is stamped identically on every page of a run so re-runs produce unique + * pixels without registering as a diff within the run. + */ +export function writeFixtureSet(dir: string, kind: FixtureKind, nonce = ''): string[] { + mkdirSync(dir, { recursive: true }); + const variant = STOREFRONT_VARIANT[kind]; + const written: string[] = []; + + for (const file of filesFor(kind)) { + const html = + file === HOME ? renderStorefront(variant, nonce) + : file === PRICING ? renderPricing(nonce) + : renderAbout(nonce); + const path = join(dir, file); + writeFileSync(path, html); + written.push(path); + } + return written; +} diff --git a/src/generators/ai.test.ts b/src/generators/ai.test.ts new file mode 100644 index 0000000..8f61782 --- /dev/null +++ b/src/generators/ai.test.ts @@ -0,0 +1,41 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { generateAI } from './ai'; +import { makeGeneratorContext, makeFakeUpstreamPlaywright } from '../testing/fakes'; + +test('ai: covers all three R12 capabilities, one build per suite', async () => { + const percyPlaywright = makeFakeUpstreamPlaywright(); + const { ctx, runnerCalls } = makeGeneratorContext({ + profile: { upstream: { seedAccounts: '/up/seed', percyPlaywright } }, + }); + + const builds = await generateAI(ctx); + + // bug-classification (in-repo fixtures) + build-summary + ai-details. + assert.equal(builds.length, 3); + assert.ok( + builds.some((b) => /bug classification/i.test(b.label)), + 'all three R12 capabilities are covered', + ); + assert.ok(builds.every((b) => b.requirement === 'R12')); + assert.ok(builds.every((b) => b.expectation), 'every build states what QA should see'); + + assert.ok(runnerCalls.every((c) => c.args.includes('snapshot'))); + + // Each suite gets its own baseline, and its head build targets that baseline — + // asserted by pairing rather than by call index, so adding a suite can't break it. + const heads = runnerCalls.filter((c) => c.env.PERCY_TARGET_BRANCH); + const baselines = runnerCalls.filter((c) => !c.env.PERCY_TARGET_BRANCH).map((c) => c.env.PERCY_BRANCH); + assert.equal(heads.length, 3); + for (const h of heads) { + assert.ok( + baselines.includes(h.env.PERCY_TARGET_BRANCH), + `${h.env.PERCY_BRANCH} targets a baseline this run created`, + ); + } +}); + +test('ai: a missing upstream checkout fails with an actionable message', async () => { + const { ctx } = makeGeneratorContext(); // profile points at /up/pw, which does not exist + await assert.rejects(generateAI(ctx), /PERCY_TESTBED_PERCY_PLAYWRIGHT_DIR/); +}); diff --git a/src/generators/ai.ts b/src/generators/ai.ts new file mode 100644 index 0000000..753b0b8 --- /dev/null +++ b/src/generators/ai.ts @@ -0,0 +1,102 @@ +/** + * AI features (plan Unit 5, R12) — now backed by real rendered pages. + * + * Reuses the curated upstream fixtures in `percy_playwright/test_bed/ai` IN PLACE + * (no vendoring, per the repo's upstream rule): eight realistic baseline/changed + * page pairs for the AI build summary, and a reduce-diff pair for AI diff review. + * Solid-colour blocks gave the classifier nothing to classify; these pages have + * headings, alerts, tables and copy, so an AI summary has real content to describe. + */ +import { join } from 'node:path'; +import { captureWeb, noncedBranch, type GeneratorContext, type GeneratedBuild } from './context'; + +/** Where the upstream AI fixtures live, relative to the percy_playwright checkout. */ +export function aiFixtureDir(percyPlaywrightRoot: string, suite: 'build-summary' | 'ai-details', side: 'baseline' | 'changed'): string { + return join(percyPlaywrightRoot, 'test_bed', 'ai', suite, side); +} + +export async function generateAI(ctx: GeneratorContext): Promise { + const out: GeneratedBuild[] = []; + const root = ctx.profile.upstream.percyPlaywright; + + // AI defaults to OFF on a fresh project; without this nothing is classified and + // `ai-details.total-diffs-reduced` stays null. Dash-cased — Percy answers 200 and + // silently ignores snake_cased keys. + await ctx.projectApi.editProject(ctx.project.slug, { 'ai-enabled': true }); + + const suites: Array<{ + suite: 'build-summary' | 'ai-details'; + key: string; + label: string; + expectation: string; + }> = [ + { + suite: 'build-summary', + key: 'ai-summary', + label: 'AI build summary over 8 realistic page pairs', + expectation: + 'The build summary should describe the changes in words a human recognises (copy edits, figures, status changes) across the eight pages — not just report a diff count.', + }, + { + suite: 'ai-details', + key: 'ai-reduce-diff', + label: 'AI diff review / reduce-diff', + expectation: + 'The AI details panel should appear on the changed comparison and reduce or explain the diff rather than flag the whole page.', + }, + ]; + + // Bug classification (the third R12 capability). Upstream's fixtures point at + // external sites (a random-content joke page and a GitHub Pages demo), which can't + // give a testbed a reproducible diff — so this uses the in-repo `visual-bugs` + // variant instead: five distinct defects Percy should mark as irregularities. + const bugMaster = noncedBranch('ai-bug-classification-master', ctx.nonce); + const bugBaseline = await captureWeb(ctx, { diffMode: 'baseline', branch: bugMaster }); + await ctx.buildApi.waitForBuildFinished(bugBaseline.id, ctx.project.readToken); + await ctx.buildApi.reviewBuild(bugBaseline.id, 'approve'); + + const bugged = await captureWeb(ctx, { + diffMode: 'visual-bugs', + branch: noncedBranch('ai-bug-classification', ctx.nonce), + targetBranch: bugMaster, + }); + await ctx.buildApi.waitForBuildFinished(bugged.id, ctx.project.readToken); + out.push({ + feature: 'ai', + requirement: 'R12', + label: 'AI visual bug classification (5 deliberate defects)', + expectation: + 'Regions should be marked as irregularities with a reason, not just "changed": carousel text unreadable on its background, banner copy clipped mid-word, price-table row misaligned with an overlapping value, ad image failing to load, sidebar overlapping the main column.', + buildId: bugged.id, + buildUrl: bugged.url, + }); + + for (const s of suites) { + const master = noncedBranch(`ai-${s.key}-master`, ctx.nonce); + + const baseline = await captureWeb(ctx, { + branch: master, + sourceDir: aiFixtureDir(root, s.suite, 'baseline'), + }); + await ctx.buildApi.waitForBuildFinished(baseline.id, ctx.project.readToken); + await ctx.buildApi.reviewBuild(baseline.id, 'approve'); + + const changed = await captureWeb(ctx, { + branch: noncedBranch(`ai-${s.key}`, ctx.nonce), + targetBranch: master, + sourceDir: aiFixtureDir(root, s.suite, 'changed'), + }); + await ctx.buildApi.waitForBuildFinished(changed.id, ctx.project.readToken); + + out.push({ + feature: 'ai', + requirement: 'R12', + label: s.label, + expectation: s.expectation, + buildId: changed.id, + buildUrl: changed.url, + }); + } + + return out; +} diff --git a/src/generators/app-percy.test.ts b/src/generators/app-percy.test.ts new file mode 100644 index 0000000..4ef25da --- /dev/null +++ b/src/generators/app-percy.test.ts @@ -0,0 +1,23 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { generateAppPercy } from './app-percy'; +import { makeGeneratorContext, makeProfile } from '../testing/fakes'; + +test('app-percy: baseline + changed via app:exec with prod-hub creds + BS_APP_ID', async () => { + const { ctx, runnerCalls } = makeGeneratorContext(); + const builds = await generateAppPercy(ctx); + assert.equal(builds.length, 2); + assert.ok(builds.every((b) => b.requirement === 'R15')); + assert.equal(runnerCalls[0].command, 'percy'); + assert.deepEqual(runnerCalls[0].args.slice(0, 2), ['app:exec', '--']); + assert.equal(runnerCalls[0].env.BUILD_VARIANT, 'baseline'); + assert.equal(runnerCalls[1].env.BUILD_VARIANT, 'changed'); + assert.equal(runnerCalls[0].env.BS_APP_ID, 'bs://app-hash'); + assert.equal(runnerCalls[0].env.PERCY_CLIENT_API_URL, 'https://canary.percy.io/api/v1'); +}); + +test('app-percy: missing creds/binary throws (skip-with-reason at the gate)', async () => { + const { ctx } = makeGeneratorContext(); + const stripped = { ...ctx, profile: makeProfile({ appBinaryId: undefined, secrets: { userToken: 'u' } }) }; + await assert.rejects(() => generateAppPercy(stripped), /BS_APP_ID|BrowserStack/); +}); diff --git a/src/generators/app-percy.ts b/src/generators/app-percy.ts new file mode 100644 index 0000000..65bdbcb --- /dev/null +++ b/src/generators/app-percy.ts @@ -0,0 +1,53 @@ +/** + * App Percy (mobile, Android via Appium) generator (plan Unit 7, R15/R4/R3b). + * + * Shells out to app_capture.py in place. Needs prod-hub BrowserStack creds + a + * pre-uploaded BS_APP_ID (or RUN_LOCAL + an app path). PERCY_CLIENT_API_URL is + * passed via env — the script reads it from env, there is no hardcode to override. + */ +import { noncedBranch, type GeneratorContext, type GeneratedBuild } from './context'; +import { parseFinalizedBuild } from '../exec'; + +export async function generateAppPercy(ctx: GeneratorContext): Promise { + const { profile, project, runner } = ctx; + const { browserstackUser, browserstackKey } = profile.secrets; + + if (!profile.runLocalApp && (!browserstackUser || !browserstackKey || !profile.appBinaryId)) { + throw new Error( + 'App Percy needs prod-hub BrowserStack creds + a pre-uploaded BS_APP_ID (or runLocalApp + an app path).', + ); + } + + const script = `${profile.upstream.seedAccounts}/scripts/seed-accounts/app_capture.py`; + const variants: Array<{ variant: 'baseline' | 'changed'; branch: string; label: string }> = [ + { variant: 'baseline', branch: noncedBranch('master', ctx.nonce), label: 'App Percy: baseline' }, + { variant: 'changed', branch: noncedBranch('app-change', ctx.nonce), label: 'App Percy: changed' }, + ]; + + const out: GeneratedBuild[] = []; + for (const v of variants) { + const env: NodeJS.ProcessEnv = { + PERCY_TOKEN: project.writeToken, + PERCY_BRANCH: v.branch, + PERCY_CLIENT_API_URL: profile.clientApiUrl, + BUILD_VARIANT: v.variant, + }; + if (profile.runLocalApp) { + env.RUN_LOCAL = '1'; + if (profile.appPath) env.APP_PATH = profile.appPath; + } else { + env.BROWSERSTACK_USERNAME = browserstackUser!; + env.BROWSERSTACK_ACCESS_KEY = browserstackKey!; + env.BS_APP_ID = profile.appBinaryId!; + } + + const result = await runner('percy', ['app:exec', '--', 'python', script], { env, timeoutMs: 600_000 }); + const parsed = parseFinalizedBuild(`${result.stdout}\n${result.stderr}`); + if (!parsed?.id) { + throw new Error(`App capture did not finalize a build (variant=${v.variant}).`); + } + await ctx.buildApi.waitForBuildFinished(parsed.id, project.readToken); + out.push({ feature: 'app-percy', requirement: 'R15', label: v.label, buildId: parsed.id, buildUrl: parsed.url }); + } + return out; +} diff --git a/src/generators/approval.test.ts b/src/generators/approval.test.ts new file mode 100644 index 0000000..53b9502 --- /dev/null +++ b/src/generators/approval.test.ts @@ -0,0 +1,36 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { generateSupersede, generateAutoApprove, generateAutoFinalization } from './approval'; +import { makeGeneratorContext } from '../testing/fakes'; + +test('auto-finalization: a single finished build (R13)', async () => { + const { ctx } = makeGeneratorContext(); + const builds = await generateAutoFinalization(ctx); + assert.equal(builds.length, 1); + assert.equal(builds[0].requirement, 'R13'); +}); + +test('supersede: two builds on the SAME branch with skipCache', async () => { + const { ctx, runnerCalls } = makeGeneratorContext(); + const builds = await generateSupersede(ctx); + assert.equal(builds.length, 1); + assert.equal(runnerCalls[0].env.PERCY_BRANCH, runnerCalls[1].env.PERCY_BRANCH); + assert.equal(runnerCalls[0].env.PERCY_SKIP_BUILD_CACHE, '1'); + assert.equal(runnerCalls[1].env.PERCY_SKIP_BUILD_CACHE, '1'); +}); + +test('auto-approve: sets the branch rule (PATCH) then builds matching + control', async () => { + const { ctx, runnerCalls, httpCalls } = makeGeneratorContext({ reviewState: 'auto_approved' }); + const builds = await generateAutoApprove(ctx); + assert.equal(builds.length, 2); + + // The branch rule was PATCHed with the dash-cased key — Percy answers 200 and + // silently ignores snake_cased attributes, so casing is load-bearing here. + const patch = httpCalls.find((c) => c.method === 'PATCH'); + assert.ok(patch, 'expected an editProject PATCH'); + assert.equal((patch!.body as any).data.attributes['auto-approve-branch-filter'], 'auto-approved/*'); + + // Matching branch matches the rule prefix; control does not. + assert.match(String(runnerCalls[0].env.PERCY_BRANCH), /^auto-approved\/x-/); + assert.match(String(runnerCalls[1].env.PERCY_BRANCH), /^not-matching-/); +}); diff --git a/src/generators/approval.ts b/src/generators/approval.ts new file mode 100644 index 0000000..c13dc62 --- /dev/null +++ b/src/generators/approval.ts @@ -0,0 +1,71 @@ +/** + * Approval automation (plan R13): auto-finalization + supersede (Unit 5) and the + * net-new auto-approve generator (Unit 6). + */ +import { captureWeb, noncedBranch, type GeneratorContext, type GeneratedBuild } from './context'; + +/** Auto-finalization (R13): a normal finished build. */ +export async function generateAutoFinalization(ctx: GeneratorContext): Promise { + const b = await captureWeb(ctx, { diffMode: 'baseline', branch: noncedBranch('auto-finalize', ctx.nonce) }); + await ctx.buildApi.waitForBuildFinished(b.id, ctx.project.readToken); + return [{ feature: 'approval', requirement: 'R13', label: 'auto-finalization', buildId: b.id, buildUrl: b.url }]; +} + +/** + * Supersede (R13): two builds on the SAME branch with build caching skipped, so the + * second supersedes the first. Requires squash_builds_enabled + the LD flag on the + * project — a precondition the gate (Unit 8) is responsible for. + */ +export async function generateSupersede(ctx: GeneratorContext): Promise { + const branch = noncedBranch('supersede', ctx.nonce); + + const first = await captureWeb(ctx, { diffMode: 'changed', branch, skipCache: true }); + await ctx.buildApi.waitForBuildFinished(first.id, ctx.project.readToken); + const second = await captureWeb(ctx, { diffMode: 'changed', branch, skipCache: true }); + await ctx.buildApi.waitForBuildFinished(second.id, ctx.project.readToken); + + return [ + { + feature: 'approval', + requirement: 'R13', + label: 'supersede: 2nd build supersedes 1st (same branch)', + buildId: second.id, + buildUrl: second.url, + }, + ]; +} + +/** + * Auto-approve (R13, NET-NEW — Unit 6). Set an auto-approve branch rule via + * editProject (USER principal — project tokens are rejected), build on a MATCHING + * branch (should auto-approve), and a control build on a non-matching branch (should + * NOT). The review-state is read back so a hung/unhandled review_state_reason surfaces. + */ +export async function generateAutoApprove(ctx: GeneratorContext): Promise { + const out: GeneratedBuild[] = []; + const prefix = 'auto-approved'; + + await ctx.projectApi.setAutoApprove(ctx.project.slug, `${prefix}/*`); + + const matching = await captureWeb(ctx, { diffMode: 'changed', branch: noncedBranch(`${prefix}/x`, ctx.nonce) }); + const matchState = await ctx.buildApi.waitForBuildFinished(matching.id, ctx.project.readToken); + out.push({ + feature: 'approval', + requirement: 'R13', + label: `auto-approve: matching branch (review-state=${matchState.reviewState ?? 'n/a'})`, + buildId: matching.id, + buildUrl: matching.url, + }); + + const control = await captureWeb(ctx, { diffMode: 'changed', branch: noncedBranch('not-matching', ctx.nonce) }); + await ctx.buildApi.waitForBuildFinished(control.id, ctx.project.readToken); + out.push({ + feature: 'approval', + requirement: 'R13', + label: 'auto-approve: control (non-matching branch, should stay unapproved)', + buildId: control.id, + buildUrl: control.url, + }); + + return out; +} diff --git a/src/generators/context.ts b/src/generators/context.ts new file mode 100644 index 0000000..15d03fd --- /dev/null +++ b/src/generators/context.ts @@ -0,0 +1,126 @@ +/** + * Shared generator context + the web build helper. + * + * Build path = `percy snapshot ` — the Percy CLI serves the generated + * fixture pages and renders them in a real browser, so builds carry actual DOM. + * That is what makes AI review, region rules and IntelliIgnore meaningful; the old + * solid-colour `percy upload` path could only prove the plumbing. + * + * Always injects PERCY_CLIENT_API_URL so builds land on the target env (never + * prod-by-default). The per-run nonce on branches defeats auto-approve carry-forward + * so re-runs still diff. + */ +import { mkdtempSync, writeFileSync, existsSync, cpSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { ResolvedProfile } from '../profile/schema'; +import type { Runner } from '../exec'; +import { parseFinalizedBuild } from '../exec'; +import type { ProjectApi } from '../percy/project-api'; +import type { BuildApi } from '../percy/build-api'; +import { writeFixtureSet, type FixtureKind } from '../fixtures/sets'; +import { buildPercyConfig, type RegionRule } from '../fixtures/rules'; + +export interface SeededProject { + id: string; + slug: string; + /** org/team id when derivable from the full-slug (e.g. "orgid/proj") — optional */ + teamId?: string; + writeToken: string; + readToken: string; +} + +export interface GeneratorContext { + profile: ResolvedProfile; + project: SeededProject; + projectApi: ProjectApi; + buildApi: BuildApi; + runner: Runner; + /** per-run nonce — applied to branches to defeat auto-approve carry-forward */ + nonce: string; +} + +export interface GeneratedBuild { + feature: string; + requirement: string; + label: string; + /** + * What QA should SEE in this build for the feature to count as working. Rich DOM + * makes builds complex enough that "looks fine" stops being a judgement anyone can + * make unaided — the expectation is what turns a link into a check. + */ + expectation?: string; + buildId?: string; + buildUrl?: string; +} + +/** Append the run nonce to a branch so re-runs are distinct identities. */ +export function noncedBranch(branch: string, nonce: string): string { + return `${branch}-${nonce}`; +} + +export interface WebCaptureOpts { + /** which fixture set to render (drives the review state) */ + diffMode?: FixtureKind; + branch: string; + targetBranch?: string; + skipCache?: boolean; + /** region rules applied to the storefront page for this capture */ + rules?: RegionRule[]; + /** + * Render HTML from an existing directory instead of the generated fixtures. + * Used for the AI suite, which reuses the curated upstream `test_bed/ai` pages + * in place rather than duplicating them here. + */ + sourceDir?: string; +} + +/** + * Create a real web build by rendering the fixture pages with `percy snapshot`. + * Returns the finalized build id/url; throws if none was finalized. + */ +export async function captureWeb( + ctx: GeneratorContext, + opts: WebCaptureOpts, +): Promise<{ id: string; url?: string }> { + const { profile, project, runner, nonce } = ctx; + const dir = mkdtempSync(join(tmpdir(), 'percy-testbed-')); + + if (opts.sourceDir) { + if (!existsSync(opts.sourceDir)) { + throw new Error( + `fixture source directory not found: ${opts.sourceDir}. ` + + 'Set PERCY_TESTBED_PERCY_PLAYWRIGHT_DIR if the upstream checkout is not a sibling of this repo.', + ); + } + cpSync(opts.sourceDir, dir, { recursive: true }); + } else { + writeFixtureSet(dir, opts.diffMode ?? 'baseline', nonce); + } + + const configPath = join(dir, 'percy.config.json'); + writeFileSync(configPath, JSON.stringify(buildPercyConfig(opts.rules ?? []), null, 2)); + + const env: NodeJS.ProcessEnv = { + PERCY_TOKEN: project.writeToken, + PERCY_BRANCH: opts.branch, + PERCY_CLIENT_API_URL: profile.clientApiUrl, // unset => builds land on prod + }; + if (opts.targetBranch) env.PERCY_TARGET_BRANCH = opts.targetBranch; + if (opts.skipCache) env.PERCY_SKIP_BUILD_CACHE = '1'; + if (profile.disableTls) env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; // local dev cert only + + const result = await runner( + 'npx', + ['-y', '@percy/cli', 'snapshot', dir, '--config', configPath], + { env, timeoutMs: 300_000 }, + ); + const logs = `${result.stdout}\n${result.stderr}`; + const parsed = parseFinalizedBuild(logs); + if (!parsed?.id) { + throw new Error( + `percy snapshot did not finalize a build (branch=${opts.branch}). Last logs: ${logs.slice(-400)}`, + ); + } + return { id: parsed.id, url: parsed.url }; +} diff --git a/src/generators/intelli-ignore.test.ts b/src/generators/intelli-ignore.test.ts new file mode 100644 index 0000000..ffef5f3 --- /dev/null +++ b/src/generators/intelli-ignore.test.ts @@ -0,0 +1,88 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { generateIntelliIgnore } from './intelli-ignore'; +import { makeGeneratorContext } from '../testing/fakes'; + +function configsFrom(runnerCalls: { args: string[] }[]): any[] { + return runnerCalls.map((c) => { + const i = c.args.indexOf('--config'); + return i === -1 ? null : JSON.parse(readFileSync(c.args[i + 1], 'utf8')); + }); +} + +test('intelli-ignore: enables AI + the noise classes before capturing', async () => { + // All default to off. With AI off the rule never runs at all, and the rule builds + // come back identical to their standard control. + const { ctx, httpCalls } = makeGeneratorContext(); + await generateIntelliIgnore(ctx); + + const patch = httpCalls.find((c) => c.method === 'PATCH'); + assert.ok(patch, 'the project is patched'); + const attrs = (patch.body as any).data.attributes; + for (const key of [ + 'ai-enabled', // IntelliIgnore is AI-backed; off means the rule never runs + 'intelli-ignore-enabled', + 'ignore-carousels-enabled', + 'ignore-banners-enabled', + 'ignore-ads-enabled', + 'intelli-ignore-dynamic-data-enabled', + ]) { + assert.equal(attrs[key], true, `${key} enabled`); + } +}); + +test('intelli-ignore: every rule build ships with a standard control', async () => { + const { ctx } = makeGeneratorContext(); + const builds = await generateIntelliIgnore(ctx); + + // 3 scenarios + 2 sensitivity sweep builds. + assert.equal(builds.length, 5); + assert.ok(builds.every((b) => b.requirement === 'R14b')); + assert.ok(builds.every((b) => b.expectation), 'every build states what QA should see'); + + const control = builds.find((b) => b.label.startsWith('CONTROL')); + assert.ok(control, 'a standard-rule control exists — "no diffs" alone proves nothing'); + assert.match(String(control.expectation), /proof that the fixture really changed/); +}); + +test('intelli-ignore: the rule and its control run over the same fixture pair', async () => { + const { ctx, runnerCalls } = makeGeneratorContext(); + await generateIntelliIgnore(ctx); + + const cfgs = configsFrom(runnerCalls); + const intelli = cfgs.filter((c) => JSON.stringify(c).includes('"algorithm":"intelliignore"')); + const standard = cfgs.filter((c) => JSON.stringify(c).includes('"algorithm":"standard"')); + + assert.ok(intelli.length >= 3); + assert.equal(standard.length, 1, 'exactly one control'); + + // Same zones under both rules — that is what makes the comparison fair. + const zonesOf = (c: any) => + c.static.options[0].regions.map((r: any) => r.elementSelector.elementCSS).sort(); + assert.deepEqual(zonesOf(standard[0]), zonesOf(intelli[0])); +}); + +test('intelli-ignore: the sensitivity sweep sends both ends of the knob', async () => { + const { ctx, runnerCalls } = makeGeneratorContext(); + await generateIntelliIgnore(ctx); + + const sensitivities = configsFrom(runnerCalls) + .flatMap((c) => c?.static?.options?.[0]?.regions ?? []) + .map((r: any) => r.configuration?.diffSensitivity) + .filter((v: unknown) => v !== undefined); + + assert.ok(sensitivities.includes(0), 'sweeps the low end'); + assert.ok(sensitivities.includes(4), 'sweeps the high end'); +}); + +test('intelli-ignore: one approved baseline serves every scenario', async () => { + const { ctx, runnerCalls } = makeGeneratorContext(); + await generateIntelliIgnore(ctx); + + const baselines = runnerCalls.filter((c) => !c.env.PERCY_TARGET_BRANCH); + assert.equal(baselines.length, 1, 'no redundant baseline per scenario'); + + const heads = runnerCalls.filter((c) => c.env.PERCY_TARGET_BRANCH); + assert.ok(heads.every((c) => c.env.PERCY_TARGET_BRANCH === baselines[0].env.PERCY_BRANCH)); +}); diff --git a/src/generators/intelli-ignore.ts b/src/generators/intelli-ignore.ts new file mode 100644 index 0000000..84207e3 --- /dev/null +++ b/src/generators/intelli-ignore.ts @@ -0,0 +1,133 @@ +/** + * IntelliIgnore (region rule `intelliignore`). + * + * The point of this generator is that "0 diffs" on its own proves nothing — it looks + * identical whether the rule suppressed the noise or the fixture never changed. So + * every rule build is paired with a `standard` CONTROL over the same fixture pair. + * The control shows the pixels really moved; the rule build shows Percy chose to + * ignore them. Read them side by side. + * + * One approved baseline serves every scenario — the baseline fixture is the same for + * all of them, and each head build targets that branch. + */ +import { captureWeb, noncedBranch, type GeneratorContext, type GeneratedBuild } from './context'; +import { NOISE_ZONES, INTELLI_NOISE_CONFIG, type RegionRule } from '../fixtures/rules'; + +const INTELLI: RegionRule = { + zones: NOISE_ZONES, + algorithm: 'intelliignore', + configuration: INTELLI_NOISE_CONFIG, +}; + +const CONTROL: RegionRule = { zones: NOISE_ZONES, algorithm: 'standard' }; + +/** + * IntelliIgnore preconditions, all PROJECT settings that default to off. + * + * `ai-enabled` matters most: IntelliIgnore is AI-backed, so with AI off the rule + * never runs at all and every build comes back matching its standard control — + * exactly what a live run showed, with `ai-details.total-diffs-reduced` null. + * + * Keys are dash-cased deliberately. Percy answers 200 and silently ignores + * snake_cased keys, so the wrong casing here reads as success and changes nothing. + */ +export async function enableIntelliIgnore(ctx: GeneratorContext): Promise { + await ctx.projectApi.editProject(ctx.project.slug, { + 'ai-enabled': true, + 'intelli-ignore-enabled': true, + 'intelli-ignore-image-diff-ignore-enabled': true, + 'intelli-ignore-dynamic-data-enabled': true, // the timestamp zone + 'ignore-carousels-enabled': true, + 'ignore-banners-enabled': true, + 'ignore-ads-enabled': true, + }); +} + +export async function generateIntelliIgnore(ctx: GeneratorContext): Promise { + const out: GeneratedBuild[] = []; + await enableIntelliIgnore(ctx); + const master = noncedBranch('ii-master', ctx.nonce); + + // Shared approved baseline for every scenario below. + const baseline = await captureWeb(ctx, { diffMode: 'baseline', branch: master, rules: [INTELLI] }); + await ctx.buildApi.waitForBuildFinished(baseline.id, ctx.project.readToken); + await ctx.buildApi.reviewBuild(baseline.id, 'approve'); + + const scenarios: Array<{ + key: string; + diffMode: 'noise' | 'noise-signal'; + rules: RegionRule[]; + label: string; + expectation: string; + }> = [ + { + key: 'suppresses-noise', + diffMode: 'noise', + rules: [INTELLI], + label: 'IntelliIgnore: carousel/ad/banner/timestamp all changed', + expectation: + 'NO diffs flagged — every changed zone is noise IntelliIgnore should absorb. Compare against the control build below, which shows the same pixels did change.', + }, + { + key: 'control-standard', + diffMode: 'noise', + rules: [CONTROL], + label: 'CONTROL (standard rule, same fixture pair as above)', + expectation: + 'Carousel, ad, banner and timestamp all flagged as diffs. This is the proof that the fixture really changed, so the build above showing none is IntelliIgnore working — not a no-op.', + }, + { + key: 'keeps-signal', + diffMode: 'noise-signal', + rules: [INTELLI], + label: 'IntelliIgnore: noise changed AND the price table changed', + expectation: + 'ONLY the price table (Growth plan $99 -> $129) is flagged. The carousel/ad/banner/timestamp changes are suppressed. A real change surviving the rule is the thing that matters.', + }, + ]; + + for (const s of scenarios) { + const build = await captureWeb(ctx, { + diffMode: s.diffMode, + branch: noncedBranch(`ii-${s.key}`, ctx.nonce), + targetBranch: master, + rules: s.rules, + }); + await ctx.buildApi.waitForBuildFinished(build.id, ctx.project.readToken); + out.push({ + feature: 'intelli-ignore', + requirement: 'R14b', + label: s.label, + expectation: s.expectation, + buildId: build.id, + buildUrl: build.url, + }); + } + + // Sensitivity sweep — same fixture pair, opposite ends of the 0-4 knob. + for (const sensitivity of [0, 4]) { + const build = await captureWeb(ctx, { + diffMode: 'noise', + branch: noncedBranch(`ii-sensitivity-${sensitivity}`, ctx.nonce), + targetBranch: master, + rules: [ + { + zones: NOISE_ZONES, + algorithm: 'intelliignore', + configuration: { ...INTELLI_NOISE_CONFIG, diffSensitivity: sensitivity }, + }, + ], + }); + await ctx.buildApi.waitForBuildFinished(build.id, ctx.project.readToken); + out.push({ + feature: 'intelli-ignore', + requirement: 'R14b', + label: `IntelliIgnore sensitivity = ${sensitivity}`, + expectation: `Same fixture pair as the scenarios above, with diffSensitivity=${sensitivity}. Compare the two sweep builds against each other — the diff count should differ, showing the knob has an effect.`, + buildId: build.id, + buildUrl: build.url, + }); + } + + return out; +} diff --git a/src/generators/recurring-diff.test.ts b/src/generators/recurring-diff.test.ts new file mode 100644 index 0000000..f6aa627 --- /dev/null +++ b/src/generators/recurring-diff.test.ts @@ -0,0 +1,14 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { generateRecurringDiff } from './recurring-diff'; +import { makeGeneratorContext } from '../testing/fakes'; + +test('recurring diff: two consecutive changed builds against the master baseline', async () => { + const { ctx, runnerCalls } = makeGeneratorContext(); + const builds = await generateRecurringDiff(ctx); + assert.equal(builds.length, 2); + assert.ok(builds.every((b) => b.requirement === 'R11')); + assert.ok(runnerCalls.every((c) => c.args.includes('snapshot'))); + // baseline (call 0) + 2 diffs (1,2); diffs target master + assert.match(String(runnerCalls[1].env.PERCY_TARGET_BRANCH), /^master-/); +}); diff --git a/src/generators/recurring-diff.ts b/src/generators/recurring-diff.ts new file mode 100644 index 0000000..d357a4a --- /dev/null +++ b/src/generators/recurring-diff.ts @@ -0,0 +1,36 @@ +/** + * Recurring diff (plan Unit 5, R11). An approved master baseline, then two + * consecutive changed builds against it so the same diff recurs and the + * recurring-diff nudge fires. Image-based (`percy upload`) path. + */ +import { captureWeb, noncedBranch, type GeneratorContext, type GeneratedBuild } from './context'; + +export async function generateRecurringDiff(ctx: GeneratorContext): Promise { + const out: GeneratedBuild[] = []; + const master = noncedBranch('master', ctx.nonce); + + const baseline = await captureWeb(ctx, { diffMode: 'baseline', branch: master }); + await ctx.buildApi.waitForBuildFinished(baseline.id, ctx.project.readToken); + await ctx.buildApi.reviewBuild(baseline.id, 'approve'); + + for (let i = 1; i <= 2; i++) { + const diff = await captureWeb(ctx, { + diffMode: 'changed', + branch: noncedBranch(`recurring-${i}`, ctx.nonce), + targetBranch: master, + }); + await ctx.buildApi.waitForBuildFinished(diff.id, ctx.project.readToken); + out.push({ + feature: 'recurring-diff', + requirement: 'R11', + label: `recurring diff build #${i}`, + expectation: + i === 1 + ? 'First occurrence of the price-table change ($99 -> $129) against the baseline — no nudge yet.' + : 'The SAME price-table change recurring a second time — the recurring-diff nudge should appear here.', + buildId: diff.id, + buildUrl: diff.url, + }); + } + return out; +} diff --git a/src/generators/regions.test.ts b/src/generators/regions.test.ts new file mode 100644 index 0000000..668d68d --- /dev/null +++ b/src/generators/regions.test.ts @@ -0,0 +1,46 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { generateRegions } from './regions'; +import { makeGeneratorContext } from '../testing/fakes'; + +test('regions: every rule build is paired with a standard control', async () => { + const { ctx, runnerCalls } = makeGeneratorContext(); + const builds = await generateRegions(ctx); + + // isolated ignore + its control, the four-zone ignore, layout + its control. + assert.equal(builds.length, 5); + assert.ok(builds.every((b) => b.requirement === 'R14')); + assert.ok(builds.every((b) => b.expectation), 'every build states what QA should see'); + + const controls = builds.filter((b) => b.label.startsWith('CONTROL')); + assert.equal(controls.length, 2, 'a control build per rule — "no diffs" alone proves nothing'); + + // The decisive test: exactly one zone changed, and that zone is ignored, so the + // build should reach zero diffs. Without isolation the count cannot move either way. + const isolated = builds.find((b) => /ONLY change/.test(b.label)); + assert.ok(isolated, 'an isolated-change ignore build exists'); + assert.match(String(isolated.expectation), /ZERO diffs/); + + assert.ok(runnerCalls.every((c) => c.args.includes('snapshot'))); +}); + +test('regions: rule config reaches the generated percy config', async () => { + const { ctx, runnerCalls } = makeGeneratorContext(); + await generateRegions(ctx); + + // Every capture passes --config; find the one carrying the ignore rule. + const configs = runnerCalls.map((c) => { + const i = c.args.indexOf('--config'); + return i === -1 ? null : JSON.parse(readFileSync(c.args[i + 1], 'utf8')); + }); + + const withIgnore = configs.find((cfg) => + JSON.stringify(cfg ?? {}).includes('"algorithm":"ignore"'), + ); + assert.ok(withIgnore, 'the ignore rule is written into a percy config'); + + const regions = (withIgnore as any).static.options[0].regions; + assert.equal(regions[0].elementSelector.elementCSS, '#promo-carousel'); + assert.equal(regions[0].algorithm, 'ignore'); +}); diff --git a/src/generators/regions.ts b/src/generators/regions.ts new file mode 100644 index 0000000..60f1c32 --- /dev/null +++ b/src/generators/regions.ts @@ -0,0 +1,98 @@ +/** + * Regions (plan Unit 5, R14) — now covering every rule in Percy's region model + * (`standard | layout | ignore | intelliignore`), applied declaratively per snapshot. + * + * `ignore` and `layout` each get a paired `standard` control over the same fixture + * pair, for the same reason IntelliIgnore does: a build with no diffs is only + * evidence if something else proves the pixels moved. + * + * IntelliIgnore has its own generator (`intelli-ignore`) — it carries the most + * scenarios, so it does not share this one. + */ +import { captureWeb, noncedBranch, type GeneratorContext, type GeneratedBuild } from './context'; +import { NOISE_ZONES, type RegionRule } from '../fixtures/rules'; + +export async function generateRegions(ctx: GeneratorContext): Promise { + const out: GeneratedBuild[] = []; + const master = noncedBranch('regions-master', ctx.nonce); + + const baseline = await captureWeb(ctx, { diffMode: 'baseline', branch: master }); + await ctx.buildApi.waitForBuildFinished(baseline.id, ctx.project.readToken); + await ctx.buildApi.reviewBuild(baseline.id, 'approve'); + + const scenarios: Array<{ + key: string; + diffMode: 'noise' | 'layout-shift' | 'carousel-only'; + rules: RegionRule[]; + label: string; + expectation: string; + }> = [ + // The decisive pair. Only the carousel differs, so ignoring it should take the + // build to ZERO diffs. With four zones changed the count cannot move regardless + // of whether the rule fired, which makes that version of the test unfalsifiable. + { + key: 'ignore-isolated', + diffMode: 'carousel-only', + rules: [{ zones: ['carousel'], algorithm: 'ignore' }], + label: 'ignore region on the carousel — carousel is the ONLY change', + expectation: + 'ZERO diffs. The carousel is the only thing that changed and it is ignored, so nothing should be flagged. If this build shows diffs, the ignore rule is not being applied.', + }, + { + key: 'ignore-isolated-control', + diffMode: 'carousel-only', + rules: [{ zones: ['carousel'], algorithm: 'standard' }], + label: 'CONTROL (standard rule, carousel-only pair)', + expectation: + 'The carousel IS flagged. Same fixture pair as the build above — this is what proves the carousel really changed, so zero diffs there means the rule worked.', + }, + { + key: 'ignore-carousel', + diffMode: 'noise', + rules: [{ zones: ['carousel'], algorithm: 'ignore' }], + label: 'ignore region on the carousel (all four zones changed)', + expectation: + 'Still flagged, because the ad, banner and timestamp also changed and are not ignored. Included to show why the isolated pair above is the meaningful test.', + }, + { + key: 'layout-rule', + diffMode: 'layout-shift', + rules: [{ zones: ['sidebar'], algorithm: 'layout' }], + label: 'layout rule on a sidebar that moved but did not change', + // Live runs give layout=4 diffs and standard=0 on this identical pair, i.e. the + // opposite of "layout suppresses movement". Stated as a comparison rather than a + // prediction, because the direction is not yet confirmed against Percy's docs — + // the rule may be designed to DETECT layout change rather than ignore it. + expectation: + 'The sidebar moved 96px down with identical content. Compare against the standard control below and confirm which way round Percy treats it — on the current fixtures the layout rule flags this pair and standard does not.', + }, + { + key: 'layout-control', + diffMode: 'layout-shift', + rules: [{ zones: ['sidebar'], algorithm: 'standard' }], + label: 'CONTROL (standard rule over the same layout shift)', + expectation: + 'The same displacement judged pixel-wise. Read alongside the layout build above — the pair is what tells you how each rule treats pure movement.', + }, + ]; + + for (const s of scenarios) { + const build = await captureWeb(ctx, { + diffMode: s.diffMode, + branch: noncedBranch(`regions-${s.key}`, ctx.nonce), + targetBranch: master, + rules: s.rules, + }); + await ctx.buildApi.waitForBuildFinished(build.id, ctx.project.readToken); + out.push({ + feature: 'regions', + requirement: 'R14', + label: s.label, + expectation: s.expectation, + buildId: build.id, + buildUrl: build.url, + }); + } + + return out; +} diff --git a/src/generators/visual-git.test.ts b/src/generators/visual-git.test.ts new file mode 100644 index 0000000..350f356 --- /dev/null +++ b/src/generators/visual-git.test.ts @@ -0,0 +1,14 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { generateVisualGit } from './visual-git'; +import { makeGeneratorContext } from '../testing/fakes'; + +test('visual-git: variant-A baseline then variant-B head with PERCY_TARGET_BRANCH', async () => { + const { ctx, runnerCalls } = makeGeneratorContext({ nonce: 'n1' }); + const builds = await generateVisualGit(ctx); + assert.equal(builds.length, 2); + assert.equal(builds[0].requirement, 'R9'); + assert.equal(builds[1].requirement, 'R10'); + assert.match(String(runnerCalls[0].env.PERCY_BRANCH), /^variant-a-n1$/); + assert.match(String(runnerCalls[1].env.PERCY_TARGET_BRANCH), /^variant-a-n1$/); +}); diff --git a/src/generators/visual-git.ts b/src/generators/visual-git.ts new file mode 100644 index 0000000..c850e7e --- /dev/null +++ b/src/generators/visual-git.ts @@ -0,0 +1,39 @@ +/** + * Visual-git / target-branch baseline selection + A/B variant comparison (plan Unit 5, R9/R10). + * + * Creates a "variant A" target-branch build, approves it, then a "variant B" head build + * with PERCY_TARGET_BRANCH pointing at variant A. "A/B testing" rides this same machinery. + */ +import { captureWeb, noncedBranch, type GeneratorContext, type GeneratedBuild } from './context'; + +export async function generateVisualGit(ctx: GeneratorContext): Promise { + const out: GeneratedBuild[] = []; + const variantA = noncedBranch('variant-a', ctx.nonce); + + const a = await captureWeb(ctx, { diffMode: 'baseline', branch: variantA }); + await ctx.buildApi.waitForBuildFinished(a.id, ctx.project.readToken); + await ctx.buildApi.reviewBuild(a.id, 'approve'); + out.push({ + feature: 'visual-git', + requirement: 'R9', + label: 'visual-git: variant-A target-branch baseline', + buildId: a.id, + buildUrl: a.url, + }); + + const b = await captureWeb(ctx, { + diffMode: 'changed', + branch: noncedBranch('variant-b', ctx.nonce), + targetBranch: variantA, + }); + await ctx.buildApi.waitForBuildFinished(b.id, ctx.project.readToken); + out.push({ + feature: 'visual-git', + requirement: 'R10', + label: 'A/B: variant-B vs variant-A (target-branch baseline)', + buildId: b.id, + buildUrl: b.url, + }); + + return out; +} diff --git a/src/generators/web-core.test.ts b/src/generators/web-core.test.ts new file mode 100644 index 0000000..6a29a26 --- /dev/null +++ b/src/generators/web-core.test.ts @@ -0,0 +1,23 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { generateCoreStates } from './web-core'; +import { makeGeneratorContext } from '../testing/fakes'; + +test('core states: approved baseline then changed via percy snapshot (client-api-url + nonce)', async () => { + const { ctx, runnerCalls } = makeGeneratorContext({ nonce: 'n123' }); + const builds = await generateCoreStates(ctx); + + assert.equal(builds.length, 2); + assert.ok(builds.every((b) => b.requirement === 'R8')); + assert.ok(builds.every((b) => b.expectation), 'every build states what QA should see'); + + // Both captures render real DOM via `percy snapshot`, inject PERCY_CLIENT_API_URL, + // and nonce the branch. + for (const c of runnerCalls) { + assert.ok(c.args.includes('snapshot'), 'uses percy snapshot, not upload'); + assert.equal(c.env.PERCY_CLIENT_API_URL, 'https://canary.percy.io/api/v1'); + assert.match(String(c.env.PERCY_BRANCH), /-n123$/); + } + // The changed build targets the baseline branch. + assert.match(String(runnerCalls[1].env.PERCY_TARGET_BRANCH), /^master-n123$/); +}); diff --git a/src/generators/web-core.ts b/src/generators/web-core.ts new file mode 100644 index 0000000..57254b4 --- /dev/null +++ b/src/generators/web-core.ts @@ -0,0 +1,46 @@ +/** + * Core review states generator (plan Unit 4, R8/R4/R3b). + * + * Produces an approved baseline then a CHANGED build that diffs against it. CHANGED + * requires the baseline approved first (on canary, master is not the default branch, + * so nothing auto-approves). The per-run nonce defeats auto-approve carry-forward. + */ +import { captureWeb, noncedBranch, type GeneratorContext, type GeneratedBuild } from './context'; + +export async function generateCoreStates(ctx: GeneratorContext): Promise { + const out: GeneratedBuild[] = []; + const masterBranch = noncedBranch('master', ctx.nonce); + + // 1. Baseline → await finish → approve (so the next build is classified CHANGED). + const baseline = await captureWeb(ctx, { diffMode: 'baseline', branch: masterBranch }); + await ctx.buildApi.waitForBuildFinished(baseline.id, ctx.project.readToken); + await ctx.buildApi.reviewBuild(baseline.id, 'approve'); + out.push({ + feature: 'core', + requirement: 'R8', + label: 'core: approved baseline', + expectation: + 'Storefront and pricing pages render as real content (carousel, banner, price table). Approved, so it is the baseline the next build diffs against.', + buildId: baseline.id, + buildUrl: baseline.url, + }); + + // 2. Changed vs the approved baseline (target-branch points at the baseline branch). + const changed = await captureWeb(ctx, { + diffMode: 'changed', + branch: noncedBranch('feature/seed-change', ctx.nonce), + targetBranch: masterBranch, + }); + await ctx.buildApi.waitForBuildFinished(changed.id, ctx.project.readToken); + out.push({ + feature: 'core', + requirement: 'R8', + label: 'core: changed (diffs vs baseline)', + expectation: + 'CHANGED review state: the Growth plan price moves $99 -> $129 on the storefront. Pricing page unchanged.', + buildId: changed.id, + buildUrl: changed.url, + }); + + return out; +} diff --git a/src/http.ts b/src/http.ts new file mode 100644 index 0000000..94add79 --- /dev/null +++ b/src/http.ts @@ -0,0 +1,46 @@ +/** + * Minimal injectable HTTP client. The default uses global `fetch`; tests inject a + * fake so the Percy REST layer is verifiable without live calls (plan: validate later). + */ +export interface HttpRequest { + method: 'GET' | 'POST' | 'PATCH' | 'DELETE'; + url: string; + headers?: Record; + /** object => JSON-encoded; string => sent as-is */ + body?: unknown; + contentType?: string; +} + +export interface HttpResponse { + status: number; + ok: boolean; + body: unknown; + text: string; +} + +export type HttpClient = (req: HttpRequest) => Promise; + +export const fetchHttpClient: HttpClient = async (req) => { + const headers: Record = { ...(req.headers ?? {}) }; + let body: string | undefined; + if (req.body !== undefined) { + if (typeof req.body === 'string') { + body = req.body; + } else { + body = JSON.stringify(req.body); + headers['content-type'] = req.contentType ?? 'application/json'; + } + } else if (req.contentType) { + headers['content-type'] = req.contentType; + } + + const res = await fetch(req.url, { method: req.method, headers, body }); + const text = await res.text(); + let parsed: unknown; + try { + parsed = text ? JSON.parse(text) : undefined; + } catch { + parsed = text; + } + return { status: res.status, ok: res.ok, body: parsed, text }; +}; diff --git a/src/main.ts b/src/main.ts index 3ea91af..1c5f1ee 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,8 +4,9 @@ * library stays free of top-level side effects and is safe to import in tests. */ import { run, UsageError, usage } from './cli'; +import { realDispatch } from './run'; -run(process.argv.slice(2)).catch((err) => { +run(process.argv.slice(2), { dispatch: realDispatch }).catch((err) => { if (err instanceof UsageError) { console.error(`error: ${err.message}`); console.error(usage()); diff --git a/src/orchestrator.test.ts b/src/orchestrator.test.ts new file mode 100644 index 0000000..b112b90 --- /dev/null +++ b/src/orchestrator.test.ts @@ -0,0 +1,89 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { orchestrate, selectTiers, selectFeatures, TIERS, type OrchestratorDeps } from './orchestrator'; +import { makeProfile, finalizingRunner, recorder, okJson, FAKE_PROJECT, makeFakeUpstreamPlaywright } from './testing/fakes'; +import { createProjectApi } from './percy/project-api'; +import { createBuildApi } from './percy/build-api'; +import type { Runner } from './exec'; + +function makeDeps(overrides: Partial = {}): OrchestratorDeps { + const { runner } = finalizingRunner(); + const { http } = recorder((req) => + req.method === 'GET' ? okJson({ data: { attributes: { state: 'finished' } } }) : okJson({}), + ); + const profile = makeProfile({ + expectedFlags: ['recurring_diff', 'ai', 'auto_approve', 'squash_builds'], + // the AI generator renders the upstream test_bed pages, so it needs a checkout + upstream: { seedAccounts: '/up/seed', percyPlaywright: makeFakeUpstreamPlaywright() }, + }); + return { + profile, + projectApi: createProjectApi(profile, http), + buildApi: createBuildApi(profile, http), + runner, + nonce: 'nX', + provisionProject: async (tier) => ({ ...FAKE_PROJECT, slug: `seed-${tier}-web` }), + ...overrides, + }; +} + +test('selectTiers/selectFeatures honor the --only filter shape', () => { + assert.deepEqual(selectTiers('paid'), ['paid']); + assert.equal(selectTiers('ai').length, TIERS.length); // 'ai' is a feature => all tiers + assert.deepEqual(selectFeatures('regions').map((f) => f.key), ['regions']); + assert.equal(selectFeatures('paid').length, 8); // 'paid' is a tier => all features +}); + +test('an explicit --tier wins and scopes to a single tier', () => { + assert.deepEqual(selectTiers(undefined, 'paid'), ['paid']); + assert.deepEqual(selectTiers('visual-git', 'free'), ['free']); // feature via --only, tier via --tier +}); + +test('--only + --tier runs that one feature in one project', async () => { + const res = await orchestrate({ profile: 'canary', only: 'visual-git', tier: 'paid' }, makeDeps()); + assert.ok(res.builds.length > 0); + assert.ok(res.builds.every((b) => b.tier === 'paid' && b.feature === 'visual-git')); +}); + +test('--only core runs core across every tier', async () => { + const res = await orchestrate({ profile: 'canary', only: 'core' }, makeDeps()); + assert.equal(res.builds.length, TIERS.length * 2); // 2 builds per tier + assert.ok(res.builds.every((b) => b.feature === 'core')); +}); + +test('--only paid runs all features on just the paid tier', async () => { + const res = await orchestrate({ profile: 'canary', only: 'paid' }, makeDeps()); + assert.ok(res.builds.length > 0); + assert.ok(res.builds.every((b) => b.tier === 'paid')); + assert.ok(res.builds.some((b) => b.feature === 'ai')); +}); + +test('AI is skipped on the ai_off tier', async () => { + const res = await orchestrate({ profile: 'canary', only: 'ai' }, makeDeps()); + assert.ok(res.skipped.some((s) => s.tier === 'ai_off' && s.feature === 'ai')); + assert.ok(res.builds.every((b) => b.tier !== 'ai_off')); +}); + +test('a feature error is isolated: skipped, pass continues', async () => { + const failingRunner: Runner = async (_c, args) => { + if (args.includes('app:exec')) throw new Error('app runner boom'); + return { stdout: 'Finalized build #1: https://x/builds/1', stderr: '', code: 0 }; + }; + const res = await orchestrate({ profile: 'canary', only: 'app-percy' }, makeDeps({ runner: failingRunner })); + assert.equal(res.builds.length, 0); + assert.ok(res.skipped.length > 0); + assert.ok(res.skipped.every((s) => s.feature === 'app-percy')); +}); + +test('provisioning failure skips the whole tier', async () => { + const res = await orchestrate( + { profile: 'canary', only: 'core' }, + makeDeps({ + provisionProject: async () => { + throw new Error('no account on env'); + }, + }), + ); + assert.equal(res.builds.length, 0); + assert.ok(res.skipped.some((s) => /provisioning failed/.test(s.reason))); +}); diff --git a/src/orchestrator.ts b/src/orchestrator.ts new file mode 100644 index 0000000..c0d023e --- /dev/null +++ b/src/orchestrator.ts @@ -0,0 +1,144 @@ +/** + * One-pass, dependency-ordered orchestrator (plan Unit 9, R1/R3/R3b/R7). + * + * Runs each feature generator across the account tiers, isolating per-feature + * failures (one failure skips only that feature, not the pass) and honoring the + * coarse `--only ` filter. Per-feature preconditions gate before any + * capture; AI is skipped on the AI-off tier so the contrast is observable. + * + * NOTE: this builds ahead of the plan's Phase-0 validation gate (the user chose to + * write all code now). Unit 0 must still confirm the shared-env credential/token + * path and the capture wiring before a real run. + */ +import type { CliConfig, FeatureKey, TierKey } from './cli'; +import type { ResolvedProfile } from './profile/schema'; +import type { ProjectApi } from './percy/project-api'; +import type { BuildApi } from './percy/build-api'; +import type { Runner } from './exec'; +import type { GeneratedBuild, GeneratorContext, SeededProject } from './generators/context'; +import { checkFeaturePreconditions } from './preconditions'; +import { generateCoreStates } from './generators/web-core'; +import { generateVisualGit } from './generators/visual-git'; +import { generateRecurringDiff } from './generators/recurring-diff'; +import { generateAI } from './generators/ai'; +import { generateRegions } from './generators/regions'; +import { generateIntelliIgnore } from './generators/intelli-ignore'; +import { generateAutoFinalization, generateSupersede, generateAutoApprove } from './generators/approval'; +import { generateAppPercy } from './generators/app-percy'; + +type GenFn = (ctx: GeneratorContext) => Promise; + +interface FeatureDef { + key: FeatureKey; + run: GenFn; + requiredFlags: string[]; + requiresApp?: boolean; + skipTiers?: TierKey[]; +} + +export const TIERS: readonly TierKey[] = ['free', 'paid', 'ent_global', 'ent_team', 'ai_off']; + +export const FEATURES: readonly FeatureDef[] = [ + { key: 'core', run: generateCoreStates, requiredFlags: [] }, + { key: 'visual-git', run: generateVisualGit, requiredFlags: [] }, + { key: 'recurring-diff', run: generateRecurringDiff, requiredFlags: ['recurring_diff'] }, + { key: 'ai', run: generateAI, requiredFlags: ['ai'], skipTiers: ['ai_off'] }, + { + key: 'approval', + run: async (ctx) => [ + ...(await generateAutoFinalization(ctx)), + ...(await generateSupersede(ctx)), + ...(await generateAutoApprove(ctx)), + ], + requiredFlags: ['auto_approve', 'squash_builds'], + }, + { key: 'regions', run: generateRegions, requiredFlags: [] }, + { key: 'intelli-ignore', run: generateIntelliIgnore, requiredFlags: [] }, + { key: 'app-percy', run: generateAppPercy, requiredFlags: [], requiresApp: true }, +]; + +export interface SkippedFeature { + tier: TierKey; + feature: FeatureKey; + reason: string; +} + +export type OrchestratedBuild = GeneratedBuild & { tier: TierKey; projectSlug: string }; + +export interface RunResult { + builds: OrchestratedBuild[]; + skipped: SkippedFeature[]; +} + +export interface OrchestratorDeps { + profile: ResolvedProfile; + projectApi: ProjectApi; + buildApi: BuildApi; + runner: Runner; + /** create/seed a project for a tier and return its SeededProject (write + read tokens) */ + provisionProject: (tier: TierKey) => Promise; + nonce: string; +} + +export function selectTiers(only?: CliConfig['only'], tier?: TierKey): TierKey[] { + if (tier) return [tier]; + if (only && (TIERS as readonly string[]).includes(only)) return [only as TierKey]; + return [...TIERS]; +} + +export function selectFeatures(only?: CliConfig['only']): FeatureDef[] { + if (only && FEATURES.some((f) => f.key === only)) return FEATURES.filter((f) => f.key === only); + return [...FEATURES]; +} + +export async function orchestrate(config: CliConfig, deps: OrchestratorDeps): Promise { + const result: RunResult = { builds: [], skipped: [] }; + const tiers = selectTiers(config.only, config.tier); + const features = selectFeatures(config.only); + + for (const tier of tiers) { + let project: SeededProject; + try { + project = await deps.provisionProject(tier); + } catch (err) { + for (const f of features) { + result.skipped.push({ tier, feature: f.key, reason: `provisioning failed: ${(err as Error).message}` }); + } + continue; + } + + const ctx: GeneratorContext = { + profile: deps.profile, + project, + projectApi: deps.projectApi, + buildApi: deps.buildApi, + runner: deps.runner, + nonce: deps.nonce, + }; + + for (const f of features) { + if (f.skipTiers?.includes(tier)) { + result.skipped.push({ tier, feature: f.key, reason: `not applicable on tier "${tier}"` }); + continue; + } + const pre = checkFeaturePreconditions(deps.profile, { + feature: f.key, + requiredFlags: f.requiredFlags, + requiresApp: f.requiresApp, + }); + if (!pre.ok) { + result.skipped.push({ tier, feature: f.key, reason: pre.skippedReason ?? 'precondition failed' }); + continue; + } + try { + const builds = await f.run(ctx); + for (const b of builds) result.builds.push({ ...b, tier, projectSlug: project.slug }); + } catch (err) { + // Isolate: one feature failing does not abort the pass. + result.skipped.push({ tier, feature: f.key, reason: `error: ${(err as Error).message}` }); + } + } + } + + return result; +} diff --git a/src/percy/auth.ts b/src/percy/auth.ts new file mode 100644 index 0000000..10beb5b --- /dev/null +++ b/src/percy/auth.ts @@ -0,0 +1,30 @@ +/** + * Auth header builders. Verified against percy-api policies: + * - project mutations / approval / regions / AI need a USER principal + * (ProjectPolicy#update? => is_user_token?; BuildPolicy#_approve? rejects project tokens) + * - build capture/finalize uses a project WRITE token; polling uses a READ token + * - reviewBuild uses BrowserStack Basic auth + */ +import type { ResolvedProfile } from '../profile/schema'; + +export function userAuthHeaders(profile: ResolvedProfile): Record { + const s = profile.secrets; + if (s.sessionCookie && s.xsrfToken) { + return { cookie: `_percy_session=${s.sessionCookie}`, 'x-csrf-token': s.xsrfToken }; + } + if (s.userToken) { + return { authorization: `Token token=${s.userToken}` }; + } + throw new Error( + 'This operation needs a user-level principal (userToken, or sessionCookie+xsrfToken). ' + + 'percy-api rejects project tokens for project mutations / approval / regions / AI.', + ); +} + +export function projectTokenHeaders(token: string): Record { + return { authorization: `Token token=${token}` }; +} + +export function basicAuthHeaders(user: string, key: string): Record { + return { authorization: `Basic ${Buffer.from(`${user}:${key}`).toString('base64')}` }; +} diff --git a/src/percy/build-api.test.ts b/src/percy/build-api.test.ts new file mode 100644 index 0000000..d3d1144 --- /dev/null +++ b/src/percy/build-api.test.ts @@ -0,0 +1,76 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createBuildApi } from './build-api'; +import { makeProfile, recorder, okJson, errStatus } from '../testing/fakes'; + +test('createBuild POSTs vnd.api+json with the write token', async () => { + const { http, calls } = recorder([okJson({ data: { id: '900' } })]); + const id = await createBuildApi(makeProfile(), http).createBuild('write-tok', 'feature/x'); + assert.equal(id, '900'); + assert.equal(calls[0].url, 'https://canary.percy.io/api/v1/builds'); + assert.equal(calls[0].contentType, 'application/vnd.api+json'); + assert.equal(calls[0].headers?.authorization, 'Token token=write-tok'); + assert.equal((calls[0].body as any).data.attributes.branch, 'feature/x'); +}); + +test('reviewBuild uses Basic auth against /reviews with the build relationship', async () => { + const { http, calls } = recorder([okJson({})]); + await createBuildApi(makeProfile(), http).reviewBuild('900', 'approve'); + assert.equal(calls[0].url, 'https://canary.percy.io/api/v1/reviews'); + assert.match(calls[0].headers?.authorization ?? '', /^Basic /); + assert.equal((calls[0].body as any).data.attributes.action, 'approve'); + assert.equal((calls[0].body as any).data.relationships.build.data.id, '900'); +}); + +test('reviewBuild tolerates 409 — an auto-approved baseline is not a failure', async () => { + // Orgs whose default branch auto-approves answer 409 "already performed" for every + // baseline. Throwing there would break every generator that approves one. + const { http } = recorder([errStatus(409, 'approve action is already performed on this build')]); + await createBuildApi(makeProfile(), http).reviewBuild('900', 'approve'); +}); + +test('reviewBuild still throws on a real failure', async () => { + const { http } = recorder([errStatus(403, 'forbidden')]); + await assert.rejects( + () => createBuildApi(makeProfile(), http).reviewBuild('900', 'approve'), + /403/, + ); +}); + +test('reviewBuild without BrowserStack creds throws', async () => { + const { http } = recorder([okJson({})]); + const api = createBuildApi(makeProfile({ secrets: { userToken: 'u' } }), http); + await assert.rejects(() => api.reviewBuild('900', 'approve'), /BUILDS_APPROVE|Basic-auth/); +}); + +test('waitForBuildFinished polls until finished (injected sleep/now, no real wait)', async () => { + const states = ['pending', 'processing', 'finished']; + let n = 0; + const { http } = recorder(() => okJson({ data: { attributes: { state: states[Math.min(n++, 2)] } } })); + const st = await createBuildApi(makeProfile(), http).waitForBuildFinished('900', 'read-tok', { + sleep: async () => {}, + now: () => 0, + }); + assert.equal(st.state, 'finished'); + assert.equal(n, 3); +}); + +test('waitForBuildFinished bails on a terminal failure state', async () => { + const { http } = recorder(() => okJson({ data: { attributes: { state: 'failed' } } })); + await assert.rejects( + () => + createBuildApi(makeProfile(), http).waitForBuildFinished('900', 'read-tok', { + sleep: async () => {}, + now: () => 0, + }), + /terminal state "failed"/, + ); +}); + +test('getBuildState surfaces an auth failure (write_only cannot read builds)', async () => { + const { http } = recorder([errStatus(401, 'Unauthorized')]); + await assert.rejects( + () => createBuildApi(makeProfile(), http).getBuildState('900', 'write-only-tok'), + /getBuildState failed \(401\)/, + ); +}); diff --git a/src/percy/build-api.ts b/src/percy/build-api.ts new file mode 100644 index 0000000..15ac92e --- /dev/null +++ b/src/percy/build-api.ts @@ -0,0 +1,136 @@ +/** + * Build REST API + poll-to-completion (plan Unit 3, R3b/R13). + * + * - createBuild/finalizeBuild use the project WRITE token. + * - getBuildState/waitForBuildFinished poll with a READ token (a write_only + * token cannot read builds — the server returns 401, surfaced here). + * - reviewBuild (approve baseline etc.) uses BrowserStack Basic auth; the user + * must carry BUILDS_APPROVE. + */ +import type { HttpClient } from '../http'; +import type { ResolvedProfile } from '../profile/schema'; +import { basicAuthHeaders, projectTokenHeaders } from './auth'; + +export interface BuildState { + state?: string; // pending | processing | finished | failed | ... + reviewState?: string; + totalComparisons?: number; + totalComparisonsFinished?: number; +} + +export type ReviewAction = 'approve' | 'reject' | 'unapprove'; + +export interface WaitOptions { + timeoutMs?: number; + intervalMs?: number; + sleep?: (ms: number) => Promise; + now?: () => number; +} + +const FINISHED = 'finished'; +const FAILED_STATES = new Set(['failed', 'errored', 'expired']); + +export interface BuildApi { + createBuild(writeToken: string, branch: string): Promise; + finalizeBuild(buildId: string, writeToken: string): Promise; + reviewBuild(buildId: string, action: ReviewAction): Promise; + getBuildState(buildId: string, readToken: string): Promise; + waitForBuildFinished(buildId: string, readToken: string, opts?: WaitOptions): Promise; +} + +export function createBuildApi(profile: ResolvedProfile, http: HttpClient): BuildApi { + const api = profile.clientApiUrl; + + function reviewAuth(): Record { + const { browserstackUser, browserstackKey } = profile.secrets; + if (!browserstackUser || !browserstackKey) { + throw new Error( + 'reviewBuild needs BrowserStack Basic-auth creds (browserstackUser/browserstackKey) with BUILDS_APPROVE.', + ); + } + return basicAuthHeaders(browserstackUser, browserstackKey); + } + + const buildApi: BuildApi = { + async createBuild(writeToken, branch) { + const res = await http({ + method: 'POST', + url: `${api}/builds`, + headers: projectTokenHeaders(writeToken), + contentType: 'application/vnd.api+json', + body: { data: { type: 'builds', attributes: { branch } } }, + }); + if (!res.ok) throw new Error(`createBuild failed (${res.status}): ${res.text}`); + return String((res.body as any)?.data?.id); + }, + + async finalizeBuild(buildId, writeToken) { + const res = await http({ + method: 'POST', + url: `${api}/builds/${buildId}/finalize`, + headers: projectTokenHeaders(writeToken), + }); + if (!res.ok) throw new Error(`finalizeBuild failed (${res.status}): ${res.text}`); + }, + + async reviewBuild(buildId, action) { + const res = await http({ + method: 'POST', + url: `${api}/reviews`, + headers: reviewAuth(), + body: { + data: { + type: 'reviews', + attributes: { action }, + relationships: { build: { data: { type: 'builds', id: buildId } } }, + }, + }, + }); + // 409 = the action is already applied. On orgs where the default branch + // auto-approves, every baseline comes back 409 — treating that as a failure + // would break every generator that approves a baseline. + if (res.status === 409) return; + if (!res.ok) throw new Error(`reviewBuild(${action}) failed (${res.status}): ${res.text}`); + }, + + async getBuildState(buildId, readToken) { + const res = await http({ + method: 'GET', + url: `${api}/builds/${buildId}`, + headers: projectTokenHeaders(readToken), + }); + if (!res.ok) throw new Error(`getBuildState failed (${res.status}): ${res.text}`); + const a = (res.body as any)?.data?.attributes ?? {}; + return { + state: a['state'], + reviewState: a['review-state'], + totalComparisons: a['total-comparisons'], + totalComparisonsFinished: a['total-comparisons-finished'], + }; + }, + + async waitForBuildFinished(buildId, readToken, opts = {}) { + const timeoutMs = opts.timeoutMs ?? 300_000; + const intervalMs = opts.intervalMs ?? 2_000; + const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))); + const now = opts.now ?? (() => Date.now()); + const start = now(); + + for (;;) { + const st = await buildApi.getBuildState(buildId, readToken); + if (st.state === FINISHED) return st; + if (st.state && FAILED_STATES.has(st.state)) { + throw new Error(`build ${buildId} reached terminal state "${st.state}" before finishing`); + } + if (now() - start >= timeoutMs) { + throw new Error( + `timed out after ${timeoutMs}ms waiting for build ${buildId} (last state: ${st.state ?? 'unknown'})`, + ); + } + await sleep(intervalMs); + } + }, + }; + + return buildApi; +} diff --git a/src/percy/org-token-api.ts b/src/percy/org-token-api.ts new file mode 100644 index 0000000..45bac91 --- /dev/null +++ b/src/percy/org-token-api.ts @@ -0,0 +1,26 @@ +/** + * Org token API (plan Unit 3). Uses the USER principal. NB: the reference helper + * hardcodes ORG_ID='test' — here the team/org id is a parameter (parameterized). + */ +import type { HttpClient } from '../http'; +import type { ResolvedProfile } from '../profile/schema'; +import { userAuthHeaders } from './auth'; + +export interface OrgTokenApi { + createOrgToken(teamId: string): Promise; +} + +export function createOrgTokenApi(profile: ResolvedProfile, http: HttpClient): OrgTokenApi { + const api = profile.clientApiUrl; + return { + async createOrgToken(teamId) { + const res = await http({ + method: 'POST', + url: `${api}/organizations/${teamId}/tokens`, + headers: userAuthHeaders(profile), + }); + if (!res.ok) throw new Error(`createOrgToken failed (${res.status}): ${res.text}`); + return String((res.body as any)?.data?.attributes?.token); + }, + }; +} diff --git a/src/percy/project-api.test.ts b/src/percy/project-api.test.ts new file mode 100644 index 0000000..21fc8a8 --- /dev/null +++ b/src/percy/project-api.test.ts @@ -0,0 +1,70 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createProjectApi } from './project-api'; +import { makeProfile, recorder, okJson } from '../testing/fakes'; + +test('createProject POSTs /projects with Basic auth (BrowserStack user:key)', async () => { + const { http, calls } = recorder([okJson({ data: { id: '42', attributes: { slug: 'org/seed-web' } } })]); + const out = await createProjectApi(makeProfile(), http).createProject('seed-web', 'web'); + assert.deepEqual(out, { id: '42', slug: 'org/seed-web' }); + assert.equal(calls[0].method, 'POST'); + assert.equal(calls[0].url, 'https://canary.percy.io/api/v1/projects'); + assert.match(calls[0].headers?.authorization ?? '', /^Basic /); + assert.equal((calls[0].body as any).data.attributes.name, 'seed-web'); + assert.equal((calls[0].body as any).data.attributes.type, 'web'); +}); + +test('createProject requires BrowserStack Basic-auth creds', async () => { + const { http } = recorder([okJson({})]); + const api = createProjectApi(makeProfile({ secrets: { userToken: 'u' } }), http); + await assert.rejects(() => api.createProject('x'), /Basic-auth creds/); +}); + +test('setAutoApprove PATCHes /projects/{slug} with the branch filter (Basic auth)', async () => { + const { http, calls } = recorder([okJson({})]); + await createProjectApi(makeProfile(), http).setAutoApprove('org/seed-web', 'auto/*'); + assert.equal(calls[0].method, 'PATCH'); + assert.equal(calls[0].url, 'https://canary.percy.io/api/v1/projects/org/seed-web'); + assert.match(calls[0].headers?.authorization ?? '', /^Basic /); + assert.equal((calls[0].body as any).data.attributes['auto-approve-branch-filter'], 'auto/*'); +}); + +test('fetchProjectToken returns the matching-role token (read_only)', async () => { + const { http } = recorder([ + okJson({ + data: [ + { attributes: { role: 'write_only', token: 'w1' } }, + { attributes: { role: 'read_only', token: 'r1' } }, + ], + }), + ]); + assert.equal(await createProjectApi(makeProfile(), http).fetchProjectToken('42', 'read_only'), 'r1'); +}); + +test('fetchProjectToken throws (with UI hint) when the role is absent', async () => { + const { http } = recorder([okJson({ data: [{ attributes: { role: 'write_only', token: 'w1' } }] })]); + await assert.rejects( + () => createProjectApi(makeProfile(), http).fetchProjectToken('42', 'read_only'), + /No "read_only" token/, + ); +}); + +test('editProject fails loudly when a 200 did not actually apply the setting', async () => { + // Percy answers 200 for unknown attribute keys and ignores them. Without this + // read-back the caller sees success while the setting stays false — which is how a + // broken settings write survived a full live run. + const { http } = recorder([ + okJson({ data: { attributes: { 'ignore-carousels-enabled': false } } }), + ]); + await assert.rejects( + () => createProjectApi(makeProfile(), http).editProject('o/p', { 'ignore-carousels-enabled': true }), + /did not apply/, + ); +}); + +test('editProject passes when the setting is reflected back', async () => { + const { http } = recorder([ + okJson({ data: { attributes: { 'ignore-carousels-enabled': true } } }), + ]); + await createProjectApi(makeProfile(), http).editProject('o/p', { 'ignore-carousels-enabled': true }); +}); diff --git a/src/percy/project-api.ts b/src/percy/project-api.ts new file mode 100644 index 0000000..51ded35 --- /dev/null +++ b/src/percy/project-api.ts @@ -0,0 +1,123 @@ +/** + * Project REST API — public Percy contract (Unit 3 / provisioning). + * + * Per the public docs, account-level actions (create/update project) authenticate + * with HTTP Basic auth = BrowserStack username + access key. There is no public + * bearer "user token" for project creation. + * - Create project: POST /api/v1/projects (Basic auth; org inferred from creds) + * - Update project: PATCH /api/v1/projects/{idOrSlug} + * - Project token: linked via a `tokens` relationship on the create response; + * the concrete API fetch is NOT fully publicly documented (UI is the solid path), + * so treat fetchProjectToken as verify-on-first-run. + * Refs: browserstack.com/docs/percy/api-reference/{projects,authentication}. + */ +import type { HttpClient } from '../http'; +import type { ResolvedProfile } from '../profile/schema'; +import { basicAuthHeaders } from './auth'; + +export type ProjectType = 'web' | 'app'; +// Verified live against percy.io: the tokens endpoint returns these role names. +export type ProjectTokenRole = 'write_only' | 'read_only' | 'master' | 'full_access'; + +export interface ProjectApi { + createProject(name: string, type?: ProjectType): Promise<{ id: string; slug: string }>; + editProject(idOrSlug: string, attributes: Record): Promise; + setAutoApprove(idOrSlug: string, branchFilter: string): Promise; + fetchProjectToken(idOrSlug: string, role: ProjectTokenRole): Promise; +} + +/** Account/control-plane auth = BrowserStack username + access key (Basic auth). */ +function controlAuth(profile: ResolvedProfile): Record { + const { browserstackUser, browserstackKey } = profile.secrets; + if (!browserstackUser || !browserstackKey) { + throw new Error( + 'Creating/editing a project needs BrowserStack Basic-auth creds ' + + '(browserstackUser + browserstackKey). Percy authenticates account-level ' + + 'actions with your BrowserStack username + access key, not a bearer token.', + ); + } + return basicAuthHeaders(browserstackUser, browserstackKey); +} + +export function createProjectApi(profile: ResolvedProfile, http: HttpClient): ProjectApi { + const api = profile.clientApiUrl; // e.g. https://percy.io/api/v1 (or the target env) + + const projectApi: ProjectApi = { + async createProject(name, type = 'web') { + const res = await http({ + method: 'POST', + url: `${api}/projects`, + headers: controlAuth(profile), + body: { data: { type: 'projects', attributes: { name, type } } }, + }); + if (!res.ok) throw new Error(`createProject failed (${res.status}): ${res.text}`); + const data = (res.body as any)?.data; + // Verified live: token/edit endpoints are addressed by the FULL-slug + // ("orgSlug/projectSlug"), e.g. /api/v1/projects/9560f98d/proj/tokens. + const slug = data?.attributes?.['full-slug'] ?? data?.attributes?.slug ?? name; + return { id: String(data?.id), slug: String(slug) }; + }, + + /** + * PATCH project settings. + * + * Attribute keys MUST be dash-cased (`ignore-carousels-enabled`). Percy answers + * **200 for unknown keys and silently ignores them**, so a snake_cased payload + * looks like a success while changing nothing — that hid a broken settings write + * until a live run showed the setting still false afterwards. We therefore read + * the returned attributes back and fail loudly when a value did not stick. + */ + async editProject(idOrSlug, attributes) { + const res = await http({ + method: 'PATCH', + url: `${api}/projects/${idOrSlug}`, + headers: controlAuth(profile), + body: { data: { attributes } }, + }); + if (!res.ok) throw new Error(`editProject failed (${res.status}): ${res.text}`); + + const applied = (res.body as any)?.data?.attributes; + if (!applied) return; // nothing to verify against + const ignored = Object.entries(attributes).filter( + ([key, value]) => key in applied && applied[key] !== value, + ); + if (ignored.length) { + throw new Error( + `editProject reported 200 but these settings did not apply: ` + + ignored.map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(', ') + + '. Percy ignores unknown attribute keys — they must be dash-cased.', + ); + } + }, + + async setAutoApprove(idOrSlug, branchFilter) { + return projectApi.editProject(idOrSlug, { 'auto-approve-branch-filter': branchFilter }); + }, + + async fetchProjectToken(idOrSlug, role) { + // NOT fully public-documented — verify on first run (Unit 0); UI is the fallback. + const res = await http({ + method: 'GET', + url: `${api}/projects/${idOrSlug}/tokens`, + headers: controlAuth(profile), + }); + if (!res.ok) { + throw new Error( + `fetchProjectToken failed (${res.status}): ${res.text}. ` + + 'Token fetch is not fully public-documented — the token may only be ' + + 'retrievable from the Project Settings UI.', + ); + } + const tokens = ((res.body as any)?.data ?? []) as Array<{ + attributes?: { role?: string; token?: string }; + }>; + const match = tokens.find((t) => t?.attributes?.role === role); + if (!match?.attributes?.token) { + throw new Error(`No "${role}" token found for project ${idOrSlug}.`); + } + return String(match.attributes.token); + }, + }; + + return projectApi; +} diff --git a/src/preconditions.test.ts b/src/preconditions.test.ts new file mode 100644 index 0000000..767f656 --- /dev/null +++ b/src/preconditions.test.ts @@ -0,0 +1,37 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { assertSafeName, checkFeaturePreconditions } from './preconditions'; +import { makeProfile } from './testing/fakes'; + +test('assertSafeName rejects reserved substrings (canary/light/priority)', () => { + assert.throws(() => assertSafeName('canary-seed'), /reserved substring/); + assert.throws(() => assertSafeName('priority-web'), /reserved/); + assert.doesNotThrow(() => assertSafeName('seed-paid-web')); +}); + +test('precondition skips when a required flag is not declared for the env', () => { + const r = checkFeaturePreconditions(makeProfile({ expectedFlags: [] }), { + feature: 'ai', + requiredFlags: ['ai'], + }); + assert.equal(r.ok, false); + assert.match(r.skippedReason!, /ai/); +}); + +test('precondition skips app features when creds/binary are missing', () => { + const r = checkFeaturePreconditions(makeProfile({ secrets: { userToken: 'u' }, appBinaryId: undefined }), { + feature: 'app-percy', + requiredFlags: [], + requiresApp: true, + }); + assert.equal(r.ok, false); +}); + +test('precondition passes when flags declared and app creds present', () => { + const p = makeProfile({ expectedFlags: ['ai'] }); + assert.equal(checkFeaturePreconditions(p, { feature: 'ai', requiredFlags: ['ai'] }).ok, true); + assert.equal( + checkFeaturePreconditions(p, { feature: 'app-percy', requiredFlags: [], requiresApp: true }).ok, + true, + ); +}); diff --git a/src/preconditions.ts b/src/preconditions.ts new file mode 100644 index 0000000..d4e1281 --- /dev/null +++ b/src/preconditions.ts @@ -0,0 +1,51 @@ +/** + * Fail-fast precondition gate (plan Unit 8, R2). + * + * IMPORTANT: this checks the profile's DECLARED expectations (flags it says are on, + * creds present) — it cannot read an environment's live LaunchDarkly state. A flag + * that is actually OFF in the env is only caught by post-build inert-detection. + */ +import type { ResolvedProfile } from './profile/schema'; + +/** Substrings the renderer/queue routes on by string surgery — must not appear in names. */ +export const RESERVED_SUBSTRINGS = ['canary', 'light', 'priority']; + +export interface FeatureRequirement { + feature: string; + /** flags that must be declared ON in the profile */ + requiredFlags: string[]; + /** needs prod-hub BrowserStack creds + a binary (App Percy) */ + requiresApp?: boolean; +} + +export interface PreconditionResult { + ok: boolean; + skippedReason?: string; +} + +/** Reject names containing a reserved substring (they get silently re-queued). */ +export function assertSafeName(name: string): void { + const bad = RESERVED_SUBSTRINGS.find((s) => name.toLowerCase().includes(s)); + if (bad) { + throw new Error( + `name "${name}" contains reserved substring "${bad}" — the renderer re-queues these. Rename it.`, + ); + } +} + +export function checkFeaturePreconditions( + profile: ResolvedProfile, + req: FeatureRequirement, +): PreconditionResult { + const missingFlags = req.requiredFlags.filter((f) => !profile.expectedFlags.includes(f)); + if (missingFlags.length) { + return { ok: false, skippedReason: `required flag(s) not declared for env: ${missingFlags.join(', ')}` }; + } + if (req.requiresApp && !profile.runLocalApp) { + const { browserstackUser, browserstackKey } = profile.secrets; + if (!browserstackUser || !browserstackKey || !profile.appBinaryId) { + return { ok: false, skippedReason: 'App Percy needs prod-hub BrowserStack creds + BS_APP_ID (or runLocalApp)' }; + } + } + return { ok: true }; +} diff --git a/src/profile/loader.test.ts b/src/profile/loader.test.ts new file mode 100644 index 0000000..76e067a --- /dev/null +++ b/src/profile/loader.test.ts @@ -0,0 +1,63 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { resolveProfile, ProfileError } from './loader'; +import type { ProfileFile } from './schema'; + +const REPO = '/home/dev/percy/percy-feature-testbed'; +const base: ProfileFile = { + env: 'canary', + baseUrl: 'https://canary.percy.io', + secrets: { userToken: 'UT' }, + expectedFlags: ['ai'], +}; + +test('resolves a canary profile: clientApiUrl, secrets, upstream siblings', () => { + const p = resolveProfile(base, { repoRoot: REPO, nonceSeed: 'seed123', env: { UT: 'tok-abcd' } }); + assert.equal(p.clientApiUrl, 'https://canary.percy.io/api/v1'); + assert.equal(p.secrets.userToken, 'tok-abcd'); + assert.equal(p.disableTls, false); + assert.equal(p.nonceSeed, 'seed123'); + assert.ok(p.upstream.seedAccounts.endsWith('percy-api-seed-accounts')); +}); + +test('refuses production always (even with confirmation)', () => { + assert.throws( + () => resolveProfile({ ...base, env: 'prod', secrets: {} }, { repoRoot: REPO, env: {}, confirmNonAllowlisted: true }), + ProfileError, + ); +}); + +test('preprod requires explicit confirmation', () => { + const pp: ProfileFile = { ...base, env: 'preprod', secrets: {} }; + assert.throws(() => resolveProfile(pp, { repoRoot: REPO, env: {} }), ProfileError); + const ok = resolveProfile(pp, { repoRoot: REPO, env: {}, confirmNonAllowlisted: true }); + assert.equal(ok.env, 'preprod'); +}); + +test('missing declared secret env var fails fast, naming the var', () => { + try { + resolveProfile(base, { repoRoot: REPO, env: {} }); // UT unset + assert.fail('expected ProfileError'); + } catch (err) { + assert.ok(err instanceof ProfileError); + assert.ok((err as Error).message.includes('UT'), 'message should name the missing env var'); + } +}); + +test('normalizes a trailing slash in baseUrl (no //api/v1)', () => { + const p = resolveProfile( + { ...base, baseUrl: 'https://canary.percy.io/' }, + { repoRoot: REPO, env: { UT: 'x' } }, + ); + assert.equal(p.clientApiUrl, 'https://canary.percy.io/api/v1'); +}); + +test('local env sets disableTls true (dev cert)', () => { + const p = resolveProfile({ ...base, env: 'local', secrets: {} }, { repoRoot: REPO, env: {} }); + assert.equal(p.disableTls, true); +}); + +test('rejects an unknown secret key', () => { + const bad = { ...base, secrets: { bogus: 'X' } } as unknown as ProfileFile; + assert.throws(() => resolveProfile(bad, { repoRoot: REPO, env: { X: 'y' } }), ProfileError); +}); diff --git a/src/profile/loader.ts b/src/profile/loader.ts new file mode 100644 index 0000000..6a61706 --- /dev/null +++ b/src/profile/loader.ts @@ -0,0 +1,106 @@ +/** + * Profile loader + resolver (plan Unit 2, R2/R2a/R6-token-side/R5-secret-safe). + * + * `resolveProfile` is pure (takes a ProfileFile + env) so it is fully unit-testable. + * `loadProfile` dynamically imports profiles/.js and delegates to it. + */ +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { resolveUpstreamPaths } from '../upstream'; +import { + NON_PROD_ALLOWLIST, + SECRET_KEYS, + type ProfileFile, + type ResolvedProfile, + type ResolvedSecrets, + type SecretKey, +} from './schema'; + +export class ProfileError extends Error {} + +export interface ResolveOptions { + env?: NodeJS.ProcessEnv; + repoRoot?: string; + /** injected in tests; otherwise derived per run */ + nonceSeed?: string; + /** required to target an env not on the non-prod allow-list (e.g. preprod) */ + confirmNonAllowlisted?: boolean; +} + +function stripTrailingSlash(u: string): string { + return u.replace(/\/+$/, ''); +} + +function defaultNonceSeed(): string { + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +/** + * Validate the allow-list, derive the client API URL, and resolve every declared + * secret reference from the environment (fail-fast, naming any missing var). + */ +export function resolveProfile(file: ProfileFile, opts: ResolveOptions = {}): ResolvedProfile { + const env = opts.env ?? process.env; + const repoRoot = opts.repoRoot ?? process.cwd(); + + // Allow-list gate (R2a). Production is never a valid target. + if (file.env === 'prod') { + throw new ProfileError('Refusing to target production — this seeder never runs against prod.'); + } + if (!NON_PROD_ALLOWLIST.includes(file.env) && !opts.confirmNonAllowlisted) { + throw new ProfileError( + `Environment "${file.env}" is not on the non-prod allow-list ${JSON.stringify(NON_PROD_ALLOWLIST)}. ` + + 'Pass explicit confirmation to target it (preprod is treated as production-adjacent).', + ); + } + + // Resolve declared secret refs from env (fail-fast, naming the missing var). + const secrets: ResolvedSecrets = {}; + for (const [logical, envVar] of Object.entries(file.secrets ?? {})) { + if (!(SECRET_KEYS as readonly string[]).includes(logical)) { + throw new ProfileError(`Unknown secret key "${logical}" in profile "${file.env}".`); + } + const value = envVar ? env[envVar] : undefined; + if (!value) { + throw new ProfileError( + `Secret "${logical}" references env var ${envVar}, which is unset. ` + + 'Set it in your environment (never inline secrets in the profile file).', + ); + } + secrets[logical as SecretKey] = value; + } + + const appBinaryId = file.appBinaryIdEnv ? env[file.appBinaryIdEnv] : undefined; + const appPath = file.app?.appPathEnv ? env[file.app.appPathEnv] : undefined; + const upstream = { ...resolveUpstreamPaths(repoRoot, env), ...(file.upstream ?? {}) }; + + return { + env: file.env, + baseUrl: file.baseUrl, + clientApiUrl: `${stripTrailingSlash(file.baseUrl)}/api/v1`, + disableTls: file.env === 'local', + secrets, + appBinaryId, + runLocalApp: file.app?.runLocal ?? false, + appPath, + expectedFlags: file.expectedFlags ?? [], + upstream, + nonceSeed: opts.nonceSeed ?? defaultNonceSeed(), + }; +} + +/** Dynamically import profiles/.js and resolve it. */ +export async function loadProfile(name: string, opts: ResolveOptions = {}): Promise { + const repoRoot = opts.repoRoot ?? process.cwd(); + const file = path.join(repoRoot, 'profiles', `${name}.js`); + let mod: { default?: ProfileFile }; + try { + mod = (await import(pathToFileURL(file).href)) as { default?: ProfileFile }; + } catch (err) { + throw new ProfileError(`Could not load profile "${name}" from ${file}: ${(err as Error).message}`); + } + if (!mod.default) { + throw new ProfileError(`Profile "${name}" (${file}) has no default export.`); + } + return resolveProfile(mod.default, { ...opts, repoRoot }); +} diff --git a/src/profile/schema.ts b/src/profile/schema.ts new file mode 100644 index 0000000..4603452 --- /dev/null +++ b/src/profile/schema.ts @@ -0,0 +1,56 @@ +/** + * Profile types + the non-prod allow-list (plan Unit 2, R2/R2a). + * + * A profile FILE (profiles/.js) holds only non-secret config plus the NAMES + * of the env vars that carry each secret. Resolution reads those env vars at + * runtime into a ResolvedProfile. Secrets are never inlined in files. + */ +import type { UpstreamPaths } from '../upstream'; + +export type EnvName = 'local' | 'staging' | 'canary' | 'preprod' | 'prod'; + +/** Environments the seeder may target without extra confirmation. Prod is never allowed. */ +export const NON_PROD_ALLOWLIST: readonly EnvName[] = ['local', 'staging', 'canary']; + +/** Logical secret keys a profile may declare (each maps to an env-var NAME in the file). */ +export const SECRET_KEYS = [ + 'userToken', // user-level principal — mutations/approval/regions/AI + 'sessionCookie', // alternative user auth (with xsrfToken) + 'xsrfToken', + 'browserstackUser', // App Percy (prod hub) + 'browserstackKey', +] as const; + +export type SecretKey = (typeof SECRET_KEYS)[number]; + +/** Raw shape of a profile file's default export. */ +export interface ProfileFile { + env: EnvName; + baseUrl: string; + /** logical secret name -> NAME of the env var carrying its value */ + secrets?: Partial>; + /** env var carrying bs:// for the App Percy binary */ + appBinaryIdEnv?: string; + expectedFlags?: string[]; + app?: { runLocal?: boolean; appPathEnv?: string }; + upstream?: Partial; +} + +export type ResolvedSecrets = Partial>; + +export interface ResolvedProfile { + env: EnvName; + baseUrl: string; + /** always `${baseUrl}/api/v1` — injected on every capture so builds don't land on prod */ + clientApiUrl: string; + /** local mkcert dev cert only — NEVER true for a shared/remote env (would enable MITM) */ + disableTls: boolean; + secrets: ResolvedSecrets; + appBinaryId?: string; + runLocalApp: boolean; + appPath?: string; + expectedFlags: string[]; + upstream: UpstreamPaths; + /** per-run nonce seed; injected in tests for reproducibility */ + nonceSeed: string; +} diff --git a/src/provisioning/accounts.test.ts b/src/provisioning/accounts.test.ts new file mode 100644 index 0000000..04938ad --- /dev/null +++ b/src/provisioning/accounts.test.ts @@ -0,0 +1,44 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { provisionProject, runLocalSeedRake } from './accounts'; +import { createProjectApi } from '../percy/project-api'; +import { makeProfile, recorder, okJson } from '../testing/fakes'; +import type { Runner } from '../exec'; + +test('provisionProject creates a project then fetches write + read tokens', async () => { + const { http, calls } = recorder((req) => { + if (req.method === 'POST') return okJson({ data: { id: '7', attributes: { slug: 'org9/seed-web' } } }); + return okJson({ + data: [ + { attributes: { role: 'write_only', token: 'w' } }, + { attributes: { role: 'read_only', token: 'r' } }, + ], + }); + }); + const sp = await provisionProject(createProjectApi(makeProfile(), http), 'seed-web'); + assert.equal(sp.id, '7'); + assert.equal(sp.slug, 'org9/seed-web'); + assert.equal(sp.writeToken, 'w'); + assert.equal(sp.readToken, 'r'); + assert.equal(sp.teamId, 'org9'); // derived from the full-slug prefix + assert.equal(calls[0].method, 'POST'); // create first + assert.equal(calls[0].url, 'https://canary.percy.io/api/v1/projects'); +}); + +test('provisionProject rejects a reserved project name before creating', async () => { + const { http } = recorder([okJson({})]); + await assert.rejects( + () => provisionProject(createProjectApi(makeProfile(), http), 'canary-seed'), + /reserved/, + ); +}); + +test('runLocalSeedRake shells the dev-only rake with the tier arg', async () => { + let seen: string[] = []; + const runner: Runner = async (_c, args) => { + seen = args; + return { stdout: '', stderr: '', code: 0 }; + }; + await runLocalSeedRake(runner, 'paid'); + assert.ok(seen.join(' ').includes('dev:seed_test_accounts[paid]')); +}); diff --git a/src/provisioning/accounts.ts b/src/provisioning/accounts.ts new file mode 100644 index 0000000..8dd7d26 --- /dev/null +++ b/src/provisioning/accounts.ts @@ -0,0 +1,44 @@ +/** + * Account/project provisioning (plan Unit 8, R6). + * + * - local: shell the dev-only rake (`dev:seed_test_accounts`) to fabricate the + * matrix, then create projects + fetch tokens. + * - shared: reference pre-provisioned accounts (the rake is Rails.env.development? + * gated and cannot run) and fetch tokens. + * + * The shared-env mechanism for resolving a team/org id + a user principal + tokens + * is a Phase-0 confirmation item (see plan Open Questions). + */ +import type { Runner } from '../exec'; +import type { ProjectApi, ProjectType } from '../percy/project-api'; +import type { SeededProject } from '../generators/context'; +import { assertSafeName } from '../preconditions'; + +/** Run the dev-only seed rake inside the api container to fabricate the matrix (local). */ +export async function runLocalSeedRake(runner: Runner, tier?: string): Promise { + const task = tier ? `dev:seed_test_accounts[${tier}]` : 'dev:seed_test_accounts'; + const res = await runner('docker', ['compose', 'exec', '-T', 'api', 'bundle', 'exec', 'rake', task], { + timeoutMs: 300_000, + }); + if (res.code && res.code !== 0) { + throw new Error(`seed rake "${task}" failed (exit ${res.code}): ${res.stderr.slice(-300)}`); + } +} + +/** + * Create a project (via the account's Basic-auth creds) and fetch its write + read + * tokens — the user-requested flow: create project -> fetch token -> (build elsewhere). + * teamId is derived from the full-slug prefix when present (e.g. "orgid/proj"). + */ +export async function provisionProject( + projectApi: ProjectApi, + projectName: string, + type: ProjectType = 'web', +): Promise { + assertSafeName(projectName); + const { id, slug } = await projectApi.createProject(projectName, type); + const writeToken = await projectApi.fetchProjectToken(id, 'write_only'); + const readToken = await projectApi.fetchProjectToken(id, 'read_only'); + const teamId = slug.includes('/') ? slug.split('/')[0] : undefined; + return { id, slug, teamId, writeToken, readToken }; +} diff --git a/src/report.test.ts b/src/report.test.ts new file mode 100644 index 0000000..6a0729e --- /dev/null +++ b/src/report.test.ts @@ -0,0 +1,33 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { formatRunSummary, buildDeepLink, assertNoSecret } from './report'; +import { makeProfile } from './testing/fakes'; +import type { RunResult } from './orchestrator'; + +test('summary labels each build with requirement + feature + a token-free link', () => { + const profile = makeProfile(); + const result: RunResult = { + builds: [ + { + feature: 'recurring-diff', + requirement: 'R11', + label: 'recurring diff build #1', + buildId: '981', + tier: 'paid', + projectSlug: 'seed-paid-web', + }, + ], + skipped: [{ tier: 'ai_off', feature: 'ai', reason: 'not applicable on tier "ai_off"' }], + }; + const out = formatRunSummary(result, profile); + assert.match(out, /\[R11\] paid\/seed-paid-web — recurring diff build #1/); + assert.match(out, /https:\/\/canary\.percy\.io\/builds\/981/); + assert.match(out, /Skipped/); + assert.match(out, /ai_off\/ai/); +}); + +test('deep links carry no tokens; assertNoSecret rejects one that does', () => { + const profile = makeProfile(); + assert.equal(buildDeepLink(profile, '981'), 'https://canary.percy.io/builds/981'); + assert.throws(() => assertNoSecret('https://x/builds/1?token=abc'), /secret/); +}); diff --git a/src/report.ts b/src/report.ts new file mode 100644 index 0000000..fb93eb1 --- /dev/null +++ b/src/report.ts @@ -0,0 +1,49 @@ +/** + * Feature-labeled run summary (plan Unit 10, R5). Prints each created build with the + * requirement/feature it demonstrates + a token-free dashboard deep-link, plus a + * "skipped" section. Deep-links must never embed secrets (guarded). + */ +import type { ResolvedProfile } from './profile/schema'; +import type { RunResult } from './orchestrator'; + +/** Deep-link built from base URL + build id only — never a token/session. */ +export function buildDeepLink(profile: ResolvedProfile, buildId?: string): string { + return buildId ? `${profile.baseUrl}/builds/${buildId}` : '(no build id)'; +} + +const SECRET_HINT = /token=|access[_-]?key|password|_percy_session|x-csrf/i; + +export function assertNoSecret(link: string): void { + if (SECRET_HINT.test(link)) { + throw new Error(`refusing to emit a link that may contain a secret: ${link.slice(0, 40)}…`); + } +} + +export function formatRunSummary(result: RunResult, profile: ResolvedProfile): string { + const lines: string[] = []; + lines.push(`Percy Feature Testbed — run summary (env: ${profile.env})`); + lines.push(`Created ${result.builds.length} build(s); skipped ${result.skipped.length}.`); + lines.push(''); + + if (result.builds.length) { + lines.push('Builds (open each to verify its feature):'); + for (const b of result.builds) { + const link = buildDeepLink(profile, b.buildId); + assertNoSecret(link); + lines.push(` [${b.requirement}] ${b.tier}/${b.projectSlug} — ${b.label} -> ${link}`); + // The expectation is what makes a rich-DOM build checkable rather than just + // something to look at — without it "no diffs" is indistinguishable from a no-op. + if (b.expectation) lines.push(` expect: ${b.expectation}`); + } + } + + if (result.skipped.length) { + lines.push(''); + lines.push('Skipped (precondition unmet / not applicable / error):'); + for (const s of result.skipped) { + lines.push(` ${s.tier}/${s.feature} — ${s.reason}`); + } + } + + return lines.join('\n'); +} diff --git a/src/run.ts b/src/run.ts new file mode 100644 index 0000000..5e28293 --- /dev/null +++ b/src/run.ts @@ -0,0 +1,40 @@ +/** + * Real dispatch: assemble live dependencies and run the one-pass orchestrator, then + * print the run summary. Wired into the CLI as the default dispatch. + * + * The per-tier `provisionProject` is the one genuinely live/deferred piece: on shared + * envs the account matrix must already exist and a user principal + tokens must be + * fetchable — the exact mechanism is a Phase-0 confirmation item (plan Unit 8). Until + * Phase-0 defines it, this throws a clear, actionable error rather than faking success. + */ +import type { CliConfig } from './cli'; +import { loadProfile } from './profile/loader'; +import { fetchHttpClient } from './http'; +import { spawnRunner } from './exec'; +import { createProjectApi } from './percy/project-api'; +import { createBuildApi } from './percy/build-api'; +import { provisionProject } from './provisioning/accounts'; +import { orchestrate, type OrchestratorDeps } from './orchestrator'; +import { formatRunSummary } from './report'; + +export async function realDispatch(config: CliConfig): Promise { + const profile = await loadProfile(config.profile, { repoRoot: process.cwd() }); + const projectApi = createProjectApi(profile, fetchHttpClient); + const buildApi = createBuildApi(profile, fetchHttpClient); + + const deps: OrchestratorDeps = { + profile, + projectApi, + buildApi, + runner: spawnRunner, + nonce: profile.nonceSeed, + // Real provisioning (public Percy flow): create a project via the account's + // Basic-auth creds, then fetch its write + read tokens. One project per tier. + // NOTE: fetchProjectToken is not fully public-documented — validate on first run. + provisionProject: (tier) => + provisionProject(projectApi, `testbed-${tier}-${profile.nonceSeed}`, 'web'), + }; + + const result = await orchestrate(config, deps); + console.log(formatRunSummary(result, profile)); +} diff --git a/src/testing/fakes.ts b/src/testing/fakes.ts new file mode 100644 index 0000000..624aa27 --- /dev/null +++ b/src/testing/fakes.ts @@ -0,0 +1,135 @@ +/** + * Shared test doubles: a fake HttpClient recorder and a profile factory. Reused + * across the Percy REST + generator + orchestrator tests so nothing hits the network. + */ +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { HttpClient, HttpRequest, HttpResponse } from '../http'; +import { aiFixtureDir } from '../generators/ai'; +import type { ResolvedProfile } from '../profile/schema'; +import type { ExecResult, Runner } from '../exec'; +import type { GeneratorContext, SeededProject } from '../generators/context'; +import { createBuildApi } from '../percy/build-api'; +import { createProjectApi } from '../percy/project-api'; + +export function makeProfile(overrides: Partial = {}): ResolvedProfile { + return { + env: 'canary', + baseUrl: 'https://canary.percy.io', + clientApiUrl: 'https://canary.percy.io/api/v1', + disableTls: false, + secrets: { userToken: 'user-tok', browserstackUser: 'bsuser', browserstackKey: 'bskey' }, + appBinaryId: 'bs://app-hash', + runLocalApp: false, + expectedFlags: [], + upstream: { seedAccounts: '/up/seed', percyPlaywright: '/up/pw' }, + nonceSeed: 'seed', + ...overrides, + }; +} + +/** + * A stand-in `percy_playwright` checkout carrying the `test_bed/ai` fixtures the AI + * generator renders. Returns the root path to point a profile's upstream at. + */ +export function makeFakeUpstreamPlaywright(): string { + const root = mkdtempSync(join(tmpdir(), 'fake-pw-')); + for (const suite of ['build-summary', 'ai-details'] as const) { + for (const side of ['baseline', 'changed'] as const) { + const dir = aiFixtureDir(root, suite, side); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'page.html'), `${suite}-${side}`); + } + } + return root; +} + +export function okJson(body: unknown, status = 200): HttpResponse { + return { status, ok: status >= 200 && status < 300, body, text: JSON.stringify(body) }; +} + +export function errStatus(status: number, text = ''): HttpResponse { + return { status, ok: false, body: undefined, text }; +} + +export interface Recorder { + http: HttpClient; + calls: HttpRequest[]; +} + +/** + * A fake HttpClient that records requests. Provide either an array of responses + * (the last repeats once exhausted) or a function computing a response per call. + */ +export function recorder( + responses: HttpResponse[] | ((req: HttpRequest, index: number) => HttpResponse), +): Recorder { + const calls: HttpRequest[] = []; + let i = 0; + const http: HttpClient = async (req) => { + calls.push(req); + if (typeof responses === 'function') return responses(req, i++); + const r = responses[Math.min(i, responses.length - 1)] ?? okJson({}); + i++; + return r; + }; + return { http, calls }; +} + +export const FAKE_PROJECT: SeededProject = { + id: 'p1', + slug: 'seed-paid-web', + teamId: 'team-1', + writeToken: 'write-tok', + readToken: 'read-tok', +}; + +export interface RunnerCall { + command: string; + args: string[]; + env: NodeJS.ProcessEnv; +} + +/** A Runner that finalizes a distinct build id per call and records the invocations. */ +export function finalizingRunner(): { runner: Runner; calls: RunnerCall[] } { + const calls: RunnerCall[] = []; + let n = 900; + const runner: Runner = async (command, args, opts) => { + calls.push({ command, args, env: opts?.env ?? {} }); + const id = ++n; + const res: ExecResult = { + stdout: `Finalized build #${id}: https://canary.percy.io/o/p/builds/${id}`, + stderr: '', + code: 0, + }; + return res; + }; + return { runner, calls }; +} + +/** + * A GeneratorContext wired to a finalizing runner and a build API whose GETs report + * `finished`. Exposes the runner calls (for env/branch assertions) and http calls + * (for PATCH/review assertions). + */ +export function makeGeneratorContext( + opts: { reviewState?: string; nonce?: string; profile?: Partial } = {}, +): { ctx: GeneratorContext; runnerCalls: RunnerCall[]; httpCalls: HttpRequest[] } { + const { runner, calls: runnerCalls } = finalizingRunner(); + const rec = recorder((req) => + req.method === 'GET' + ? okJson({ data: { attributes: { state: 'finished', 'review-state': opts.reviewState ?? 'unreviewed' } } }) + : okJson({}), + ); + const profile = makeProfile(opts.profile ?? {}); + const ctx: GeneratorContext = { + profile, + project: FAKE_PROJECT, + projectApi: createProjectApi(profile, rec.http), + buildApi: createBuildApi(profile, rec.http), + runner, + nonce: opts.nonce ?? 'n1', + }; + return { ctx, runnerCalls, httpCalls: rec.calls }; +}