Skip to main content

Testing

See also: Local Development · CI CD and Deploys · Tooling and Scripts

Four layers: unit tests (Vitest), Storybook interaction tests, Playwright e2e (web + mobile + extension), and frozen Cypress e2e (web + mobile).

Reproducing CI locally (read this before chasing a "CI-only" failure)​

CI is not a different test suite — it is the SAME commands on a smaller machine. The variable that changes behavior is total resident load on the box. Vitest 4 with isolate: true (our default) forks a FRESH process per test file, but three things still accumulate machine-wide: the MAIN vitest process holds the whole project's v8 coverage in memory for the entire run (open vitest issue #4476) — multiplied by however many projects nx runs concurrently; DYING forks pile up (terminations are "started but not awaited until the end of the run", each lingering up to 60s with open handles, so resident processes exceed maxWorkers); and any leaked handle keeps its dying fork alive longer. Oversubscribe a small box with that and it GC/swap-thrashes wholesale — at which point trivial synchronous tests time out (that tell means the MACHINE is frozen, not that the test is slow; no timeout bump fixes it). Slow lanes make it worse, which is why capping workers backfired. (Historical note: the vitest-3 era really did reuse one worker across many files — the mock-bleed sagas below date from then, and void-ed rejections / leaked timers remain fail-the-file bugs today.)

Because of that, CI (ci.yml test job) runs FULL vitest workers (fewest files per fork) but NX_PARALLEL=1 (one project owns the 4-vCPU box at a time) with NODE_OPTIONS=--max-old-space-size=3072 so a runaway process dies fast with a V8 trace instead of swap-thrashing the runner into the "lost communication" abyss. Never cap VITEST_MAX_WORKERS on CI — a 2026-07-31 attempt at '50%' packed ~85 files into each fork and produced exactly the frozen-fork wedge, three runs in a row.

To reproduce a CI-shaped failure locally, the worker cap is the STRESS AMPLIFIER — it forces the many-files-per-fork world on any machine:

VITEST_MAX_WORKERS='50%' bunx nx vite:test <project> --coverage --skip-nx-cache
# or the full CI sweep shape, cross-project parallelism included:
VITEST_MAX_WORKERS='50%' bunx nx affected -t vite:test --base=origin/main --coverage

Run it a few times — worker packing is order-dependent. Known CI-death signatures:

  • "Worker exited unexpectedly", no V8 heap trace, ~last files missing → the OS OOM-killed a fork (capacity). The dead worker's in-flight files are the suite files that never printed a ✓ line — if they're all trivial, suspect capacity or a cumulative leak from files that DID run, not the missing files themselves.
  • "The hosted runner lost communication" at ~49 min, no logs at all → the runner agent itself starved: either a synchronous spin (hung while-loop, wedged scheduler) or whole-box memory thrash from bloated forks. Every job now carries timeout-minutes: 30, which bounds the loss and (when the agent is still breathing) preserves logs.
  • Trivial sync tests timing out (even at 15s) → the fork is FROZEN (GC death spiral), not slow. Reduce files-per-fork / process count; never raise timeouts for this.
  • A spec FILE fails while all its tests pass → an unhandled rejection or leaked handle detonating after the last test. Rules that prevent the class: every void someAsync() must be unable to reject (catch inside, or fail-closed like AuthService.accountClaims); every setTimeout/interval/ rAF a component starts is cleared via DestroyRef; specs use mockReturnValueOnce for poisoned values (vi.clearAllMocks does NOT clear implementations — a persistent never-settling promise once cost a file 12 silent seconds).

Never retry past a pre-push hook flake — the hook runs the CI commands, so an intermittent hook failure IS the CI failure with better odds. Chase it with the env knob above.

Unit tests (Vitest + Angular TestBed)​

Projects use the vite:test nx target (Vitest with @analogjs/vitest-angular / @analogjs/vite-plugin-angular, jsdom), so Angular TestBed works inside Vitest specs.

