What gets generated

A file-by-file walk through everything the flawless-react scaffolders write into the output directory, what each file is for, and when you would edit it

The output directory

Every file under <outDir>/ is a string template emitted by the scaffolders. The result is fully standalone — it knows nothing about flawless-react once it has been written, and you can commit, edit, fork, or delete it without affecting the generator.

Each task writes its own slice of the directory. perf-harness owns host/ and profile/, unit-browser owns unit/ and browser/, e2e owns e2e/, load owns load/, and the three analysis tasks (perf-static, a11y, react-rules) write JSON into reports/. The slices barely overlap: package.json is the only file two tasks both touch, and the second one merges into it rather than replacing it. Everything else the tasks share is the component analysis they all read from. Every run also writes REPORT.md.

The default output directory is ./.flawless; --out <dir> moves it.

<outDir>/
├── package.json              # standalone deps + scripts (merged, not replaced, by unit-browser)
├── tsconfig.json
├── playwright.config.ts      # perf suite — auto-boots host/server.ts (and mock/server.ts with --api)
├── vitest.config.mts         # unit-browser
├── setup.ts                  # unit-browser — jest-dom, native-import stubs, jsdom gaps, optional MSW
├── budgets.json              # per-component mount/update budgets (warn-only by default)
├── host/
│   ├── server.ts             # Bun.serve over a Bun.build bundle of your component(s)
│   ├── _polyfill.ts          # process / global / setImmediate shims for the browser
│   ├── index.html            # the shell the bundle mounts into
│   ├── registry.ts           # REGISTRY / FN_PROPS / WRAP_IN — regenerated every run
│   └── main.tsx              # write-once: createRoot + <Profiler> + react-scan + the ALL spreads
├── profile/                  # perf-harness
│   ├── _helpers.ts           # render-info / scan-event harvesters, summarize() stats
│   ├── _heap.ts              # CDP-driven heap-snapshot helpers (write to ./heaps/)
│   └── <Component>.profile.spec.ts
├── unit/                     # unit-browser
│   └── <Component>.test.tsx
├── browser/                  # unit-browser
│   ├── playwright.config.ts
│   └── <Component>.spec.ts
├── provider.tsx              # only with --provider — the derived wrapper
├── provider.fixtures.ts      # write-once — the values your provider chain needs
├── e2e/                      # e2e (Next.js / Expo mode)
│   ├── global-setup.ts       # write-once login stub; writes .auth.json
│   ├── params.json           # merge-preserving — real ids per dynamic route
│   └── <route>.spec.ts
├── load/                     # load
│   ├── routes.pipes          # crust pipelines per route and per GET API operation
│   ├── routes.setup.ts       # write-once — seeds LOAD_<PARAM> ids, proves the auth
│   ├── last-<name>.json      # written every run
│   └── baseline-<name>.json  # you promote one of the above by hand
├── mock/                     # only with --api
│   ├── server.ts             # Bun.serve on :5174, host proxies non-asset requests to it
│   └── routes.ts             # write-once — static route handlers derived from the spec
├── reports/                  # perf-static / a11y / react-rules findings + summary.json
├── scripts/report.mjs        # only with --report
├── heaps/                    # populated at test time — V8 .heapsnapshot files
├── REPORT.md                 # every run — what mounted, what was excluded, hand-work left
└── README.md                 # how to run + customise the suite

With --colocate, the unit specs move out of unit/ and land beside your sources as <Component>.flawless.test.tsx, each opening with a provenance stamp. The rest of the directory is unaffected. See Colocated spec stamps.

A <Component>.flawless.json is also dropped next to each analysed source file, outside the output directory. That is covered at the end of this page.

package.json

perf-harness declares the suite as its own private package with three runtime dependencies:

{
  "dependencies": {
    "react": "^18.3.0",
    "react-dom": "^18.3.0",
    "react-scan": "^0.4.3"
  },
  "devDependencies": {
    "@playwright/test": "^1.49.0",
    "@types/node": "^22.0.0",
    "@types/react": "^18.3.0",
    "@types/react-dom": "^18.3.0",
    "typescript": "^5.6.0"
  }
}

Render timing comes from React’s built-in <Profiler>, so no separate measurement library is required. The package name is the suite’s own name with a -profile suffix, and it is marked private and "type": "module".

Scripts from perf-harness: test (playwright test), test:perf (the same command under the name --run-tests looks for), test:headed (playwright test --headed — Playwright’s own flag, no second project), and — only when you passed --report, which is also the only time scripts/report.mjs is written — report (node ./scripts/report.mjs).

If unit-browser runs as well, it merges into this file rather than replacing it — and so does perf-harness, so the result does not depend on the order you list the tasks in. It adds test:unit (vitest run) and test:browser (playwright test --config browser/playwright.config.ts), and adds vitest, @vitest/ui, jsdom, @testing-library/react, @testing-library/user-event and @testing-library/jest-dom to devDependencies. Existing pinned versions are kept, so re-running does not silently downgrade anything you have bumped by hand. If no package.json exists yet — unit-browser on its own — one is created with the same base deps.

Edit when: your parent project uses React 17 or 18 instead of 19 — the scaffolder already pins the suite’s react, react-dom and Testing Library to match the app’s React major (@testing-library/react 12 plus @testing-library/dom 8 below React 18; a stale 13+ pin surviving a merge is repaired on the next run), so you only edit this if you have deliberately re-bumped one by hand.

tsconfig.json

A minimal config tuned for Bun plus Playwright: target and module at ESNext, bundler module resolution, strict, noEmit, and an include covering host/, profile/ and playwright.config.ts.

Two lines are non-default and matter: "jsx": "react-jsx" and "allowImportingTsExtensions": true. Together they let the host file say import { Foo } from "./Foo.tsx" without choking the type checker.

Edit when: you want stricter type-checking across the suite, or you want to add path aliases.

playwright.config.ts

The perf suite’s config. testDir is ./profile, the reporters are list, a json report written to test-results/results.json (what scripts/report.mjs reads) and an HTML report written to playwright-report/ (never auto-opened), preserveOutput is "always", and traces are kept on failure. One project, chromium — headed runs use test:headed (--headed), not a second project.

The key field is webServer, which auto-boots the host before any test runs:

webServer: {
  command: "bun host/server.ts",
  url: "http://127.0.0.1:5173",
  reuseExistingServer: !process.env.CI,
  stdout: "ignore",
  stderr: "pipe",
}

