@lariocpt/flawless-react 0.0.0-stage → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/.agents/skills/flawless-a11y/SKILL.md +113 -0
  2. package/.agents/skills/flawless-api/SKILL.md +159 -0
  3. package/.agents/skills/flawless-api-fixtures/SKILL.md +144 -0
  4. package/.agents/skills/flawless-e2e/SKILL.md +155 -0
  5. package/.agents/skills/flawless-expo/SKILL.md +283 -0
  6. package/.agents/skills/flawless-findings/SKILL.md +116 -0
  7. package/.agents/skills/flawless-load/SKILL.md +112 -0
  8. package/.agents/skills/flawless-nextjs/SKILL.md +198 -0
  9. package/.agents/skills/flawless-perf-harness/SKILL.md +158 -0
  10. package/.agents/skills/flawless-perf-static/SKILL.md +131 -0
  11. package/.agents/skills/flawless-react/SKILL.md +403 -0
  12. package/.agents/skills/flawless-react-rules/SKILL.md +96 -0
  13. package/.agents/skills/flawless-regen/SKILL.md +135 -0
  14. package/.agents/skills/flawless-unit-browser/SKILL.md +178 -0
  15. package/LICENSE +21 -0
  16. package/README.md +611 -2
  17. package/bin/flawless-react +4 -0
  18. package/package.json +67 -4
  19. package/scripts/postinstall.d.mts +46 -0
  20. package/scripts/postinstall.mjs +127 -0
  21. package/src/analyse/authShims.ts +220 -0
  22. package/src/analyse/bootability.ts +257 -0
  23. package/src/analyse/childBridge.ts +302 -0
  24. package/src/analyse/childrenIntoSlot.ts +66 -0
  25. package/src/analyse/compoundFamily.ts +100 -0
  26. package/src/analyse/dataExports.ts +200 -0
  27. package/src/analyse/extractChildAttrs.ts +112 -0
  28. package/src/analyse/extractDataDeps.ts +857 -0
  29. package/src/analyse/extractDisclosures.ts +186 -0
  30. package/src/analyse/extractFetchCalls.ts +81 -0
  31. package/src/analyse/extractFlows.ts +239 -0
  32. package/src/analyse/extractGuards.ts +270 -0
  33. package/src/analyse/extractHooks.ts +237 -0
  34. package/src/analyse/extractInteractions.ts +1421 -0
  35. package/src/analyse/extractLogging.ts +45 -0
  36. package/src/analyse/extractNativeA11y.ts +138 -0
  37. package/src/analyse/extractPredicates.ts +784 -0
  38. package/src/analyse/extractPropUsage.ts +162 -0
  39. package/src/analyse/extractProps.ts +1212 -0
  40. package/src/analyse/extractRenderMode.ts +165 -0
  41. package/src/analyse/extractRootTag.ts +117 -0
  42. package/src/analyse/extractState.ts +361 -0
  43. package/src/analyse/extractStaticText.ts +212 -0
  44. package/src/analyse/extractVariants.ts +186 -0
  45. package/src/analyse/harvestApiEndpoints.ts +284 -0
  46. package/src/analyse/hostProps.ts +185 -0
  47. package/src/analyse/hostTag.ts +72 -0
  48. package/src/analyse/htmlContentModel.ts +94 -0
  49. package/src/analyse/index.ts +744 -0
  50. package/src/analyse/mountPathCalls.ts +442 -0
  51. package/src/analyse/nativeContentModel.ts +129 -0
  52. package/src/analyse/nativeDialect.ts +83 -0
  53. package/src/analyse/nativeProps.ts +339 -0
  54. package/src/analyse/ownReturns.ts +31 -0
  55. package/src/analyse/parseFile.ts +919 -0
  56. package/src/analyse/pos.ts +8 -0
  57. package/src/analyse/providerWraps.ts +1977 -0
  58. package/src/analyse/pureModule.ts +88 -0
  59. package/src/analyse/reexportTable.ts +148 -0
  60. package/src/analyse/refs.ts +195 -0
  61. package/src/analyse/renderPath.ts +441 -0
  62. package/src/analyse/resolveDepReturns.ts +370 -0
  63. package/src/analyse/resolveIndexKeys.ts +98 -0
  64. package/src/analyse/resolveModule.ts +431 -0
  65. package/src/analyse/resolveTagRootTags.ts +103 -0
  66. package/src/analyse/serverActionReturns.ts +213 -0
  67. package/src/analyse/sourceCache.ts +263 -0
  68. package/src/analyse/spreadSiblings.ts +98 -0
  69. package/src/analyse/walkChildren.ts +98 -0
  70. package/src/analyse/zodSchema.ts +424 -0
  71. package/src/bin.ts +23 -0
  72. package/src/changed.ts +221 -0
  73. package/src/cli.ts +560 -0
  74. package/src/cliFlags.ts +281 -0
  75. package/src/core.ts +1229 -0
  76. package/src/docLine.ts +122 -0
  77. package/src/emitPaths.ts +51 -0
  78. package/src/expo/detect.ts +184 -0
  79. package/src/expo/refusal.ts +62 -0
  80. package/src/expo/routes.ts +145 -0
  81. package/src/index.ts +95 -0
  82. package/src/mcp/analysisTools.ts +720 -0
  83. package/src/mcp/infoTools.ts +135 -0
  84. package/src/mcp/protocol.ts +130 -0
  85. package/src/mcp/run.ts +269 -0
  86. package/src/mcp/runTools.ts +362 -0
  87. package/src/mcp/server.ts +279 -0
  88. package/src/mcp/spec.ts +86 -0
  89. package/src/mcp/support.ts +268 -0
  90. package/src/mcp/tools.ts +75 -0
  91. package/src/nextjs/apiRoutes.ts +116 -0
  92. package/src/nextjs/detect.ts +397 -0
  93. package/src/nextjs/emit.ts +106 -0
  94. package/src/nextjs/liveSpec.ts +79 -0
  95. package/src/nextjs/mswHandlers.ts +55 -0
  96. package/src/nextjs/providerWrapper.ts +603 -0
  97. package/src/nextjs/routes.ts +86 -0
  98. package/src/nextjs/swagger.ts +32 -0
  99. package/src/packageJson.ts +414 -0
  100. package/src/platform.ts +18 -0
  101. package/src/prompt.ts +132 -0
  102. package/src/reactCompiler.ts +165 -0
  103. package/src/reporters/findings.ts +156 -0
  104. package/src/reporters/report.ts +463 -0
  105. package/src/runner/runTests.ts +402 -0
  106. package/src/scaffold/adoptStyle.ts +181 -0
  107. package/src/scaffold/index.ts +253 -0
  108. package/src/scaffold/openapi.ts +183 -0
  109. package/src/scaffold/templates.ts +2379 -0
  110. package/src/scaffold-tests/index.ts +463 -0
  111. package/src/scaffold-tests/mockValues.ts +208 -0
  112. package/src/scaffold-tests/mocks.ts +794 -0
  113. package/src/scaffold-tests/nativeIndex.ts +52 -0
  114. package/src/scaffold-tests/nativeTemplates.ts +1528 -0
  115. package/src/scaffold-tests/postDispatch.ts +152 -0
  116. package/src/scaffold-tests/registry.ts +443 -0
  117. package/src/scaffold-tests/selectors.ts +493 -0
  118. package/src/scaffold-tests/selectorsNative.ts +273 -0
  119. package/src/scaffold-tests/stamp.ts +79 -0
  120. package/src/scaffold-tests/templates.ts +3578 -0
  121. package/src/skills/cli.ts +248 -0
  122. package/src/skills/compat.ts +166 -0
  123. package/src/skills/data.ts +192 -0
  124. package/src/skills/md.d.ts +7 -0
  125. package/src/skills/menu.ts +55 -0
  126. package/src/skills/spec.ts +227 -0
  127. package/src/suiteScripts.ts +110 -0
  128. package/src/tasks/a11yNative.ts +281 -0
  129. package/src/tasks/a11yScan.ts +67 -0
  130. package/src/tasks/biomeRunner.ts +301 -0
  131. package/src/tasks/biomeSweep.ts +166 -0
  132. package/src/tasks/e2eExpo.ts +436 -0
  133. package/src/tasks/e2eRoutes.ts +756 -0
  134. package/src/tasks/eslintReactRules.ts +67 -0
  135. package/src/tasks/index.ts +24 -0
  136. package/src/tasks/loadExpo.ts +273 -0
  137. package/src/tasks/loadPipes.ts +750 -0
  138. package/src/tasks/perfHarness.ts +145 -0
  139. package/src/tasks/staticPerfAnalysis.ts +510 -0
  140. package/src/tasks/types.ts +1 -0
  141. package/src/tasks/unitAndBrowser.ts +462 -0
  142. package/src/types.ts +1419 -0
  143. package/src/version.ts +12 -0