bunx nx vite:test stretched-components # one project
bunx nx affected --target=vite:test --base=origin/main --head=HEAD # what CI / pre-push run
bun run test:functions # Firebase Functions (own vitest setup in apps/firebase-functions)

The tools/generate-types-erd.ts generator has its own spec run via bun run types:erd:test (see Tooling and Scripts).

Storybook play / interaction tests​

Stories in apps/storybook double as tests. bunx nx e2e storybook-e2e (or bun run storybook:e2e) builds Storybook and runs tools/storybook-e2e.ts, which serves dist/storybook on port 6199 and runs @storybook/test-runner (Playwright, chromium) against it:

  • Every story is smoke-tested in a real browser — any render or console error fails.
  • Stories with a play function get their interactions asserted.

The script installs the required chromium build itself (idempotent). Storybook provides DI-level mocks by default, so no backend is needed. See Component Library Guide for writing stories and play functions.

The test-runner.ts preparation hook serves empty CSS for Google Fonts requests in interaction tests. The external stylesheets in preview-head.html can otherwise stall the iframe's load event and fail a suite during setup, before any assertions run. Interaction tests use system fonts; the normal Storybook UI and visual pipeline still load the real fonts. Local scripts, page-load readiness, and story assertions remain required. The hook preserves the runner's HTTP-header setup; compare it with upstream defaultPrepare when upgrading @storybook/test-runner.

Runner overrides (apps/storybook/.storybook/test-runner-jest.config.mjs)​