With --api, webServer becomes an array and the mock server on port 5174 is booted alongside it.

fullyParallel is on, and workers are pinned to 1 only under CI — where a recorded number usually matters — or when FLAWLESS_WORKERS says so. One worker gives the cleanest timings; more give a faster verification run at the cost of noisier numbers, which never fails a run because the budgets only warn.

Edit when: you need a different port, you want to add a firefox or webkit project, or you want a single worker everywhere rather than only under CI.

host/server.ts

A small Bun.serve that builds host/main.tsx once via Bun.build({ target: "browser" }) and serves three routes: / (the HTML shell), /bundle.js (the built ESM), and a 404 for everything else. On boot it prints host listening on http://127.0.0.1:5173, which is the line to look for when a run fails early.

The build runs at startup, not per request, so test runs hit a hot bundle. If you are iterating on main.tsx you can either restart the server manually or replace the one-shot build with a Bun.build watcher.

Three plugins ride the build, each for a specific reason:

  • A resolve-from-suite Bun plugin resolves bare specifiers (react, react-dom/client, and so on) against the suite’s own node_modules instead of the importing file’s directory. Component files usually live outside the suite — in your repo, or in a fixtures directory with no React installed — and without the plugin those imports fail to resolve. Under a pnpm-style strict layout, transitive deps are out of the suite’s reach but visible from the importer’s own directory, so the plugin falls back to resolving from the importer — a silently unresolved transitive import emits the use site with the binding undefined and dies at boot, not at build time.
  • A stubServerModules plugin keeps anything that cannot run in a browser out of the bundle. Its full policy is the next section.
  • define: { global: "globalThis" } rewrites bare global references at build time, alongside the inlined env vars below. The runtime half of that problem is handled by host/_polyfill.ts.

With --api, any request that is not /, /index.html or /bundle.js is proxied to the mock server, method, headers and body unchanged.

Edit when: you want to inject env vars into the bundle, add CSS, or serve additional static assets such as mock JSON fixtures.

host build: stubs and inlined env

The perf host mounts your real components in a bare browser. Most modules your components transitively import have no business being in that bundle — they want node builtins, native binaries, a database driver, or a compiler hook that only exists inside next build. The build answers each of those with something that builds and mounts, and says what it stubbed on boot: [flawless] stubbed 3 server-action module(s) for the browser bundle.

  • CSS is invisible. .css, .scss, .sass, .less — and the tailwindcss package itself — resolve to one empty stylesheet module. Bun’s CSS pipeline cannot run the app’s real one (tailwind v4’s @import "tailwindcss" is a build step, not a file), and render-count measurement does not care about styles at all.
  • Server modules are stubbed by path. "use server" action modules the analysis found get a generated stub surface with the real export names. The map is keyed by path emitted relative to the host directory and resolved back to absolute at runtime — an absolute key in the committed file would name the machine that generated it, and every stub would miss on any other checkout.
  • Server-only libraries are intercepted by bare specifier. Provider-bound auth libraries and the data-connector families — @supabase/*, @prisma/adapter*, mongodb, mongoose, pg, kysely, drizzle-orm — resolve fine in the suite’s node_modules, and that is the problem: the real module lands in the bundle and throws at import. Where the analyser produced a stub surface it is used; where it did not, a recursive self-describing CJS Proxy answers any named import with a callable stub — then deliberately returns undefined, so nothing can mistake a stub for a thenable and await forever.
  • prisma and @prisma/* get a named-export stub enumerating the real runtime’s surface (Decimal, PrismaClientKnownRequestError, sqltag, …): the generated client imports those names from the package by ESM, and a missing named export fails the build, not just runtime.
  • Node builtins — node:fs and the unprefixed fs/stream spellings the resolver proves are builtins — share a node stub; in a browser target there is no such file to resolve to.
  • next/navigation gets the deterministic router stub; server-only resolves to an empty module (the package is absent in many repos and throw-on-import where installed, and modules that import it transitively still have to build).
  • next/font/* gets a makeFont factory behind a Proxy namespace. The real loader is a compiler hook with no runtime, and geist and friends run their factory at module scope of every file that imports them; the stub returns the same shape consumers read — className, the CSS variable, and a style carrying the fallback stack.

Alongside the plugins, the build inlines the repo-root env: .env, .env.local, .env.development and .env.development.local, merged in Next’s precedence order (later file wins) and emitted as define entries for process.env.*, with NODE_ENV pinned to "development". Module-scope code runs during the bundle’s evaluation, and the repo that boots new URL(process.env.DATABASE_URL) at import time does not get a second chance — without the inline it throws at startup and takes the whole host down.

host/index.html

Two lines matter: a <div id="root"> and a <script type="module" src="/bundle.js">. The rest is boilerplate — charset, viewport, title.

Edit when: you need a different root element ID, a CSS reset, or extra DOM your component depends on, such as a portal target.

host/_polyfill.ts

Browser shims for Node-only globals that some runtime dependencies touch at module init, before any user code runs. It defines process (with env, platform, versions, argv), points global at globalThis, and falls back to a setTimeout-based setImmediate.

It is imported first from main.tsx. ESM evaluates imports in source order, so globalThis is patched before any other module is initialised.

host/registry.ts

The mechanical half of the host: one import line per analysed component, the Component type, and three tables the wrapper beside it reads.

  • REGISTRY maps component names to imported components, populated from the analysed ComponentTree.
  • FN_PROPS names the props each component calls. A spec’s props reach the page through page.evaluate’s structured clone, which drops functions silently, so mount supplies a no-op for every named prop the incoming object did not define. Without it an error.tsx whose <Button onClick={reset}> never received reset throws the instant the click lands.
  • WRAP_IN names the ancestors HTML requires around a component’s root tag. <TableBody> renders <tbody>, which is only valid inside a <table>; mounted straight into #root it makes React log “In HTML, tbody cannot be a child of div”, and the browser spec then fails the component for a warning the harness caused. mount nests the element through the chain.

This file is regenerated on every run — a plain re-run with no --force rewrites it, even though host/main.tsx beside it is kept. That is how a component you analysed for the first time today gets profile and browser specs that can actually mount it.

Edit never. The next run rewrites the file byte-for-byte from the analysis, so per-component wiring written here is lost. Hand registrations belong in host/main.tsx’s ALL spread, which survives.

A main.tsx older than the split

A host/main.tsx generated before the tables were split out bakes them into itself and has no ./registry import, so newly analysed components can never mount through it — and the tool cannot repair it for you. Write-once is absolute: no flag rewrites host/main.tsx, --force included, because budgets.json and your provider wiring rest on that same promise. So the run writes host/registry.ts inert beside it and warns, naming the file under whatever --out you used:

[flawless] flawless/host/main.tsx predates the registry split: it bakes in its own component tables, so newly analysed components cannot mount through it, and no flag may rewrite a write-once file — delete host/main.tsx and re-run (provider wiring regenerates from --provider), or port your hand wiring and import "./registry".

A shorter form of the same note rides along in the perf-harness summary, so it reaches the run’s result JSON rather than only scrolling past on stderr.

The escape is one-time, and it is one of those two moves. Delete host/main.tsx and re-run — provider wiring regenerates from --provider, so what you lose is only what you typed in by hand — or port that hand wiring into a wrapper of your own that imports ./registry.

host/main.tsx

The wrapper around those tables, and the user-owned half of the host. It is write-once: written when it is absent and never again, under any flag, because nothing in it can be rebuilt from the analysis — the ./_polyfill import that has to evaluate first, the provider wrapping, the MSW worker under --api, the <Profiler> plumbing, and whatever you added yourself.

Three things happen here:

  1. react-scan is initialised once at module load, with log: false. Its onRender callback buffers any render flagged unnecessary: true into a scanEvents array, tagged with the component’s display name and a performance.now() timestamp.
  2. The mount tables are imported from ./registry and spread into ALL, ALL_FN_PROPS and ALL_WRAP_IN, the three objects every lookup below actually reads. The import sits last among the app imports on purpose: it pulls in every component module, and a component with module-scope code leaning on a provider or MSW module’s side effects needs that order kept.
  3. window.__profile is exposed for the Playwright driver:
window.__profile = {
  list(): string[];
  async mount(name, props): Promise<void>;
  async rerender(name, props): Promise<void>;
  async unmount(): Promise<void>;
  getRenderInfo(): { renderCount, renderTime }; // populated by React.Profiler
  getScanEvents(): ScanEvent[];
  clearScanEvents(): void;
};

mount wraps the user’s component in React’s built-in <Profiler id={name} onRender={...}> and renders it via activeRoot.render(...). Each render fires onRender, which increments renderCount[name] and appends to renderTime[name] — the mount phase writes mount, every later phase pushes onto updates. mount then polls on requestAnimationFrame until the first render lands, and throws if nothing has rendered within 5000 ms. rerender re-issues the render and waits one requestAnimationFrame for React to commit. unmount tears down the root and clears both dictionaries, which is why the per-test counters start clean.

mount also carries out the error React swallowed. createRoot is given an onUncaughtError handler and the failure path reports what it caught, so a component that throws during render says what it threw rather than the generic “did not render within 5000 ms” — which used to look identical whether the component hit a missing prop, a missing provider or a bad import.

Measurement uses <Profiler> rather than a patching library on purpose: patching approaches instrument the classic JSX runtime (React.createElement), and Bun.build emits JSX through the automatic react/jsx-runtime. <Profiler> works with either runtime and across React versions, so the host stays portable.

The three ALL spreads

The imported tables are never read directly. Each is spread into a local of its own, once:

const ALL: Record<string, Component> = {
  ...REGISTRY /* hand-registered components ride here and survive regeneration */,
};
const ALL_FN_PROPS: Record<string, readonly string[]> = {
  ...FN_PROPS /* hand-registered components' function-prop names ride here */,
};
const ALL_WRAP_IN: Record<string, readonly string[]> = {
  ...WRAP_IN /* hand-registered components' wrapper chains ride here */,
};