@@ -0,0 +1,2379 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { type AuthStub, authShimEsm, collectAuthStubs } from "../analyse/authShims.ts";
4
+ import { requiredAncestors } from "../analyse/htmlContentModel.ts";
5
+ import { libraryShimSource, referencedLibraryShims } from "../analyse/providerWraps.ts";
6
+ import { relativeToEmitted } from "../emitPaths.ts";
7
+ import { appReactMajor, detectAppReact } from "../packageJson.ts";
8
+ import {
9
+ allocateSpecNames,
10
+ compoundWrappable,
11
+ defaultsJson,
12
+ flattenTrees,
13
+ functionProps,
14
+ hostEligibility,
15
+ synthesiseDefaults,
16
+ } from "../scaffold-tests/registry.ts";
17
+ import { emitPlaywrightClickTarget } from "../scaffold-tests/selectors.ts";
18
+ import { stamp } from "../scaffold-tests/stamp.ts";
19
+ import { NEXT_SCRIPT, SUITE_COMMAND, SUITE_SCRIPT } from "../suiteScripts.ts";
20
+ import type {
21
+ ComponentInfo,
22
+ ComponentTree,
23
+ HeapMode,
24
+ MockRoute,
25
+ PropsShape,
26
+ PropValue,
27
+ ProviderRef,
28
+ } from "../types.ts";
29
+ import { VERSION } from "../version.ts";
30
+
31
+ const NL = "\n";
32
+
33
+ interface FlatComponent {
34
+ info: ComponentInfo;
35
+ importPath: string;
36
+ /** Registry key. Differs from componentName when two components share a name. */
37
+ specName: string;
38
+ }
39
+
40
+ export interface ExcludedComponent {
41
+ name: string;
42
+ file: string;
43
+ reason: string;
44
+ }
45
+
46
+ export interface RenderedFile {
47
+ relPath: string;
48
+ content: string;
49
+ /** When set, write here instead of <outDir>/<relPath> (library shim files). */
50
+ absPath?: string;
51
+ executable?: boolean;
52
+ /**
53
+ * Never overwrite an existing file here, `--force` included.
54
+ *
55
+ * For the files whose contents cannot be recovered from the analysis — the
56
+ * same criterion `provider.fixtures.ts` and `e2e/global-setup.ts` are held to.
57
+ * Everything else in the suite is derived and must stay regenerable, or an
58
+ * existing suite never receives a later version's improvements.
59
+ *
60
+ * **The set is smaller than it looks: in renderSuite only `host/main.tsx` and
61
+ * `budgets.json` carry this.** `profile/_helpers.ts` reads like somewhere to
62
+ * keep your own scenarios and is rebuilt on every `--force`. Check this flag
63
+ * before telling a user, in emitted prose or in docs, that their edits to a
64
+ * file will survive — the emitted README claimed exactly that about
65
+ * `_helpers.ts` in a draft of the commit that fixed the README for claiming
66
+ * it about itself. Rendering the file and reading it caught that; no test did.
67
+ */
68
+ writeOnce?: boolean;
69
+ }
70
+
71
+ export interface RenderOptions {
72
+ tree: ComponentTree;
73
+ /** How much of the V8 heap the emitted profile suite keeps. */
74
+ heaps?: HeapMode;
75
+ extraTrees?: ComponentTree[];
76
+ outDir: string;
77
+ variants: number;
78
+ report: boolean;
79
+ providerImportPath?: string;
80
+ /**
81
+ * Absolute path of the same provider, for reading its import table: the host
82
+ * mounts it around every component, so its provider-bound auth imports need
83
+ * stubbing too (analyse/authShims.ts).
84
+ */
85
+ providerFile?: string;
86
+ mockRoutes?: MockRoute[];
87
+ suiteName?: string;
88
+ nextjsHost?: boolean;
89
+ mswSetup?: boolean;
90
+ /** Context providers to mount around each component; see ScaffoldOptions. */
91
+ providerWraps?: Map<string, ProviderRef[]>;
92
+ }
93
+
94
+ export function renderSuite(opts: RenderOptions): RenderedFile[] {
95
+ const trees: ComponentTree[] = [opts.tree, ...(opts.extraTrees ?? [])];
96
+ const {
97
+ hosted: flat,
98
+ excluded,
99
+ serverActionStubs,
100
+ authStubs,
101
+ } = selectHostComponents(trees, opts.outDir, opts.providerFile);
102
+ const hasMock = Array.isArray(opts.mockRoutes);
103
+ const useMsw = Boolean(opts.mswSetup);
104
+ const suiteName = opts.suiteName ?? trees[0]?.entry.componentName ?? "suite";
105
+ const files: RenderedFile[] = [
106
+ { relPath: "playwright.config.ts", content: playwrightConfig(hasMock) },
107
+ {
108
+ relPath: "host/server.ts",
109
+ content: hostServer(hasMock, serverActionStubs, path.join(opts.outDir, "host"), authStubs),
110
+ },
111
+ { relPath: "host/index.html", content: hostHtml() },
112
+ { relPath: "host/_polyfill.ts", content: hostPolyfill() },
113
+ // Regenerated (FR-016): a re-run must be able to register newly analysed
114
+ // components over a kept wrapper.
115
+ {
116
+ relPath: "host/registry.ts",
117
+ content: hostRegistry(flat, opts.providerWraps, path.resolve(opts.outDir, "host")),
118
+ },
119
+ // writeOnce: docs/extending.md tells you to edit this to add providers,
120
+ // MSW and hand registrations in the ALL spread, and nothing can rebuild
121
+ // that wiring from the analysis. The tables it reads live in ./registry.
122
+ {
123
+ relPath: "host/main.tsx",
124
+ // legacyMount: a React 17 host renders through ReactDOM.render — there
125
+ // is no react-dom/client to import, so this cannot be runtime-detected.
126
+ content: hostMain(opts.providerImportPath, useMsw, (appReactMajor(opts.outDir) ?? 18) <= 17),
127
+ writeOnce: true,
128
+ },
129
+ { relPath: "profile/_helpers.ts", content: helpers() },
130
+ { relPath: "profile/_heap.ts", content: heapHelper(opts.heaps ?? "stress") },
131
+ // writeOnce: docs/generated-suite.md tells you to tighten these to your
132
+ // observed p95. A regenerated default silently un-tightens the gate.
133
+ { relPath: "budgets.json", content: budgets(flat, opts.variants), writeOnce: true },
134
+ {
135
+ relPath: "README.md",
136
+ content: readme(
137
+ suiteName,
138
+ flat.map((c) => c.specName),
139
+ excluded,
140
+ { nextjsHost: opts.nextjsHost, report: opts.report },
141
+ ),
142
+ },
143
+ ];
144
+ // Generated library-provider shims the wrap map mounts: the file lives at
145
+ // the IMPORTING PACKAGE's root, not the outDir — a bare library import only
146
+ // resolves through that package's node_modules (bun workspaces hoist
147
+ // nothing). Tool-owned content, rewritten per run.
148
+ for (const shim of referencedLibraryShims(
149
+ opts.providerWraps,
150
+ flat.map((c) => c.info.filePath),
151
+ )) {
152
+ files.push({
153
+ relPath: path.relative(opts.outDir, shim.filePath),
154
+ absPath: shim.filePath,
155
+ content: libraryShimSource(shim.module, shim.componentName, shim.filePath),
156
+ });
157
+ }
158
+ if (!opts.nextjsHost) {
159
+ files.unshift({ relPath: "tsconfig.json", content: tsConfig() });
160
+ files.unshift({
161
+ relPath: "package.json",
162
+ content: pkgJson(suiteName, opts.report, opts.outDir),
163
+ });
164
+ files.push({ relPath: "pnpm-workspace.yaml", content: PNPM_SUITE_SETTINGS, writeOnce: true });
165
+ }
166
+ for (const c of flat) {
167
+ files.push({
168
+ relPath: `profile/${c.specName}.profile.spec.ts`,
169
+ // Stamped like the colocated and browser specs: this file is only
170
+ // rewritten while the component STAYS host-mountable, so the stamp is
171
+ // what lets a later sweep tell "our own stale output" (safe to remove)
172
+ // from "someone worked on this" (kept). Restamped after the host
173
+ // formatter runs, same as the colocated specs (core.ts restampGenerated).
174
+ content: stamp(spec(c, opts.variants)),
175
+ });
176
+ }
177
+ if (hasMock) {
178
+ files.push({ relPath: "mock/server.ts", content: mockServer() });
179
+ // writeOnce: its emitted header says no regeneration step overwrites it —
180
+ // hand-tuned fixture bodies cannot be rebuilt from the OpenAPI spec.
181
+ files.push({
182
+ relPath: "mock/routes.ts",
183
+ content: mockRoutesFile(opts.mockRoutes ?? []),
184
+ writeOnce: true,
185
+ });
186
+ }
187
+ if (opts.report) {
188
+ files.push({
189
+ relPath: "scripts/report.mjs",
190
+ content: reportScript(
191
+ opts.nextjsHost ? NEXT_SCRIPT.report : SUITE_SCRIPT.report,
192
+ opts.nextjsHost ? NEXT_SCRIPT.perf : SUITE_SCRIPT.test,
193
+ ),
194
+ executable: true,
195
+ });
196
+ }
197
+ return files;
198
+ }
199
+
200
+ export function toHostRelImport(outDir: string, absFile: string): string {
201
+ const hostDir = path.resolve(outDir, "host");
202
+ return toRel(hostDir, path.resolve(absFile));
203
+ }
204
+
205
+ /**
206
+ * The components the host can actually mount, plus what was left out and why.
207
+ *
208
+ * The old implementation deduped on filePath alone, so a file exporting seven
209
+ * components contributed one — 84 of the dogfood app's 181 vanished without a
210
+ * word. It also keyed imports and the REGISTRY on the bare component name, which
211
+ * does not compile when two files both export a CommentForm, and it emitted
212
+ * `import { X }` for default exports and for components that are not exported at
213
+ * all.
214
+ *
215
+ * Names are allocated over the FULL list before filtering, so they match the
216
+ * unit scaffolder's — the browser specs look components up by these keys.
217
+ */
218
+ /** One "use server" module to stub for the browser bundle. */
219
+ interface ServerActionStub {
220
+ /** Every export name the stub must bind. */
221
+ names: string[];
222
+ /** Module-side name → the literal that action returns, where resolved. */
223
+ returns: Record<string, PropValue>;
224
+ /**
225
+ * Bare (non-relative) specifiers that import this module. The analyser
226
+ * resolves a workspace package to its SOURCE tree — that is where the
227
+ * annotations live — but the bundler resolves the same bare specifier
228
+ * through package.json `main`/`exports` to `dist/`, so a path-keyed stub
229
+ * never fires for it (B-11). These specifiers are intercepted BEFORE
230
+ * resolution instead, the way the unit plane's vi.mock always has.
231
+ */
232
+ specifiers: string[];
233
+ }
234
+
235
+ function selectHostComponents(
236
+ trees: ComponentTree[],
237
+ outDir: string,
238
+ providerFile?: string,
239
+ ): {
240
+ hosted: FlatComponent[];
241
+ excluded: ExcludedComponent[];
242
+ /** Absolute path → the stub for the module at that path. */
243
+ serverActionStubs: Map<string, ServerActionStub>;
244
+ /** Bare specifier → the signed-out auth-library stub (analyse/authShims.ts). */
245
+ authStubs: Map<string, AuthStub>;
246
+ } {
247
+ const hostDir = path.resolve(outDir, "host");
248
+ const flat = flattenTrees(trees);
249
+ const specNames = allocateSpecNames(flat);
250
+ // EVERY analysed component contributes imports here, hosted or not: an
251
+ // excluded component still sits in a hosted neighbour's import chain, and
252
+ // one library that throws at module init takes the whole bundle down. The
253
+ // provider wrapper joins them for the same reason — the host renders it around
254
+ // every component, so its auth imports are in that bundle whether the
255
+ // analysis visited the file or not.
256
+ const authStubs = collectAuthStubs(
257
+ flat.map((c) => c.info),
258
+ providerFile ? [providerFile] : [],
259
+ );
260
+
261
+ const hosted: FlatComponent[] = [];
262
+ const excluded: ExcludedComponent[] = [];
263
+ const serverActionStubs = new Map<string, ServerActionStub>();
264
+ for (const c of flat) {
265
+ const verdict = hostEligibility(c.info);
266
+ if (!verdict.eligible) {
267
+ excluded.push({
268
+ name: c.info.componentName,
269
+ file: c.info.filePath,
270
+ reason: verdict.reason ?? "not eligible",
271
+ });
272
+ continue;
273
+ }
274
+ for (const mi of c.info.moduleImports ?? []) {
275
+ if (!mi.isServerAction || !mi.resolvedPath || mi.isTypeOnly) continue;
276
+ const entry = serverActionStubs.get(mi.resolvedPath) ?? {
277
+ names: [],
278
+ returns: {},
279
+ specifiers: [],
280
+ };
281
+ const names = new Set(entry.names);
282
+ for (const n of mi.serverActionExports ?? mi.names) names.add(n);
283
+ const specifiers = new Set(entry.specifiers);
284
+ if (!mi.specifier.startsWith(".")) specifiers.add(mi.specifier);
285
+ // Sorted, not Set order: the emitted file has to be byte-identical across
286
+ // runs or the idempotence gate churns. Returns merge trivially — every
287
+ // importer's record was extracted from the same file.
288
+ serverActionStubs.set(mi.resolvedPath, {
289
+ names: [...names].sort(),
290
+ returns: { ...entry.returns, ...(mi.serverActionReturns ?? {}) },
291
+ specifiers: [...specifiers].sort(),
292
+ });
293
+ }
294
+ hosted.push({
295
+ info: c.info,
296
+ // No .tsx: the host is type-checked by the target project, which does not
297
+ // enable allowImportingTsExtensions.
298
+ importPath: stripTsExt(toRel(hostDir, c.info.filePath)),
299
+ specName: specNames.get(c) as string,
300
+ });
301
+ }
302
+ return { hosted, excluded, serverActionStubs, authStubs };
303
+ }
304
+
305
+ function stripTsExt(spec: string): string {
306
+ return spec.replace(/\.(tsx|ts|jsx|js)$/, "");
307
+ }
308
+
309
+ /**
310
+ * Safe inside an emitted `test(\`...\`)` / `test.skip(\`...\`)` template literal.
311
+ *
312
+ * A title quotes the source — a tag name, a prop name, a clickable label — so
313
+ * it can carry a backtick or a `${`: the backtick would end the literal early
314
+ * and emit a spec that does not parse, the `${` would become a live
315
+ * substitution. Exported for the unit/browser emitter, whose titles quote the
316
+ * same source text.
317
+ */
318
+ export function titleSafe(reason: string): string {
319
+ return reason.replace(/`/g, "'").replace(/\$\{/g, "$ {");
320
+ }
321
+
322
+ /** Named vs default import, aliased when the registry key differs from the name. */
323
+ function hostComponentImport(c: FlatComponent): string {
324
+ const { componentName, exportKind } = c.info;
325
+ if (exportKind === "default") return `import ${c.specName} from "${c.importPath}";`;
326
+ // `export { Internal as Public }` — only the alias is a module binding.
327
+ const boundary = c.info.exportedAs ?? componentName;
328
+ if (c.specName === boundary) {
329
+ return `import { ${boundary} } from "${c.importPath}";`;
330
+ }
331
+ return `import { ${boundary} as ${c.specName} } from "${c.importPath}";`;
332
+ }
333
+
334
+ function toRel(fromDir: string, absFile: string): string {
335
+ let rel = path.relative(fromDir, absFile);
336
+ if (!rel.startsWith(".")) rel = `./${rel}`;
337
+ return rel.split(path.sep).join("/");
338
+ }
339
+
340
+ // pnpm >=10 refuses to run a dependency's build scripts unless the manager
341
+ // declares them, and esbuild's postinstall IS its binary: a suite installed
342
+ // with plain `pnpm install` died at vitest startup with a confusing
343
+ // "esbuild binary missing" from inside vite. pnpm >=10 reads the allow-list
344
+ // from pnpm-workspace.yaml (onlyBuiltDependencies) and pnpm >=12 renamed it
345
+ // (allowBuilds); the file spells both, and each manager ignores the key it
346
+ // does not know. The suite-local file also keeps `pnpm install` run inside
347
+ // the suite from being captured by the application's workspace.
348
+ export const PNPM_SUITE_SETTINGS = `# Generated by flawless-react (write-once: add your own keys freely).
349
+ # esbuild's postinstall IS its binary — without this allowance pnpm refuses
350
+ # it and vitest dies at startup; msw's copies its service worker. Both
351
+ # spellings: pnpm >=10 and >=12 renamed.
352
+ onlyBuiltDependencies:
353
+ - esbuild
354
+ - msw
355
+ allowBuilds:
356
+ esbuild: true
357
+ msw: true
358
+ `;
359
+
360
+ function pkgJson(name: string, report: boolean, outDir: string): string {
361
+ // The suite's react IS the copy the perf host bundles and renders with
362
+ // (resolveFromSuite resolves from the suite directory), so it must be the
363
+ // version the app's component code was written against. See detectAppReact.
364
+ const pin = detectAppReact(outDir);
365
+ const typesPin = pin ? `^${pin.react.split(".")[0]}.0.0` : "^18.3.0";
366
+ const dashed = name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
367
+ return `${JSON.stringify(
368
+ {
369
+ name: `${dashed}-profile`,
370
+ version: "0.0.0",
371
+ private: true,
372
+ type: "module",
373
+ scripts: {
374
+ [SUITE_SCRIPT.test]: SUITE_COMMAND.perf,
375
+ // Same command under the name --run-tests looks for. Without it, a
376
+ // standalone perf-harness run with --run-tests reported "failed suites:
377
+ // perf" on a suite that was fine, because `bun run test:perf` did not
378
+ // exist — the key is only added when unit-browser also ran.
379
+ [SUITE_SCRIPT.perf]: SUITE_COMMAND.perf,
380
+ [SUITE_SCRIPT.headed]: SUITE_COMMAND.headed,
381
+ // Only when scripts/report.mjs is emitted beside it. Declared
382
+ // unconditionally, `bun run report` on a suite generated without
383
+ // --report failed with a module-not-found naming a file the user never
384
+ // asked for and cannot restore without regenerating.
385
+ ...(report ? { [SUITE_SCRIPT.report]: SUITE_COMMAND.report } : {}),
386
+ },
387
+ dependencies: {
388
+ react: pin?.react ?? "^18.3.0",
389
+ "react-dom": pin?.reactDom ?? "^18.3.0",
390
+ "react-scan": "^0.4.3",
391
+ },
392
+ devDependencies: {
393
+ "@playwright/test": "^1.49.0",
394
+ "@types/node": "^22.0.0",
395
+ "@types/react": typesPin,
396
+ "@types/react-dom": typesPin,
397
+ typescript: "^5.6.0",
398
+ },
399
+ },
400
+ null,
401
+ 2,
402
+ )}\n`;
403
+ }
404
+
405
+ export function tsConfig(): string {
406
+ return `${JSON.stringify(
407
+ {
408
+ compilerOptions: {
409
+ target: "ESNext",
410
+ lib: ["ESNext", "DOM"],
411
+ module: "ESNext",
412
+ moduleResolution: "bundler",
413
+ jsx: "react-jsx",
414
+ strict: true,
415
+ skipLibCheck: true,
416
+ esModuleInterop: true,
417
+ allowImportingTsExtensions: true,
418
+ noEmit: true,
419
+ resolveJsonModule: true,
420
+ },
421
+ // mock/ is emitted TypeScript like the rest and was outside this list, so
422
+ // `bunx tsc --noEmit` inside a generated suite type-checked everything
423
+ // except the server it tells you to run. scripts/ is deliberately still
424
+ // out: it holds one .mjs, and tsc ignores JS without allowJs/checkJs, so
425
+ // the glob would look like coverage and give none — that file is covered
426
+ // by running it under node in tests/emittedExecution.test.ts instead.
427
+ include: ["host/**/*", "profile/**/*", "mock/**/*", "playwright.config.ts"],
428
+ },
429
+ null,
430
+ 2,
431
+ )}\n`;
432
+ }
433
+
434
+ function playwrightConfig(hasMock: boolean): string {
435
+ const hostServerBlock = `{
436
+ command: "bun host/server.ts",
437
+ url: "http://127.0.0.1:5173",
438
+ reuseExistingServer: !process.env.CI,
439
+ stdout: "ignore",
440
+ stderr: "pipe",
441
+ }`;
442
+ const mockServerBlock = `{
443
+ command: "bun mock/server.ts",
444
+ url: "http://127.0.0.1:5174",
445
+ reuseExistingServer: !process.env.CI,
446
+ stdout: "ignore",
447
+ stderr: "pipe",
448
+ }`;
449
+ const webServerField = hasMock
450
+ ? `webServer: [\n ${hostServerBlock},\n ${mockServerBlock},\n ]`
451
+ : `webServer: ${hostServerBlock}`;
452
+
453
+ // The first line is this suite's provenance record: it is the marker file a
454
+ // re-run checks for, and a changed-mode run reads the version back out of
455
+ // this banner (scaffold/index.ts parseExistingVersion) to report which tool
456
+ // generated the suite it is keeping. The literal phrase is load-bearing:
457
+ // BANNER_RE in scaffold/index.ts matches these exact words, so rewording
458
+ // it degrades every future suite to "unknown version". Change both or
459
+ // neither.
460
+ return `// Generated by flawless-react ${VERSION}
461
+ import path from "node:path";
462
+ import { defineConfig, devices } from "@playwright/test";
463
+
464
+ export default defineConfig({
465
+ testDir: "./profile",
466
+ // Relative, which playwright resolves against THIS FILE. The unset default
467
+ // resolves elsewhere (the package.json directory) and once put 347MB of
468
+ // traces in <app>/test-results/ outside the emitted .gitignore.
469
+ // Never the CJS dirname global: a "type":"module" suite loads this config
470
+ // as ESM, where it does not exist and the whole config dies at import.
471
+ outputDir: "test-results",
472
+ // One worker gives the cleanest timings; more give a fast verification run.
473
+ // Budgets only warn, so parallel measurement never fails a run — but it does
474
+ // make the numbers noisier, so CI (where a recorded number usually matters)
475
+ // keeps one, and FLAWLESS_WORKERS=1 forces it anywhere else.
476
+ fullyParallel: true,
477
+ workers: process.env.FLAWLESS_WORKERS
478
+ ? Number(process.env.FLAWLESS_WORKERS)
479
+ : process.env.CI
480
+ ? 1
481
+ : undefined,
482
+ // The JSON entry is what scripts/report.mjs reads. It goes under outputDir,
483
+ // which is already gitignored and which Playwright clears at the START of a
484
+ // run — the reporter writes at the end, so the file survives the run that
485
+ // produced it. The HTML report cannot serve this purpose: it stores stdout
486
+ // inside a base64 zip blob.
487
+ reporter: [
488
+ ["list"],
489
+ ["json", { outputFile: "test-results/results.json" }],
490
+ ["html", { open: "never", outputFolder: "playwright-report" }],
491
+ ],
492
+ preserveOutput: "always",
493
+ use: {
494
+ baseURL: "http://127.0.0.1:5173",
495
+ trace: "retain-on-failure",
496
+ },
497
+ ${webServerField},
498
+ projects: [
499
+ // Exactly ONE project. A second, headed "interactive" project used to
500
+ // live here for manual debugging — and a bare \`playwright test\` then
501
+ // ran every spec twice, the second pass in a real browser window, per
502
+ // spec (a 2,590-spec dogfood run turned into a window storm). Headed
503
+ // probing is what Playwright's own --headed flag is for; \`bun run
504
+ // test:headed\` carries it.
505
+ { name: "chromium", use: { ...devices["Desktop Chrome"] } },
506
+ ],
507
+ });
508
+ `;
509
+ }
510
+
511
+ // The app's module scope runs BEFORE any spec: a top-level
512
+ // `new URL(process.env.DATABASE_URL)` throws and takes the whole host bundle
513
+ // down (one registry imports every component). The runtime polyfill gives
514
+ // `process` an empty env, so the values must be inlined as build defines.
515
+ // Repo-root .env files, Next's precedence order (later wins). The host binds
516
+ // 127.0.0.1 and the bundle never leaves the suite's disk.
517
+ function collectEnvDefines(): string {
518
+ const files = [".env", ".env.local", ".env.development", ".env.development.local"];
519
+ const merged = new Map<string, string>();
520
+ for (const f of files) {
521
+ let txt: string;
522
+ try {
523
+ txt = readFileSync(path.resolve(process.cwd(), f), "utf8");
524
+ } catch {
525
+ continue;
526
+ }
527
+ for (const line of txt.split("\n")) {
528
+ const m = line.match(/^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/);
529
+ if (!m) continue;
530
+ const name = m[1];
531
+ if (name === undefined) continue;
532
+ let v = (m[2] ?? "").trim();
533
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
534
+ v = v.slice(1, -1);
535
+ }
536
+ if (v) merged.set(name, v);
537
+ }
538
+ }
539
+ return [...merged].map(([k, v]) => `"process.env.${k}": ${JSON.stringify(v)},`).join("\n ");
540
+ }
541
+
542
+ function hostServer(
543
+ hasMock: boolean,
544
+ serverActionStubs = new Map<string, ServerActionStub>(),
545
+ hostDir = "",
546
+ authStubs = new Map<string, AuthStub>(),
547
+ ): string {
548
+ const envDefineLines = collectEnvDefines();
549
+ const mockProxyBlock = hasMock
550
+ ? `
551
+ // Proxy any other request to the mock server so the component's relative
552
+ // fetch calls (\`/api/...\`) reach the OpenAPI-derived mock without code
553
+ // changes. Headers and body pass through unchanged.
554
+ if (url.pathname !== "/" && url.pathname !== "/index.html" && url.pathname !== "/bundle.js") {
555
+ const target = "http://127.0.0.1:" + MOCK_PORT + url.pathname + url.search;
556
+ return fetch(target, {
557
+ method: req.method,
558
+ headers: req.headers,
559
+ body: req.method === "GET" || req.method === "HEAD" ? undefined : req.body,
560
+ });
561
+ }`
562
+ : "";
563
+ const mockPortConst = hasMock ? `const MOCK_PORT = Number(process.env.MOCK_PORT ?? 5174);\n` : "";
564
+
565
+ // "use server" modules a hosted component imports. Bundling them for the
566
+ // browser pulls in the database driver, the mailer and Node builtins — the
567
+ // build fails outright, and since the host is ONE bundle that takes every
568
+ // profile test with it. They are intercepted by absolute path so no resolution
569
+ // guessing is involved, and each stub carries the real export names: ESM named
570
+ // imports have to bind, which a catch-all default export cannot satisfy.
571
+ const stubEntries = [...serverActionStubs.entries()].sort(([a], [b]) => (a < b ? -1 : 1));
572
+ const specifierSources = new Map<string, string>();
573
+ const stubSources = stubEntries
574
+ .map(([abs, stub]) => {
575
+ // A name the analyser resolved returns the action's real literal; the
576
+ // rest keep the {} stand-in the comment above justifies.
577
+ const stubValue = (n: string): string =>
578
+ stub.returns[n] === undefined ? "{}" : JSON.stringify(stub.returns[n]);
579
+ const body = stub.names
580
+ .map((n) =>
581
+ n === "default"
582
+ ? `export default async () => (${stubValue(n)});`
583
+ : `export const ${n} = async () => (${stubValue(n)});`,
584
+ )
585
+ .join("\n");
586
+ // A module imported by a BARE specifier (a workspace package) is also
587
+ // stubbed at the specifier itself: the analyser resolved it to src, the
588
+ // bundler would resolve it to package.json's dist — two paths, neither
589
+ // of which the path key below can cover (B-11).
590
+ for (const spec of stub.specifiers) specifierSources.set(spec, body || "export {};");
591
+ // Relative to this file, resolved back to absolute when the map is built.
592
+ // Bun's onLoad hands us an absolute `args.path`, so the KEYS must be
593
+ // absolute at runtime — but an absolute key in the committed source names
594
+ // the machine that generated it, and every stub would miss on any other
595
+ // checkout, putting pg and nodemailer back into the browser bundle.
596
+ const key = hostDir ? relativeToEmitted(hostDir, abs) : abs;
597
+ return ` ${JSON.stringify(key)}: ${JSON.stringify(body || "export {};")},`;
598
+ })
599
+ .join("\n");
600
+ // Provider-bound auth libraries ride the same bare-specifier interception:
601
+ // they resolve fine in the suite's node_modules — that's the problem. The
602
+ // real module lands in the bundle and throws at import or first hook call
603
+ // without ClerkProvider/SessionProvider keys nobody hands a perf host.
604
+ for (const [spec, stub] of authStubs) specifierSources.set(spec, authShimEsm(spec, stub));
605
+ const specifierEntries = [...specifierSources.entries()].sort(([a], [b]) => (a < b ? -1 : 1));
606
+ const specifierSourceLines = specifierEntries
607
+ .map(([spec, body]) => ` ${JSON.stringify(spec)}: ${JSON.stringify(body)},`)
608
+ .join("\n");
609
+ // Escaped at GENERATION time so the emitted file carries a plain literal;
610
+ // /$^/ when empty, so the (never-entered) registration still parses.
611
+ const specifierFilter = specifierEntries
612
+ .map(([spec]) => spec.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
613
+ .join("|");
614
+ const specifierFilterLiteral =
615
+ specifierEntries.length > 0
616
+ ? `new RegExp(${JSON.stringify(`^(?:${specifierFilter})$`)})`
617
+ : "/$^/";
618
+ // next/navigation's client hooks throw "invariant expected app router to be
619
+ // mounted" outside a Next runtime, and the host IS a bare browser. Same
620
+ // stand-ins the unit plane's setup.ts installs (redirect/notFound throw on
621
+ // purpose: a component that wants to leave should fail the mount, not
622
+ // profile a page that never rendered). Fixed export list — the Next API
623
+ // surface is stable, so this needs no analysis pass, unlike authShims.
624
+ const navStubSource = [
625
+ "const __noopRouter = { push: () => {}, replace: () => {}, refresh: () => {}, back: () => {}, forward: () => {}, prefetch: () => {} };",
626
+ 'export const notFound = () => { throw new Error("notFound() called"); };',
627
+ 'export const redirect = () => { throw new Error("NEXT_REDIRECT"); };',
628
+ 'export const permanentRedirect = () => { throw new Error("NEXT_REDIRECT"); };',
629
+ 'export const usePathname = () => "/";',
630
+ "export const useRouter = () => __noopRouter;",
631
+ "export const useSearchParams = () => new URLSearchParams();",
632
+ "export const useParams = () => ({});",
633
+ "export const useSelectedLayoutSegment = () => null;",
634
+ "export const useSelectedLayoutSegments = () => [];",
635
+ 'export const useLinkStatus = () => "idle";',
636
+ ].join("\n");
637
+ // next/router — the PAGES-router twin of the stub above. Its useRouter
638
+ // throws "NextRouter was not mounted" outside Next's App container, so a
639
+ // pages app's component dies at render in the host exactly as it does under
640
+ // jsdom (talkformai: NavBar, DashboardMode, SignInForm — every mount). Navigate
641
+ // nowhere, but define every field the router API documents: `router.query.id`
642
+ // and `router.pathname.split("/")` are what a render actually touches, and an
643
+ // undefined one is a different crash, not a quieter one.
644
+ const pagesRouterStubSource = [
645
+ "import * as React from 'react';",
646
+ "const __router = { pathname: '/', route: '/', query: {}, asPath: '/', basePath: '', locale: undefined, locales: [], isLocaleDomain: false, isReady: true, isPreview: false, push: () => Promise.resolve(true), replace: () => Promise.resolve(true), prefetch: () => Promise.resolve(), pushScope: () => [], popScope: () => false, beforePopState: () => {}, reload: () => {}, back: () => {}, forward: () => {}, events: { on: () => {}, off: () => {}, emit: () => {} } };",
647
+ "export const router = __router;",
648
+ "export const useRouter = () => __router;",
649
+ "export const withRouter = (Component) => (props) => React.createElement(Component, Object.assign({}, props, { router: __router }));",
650
+ ].join("\n");
651
+ // next/script renders NOTHING into the tree: it injects its tag through
652
+ // Next's client router context, which a bare host does not have. A component
653
+ // whose whole output is <Script> therefore mounted as an empty container and
654
+ // failed the browser smoke on its markup floor — talkformai's GoogleAnalytics
655
+ // and PostHogScript, both expect(rootHtml.length).toBeGreaterThan(0) on an
656
+ // otherwise clean, error-free mount. Answer with a script element rendered IN
657
+ // PLACE so the component's own render is what gets measured, marked
658
+ // type="text/plain" so the browser neither fetches src nor executes inline
659
+ // code: third-party analytics has no business running inside a render
660
+ // benchmark. An inert stand-in is this host's answer everywhere else (DB
661
+ // drivers, fonts, both routers) — a silent blank was the outlier.
662
+ const nextScriptStubSource = [
663
+ "import * as React from 'react';",
664
+ "function FlawlessScript(props) { return React.createElement('script', { type: 'text/plain', src: typeof props.src === 'string' ? props.src : undefined, id: props.id, 'data-flawless-stub': 'next/script' }); }",
665
+ "export default FlawlessScript;",
666
+ ].join("\n");
667
+ const stubPlugin = `
668
+ // Path relative to this file -> replacement source, resolved to the absolute
669
+ // paths Bun's onLoad reports. See the comment where this was generated.
670
+ const SERVER_STUB_SOURCES: Record<string, string> = {
671
+ ${stubSources}
672
+ };
673
+ const SERVER_STUBS: Record<string, string> = Object.fromEntries(
674
+ Object.entries(SERVER_STUB_SOURCES).map(([rel, src]) => [path.resolve(HOST_DIR, rel), src]),
675
+ );
676
+
677
+ // Bare specifiers (workspace packages) -> replacement source. Intercepted at
678
+ // RESOLUTION, before package.json main/exports can route them to a dist build
679
+ // the path-keyed map has never heard of.
680
+ const SERVER_STUB_SPECIFIERS: Record<string, string> = {
681
+ ${specifierSourceLines}
682
+ };
683
+
684
+ const stubServerModules: BunPlugin = {
685
+ name: "flawless-server-stubs",
686
+ setup(build) {
687
+ // Node/bun built-ins are not available in the browser target. Stub them
688
+ // so component deps that conditionally import fs/stream/zlib don't crash
689
+ // the build.
690
+ build.onResolve({ filter: /^(node:|bun:)/ }, (args) => ({
691
+ path: args.path,
692
+ namespace: "flawless-node-stub",
693
+ }));
694
+ build.onLoad({ filter: /.*/, namespace: "flawless-node-stub" }, () => ({
695
+ contents:
696
+ "const make = () => { const f = function () {}; return new Proxy(f, { get: (t, p) => (p === '__esModule' ? false : p === 'then' ? undefined : (t[p] ??= make())), apply: () => make(), construct: () => new Proxy({}, { get: () => () => undefined }) }); }; const KNOWN = ['default', 'Buffer', 'URL', 'URLSearchParams', 'TextEncoder', 'TextDecoder', 'performance', 'platform', 'arch', 'env', 'version', 'cwd', 'dirname', 'join', 'resolve', 'sep', 'delimiter', 'normalize', 'extname', 'basename', 'relative', 'isAbsolute', 'tmpdir', 'homedir', 'readFileSync', 'writeFileSync', 'existsSync', 'statSync', 'readdirSync', 'mkdirSync', 'rmSync', 'promisify', 'createReadStream', 'createWriteStream', 'createGzip', 'createDeflate', 'pipeline', 'fileURLToPath', 'pathToFileURL', 'randomUUID', 'execSync', 'spawnSync', 'execFileSync', 'fetch', 'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval', 'AbortController', 'AbortSignal', 'EventEmitter', 'once', 'Stream', 'inspect', 'format', 'types', 'isDeepStrictEqual', 'createInterface', 'question', 'getLogger', 'Drizzle', 'drizzle', 'Kysely', 'Pool', 'Client', 'sql', 'PrismaPg', 'PrismaBetterSqlite3', 'PrismaBunSql', 'PrismaLibSql', 'PrismaD1', 'PrismaSqlite', 'SupabaseClient', 'createClient', 'globalThis']; const root = new Proxy({}, { get: (t, p) => (p === '__esModule' ? false : p === 'then' ? undefined : (t[p] ??= make())), ownKeys: () => KNOWN, getOwnPropertyDescriptor: (t, p) => ({ value: t[p] ??= make(), enumerable: true, configurable: true, writable: true }) }); module.exports = root;",
697
+ loader: "js",
698
+ }));
699
+ // Native .node addons cannot build for a browser target at all, and
700
+ // ?module wasm (next's vendored resvg) is not loadable here either. A
701
+ // component that actually CALLS into one throws at render and fails its
702
+ // own spec — acceptable; a hard BUILD error takes the whole host down.
703
+ build.onResolve({ filter: /\\.(node|wasm|so|dylib|dll)(\\?.*)?$/ }, (args) => ({
704
+ path: args.path,
705
+ namespace: "flawless-node-stub",
706
+ }));
707
+ // Database clients: even their "pure JS" entries load native query
708
+ // engines at module init. A relative import of a generated client (e.g.
709
+ // generated/prisma/client.ts) still drags in these BARE runtime
710
+ // specifiers, which is where the bundle is cut. Perf then measures the
711
+ // component's render with inert data — the suite's mocking philosophy.
712
+ //
713
+ // Yield the ones with a generated stub of their own. The Supabase family
714
+ // is matched here by PACKAGE PATTERN and in SERVER_STUB_SPECIFIERS by
715
+ // EXACT NAME, and esbuild asks hooks in registration order — this one is
716
+ // registered first, so it used to win: the signed-out auth-helpers stub
717
+ // sat in the map unused while the component got the inert CJS Proxy, and
718
+ // talkformai's NavBar / SignInForm / DashboardMode all timed out on
719
+ // "useSessionContext is not a function". A named stub is a decision about
720
+ // that module's surface; a pattern stub is only a fallback.
721
+ build.onResolve(
722
+ {
723
+ filter:
724
+ /^(@supabase\\/[a-z0-9._-]+|@prisma\\/adapter[a-z0-9._-]*|mongodb|mongoose|pg|kysely|drizzle-orm)([\\/:].*)?$/,
725
+ },
726
+ (args) =>
727
+ SERVER_STUB_SPECIFIERS[args.path]
728
+ ? undefined
729
+ : { path: args.path, namespace: "flawless-node-stub" },
730
+ );
731
+ // Prisma's generated client destructures the runtime namespace AT MODULE
732
+ // SCOPE — a Proxy'd CJS namespace loses its keys through bun's __toESM
733
+ // key-copy and kills the whole host bundle. A real ESM module with the
734
+ // runtime's export names declared survives the copy; each value is a
735
+ // recursive inert proxy (any call shape resolves, nothing connects).
736
+ build.onResolve({ filter: /^(prisma|@prisma\\/[a-z0-9._-]+)([\\/:].*)?$/ }, (args) => ({
737
+ path: args.path,
738
+ namespace: "flawless-prisma-stub",
739
+ }));
740
+ build.onLoad({ filter: /.*/, namespace: "flawless-prisma-stub" }, () => ({
741
+ contents:
742
+ "const make = () => { const f = function () {}; return new Proxy(f, { get: (t, p) => (p === '__esModule' ? true : p === 'then' ? undefined : (t[p] ??= make())), apply: () => make(), construct: () => new Proxy({}, { get: () => () => undefined }) }); };\\n" +
743
+ [
744
+ "AnyNull",
745
+ "Bytes",
746
+ "DbNull",
747
+ "Decimal",
748
+ "DecimalJsLike",
749
+ "DMMF",
750
+ "empty",
751
+ "Extensions",
752
+ "FieldRef",
753
+ "getPrismaClient",
754
+ "GetPrismaClientConfig",
755
+ "InputJsonArray",
756
+ "InputJsonObject",
757
+ "InputJsonValue",
758
+ "ITXClientDenyList",
759
+ "join",
760
+ "JsonArray",
761
+ "JsonNull",
762
+ "JsonObject",
763
+ "JsonValue",
764
+ "makeStrictEnum",
765
+ "NullTypes",
766
+ "Operation",
767
+ "PrismaClientInitializationError",
768
+ "PrismaClientKnownRequestError",
769
+ "PrismaClientRustPanicError",
770
+ "PrismaClientUnknownRequestError",
771
+ "PrismaClientValidationError",
772
+ "raw",
773
+ "Sql",
774
+ "SqlCommenterPlugin",
775
+ "SqlDriverAdapterFactory",
776
+ "sqltag",
777
+ "Types",
778
+ ]
779
+ .map((n) => "export const " + n + " = make();")
780
+ .join("\\n") +
781
+ "\\nexport default make();",
782
+ loader: "js",
783
+ }));
784
+ // The \\.js variant is real: nuqs (and other tsm-compiled libs) ship
785
+ // pre-compiled JS importing "next/navigation.js" to satisfy TS module
786
+ // resolution — an exact-specifier filter lets the genuine module
787
+ // through, and its useRouter throws "expected app router to be
788
+ // mounted" on every mount.
789
+ build.onResolve({ filter: /^next\\/navigation(\\.js)?$/ }, (args) => ({
790
+ path: args.path,
791
+ namespace: "flawless-next-nav-stub",
792
+ }));
793
+ // next/link and next/image are REAL components that resolve from the
794
+ // app's node_modules — and the link's router hook needs the app-router
795
+ // context no host provides ("invariant expected app router to be
796
+ // mounted", every chanhdai mount including icons, because the layout
797
+ // tree in main.tsx renders SiteHeader). Answer with the DOM elements
798
+ // they compile down to; anchors keep hrefs clickable for e2e habits.
799
+ build.onResolve({ filter: /^next[\\/](link|image)(\\.js)?$/ }, (args) => ({
800
+ path: args.path.indexOf("image") >= 0 ? "flawless:next/image" : "flawless:next/link",
801
+ namespace: "flawless-next-component-stub",
802
+ }));
803
+ build.onLoad({ filter: /.*/, namespace: "flawless-next-component-stub" }, (args) => ({
804
+ contents: args.path.endsWith(":image")
805
+ ? "import * as React from 'react'; function FlawlessImage(props) { var src = typeof props.src === 'string' ? props.src : (props.src && props.src.src) || ''; return React.createElement('img', { src: src, alt: props.alt ?? '', className: props.className, width: props.width, height: props.height }); } const Image = FlawlessImage; Image.loader = (s) => (typeof s === 'string' ? s : (s && s.src) || ''); export default Image;"
806
+ : "import * as React from 'react'; function FlawlessLink({ children, href, prefetch, scroll, replace, shallow, scrollBehavior, legacyBehavior, passHref, className, ...rest }) { return React.createElement('a', Object.assign({ href: href == null ? '#' : String(href) }, rest), children); } export default FlawlessLink;",
807
+ loader: "jsx",
808
+ }));
809
+ // next/font (google/local) factories RUN at module scope in every repo
810
+ // that imports a font package (geist et al.) — and the real loader is a
811
+ // compiler hook with no runtime. Answer with a callable that returns the
812
+ // same shape: className + CSS variable are all any consumer reads.
813
+ build.onResolve({ filter: /^next[\\/]font([\\/:].*)?$/ }, (args) => ({
814
+ path: args.path,
815
+ namespace: "flawless-font-stub",
816
+ }));
817
+ build.onLoad({ filter: /.*/, namespace: "flawless-font-stub" }, () => ({
818
+ contents:
819
+ "function makeFont(o) { return { className: 'flawless-stub-font', variable: '--flawless-stub-font', style: { fontFamily: (o && o.fallback && o.fallback.join(', ')) || '' } }; } const ns = new Proxy(makeFont, { get: (t, p) => (p === '__esModule' ? false : p === 'then' ? undefined : (t[p] ??= makeFont)), }); module.exports = ns;",
820
+ loader: "js",
821
+ }));
822
+ build.onLoad({ filter: /.*/, namespace: "flawless-next-nav-stub" }, () => ({
823
+ contents: ${JSON.stringify(navStubSource)},
824
+ loader: "js",
825
+ }));
826
+ // Pages-router twin of the nav stub, same \\.js reasoning.
827
+ build.onResolve({ filter: /^next[\\/]router(\\.js)?$/ }, (args) => ({
828
+ path: args.path,
829
+ namespace: "flawless-next-router-stub",
830
+ }));
831
+ build.onLoad({ filter: /.*/, namespace: "flawless-next-router-stub" }, () => ({
832
+ contents: ${JSON.stringify(pagesRouterStubSource)},
833
+ loader: "js",
834
+ }));
835
+ // next/script: renders nothing without Next's router context, so a component
836
+ // made only of <Script> would mount as an empty container. See the source.
837
+ build.onResolve({ filter: /^next[\\/]script(\\.js)?$/ }, (args) => ({
838
+ path: args.path,
839
+ namespace: "flawless-next-script-stub",
840
+ }));
841
+ build.onLoad({ filter: /.*/, namespace: "flawless-next-script-stub" }, () => ({
842
+ contents: ${JSON.stringify(nextScriptStubSource)},
843
+ loader: "js",
844
+ }));
845
+ // The server-component marker: absent in many repos (Bun's resolver
846
+ // fails outright) and throw-on-import where installed. Transitively
847
+ // importing modules still have to BUILD, so the specifier resolves to
848
+ // an empty module — same call the vitest config's alias makes.
849
+ build.onResolve({ filter: /^server-only$/ }, () => ({
850
+ path: "server-only",
851
+ namespace: "flawless-empty-stub",
852
+ }));
853
+ build.onLoad({ filter: /.*/, namespace: "flawless-empty-stub" }, () => ({
854
+ contents: "export {};",
855
+ loader: "ts",
856
+ }));
857
+ if (Object.keys(SERVER_STUB_SPECIFIERS).length > 0) {
858
+ build.onResolve({ filter: ${specifierFilterLiteral} }, (args) => ({
859
+ path: args.path,
860
+ namespace: "flawless-server-stub",
861
+ }));
862
+ build.onLoad({ filter: /.*/, namespace: "flawless-server-stub" }, (args) =>
863
+ SERVER_STUB_SPECIFIERS[args.path]
864
+ ? { contents: SERVER_STUB_SPECIFIERS[args.path], loader: "ts" }
865
+ : // No generated surface for this specifier: a CJS Proxy answers ANY
866
+ // named import at runtime instead of failing the ESM build.
867
+ {
868
+ contents:
869
+ "const make = () => { const f = function () {}; return new Proxy(f, { get: (t, p) => (p === '__esModule' ? false : p === 'then' ? undefined : (t[p] ??= make())), apply: () => make(), construct: () => new Proxy({}, { get: () => () => undefined }) }); }; const KNOWN = ['default', 'Buffer', 'URL', 'URLSearchParams', 'TextEncoder', 'TextDecoder', 'performance', 'platform', 'arch', 'env', 'version', 'cwd', 'dirname', 'join', 'resolve', 'sep', 'delimiter', 'normalize', 'extname', 'basename', 'relative', 'isAbsolute', 'tmpdir', 'homedir', 'readFileSync', 'writeFileSync', 'existsSync', 'statSync', 'readdirSync', 'mkdirSync', 'rmSync', 'promisify', 'createReadStream', 'createWriteStream', 'createGzip', 'createDeflate', 'pipeline', 'fileURLToPath', 'pathToFileURL', 'randomUUID', 'execSync', 'spawnSync', 'execFileSync', 'fetch', 'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval', 'AbortController', 'AbortSignal', 'EventEmitter', 'once', 'Stream', 'inspect', 'format', 'types', 'isDeepStrictEqual', 'createInterface', 'question', 'getLogger', 'Drizzle', 'drizzle', 'Kysely', 'Pool', 'Client', 'sql', 'PrismaPg', 'PrismaBetterSqlite3', 'PrismaBunSql', 'PrismaLibSql', 'PrismaD1', 'PrismaSqlite', 'SupabaseClient', 'createClient', 'globalThis']; const root = new Proxy({}, { get: (t, p) => (p === '__esModule' ? false : p === 'then' ? undefined : (t[p] ??= make())), ownKeys: () => KNOWN, getOwnPropertyDescriptor: (t, p) => ({ value: t[p] ??= make(), enumerable: true, configurable: true, writable: true }) }); module.exports = root;",
870
+ loader: "js",
871
+ },
872
+ );
873
+ }
874
+ build.onLoad({ filter: /\\.(ts|tsx|js|jsx|mjs)$/, namespace: "file" }, (args) => {
875
+ const src = SERVER_STUBS[args.path];
876
+ if (src) return { contents: src, loader: "ts" };
877
+ // next/font's NAMED exports are a compiler fiction — no real module
878
+ // backs them at runtime, so the stub above answers the specifier with
879
+ // a CJS Proxy. But CJS->ESM interop copies the export object's OWN
880
+ // keys before reading any of them, and a Proxy without an ownKeys
881
+ // trap reports none: every named font import arrives as undefined
882
+ // and the host dies at boot with "IBM_Plex_Serif is not a function"
883
+ // (chanhdai's first perf run, 341 specs, one error). Rewrite the
884
+ // forms that break — named and namespace imports — onto the default
885
+ // import, which interop preserves verbatim, and which the stub's
886
+ // callable Proxy answers for every name.
887
+ let code;
888
+ try {
889
+ code = readFileSync(args.path, "utf8");
890
+ } catch {
891
+ return;
892
+ }
893
+ if (!/next\\/font/.test(code)) return;
894
+ let flFontSeq = 0;
895
+ const rewritten = code
896
+ .replace(
897
+ /import\\s+(?:(\\w+)\\s*,\\s*)?\\{([^}]*)\\}\\s+from\\s+(["'])(next\\/font[^"']*)\\3/g,
898
+ (m, def, clause, quote, spec) => {
899
+ const text = String(clause).trim();
900
+ if (text.startsWith("type")) return m;
901
+ const parts = text
902
+ .split(",")
903
+ .map((s) => /^\\s*(\\w+)(?:\\s+as\\s+(\\w+))?\\s*$/.exec(s.trim()));
904
+ const named = parts.filter((p): p is RegExpExecArray => p !== null);
905
+ if (named.length !== parts.length) return m;
906
+ const alias = def || "__flFont" + flFontSeq++;
907
+ return (
908
+ "import " + alias + " from " + quote + spec + quote + "; const " +
909
+ named.map((p) => (p[2] || p[1]) + " = " + alias).join(", ") + ";"
910
+ );
911
+ },
912
+ )
913
+ .replace(
914
+ /import\\s+\\*\\s+as\\s+(\\w+)\\s+from\\s+(["'])(next\\/font[^"']*)\\2/g,
915
+ (m, name, quote, spec) => {
916
+ const alias = "__flFont" + flFontSeq++;
917
+ return "import " + alias + " from " + quote + spec + quote + "; const " + name + " = " + alias + ";";
918
+ },
919
+ );
920
+ if (rewritten === code) return;
921
+ const ext = args.path.slice(args.path.lastIndexOf(".") + 1);
922
+ const loader = ext === "tsx" || ext === "jsx" ? ext : ext === "js" || ext === "mjs" ? "js" : "ts";
923
+ return { contents: rewritten, loader };
924
+ });
925
+ },
926
+ };
927
+ const stubCount = Object.keys(SERVER_STUBS).length;
928
+ if (stubCount > 0) {
929
+ console.log(
930
+ "[flawless] stubbed " + stubCount + " server-action module(s) for the browser bundle" +
931
+ (Object.keys(SERVER_STUB_SPECIFIERS).length > 0
932
+ ? " (" + Object.keys(SERVER_STUB_SPECIFIERS).length + " by bare specifier)"
933
+ : ""),
934
+ );
935
+ }
936
+ `;
937
+
938
+ return `import path from "node:path";
939
+ import { readFileSync } from "node:fs";
940
+ import type { BunPlugin } from "bun";
941
+
942
+ const HOST_DIR = import.meta.dir;
943
+ const SUITE_DIR = path.dirname(HOST_DIR);
944
+ const PORT = Number(process.env.PORT ?? 5173);
945
+ ${mockPortConst}
946
+
947
+ // Resolve bare specifiers ("react", "react-dom/client", etc.) against the
948
+ // suite's node_modules instead of the importing file's directory. Component
949
+ // files often live outside the suite (e.g. in the user's repo, or in a
950
+ // fixtures directory with no React installed); without this plugin those
951
+ // imports fail to resolve.
952
+ const resolveFromSuite: BunPlugin = {
953
+ name: "resolve-from-suite",
954
+ setup(build) {
955
+ build.onResolve({ filter: /^[a-zA-Z@][^:]*$/ }, (args) => {
956
+ // msw ships its BROWSER WORKER entry behind a "browser" export
957
+ // CONDITION, and Bun.resolveSync resolves server-side conditions —
958
+ // answering this plugin for msw/browser would pin the node variant
959
+ // onto a browser bundle ("setupWorker failed in a non-browser
960
+ // environment", with global.process now faithfully present for
961
+ // scandir to split). Subpaths therefore fall through: the bundler's
962
+ // own resolver applies the target's conditions. The BARE specifier
963
+ // does NOT — with a catch-all plugin on bare specifiers attached, a
964
+ // fallen-through "msw" came back as a module shell whose named
965
+ // imports were silently dropped: miro-clone's host booted with
966
+ // "http is not defined" exactly where handlers.ts had written
967
+ // import { http } from "msw". Core msw is environment-neutral
968
+ // (its checkGlobals only checks URL), so resolving it like any other
969
+ // package is both correct and the only shape the bundler wires.
970
+ if (args.path.startsWith("msw/")) return;
971
+ try {
972
+ let resolved: string;
973
+ try {
974
+ resolved = Bun.resolveSync(args.path, SUITE_DIR);
975
+ } catch {
976
+ // A pnpm-style strict layout keeps transitive deps out of the
977
+ // suite's reach, but they ARE visible from the importer's own
978
+ // directory — resolve from there, the way node would. Letting the
979
+ // specifier fall through instead can silently drop the import:
980
+ // a transitive bare import that no plugin resolves never reaches
981
+ // the default resolver here, and the bundler emits the use site
982
+ // with the binding undefined ("toHtml is not defined" at boot).
983
+ if (!args.importer) return;
984
+ resolved = Bun.resolveSync(args.path, path.dirname(args.importer));
985
+ }
986
+ // A built-in comes back as its specifier ("node:fs"), not a file
987
+ // path. In the browser target there is no such file, and
988
+ // path.resolve("node:fs") would mangle it into "<suite>/node:fs"
989
+ // and crash the build with a bogus "File not found". Route it to the
990
+ // shared stub namespace (loaded below by stubServerModules) instead.
991
+ // This fires for bare imports like \`fs\`/\`stream\` — no \`node:\`
992
+ // prefix, so the /^(node:|bun:)/ filter above never sees them.
993
+ if (resolved.startsWith("node:") || resolved.startsWith("bun:")) {
994
+ return { path: args.path, namespace: "flawless-node-stub" };
995
+ }
996
+ return { path: resolved };
997
+ } catch {
998
+ return; // fall through to Bun's default resolver
999
+ }
1000
+ });
1001
+ },
1002
+ };
1003
+
1004
+ ${stubPlugin}
1005
+ // Stylesheets are deliberately invisible to the perf host. Bun's CSS loader
1006
+ // cannot run the app's real pipeline — tailwind v4's \`@import "tailwindcss"\`
1007
+ // resolves to the package's JS entry ("Cannot import a .js file into a CSS
1008
+ // file") and @theme/@apply are custom at-rules it never compiles anyway.
1009
+ // Perf measures the JS render cost; style fidelity is e2e/load's business.
1010
+ const stubCss: BunPlugin = {
1011
+ name: "flawless-css-stub",
1012
+ setup(build) {
1013
+ build.onResolve({ filter: /\\.(css|scss|sass|less)(\\?.*)?$/ }, (args) => ({
1014
+ path: args.path,
1015
+ namespace: "flawless-css-stub",
1016
+ }));
1017
+ build.onResolve({ filter: /^(@?tailwindcss)([\\/:].*)?$/ }, () => ({
1018
+ path: "tailwind-import",
1019
+ namespace: "flawless-css-stub",
1020
+ }));
1021
+ build.onLoad({ filter: /.*/, namespace: "flawless-css-stub" }, () => ({
1022
+ contents: "/* flawless perf: css intentionally stubbed */",
1023
+ loader: "css",
1024
+ }));
1025
+ },
1026
+ };
1027
+ const built = await Bun.build({
1028
+ entrypoints: [path.join(HOST_DIR, "main.tsx")],
1029
+ target: "browser",
1030
+ format: "esm",
1031
+ splitting: false,
1032
+ sourcemap: "inline",
1033
+ minify: false,
1034
+ plugins: [stubCss, stubServerModules, resolveFromSuite],
1035
+ // Replace bare \`global\` references at build time. The runtime polyfill
1036
+ // (host/_polyfill.ts, imported first from main.tsx) handles \`process\`.
1037
+ define: {
1038
+ global: "globalThis",
1039
+ ${envDefineLines}
1040
+ "process.env.NODE_ENV": '"development"',
1041
+ },
1042
+ });
1043
+
1044
+ if (!built.success) {
1045
+ for (const log of built.logs) console.error(log);
1046
+ throw new Error("host bundle failed");
1047
+ }
1048
+
1049
+ const bundle = await built.outputs[0]?.text();
1050
+ if (!bundle) throw new Error("host bundle produced no output");
1051
+
1052
+ const indexHtml = await Bun.file(path.join(HOST_DIR, "index.html")).text();
1053
+
1054
+ Bun.serve({
1055
+ port: PORT,
1056
+ hostname: "127.0.0.1",
1057
+ fetch(req) {
1058
+ const url = new URL(req.url);
1059
+ if (url.pathname === "/" || url.pathname === "/index.html") {
1060
+ return new Response(indexHtml, { headers: { "content-type": "text/html" } });
1061
+ }
1062
+ // MSW registers its worker from the page origin, so the host has to serve
1063
+ // it. Without this the registration 404s on every boot.
1064
+ if (url.pathname === "/mockServiceWorker.js") {
1065
+ try {
1066
+ const swPath = Bun.resolveSync("msw/mockServiceWorker.js", SUITE_DIR);
1067
+ return new Response(Bun.file(swPath), {
1068
+ headers: { "content-type": "text/javascript", "service-worker-allowed": "/" },
1069
+ });
1070
+ } catch {
1071
+ return new Response("// msw not installed", {
1072
+ status: 404,
1073
+ headers: { "content-type": "text/javascript" },
1074
+ });
1075
+ }
1076
+ }
1077
+ if (url.pathname === "/bundle.js") {
1078
+ return new Response(bundle, { headers: { "content-type": "application/javascript" } });
1079
+ }${mockProxyBlock}
1080
+ return new Response("not found", { status: 404 });
1081
+ },
1082
+ });
1083
+
1084
+ console.log("host listening on http://127.0.0.1:" + PORT);
1085
+ `;
1086
+ }
1087
+
1088
+ function hostHtml(): string {
1089
+ return `<!doctype html>
1090
+ <html lang="en">
1091
+ <head>
1092
+ <meta charset="utf-8" />
1093
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
1094
+ <title>flawless-react host</title>
1095
+ <script>
1096
+ // Node-shim fill-in BEFORE any bundle code evaluates. The _polyfill
1097
+ // module cannot own this: the bundler decides module execution order,
1098
+ // and a CJS require chain reached through an app dependency (fast-glob
1099
+ // via @nodelib/fs.scandir splitting process.versions.node) ran before
1100
+ // it. An inline classic script is the only position that provably
1101
+ // precedes the bundle's module script.
1102
+ (function () {
1103
+ var g = globalThis;
1104
+ var p = (g.process = g.process || {});
1105
+ try {
1106
+ p.env = p.env || {};
1107
+ p.versions = p.versions || {};
1108
+ if (typeof p.versions.node !== "string") p.versions.node = "22.0.0";
1109
+ if (typeof p.platform !== "string") p.platform = "browser";
1110
+ if (!Array.isArray(p.argv)) p.argv = [];
1111
+ // versions.node is also msw's isNodeProcess fingerprint. The
1112
+ // renderer type is the Electron hatch msw and @mswjs/interceptors
1113
+ // built for exactly this: process-shaped-but-in-a-browser.
1114
+ if (typeof p.type !== "string") p.type = "renderer";
1115
+ if (typeof p.cwd !== "function") p.cwd = function () { return "/"; };
1116
+ if (typeof g.global === "undefined") g.global = g;
1117
+ if (typeof g.setImmediate === "undefined")
1118
+ g.setImmediate = function (fn) { return setTimeout(fn, 0); };
1119
+ } catch (e) {}
1120
+ })();
1121
+ </script>
1122
+ </head>
1123
+ <body>
1124
+ <div id="root"></div>
1125
+ <script type="module" src="/bundle.js"></script>
1126
+ </body>
1127
+ </html>
1128
+ `;
1129
+ }
1130
+
1131
+ function hostPolyfill(): string {
1132
+ return `// Browser polyfill for Node-only globals referenced at module init by some
1133
+ // runtime deps (e.g. react-performance-testing's CJS build reads
1134
+ // process.env.RPT_SKIP_AUTO_CLEANUP before any user code runs).
1135
+ //
1136
+ // Imported first from main.tsx — ESM evaluates imports in source order, so
1137
+ // globalThis is patched before any other module is initialised.
1138
+ const g = globalThis as unknown as {
1139
+ process?: {
1140
+ env: Record<string, string | undefined>;
1141
+ platform: string;
1142
+ versions: Record<string, string>;
1143
+ argv: string[];
1144
+ type?: string;
1145
+ cwd?: () => string;
1146
+ };
1147
+ global?: typeof globalThis;
1148
+ setImmediate?: (fn: (...args: unknown[]) => void, ...args: unknown[]) => number;
1149
+ };
1150
+
1151
+ if (typeof g.process === "undefined") {
1152
+ g.process = {
1153
+ env: { NODE_ENV: "development" },
1154
+ platform: "browser",
1155
+ versions: {},
1156
+ argv: [],
1157
+ type: "renderer",
1158
+ cwd: () => "/",
1159
+ };
1160
+ } else {
1161
+ // The bundler's own browser shim usually wins the race to define process,
1162
+ // and it is partial: no versions.node, which @nodelib/fs.scandir (and
1163
+ // every other fast-glob caller) splits at module scope. Fill the holes
1164
+ // rather than assume an all-or-nothing assignment.
1165
+ const p = g.process as Record<string, unknown>;
1166
+ try {
1167
+ p.env ??= {};
1168
+ p.versions ??= {};
1169
+ const v = p.versions as Record<string, string>;
1170
+ if (typeof v.node !== "string") v.node = "22.0.0";
1171
+ if (typeof p.platform !== "string") p.platform = "browser";
1172
+ if (!Array.isArray(p.argv)) p.argv = [];
1173
+ if (typeof p.type !== "string") p.type = "renderer";
1174
+ if (typeof p.cwd !== "function") p.cwd = () => "/";
1175
+ } catch {
1176
+ // A frozen shim is not this file's problem to solve.
1177
+ }
1178
+ }
1179
+ if (typeof g.global === "undefined") {
1180
+ g.global = globalThis;
1181
+ }
1182
+ if (typeof g.setImmediate === "undefined") {
1183
+ // react-performance-testing schedules its task queue via setImmediate; falling
1184
+ // back to a microtask is good enough for our profiling needs.
1185
+ g.setImmediate = ((fn: (...args: unknown[]) => void, ...args: unknown[]) =>
1186
+ setTimeout(fn, 0, ...args)) as unknown as typeof g.setImmediate;
1187
+ }
1188
+
1189
+ export {};
1190
+ `;
1191
+ }
1192
+
1193
+ /**
1194
+ * host/registry.ts — the mechanical mount tables, split out of the write-once
1195
+ * wrapper (FR-016: a kept main.tsx froze the registry with it, so a newly
1196
+ * analysed component's specs could never mount, and no flag could fix it).
1197
+ * Regenerated on every run; hand registrations belong in main.tsx's ALL
1198
+ * spread, which survives.
1199
+ */
1200
+ function hostRegistry(
1201
+ flat: FlatComponent[],
1202
+ providerWraps: Map<string, ProviderRef[]> | undefined,
1203
+ hostDir: string,
1204
+ ): string {
1205
+ // Sorted by registry key: emission is byte-identical for a given set of
1206
+ // specNames. (Byte-identity across PERMUTED entry order holds only until a
1207
+ // componentName collision — allocateSpecNames is delivery-order-dependent
1208
+ // in who keeps the bare name; that predates the split and lives upstream.)
1209
+ // Code-unit order (not localeCompare): locale rules interleave cases and
1210
+ // would reorder — churning — every adopted suite's registry on refresh.
1211
+ const sorted = [...flat].sort((a, b) =>
1212
+ a.specName < b.specName ? -1 : a.specName > b.specName ? 1 : 0,
1213
+ );
1214
+ // A wrappable compound part mounts through a composed entry: the family
1215
+ // Root (open, unless the part is the Trigger — a trigger's visible state
1216
+ // is a CLOSED root) and the Content sibling around the part itself. The
1217
+ // composition lives HERE, regenerated-side, so a kept main.tsx benefits
1218
+ // from a plain refresh — its ALL spread sees a Component like any other.
1219
+ // Wrapper imports are aliased per entry: the Root is often a registry
1220
+ // component itself, already imported under its own name from this module.
1221
+ const wrappedOf = (c: FlatComponent): NonNullable<ComponentInfo["compoundFamily"]> | undefined =>
1222
+ compoundWrappable(c.info) ? c.info.compoundFamily : undefined;
1223
+ const wrapImports = sorted
1224
+ .map((c) => {
1225
+ const fam = wrappedOf(c);
1226
+ if (!fam) return "";
1227
+ const content =
1228
+ fam.part === "other" && fam.content ? `, ${fam.content} as ${c.specName}__Content` : "";
1229
+ return `import { ${fam.root} as ${c.specName}__Root${content} } from "${c.importPath}";`;
1230
+ })
1231
+ .filter((l) => l !== "")
1232
+ .join(NL);
1233
+ const anyWrapped = wrapImports !== "";
1234
+ // Context providers to mount around each component, derived upstream in
1235
+ // analyse/providerWraps.ts from useX CALL evidence — the provider is often
1236
+ // exported by a file NO analysed component imports (route layouts import
1237
+ // SidebarProvider, and route files are not entries), so it is imported here
1238
+ // directly under a __Prov alias allocateSpecNames never produces. A
1239
+ // provider that cannot mount bare breaks the wraps it guards, and the
1240
+ // dogfood failure then names it.
1241
+ const hostedKey = new Map<string, string>();
1242
+ for (const c of sorted) {
1243
+ hostedKey.set(`${c.info.filePath}::${c.info.componentName}`, c.specName);
1244
+ }
1245
+ const provAlias = new Map<string, string>();
1246
+ const bindingLabel = new Map<string, string>();
1247
+ const provImportLines: string[] = [];
1248
+ const providersFor = (c: FlatComponent): string[] => {
1249
+ const out: string[] = [];
1250
+ const self = `${c.info.filePath}::${c.info.componentName}`;
1251
+ for (const ref of providerWraps?.get(self) ?? []) {
1252
+ const key = `${ref.filePath}::${ref.componentName}`;
1253
+ if (key === self) continue; // never wraps itself
1254
+ const hosted = hostedKey.get(key);
1255
+ if (hosted) {
1256
+ bindingLabel.set(hosted, ref.componentName);
1257
+ out.push(hosted);
1258
+ continue;
1259
+ }
1260
+ let binding = provAlias.get(key);
1261
+ if (!binding) {
1262
+ binding = `__Prov${ref.componentName}`;
1263
+ for (let n = 2; bindingLabel.has(binding); n++) binding = `__Prov${ref.componentName}${n}`;
1264
+ provAlias.set(key, binding);
1265
+ bindingLabel.set(binding, ref.componentName);
1266
+ const rel = stripTsExt(toRel(hostDir, ref.filePath));
1267
+ provImportLines.push(
1268
+ ref.exportKind === "default"
1269
+ ? `import ${binding} from "${rel}";`
1270
+ : `import { ${ref.exportedAs ?? ref.componentName} as ${binding} } from "${rel}";`,
1271
+ );
1272
+ }
1273
+ out.push(binding);
1274
+ }
1275
+ return out;
1276
+ };
1277
+ const providersByEntry = new Map<string, { bindings: string[]; labels: string[] }>();
1278
+ for (const c of sorted) {
1279
+ const bindings = providersFor(c);
1280
+ if (bindings.length > 0) {
1281
+ providersByEntry.set(
1282
+ c.specName,
1283
+ bindings.reduce(
1284
+ (acc, b) => {
1285
+ acc.bindings.push(b);
1286
+ acc.labels.push(bindingLabel.get(b) ?? b);
1287
+ return acc;
1288
+ },
1289
+ { bindings: [], labels: [] } as { bindings: string[]; labels: string[] },
1290
+ ),
1291
+ );
1292
+ }
1293
+ }
1294
+ const anyProvider = providersByEntry.size > 0;
1295
+ const imports = [
1296
+ ...(anyWrapped || anyProvider ? ['import { createElement } from "react";'] : []),
1297
+ sorted.map(hostComponentImport).join(NL),
1298
+ ...(anyWrapped ? [wrapImports] : []),
1299
+ ...(anyProvider ? [provImportLines.join(NL)] : []),
1300
+ ].join(NL);
1301
+
1302
+ // Cast per entry: a component declaring required props is not assignable to
1303
+ // the host's loose (props: Record<string, unknown>) => unknown signature, and
1304
+ // supplying those props is exactly what the profile spec does at runtime.
1305
+ const withProvidersCall = (value: string, c: FlatComponent): string => {
1306
+ const entry = providersByEntry.get(c.specName);
1307
+ return entry === undefined ? value : `withProviders(${value}, [${entry.bindings.join(", ")}])`;
1308
+ };
1309
+ const registry = sorted
1310
+ .map((c) => {
1311
+ const fam = wrappedOf(c);
1312
+ const entry = fam
1313
+ ? (() => {
1314
+ const inner = `createElement(${c.specName} as never, props as never)`;
1315
+ const chained =
1316
+ fam.part === "other" && fam.content
1317
+ ? `createElement(${c.specName}__Content as never, null, ${inner})`
1318
+ : inner;
1319
+ const rootProps = fam.part === "trigger" ? "null" : "{ open: true } as never";
1320
+ return `((props: Record<string, unknown>) => createElement(${c.specName}__Root as never, ${rootProps}, ${chained})) as Component`;
1321
+ })()
1322
+ : `${c.specName} as unknown as Component`;
1323
+ return ` ${JSON.stringify(c.specName)}: ${withProvidersCall(entry, c)},`;
1324
+ })
1325
+ .join(NL);
1326
+ const providerHelper = anyProvider
1327
+ ? `
1328
+ /** Nest the derived context providers around a component (outermost first). */
1329
+ const withProviders = (Component: Component, providers: Component[]): Component =>
1330
+ providers.length === 0
1331
+ ? Component
1332
+ : (props) =>
1333
+ providers.reduceRight(
1334
+ (children, Provider) => createElement(Provider as never, null, children),
1335
+ createElement(Component as never, props as never),
1336
+ );
1337
+ `
1338
+ : "";
1339
+ const autoTable = sorted
1340
+ .map((c) => {
1341
+ const entry = providersByEntry.get(c.specName);
1342
+ return entry ? ` ${JSON.stringify(c.specName)}: ${JSON.stringify(entry.labels)},` : "";
1343
+ })
1344
+ .filter(Boolean)
1345
+ .join(NL);
1346
+
1347
+ // Function props cannot cross page.evaluate's structured clone, so a spec's
1348
+ // DEFAULTS can never carry one. The component then calls undefined: AuthError
1349
+ // renders <Button onClick={reset}> and threw the moment the click landed. The
1350
+ // host holds the names and supplies no-ops on this side of the boundary.
1351
+ const fnProps = sorted
1352
+ .map((c) => ({ name: c.specName, props: functionProps(c.info) }))
1353
+ .filter((e) => e.props.length > 0)
1354
+ .map((e) => ` ${JSON.stringify(e.name)}: ${JSON.stringify(e.props)},`)
1355
+ .join(NL);
1356
+
1357
+ // Components whose root tag HTML only permits inside a particular parent.
1358
+ // Rendering <tbody> into the host's #root div made React log "In HTML, <tbody>
1359
+ // cannot be a child of <div>", which failed seven table primitives for a
1360
+ // warning the harness itself caused.
1361
+ const wrapIn = sorted
1362
+ .map((c) => ({ name: c.specName, chain: requiredAncestors(c.info.rootTag) }))
1363
+ .filter((e) => e.chain.length > 0)
1364
+ .map((e) => ` ${JSON.stringify(e.name)}: ${JSON.stringify(e.chain)},`)
1365
+ .join(NL);
1366
+
1367
+ return `// Regenerated on every flawless-react run — derived tables only, so edits
1368
+ // here are lost. Hand-registered components belong in main.tsx's ALL spread,
1369
+ // which is never overwritten.
1370
+ ${imports}
1371
+
1372
+ /** The loose signature the host mounts through; props arrive at runtime. */
1373
+ export type Component = (props: Record<string, unknown>) => unknown;
1374
+ ${providerHelper}
1375
+ export const REGISTRY: Record<string, Component> = {
1376
+ ${registry}
1377
+ };
1378
+
1379
+ /** Component → context providers the host mounts around it (useX call evidence). */
1380
+ export const AUTO_PROVIDERS: Record<string, string[]> = {
1381
+ ${autoTable}
1382
+ };
1383
+
1384
+ /** Props the component calls, which a serialised props object cannot carry. */
1385
+ export const FN_PROPS: Record<string, string[]> = {
1386
+ ${fnProps}
1387
+ };
1388
+
1389
+ /** Ancestors HTML requires around a component's root tag, outermost first. */
1390
+ export const WRAP_IN: Record<string, string[]> = {
1391
+ ${wrapIn}
1392
+ };
1393
+ `;
1394
+ }
1395
+
1396
+ function hostMain(providerImportPath?: string, mswSetup = false, legacyMount = false): string {
1397
+ const providerImport = providerImportPath
1398
+ ? `import AppProvider from ${JSON.stringify(stripTsExt(providerImportPath))};\n`
1399
+ : "";
1400
+ const wrapBody = providerImportPath ? "createElement(AppProvider as never, null, tree)" : "tree";
1401
+ // React 17 has no react-dom/client. The host speaks the legacy renderer
1402
+ // through the same small Root interface the 18+ path uses, so every
1403
+ // mount/rerender/unmount call site stays version-blind.
1404
+ const mountImport = legacyMount
1405
+ ? `import * as ReactDOM from "react-dom";
1406
+
1407
+ interface Root {
1408
+ render(node: ReactNode): void;
1409
+ unmount(): void;
1410
+ }
1411
+ `
1412
+ : `import { createRoot, type Root } from "react-dom/client";
1413
+ `;
1414
+ const rootInit = legacyMount
1415
+ ? ` // React 17 has no onUncaughtError: the legacy render throws synchronously.
1416
+ // Record and rethrow — the mount wait-loop reads the same variable the
1417
+ // concurrent renderer's callback fills, so failure reporting stays identical.
1418
+ activeRoot = {
1419
+ render: (node) => {
1420
+ try {
1421
+ ReactDOM.render(node as never, el);
1422
+ } catch (err) {
1423
+ lastRenderError = err;
1424
+ throw err;
1425
+ }
1426
+ },
1427
+ unmount: () => {
1428
+ ReactDOM.unmountComponentAtNode(el);
1429
+ },
1430
+ };
1431
+ `
1432
+ : ` activeRoot = createRoot(el, {
1433
+ onUncaughtError: (err: unknown) => {
1434
+ lastRenderError = err;
1435
+ },
1436
+ });
1437
+ `;
1438
+ const mswImport = mswSetup ? `import { startMockWorker } from "../mocks/browser";\n` : "";
1439
+ const mswBoot = mswSetup
1440
+ ? `
1441
+ // Boot MSW before any component mounts so fetch() in render-time effects is
1442
+ // intercepted. Failure is NOT fatal: this await sits at module top level, so a
1443
+ // rejection would abort the module and leave window.__profile undefined —
1444
+ // every profile test then times out waiting for a harness that never loaded,
1445
+ // which is a very confusing way to learn that a service worker 404'd.
1446
+ try {
1447
+ await startMockWorker();
1448
+ } catch (err) {
1449
+ console.warn("[flawless] MSW did not start; fetches will hit the network:", err);
1450
+ }
1451
+ `
1452
+ : "";
1453
+
1454
+ return `import "./_polyfill";
1455
+ import { createElement, Profiler, type ReactNode } from "react";
1456
+ ${mountImport}import { scan } from "react-scan";
1457
+ ${mswImport}${providerImport}// ./registry last among the app imports: it pulls every component module,
1458
+ // and the monolithic host evaluated msw/provider modules FIRST — a component
1459
+ // with module-scope code leaning on a provider module's side effects (i18n
1460
+ // init, interceptor install) must keep that order across the split.
1461
+ import { type Component, FN_PROPS, REGISTRY, WRAP_IN } from "./registry";
1462
+ ${mswBoot}
1463
+
1464
+ // We measure renders with React's built-in Profiler rather than
1465
+ // react-performance-testing's patching: the latter only instruments the
1466
+ // classic JSX runtime (\`React.createElement\`), and Bun.build emits JSX via
1467
+ // the automatic \`react/jsx-runtime\`. Profiler works with either runtime and
1468
+ // across React versions, so the host stays portable.
1469
+
1470
+ // Every lookup below goes through the ALL_* spreads, never the registry
1471
+ // tables directly: ./registry is regenerated on every run, so these spreads
1472
+ // are the one place hand-registered entries survive. All three exist for the
1473
+ // same reason — a hand-registered component may also need function-prop
1474
+ // stubs (functions cannot cross page.evaluate's structured clone) or a
1475
+ // wrapper chain, and without its own spread each table would silently drop
1476
+ // hand entries on the next regeneration.
1477
+ const ALL: Record<string, Component> = {
1478
+ ...REGISTRY /* hand-registered components ride here and survive regeneration */,
1479
+ };
1480
+ const ALL_FN_PROPS: Record<string, readonly string[]> = {
1481
+ ...FN_PROPS /* hand-registered components' function-prop names ride here */,
1482
+ };
1483
+ const ALL_WRAP_IN: Record<string, readonly string[]> = {
1484
+ ...WRAP_IN /* hand-registered components' wrapper chains ride here */,
1485
+ };
1486
+
1487
+ function withFnProps(name: string, props: Record<string, unknown>): Record<string, unknown> {
1488
+ const names = ALL_FN_PROPS[name];
1489
+ if (!names) return props;
1490
+ const out = { ...props };
1491
+ for (const p of names) if (out[p] === undefined) out[p] = () => {};
1492
+ return out;
1493
+ }
1494
+
1495
+ function nest(name: string, tree: ReactNode): ReactNode {
1496
+ const chain = ALL_WRAP_IN[name];
1497
+ if (!chain) return tree;
1498
+ let out = tree;
1499
+ for (let i = chain.length - 1; i >= 0; i--) out = createElement(chain[i] as string, null, out);
1500
+ return out;
1501
+ }
1502
+
1503
+ interface RenderCountField { value: number }
1504
+ interface RenderTimeField { mount?: number; updates?: number[] }
1505
+
1506
+ const renderCount: Record<string, RenderCountField | undefined> = {};
1507
+ const renderTime: Record<string, RenderTimeField | undefined> = {};
1508
+
1509
+ let activeRoot: Root | undefined;
1510
+ let activeName: string | undefined;
1511
+
1512
+ const scanEvents: Array<{ name: string; reason: string; ts: number }> = [];
1513
+
1514
+ // react-scan is measurement sugar: on a React it cannot instrument (17 is
1515
+ // supported but its internals differ) it must never take the host down —
1516
+ // render counts come from the Profiler, not from scan.
1517
+ try {
1518
+ scan({
1519
+ enabled: true,
1520
+ log: false,
1521
+ onRender: (fiber: { type?: { displayName?: string; name?: string } }, renders: unknown[]) => {
1522
+ const name = fiber.type?.displayName ?? fiber.type?.name ?? "anonymous";
1523
+ for (const r of renders as Array<{ changes?: unknown[]; unnecessary?: boolean; reason?: string }>) {
1524
+ if (r.unnecessary) {
1525
+ scanEvents.push({
1526
+ name,
1527
+ reason: r.reason ?? "unnecessary rerender",
1528
+ ts: performance.now(),
1529
+ });
1530
+ }
1531
+ }
1532
+ },
1533
+ });
1534
+ } catch (err) {
1535
+ console.warn("[flawless] react-scan disabled:", err);
1536
+ }
1537
+
1538
+ // The last exception React swallowed on our behalf. Without it every render
1539
+ // failure looked identical — "did not render within 5000ms" — no matter whether
1540
+ // the component threw on a missing prop, a missing provider or a bad import.
1541
+ let lastRenderError: unknown;
1542
+
1543
+ function ensureRoot(): Root {
1544
+ if (activeRoot) return activeRoot;
1545
+ const el = document.getElementById("root");
1546
+ if (!el) throw new Error("#root not found");
1547
+ ${rootInit} return activeRoot;
1548
+ }
1549
+
1550
+ // Wrap the profiled component tree in the user's Provider (if --provider was
1551
+ // passed at scaffold time). Without a provider this is the identity function,
1552
+ // so call sites stay uniform.
1553
+ function wrap(tree: ReactNode): ReactNode {
1554
+ return ${wrapBody};
1555
+ }
1556
+
1557
+ function onProfile(id: string, phase: string, actualDuration: number): void {
1558
+ const c = renderCount[id] ?? (renderCount[id] = { value: 0 });
1559
+ c.value += 1;
1560
+ const t = renderTime[id] ?? (renderTime[id] = {});
1561
+ if (phase === "mount") {
1562
+ t.mount = actualDuration;
1563
+ } else {
1564
+ (t.updates ??= []).push(actualDuration);
1565
+ }
1566
+ }
1567
+
1568
+ declare global {
1569
+ interface Window {
1570
+ __profile: {
1571
+ mount(name: string, props: Record<string, unknown>): Promise<void>;
1572
+ rerender(name: string, props: Record<string, unknown>): Promise<void>;
1573
+ unmount(): Promise<void>;
1574
+ getRenderInfo(): { renderCount: unknown; renderTime: unknown };
1575
+ getScanEvents(): typeof scanEvents;
1576
+ clearScanEvents(): void;
1577
+ list(): string[];
1578
+ };
1579
+ }
1580
+ }
1581
+
1582
+ window.__profile = {
1583
+ list: () => Object.keys(ALL),
1584
+ async mount(name, props) {
1585
+ const Comp = ALL[name];
1586
+ if (!Comp) throw new Error("unknown component: " + name);
1587
+ activeName = name;
1588
+ lastRenderError = undefined;
1589
+ ensureRoot().render(
1590
+ wrap(
1591
+ createElement(
1592
+ Profiler,
1593
+ { id: name, onRender: onProfile },
1594
+ nest(name, createElement(Comp as never, withFnProps(name, props))),
1595
+ ),
1596
+ ),
1597
+ );
1598
+ const deadline = performance.now() + 5000;
1599
+ while (performance.now() < deadline) {
1600
+ if ((renderCount[name]?.value ?? 0) >= 1) return;
1601
+ if (lastRenderError) break;
1602
+ await new Promise<void>((res) => requestAnimationFrame(() => res()));
1603
+ }
1604
+ const cause = lastRenderError
1605
+ ? ": " + (lastRenderError instanceof Error ? lastRenderError.message : String(lastRenderError))
1606
+ : " within 5000ms";
1607
+ throw new Error("mount: " + name + " did not render" + cause);
1608
+ },
1609
+ async rerender(name, props) {
1610
+ if (!activeRoot) throw new Error("mount first");
1611
+ const Comp = ALL[name];
1612
+ if (!Comp) throw new Error("unknown component: " + name);
1613
+ activeRoot.render(
1614
+ wrap(
1615
+ createElement(
1616
+ Profiler,
1617
+ { id: name, onRender: onProfile },
1618
+ nest(name, createElement(Comp as never, withFnProps(name, props))),
1619
+ ),
1620
+ ),
1621
+ );
1622
+ activeName = name;
1623
+ await new Promise<void>((res) => requestAnimationFrame(() => res()));
1624
+ },
1625
+ async unmount() {
1626
+ if (activeRoot) {
1627
+ activeRoot.unmount();
1628
+ activeRoot = undefined;
1629
+ activeName = undefined;
1630
+ for (const k of Object.keys(renderCount)) delete renderCount[k];
1631
+ for (const k of Object.keys(renderTime)) delete renderTime[k];
1632
+ }
1633
+ },
1634
+ getRenderInfo() {
1635
+ return { renderCount, renderTime };
1636
+ },
1637
+ getScanEvents() {
1638
+ return [...scanEvents];
1639
+ },
1640
+ clearScanEvents() {
1641
+ scanEvents.length = 0;
1642
+ },
1643
+ };
1644
+
1645
+ // Suppress unused-name warning during isolated builds.
1646
+ void activeName;
1647
+ `;
1648
+ }
1649
+
1650
+ function helpers(): string {
1651
+ return `import type { Page } from "@playwright/test";
1652
+
1653
+ export interface RenderInfoSnapshot {
1654
+ renderCount: Record<string, { value: number } | undefined>;
1655
+ renderTime: Record<
1656
+ string,
1657
+ | {
1658
+ mount?: number;
1659
+ updates?: number[];
1660
+ }
1661
+ | undefined
1662
+ >;
1663
+ }
1664
+
1665
+ export interface ScanEvent {
1666
+ name: string;
1667
+ reason: string;
1668
+ ts: number;
1669
+ }
1670
+
1671
+ export async function mountComponent(page: Page, name: string, props: Record<string, unknown>): Promise<void> {
1672
+ await page.evaluate(
1673
+ ([n, p]) => window.__profile.mount(n as string, p as Record<string, unknown>),
1674
+ [name, props],
1675
+ );
1676
+ }
1677
+
1678
+ export async function rerenderComponent(
1679
+ page: Page,
1680
+ name: string,
1681
+ props: Record<string, unknown>,
1682
+ ): Promise<void> {
1683
+ await page.evaluate(
1684
+ ([n, p]) => window.__profile.rerender(n as string, p as Record<string, unknown>),
1685
+ [name, props],
1686
+ );
1687
+ }
1688
+
1689
+ /**
1690
+ * N rerenders in one round trip.
1691
+ *
1692
+ * Driving the loop from Node cost a CDP round trip per iteration — 50 per
1693
+ * component, 4,700 across a whole-app sweep, and about 150s of a 13.5-minute
1694
+ * run spent in IPC rather than in React. The loop runs in the page instead, and
1695
+ * rerender still awaits a frame between commits, so the Profiler records
1696
+ * exactly the same renders.
1697
+ */
1698
+ export async function rerenderMany(
1699
+ page: Page,
1700
+ name: string,
1701
+ pool: Array<Record<string, unknown>>,
1702
+ iterations: number,
1703
+ ): Promise<void> {
1704
+ await page.evaluate(
1705
+ async ([n, p, count]) => {
1706
+ const props = p as Array<Record<string, unknown>>;
1707
+ for (let i = 0; i < (count as number); i++) {
1708
+ await window.__profile.rerender(n as string, props[i % props.length] as Record<string, unknown>);
1709
+ }
1710
+ },
1711
+ [name, pool, iterations] as [string, Array<Record<string, unknown>>, number],
1712
+ );
1713
+ }
1714
+
1715
+ export async function unmount(page: Page): Promise<void> {
1716
+ await page.evaluate(() => window.__profile.unmount());
1717
+ }
1718
+
1719
+ export async function getRenderInfo(page: Page): Promise<RenderInfoSnapshot> {
1720
+ return page.evaluate(() => window.__profile.getRenderInfo() as RenderInfoSnapshot);
1721
+ }
1722
+
1723
+ export async function getScanEvents(page: Page): Promise<ScanEvent[]> {
1724
+ return page.evaluate(() => window.__profile.getScanEvents());
1725
+ }
1726
+
1727
+ export async function clearScanEvents(page: Page): Promise<void> {
1728
+ await page.evaluate(() => window.__profile.clearScanEvents());
1729
+ }
1730
+
1731
+ export function quantile(sorted: number[], q: number): number {
1732
+ if (sorted.length === 0) return 0;
1733
+ const pos = (sorted.length - 1) * q;
1734
+ const lo = Math.floor(pos);
1735
+ const hi = Math.ceil(pos);
1736
+ const a = sorted[lo] as number;
1737
+ if (lo === hi) return a;
1738
+ const b = sorted[hi] as number;
1739
+ return a + (b - a) * (pos - lo);
1740
+ }
1741
+
1742
+ export function summarize(samples: number[]): {
1743
+ min: number;
1744
+ avg: number;
1745
+ p50: number;
1746
+ p95: number;
1747
+ max: number;
1748
+ n: number;
1749
+ } {
1750
+ if (samples.length === 0) return { min: 0, avg: 0, p50: 0, p95: 0, max: 0, n: 0 };
1751
+ const sorted = [...samples].sort((a, b) => a - b);
1752
+ const sum = sorted.reduce((s, v) => s + v, 0);
1753
+ return {
1754
+ min: sorted[0] as number,
1755
+ avg: sum / sorted.length,
1756
+ p50: quantile(sorted, 0.5),
1757
+ p95: quantile(sorted, 0.95),
1758
+ max: sorted[sorted.length - 1] as number,
1759
+ n: sorted.length,
1760
+ };
1761
+ }
1762
+ `;
1763
+ }
1764
+
1765
+ function heapHelper(mode: HeapMode): string {
1766
+ return `import { mkdir, writeFile } from "node:fs/promises";
1767
+ import path from "node:path";
1768
+ import type { CDPSession, Page, TestInfo } from "@playwright/test";
1769
+
1770
+ // Drives the browser's V8 heap profiler via CDP. Snapshots land in
1771
+ // <suite-root>/heaps/<spec>__<test>__<label>.heapsnapshot with predictable
1772
+ // names so you can grab them from the filesystem directly (no need to dig
1773
+ // through playwright-report/data/<sha1>). They're also attached to the
1774
+ // Playwright HTML report so you can navigate from a failed test to its
1775
+ // snapshot. Format is the standard V8 .heapsnapshot, viewable in Chrome
1776
+ // DevTools' Memory tab.
1777
+
1778
+ const HEAPS_DIR = "heaps";
1779
+
1780
+ /**
1781
+ * How much of the heap this suite keeps, from --heaps at scaffold time.
1782
+ *
1783
+ * "off" skips the CDP capture entirely. That is not only about disk: a heap
1784
+ * snapshot of a real app is tens of megabytes and takes real time, and one is
1785
+ * taken either side of every scenario — an observer that costs more than the
1786
+ * thing it observes, inside the measurement it is bracketing.
1787
+ */
1788
+ const HEAP_MODE: "off" | "stress" | "all" = ${JSON.stringify(mode)};
1789
+
1790
+ /**
1791
+ * A heap snapshot, or an empty buffer.
1792
+ *
1793
+ * Snapshots are diagnostics, not the measurement. Opening a CDP session can fail
1794
+ * for reasons that have nothing to do with the component under test — a page
1795
+ * that has already navigated, a Playwright/Chromium pairing that disagrees about
1796
+ * a target guid — and letting that throw means the profiling extra breaks the
1797
+ * very tests it was added to observe. It warns and returns nothing instead.
1798
+ */
1799
+ async function captureSnapshot(page: Page): Promise<Buffer> {
1800
+ if (HEAP_MODE === "off") return Buffer.alloc(0);
1801
+ let session: CDPSession;
1802
+ try {
1803
+ session = await page.context().newCDPSession(page);
1804
+ } catch (err) {
1805
+ console.warn("[flawless] heap snapshot skipped:", (err as Error).message);
1806
+ return Buffer.alloc(0);
1807
+ }
1808
+ const chunks: string[] = [];
1809
+ const onChunk = (e: { chunk: string }): void => {
1810
+ chunks.push(e.chunk);
1811
+ };
1812
+ session.on("HeapProfiler.addHeapSnapshotChunk", onChunk);
1813
+ try {
1814
+ // Force GC so the snapshot reflects retained-by-reachability, not
1815
+ // not-yet-collected garbage.
1816
+ await session.send("HeapProfiler.collectGarbage");
1817
+ await session.send("HeapProfiler.takeHeapSnapshot", {
1818
+ reportProgress: false,
1819
+ captureNumericValue: true,
1820
+ });
1821
+ } catch (err) {
1822
+ console.warn("[flawless] heap snapshot failed:", (err as Error).message);
1823
+ return Buffer.alloc(0);
1824
+ } finally {
1825
+ session.off("HeapProfiler.addHeapSnapshotChunk", onChunk);
1826
+ await session.detach().catch(() => undefined);
1827
+ }
1828
+ return Buffer.from(chunks.join(""), "utf8");
1829
+ }
1830
+
1831
+ function slug(s: string): string {
1832
+ return s.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
1833
+ }
1834
+
1835
+ async function persistSnapshot(buf: Buffer, label: string, testInfo: TestInfo): Promise<string> {
1836
+ // Anchored to the CONFIG FILE via the runtime FullConfig — the one anchor
1837
+ // that exists under both loaders. NOT rootDir: playwright resolves rootDir
1838
+ // to the testDir ("./profile" here), which would put snapshots in
1839
+ // profile/heaps/ outside the emitted .gitignore's <out>/heaps/ entry — the
1840
+ // 566MB-of-untracked-binaries class this path has already been through
1841
+ // twice (cwd first, the CJS dirname global second).
1842
+ const configFile = testInfo.config.configFile;
1843
+ const suiteRoot = configFile ? path.dirname(configFile) : testInfo.config.rootDir;
1844
+ const dir = path.join(suiteRoot, HEAPS_DIR);
1845
+ await mkdir(dir, { recursive: true });
1846
+ const specBase = slug(path.basename(testInfo.file).replace(/\\.spec\\.ts$/, ""));
1847
+ const title = slug(testInfo.title);
1848
+ const filename = \`\${specBase}__\${title}__\${slug(label)}.heapsnapshot\`;
1849
+ const out = path.join(dir, filename);
1850
+ await writeFile(out, buf);
1851
+ await testInfo.attach(filename, { path: out, contentType: "application/octet-stream" });
1852
+ return out;
1853
+ }
1854
+
1855
+ export interface HeapSnapshotResult {
1856
+ byteSize: number;
1857
+ path: string;
1858
+ }
1859
+
1860
+ export async function takeHeapSnapshot(
1861
+ page: Page,
1862
+ label: string,
1863
+ testInfo: TestInfo,
1864
+ ): Promise<HeapSnapshotResult> {
1865
+ const buf = await captureSnapshot(page);
1866
+ const outPath = await persistSnapshot(buf, label, testInfo);
1867
+ return { byteSize: buf.byteLength, path: outPath };
1868
+ }
1869
+
1870
+ // Brackets a scenario with before/after heap snapshots. Snapshots are only
1871
+ // persisted on failure (cheap signal when something breaks) or when the
1872
+ // scenario name starts with "stress-" (always kept so leak diffs are
1873
+ // available). On a clean non-stress pass, the buffers are dropped to keep
1874
+ // CI artifacts small.
1875
+ export async function bracketScenario(
1876
+ page: Page,
1877
+ name: string,
1878
+ testInfo: TestInfo,
1879
+ fn: () => Promise<void>,
1880
+ ): Promise<void> {
1881
+ const before = await captureSnapshot(page);
1882
+ let threw: unknown;
1883
+ try {
1884
+ await fn();
1885
+ } catch (err) {
1886
+ threw = err;
1887
+ }
1888
+ const after = await captureSnapshot(page);
1889
+ const failed = threw !== undefined;
1890
+ const isStress = name.startsWith("stress-");
1891
+ const keep =
1892
+ HEAP_MODE !== "off" && (HEAP_MODE === "all" || failed || isStress) && after.byteLength > 0;
1893
+ if (keep) {
1894
+ const bp = await persistSnapshot(before, \`\${name}-before\`, testInfo);
1895
+ const ap = await persistSnapshot(after, \`\${name}-after\`, testInfo);
1896
+ const delta = after.byteLength - before.byteLength;
1897
+ const sign = delta >= 0 ? "+" : "";
1898
+ console.log(
1899
+ \`[heap] \${name}: \${before.byteLength} -> \${after.byteLength} bytes (delta \${sign}\${delta}) -> \${path.relative(process.cwd(), ap)}\`,
1900
+ );
1901
+ void bp;
1902
+ }
1903
+ if (threw) throw threw;
1904
+ }
1905
+
1906
+ export async function repeatClick(page: Page, selector: string, n: number): Promise<void> {
1907
+ const locator = page.locator(selector).first();
1908
+ for (let i = 0; i < n; i++) {
1909
+ await locator.click();
1910
+ }
1911
+ }
1912
+ `;
1913
+ }
1914
+
1915
+ function budgets(flat: FlatComponent[], _variants: number): string {
1916
+ const obj: Record<string, { mountMs: number; updateMs: number }> = {};
1917
+ for (const c of flat) obj[c.specName] = { mountMs: 50, updateMs: 16 };
1918
+ return `${JSON.stringify(obj, null, 2)}\n`;
1919
+ }
1920
+
1921
+ function spec(c: FlatComponent, variants: number): string {
1922
+ // The registry key, so the mount call finds the right component when two share
1923
+ // a name.
1924
+ const name = c.specName;
1925
+ const propsLiteral = defaultsJson(synthesiseDefaults(c.info));
1926
+ const variantsLiteral = JSON.stringify(c.info.variants.slice(0, variants));
1927
+ const hooksLiteral = JSON.stringify(c.info.hooks);
1928
+ const forcedIterations = Math.max(10, variants * 10);
1929
+ const targetedClick = emitPlaywrightClickTarget(c.info.interactions);
1930
+ // Clicking a purely presentational component re-renders nothing, so waiting
1931
+ // for the render count to rise times out after 30s and reports a stateless
1932
+ // Button as broken. Only assert a rerender when the analyser saw something
1933
+ // that could cause one: local state, or a handler the parent supplies.
1934
+ const hasAnalysedClick = (c.info.interactions?.clicks ?? []).some((cl) => cl.event === "onClick");
1935
+ const hasForm = (c.info.interactions?.forms ?? []).length > 0;
1936
+ const canRerender =
1937
+ ((c.info.state ?? []).some((st) => st.kind !== "useRef") ||
1938
+ (c.info.interactions?.clicks ?? []).some((cl) => cl.binding.kind === "prop") ||
1939
+ (c.info.hooks ?? []).some((h) => /^use(State|Reducer|SyncExternalStore|Context)$/.test(h))) &&
1940
+ // With no analysed click handler the scenario falls back to "the first
1941
+ // button on the page". In a form component that button is the SUBMIT
1942
+ // control, and clicking it runs an action — against a stubbed server action
1943
+ // in a host with no backend, which is not a render measurement.
1944
+ !(hasForm && !hasAnalysedClick);
1945
+ // No blind fallback. `page.locator("button").first()` claims the first button
1946
+ // is the one the analysis found a handler on — true only when there is one
1947
+ // button, and in VoteCard it resolved to a disabled "Cast vote" while the
1948
+ // handler belonged to a vote option. When nothing names the target, the
1949
+ // scenario skips with the reason instead of clicking whatever is first:
1950
+ // an empty locator forces clickSkipReason below, so this expression is only
1951
+ // ever emitted with a derived target in it.
1952
+ const clickButtonExpr = targetedClick.locator;
1953
+ const openDisclosure = targetedClick.prelude ? `\n ${targetedClick.prelude}` : "";
1954
+
1955
+ // Whether a click scenario can run at all is decided HERE, not at run time.
1956
+ // `test.skip(cond, reason)` inside a body runs every beforeEach first and then
1957
+ // throws, so 259 of the dogfood app's 470 profile tests were paying a full
1958
+ // page load to discover they had nothing to do — 38% of a 13.5-minute run.
1959
+ // `test.skip(title, fn)` is declarative: Playwright never runs the hooks.
1960
+ //
1961
+ // The reason moves into the title, which is a reporting gain: the list
1962
+ // reporter prints titles and does not print runtime skip reasons at all.
1963
+ const clickSkipReason = !targetedClick.locator
1964
+ ? (targetedClick.reason ?? "no clickable target could be derived")
1965
+ : !canRerender
1966
+ ? hasForm && !hasAnalysedClick
1967
+ ? "the only clickable target is a form submit, not a render measurement"
1968
+ : "no state or handler prop, so a click cannot re-render this component"
1969
+ : undefined;
1970
+
1971
+ const clickScenario = !clickSkipReason
1972
+ ? `test(\`\${NAME}: click rerender\`, async ({ page }, testInfo) => {
1973
+ await mountComponent(page, NAME, DEFAULTS);${openDisclosure}
1974
+ const button = ${clickButtonExpr};
1975
+ const hasButton = (await button.count()) > 0;
1976
+ test.skip(!hasButton, "component does not render a clickable target");
1977
+ // A control that is disabled or covered is not a profiling failure; clicking
1978
+ // it would just wait out the 30s actionability timeout and report the
1979
+ // component as broken.
1980
+ const clickable = hasButton && (await button.isEnabled()) && (await button.isVisible());
1981
+ test.skip(!clickable, "the clickable target is disabled or not visible at defaults");
1982
+ await bracketScenario(page, "click-rerender", testInfo, async () => {
1983
+ await clearScanEvents(page);
1984
+ const before = (await getRenderInfo(page)).renderCount[NAME]?.value ?? 0;
1985
+ await button.click();
1986
+ await page.waitForFunction(
1987
+ ([n, b]) => {
1988
+ const v = window.__profile.getRenderInfo().renderCount as Record<string, { value: number } | undefined>;
1989
+ return (v[n as string]?.value ?? 0) > (b as number);
1990
+ },
1991
+ [NAME, before],
1992
+ );
1993
+ const events = await getScanEvents(page);
1994
+ if (events.length > 0) {
1995
+ console.warn(\`[react-scan] \${NAME} click triggered \${events.length} unnecessary rerender(s)\`);
1996
+ }
1997
+ });
1998
+ });`
1999
+ : `test.skip(\`\${NAME}: click rerender — ${titleSafe(clickSkipReason)}\`, () => {});`;
2000
+ const stressScenario = !clickSkipReason
2001
+ ? `test(\`\${NAME}: stress-click-rerender x 50\`, async ({ page }, testInfo) => {
2002
+ await mountComponent(page, NAME, DEFAULTS);${openDisclosure}
2003
+ const button = ${clickButtonExpr};
2004
+ const hasButton = (await button.count()) > 0;
2005
+ test.skip(!hasButton, "component does not render a clickable target");
2006
+ // A control that is disabled or covered is not a profiling failure; clicking
2007
+ // it would just wait out the 30s actionability timeout and report the
2008
+ // component as broken.
2009
+ const clickable = hasButton && (await button.isEnabled()) && (await button.isVisible());
2010
+ test.skip(!clickable, "the clickable target is disabled or not visible at defaults");
2011
+ await bracketScenario(page, "stress-click-rerender", testInfo, async () => {
2012
+ const startCount = (await getRenderInfo(page)).renderCount[NAME]?.value ?? 0;
2013
+ // Stop when the control goes away. A "Remove" button in a list removes its
2014
+ // own row, so it survives as many clicks as there are rows — and clicking a
2015
+ // locator that no longer resolves waits out the whole timeout, reporting a
2016
+ // working component as broken.
2017
+ let __clicks = 0;
2018
+ for (let __i = 0; __i < 50; __i++) {
2019
+ if ((await button.count()) === 0 || !(await button.isEnabled())) break;
2020
+ await button.click();
2021
+ __clicks++;
2022
+ }
2023
+ test.skip(__clicks === 0, "the clickable target vanished before the first click");
2024
+ await page.waitForFunction(
2025
+ ([n, b]) => {
2026
+ const v = window.__profile.getRenderInfo().renderCount as Record<string, { value: number } | undefined>;
2027
+ return (v[n as string]?.value ?? 0) > (b as number);
2028
+ },
2029
+ [NAME, startCount],
2030
+ { timeout: 30_000 },
2031
+ );
2032
+ console.log(\`[\${NAME} stress] \${__clicks} click(s) landed before the target went away\`);
2033
+ });
2034
+ });`
2035
+ : `test.skip(\`\${NAME}: stress-click-rerender x 50 — ${titleSafe(clickSkipReason)}\`, () => {});`;
2036
+
2037
+ // Same reasoning as the click scenarios: VARIANTS is emitted as a literal, so
2038
+ // "are there any" is answerable here rather than after a page load.
2039
+ const variantScenario =
2040
+ variantsLiteral === "[]"
2041
+ ? `test.skip(\`\${NAME}: variant matrix — no union-of-literal props to vary\`, () => {});`
2042
+ : `test(\`\${NAME}: variant matrix\`, async ({ page }, testInfo) => {
2043
+ await bracketScenario(page, "variant-matrix", testInfo, async () => {
2044
+ await mountComponent(page, NAME, DEFAULTS);
2045
+ const samples: number[] = [];
2046
+ for (const v of VARIANTS) {
2047
+ await rerenderComponent(page, NAME, v);
2048
+ const info = await getRenderInfo(page);
2049
+ const updates = info.renderTime[NAME]?.updates ?? [];
2050
+ const last = updates[updates.length - 1];
2051
+ if (typeof last === "number") samples.push(last);
2052
+ }
2053
+ const stats = summarize(samples);
2054
+ console.log(\`[\${NAME} variants] \${JSON.stringify(stats)}\`);
2055
+ const budget = (budgets as Record<string, { updateMs: number } | undefined>)[NAME]?.updateMs ?? 16;
2056
+ if (stats.p95 > budget) {
2057
+ console.warn(\`[budget] \${NAME} p95 \${stats.p95.toFixed(2)}ms > \${budget}ms\`);
2058
+ }
2059
+ });
2060
+ });`;
2061
+
2062
+ return `import { test, expect } from "@playwright/test";
2063
+ import budgets from "../budgets.json" with { type: "json" };
2064
+ import {
2065
+ mountComponent,
2066
+ rerenderComponent,
2067
+ rerenderMany,
2068
+ unmount,
2069
+ getRenderInfo,
2070
+ getScanEvents,
2071
+ clearScanEvents,
2072
+ summarize,
2073
+ } from "./_helpers";
2074
+ import { bracketScenario, repeatClick, takeHeapSnapshot } from "./_heap";
2075
+
2076
+ const NAME = ${JSON.stringify(name)};
2077
+ const DEFAULTS = ${propsLiteral} as Record<string, unknown>;
2078
+ const VARIANTS = ${variantsLiteral} as Array<Record<string, unknown>>;
2079
+ const HOOKS = ${hooksLiteral} as string[];
2080
+
2081
+ test.beforeEach(async ({ page }) => {
2082
+ await page.goto("/");
2083
+ await page.waitForFunction(() => typeof window.__profile?.mount === "function");
2084
+ await clearScanEvents(page);
2085
+ });
2086
+
2087
+ test.afterEach(async ({ page }) => {
2088
+ await unmount(page);
2089
+ });
2090
+
2091
+ test(\`\${NAME}: mounts at defaults\`, async ({ page }) => {
2092
+ await mountComponent(page, NAME, DEFAULTS);
2093
+ const info = await getRenderInfo(page);
2094
+ const count = info.renderCount[NAME]?.value ?? 0;
2095
+ expect(count).toBeGreaterThanOrEqual(1);
2096
+ const mountMs = info.renderTime[NAME]?.mount ?? 0;
2097
+ const budget = (budgets as Record<string, { mountMs: number } | undefined>)[NAME]?.mountMs ?? 50;
2098
+ if (mountMs > budget) {
2099
+ console.warn(\`[budget] \${NAME} mount \${mountMs.toFixed(2)}ms > \${budget}ms\`);
2100
+ }
2101
+ });
2102
+
2103
+ ${variantScenario}
2104
+
2105
+ ${clickScenario}
2106
+
2107
+ test(\`\${NAME}: forced rerender x \${${forcedIterations}}\`, async ({ page }, testInfo) => {
2108
+ await mountComponent(page, NAME, DEFAULTS);
2109
+ await bracketScenario(page, "forced-rerender", testInfo, async () => {
2110
+ const pool: Array<Record<string, unknown>> =
2111
+ VARIANTS.length > 0 ? VARIANTS : [DEFAULTS];
2112
+ await rerenderMany(page, NAME, pool, ${forcedIterations});
2113
+ const info = await getRenderInfo(page);
2114
+ const updates = info.renderTime[NAME]?.updates ?? [];
2115
+ const stats = summarize(updates as number[]);
2116
+ console.log(\`[\${NAME} forced] \${JSON.stringify(stats)}\`);
2117
+ // Smoke: at least one update was timed.
2118
+ expect(stats.n).toBeGreaterThan(0);
2119
+ // HOOKS list is informational — surface it in the test output for context.
2120
+ if (HOOKS.length > 0) console.log(\`[\${NAME} hooks] \${HOOKS.join(", ")}\`);
2121
+ });
2122
+ });
2123
+
2124
+ // Repeat-N click stress: baseline snapshot, 50 clicks, final snapshot. Both
2125
+ // snapshots are always attached so a leak diff can be opened in Chrome DevTools
2126
+ // (Memory tab → load → Comparison view).
2127
+ ${stressScenario}
2128
+
2129
+ // takeHeapSnapshot is exported so future analyses (e.g. byte-delta asserts) can
2130
+ // call it directly without bracketScenario.
2131
+ void takeHeapSnapshot;
2132
+ `;
2133
+ }
2134
+
2135
+ /**
2136
+ * The profile suite's README, in the vocabulary of the mode that generated it.
2137
+ *
2138
+ * Standalone, the suite is its own package and `bun run test` is its script.
2139
+ * In Next.js mode there is no suite package.json — it is skipped in renderSuite
2140
+ * — so the same block told the user to run `bun run test`, which walks up to the
2141
+ * APPLICATION's package.json and runs its real test suite. That reads as
2142
+ * success, which is why it lasted.
2143
+ *
2144
+ * Only the perf scripts are named. This file IS the perf-harness output, so
2145
+ * those are the only ones guaranteed to have been declared by the run that
2146
+ * wrote it; naming flawless:e2e or flawless:load here would reintroduce the
2147
+ * defect one task over.
2148
+ */
2149
+ function readme(
2150
+ suiteName: string,
2151
+ componentNames: string[],
2152
+ excluded: ExcludedComponent[] = [],
2153
+ opts: { nextjsHost?: boolean; report?: boolean } = {},
2154
+ ): string {
2155
+ const nextjs = Boolean(opts.nextjsHost);
2156
+ const perfScript = nextjs ? NEXT_SCRIPT.perf : SUITE_SCRIPT.test;
2157
+ // Commented lines aligned on the longest command in THIS block, rather than a
2158
+ // fixed width — `bun run flawless:report` is wider than the standalone names
2159
+ // the constant was picked for, and overflowed it.
2160
+ const runLines: Array<[string, string]> = nextjs
2161
+ ? [[`bun run ${perfScript}`, "headless"]]
2162
+ : [
2163
+ [`bun run ${perfScript}`, "headless"],
2164
+ [`bun run ${SUITE_SCRIPT.headed}`, "open Chromium"],
2165
+ ];
2166
+ if (opts.report) {
2167
+ runLines.push([
2168
+ `bun run ${nextjs ? NEXT_SCRIPT.report : SUITE_SCRIPT.report}`,
2169
+ "aggregate the last run into report.md",
2170
+ ]);
2171
+ }
2172
+ const width = Math.max(...runLines.map(([cmd]) => cmd.length));
2173
+ const commented = runLines.map(([cmd, note]) => `${cmd.padEnd(width)} # ${note}`).join("\n");
2174
+ // No `bun install` in Next.js mode: the host project owns node_modules, and
2175
+ // there is no suite package.json here to install from.
2176
+ const runBlock = nextjs ? commented : `bun install\n${commented}`;
2177
+ // The React-version note is about the suite's OWN package.json, which only
2178
+ // exists standalone. In Next.js mode the host project owns node_modules and
2179
+ // there is no `bun install` here to pin anything.
2180
+ const reactNote = nextjs
2181
+ ? "- Components import their own React from the host project's `node_modules`, which is the only copy in play."
2182
+ : "- Components import their own React from the parent project's `node_modules`. The suite's own React must match the major version; `bun install` here pins the version your app declares (detected at generation; `^18.3.0` when nothing is detectable).";
2183
+ const componentList =
2184
+ componentNames.length > 1
2185
+ ? `\n\nComponents in this suite: ${componentNames.map((n) => `\`${n}\``).join(", ")}.\n`
2186
+ : "";
2187
+ // Silence here would read as "everything is covered". Each line says what was
2188
+ // left out of the browser host and why.
2189
+ const excludedList =
2190
+ excluded.length > 0
2191
+ ? `\n## Not in this host (${excluded.length})\n\n${excluded
2192
+ .map((e) => `- \`${e.name}\` — ${e.reason}`)
2193
+ .join("\n")}\n`
2194
+ : "";
2195
+ return `# ${suiteName}.profile${componentList}${excludedList}
2196
+
2197
+ Auto-generated by [flawless-react](https://github.com/lariocpt/flawless-react).
2198
+
2199
+ This README is not yours — it is regenerated by \`--force\`, so that a later
2200
+ version of flawless-react can tell you something it worked out after your suite
2201
+ was generated. Put your own notes in \`host/main.tsx\` or \`budgets.json\`: those
2202
+ two are written once and never overwritten, and everything else here is derived
2203
+ and will be rebuilt.
2204
+
2205
+ ## Run
2206
+
2207
+ \`\`\`bash
2208
+ ${runBlock}
2209
+ \`\`\`
2210
+
2211
+ ## What's measured
2212
+
2213
+ - **Mount time** and **update times** per component, via React's built-in \`<Profiler>\`.
2214
+ - **Unnecessary rerenders** flagged by \`react-scan\`.
2215
+ - Wall-clock distribution (min / avg / p50 / p95 / max) over a forced-rerender loop.
2216
+
2217
+ ## Customise
2218
+
2219
+ - \`budgets.json\` — tighten per-component mount/update budgets. Tests warn (not fail) on breach by default; switch the \`console.warn\` to \`expect(...).toBeLessThan(...)\` to make them hard.
2220
+ - \`profile/_helpers.ts\` — add scenarios; e.g. mock data sources before \`mountComponent\`.
2221
+ - \`host/main.tsx\` — hand-register extra components in the \`ALL\` spread. The tables in \`host/registry.ts\` are regenerated on every run, so entries added there are lost.
2222
+
2223
+ ## Notes
2224
+
2225
+ ${reactNote}
2226
+ - The Playwright \`webServer\` boots \`bun host/server.ts\` automatically; no manual server step.
2227
+ `;
2228
+ }
2229
+
2230
+ /**
2231
+ * A .mjs file, so it is plain JavaScript — no type annotations.
2232
+ *
2233
+ * It carried `const sections: string[] = […]` for as long as it existed, which
2234
+ * node refuses outright ("Missing initializer in const declaration"), so
2235
+ * `bun run report` had never once worked. Nothing noticed because
2236
+ * tests/emittedSyntax.test.ts filters on /\.tsx?$/ and this file is neither.
2237
+ * tests/emittedExecution.test.ts now runs it under node.
2238
+ */
2239
+ function reportScript(reportScriptName: string, perfScriptName: string): string {
2240
+ return `#!/usr/bin/env node
2241
+ // Aggregates the latest Playwright run's stdout into a markdown summary at report.md.
2242
+ // Re-run with: bun run ${reportScriptName}
2243
+ import { readFileSync, writeFileSync } from "node:fs";
2244
+ import path from "node:path";
2245
+ import { fileURLToPath } from "node:url";
2246
+
2247
+ // Anchored to this file, not to the working directory. Standalone the script is
2248
+ // run from the suite root and a relative path happened to work; in Next.js mode
2249
+ // the script is declared in the APP's package.json and runs from the app root,
2250
+ // where "test-results/..." resolves somewhere that does not exist.
2251
+ const SUITE_DIR = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
2252
+
2253
+ // The JSON reporter's output, not playwright-report/index.html. The HTML report
2254
+ // keeps every stdout line inside a base64 zip blob under
2255
+ // window.playwrightReportBase64, so the regex below matched nothing there and
2256
+ // this script wrote a report with no sections in it.
2257
+ const RESULTS = path.join(SUITE_DIR, "test-results", "results.json");
2258
+
2259
+ let report;
2260
+ try {
2261
+ report = JSON.parse(readFileSync(RESULTS, "utf8"));
2262
+ } catch {
2263
+ console.error("no " + RESULTS + " — run \`bun run ${perfScriptName}\` first");
2264
+ process.exit(1);
2265
+ }
2266
+
2267
+ // Suites nest arbitrarily deep (file, describe, project), and the lines we want
2268
+ // are on the individual test results.
2269
+ function collectStdout(suites) {
2270
+ let text = "";
2271
+ for (const suite of suites ?? []) {
2272
+ for (const spec of suite.specs ?? []) {
2273
+ for (const t of spec.tests ?? []) {
2274
+ for (const r of t.results ?? []) {
2275
+ for (const chunk of r.stdout ?? []) {
2276
+ if (typeof chunk === "string") text += chunk;
2277
+ else if (chunk.text !== undefined) text += chunk.text;
2278
+ else if (chunk.buffer !== undefined) {
2279
+ text += Buffer.from(chunk.buffer, "base64").toString("utf8");
2280
+ }
2281
+ }
2282
+ }
2283
+ }
2284
+ }
2285
+ text += collectStdout(suite.suites);
2286
+ }
2287
+ return text;
2288
+ }
2289
+
2290
+ const sections = ["# Profile report", ""];
2291
+ const stats = [...collectStdout(report.suites).matchAll(/\\[(.+?) (variants|forced)\\] (\\{.+?\\})/g)];
2292
+ for (const m of stats) {
2293
+ sections.push(\`## \${m[1]} (\${m[2]})\`);
2294
+ sections.push("\\n\`\`\`json");
2295
+ sections.push(m[3] ?? "{}");
2296
+ sections.push("\`\`\`\\n");
2297
+ }
2298
+ writeFileSync(path.join(SUITE_DIR, "report.md"), sections.join("\\n"));
2299
+ console.log("wrote report.md");
2300
+ `;
2301
+ }
2302
+
2303
+ function mockServer(): string {
2304
+ return `import { routes } from "./routes";
2305
+
2306
+ const PORT = Number(process.env.MOCK_PORT ?? 5174);
2307
+
2308
+ // Convert an OpenAPI path template ("/users/{id}") into a RegExp matcher.
2309
+ // Captures the parameter names so handlers can read them via the second arg
2310
+ // even though we don't currently expose them (the response body is static).
2311
+ function compile(pattern: string): { re: RegExp; params: string[] } {
2312
+ const params: string[] = [];
2313
+ const reSrc =
2314
+ "^" +
2315
+ pattern.replace(/{([^}]+)}/g, (_, name) => {
2316
+ params.push(name);
2317
+ return "([^/]+)";
2318
+ }) +
2319
+ "$";
2320
+ return { re: new RegExp(reSrc), params };
2321
+ }
2322
+
2323
+ const compiled = routes.map((r) => ({ ...r, ...compile(r.path) }));
2324
+
2325
+ Bun.serve({
2326
+ port: PORT,
2327
+ hostname: "127.0.0.1",
2328
+ fetch(req) {
2329
+ const url = new URL(req.url);
2330
+ const method = req.method.toUpperCase();
2331
+ // Root health endpoint — Playwright's webServer rejects 404s when probing
2332
+ // readiness, so we return a 200 with a route count for "/".
2333
+ if (url.pathname === "/") {
2334
+ return new Response(JSON.stringify({ ok: true, routes: compiled.length }), {
2335
+ status: 200,
2336
+ headers: { "content-type": "application/json" },
2337
+ });
2338
+ }
2339
+ for (const r of compiled) {
2340
+ if (r.method !== method) continue;
2341
+ if (!r.re.test(url.pathname)) continue;
2342
+ return new Response(r.body === null ? null : JSON.stringify(r.body), {
2343
+ status: r.status,
2344
+ headers: r.body === null ? {} : { "content-type": "application/json" },
2345
+ });
2346
+ }
2347
+ return new Response(JSON.stringify({ error: "not mocked", method, path: url.pathname }), {
2348
+ status: 404,
2349
+ headers: { "content-type": "application/json" },
2350
+ });
2351
+ },
2352
+ });
2353
+
2354
+ console.log("mock listening on http://127.0.0.1:" + PORT);
2355
+ `;
2356
+ }
2357
+
2358
+ function mockRoutesFile(routes: MockRoute[]): string {
2359
+ const literal = JSON.stringify(routes, null, 2);
2360
+ return `// Routes derived at scaffold time from the OpenAPI spec passed to --api.
2361
+ // Each entry pairs a path template + method with a pre-computed response body
2362
+ // (either the spec's first example, or a schema-synthesised default).
2363
+ //
2364
+ // Edit freely — there is no regeneration step that will overwrite this file.
2365
+
2366
+ export interface MockRoute {
2367
+ method: string;
2368
+ path: string;
2369
+ status: number;
2370
+ body: unknown;
2371
+ }
2372
+
2373
+ export const routes: MockRoute[] = ${literal};
2374
+ `;
2375
+ }
2376
+
2377
+ export function _exportedPropsLiteral(p: PropsShape): string {
2378
+ return JSON.stringify(p);
2379
+ }