The test-runner globs test-runner-jest* inside its --config-dir, so that file is our jest config; without it the packaged default in node_modules is used verbatim. Name helper modules in that directory something else, or they get picked up as the config — and keep this to exactly one test-runner-jest* file. A second one (there was briefly a .js alongside this .mjs, from two branches solving different problems in parallel) makes which config wins glob-order-dependent; they were consolidated here on 2026-08-05. Five overrides:

  • maxWorkers capped at 16 (override with STORYBOOK_E2E_MAX_WORKERS to retune or benchmark). Jest defaults to cpus - 1, and every worker drives its own chromium — 127 browsers at once on a 128-core host. The contention starves Angular's zone scheduler and derived values land seconds late, which reads as a test failure: Foundations/WageStory timed out waiting 10 s for $32.50 under the full sweep, then passed in 546 ms on its own. CI runners (2–4 cores) are already under the cap and keep their default, so this only ever narrows the local↔CI gap.

    Measured on the 128-core box (2026-08-05, isolated runs): 8 → 43 s · 16 → 32 s · 32 → 26 s · 48 → 29 s · 64 → 30 s · 96 → 38 s + FAIL. The cap is 16 and not the apparent 32 optimum: 32 passed once and then failed WageStory with the starved-scheduler signature, and 96 failed TimeStory outright. The curve is shallow from 16–64, so seconds bought past 16 are paid in flake risk — and the gate runs this step concurrently with the rest of the graph, i.e. more loaded than an isolated benchmark. 16 measured 4/4 clean at ~24 s. If you retune, repeat each candidate several times; one green run proves nothing about a probabilistic flake.

  • A patched runner (patched-playwright-runner.mjs) that puts a 2 s deadline on closing the shared browser server. That close never resolves on this host — the abandon-warning fires every run — so the deadline is not an exceptional path but a fixed per-run tax, and it lands inside jest's reported time. Cutting it from 10 s to 2 s took the suite ~26 s → ~24 s on its own. Losing the race is harmless: every suite has reported by then, and tools/storybook-e2e.ts kills the process group so the abandoned server is reaped, not orphaned. Don't raise it back without re-measuring. The stock runner ends runTests with browser2Server[key].close(), which never resolves here because the chromium children are left unreaped (Z defunct under the jest process). Jest then hangs after the last suite reports and before printing its summary — a fully green run that never exits, turning git push into an indefinite stall (~3 min of work, 16 min of wall clock, plus orphaned headless_shell trees alive for hours).

  • forceExit for a separate leaked esbuild service that outlives the run. It cannot mask a failure — jest still exits non-zero when a test failed.

  • reducedMotion: 'reduce' on the browser context. Every page claims prefers-reduced-motion, so compliant components snap to settled states instead of animating — play tests assert end states either way, and they land immediately instead of awaiting rAF count-ups/tickers. Landed 2026-08-08 together with making price-translator honor the preference (it was the lib's one non-compliant animator — an a11y gap on its own). Measured: total test-body time 50.3 s → 37.1 s; the five animation-bound tests went from 2–4 s each to 0.04–0.31 s; the suite's slowest test is now 0.8 s. Wall-clock impact is ~1–2 s (boot dominates) — the real wins are determinism (settled states can't flake on animation timing) and that a component NOT honoring reduced motion now shows up as a conspicuously slow test.

  • testMatch normalized to forward slashes. On Windows the runner resolves main.ts's story globs against the workspace root and leaves a literal backslash at the join seam (visible under a dot-folder git worktree, e.g. …/Stretched\.claude/worktrees/…); micromatch requires forward slashes on every platform, so jest matched 0 stories and failed with "No tests found", breaking the pre-push hook from any such worktree. A no-op on POSIX — don't delete it because CI is green without it.

Two fixes that do not work, so nobody re-tries them: forceExit alone (the hang is inside runTests, before jest's exit logic runs), and launchType: 'LAUNCH' (skips the shared server, but every worker's browser died on launch and no suite ran at all). tools/storybook-e2e.ts spawns the runner detached and kills its whole process group afterwards, so abandoned browsers are reaped rather than orphaned onto init.

Story files are consolidated (add stories, not files)​

Every story FILE costs ~2 s of per-file boot in the interaction gate — a fresh browser context parses the iframe bundle and boots Angular before the first story runs — while stories WITHIN a file are nearly free (~35 ms each; measured 2026-08-09: a 32-story file and a 1-story file both cost ~5 s). 84 per-component files were therefore consolidated into 24: thirteen group files (display, interactive, inputs, charts, cards, layouts, animations, foundations, organisms, user-organisms, user-settings, dynamic, stories-overview) plus eleven sanctioned standalones (narrative-titled demos and app-side stories). The e2e step went 24 s → 13 s; all 266 tests were preserved story-for-story.

Adding a story = adding to the matching group file with a component-prefixed export and a name: 'Component: Story' display name — see the authoring rules in libs/stretched-components/src/components/CLAUDE.md. bun run storybook:files:check (tools/storybook-story-files.spec.ts, pre-push + CI) fails on any stray .stories.ts; a genuinely new file must be allowlisted there with its reason.

Vitest browser-mode runner — evaluated, parked (2026-08-11)​

@storybook/addon-vitest + vitest browser mode was fully stood up as a jest-playwright replacement (apps/storybook/vitest.storybook.mts, tools/storybook-vitest.ts): 24 files / 266 tests all green, compiled from source with no storybook build. It is deliberately NOT the gate runner — measured slower on every path that matters for this repo's shape:

  • Full sweep ~34 s vs 15–17 s (jest + built storybook). The cost is ~14 s of unbundled module-graph load per isolated browser session (~350 s CPU/run).
  • Prebundling @angular/* to fix that is architecturally impossible — it bypasses the Analog AOT linker ("JIT compilation failed").
  • --changed affected-scoping WORKS (badge.ts → 6 files / 91 tests) but measured ~35 s anyway: session boot + startup dominate, not test count.
  • --browser.isolate=false (community-recommended module-graph reuse): 27.7 s vitest-reported / ~45 s wall — reuse only helps within a worker, and at 24 files / 16 workers most workers hold one file. The floor is ~10 s node-side startup + ≥14 s first-session boot + reap, paid at ANY scope — a single-file run still costs ~30 s. Known upstream: Storybook #33067 documents the same CI slowness + mid-run optimizeDeps reloads, unresolved.

The upstream-PR hypothesis (port @angular/build's linker-in-optimizer pattern into Analog's plugin) was SPIKED and killed 2026-08-12: with the linker running in the dep optimizer the prebundle works (no JIT errors) — but setup CPU stays ~354 s because the per-session load is dominated by the WORKSPACE component graph, which can never be prebundled; wall got slightly worse (39.8 s), and 5 zone-dependent form tests broke (prebundled output skips the async-downleveling the analog transform applies for zone.js). Vendor prebundling is not the lever here. Do not re-derive this.

Config gotchas already paid for (in the config's comments): test.dir for workspace-root story globs; absolute tsconfig for the analog plugin; scss loadPaths and the global stylesheet must be wired manually (builder options don't reach the bridge — without styles.scss every var(--duration-*) animation silently never runs); reducedMotion goes on the playwright() FACTORY (instance-level is a silent no-op); this host's close-hang needs the bounded wrapper. What it's still good for today: vitest --ui story debugging, --watch on stories, and composeStories in unit specs. Re-run the full-sweep comparison when Storybook/vitest ship per-session bundling — that's the single number keeping this parked.

Vite builder (2026-08-09)​

Storybook builds with @analogjs/storybook-angular (Vite + the same Analog Angular plugin the unit tests use), not @storybook/angular's webpack builder. Measured on the switch: cold build 43-51 s → ~24 s, output 25 MB → 9.7 MB, and the 3.2 MB webpack iframe monolith became 96 split chunks (biggest 920 kB) — so each interaction-test browser context parses far less per story file. All 266 interaction tests passed unchanged on the swap.

Gotchas that cost time, so they're recorded: the builder runs Compodoc by default — keep "compodoc": false in both storybook targets or builds stall for minutes; it requires @angular/animations to be installed (webpack tolerated its absence; rolldown fails with seven MISSING_EXPORT errors from platform-browser/animations); stylePreprocessorOptions uses loadPaths (Vite/sass), not webpack's includePaths; and the nx cache entry in nx.json is keyed by EXECUTOR name — it moved to @analogjs/storybook-angular:build-storybook and must move again if the builder ever changes, or caching silently stops.

Storybook build caching (and the stale-bundle trap)​

build-storybook is cached via a @storybook/angular:build-storybook entry in nx.json targetDefaults, with outputs: ["{workspaceRoot}/dist/storybook"]. Without it the build ran twice per gate — once as its own step, once as storybook-e2e's dependsOn — at ~42 s each, which was the entire critical path of bun run verify (124 s total, of which 124 s was that chain). Cached, a no-op run replays in ~3 s and the whole gate lands in ~47 s.

The inputs cannot be the usual ["production", "^production"]. The storybook project has NO nx dependencies (implicitDependencies: []) and discovers stories by glob (apps/stretched/src/**/*.stories.*, libs/**/*.stories.*) rather than by imports nx can trace — so ^production hashes nothing that matters. Keyed that way, editing a component or story still returns a cache hit and storybook-e2e then asserts against a stale bundle: green tests over code that isn't there. That is strictly worse than a slow build.

The storybookSources named input covers the globbed sources explicitly (both story roots, tsconfig.base.json, package.json, bun.lock; lib specs excluded since they cannot reach the bundle). Verified both directions on 2026-08-04: unchanged tree → 1.5 s cache hit; one comment appended to badge.stories.ts → full 42 s rebuild. If you add a stories glob to .storybook/main.ts, add the matching path to storybookSources in the same change — nothing will fail loudly if you forget; you will just silently start testing stale bundles.

Known-benign warning (investigated 2026-08-04): "A worker process has failed to exit gracefully and has been force exited … Try running with --detectOpenHandles". This is Jest's per-WORKER teardown notice, not a test failure and not attributable to any story: an in-band --detectOpenHandles pass finds zero open handles, and the same suite runs clean in parallel on an idle box. The handle is the runner's own Playwright/browser connection whose close outlasts Jest's grace period on a slow or loaded machine (small core count, thermal throttle, a concurrent bun run verify/shotgun). Ignore it unless suites actually FAIL alongside it.

Note this warning is about per-worker teardown and is not what the forceExit override above addresses (a leaked esbuild service in the main process). Neither can hide a red test: jest still exits non-zero when a suite fails. What forceExit genuinely would mask is a leaked handle that is a real bug in product code — so if you ever see the warning name a specific story rather than the runner's own browser connection, chase it rather than shrugging at it.

Visual regression (Storybook) — base vs branch​

bun run storybook:visual # diff main → working tree, full report
bun run storybook:visual -- --filter card # only stories whose id/title matches
bun run storybook:visual -- --all # ignore change detection, shoot everything
bun run storybook:visual -- --base <ref> --reuse-builds --themes light,dark

Parallelism follows the machine: the two storybook builds and the two screenshot sides run concurrently, and each side's page pool defaults to half the repo-wide NX_PARALLEL budget (the same machine-derived knob pre-push and CI export), capped at 8 — override per-run with --concurrency N.

tools/storybook-visual-diff.ts answers "what am I visually accepting on this branch?":

  1. Builds the base ref's storybook in a detached git worktree (tmp/visual-diff-base, cached incl. its node_modules) and the working tree's storybook.
  2. Screenshots every story on both sides with Playwright (light + dark via prefers-color-scheme, animations disabled, fullPage). Stories are enumerated from index.json, and the shot waits for Storybook's render lifecycle to settle (phase finished) — so play functions have already run and screenshots capture post-interaction end states. Adding a story (with or without play) adds its visual coverage automatically; nobody writes screenshot tests. Speed comes from in-place story switching (each worker page boots Angular once, then setCurrentStory — the test-runner's own mechanism) and from skipping networkidle in favor of the render-phase wait; a stability loop (two consecutive identical captures, 3 s cap) makes JS-driven animations (d3 transitions, count-ups) deterministic — full suite ≈ 790 shots + diffs in under 2 minutes with builds reused. Known noise floor: infinitely-animating stories (acrostics, tickers) never settle and can show sub-0.25 % ratios at the bottom of the report. The Playwright driving runs in a Node worker (shoot-worker.ts, bundled at runtime) — Playwright cannot control its browser from Bun on Windows.
  3. Pixel-diffs the pairs (pixelmatch, images padded to union size so a size change counts) and writes dist/storybook-visual-diff/report.html: added/removed stories pinned to the top, then changed stories sorted most-different-first, identical/skipped collapsed below.
  4. Change detection ("TurboSnap at home", tools/visual-diff/affected.ts): a story is skipped when its transitive TS import closure (plus sibling .scss/.html in closure directories) doesn't intersect the changed files. Changes to global surfaces — foundations/, apps/storybook/, package.json, bun.lock — invalidate every story. Unmappable stories are never skipped silently. Unit specs: bun run storybook:visual:test.