Because every lookup goes through the spreads, a component you register by hand mounts exactly like a generated one and outlives every regeneration of host/registry.ts. Write it as MyComponent: MyComponent as unknown as Component — the cast the generator itself emits, since a props-typed component is not assignable to Record<string, Component> under the suite’s strict tsconfig.

All three exist for the same reason. A hand-registered component may also call function props, or need a wrapper chain around its root tag, and a table without its own spread would silently drop your entry the next time the registry is rewritten. With only the first spread you would register a component that mounts and then throws on its first click, having been handed no stub for the handler the click calls.

Edit when: you want to wrap components in a provider (router, theme, query client, store) — prefer the --provider <file> flag at scaffold time, which threads your wrapper through mount and rerender for you. You can also hand-register a component the analyser cannot see in the ALL spread, or expose additional helpers on window.__profile if your tests need them.

host/main.tsx with —provider

If you scaffolded with --provider <file>, host/main.tsx imports the file as AppProvider and wraps every render in it:

import AppProvider from "../../src/providers/AppProvider.tsx"; // your file
// ...
function wrap(tree: ReactNode): ReactNode {
  return createElement(AppProvider as never, null, tree);
}

Without a provider, wrap is the identity function, so the call sites stay uniform either way.

The provider file must default-export a React component that accepts children. Anything with a Provider in the React ecosystem fits — Redux’s <Provider store={...}>, React Query’s <QueryClientProvider client={...}>, theme providers. Wrap them in your own AppProvider and you only have to maintain one file:

// src/providers/AppProvider.tsx
import type { ReactNode } from "react";
import { Provider } from "react-redux";
import { QueryClientProvider } from "@tanstack/react-query";
import { store } from "../store";
import { queryClient } from "../query";

export default function AppProvider({ children }: { children: ReactNode }) {
  return (
    <Provider store={store}>
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    </Provider>
  );
}

The provider import line is tool plumbing. import AppProvider from "…" is the one line in this file the tool owns: when the resolved provider moves — you pass --provider on a later run, or Next.js derivation starts or stops producing flawless/provider.tsx — the next run re-points that line, keeping its quote style and every other byte of your main.tsx, and names the change in the run summary. An import shape it does not recognise (named, multi-line, aliased) is left alone and reported instead, because that shape means a hand edit. Delete the line if you want to mount unwrapped permanently; the tool will not put it back.

Edit when: you want async setup before each mount — seeding store state, logging in a mock user. Add a setup/teardown named export and call them from mount/unmount in host/main.tsx.

profile/_helpers.ts

