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 likeAuthService.accountClaims); everysetTimeout/interval/ rAF a component starts is cleared viaDestroyRef; specs usemockReturnValueOncefor poisoned values (vi.clearAllMocksdoes 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
playfunction 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:
-
maxWorkerscapped at 16 (override withSTORYBOOK_E2E_MAX_WORKERSto retune or benchmark). Jest defaults tocpus - 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/WageStorytimed out waiting 10 s for$32.50under 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
WageStorywith the starved-scheduler signature, and 96 failedTimeStoryoutright. 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, andtools/storybook-e2e.tskills the process group so the abandoned server is reaped, not orphaned. Don't raise it back without re-measuring. The stock runner endsrunTestswithbrowser2Server[key].close(), which never resolves here because the chromium children are left unreaped (Zdefunct under the jest process). Jest then hangs after the last suite reports and before printing its summary — a fully green run that never exits, turninggit pushinto an indefinite stall (~3 min of work, 16 min of wall clock, plus orphanedheadless_shelltrees alive for hours). -
forceExitfor 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 claimsprefers-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 makingprice-translatorhonor 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. -
testMatchnormalized to forward slashes. On Windows the runner resolvesmain.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"). --changedaffected-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?":
- Builds the base ref's storybook in a detached git worktree (
tmp/visual-diff-base, cached incl. itsnode_modules) and the working tree's storybook. - Screenshots every story on both sides with Playwright (light + dark via
prefers-color-scheme, animations disabled,fullPage). Stories are enumerated fromindex.json, and the shot waits for Storybook's render lifecycle to settle (phasefinished) — soplayfunctions have already run and screenshots capture post-interaction end states. Adding a story (with or withoutplay) adds its visual coverage automatically; nobody writes screenshot tests. Speed comes from in-place story switching (each worker page boots Angular once, thensetCurrentStory— the test-runner's own mechanism) and from skippingnetworkidlein 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. - Pixel-diffs the pairs (
pixelmatch, images padded to union size so a size change counts) and writesdist/storybook-visual-diff/report.html: added/removed stories pinned to the top, then changed stories sorted most-different-first, identical/skipped collapsed below. - Change detection ("TurboSnap at home",
tools/visual-diff/affected.ts): a story is skipped when its transitive TS import closure (plus sibling.scss/.htmlin 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-flagsin local mode withforceAllEnabled: 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-offvariant 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.comis signed in with theadmin: truecustom 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:
- Refuses to start if anything is listening on
:9099— running against the dev emulator would pollute its persistedemulator-data/with test users. Stopbun run emulatefirst. - Starts a fresh Firebase emulator (
--only auth,firestore) with no import and no export — e2e state never touchesemulator-data/and nothing persists after the run. - Runs
nx e2e <project> --configuration=emulatedwithCYPRESS_EMULATED=1. - 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:
- Serves the app (
bunx nx serve <project> --configuration=<config>) — unlessCYPRESS_BASE_URLis 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. - Polls the base URL until it answers (up to ~2 minutes).
- Runs
bunx cypress runin the e2e project dir withCYPRESS_BASE_URLset to the target URL, passingCYPRESS_EMULATEDthrough.
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
| Layer | Local command | CI |
|---|---|---|
| Unit (affected) | bunx nx affected --target=vite:test ... | test job + pre-push hook |
| Functions | bun run test:functions:coverage | test job + pre-push hook (with --coverage) |
| Storybook e2e | bun run storybook:e2e | Independent storybook job + pre-push hook |
| Playwright e2e | bun run e2e:playwright | Independent playwright job |
| Cypress web e2e | bun run e2e:emulated | Independent cypress job; deploy-preview also tests its URL |
| Cypress mobile e2e | bun run e2e:mobile:emulated | Frozen 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):
| Project | Thresholds |
|---|---|
stretched-types | 100 / 100 / 100 / 100 |
firebase-functions | 100 / 98 / 100 / 100 |
stretched-chrome-extension | 100 / 98 / 100 / 100 |
stretched (web) | 98 / 85 / 99 / 99 |
stretched-mobile | 99 / 95 / 100 / 99 |
stretched-components | 96 / 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.