Both sides are screenshotted on the same machine in the same browser build, so there is no cross-platform baseline problem — the diff is pure code-change signal. CI/branch-deployment wiring is future work.

Visual regression (apps + extension) — base vs branch​

bun run app:visual # web app, all routes, vs a fresh seeded emulator
bun run mobile:visual # mobile app at 390×844 (browser build of the Ionic shell)
bun run extension:visual # extension popup + annotated fixture pages
# all take --base <ref> and --reuse-builds; app runners take --filter/--themes

tools/app-visual-diff.ts builds both sides with --configuration=emulated, starts a fresh auth+firestore emulator (same no-import/no-export contract as e2e — refuses to run while the dev emulator is up), and screenshots every route in light + dark. Reports land in dist/app-visual-diff/<app>/report.html and dist/extension-visual-diff/report.html, most-different-first.

⚠️ The rendered world is deliberately fully unlocked, and every report carries a banner saying so:

  • Every feature flag is ON — emulated builds run @stretched/feature-flags in local mode with forceAllEnabled: true (no Firebase contact, nothing to seed), so flag-gated pages render enabled even though they ship default-OFF. New registered flags join automatically. The --flags-off variant shots neutralize the force via the __STRETCHED_FLAGS_DEFAULTS_ONLY__ global (set by the shoot worker before app boot) to capture the customer-default view.
  • The emulated account test@test.com is signed in with the admin: true custom claim (granted via the emulator's privileged endpoint), so /admin/* pages render. The Firebase session is injected into IndexedDB before app boot — no UI login flow.
  • The extension runner forces all extension feature toggles ON with a deterministic $25/h rate, loads the unpacked MV3 build via a persistent Chromium context (channel: 'chromium' — the headless shell doesn't support extensions), and shots the popup plus local fixture pages with fixed prices (tools/visual-diff/fixtures/) that the content script annotates. Extension shots never touch live retailer pages.

Routes listed in the manifests' *_FLAGS_OFF_ROUTES get a second, flags-off shot (report rows suffixed --flags-off): after the all-on pass the seeded doc is cleared and the page re-shot on the compiled defaults — the customer-reality view of pages a partial-page flag trims (today: /account, which offers only Minutes + Hours while web_section_upper_tiers_enabled is OFF). Add a route there whenever a partial-page flag lands.

Route coverage is drift-gated: the manifests in tools/visual-diff/route-manifests.ts must cover every path in the apps' route tables — route-manifests.spec.ts (part of storybook:visual:test, pre-push + CI) fails when a new route lacks a manifest entry or an explicit exclusion. Serving uses the CSR index for all routes so guards and flags evaluate against live emulator state, never a stale prerendered page. Mobile note: home/dashboard sit behind incomeStoryGuard and deterministically render the onboarding redirect for a fresh account; native-shell truth (safe areas, native fonts) is a future Android-emulator phase.

E2E (Playwright) — web, mobile, and extension​

bun run e2e:playwright # all three surfaces (~15 s + builds)
bun run e2e:playwright -- --project=web # web | mobile | extension
bun run e2e:playwright -- --grep layaway # any Playwright Test flag

tools/e2e-playwright.ts builds the emulated apps (reusing existing dists) plus the Firebase Functions (built + npm-installed into dist/apps/firebase-functions), starts a fresh auth+firestore+functions emulator (same refuse-to-reuse contract as the Cypress runner), seeds the unlocked world (all feature flags ON, test@test.com with the admin claim; per-spec accounts that need paid content grant themselves a tier claim via grantClaims, because the admin role no longer opens member content — see Auth and Users), serves the builds + price fixtures, and runs Playwright Test under Node. Running the functions emulator means flows like account deletion and the GDPR data export (account-deletion.e2e.ts) exercise the REAL users function — the e2e asserts via the emulator's owner-bypass REST API that the Auth account and every uid-keyed Firestore doc are actually gone. Specs live in apps/playwright-e2e (see its README): web auth/navigation/admin-gate, mobile shell + guest journey, and the extension's first-ever e2e — annotation, tooltip, multi-currency, popup persistence, layaway sign-in gating — driving the unpacked MV3 build in a persistent full-Chromium context against local fixtures. Signed-in states are injected via the Firebase IndexedDB session (no UI login round-trip) except in specs where the login flow itself is the subject. Runs in CI's independent playwright job; it does not wait for unit tests or Cypress because its runner owns every build, service, and cleanup dependency.

Policy: the Cypress suites below are frozen — keep them green, invest nothing new. New e2e coverage goes to apps/playwright-e2e; migrate the remaining Cypress specs opportunistically, then retire Cypress and its Windows workarounds.

E2E (Cypress) — web and mobile (frozen)​

bun run e2e:emulated # web suite (apps/stretched-e2e) on :4200
bun run e2e:mobile:emulated # mobile suite (apps/stretched-mobile-e2e) on :4300

Both run tools/e2e-emulate.ts, which:

  1. Refuses to start if anything is listening on :9099 — running against the dev emulator would pollute its persisted emulator-data/ with test users. Stop bun run emulate first.
  2. Starts a fresh Firebase emulator (--only auth,firestore) with no import and no export — e2e state never touches emulator-data/ and nothing persists after the run.
  3. Runs nx e2e <project> --configuration=emulated with CYPRESS_EMULATED=1.
  4. Force-kills the emulator (nothing to export) and exits with Cypress's exit code.

CYPRESS_EMULATED gating​

Emulator-only specs — e.g. apps/stretched-e2e/src/e2e/auth.cy.ts (creates/deletes accounts) and story-builder.cy.ts — gate themselves with:

const emulatedDescribe = Cypress.env('EMULATED') ? describe : describe.skip;

So a plain bunx nx e2e stretched-e2e or a CI run against deployed dev can never create test users in a real Firebase project; those suites simply skip.

How the e2e targets work (important gotcha)​

The e2e targets in apps/stretched-e2e/project.json and apps/stretched-mobile-e2e/project.json use nx:run-commands + tools/cypress-e2e.ts, not the @nx/cypress:cypress executor. That executor starts the dev server and then reports success without ever launching Cypress (observed on nx 22.7.5 + @nx/cypress 22.7.1 with continuous serve targets). Do not "simplify" the targets back to it.

tools/cypress-e2e.ts <serveProject> <serveConfiguration> <e2eDir> <baseUrl> does serve → wait → run:

  1. Serves the app (bunx nx serve <project> --configuration=<config>) — unless CYPRESS_BASE_URL is already set (the deploy-preview CI runs Cypress against a deployed channel URL; see CI CD and Deploys), in which case no dev server is started.
  2. Polls the base URL until it answers (up to ~2 minutes).
  3. Runs bunx cypress run in the e2e project dir with CYPRESS_BASE_URL set to the target URL, passing CYPRESS_EMULATED through.

It also strips NX_WORKSPACE_ROOT_PATH and ELECTRON_RUN_AS_NODE from the environment — the VS Code terminal quirks described on Tooling and Scripts.

What runs where​

LayerLocal commandCI
Unit (affected)bunx nx affected --target=vite:test ...test job + pre-push hook
Functionsbun run test:functions:coveragetest job + pre-push hook (with --coverage)
Storybook e2ebun run storybook:e2eIndependent storybook job + pre-push hook
Playwright e2ebun run e2e:playwrightIndependent playwright job
Cypress web e2ebun run e2e:emulatedIndependent cypress job; deploy-preview also tests its URL
Cypress mobile e2ebun run e2e:mobile:emulatedFrozen local/emulator suite only

Coverage thresholds (enforced)​

Every project pins coverage.thresholds in its vitest config, floored at the level its suite actually achieves — so any --coverage run fails if coverage regresses. CI's affected vite:test --coverage sweep and the dedicated functions step both run with coverage, which is what makes the thresholds a real gate. Current floors (statements/branches/functions/lines):

ProjectThresholds
stretched-types100 / 100 / 100 / 100
firebase-functions100 / 98 / 100 / 100
stretched-chrome-extension100 / 98 / 100 / 100
stretched (web)98 / 85 / 99 / 99
stretched-mobile99 / 95 / 100 / 99
stretched-components96 / 88 / 96 / 98

Raise a floor when you add tests; never lower one to make a change pass — uncoverable code (bootstrap/wiring, Storybook demos) belongs in that project's commented coverage.exclude list instead. The remaining uncovered branches are documented per-project in the config comments (SSR typeof window guards, WebView-only wiring, jsdom Selection/Range limits in the page builder).

See CI CD and Deploys for the full pipeline.