Thin wrappers around page.evaluate(...) so the spec files stay readable: mountComponent, rerenderComponent, unmount, getRenderInfo, getScanEvents, clearScanEvents, plus quantile(sorted, q) and summarize(samples), which computes min / avg / p50 / p95 / max and the sample count n.

Edit when: you want a higher-level helper such as mountWithUser(page, name, props, userFixture) that sets up a mock auth context first.

profile/_heap.ts

Drives the browser’s V8 heap profiler through Playwright’s CDP session (HeapProfiler.takeHeapSnapshot). It produces .heapsnapshot artefacts in the same format Chrome DevTools’ Memory tab consumes, so two snapshots can be loaded and diffed in the Comparison view.

Three exports:

takeHeapSnapshot(page, label, testInfo): Promise<{ byteSize: number; path: string }>;
bracketScenario(page, name, testInfo, fn): Promise<void>;
repeatClick(page, selector, n): Promise<void>;

bracketScenario wraps a scenario callback with before/after snapshots. To keep CI artefacts small, snapshots are persisted only when the scenario fails, or the scenario name starts with stress-. On a clean non-stress pass the captured buffers are dropped. When they are kept, the byte delta is logged to stdout as a coarse leak indicator:

[heap] click-rerender: 12345 -> 12410 bytes (delta +65) -> heaps/Foo-profile__Foo-click-rerender__click-rerender-after.heapsnapshot

Persisted snapshots land in <suite-root>/heaps/<spec>__<test>__<label>.heapsnapshot, resolved from the working directory the test run was invoked in. Filenames are deterministic — no hashed suffixes — so you can open heaps/... straight from the terminal instead of digging through the report’s content-addressed data directory. They are also attached to the Playwright HTML report, so you can navigate from a failed test result to its snapshot.

HeapProfiler.collectGarbage is invoked before each snapshot, so the result reflects retained-by-reachability rather than not-yet-collected.

How to view a snapshot: open Chrome, DevTools, Memory tab, then Load (the folder icon) and pick the .heapsnapshot file. Switch to Comparison view and load a second snapshot to find objects retained after the scenario.

Edit when: you want different snapshot timing — mid-scenario rather than just before and after — or you want the byte delta as a hard assertion: expect(after.byteLength - before.byteLength).toBeLessThan(THRESHOLD).

profile/<Component>.profile.spec.ts

One spec per analysed component. Each spec is fully self-contained: its DEFAULTS, VARIANTS and HOOKS are baked in as literals at the top. There is no shared fixture file and no implicit dependency between specs.

A beforeEach navigates to the host, waits for window.__profile.mount to exist, and clears the react-scan buffer. An afterEach unmounts, so each test starts from a clean root and clean counters.

Where the analyser detected an onClick handler, the click-driven tests target it with a real Playwright locator — getByTestId, getByLabel, getByRole with an accessible name, an ID selector, or getByText, in that order of preference. Where it detected nothing, they fall back to page.locator("button").first().

mounts at defaults

await mountComponent(page, NAME, DEFAULTS);
const info = await getRenderInfo(page);
expect(info.renderCount[NAME]?.value).toBeGreaterThanOrEqual(1);
// warns if info.renderTime[NAME].mount > budgets[NAME].mountMs

The mount budget breach is intentionally a console.warn, not expect(...).toBeLessThan(...). CI should not fail the first time a component is profiled — you would never get past the initial measurement run. To make it hard, change the console.warn(…) line to expect(mountMs).toBeLessThan(budget).

variant matrix

Mounts at defaults, then iterates the VARIANTS array, calling rerenderComponent(page, NAME, v) for each and collecting the update duration that render produced. Skipped entirely if the static parse produced zero variants. After the loop it logs the summary as [<Name> variants] {...} and warns if the p95 exceeds the component’s updateMs budget.

click rerender

Looks for the click target in the mounted DOM. If there is none, the test skips. If there is one:

  • Clear the react-scan buffer and capture the current render count.
  • Click the target.
  • Wait — via page.waitForFunction against the live render count — for the count to increment.
  • Read getScanEvents(): any unnecessary rerenders flagged by react-scan become a console warning.

This test is the most exploratory of the five. It works for components with a single primary action button. If your component has more nuanced interactions — form fields, drag handles, keyboard shortcuts — this is the test you will most often replace by hand.

forced rerender × N

Cycles --variants × 10 renders (minimum 10) through the variant pool, or through [DEFAULTS] if there are no variants, then prints a {min, avg, p50, p95, max, n} summary as [<Name> forced] and asserts that at least one update was timed. The HOOKS list is logged at the end for context.

Edit when: you want to assert hard ceilings on p95, you want a different N, or you want to drive prop changes through your own data fixtures rather than the synthesised ones.

stress-click-rerender × 50

A leak-detection scenario. It mounts the component, takes a baseline heap snapshot, clicks the primary target fifty times in a tight loop, waits for the render count to catch up, then takes a final heap snapshot. Because the scenario name starts with stress-, both snapshots are always persisted and attached to the Playwright report. A snapshot of a small component is typically under 500 KB.

There is no assertion on the byte delta — it is too noisy across machines and React versions. The value is the saved snapshots: open them in Chrome DevTools’ Memory tab, switch to Comparison view, and look for unexpectedly retained objects such as detached DOM nodes or growing arrays of listener closures.

The other three scenarios (variant matrix, click rerender, forced rerender × N) are wrapped by bracketScenario(...) too. They take before/after snapshots but only attach them on failure, which keeps passing-run artefacts small.

Edit when: fifty clicks is not enough to surface a slow leak — bump it to 500 — or you want a hard byte-delta ceiling, in which case replace the bracketScenario body with explicit takeHeapSnapshot calls and an expect(after - before).toBeLessThan(...).

vitest.config.mts

Emitted by unit-browser. It sets environment: "jsdom", pins esbuild.jsx: "automatic" in the config itself (a Next.js host’s tsconfig says jsx: "preserve", which leaves Vitest’s esbuild on the classic runtime and every render dies with “React is not defined”), loads ./setup.ts as a setup file, includes unit/**/*.test.{ts,tsx}, and turns Vitest globals on.

It also reads the app’s own env files — .env.test.local, .env.test, .env.local, .env and .env.example, in that order, first value wins, with keys already in the environment beating all of them. .env.example rides last because a template repository ships only the example, and its placeholder values exist precisely to satisfy module-scope construction: a component that throws ”… URL is required” while reading process.env at import time needs a value, any value, before a single test runs. Export an override when a placeholder must not be used.

The .mts extension is not cosmetic. Vite decides ESM or CommonJS from the nearest package.json, which in Next.js mode is the target application’s — and that usually has no "type": "module", so the config loaded as CommonJS and Vite warned that its strict loader, planned to become the default in a future major, will reject it. A standalone suite escaped only because the suite’s own package.json sets "type": "module". .mts is unambiguous in both modes, so nothing has to reason about whose package.json is nearest. Regenerating an older suite deletes the stale vitest.config.ts rather than leaving two configs in one directory, where Vitest’s own precedence decides which one runs and the file you edit may not be the file that ran.

setup.ts

The unit plane’s runtime floor — more than the one import of @testing-library/jest-dom/vitest it starts with. Three more things live here, and each answers a class of failure that would otherwise surface as a mysterious red suite:

  • Native-import interception. The runner pulls node_modules packages in through plain import(), where vi.mock cannot reach. A Node ESM resolve hook (installed synchronously via module.registerHooks when the runtime has it) rewrites two classes of specifier to inert stubs shipped as data URLs: next/font/* — font-loader wrappers execute at module scope and die on collection — and next/navigation (with or without the .js extension bundled output uses), so router-wrapper libraries never mount the real router and throw “expected app router to be mounted”.
  • jsdom gaps, each assigned only where absent: matchMedia (next-themes), ResizeObserver and IntersectionObserver (virtualisers, lazy mounts), scrollIntoView/scrollTo, and getAnimations (framer-motion-style cleanup). Plus an inert-but-complete Web Audio graph: jsdom ships no AudioContext, and a “play a sound on click” hook that constructs one kills the whole run rc=1 — an unhandled rejection — even when every test passed. Canvas is answered with a recording 2D context. jsdom’s HTMLCanvasElement.getContext logs Not implemented and throws, so a chart, sparkline or signature pad used to take its entire spec file down with it — 45 occurrences across two corpus apps, and in one of them 27 of the 50 spec files reported zero tests, none of it the app’s fault. Every documented 2d method exists and counts itself into canvas.getContext("2d").__flawlessDraws(), so a spec can assert that the component drew. Nothing here claims what it drew — there are no pixels, no compositing and no font metrics — and webgl/webgl2 answer null rather than a fake GPU: “no GPU context” is a state real browsers hand out daily and apps already branch on, while a fake one would let a shader component run to a result nobody measured. The function being replaced stays reachable as __flawlessRealGetContext.
  • Auth-library shims. When any analysed component imports an auth library, its deterministic vi.mock rides here rather than in every spec — the shim has to be in place before module bodies run, and per-spec placement races the import graph.

With --nextjs and fetch-intercepted routes, this is also where the MSW node server lives: listen before all tests, resetHandlers after each, close at the end — the same handlers the browser layer uses, so both layers agree on what /api/* returns.

unit/<Component>.test.tsx

One jsdom unit spec per analysed component, using Vitest, @testing-library/react and @testing-library/user-event. Like the perf specs, each one bakes its DEFAULTS and VARIANTS in as literals.

Every prop matching /^on[A-Z]/, plus every handler the analyser saw bound to a detected interaction, becomes a vi.fn() in a handlers object that is spread into the component on every render and cleared in beforeEach. A local renderTarget(extra) helper merges defaults, handlers and per-test overrides.

What gets emitted:

  • A render-at-defaults smoke test.
  • A describe block that renders once per variant row.
  • One test per detected click handler. Where the handler is bound to a prop, the test dispatches the real event — userEvent.click, userEvent.type, userEvent.pointer, userEvent.keyboard, focus/blur, depending on the event name — asserts that handler was called, and asserts that no other handler prop was called. That isolation assertion is the interesting half: it catches a click that fires two callbacks. A named local handler is read through: onClick={handleConfirm} resolves to its declaration, and when that body calls exactly one prop handler as a plain statement the step asserts on that prop for real. A body that calls two props claims neither, and the test stays a canary — dispatch the event, assert no prop fired.
  • A control behind a closed disclosure gets an open prelude. The spec clicks the trigger (by prop label, literal text, or its own fixture child), clears the spies, then queries with the awaiting findBy* form, because a Radix content subtree is not in the DOM until the trigger is clicked. A gated step whose trigger label cannot be resolved is emitted unprotected and goes red rather than skipping — that red is the generator marking where its own reading stopped.
  • A children value the analysis sees flowing into an asChild/Slot site is mounted as a <span>, never bare text, because slot cloning rejects a text child.
  • One test per detected form: fills each field the analyser could locate, with a type-appropriate sample value (user@example.com for an email input, 42 for a number, and so on), clicks the submit button, and asserts the submit handler ran.
  • If no interactions were detected at all, a fallback test per handler prop that clicks the first <button> in the container and asserts the handler fired.

Element lookups prefer getByTestId, then getByLabelText for an aria-label, then getByRole with the accessible name, then an ID or text query. That ordering means the generated tests break for the right reasons — a changed class name does not fail them, a removed accessible name does.

The derived provider wraps

Under a real layout a component mounts inside its theme, router, query and app-context providers; mounted bare it answers useSidebar must be used within a SidebarProvider instead of findings. So every render opens with a derived nest — in the unit specs and in the perf host’s registry alike — imported under a __Prov prefix so it can never collide with a spec’s own imports.

The derivations, strongest evidence first:

  • Hook call — useSidebar → the SidebarProvider exported by the file the hook’s import resolves to, or by the component’s own file; only that file’s provider counts, so an unrelated FooProvider elsewhere never catches an unrelated useFoo caller.
  • Namespace compound — CommandInput renders CommandPrimitive.Input and needs the same file’s <Command> mounted around it.
  • Transitive requirement — AppSidebar never calls useSidebar, its <Sidebar> child does; whatever a component renders, it must satisfy too.
  • Dynamic render — const { Component } = useTabs() above {Component && <Component />}. The tag is a value a project hook handed back, so there is no import for the analysis to walk and no child edge for the closure to close over. The component owes every component the hook’s values can name: the hook body’s own JSX tags, plus one import hop into the top-level definition of each value identifier the body holds — the registry it selects from, where an array or object member steps in, an arrow body steps into its return, and as const / satisfies / parens unwrap for free, to sixteen components. The edge merges into the render-edge worklist before the closure, so it orders, supersedes and cycles exactly like a statically rendered child: this is how a spec that mounts a tab through useTabs() ends up inside the data router that tab needs.

A hook arriving from a bare module — node_modules, or a barrel that hands the library itself along instead of the project file behind it, the same walk-ending table the dynamic hop stops at — has no project file to join on, so it pairs by convention instead: an exported component named after the hook (useTheme → ThemeProvider) that renders a binding imported from the hook’s own module. All three conjuncts or no wrap.

Library contexts the app cannot supply get generated shims:

  • @tanstack/react-query → FlawlessQueryClientProvider — a fresh QueryClient per mount, retry: false
  • react-router(-dom) hooks, <Link> / <NavLink> / <Navigate> / <Outlet> → FlawlessRouterProvider — a MemoryRouter
  • the data-router hooks (useLoaderData…), <Form>, <ScrollRestoration> → FlawlessDataRouterProvider — createMemoryRouter + RouterProvider; supersedes the MemoryRouter wrap, needs router ≥ 6.4
  • react-hook-form → FlawlessFormProvider — an empty form, fresh per mount
  • react-intl hooks, <FormattedMessage> and friends → FlawlessIntlProvider — locale="en", nothing fetched

The nest opens outermost-first: a provider that requires another sits outside it; ties keep alphabetical order; a true requirement cycle — the app is broken at that mount — falls back to alphabetical and fails red rather than guessing. Shim files live at the root of the importing package and are rewritten every run. Your --provider wrapper sits outside the whole nest.

Colocated spec stamps

With --colocate the unit specs are written beside your sources as <Component>.flawless.test.tsx instead of into unit/, and each one opens with a provenance stamp:

// @flawless-generated sha256:<hex16> flawless-react@<version>

<hex16> is the first 16 hex characters of the sha256 of everything below the line, computed after the run’s style pass — so the stamp names the bytes your project’s own formatter left on disk, not the raw template. A suite whose formatter rewrites quotes or line width would otherwise report every spec as hand-edited the moment it was written.

That is the whole point of the stamp: it tells “still exactly what we generated” from “someone worked on this”.

State on diskPlain re-run--force
no spec yetwritten, stampedwritten, stamped
stamp matches the bodykeptrewritten from the current templates
stamp mismatch — you edited or reformatted itkeptkept, listed as hand-edited
no stamp line — pre-stamp, or you removed itkeptkept, listed as pre-stamp

Both kept-under---force cases are named in the run summary, with the same escape hatch: delete the file and re-run to regenerate it. Kept files also drop out of the run’s hand-work tallies — a TODO(flawless) inside a render that was never written is not your debt, and counting it would inflate the number REPORT.md exists to make honest.

Deleting only the stamp line does not hand the file back to the tool. It demotes the spec to pre-stamp, which is still kept under every flag. The unit of ownership is the file.

The regeneration rules cover the rest of the write-once set.

browser/playwright.config.ts

A second, separate Playwright config for the functional browser specs. It has its own testDir (the browser/ directory itself), matches *.spec.ts, runs a single worker with the list reporter, keeps traces on failure, and boots the same host with bun ../host/server.ts on port 5173. Keeping it separate from the perf config is what lets bun run test:perf and bun run test:browser run independently, with different reporters and different test directories.

browser/<Component>.spec.ts

The functional counterpart to the unit specs: the same component, the same detected interactions, but driven through the real host bundle in Chromium rather than jsdom.

Every spec starts with a mount-without-errors test that collects console errors and pageerror events, mounts the component through window.__profile.mount, and asserts both that nothing was logged as an error and that #root actually has content. After that comes one test per detected click (dispatching through the equivalent Playwright locator API) and one per detected form (fill, submit, assert no page errors). If no interactions were detected, a placeholder test records that the mount-only smoke above is sufficient.

These specs assert on errors and mountability, not on timings — the timings live in the perf suite. They are the layer that catches a component which renders fine under jsdom but throws in a real browser.

budgets.json

{
  "Dashboard": { "mountMs": 50, "updateMs": 16 },
  "Hello": { "mountMs": 50, "updateMs": 16 },
  "Card": { "mountMs": 50, "updateMs": 16 }
}

Per-component budgets in milliseconds, one entry per analysed component. The defaults are loose on purpose: on the first run you measure, and on subsequent runs you tighten them to your observed p95 plus about 20%.

The numbers are read by every perf spec but only produce warnings by default. See the mounts at defaults and variant matrix notes above for how to upgrade those into hard assertions.

mock/server.ts and mock/routes.ts

Only with --api <openapi.json|yaml>. The suite then ships a standalone Bun mock server on port 5174, and the host server on 5173 transparently proxies every non-asset request to it — so the component’s relative fetch('/api/users') calls reach the mock with no code changes.

mock/routes.ts is a flat list of routes, derived at scaffold time from the OpenAPI spec:

export const routes: MockRoute[] = [
  { method: "GET",  path: "/api/users",       status: 200, body: [{ id: 1, name: "Alice" }] },
  { method: "POST", path: "/api/users",       status: 201, body: { id: 0, name: "" } },
  { method: "GET",  path: "/api/users/{id}",  status: 200, body: { id: 1, name: "Alice" } },
];

Body extraction priority: the first application/json example, then a schema-synthesised default (object becomes an object with the property keys, array becomes a one-element sample, primitives become empty or zero, an enum becomes its first value). $ref chains are resolved one level deep.

mock/server.ts compiles each path template at startup (/users/{id} becomes a regex with capture groups), matches incoming requests against the list, and falls through to a JSON 404 body. The root / returns 200 {ok:true, routes: N} so Playwright’s webServer health check passes — it rejects a 404 when probing readiness.

Edit when: you want stateful mocks — a route handler that mutates an in-memory store so the next GET reflects it — conditional responses based on query or body, or delayed responses. The generated file is a starting point: replace any route’s static body with a function that inspects the Request and returns a Response.

provider.tsx and provider.fixtures.ts

Next.js mode writes both, at the root of the output directory.

provider.tsx is the client-safe provider chain, derived from your app-router layout’s JSX around {children} — the same wrapper your pages really render inside, minus what cannot run in a client test. Nothing it leaves out is silent. A prop the layout passed non-literally is emitted as a TODO(flawless) comment rather than guessed at, so a session the layout read on the server becomes a named hole instead of a plausible-looking lie. A provider layer it will not mount is listed at the top of the file with the reason — not a "use client" module, an import that does not resolve locally, or one that pulls in an auth library, which the planes replace with a signed-out stub so that a provider gating its children on a session would render nothing at all. The list names the layer, which is the difference between “this spec has no Convex client” being a mystery and being a line item; the fix is your own --provider, because this file is derived and --force overwrites it. Because it is derived from the layout, --force refreshes it when your provider chain changes.

provider.fixtures.ts beside it holds the values that chain needs: your store’s initial state, a query client, a theme. It is write-once and no flag overwrites it, because nothing can recover a typed fixture from a layout. That division is the useful one — the shape of the chain is the tool’s to keep current, the values in it are yours.

e2e/

Written by the e2e task, which runs under --nextjs or --expo (expo-router). Every analysed app-router page.tsx becomes one spec, and e2e/playwright.config.ts boots the target’s own bun run dev, reading the port out of that script.

Running against the real app is a decision rather than a shortcut. MSW can only intercept fetch, and a React server component that queries the database directly never issues one — so an MSW-backed route test would render against your real data while claiming to be mocked. Seed the database with whatever your repo already provides, then run. Nothing is stubbed and nothing pretends to be.

e2e/global-setup.ts

Where login goes, and write-once. As shipped it writes an empty storage state to .auth.json, so the config’s storageState reference always resolves, above a worked example: request your login endpoint with a Playwright request context and persist storageState to that same path. Fill it in once — the file is yours, and .auth.json is gitignored.

REPORT.md counts the hook as hand-work for exactly as long as the file on disk is byte-identical to what the tool emits. That is an identity check, not a grep for the TODO(flawless) marker, so wiring in your login while leaving the comment above it stops the report rather than nagging forever.

e2e/params.json

The seeded ids, one entry per dynamic route, and merge-preserving under --force: nothing you have typed is ever removed, and neither is a blank slot another task added. A spec resolves each segment in order — FLAWLESS_E2E_<PARAM> from the environment, then routes[<route>][<param>], then defaults[<param>], then the legacy FLAWLESS_E2E_ID.

When nothing resolves, the spec skips with the reason instead of navigating. The placeholder it replaced built a URL that 404s, so a fixture nobody had filled in was reported as an application bug, once per route.

The same file carries landing, for a route that redirects on purpose — a default sub-tab, a locale landing — which the landing assertion below would otherwise fail forever:

{ "landing": { "/dash": "/dash/overview" } }

Recorded there and not in the spec: --force regenerates specs and leaves params.json alone, so an expectation written into a spec is advice the next regeneration deletes.

e2e/<route>.spec.ts

Each spec navigates its route, asserts the response is not 5xx, waits for networkidle, asserts it is still on the route it asked for, and requires an empty pageerror and console-error list. Where the page’s component tree contains click interactions, up to three of them are replayed in a second test, each behind a visibility check so a spec never fails merely because an element sits behind state it did not open.

FLAWLESS_E2E_BASEURL overrides the base URL and makes the config skip booting its own webServer. That is how you put a crust validation proxy between the browser and the app, then assert afterwards that nothing the UI triggered violated your OpenAPI spec.

Why the emitted suite refuses to be green before it is real

Until global-setup.ts is filled in, the suite runs unauthenticated — and that used to pass. A protected route 302s to /login, page.goto follows the redirect, and the login page answers 200 with a clean console. “Not 5xx” and “no console errors” are both true of somebody else’s form, so thirty route specs could go green having tested one login screen.

The landing assertion closes it, and it is in every spec — the render test and the interaction test both, since a login page swallows every visibility-guarded click and then reports a clean error list. After networkidle the spec compares the final pathname against the route it navigated to, trailing slashes normalised.

Stated precisely, because an overclaim here is the same bug wearing a different hat: it catches redirects that change the URL and have landed by the time the spec looks — a server 3xx, and a client-side navigation that completes before networkidle settles. It does not catch a middleware rewrite that serves the login page at the requested path, a 401 body, or a redirect that fires later than the spec waited. A 401 usually still reds the spec, through the console-error check rather than this one.

load/

Emit-only: the load task writes files and runs nothing. Its targets are the analysed page routes plus the GET endpoints your components actually fetch. Mutations are excluded deliberately — a default load run should not write junk into whatever environment it is pointed at.

load/routes.pipes

One crust pipeline per target, in crust’s CI-gate shape: one assert per threshold, with the baseline read back asynchronously.

load 10s 50/s | parallel 16 | GET $LOAD_BASE/dashboard/buildings/$LOAD_ID -H "$LOAD_AUTH" --timeout 2s | expect 200 \
  | stats --out flawless/load/last-dashboard-buildings-id.json \
  | assert (async s => { const b = Bun.file("flawless/load/baseline-dashboard-buildings-id.json"); return !(await b.exists()) || s.p95 < 2 * (await b.json()).summary.p95 })

A pipe whose ids are still blank is emitted commented out, above a line naming the exact key to fill. A pipe with an empty segment does not measure its route — it measures your 404 handler: GET /invite/ with an empty token answered 500 on every one of 500 requests, and the run reported the route as failing when the truth was that nothing seeds an invitation. The route stays visible, so the file itself says what is not being measured. Fill the id, regenerate with --force, and the pipe turns back on.

load/routes.setup.ts

crust imports the sibling setup module of a pipes file and awaits its default export before a single pipe runs. This is that module, and it does two jobs.

It seeds one LOAD_<PARAM> per id in e2e/params.json, so the load pipes and the e2e specs cannot drift apart over which records they target — and it throws when it seeded none while any dynamic route is in the file, rather than letting those pipes request URLs with empty segments. The shim it replaced was a shell script whose own comments record two incidents of exporting nothing silently. This has one failure mode, and it is loud.

Then it probes every static route and checks where the request actually landed. crust follows redirects, so an expired cookie turns a protected route into a login page answering 200: the status check passes, stats reports healthy percentiles, and the whole file goes green having measured a login form. Nothing downstream can catch that either — a load-mode record carries the requested URL, not the landed one — so the probe has to happen here, before any measurement, and it is enforced rather than left to you. A route that redirects by design goes under landing in params.json, the same map the e2e specs read.

The module is write-once. A check that knows your app — an authenticated endpoint answering 401 is a sharper signal than a redirect — beats the generated one, and no regeneration overwrites yours. It is written whenever it is absent, so a suite that predates it still gets the guard.

The env contract

LOAD_BASE, LOAD_AUTH, and one LOAD_<PARAM> per dynamic segment, the last of those seeded for you by the setup module. Two of them decide whether the numbers mean anything:

  • LOAD_BASE must point at a production build — bun run build && bun run start, not next dev. The dev server compiles routes on demand, so the first request to each route measures the compiler and the rest measure a warm cache.
  • LOAD_AUTH is a header line, and in a Next.js app it is usually a cookie: cookie: session=<value>, not authorization: Bearer …. The setup module rejects a value with no colon in it for that reason.

Baselines

Every run writes load/last-<name>.json. Nothing writes a baseline for you: copy a good last-<name>.json to baseline-<name>.json and commit it, and from then on the pipe fails when its p95 exceeds 2x the recorded one. last-*.json is gitignored. The assert reads the baseline back at run time and passes when the file is absent, so an un-promoted pipe measures without gating.

--force folds this run’s routes into an existing routes.pipes rather than replacing it, matching pipes by the name in their stats --out path rather than by line number. Only a pipe’s comment state is ever changed, so a rate you tuned survives being gated and un-gated, and nothing is pruned — a stale generated pipe and a POST flow you added by hand look identical to a line-oriented parser.

crust does not have to be installed for the task to succeed. It says so in its summary instead of failing a generation run over a missing optional tool. Once crust is installed, run the file with bun run flawless:load.

scripts/report.mjs

Only with --report — and that is also the only time the suite’s package.json declares a report script, so the two cannot disagree. It reads test-results/results.json, written by the json reporter in playwright.config.ts, pulls out the per-component variant and forced-rerender stat lines, and writes a report.md summarising them. It is a plain Node script with no Playwright runtime needed, emitted executable, and it exits with an error if you run it before the first test run.

It reads the JSON reporter’s output rather than playwright-report/index.html because the HTML report keeps stdout inside a base64 zip blob, where the stat lines cannot be read out of the file.

Edit when: you want HTML output, you want to push the summary to a metrics endpoint, or you want to diff against a baseline JSON.

reports/

The three analysis tasks do not scaffold anything runnable — they write findings. Each one produces one file per analysed root component for perf-static, and one aggregate for each sweep:

FileWritten byContents
<Name>.perf-static.jsonperf-staticcomponent, file, findings
a11y.jsona11ytask, filesScanned, findingCount, findingsByFile
react-rules.jsonreact-rulestask, a note, filesScanned, findingCount, findingsByFile
summary.jsonalwaysa timestamp plus one row per task that ran

Every finding has the same shape, whichever task produced it:

{
  rule: string;                    // "perf/unresolved-prop", "lint/a11y/useAltText", …
  severity: "error" | "warn";
  file: string;
  line: number;
  column: number;
  message: string;
  source: TaskId;                  // which task found it
}

That uniformity is the point: the same findings are printed to the terminal grouped by file, rolled into summary.json, and rolled into the per-component sidecar below, without each task needing its own reporting path.

perf-static derives its findings from the parsed tree rather than from a linter. It raises perf/no-memoisation-with-children when a component renders three or more child components but uses none of useMemo, useCallback, memo or useRef — every parent rerender will then rerender all of them — and perf/unresolved-prop for each prop it could not synthesise a default for, because the generated tests will pass undefined there.

a11y and react-rules both shell out to biome with a curated rule set written to a temporary config, so your project’s own biome.json is bypassed and the results do not depend on how strictly the project happens to be configured. a11y additionally drops any finding whose category is not under lint/a11y, so an accessibility scan never reports correctness rules its config did not enable.

summary.json records ts (an ISO timestamp) and, for each task, its id, whether it was ok, its one-line summary, how many files it wrote, how many findings it produced, and any error. It is written on every run, even when only scaffolding tasks were selected.

REPORT.md

Written by every run, whichever tasks you selected, and the file to read first.

Its job is accounting. Every component the analyser found lands in exactly one column of its component table — mountable in the browser host, or not — broken down by render mode, with the arithmetic spelled out underneath so the totals can be checked rather than trusted. Files that yielded no component at all are listed separately with the reason, because “nothing was found here” and “this was never looked at” are different facts.

A task table follows: files written, findings, lifecycle status (generated / refreshed / kept) and the one-line summary, per task — plus an explicit note of the tasks this run did not select. A --tasks load run should not read as “this project has one task”. Scripts the run left alone because their commands are not ones this tool version writes are named here too.

Then hand-work remaining: the TODO(flawless) markers the emitters planted, by kind, with a count and an example file to search from. Each marker is a place where a spec needs a human — a fixture, an addressable target, an assertion the source could not support statically — so this is the adoption cost the component table cannot show. The section is always present, in one of three states: a table, an explicit “none”, or an explicit “not measured” naming the tasks that reported no count. A silently absent table reads as zero debt, and on the app this tool was built against the truth was 181 markers.

Below that comes every component with no browser profile spec and the reason for each, then the standing gaps — properties of the analysis rather than of this run, which do not go away by re-running it.

REPORT.md carries no timestamp and every list in it is sorted, so a second identical run leaves git status empty.

<Component>.flawless.json

Written next to each analysed source file rather than into the output directory, so issues surface where the code lives. Each one carries the component name and file path, per-task finding counts plus a total, the top three findings ranked with errors before warnings, and paths to the report files.

Those paths are relative to the sidecar itself — resolve them with path.resolve(dirname(sidecarPath), value), and the same goes for its file field. They were absolute until 0.3.0, which named the machine that ran the tool inside a file that gets committed. Note also that reports.a11y and reports.reactRules point at the run’s single aggregate, because those two tasks are sweeps; only reports.perfStatic is per component.

Pass --no-inline-summary to skip these.

heaps/

Not generated — created at test time by profile/_heap.ts, holding the .heapsnapshot files described above. Already added to your .gitignore by the run, alongside reports/ and playwright-report/ — a single snapshot is tens of megabytes.

README.md

A short user-facing README for the generated suite, separate from the tool’s own documentation. It covers how to run the suite, what is measured, and which files to reach for when customising. It is yours to edit.

After generation

Run bun install in the output directory once, then the scripts the tasks you selected added. bunx playwright install chromium may be needed the first time, and again after a Playwright upgrade pulls in a new browser build.

From that point the suite is ordinary test code. It has no runtime dependency on flawless-react, nothing reaches back into the generator, and every file above is a starting point rather than a contract.