@lariocpt/flawless-react 0.0.0-stage → 0.3.0

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 +608 -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,3578 @@
1
+ import { existsSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { type AuthStub, authShimVitestBlock, collectAuthStubs } from "../analyse/authShims.ts";
4
+ import { hasSpec } from "../analyse/bootability.ts";
5
+ import {
6
+ libraryShimSource,
7
+ providerKey,
8
+ referencedLibraryShims,
9
+ } from "../analyse/providerWraps.ts";
10
+ import { relativeToEmitted } from "../emitPaths.ts";
11
+ import { appReactMajor } from "../packageJson.ts";
12
+ import { titleSafe } from "../scaffold/templates.ts";
13
+ import { VITEST_CONFIG } from "../suiteScripts.ts";
14
+ import type {
15
+ ClickHandler,
16
+ ComponentInfo,
17
+ ComponentTree,
18
+ FetchCallInfo,
19
+ FormInfo,
20
+ LabelSource,
21
+ NextjsApiRoute,
22
+ PredicateSiteInfo,
23
+ PropsShape,
24
+ ProviderRef,
25
+ StateInfo,
26
+ TodoCounter,
27
+ TodoRecorder,
28
+ } from "../types.ts";
29
+ import {
30
+ apiRouteFixture,
31
+ dottedMockConflicts,
32
+ emitModuleMocks,
33
+ inferredDepReturn,
34
+ mockSpecifier,
35
+ } from "./mocks.ts";
36
+ import { decidePostDispatch, describeNoClaim, type PostDispatchContext } from "./postDispatch.ts";
37
+ import {
38
+ allocateSpecNames,
39
+ compoundWrappable,
40
+ defaultsJson,
41
+ type FlatComponent,
42
+ flattenTrees,
43
+ hostEligibility,
44
+ synthesiseDefaults,
45
+ withRouteParams,
46
+ } from "./registry.ts";
47
+ import {
48
+ awaitSelector,
49
+ describeUnlocatable,
50
+ emitPlaywrightClickTarget,
51
+ emitPlaywrightDispatch,
52
+ emitPlaywrightFill,
53
+ emitPlaywrightSelector,
54
+ emitPlaywrightSubmit,
55
+ emitTriggerSelector,
56
+ emitVitestDispatch,
57
+ emitVitestFill,
58
+ emitVitestFillAssertion,
59
+ emitVitestSelector,
60
+ emitVitestSubmit,
61
+ firstOf,
62
+ } from "./selectors.ts";
63
+ import { stamp } from "./stamp.ts";
64
+
65
+ export interface RenderedFile {
66
+ relPath: string;
67
+ content: string;
68
+ /** When set, write here instead of <outDir>/<relPath> (colocated specs). */
69
+ absPath?: string;
70
+ /** Never overwrite an existing file at the target (unless --force). */
71
+ skipIfExists?: boolean;
72
+ }
73
+
74
+ export interface AliasEntry {
75
+ /** Import prefix ("@/") or exact specifier ("react"). */
76
+ find: string;
77
+ /** Absolute replacement path (dir for prefixes, module path for exact). */
78
+ replacement: string;
79
+ prefix: boolean;
80
+ /**
81
+ * `find` is a RAW regex source (anchors included), emitted verbatim rather
82
+ * than escaped-and-wrapped. Used for the pnpm-realpath pins: vite resolves
83
+ * an internalized package to its realpath under <nm>/.pnpm/<name>@<ver>/,
84
+ * and the package's own require("react") is a request-level rewrite no
85
+ * request alias can intercept — only a pattern against the resolved path.
86
+ */
87
+ raw?: boolean;
88
+ }
89
+
90
+ /**
91
+ * A tsconfig prefix (usually `@/`) claimed by TWO packages whose tsconfigs
92
+ * point it at different directories — a workspace with an app and a docs site.
93
+ * One global vite alias cannot honour both, so these resolve per importer
94
+ * through the emitted plugin instead of resolve.alias.
95
+ */
96
+ export interface MultiRootAlias {
97
+ /** Import prefix as tsconfig writes it, wildcard stripped ("@/"). */
98
+ prefix: string;
99
+ /** Candidate target directories in root-discovery order. */
100
+ dirs: string[];
101
+ }
102
+
103
+ /**
104
+ * Additive fill for React named exports the app imports that its installed
105
+ * react does not carry, but the copy Next bundles (next/dist/compiled/react,
106
+ * a canary) does — aurorascharff: ViewTransition, addTransitionType. The app
107
+ * runs on the bundled copy; tests run on the installed one, so the import
108
+ * yields undefined and every render touching it dies "Element type is
109
+ * invalid". Swapping the whole core for the bundle would split the graph —
110
+ * externalized deps resolve react from the app tree by plain node rules and
111
+ * the heal symlinks converge there — so instead the config aliases the bare
112
+ * "react" specifier onto an emitted shim: `export *` of the installed core,
113
+ * plus a render-transparent stand-in per missing name. One React, filled.
114
+ */
115
+ export interface ReactExportShim {
116
+ /** Absolute path to the installed react's entry — the single shared core. */
117
+ coreIndex: string;
118
+ coreVersion: string;
119
+ /** Version of the Next bundle the missing names are proven against. */
120
+ canaryVersion: string;
121
+ /** Missing names used as JSX (PascalCase): pass children straight through. */
122
+ components: string[];
123
+ /** Missing names called as functions (addTransitionType): no-op. */
124
+ functions: string[];
125
+ }
126
+
127
+ export interface UnitBrowserRenderOptions {
128
+ trees: ComponentTree[];
129
+ outDir: string;
130
+ variants: number;
131
+ mswSetup?: boolean;
132
+ /** Absolute path of a .tsx default-exporting a provider wrapper. */
133
+ provider?: string;
134
+ /** vitest resolve.alias entries (tsconfig paths + react dedupe). */
135
+ resolveAliases?: AliasEntry[];
136
+ /**
137
+ * Additive fill-in for React named exports the app imports but its
138
+ * installed react does not carry — the ones only the copy Next bundles
139
+ * has (canary ViewTransition). The config aliases the bare "react"
140
+ * specifier onto the emitted shim module, which re-exports the installed
141
+ * core untouched: one React, missing names supplied.
142
+ */
143
+ reactShim?: ReactExportShim;
144
+ /** tsconfig prefixes claimed by several roots with different targets: the
145
+ * emitted config resolves those per importer instead of aliasing them. */
146
+ multiRootAliases?: MultiRootAlias[];
147
+ /**
148
+ * The node_modules holding the ONE react/react-dom pair the emitted config
149
+ * aliases point at (computeResolveAliases' anchor). setup.ts redirects every
150
+ * NATIVE load of the react family here: vite aliases only reach code vite
151
+ * loads, and an externalised @testing-library/react otherwise requires the
152
+ * suite's own peer-linked copy — physically a second React under pnpm, and
153
+ * every mount dies on a null dispatcher.
154
+ */
155
+ reactPinDir?: string;
156
+ /** Emit unit specs beside the analysed sources as <base>.flawless.test.tsx. */
157
+ colocate?: boolean;
158
+ /** API routes with response bodies, for fetch-spec fixtures. */
159
+ apiRoutes?: NextjsApiRoute[];
160
+ /**
161
+ * Emit the Playwright browser specs. They drive the perf-harness host
162
+ * (`host/server.ts`), so without that host in the same outDir they cannot
163
+ * run — the caller passes false when perf-harness isn't part of the run.
164
+ */
165
+ browserSpecs?: boolean;
166
+ /**
167
+ * Collector for the TODO(flawless) markers these templates plant, mutated as
168
+ * each marker is emitted (TodoCounter in types.ts says why never by grep).
169
+ * It rides the options object through scaffoldUnitBrowser unchanged, so the
170
+ * unit-browser task can hand the tallies to the run report.
171
+ */
172
+ todos?: TodoCounter;
173
+ /**
174
+ * Derived context providers to mount around each unit render, keyed by
175
+ * providerKey (analyse/providerWraps.ts). The same map the perf host
176
+ * consumes: a component that throws "must be used within X" on the host
177
+ * throws the same in its unit spec, and one derivation fixes both planes.
178
+ */
179
+ providerWraps?: Map<string, ProviderRef[]>;
180
+ /**
181
+ * The APP package's root — where its .env files live. Emitted into the
182
+ * vitest config as a config-load reader: modules under test execute their
183
+ * import-time side effects (a prisma client doing `new URL(process.env
184
+ * .DATABASE_URL)` at module scope crashes every importing spec at
185
+ * collection when the variable is unset, while the app itself reads that
186
+ * file from this very directory).
187
+ */
188
+ appEnvRoot?: string;
189
+ }
190
+
191
+ export function renderUnitBrowser(opts: UnitBrowserRenderOptions): RenderedFile[] {
192
+ const flat = flattenTrees(opts.trees);
193
+ const specNames = allocateSpecNames(flat);
194
+ const withBrowser = opts.browserSpecs !== false;
195
+ const authStubs = collectAuthStubs(
196
+ flat.map((c) => c.info),
197
+ // The provider wrapper renders around every spec but is not an analysed
198
+ // component: its auth imports must reach the stub table too.
199
+ opts.provider ? [opts.provider] : [],
200
+ );
201
+ const files: RenderedFile[] = [
202
+ {
203
+ relPath: "setup.ts",
204
+ content: setupTs(
205
+ Boolean(opts.mswSetup),
206
+ authStubs,
207
+ opts.reactPinDir ? { dir: opts.reactPinDir, outDir: path.resolve(opts.outDir) } : undefined,
208
+ ),
209
+ },
210
+ // The inert target of the /^server-only$/ alias in the vitest config: the
211
+ // marker package is missing or throw-on-import, and a transitive importer
212
+ // (MDX -> a server table -> "server-only") cannot be saved by a per-spec
213
+ // vi.mock. This file is the whole alias target.
214
+ {
215
+ relPath: "server-only-stub.ts",
216
+ content: "// Inert replacement for the server-component marker package.\nexport {};\n",
217
+ },
218
+ // The react named-export shim (see ReactExportShim): a tool-owned file,
219
+ // rewritten per run, and the alias target of the bare "react" specifier
220
+ // in the emitted config — same shape as the server-only stub above.
221
+ ...(opts.reactShim
222
+ ? [{ relPath: REACT_SHIM_REL, content: reactExportsShimSource(opts.reactShim, opts.outDir) }]
223
+ : []),
224
+ // Generated library-provider shims the wrap map mounts: the file lives at
225
+ // the IMPORTING PACKAGE's root, not the outDir, because that is the only
226
+ // place the bare library import inside it resolves (package hoisting is
227
+ // not a thing in bun workspaces). Tool-owned content — rewritten per run.
228
+ ...referencedLibraryShims(
229
+ opts.providerWraps,
230
+ flat.map((c) => c.info.filePath),
231
+ ).map((shim) => ({
232
+ relPath: path.relative(opts.outDir, shim.filePath),
233
+ absPath: shim.filePath,
234
+ content: libraryShimSource(shim.module, shim.componentName, shim.filePath),
235
+ })),
236
+ ];
237
+ if (withBrowser) {
238
+ files.push({ relPath: "browser/playwright.config.ts", content: browserPlaywrightConfig() });
239
+ }
240
+
241
+ // A component with no export cannot be imported by a spec, so there is no
242
+ // spec to write. These reach `flat` only as children of an exported parent,
243
+ // and emitting `import { X }` for them yields undefined -> "Element type is
244
+ // invalid" at render time. Their behaviour is covered through that parent.
245
+ // hasSpec is the SAME predicate findUnresolvableImports reads: the collect
246
+ // warning is a promise about these files, so the two may not disagree.
247
+ const specTargets = flat.filter((c) => hasSpec(c.info));
248
+
249
+ const perFile = new Map<string, number>();
250
+ for (const c of specTargets)
251
+ perFile.set(c.info.filePath, (perFile.get(c.info.filePath) ?? 0) + 1);
252
+
253
+ // The emitted vitest config roots at the outDir's parent; a component
254
+ // whose source lives OUTSIDE that root (a workspace-resolved child in a
255
+ // sibling package) cannot colocate — the config could never run the spec,
256
+ // while the sibling's own test glob WOULD, under the wrong setup. Those
257
+ // fall back to in-outDir placement.
258
+ const configRoot = path.dirname(path.resolve(opts.outDir));
259
+
260
+ for (const c of specTargets) {
261
+ const specName = specNames.get(c) as string;
262
+ const colocatable =
263
+ opts.colocate && !path.relative(configRoot, path.resolve(c.info.filePath)).startsWith("..");
264
+ if (colocatable) {
265
+ const srcDir = path.dirname(c.info.filePath);
266
+ const base = path.basename(c.info.filePath).replace(/\.(tsx|jsx|ts|js)$/, "");
267
+ // Multi-component files get one spec per component (card.CardHeader....)
268
+ // so they don't overwrite each other.
269
+ const stem =
270
+ (perFile.get(c.info.filePath) ?? 1) > 1 ? `${base}.${c.info.componentName}` : base;
271
+ const absPath = path.join(srcDir, `${stem}.flawless.test.tsx`);
272
+ files.push({
273
+ relPath: `unit/${specName}.test.tsx`,
274
+ absPath,
275
+ skipIfExists: true,
276
+ content: renderUnitFile(c, opts, srcDir, `./${base}`, opts.todos?.for(absPath)),
277
+ });
278
+ } else {
279
+ const fromDir = path.resolve(opts.outDir, "unit");
280
+ // The recorder is bound to the path the writer will use, so a tally's
281
+ // exemplar names the spec that actually carries the marker.
282
+ const target = path.join(fromDir, `${specName}.test.tsx`);
283
+ files.push({
284
+ relPath: `unit/${specName}.test.tsx`,
285
+ content: renderUnitFile(
286
+ c,
287
+ opts,
288
+ fromDir,
289
+ toRel(fromDir, c.info.filePath),
290
+ opts.todos?.for(target),
291
+ ),
292
+ });
293
+ }
294
+ }
295
+
296
+ if (withBrowser) {
297
+ for (const c of specTargets) {
298
+ // The browser spec mounts through the perf host's REGISTRY by name, so
299
+ // the emit predicate must be the host's OWN — hostEligibility — not a
300
+ // re-derivation of it. This loop used to check `renderMode === "client"`,
301
+ // which is broader than the host: a client component whose render()
302
+ // branches on an undecidable condition is refused by the host ("no
303
+ // render to profile") yet still drew a browser spec that threw
304
+ // "unknown component" on every run (FR-061). And it is narrower: a
305
+ // wrappable compound part the host DOES carry drew no browser spec at
306
+ // all. One predicate, four call sites — the unit spec emitter, the host
307
+ // registry, the report, and here.
308
+ if (!hostEligibility(c.info).eligible) continue;
309
+ const specName = specNames.get(c) as string;
310
+ const target = path.join(path.resolve(opts.outDir), "browser", `${specName}.spec.ts`);
311
+ files.push({
312
+ relPath: `browser/${specName}.spec.ts`,
313
+ // Stamped like the colocated specs, for the same reason: this file is
314
+ // only rewritten while the component STAYS mountable. Once it leaves
315
+ // the host the file is simply no longer rendered, and the stamp is
316
+ // what lets the writer tell "our own stale output" (safe to remove)
317
+ // from "a human worked in here" (never touch).
318
+ content: stamp(browserSpec(c, specName, opts.todos?.for(target))),
319
+ });
320
+ }
321
+ }
322
+ // The config's include has to cover wherever the colocated specs actually
323
+ // landed — derived from the emitted files, so filtering rules stay in one
324
+ // place. Rendered last for that reason, prepended so it is written first.
325
+ files.unshift({ relPath: VITEST_CONFIG, content: vitestConfig(opts) });
326
+ return files;
327
+ }
328
+
329
+ function renderUnitFile(
330
+ c: FlatComponent,
331
+ opts: UnitBrowserRenderOptions,
332
+ specDir: string,
333
+ importPath: string,
334
+ todo?: TodoRecorder,
335
+ ): string {
336
+ const providerImport = opts.provider ? toImportPath(specDir, opts.provider) : undefined;
337
+ const mocksImport = opts.mswSetup
338
+ ? toImportPath(specDir, path.join(path.resolve(opts.outDir), "mocks", "node.ts"))
339
+ : undefined;
340
+ const mode = c.info.renderMode ?? (c.info.isAsync ? "rsc" : "client");
341
+ // next/og route handlers (app/icon.tsx, opengraph-image.tsx) RETURN an
342
+ // ImageResponse — a Response built by satori/resvg at the edge, not a React
343
+ // element. Any spec that mounts one gets "Objects are not valid as a React
344
+ // child (found: [object Response])" and can never pass; the browser host
345
+ // already excludes them (hostEligible). The unit plane gets the same verdict
346
+ // as an export-exists smoke spec.
347
+ if (
348
+ (c.info.moduleImports ?? []).some(
349
+ (mi) => mi.specifier === "next/og" || mi.specifier.startsWith("next/og/"),
350
+ )
351
+ ) {
352
+ const name = c.info.componentName;
353
+ const mocks = emitModuleMocks(c.info, "rsc", specDir, todo, opts.apiRoutes).statements;
354
+ const mockBlock = mocks.length > 0 ? `${mocks.join("\n")}\n` : "";
355
+ return `import { test, expect${mocks.length > 0 ? ", vi" : ""} } from "vitest";
356
+ ${mockBlock}${emitComponentImport(c.info, importPath)}
357
+
358
+ // ${name} renders through next/og: it returns an ImageResponse (satori +
359
+ // resvg, edge runtime), not a React element — there is no DOM to assert on
360
+ // under jsdom. The module contract is all a unit spec can honestly check.
361
+ test("${name} is a next/og route handler", () => {
362
+ expect(typeof ${name}).toBe("function");
363
+ });
364
+ `;
365
+ }
366
+ // Derived wraps ride along only where the spec actually mounts the
367
+ // component. constrainedSpec voids providerImport for the same reason:
368
+ // these specs never render interactively, so nothing to wrap.
369
+ const autoProv = autoProviderBits(c.info, opts.providerWraps, specDir);
370
+ if (mode === "compound-part" || mode === "document-root") {
371
+ return constrainedSpec(
372
+ c,
373
+ importPath,
374
+ providerImport,
375
+ mode,
376
+ specDir,
377
+ todo,
378
+ opts.apiRoutes,
379
+ (appReactMajor(opts.outDir) ?? 18) <= 17,
380
+ );
381
+ }
382
+ return mode === "rsc"
383
+ ? rscSpec(c, importPath, providerImport, specDir, todo, opts.apiRoutes, autoProv)
384
+ : unitSpec(
385
+ c,
386
+ opts.variants,
387
+ importPath,
388
+ providerImport,
389
+ mocksImport,
390
+ opts.apiRoutes,
391
+ specDir,
392
+ todo,
393
+ autoProv,
394
+ );
395
+ }
396
+
397
+ function toRel(fromDir: string, absFile: string): string {
398
+ let rel = path.relative(fromDir, absFile);
399
+ if (!rel.startsWith(".")) rel = `./${rel}`;
400
+ return rel.split(path.sep).join("/");
401
+ }
402
+
403
+ /**
404
+ * Emitted import paths must not carry a .ts/.tsx extension: the target project
405
+ * type-checks the generated specs, and TS rejects those unless it opts into
406
+ * allowImportingTsExtensions. flawless's own source uses them (Bun), the code
407
+ * it writes into someone else's repo must not.
408
+ */
409
+ function toImportPath(fromDir: string, absFile: string): string {
410
+ return stripTsExt(toRel(fromDir, absFile));
411
+ }
412
+
413
+ function stripTsExt(spec: string): string {
414
+ return spec.replace(/\.(tsx|ts|jsx|js)$/, "");
415
+ }
416
+
417
+ /** Spec-side rendering of one component's derived provider wraps. */
418
+ interface AutoProvBits {
419
+ imports: string;
420
+ open: string;
421
+ close: string;
422
+ }
423
+
424
+ /**
425
+ * Turn the derived provider map into spec imports and a JSX nest. Bindings
426
+ * carry a `__Prov` prefix so they can never collide with anything the spec
427
+ * imports itself (the same convention the host registry uses).
428
+ */
429
+ function autoProviderBits(
430
+ info: ComponentInfo,
431
+ wraps: Map<string, ProviderRef[]> | undefined,
432
+ specDir: string,
433
+ ): AutoProvBits {
434
+ const refs = wraps?.get(providerKey(info)) ?? [];
435
+ if (refs.length === 0) return { imports: "", open: "", close: "" };
436
+ const imports: string[] = [];
437
+ let open = "";
438
+ let close = "";
439
+ for (const ref of refs) {
440
+ const binding = `__Prov${ref.componentName}`;
441
+ const rel = toImportPath(specDir, ref.filePath);
442
+ imports.push(
443
+ ref.exportKind === "default"
444
+ ? `import ${binding} from "${rel}";`
445
+ : `import { ${ref.exportedAs ?? ref.componentName} as ${binding} } from "${rel}";`,
446
+ );
447
+ open = `${open}<${binding}>`;
448
+ close = `</${binding}>${close}`;
449
+ }
450
+ return { imports: `${imports.join("\n")}\n`, open, close };
451
+ }
452
+
453
+ /** Named vs default import, from the analyser's exportKind. */
454
+ function emitComponentImport(info: ComponentInfo, importPath: string): string {
455
+ const stripped = stripTsExt(importPath);
456
+ if (info.exportKind === "default") {
457
+ return `import ${info.componentName} from "${stripped}";`;
458
+ }
459
+ // A re-export alias is the module's real binding; the internal name is not
460
+ // importable. Alias it back so every spec body can keep naming the component.
461
+ if (info.exportedAs && info.exportedAs !== info.componentName) {
462
+ return `import { ${info.exportedAs} as ${info.componentName} } from "${stripped}";`;
463
+ }
464
+ return `import { ${info.componentName} } from "${stripped}";`;
465
+ }
466
+
467
+ /** Where the react named-export shim lives inside the suite. See ReactExportShim. */
468
+ const REACT_SHIM_REL = "shims/react-exports.mjs";
469
+
470
+ function reactExportsShimSource(shim: ReactExportShim, outDir: string): string {
471
+ // The import is relative to the shim file itself, so the generated suite
472
+ // stays relocatable like the config is (see vitestConfig's portability note).
473
+ const rel = path
474
+ .relative(path.join(path.resolve(outDir), path.dirname(REACT_SHIM_REL)), shim.coreIndex)
475
+ .split(path.sep)
476
+ .join("/");
477
+ const target = JSON.stringify(rel.startsWith(".") ? rel : `./${rel}`);
478
+ const fills = [
479
+ ...shim.components.map((n) => `export const ${n} = __through;`),
480
+ ...shim.functions.map((n) => `export const ${n} = () => {};`),
481
+ ].join("\n");
482
+ return [
483
+ "// Generated by flawless-react — rewritten on every run.",
484
+ `// This app's code imports React names its installed react@${shim.coreVersion} does not`,
485
+ `// export; they come from the copy Next ${shim.canaryVersion} bundles and compiles the`,
486
+ '// app against. The vitest config aliases the bare "react" specifier to this file.',
487
+ "// Everything below is the installed core re-exported UNCHANGED — the same physical",
488
+ "// copy externalized deps node-resolve and the heal symlinks point at — plus a",
489
+ "// render-transparent stand-in for each missing name. The test graph runs on exactly",
490
+ "// one React; this only answers the imports the core cannot.",
491
+ `export * from ${target};`,
492
+ `import __core from ${target};`,
493
+ "export default __core;",
494
+ "const __through = (props) => (props && props.children !== undefined ? props.children : null);",
495
+ fills,
496
+ "",
497
+ ].join("\n");
498
+ }
499
+
500
+ function vitestConfig(opts: UnitBrowserRenderOptions): string {
501
+ // Every replacement is resolved against the config's own directory rather
502
+ // than baked in absolute. This file is committed, and the react/react-dom
503
+ // pins in particular pointed into a node_modules path that exists only on the
504
+ // machine that generated them — on a checkout rooted elsewhere the suite
505
+ // could not resolve React at all.
506
+ // The shim outranks the plain react pin: with one active, the bare
507
+ // specifier must land on the shim (which re-exports that same core), and
508
+ // only the pnpm-realpath regex pins keep pointing at the core directly.
509
+ const aliasLines = (opts.resolveAliases ?? [])
510
+ .filter((a) => !opts.reactShim || a.find !== "react")
511
+ .map((a) => {
512
+ const rel = JSON.stringify(relativeToEmitted(opts.outDir, a.replacement));
513
+ const target = a.prefix
514
+ ? `path.resolve(configDir, ${rel}) + "/"`
515
+ : `path.resolve(configDir, ${rel})`;
516
+ // Raw patterns are emitted as regex LITERALS, so every "/" in the
517
+ // source must carry a backslash — an unescaped one would terminate the
518
+ // literal mid-pattern and leave the config unparseable.
519
+ const find = a.raw
520
+ ? `/${a.find.replace(/\//g, "\\/")}/`
521
+ : a.prefix
522
+ ? `/^${escapeRegex(a.find)}/`
523
+ : `/^${escapeRegex(a.find)}$/`;
524
+ return ` { find: ${find}, replacement: ${target} },`;
525
+ })
526
+ .concat(
527
+ // The react named-export shim, when the app's imports outran its
528
+ // installed react. First exact match wins in vite, and the plain
529
+ // react pin was filtered out above, so this is the only rule that
530
+ // answers the bare specifier; the pnpm-realpath regexes beside it
531
+ // match resolved paths, not "react", and keep pointing at the core.
532
+ opts.reactShim
533
+ ? [
534
+ (() => {
535
+ const rel = JSON.stringify(
536
+ relativeToEmitted(opts.outDir, path.resolve(opts.outDir, REACT_SHIM_REL)),
537
+ );
538
+ return ` { find: /^react$/, replacement: path.resolve(configDir, ${rel}) },`;
539
+ })(),
540
+ ]
541
+ : [],
542
+ )
543
+ .concat(
544
+ // zod v4's index.js re-exports a NAMESPACE binding (import * as z,
545
+ // then export { z }) — vite's module pipeline drops the rebinding:
546
+ // every export survives except z, which arrives undefined, and every
547
+ // schema module dies at collection on z.object of undefined. The
548
+ // CJS entry assigns exports.z = z outright, so interop hands z over
549
+ // intact. Only then does the specifier exist with that file (zod v3
550
+ // ships no .cjs, and its z is a plain local export vite transforms
551
+ // fine).
552
+ ...(existsSync(path.resolve(opts.outDir, "../node_modules/zod/index.cjs"))
553
+ ? [
554
+ (() => {
555
+ const rel = JSON.stringify("../node_modules/zod/index.cjs");
556
+ return ` { find: /^zod$/, replacement: path.resolve(configDir, ${rel}) },`;
557
+ })(),
558
+ ]
559
+ : []),
560
+ )
561
+ .concat(
562
+ // The server-component MARKER package: not installed in many repos (vite
563
+ // fails to resolve the import outright — chanhdai's MDX crashed at
564
+ // collection on a transitive one), and where installed its module THROWS
565
+ // on import. Both lands want the same thing: resolve the specifier to
566
+ // nothing so the surrounding module loads and jsdom mounts what is
567
+ // really ordinary code. Per-spec vi.mock cannot reach transitive
568
+ // importers, the alias can.
569
+ [
570
+ (() => {
571
+ const rel = JSON.stringify(
572
+ relativeToEmitted(opts.outDir, path.resolve(opts.outDir, "server-only-stub.ts")),
573
+ );
574
+ return ` { find: /^server-only$/, replacement: path.resolve(configDir, ${rel}) },`;
575
+ })(),
576
+ ],
577
+ )
578
+ .join("\n");
579
+ // dedupe is not optional in a workspace. When react resolves to the repo root
580
+ // and react-dom to the package, react-dom-server sets its hook dispatcher on a
581
+ // different React instance than the component reads from, and every hook call
582
+ // fails with "Cannot read properties of null (reading 'useEffect')" — a
583
+ // confusing error that looks like a generator bug and is not one.
584
+ const dedupeLine = ` dedupe: ["react", "react-dom"],\n`;
585
+ const aliasBlock = aliasLines.length > 0 ? ` alias: [\n${aliasLines}\n ],\n` : "";
586
+ const resolveBlock = ` resolve: {\n${dedupeLine}${aliasBlock} },\n`;
587
+ // Several roots claiming one tsconfig prefix. The alias map is a pure string
588
+ // rewrite, so with two packages both writing `@/` to different targets half
589
+ // the repo's imports land in the wrong tree (nakama: docs/website's
590
+ // `@/lib/source` resolved into apps/web). Resolve per importer instead:
591
+ // prefer the candidate the importer itself lives under, then whichever
592
+ // candidate actually has the file. Dirs are emitted configDir-relative so
593
+ // the config survives a moved checkout.
594
+ const multi = opts.multiRootAliases ?? [];
595
+ const multiBlock =
596
+ multi.length === 0
597
+ ? ""
598
+ : `const multiRootPaths = [
599
+ ${multi
600
+ .map(
601
+ (m) =>
602
+ ` { prefix: ${JSON.stringify(m.prefix)}, dirs: [${m.dirs
603
+ .map((d) => `path.resolve(configDir, ${JSON.stringify(relativeToEmitted(opts.outDir, d))})`)
604
+ .join(", ")}] },`,
605
+ )
606
+ .join("\n")}
607
+ ];
608
+ const probeFile = (base: string): string | null => {
609
+ for (const cand of [
610
+ ...[".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json"].map((e) => base + e),
611
+ ...[".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json"].map((e) =>
612
+ path.join(base, "index" + e),
613
+ ),
614
+ ]) {
615
+ if (existsSync(cand) && statSync(cand).isFile()) return cand;
616
+ }
617
+ return null;
618
+ };
619
+ const multiRootPlugin = {
620
+ name: "flawless-multiroot-tsconfig-paths",
621
+ enforce: "pre",
622
+ resolveId(spec: string, importer?: string) {
623
+ // A tsconfig \`paths\` key never matches these, and a catch-all \`"*"\` entry
624
+ // must not either: rewriting vite's own \`/@vite/env\` into \`<src>/@vite/env\`
625
+ // is how a run dies before a single spec loads.
626
+ if (
627
+ spec.startsWith(".") ||
628
+ spec.startsWith("/") ||
629
+ spec.charCodeAt(0) === 0 ||
630
+ /^[a-zA-Z0-9+.-]+:/.test(spec)
631
+ )
632
+ return null;
633
+ for (const m of multiRootPaths) {
634
+ if (!spec.startsWith(m.prefix)) continue;
635
+ const rest = spec.slice(m.prefix.length);
636
+ const dirs = [...m.dirs].sort(
637
+ (a, b) => (importer?.startsWith(b) ? 1 : 0) - (importer?.startsWith(a) ? 1 : 0),
638
+ );
639
+ for (const dir of dirs) {
640
+ const hit = probeFile(path.join(dir, rest));
641
+ if (hit) return hit;
642
+ }
643
+ }
644
+ return null;
645
+ },
646
+ };
647
+ `;
648
+ const pluginsBlock = multi.length === 0 ? "" : ` plugins: [multiRootPlugin],\n`;
649
+ // The app's .env files, read at config load — see UnitBrowserRenderOptions
650
+ // .appEnvRoot for why the suite must see them. Nothing is copied into this
651
+ // file, and keys already in the environment win: exporting an override
652
+ // still redirects the suite. .env.example rides last: template repos ship
653
+ // only the example, and its placeholders exist precisely to satisfy
654
+ // module-scope construction (a provider that reads NEXT_PUBLIC_*_URL at
655
+ // import throws for every spec at collection without some value).
656
+ const envBlock = opts.appEnvRoot
657
+ ? `{
658
+ const appRoot = path.resolve(
659
+ configDir,
660
+ ${JSON.stringify(relativeToEmitted(opts.outDir, opts.appEnvRoot))},
661
+ );
662
+ for (const file of [".env.test.local", ".env.test", ".env.local", ".env", ".env.example"]) {
663
+ try {
664
+ const text = readFileSync(path.join(appRoot, file), "utf8");
665
+ for (const line of text.split("\\n")) {
666
+ const kv = line.match(/^(?:export )?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
667
+ if (kv && !(kv[1] in process.env))
668
+ process.env[kv[1]] = kv[2].trim().replace(/^["']|["']$/g, "");
669
+ }
670
+ } catch {
671
+ /* that file does not exist */
672
+ }
673
+ }
674
+ }
675
+
676
+ `
677
+ : "";
678
+ const fsNames = [
679
+ ...(multi.length > 0 ? ["existsSync", "statSync"] : []),
680
+ ...(envBlock ? ["readFileSync"] : []),
681
+ ];
682
+ const fsImport = fsNames.length === 0 ? "" : `import { ${fsNames.join(", ")} } from "node:fs";\n`;
683
+ if (opts.colocate) {
684
+ // Colocate mode runs as `vitest run --config <out>/vitest.config.mts`.
685
+ // Vite roots itself at the CONFIG file's directory, so without an explicit
686
+ // root the include globs resolve under <out>/ and match nothing.
687
+ //
688
+ // The out dir's own name, not the literal "flawless": setupFiles and the
689
+ // unit include were hardcoded to it, so --colocate with the standalone
690
+ // default (.flawless) or any --out pointed vitest at a setup file that does
691
+ // not exist, and it died at startup.
692
+ const outName = path.basename(opts.outDir);
693
+ // Root-wide, NOT derived from this run's emitted files: the config is
694
+ // always rewritten, so a per-run derivation (run 1 over src/**, run 2 over
695
+ // app/**) left only the latest run's dirs and silently deselected every
696
+ // previously colocated spec. The same two globs, whatever this run
697
+ // touched — run-order independent and byte-identical across runs by
698
+ // construction. Known limit: with --out OUTSIDE the project tree the
699
+ // config root is the out dir's parent and cannot reach colocated specs in
700
+ // the source tree — same as before this change, and out of scope here.
701
+ const include = [`**/*.flawless.test.{ts,tsx}`, `${outName}/unit/**/*.test.{ts,tsx}`];
702
+ return `${fsImport}import path from "node:path";
703
+ import { fileURLToPath } from "node:url";
704
+ import { defineConfig } from "vitest/config";
705
+
706
+ const configDir = path.dirname(fileURLToPath(import.meta.url));
707
+ const projectRoot = path.resolve(configDir, "..");
708
+ ${multiBlock}${envBlock}export default defineConfig({
709
+ root: projectRoot,
710
+ ${pluginsBlock} // The automatic JSX runtime, pinned HERE: a Next.js host's tsconfig says
711
+ // jsx "preserve", which gives vitest's esbuild nothing usable, and every
712
+ // spec died at render with "React is not defined". The suite pins react
713
+ // >=18, where the automatic runtime always exists (FR-018 — found the
714
+ // first time an emitted spec was actually executed in this repo).
715
+ esbuild: { jsx: "automatic" },
716
+ // Kill the PostCSS config FILE search: vite walks up from the suite and
717
+ // picks up the repo's own postcss config (a tailwind v4 plugin vite cannot
718
+ // even load here — "Invalid PostCSS Plugin found at: plugins[0]"). Specs
719
+ // never need the app's styles (test.css is false below).
720
+ css: { postcss: { plugins: [] } },
721
+ ${resolveBlock} test: {
722
+ environment: "jsdom",
723
+ setupFiles: [path.join(configDir, "setup.ts")],
724
+ include: [${include.map((g) => JSON.stringify(g)).join(", ")}],
725
+ // Setting exclude REPLACES vitest's defaults, so the standard set rides
726
+ // along explicitly: without dist/ here, a build step that copies raw
727
+ // .flawless.test sources into dist/ would run every spec twice. The
728
+ // root-wide include reaches all of them; this committed config must be
729
+ // safe on its own terms.
730
+ exclude: ["**/node_modules/**", "**/dist/**", "**/.{idea,git,cache,output,temp}/**"],
731
+ globals: true,
732
+ css: false,
733
+ // Best-effort: run the harness's own closure (testing-library, react,
734
+ // react-dom) through vite so the resolve ALIASES above apply to it.
735
+ // Vitest does not honour these patterns uniformly (an externalised
736
+ // @testing-library/react still requires react-dom by plain node rules,
737
+ // landing on a second physical React), so the guarantee lives in the
738
+ // native resolve hook in setup.ts — this only helps where it works.
739
+ server: {
740
+ deps: {
741
+ inline: [/@testing-library/, /^react$/, /^react\\//, /^react-dom$/, /^react-dom\\//],
742
+ },
743
+ },
744
+ },
745
+ });
746
+ `;
747
+ }
748
+ const anchor =
749
+ aliasLines.length > 0 || multi.length > 0 || envBlock !== ""
750
+ ? `${fsImport}import path from "node:path";
751
+ import { fileURLToPath } from "node:url";
752
+ import { defineConfig } from "vitest/config";
753
+
754
+ const configDir = path.dirname(fileURLToPath(import.meta.url));
755
+ ${multiBlock}${envBlock}`
756
+ : `import { defineConfig } from "vitest/config";
757
+
758
+ `;
759
+ return `${anchor}export default defineConfig({
760
+ ${pluginsBlock} // See the colocate branch: a host tsconfig with jsx "preserve" (the
761
+ // Next.js default) left esbuild on the classic runtime and every render
762
+ // threw "React is not defined" (FR-018).
763
+ esbuild: { jsx: "automatic" },
764
+ // Kill the PostCSS config FILE search: vite walks up from the suite and
765
+ // picks up the repo's own postcss config (a tailwind v4 plugin vite cannot
766
+ // even load here — "Invalid PostCSS Plugin found at: plugins[0]"). Specs
767
+ // never need the app's styles (test.css is false below).
768
+ css: { postcss: { plugins: [] } },
769
+ ${resolveBlock} test: {
770
+ environment: "jsdom",
771
+ setupFiles: ["./setup.ts"],
772
+ include: ["unit/**/*.test.{ts,tsx}"],
773
+ globals: true,
774
+ css: false,
775
+ // Best-effort: run the harness's own closure (testing-library, react,
776
+ // react-dom) through vite so the resolve ALIASES above apply to it.
777
+ // Vitest does not honour these patterns uniformly (an externalised
778
+ // @testing-library/react still requires react-dom by plain node rules,
779
+ // landing on a second physical React), so the guarantee lives in the
780
+ // native resolve hook in setup.ts — this only helps where it works.
781
+ server: {
782
+ deps: {
783
+ inline: [/@testing-library/, /^react$/, /^react\\//, /^react-dom$/, /^react-dom\\//],
784
+ },
785
+ },
786
+ },
787
+ });
788
+ `;
789
+ }
790
+
791
+ function escapeRegex(s: string): string {
792
+ return s.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
793
+ }
794
+
795
+ function setupTs(
796
+ mswSetup: boolean,
797
+ authStubs = new Map<string, AuthStub>(),
798
+ reactPin?: { dir: string; outDir: string },
799
+ ): string {
800
+ // Global signed-out stubs for provider-bound auth libs (analyse/authShims.ts
801
+ // says why): a bare unit mount has no SessionProvider/ClerkProvider, and the
802
+ // real modules throw before the component under test renders.
803
+ const authMocks = [...authStubs.entries()]
804
+ .map(([spec, stub]) => authShimVitestBlock(spec, stub))
805
+ .join("");
806
+ // The single-React pin (UnitBrowserRenderOptions.reactPinDir says why):
807
+ // emitted only when the canonical pair the config aliases point at exists.
808
+ const pinRel =
809
+ reactPin &&
810
+ existsSync(path.join(reactPin.dir, "react")) &&
811
+ existsSync(path.join(reactPin.dir, "react-dom"))
812
+ ? {
813
+ react: JSON.stringify(
814
+ relativeToEmitted(reactPin.outDir, path.join(reactPin.dir, "react")),
815
+ ),
816
+ reactDom: JSON.stringify(
817
+ relativeToEmitted(reactPin.outDir, path.join(reactPin.dir, "react-dom")),
818
+ ),
819
+ }
820
+ : undefined;
821
+ const pinImports = pinRel
822
+ ? `import * as __flFs from "node:fs";
823
+ import * as __flPath from "node:path";
824
+ import * as __flUrl from "node:url";
825
+ `
826
+ : "";
827
+ // String.raw so the redirect's regexes keep their backslashes without
828
+ // quadruple escaping through this template. Emitted inside the registerHooks
829
+ // block's braces; the ${} holes are the only interpolation.
830
+ const pinSource = pinRel
831
+ ? String.raw`
832
+ // Single React pin: vite's aliases reach only the code VITE loads. pnpm
833
+ // stores react (and the peer-linked react-dom) PHYSICALLY under every
834
+ // node_modules root, so an externalised @testing-library/react requires
835
+ // the SUITE's pair through plain node resolution while transformed
836
+ // components read hooks from the aliased app copy — two dispatchers, and
837
+ // every mount dies on "Cannot read properties of null (reading 'useRef')".
838
+ // This hook owns every native resolution in the worker thread, so the
839
+ // whole react family — bare specifiers and the absolute/file: paths
840
+ // vite-node hands externalised modules — is redirected to the one pair the
841
+ // config's aliases point at (resolved against the suite directory: the
842
+ // committed suite stays relocatable, like the config itself).
843
+ // NOT the two-argument new URL(spec, import.meta.url) form: vite rewrites
844
+ // that exact pattern as an asset import and the expression throws at
845
+ // runtime — fileURLToPath takes the url directly (a string under
846
+ // vite-node, a URL object in plain ESM; the query a runner may append
847
+ // sits after the filename, so dirname is unaffected).
848
+ const SUITE_DIR = (() => {
849
+ try {
850
+ return __flPath.dirname(__flUrl.fileURLToPath(import.meta.url));
851
+ } catch {
852
+ return undefined;
853
+ }
854
+ })();
855
+ const REACT_DIRS: Record<string, string> = {
856
+ react: SUITE_DIR ? __flPath.resolve(SUITE_DIR, ${pinRel.react}) : "",
857
+ "react-dom": SUITE_DIR ? __flPath.resolve(SUITE_DIR, ${pinRel.reactDom}) : "",
858
+ };
859
+ const flPinFile = (name: string, rest: string): string | undefined => {
860
+ const base = REACT_DIRS[name];
861
+ if (!base) return undefined;
862
+ const p = rest ? base + rest.replace(/^[/\\]+/, "/") : base;
863
+ try {
864
+ return __flFs.statSync(p).isDirectory() ? p + "/index.js" : p;
865
+ } catch {
866
+ return __flFs.existsSync(p + ".js") ? p + ".js" : undefined;
867
+ }
868
+ };
869
+ const flPinResolve = (specifier: string): string | undefined => {
870
+ const bare = /^(react|react-dom)(?:[/\\](.+))?$/.exec(specifier);
871
+ if (bare) return flPinFile(bare[1], bare[2] ?? "");
872
+ if (!specifier.startsWith("file:") && !__flPath.isAbsolute(specifier))
873
+ return undefined;
874
+ let p = specifier;
875
+ if (p.startsWith("file:")) {
876
+ try {
877
+ p = __flUrl.fileURLToPath(p);
878
+ } catch {
879
+ return undefined;
880
+ }
881
+ }
882
+ const copy =
883
+ /[\\/]node_modules[\\/]\.pnpm[\\/](?:react|react-dom)@[^\\/]+[\\/]node_modules[\\/](react|react-dom)([/\\].*)?$/.exec(p);
884
+ return copy ? flPinFile(copy[1], copy[2] ?? "") : undefined;
885
+ };
886
+ `
887
+ : "";
888
+ const pinRedirect = pinRel
889
+ ? ` {
890
+ const pinned = flPinResolve(specifier);
891
+ if (pinned)
892
+ return {
893
+ url: __flUrl.pathToFileURL(pinned).href,
894
+ format: "commonjs",
895
+ shortCircuit: true,
896
+ };
897
+ }
898
+ `
899
+ : "";
900
+ const polyfills = `
901
+ // Native-import interception: the runner loads node_modules packages through
902
+ // plain import(), where vi.mock cannot reach them. The resolve hook below
903
+ // answers three classes: font-loader wrappers executing at module scope
904
+ // (geist), router-wrapper libraries mounting the REAL next/navigation —
905
+ // "invariant expected app router to be mounted" — and (when configured) the
906
+ // react family, pinned to a single physical copy.
907
+ // registerHooks (synchronous, runs IN this thread) is preferred over
908
+ // register() (spawns a separate hooks thread Node deprecates); the stubs ride
909
+ // as data: URLs so no file-path juggling is needed — import.meta.url is an
910
+ // http: URL under vite-node, which the native loader rejects. The capability
911
+ // guard keeps older Node (no registerHooks) compiling and running unchanged.
912
+ ${pinImports}import * as __flNodeModule from "node:module";
913
+ {
914
+ const fontStub =
915
+ "const font={className:'',variable:'',style:{fontFamily:''}};" +
916
+ "export default function fontLoader(){return font}";
917
+ const navigationStub =
918
+ "const noop=()=>{};" +
919
+ "export function notFound(){throw new Error('notFound() called')}" +
920
+ "export function redirect(){throw new Error('NEXT_REDIRECT')}" +
921
+ "export function permanentRedirect(){throw new Error('NEXT_REDIRECT')}" +
922
+ "export function usePathname(){return '/'}" +
923
+ "export function useRouter(){return{push:noop,replace:noop,refresh:noop,back:noop,forward:noop,prefetch:noop}}" +
924
+ "export function useSearchParams(){return new URLSearchParams()}" +
925
+ "export function useParams(){return {}}" +
926
+ "export function useRoute(){return '/'}";${pinSource}
927
+ const registerHooks = (__flNodeModule as unknown as {
928
+ registerHooks?: (hooks: {
929
+ resolve?: (
930
+ specifier: string,
931
+ context: unknown,
932
+ nextResolve: (specifier: string) => { url: string; format: string; shortCircuit: boolean },
933
+ ) => { url: string; format: string; shortCircuit: boolean };
934
+ }) => void;
935
+ }).registerHooks;
936
+ if (registerHooks) {
937
+ registerHooks({
938
+ resolve: (specifier, _context, nextResolve) => {
939
+ ${pinRedirect} // Bundled library output imports "next/navigation.js" with the
940
+ // extension, so the bare name and the .js spelling both match.
941
+ if (/^next\\/font(\\/.*)?$/.test(specifier)) {
942
+ return {
943
+ url: \`data:text/javascript,\${encodeURIComponent(fontStub)}\`,
944
+ format: "module",
945
+ shortCircuit: true,
946
+ };
947
+ }
948
+ if (/^next\\/navigation(\\.js)?$/.test(specifier)) {
949
+ return {
950
+ url: \`data:text/javascript,\${encodeURIComponent(navigationStub)}\`,
951
+ format: "module",
952
+ shortCircuit: true,
953
+ };
954
+ }
955
+ return nextResolve(specifier);
956
+ },
957
+ });
958
+ }
959
+ }
960
+
961
+ // jsdom gaps that real-world component stacks hit constantly (next-themes →
962
+ // matchMedia, virtualisers → ResizeObserver, lazy mounts → IntersectionObserver).
963
+ if (typeof window !== "undefined") {
964
+ window.matchMedia ??= ((query: string) => ({
965
+ matches: false,
966
+ media: query,
967
+ onchange: null,
968
+ addListener: () => {},
969
+ removeListener: () => {},
970
+ addEventListener: () => {},
971
+ removeEventListener: () => {},
972
+ dispatchEvent: () => false,
973
+ })) as typeof window.matchMedia;
974
+ window.ResizeObserver ??= class {
975
+ observe(): void {}
976
+ unobserve(): void {}
977
+ disconnect(): void {}
978
+ } as unknown as typeof ResizeObserver;
979
+ window.IntersectionObserver ??= class {
980
+ observe(): void {}
981
+ unobserve(): void {}
982
+ disconnect(): void {}
983
+ takeRecords(): IntersectionObserverEntry[] { return []; }
984
+ root = null;
985
+ rootMargin = "";
986
+ thresholds = [];
987
+ } as unknown as typeof IntersectionObserver;
988
+ Element.prototype.scrollIntoView ??= () => {};
989
+ Element.prototype.scrollTo ??= (() => {}) as typeof Element.prototype.scrollTo;
990
+ Element.prototype.getAnimations ??= (() => [] as Animation[]) as typeof Element.prototype.getAnimations;
991
+
992
+ // jsdom ships no Web Audio: the "play a sound on click" hook crashes at
993
+ // mount on \`new AudioContext()\` — an unhandled rejection that exits the
994
+ // run rc=1 even when every test passed. The graph below is inert but
995
+ // complete: every node answers, params chain, and \`decodeAudioData\`
996
+ // resolves so awaiting loaders complete instead of rejecting.
997
+ class FlawlessAudioParam {
998
+ value = 0;
999
+ defaultValue = 0;
1000
+ minValue = -3.4028234663852886e38;
1001
+ maxValue = 3.4028234663852886e38;
1002
+ setValueAtTime(): FlawlessAudioParam { return this; }
1003
+ linearRampToValueAtTime(): FlawlessAudioParam { return this; }
1004
+ exponentialRampToValueAtTime(): FlawlessAudioParam { return this; }
1005
+ setTargetAtTime(): FlawlessAudioParam { return this; }
1006
+ setValueCurveAtTime(): FlawlessAudioParam { return this; }
1007
+ cancelScheduledValues(): FlawlessAudioParam { return this; }
1008
+ cancelAndHoldAtTime(): FlawlessAudioParam { return this; }
1009
+ }
1010
+ class FlawlessAudioNode {
1011
+ channelCount = 2;
1012
+ numberOfInputs = 1;
1013
+ numberOfOutputs = 1;
1014
+ connect<T>(node: T): T { return node; }
1015
+ disconnect(): void {}
1016
+ addEventListener(): void {}
1017
+ removeEventListener(): void {}
1018
+ dispatchEvent(): boolean { return true; }
1019
+ }
1020
+ class FlawlessAudioBuffer {
1021
+ numberOfChannels: number;
1022
+ length: number;
1023
+ sampleRate: number;
1024
+ constructor(channels: number, length: number, sampleRate: number) {
1025
+ this.numberOfChannels = Math.max(1, channels | 0);
1026
+ this.length = Math.max(1, length | 0);
1027
+ this.sampleRate = sampleRate > 0 ? sampleRate : 44100;
1028
+ }
1029
+ get duration(): number { return this.length / this.sampleRate; }
1030
+ getChannelData(): Float32Array { return new Float32Array(this.length); }
1031
+ copyToChannel(): void {}
1032
+ copyFromChannel(): void {}
1033
+ }
1034
+ class FlawlessSourceNode extends FlawlessAudioNode {
1035
+ buffer: unknown = null;
1036
+ loop = false;
1037
+ loopStart = 0;
1038
+ loopEnd = 0;
1039
+ playbackRate = new FlawlessAudioParam();
1040
+ detune = new FlawlessAudioParam();
1041
+ onended: ((event?: unknown) => void) | null = null;
1042
+ start(): void {}
1043
+ stop(): void {}
1044
+ }
1045
+ class FlawlessAudioContext extends FlawlessAudioNode {
1046
+ readonly sampleRate = 44100;
1047
+ readonly state = "suspended";
1048
+ readonly baseLatency = 0;
1049
+ readonly destination = new FlawlessAudioNode();
1050
+ readonly listener: Record<string, unknown> = {};
1051
+ readonly audioWorklet = { addModule: async () => {} };
1052
+ onstatechange: ((event?: unknown) => void) | null = null;
1053
+ get currentTime(): number { return 0; }
1054
+ createBuffer(channels: number, length: number, sampleRate: number) {
1055
+ return new FlawlessAudioBuffer(channels, length, sampleRate);
1056
+ }
1057
+ createBufferSource() { return new FlawlessSourceNode(); }
1058
+ createGain() {
1059
+ const n = new FlawlessAudioNode();
1060
+ return Object.assign(n, { gain: new FlawlessAudioParam() });
1061
+ }
1062
+ createBiquadFilter() {
1063
+ const n = new FlawlessAudioNode();
1064
+ return Object.assign(n, {
1065
+ frequency: new FlawlessAudioParam(),
1066
+ Q: new FlawlessAudioParam(),
1067
+ gain: new FlawlessAudioParam(),
1068
+ detune: new FlawlessAudioParam(),
1069
+ getFrequencyResponse: () => {},
1070
+ });
1071
+ }
1072
+ createDynamicsCompressor() {
1073
+ const n = new FlawlessAudioNode();
1074
+ return Object.assign(n, {
1075
+ threshold: new FlawlessAudioParam(),
1076
+ knee: new FlawlessAudioParam(),
1077
+ ratio: new FlawlessAudioParam(),
1078
+ attack: new FlawlessAudioParam(),
1079
+ release: new FlawlessAudioParam(),
1080
+ reduction: 0,
1081
+ });
1082
+ }
1083
+ createStereoPanner() {
1084
+ const n = new FlawlessAudioNode();
1085
+ return Object.assign(n, { pan: new FlawlessAudioParam() });
1086
+ }
1087
+ createPanner() {
1088
+ return new FlawlessAudioNode();
1089
+ }
1090
+ createOscillator() {
1091
+ const n = new FlawlessAudioNode();
1092
+ return Object.assign(n, {
1093
+ frequency: new FlawlessAudioParam(),
1094
+ detune: new FlawlessAudioParam(),
1095
+ onended: null,
1096
+ start: () => {},
1097
+ stop: () => {},
1098
+ });
1099
+ }
1100
+ createDelay() {
1101
+ const n = new FlawlessAudioNode();
1102
+ return Object.assign(n, { delayTime: new FlawlessAudioParam() });
1103
+ }
1104
+ createWaveShaper() {
1105
+ const n = new FlawlessAudioNode();
1106
+ return Object.assign(n, { curve: null, oversample: "none" });
1107
+ }
1108
+ createConvolver() {
1109
+ const n = new FlawlessAudioNode();
1110
+ return Object.assign(n, { buffer: null, normalize: true });
1111
+ }
1112
+ createAnalyser() {
1113
+ const n = new FlawlessAudioNode();
1114
+ return Object.assign(n, {
1115
+ fftSize: 2048,
1116
+ frequencyBinCount: 1024,
1117
+ minDecibels: -100,
1118
+ maxDecibels: -30,
1119
+ smoothingTimeConstant: 0.8,
1120
+ getByteFrequencyData: () => {},
1121
+ getByteTimeDomainData: () => {},
1122
+ getFloatFrequencyData: () => {},
1123
+ getFloatTimeDomainData: () => {},
1124
+ });
1125
+ }
1126
+ createChannelMerger() { return new FlawlessAudioNode(); }
1127
+ createChannelSplitter() { return new FlawlessAudioNode(); }
1128
+ decodeAudioData(
1129
+ _data: ArrayBuffer,
1130
+ onSuccess?: (buffer: unknown) => void,
1131
+ onError?: (e: unknown) => void,
1132
+ ): Promise<unknown> {
1133
+ const buffer = this.createBuffer(2, 1, this.sampleRate);
1134
+ onSuccess?.(buffer);
1135
+ void onError;
1136
+ return Promise.resolve(buffer);
1137
+ }
1138
+ resume(): Promise<void> { return Promise.resolve(); }
1139
+ suspend(): Promise<void> { return Promise.resolve(); }
1140
+ close(): Promise<void> { return Promise.resolve(); }
1141
+ }
1142
+ const webAudio = FlawlessAudioContext as unknown as typeof AudioContext;
1143
+ window.AudioContext ??= webAudio;
1144
+ (window as unknown as Record<string, unknown>).webkitAudioContext ??= webAudio;
1145
+ (window as unknown as Record<string, unknown>).OfflineAudioContext ??= FlawlessAudioContext;
1146
+
1147
+ // Node >= 22 defines a \`localStorage\` global that is inert unless the process
1148
+ // was started with --localstorage-file. Under vitest's jsdom environment
1149
+ // \`window.localStorage\` can resolve to that global rather than to jsdom's own
1150
+ // implementation, so persistence middleware (zustand persist, redux-persist)
1151
+ // is handed undefined and throws on its first write.
1152
+ const memoryStorage = (): Storage => {
1153
+ const m = new Map<string, string>();
1154
+ return {
1155
+ get length() {
1156
+ return m.size;
1157
+ },
1158
+ clear: () => {
1159
+ m.clear();
1160
+ },
1161
+ getItem: (k: string) => m.get(k) ?? null,
1162
+ key: (i: number) => [...m.keys()][i] ?? null,
1163
+ removeItem: (k: string) => {
1164
+ m.delete(k);
1165
+ },
1166
+ setItem: (k: string, v: string) => {
1167
+ m.set(k, String(v));
1168
+ },
1169
+ } as Storage;
1170
+ };
1171
+ if (typeof window.localStorage?.setItem !== "function") {
1172
+ Object.defineProperty(window, "localStorage", {
1173
+ value: memoryStorage(),
1174
+ configurable: true,
1175
+ });
1176
+ }
1177
+ if (typeof window.sessionStorage?.setItem !== "function") {
1178
+ Object.defineProperty(window, "sessionStorage", {
1179
+ value: memoryStorage(),
1180
+ configurable: true,
1181
+ });
1182
+ }
1183
+
1184
+ // jsdom has never implemented the static \`AbortSignal.any\` — measured on
1185
+ // 25.0.1, the version this suite pins: \`abort\` and \`timeout\` are there,
1186
+ // \`any\` is not — while Node has had it since 20.3. So this plane is the only
1187
+ // place a library that composes abort signals dies for a reason that has
1188
+ // nothing to do with the component under test: saasmail's 19 unhandled
1189
+ // rejections reading \`AbortSignal.any is not a function\` were all
1190
+ // @mcp-b/webmcp-ts-sdk composing its registration signal with the caller's.
1191
+ // Composing signals fakes no backend and answers no request, so this is the
1192
+ // missing standard API rather than a stubbed dependency: the first abort wins
1193
+ // with its own reason, exactly as specified, built from what jsdom provides.
1194
+ // Node's own copy is left alone, and a future jsdom that ships this makes it a
1195
+ // no-op.
1196
+ const __abortSignal =
1197
+ typeof AbortSignal === "undefined" ? undefined : (AbortSignal as unknown as Record<string, unknown>);
1198
+ if (__abortSignal && typeof __abortSignal.any !== "function") {
1199
+ Object.defineProperty(AbortSignal, "any", {
1200
+ configurable: true,
1201
+ writable: true,
1202
+ value: (iterable: Iterable<AbortSignal>): AbortSignal => {
1203
+ const controller = new AbortController();
1204
+ for (const signal of iterable) {
1205
+ if (signal.aborted) {
1206
+ controller.abort(signal.reason);
1207
+ break;
1208
+ }
1209
+ signal.addEventListener("abort", () => controller.abort(signal.reason), {
1210
+ once: true,
1211
+ signal: controller.signal,
1212
+ });
1213
+ }
1214
+ return controller.signal;
1215
+ },
1216
+ });
1217
+ }
1218
+
1219
+ // jsdom has no canvas backend: \`HTMLCanvasElement.prototype.getContext\`
1220
+ // exists and THROWS \`Error: Not implemented: … (without installing the canvas
1221
+ // npm package)\` on every call. Any component that touches a canvas at mount —
1222
+ // a chart, a sparkline, a signature pad, a cropper, an avatar ring — dies in
1223
+ // the unit plane for a reason that has nothing to do with the component: 45
1224
+ // failures across two corpus apps (issue #98, meilisearch-ui 27 and
1225
+ // claw-empire 18), none of them the app's fault.
1226
+ //
1227
+ // What goes in its place is the shape #98 asks for: a RECORDING 2D context.
1228
+ // Every documented 2d method exists and does nothing but count itself; state
1229
+ // properties are kept and read back; measureText answers with a fixed-advance
1230
+ // width and getImageData with zeroed pixels of the size asked for. So the
1231
+ // mount survives and a spec can assert THAT the component drew —
1232
+ // canvas.getContext("2d").__flawlessDraws(), the array a context pushes
1233
+ // method names onto, with \`flawlessDrawCount\` for the common "did it paint"
1234
+ // question. What is NOT claimed is WHAT it drew: there are no pixels, no
1235
+ // compositing and no font metrics here, and a spec asserting a rendered colour
1236
+ // or glyph would be a lie this stub must not make possible.
1237
+ //
1238
+ // \`webgl\`/\`webgl2\` answer null instead of a fake on purpose: "no GPU context"
1239
+ // is a state real browsers hand out daily and apps already branch on, while a
1240
+ // fake GPU would let a shader component run to a result nobody measured. A
1241
+ // component that cannot live without WebGL fails, and that failure is about
1242
+ // the app — which is the line this whole family keeps: supply the standard API
1243
+ // the environment lacks, invent nothing a component is awaiting.
1244
+ //
1245
+ // Installed only where the platform has nothing. The browser plane renders in
1246
+ // Chromium, where the real backend answers and this block never runs.
1247
+ //
1248
+ // The environment is identified by jsdom's own user-agent marker rather than
1249
+ // by calling getContext to see whether it works: jsdom logs \`Not implemented\`
1250
+ // to its virtual console BEFORE it throws, so a probe puts one alarming stderr
1251
+ // block in every spec file of a healthy run (measured: 50 files, 50 lines).
1252
+ // A suite that overrides its user-agent therefore keeps jsdom's throw — a miss
1253
+ // the run inherits today anyway, never a wrong claim. The function being
1254
+ // replaced stays reachable as \`__flawlessRealGetContext\`. Detection is a plain
1255
+ // string test: a regex literal does not survive this template's escaping.
1256
+ const __isJsdom =
1257
+ typeof navigator !== "undefined" &&
1258
+ String(navigator.userAgent ?? "").toLowerCase().includes("jsdom");
1259
+ const __canvasProto: any =
1260
+ typeof HTMLCanvasElement === "undefined" ? undefined : HTMLCanvasElement.prototype;
1261
+ if (__isJsdom && __canvasProto) {
1262
+ const __realGetContext = __canvasProto.getContext;
1263
+ // The documented 2d surface that has no interesting return value. Methods
1264
+ // with one (measureText, getImageData, gradients, the point tests) are
1265
+ // written out below, and anything NOT on this list reads back undefined
1266
+ // rather than becoming a no-op function — inventing an API is how a stub
1267
+ // teaches an app that a browser it does not have exists.
1268
+ const __drawMethods = [
1269
+ "clearRect", "fillRect", "strokeRect", "reset",
1270
+ "beginPath", "closePath", "moveTo", "lineTo", "bezierCurveTo",
1271
+ "quadraticCurveTo", "arc", "arcTo", "ellipse", "rect", "roundRect",
1272
+ "fill", "stroke", "clip", "fillText", "strokeText", "drawImage",
1273
+ "save", "restore", "translate", "rotate", "scale", "transform",
1274
+ "setTransform", "resetTransform", "setLineDash", "drawFocusIfNeeded",
1275
+ "scrollPathIntoView", "putImageData", "transferFromImageBitmap",
1276
+ ];
1277
+ const __make2d = (canvas: unknown): any => {
1278
+ const draws: string[] = [];
1279
+ const ctx: any = {
1280
+ canvas,
1281
+ __flawlessDraws: draws,
1282
+ get flawlessDrawCount() {
1283
+ return draws.length;
1284
+ },
1285
+ fillStyle: "#000000",
1286
+ strokeStyle: "#000000",
1287
+ lineWidth: 1,
1288
+ lineCap: "butt",
1289
+ lineJoin: "miter",
1290
+ miterLimit: 10,
1291
+ lineDashOffset: 0,
1292
+ font: "10px sans-serif",
1293
+ textAlign: "start",
1294
+ textBaseline: "alphabetic",
1295
+ direction: "inherit",
1296
+ globalAlpha: 1,
1297
+ globalCompositeOperation: "source-over",
1298
+ shadowOffsetX: 0,
1299
+ shadowOffsetY: 0,
1300
+ shadowBlur: 0,
1301
+ shadowColor: "rgba(0, 0, 0, 0)",
1302
+ imageSmoothingEnabled: true,
1303
+ imageSmoothingQuality: "low",
1304
+ filter: "none",
1305
+ measureText: (text: unknown) => {
1306
+ // A fixed advance, not a font metric: enough for layout that reads
1307
+ // a width, nothing a glyph claim could rest on.
1308
+ const width = String(text ?? "").length * 6;
1309
+ return {
1310
+ width,
1311
+ actualBoundingBoxLeft: 0,
1312
+ actualBoundingBoxRight: width,
1313
+ actualBoundingBoxAscent: 8,
1314
+ actualBoundingBoxDescent: 2,
1315
+ fontBoundingBoxAscent: 8,
1316
+ fontBoundingBoxDescent: 2,
1317
+ };
1318
+ },
1319
+ getImageData: (_x: number, _y: number, w: number, h: number) => ({
1320
+ data: new Uint8ClampedArray(Math.max(0, Math.trunc(w * h * 4))),
1321
+ width: w,
1322
+ height: h,
1323
+ colorSpace: "srgb",
1324
+ }),
1325
+ createImageData: (w: number, h: number) => ({
1326
+ data: new Uint8ClampedArray(Math.max(0, Math.trunc(w * h * 4))),
1327
+ width: w,
1328
+ height: h,
1329
+ colorSpace: "srgb",
1330
+ }),
1331
+ getLineDash: () => [],
1332
+ getContextAttributes: () => ({
1333
+ alpha: true,
1334
+ colorSpace: "srgb",
1335
+ desynchronized: false,
1336
+ willReadFrequently: false,
1337
+ }),
1338
+ createLinearGradient: () => ({ addColorStop: () => {} }),
1339
+ createRadialGradient: () => ({ addColorStop: () => {} }),
1340
+ createConicGradient: () => ({ addColorStop: () => {} }),
1341
+ createPattern: () => ({ setTransform: () => {} }),
1342
+ isPointInPath: () => false,
1343
+ isPointInStroke: () => false,
1344
+ };
1345
+ for (const name of __drawMethods) {
1346
+ ctx[name] = (...args: unknown[]) => {
1347
+ void args;
1348
+ draws.push(name);
1349
+ };
1350
+ }
1351
+ return ctx;
1352
+ };
1353
+ Object.defineProperty(__canvasProto, "getContext", {
1354
+ configurable: true,
1355
+ writable: true,
1356
+ value(this: unknown, type?: string) {
1357
+ if (type === "2d") return __make2d(this);
1358
+ // Everything else — webgl, webgl2, bitmaprenderer — is the real
1359
+ // browser's "not available" answer, not a substitute.
1360
+ return null;
1361
+ },
1362
+ });
1363
+ // Nothing is lost on the way in: the replaced function stays reachable.
1364
+ __canvasProto.__flawlessRealGetContext = __realGetContext;
1365
+ }
1366
+ }
1367
+ // Global next/navigation mock: covers provider wrappers and third-party
1368
+ // libs (Clerk, TanStack Router, etc.) that import next/navigation
1369
+ // internally. Per-test mocks still override for specific components.
1370
+ vi.mock("next/navigation", () => ({
1371
+ notFound: vi.fn(() => { throw new Error("notFound() called"); }),
1372
+ redirect: vi.fn(() => { throw new Error("NEXT_REDIRECT"); }),
1373
+ permanentRedirect: vi.fn(() => { throw new Error("NEXT_REDIRECT"); }),
1374
+ usePathname: vi.fn(() => "/"),
1375
+ useRouter: vi.fn(() => ({
1376
+ push: vi.fn(),
1377
+ replace: vi.fn(),
1378
+ refresh: vi.fn(),
1379
+ back: vi.fn(),
1380
+ forward: vi.fn(),
1381
+ prefetch: vi.fn(),
1382
+ })),
1383
+ useSearchParams: vi.fn(() => new URLSearchParams()),
1384
+ useParams: vi.fn(() => ({})),
1385
+ useRoute: vi.fn(() => "/"),
1386
+ useSelectedLayoutSegment: vi.fn(() => null),
1387
+ useSelectedLayoutSegments: vi.fn(() => []),
1388
+ }));
1389
+ // Global next/router mock — the PAGES-router twin of the one above. Outside
1390
+ // Next's App container its useRouter throws "NextRouter was not mounted", so a
1391
+ // pages app's component dies at render before any assertion (talkformai: all
1392
+ // 26). Navigation is recorded and never performed, and every documented field
1393
+ // is defined: router.query.id is what a render touches, and an undefined one
1394
+ // is a different crash rather than a quieter one.
1395
+ vi.mock("next/router", async () => {
1396
+ const React = await import("react");
1397
+ const __router = {
1398
+ pathname: "/",
1399
+ route: "/",
1400
+ query: {},
1401
+ asPath: "/",
1402
+ basePath: "",
1403
+ locale: undefined,
1404
+ locales: [],
1405
+ isLocaleDomain: false,
1406
+ isReady: true,
1407
+ isPreview: false,
1408
+ push: vi.fn(async () => true),
1409
+ replace: vi.fn(async () => true),
1410
+ prefetch: vi.fn(async () => {}),
1411
+ pushScope: vi.fn(() => []),
1412
+ popScope: vi.fn(() => false),
1413
+ beforePopState: vi.fn(),
1414
+ reload: vi.fn(),
1415
+ back: vi.fn(),
1416
+ forward: vi.fn(),
1417
+ events: { on: vi.fn(), off: vi.fn(), emit: vi.fn() },
1418
+ };
1419
+ return {
1420
+ router: __router,
1421
+ useRouter: vi.fn(() => __router),
1422
+ withRouter: vi.fn(
1423
+ (Component: any) =>
1424
+ (props: any) => React.createElement(Component, { ...props, router: __router }),
1425
+ ),
1426
+ };
1427
+ });
1428
+ // Global next/cache mock: revalidate & co. are no-ops without a server, and
1429
+ // cacheLife()/cacheTag() THROW at execution time ("... is only available
1430
+ // with the cacheComponents config") in a plain vitest process — an app whose
1431
+ // cached server actions call them fails every spec that executes one.
1432
+ vi.mock("next/cache", () => ({
1433
+ revalidatePath: vi.fn(),
1434
+ revalidateTag: vi.fn(),
1435
+ updateTag: vi.fn(),
1436
+ refresh: vi.fn(),
1437
+ cacheLife: vi.fn(),
1438
+ cacheTag: vi.fn(),
1439
+ }));
1440
+ // Global next/headers mock: cookies()/headers() throw "called outside a
1441
+ // request scope" in a plain vitest process. A per-spec mock only covers a
1442
+ // component that imports the module ITSELF — the throw usually comes from a
1443
+ // transitive server import (a data-layer getCurrentUser reading cookies),
1444
+ // which no per-spec mock can reach. The cookie jar resolves empty, so auth
1445
+ // guards fall to their default/anonymous path instead of erroring.
1446
+ vi.mock("next/headers", () => ({
1447
+ headers: vi.fn(async () => new Headers()),
1448
+ cookies: vi.fn(async () => ({
1449
+ get: vi.fn(() => undefined),
1450
+ getAll: vi.fn(() => []),
1451
+ set: vi.fn(),
1452
+ delete: vi.fn(),
1453
+ })),
1454
+ draftMode: vi.fn(async () => ({
1455
+ isEnabled: () => false,
1456
+ enable: vi.fn(),
1457
+ disable: vi.fn(),
1458
+ })),
1459
+ }));
1460
+ // Global next/font mocks: the loaders are compile-time shims in the Next
1461
+ // pipeline; their runtime modules have NO exports to enumerate. A factory
1462
+ // listing only \`default\` starves named loaders (\`import { Caveat } from
1463
+ // "next/font/google"\`), so hand every requested name the same callable stub
1464
+ // through a Proxy. Native imports (node_modules wrappers like geist that run
1465
+ // the loader at module scope) bypass vi.mock entirely — the resolve hook
1466
+ // registered above answers those.
1467
+ vi.mock("next/font", () => {
1468
+ const loader = () => ({ className: "", variable: "", style: { fontFamily: "" } });
1469
+ return new Proxy({}, {
1470
+ get: (_t, p) => (p === "__esModule" ? true : typeof p === "string" && p !== "then" ? loader : undefined),
1471
+ has: () => true,
1472
+ });
1473
+ });
1474
+ vi.mock("next/font/local", () => {
1475
+ const loader = () => ({ className: "", variable: "", style: { fontFamily: "" } });
1476
+ return new Proxy({}, {
1477
+ get: (_t, p) => (p === "__esModule" ? true : typeof p === "string" && p !== "then" ? loader : undefined),
1478
+ has: () => true,
1479
+ });
1480
+ });
1481
+ vi.mock("next/font/google", () => {
1482
+ const loader = () => ({ className: "", variable: "", style: { fontFamily: "" } });
1483
+ return new Proxy({}, {
1484
+ get: (_t, p) => (p === "__esModule" ? true : typeof p === "string" && p !== "then" ? loader : undefined),
1485
+ has: () => true,
1486
+ });
1487
+ });
1488
+ ${authMocks}`;
1489
+ // The explicit vi import: setup files run with vitest's globals at runtime,
1490
+ // but the emitted suite must also TYPE-check standalone, and the nav/auth
1491
+ // mocks below are bare vi.mock calls.
1492
+ if (!mswSetup) {
1493
+ return `import "@testing-library/jest-dom/vitest";
1494
+ import { vi } from "vitest";
1495
+ ${polyfills}`;
1496
+ }
1497
+ return `import "@testing-library/jest-dom/vitest";
1498
+ import { afterAll, afterEach, beforeAll, vi } from "vitest";
1499
+ import { server } from "./mocks/node";
1500
+ ${polyfills}
1501
+ // MSW node server intercepts fetch() inside jsdom — same handlers your browser
1502
+ // suite uses, so unit and browser layers agree on what /api/* returns.
1503
+ beforeAll(() => server.listen({ onUnhandledRequest: "bypass" }));
1504
+ afterEach(() => server.resetHandlers());
1505
+ afterAll(() => server.close());
1506
+ `;
1507
+ }
1508
+
1509
+ // ---------------------------------------------------------------------------
1510
+ // Client-component unit spec
1511
+ // ---------------------------------------------------------------------------
1512
+
1513
+ function unitSpec(
1514
+ c: FlatComponent,
1515
+ variants: number,
1516
+ importPath: string,
1517
+ providerImport: string | undefined,
1518
+ mocksImport: string | undefined,
1519
+ apiRoutes: NextjsApiRoute[] | undefined,
1520
+ specDir?: string,
1521
+ todo?: TodoRecorder,
1522
+ autoProv?: AutoProvBits,
1523
+ ): string {
1524
+ const info = c.info;
1525
+ const name = info.componentName;
1526
+ // No render could be described at all: renderPath could not decide which
1527
+ // branch runs and every branch returns content. README has said since it was
1528
+ // written that such a component "produces no description at all", and until
1529
+ // renderUndecidable existed nothing recorded it — so the walk fell back to the
1530
+ // whole body and the spec drove a control inside a branch the defaults never
1531
+ // take. Executed: "Unable to find an accessible element with the role
1532
+ // 'button' and name 'Try again'" on a component that is entirely correct.
1533
+ //
1534
+ // A skip that names the cause, and nothing else. Not a mount test either: the
1535
+ // mount is the same claim in a weaker voice, and for a class whose props are
1536
+ // required it does not even compile.
1537
+ if (info.renderUndecidable === true) {
1538
+ // An error boundary is the one undecidable render whose BOTH branches a spec
1539
+ // can reach — React's contract for getDerivedStateFromError says a throwing
1540
+ // child puts it into the caught state — so it gets a real two-path spec
1541
+ // rather than the refusal.
1542
+ const fallback = info.errorBoundary?.fallbackText ?? [];
1543
+ if (info.errorBoundary && fallback.length > 0) {
1544
+ return errorBoundarySpec(name, importPath, info, fallback);
1545
+ }
1546
+ todo?.("other");
1547
+ return undecidableRenderSpec(name, importPath, info);
1548
+ }
1549
+ // A prop whose type could not be resolved is absent from propsShape, so the
1550
+ // component receives undefined — and any `prop.field` read then throws. When
1551
+ // the analyser saw it used via member access, stand it up as {} so the read
1552
+ // yields undefined (renders as nothing) instead of crashing the spec.
1553
+ const propsLiteral = defaultsJson(synthesiseDefaults(info));
1554
+ const variantsLiteral = JSON.stringify(info.variants.slice(0, variants));
1555
+ const promiseProps = info.promiseProps ?? [];
1556
+ const interactions = info.interactions;
1557
+
1558
+ const allHandlers = collectPropHandlers(info);
1559
+ const handlerObjectLiteral =
1560
+ allHandlers.length > 0 ? `{ ${allHandlers.map((h) => `${h}: vi.fn()`).join(", ")} }` : `{}`;
1561
+
1562
+ // What THIS spec mocks, which is what it can assert on. Derived from
1563
+ // moduleImports rather than from emitModuleMocks' result, because that runs
1564
+ // later in this function and the ladder is needed here — the two agree because
1565
+ // client mode mocks the allowlist plus "use server" modules, and both read the
1566
+ // same field.
1567
+ const claimCtx = {
1568
+ info,
1569
+ ctx: {
1570
+ mockedBindings: new Set(
1571
+ (info.moduleImports ?? []).filter((mi) => mi.isServerAction).flatMap((mi) => mi.names),
1572
+ ),
1573
+ routerMocked: (info.moduleImports ?? []).some((mi) => mi.specifier === "next/navigation"),
1574
+ },
1575
+ };
1576
+
1577
+ // Bindings a rung-2 claim will assert on. They have to be IMPORTED into the
1578
+ // spec: vi.mock replaces the module, so importing the name yields the mock —
1579
+ // but without the import the assertion is a ReferenceError. Computed by
1580
+ // running the same ladder the emitter will, so the two cannot disagree.
1581
+ const assertedBindings = new Set<string>();
1582
+ for (const click of interactions?.clicks ?? []) {
1583
+ const claim = decidePostDispatch(info, click, claimCtx.ctx);
1584
+ if (claim.kind === "module-spy") assertedBindings.add(claim.binding);
1585
+ }
1586
+ for (const form of interactions?.forms ?? []) {
1587
+ const a = form.actionBinding;
1588
+ if (a !== undefined && claimCtx.ctx.mockedBindings.has(a)) assertedBindings.add(a);
1589
+ }
1590
+ // Only when something actually uses it: an unused import fails a project's own
1591
+ // lint gate, which is where these files have to live.
1592
+ const fireEventImport = (interactions?.forms ?? []).some(
1593
+ (f) => f.actionBinding !== undefined && claimCtx.ctx.mockedBindings.has(f.actionBinding),
1594
+ )
1595
+ ? ", fireEvent"
1596
+ : "";
1597
+ const assertedImports = (info.moduleImports ?? [])
1598
+ .map((mi) => {
1599
+ const wanted = mi.names.filter((n) => assertedBindings.has(n));
1600
+ if (wanted.length === 0) return undefined;
1601
+ return `import { ${wanted.join(", ")} } from "${mockSpecifier(mi, specDir)}";`;
1602
+ })
1603
+ .filter((l): l is string => l !== undefined);
1604
+
1605
+ const interactionTests = interactions
1606
+ ? emitInteractionTests(
1607
+ name,
1608
+ interactions,
1609
+ allHandlers,
1610
+ firstClickTrigger(info),
1611
+ claimCtx,
1612
+ info.renderGuarded === true,
1613
+ todo,
1614
+ )
1615
+ : emitFallbackHandlerTests(name, info.propsShape, info.unresolvedProps);
1616
+
1617
+ const stateTests = emitStateTests(name, info, todo);
1618
+ const staticTextTests = emitStaticTextTests(name, info, "unit");
1619
+ const effectFetches = (info.fetchCalls ?? []).filter((f) => f.inEffect);
1620
+ const fetchTests =
1621
+ mocksImport && effectFetches.length > 0
1622
+ ? emitFetchTests(name, info, effectFetches, apiRoutes, firstClickTrigger(info), todo)
1623
+ : "";
1624
+
1625
+ const providerLine =
1626
+ (providerImport ? `import AppProvider from "${providerImport}";\n` : "") +
1627
+ (autoProv?.imports ?? "");
1628
+ const mswImports =
1629
+ mocksImport && effectFetches.length > 0
1630
+ ? `import { http, HttpResponse, delay } from "msw";\nimport { server } from "${mocksImport}";\n`
1631
+ : "";
1632
+
1633
+ // A component with no discoverable props has `ComponentProps<typeof X>` of
1634
+ // `unknown`, and spreading that is a type error in the target project.
1635
+ const hasProps =
1636
+ Object.keys(info.propsShape).length > 0 ||
1637
+ info.unresolvedProps.length > 0 ||
1638
+ allHandlers.length > 0;
1639
+ const spread = hasProps ? ` {...(merged as unknown as Props)}` : "";
1640
+ const childTextConst = info.acceptsChildren ? `const CHILD_TEXT = "flawless child";\n` : "";
1641
+ // The child must be an ELEMENT where the component slots it into an
1642
+ // asChild/Slot site — Slot clones elements and throws on the bare text
1643
+ // (analyse/childrenIntoSlot.ts). A span renders identically everywhere else.
1644
+ const childJsx = info.childrenSlotted ? "<span>{CHILD_TEXT}</span>" : "{CHILD_TEXT}";
1645
+ const element = info.acceptsChildren
1646
+ ? `<${name}${spread}>${childJsx}</${name}>`
1647
+ : `<${name}${spread} />`;
1648
+ const wrappedElement = autoProv?.open ? `${autoProv.open}${element}${autoProv.close}` : element;
1649
+ const renderExpr = providerImport
1650
+ ? `render(<AppProvider>${wrappedElement}</AppProvider>)`
1651
+ : `render(${wrappedElement})`;
1652
+ // The variant matrix asserts the component's OWN root where one is known.
1653
+ // `container.firstElementChild` used to stand here and cannot fail — see the
1654
+ // mount branch for why.
1655
+ const variantRootTag =
1656
+ info.rootTag && /^[a-z]/.test(info.rootTag) && !info.renderGuarded ? info.rootTag : undefined;
1657
+ const variantAssert = variantRootTag
1658
+ ? `const { container } = renderTarget(v);\n expect(container.querySelector(${JSON.stringify(variantRootTag)}), \`\${JSON.stringify(v)} rendered no <${variantRootTag}>\`).not.toBeNull();`
1659
+ : // biome-ignore lint/suspicious/noTemplateCurlyInString: emitted-spec text — the placeholder interpolates when the SPEC runs, and must survive this generator untouched
1660
+ "expect(() => renderTarget(v), `${JSON.stringify(v)} threw on mount`).not.toThrow();";
1661
+
1662
+ // `Props` exists only to type the props spread, and the React import exists
1663
+ // only to declare it. A zero-prop component renders bare, so emitting either
1664
+ // leaves dead code that fails a project's lint gate (noUnusedVariables).
1665
+ const propsTypeBlock = hasProps ? `import type * as React from "react";\n` : "";
1666
+ const propsAlias = hasProps ? `\ntype Props = React.ComponentProps<typeof ${name}>;\n` : "";
1667
+
1668
+ // Client specs emitted no mocks at all until now, so a component importing
1669
+ // next/navigation or calling a server action through useActionState blew up on
1670
+ // import. Allowlist only — see mocks.ts for why a blanket mock is wrong here.
1671
+ const mocks = emitModuleMocks(info, "client", specDir, todo).statements;
1672
+ const mockBlock = mocks.length > 0 ? `${mocks.join("\n")}\n` : "";
1673
+
1674
+ return `import { describe, test, expect, vi, beforeEach } from "vitest";
1675
+ import { render, screen, waitFor${fireEventImport} } from "@testing-library/react";
1676
+ import userEvent from "@testing-library/user-event";
1677
+ ${propsTypeBlock}${mswImports}${mockBlock}${providerLine}${emitComponentImport(info, importPath)}
1678
+ ${assertedImports.join("\n")}
1679
+ ${propsAlias}${childTextConst}
1680
+ const DEFAULTS: Record<string, unknown> = ${propsLiteral};
1681
+ const PROMISE_PROPS: string[] = ${JSON.stringify(promiseProps)};
1682
+ const VARIANTS: Array<Record<string, unknown>> = ${variantsLiteral};
1683
+ const handlers: Record<string, ReturnType<typeof vi.fn>> = ${handlerObjectLiteral};
1684
+
1685
+ beforeEach(() => {
1686
+ for (const v of Object.values(handlers)) v.mockClear();
1687
+ });
1688
+
1689
+ function buildProps(extra: Record<string, unknown> = {}): Record<string, unknown> {
1690
+ const merged: Record<string, unknown> = { ...DEFAULTS, ...handlers, ...extra };
1691
+ for (const k of PROMISE_PROPS) {
1692
+ if (k in merged) merged[k] = Promise.resolve(merged[k]);
1693
+ }
1694
+ return merged;
1695
+ }
1696
+
1697
+ function renderTarget(extra: Record<string, unknown> = {}) {
1698
+ const merged = buildProps(extra);
1699
+ void merged;
1700
+ return ${renderExpr};
1701
+ }
1702
+
1703
+
1704
+ ${emitContractTests(name, info)}
1705
+ ${
1706
+ info.variants.slice(0, variants).length > 0
1707
+ ? `describe("${name} variants", () => {
1708
+ for (const v of VARIANTS) {
1709
+ test(\`renders with \${JSON.stringify(v)}\`, () => {
1710
+ ${variantAssert}
1711
+ });
1712
+ }
1713
+ });`
1714
+ : `// no literal-union props — no variant matrix (VARIANTS kept for hand-written rows).
1715
+ void VARIANTS;`
1716
+ }
1717
+
1718
+ ${interactionTests}
1719
+ ${staticTextTests}${stateTests}${fetchTests}`;
1720
+ }
1721
+
1722
+ /**
1723
+ * What a component's markup is contractually obliged to do, for components that
1724
+ * render no literal copy of their own.
1725
+ *
1726
+ * The whole shadcn/ui layer is like this — CardFooter, TableRow, AlertDialogHeader
1727
+ * — and before these rules every one of their specs was a bare `renderTarget()`
1728
+ * with `void screen;` to satisfy lint: six tests that proved nothing. Each rule
1729
+ * asserts something the SOURCE says is true, and is emitted only when the
1730
+ * analyser saw the value actually reach the DOM.
1731
+ *
1732
+ * When none of them apply the spec says so and skips, rather than passing on a
1733
+ * render that asserted nothing.
1734
+ */
1735
+ function emitContractTests(name: string, info: ComponentInfo): string {
1736
+ const parts: string[] = [];
1737
+ // A guarded render may produce nothing at all at these props — `if
1738
+ // (toasts.length === 0) return null` with a store-backed list is the shape —
1739
+ // so the tag below it is a description, not a promise.
1740
+ const rootTag =
1741
+ info.rootTag && /^[a-z]/.test(info.rootTag) && !info.renderGuarded ? info.rootTag : undefined;
1742
+
1743
+ if (rootTag) {
1744
+ parts.push(`
1745
+ test("${name} renders a <${rootTag}>", () => {
1746
+ const { container } = renderTarget();
1747
+ expect(container.querySelector(${JSON.stringify(rootTag)})).not.toBeNull();
1748
+ });`);
1749
+ } else if (info.renderGuarded) {
1750
+ parts.push(`
1751
+ test("${name} mounts at defaults without throwing", () => {
1752
+ // An early return that this analysis could not decide means the component may
1753
+ // legitimately render nothing here, so the assertion is that mounting is safe
1754
+ // rather than that anything appeared.
1755
+ expect(() => renderTarget()).not.toThrow();
1756
+ void screen;
1757
+ });`);
1758
+ } else {
1759
+ parts.push(`
1760
+ test("${name} mounts at defaults without throwing", () => {
1761
+ // No host root tag resolved for this component, so nothing here claims it
1762
+ // rendered one. \`expect(container.firstElementChild).not.toBeNull()\` stood
1763
+ // here and is not that claim either: any wrapper in the render tree — a
1764
+ // provider injecting a <script>, say — answers it whether or not the component
1765
+ // rendered anything at all.
1766
+ expect(() => renderTarget()).not.toThrow();
1767
+ void screen;
1768
+ });`);
1769
+ }
1770
+
1771
+ if (info.acceptsChildren && info.childrenFlow) {
1772
+ parts.push(`
1773
+ test("${name} renders the children it is given", () => {
1774
+ renderTarget();
1775
+ expect(screen.getByText(CHILD_TEXT)).toBeInTheDocument();
1776
+ });`);
1777
+ }
1778
+
1779
+ parts.push(emitVariantDistinctness(name, info));
1780
+
1781
+ if (info.classNameFlow) {
1782
+ // Queried across the whole container rather than on the root: <Table>
1783
+ // renders <div><table className={cn(…, className)}/></div>, so a root-only
1784
+ // assertion would be wrong about where the class lands.
1785
+ parts.push(`
1786
+ test("${name} forwards className to the markup it renders", () => {
1787
+ const { container } = renderTarget({ className: "flawless-cls" });
1788
+ expect(container.querySelector(".flawless-cls")).not.toBeNull();
1789
+ });`);
1790
+ }
1791
+
1792
+ return parts.join("\n");
1793
+ }
1794
+
1795
+ /**
1796
+ * Each cva option renders an observably different DOM.
1797
+ *
1798
+ * Deliberately NOT an assertion about specific Tailwind tokens. `cn()` is
1799
+ * usually tailwind-merge, which drops base classes a later variant conflicts
1800
+ * with — verified against the real DOM, where `rounded-lg` is absent at
1801
+ * size="sm" — and the tool cannot know whether a project's `cn` merges or just
1802
+ * concatenates. What the cva table DOES support is that two options whose class
1803
+ * strings differ must produce different output. Comparing the whole outerHTML,
1804
+ * not the class attribute, is what makes that hold for the shadcn pattern of
1805
+ * expressing the variant as `data-variant`/`data-size` (the group root carries
1806
+ * the data attributes; the per-variant classes live in the CSS through them)
1807
+ * while still catching a variant that reaches no DOM at all.
1808
+ *
1809
+ * Bails when any two options share a class string (they legitimately render the
1810
+ * same), when one is empty, or when there is only one option to compare. When
1811
+ * the component's own render carries no class attribute at all the test SKIPS
1812
+ * itself with a reason: the comparison has no observable to run against, which
1813
+ * is a fact about the component (Radix's Toast.Root) rather than a regression.
1814
+ */
1815
+ function emitVariantDistinctness(name: string, info: ComponentInfo): string {
1816
+ const classes = info.variantClasses ?? {};
1817
+ const parts: string[] = [];
1818
+
1819
+ for (const [key, byOption] of Object.entries(classes)) {
1820
+ const options = Object.keys(byOption);
1821
+ if (options.length < 2) continue;
1822
+ const strings = options.map((o) => byOption[o] ?? "");
1823
+ if (strings.some((v) => v === "")) continue;
1824
+ if (new Set(strings).size !== strings.length) continue;
1825
+
1826
+ parts.push(`
1827
+ test("${name}: every \`${key}\` option renders distinct markup", (ctx) => {
1828
+ // The witness is an element carrying a class, so first check that this
1829
+ // component has one at all. Radix's Toast.Root hands jsdom an element with
1830
+ // NO class attribute — invoify's Toast failed below on variant="default",
1831
+ // the option the component itself picks — and every option after it would
1832
+ // have failed the same way for a reason the spec cannot see: the cva table's
1833
+ // classes reach the DOM through Radix's styling context, not through the
1834
+ // rendered element. A comparison with no observable is the component's
1835
+ // shape, not a regression: skip once, named, instead of calling it red.
1836
+ const probe = renderTarget();
1837
+ const probeCarriesClass = probe.container.querySelector("[class]") !== null;
1838
+ probe.unmount();
1839
+ if (!probeCarriesClass) {
1840
+ ctx.skip("no element in this component's render carries a class — nothing to compare");
1841
+ }
1842
+ const seen = ${JSON.stringify(options)}.map((option) => {
1843
+ const { container, unmount } = renderTarget({ ${JSON.stringify(key)}: option });
1844
+ const el = container.querySelector("[class]");
1845
+ expect(el, \`${key}=\${option} rendered no element carrying a class\`).not.toBeNull();
1846
+ const dom = el?.outerHTML ?? "";
1847
+ unmount();
1848
+ return dom;
1849
+ });
1850
+ expect(new Set(seen).size, "two options rendered identical markup").toBe(seen.length);
1851
+ });`);
1852
+ }
1853
+
1854
+ return parts.join("\n");
1855
+ }
1856
+
1857
+ // ---------------------------------------------------------------------------
1858
+ // State-derived tests
1859
+ // ---------------------------------------------------------------------------
1860
+
1861
+ function emitStateTests(name: string, info: ComponentInfo, todo?: TodoRecorder): string {
1862
+ const states = (info.state ?? []).filter((s) => s.kind !== "useRef");
1863
+ if (states.length === 0) return "";
1864
+
1865
+ const parts: string[] = [];
1866
+ const summary = states
1867
+ .map(
1868
+ (s) =>
1869
+ `// ${s.varName}${s.setterName ? ` / ${s.setterName}` : ""} (${s.typeKind}) = ${escapeComment(s.initialRaw ?? "?")}`,
1870
+ )
1871
+ .join("\n");
1872
+ parts.push(`\n// Detected state:\n${summary}`);
1873
+
1874
+ for (const s of states) {
1875
+ const witness = initialWitness(s);
1876
+ if (witness) {
1877
+ parts.push(`
1878
+ test("${name} initial state: ${s.varName} = ${escapeTestName(s.initialRaw ?? "")} is rendered", () => {
1879
+ renderTarget();
1880
+ expect(screen.queryAllByText(${JSON.stringify(witness)}).length).toBeGreaterThan(0);
1881
+ });`);
1882
+ }
1883
+ }
1884
+
1885
+ // Interaction → state change: a click bound to a local handler that calls a setter.
1886
+ const clicks = info.interactions?.clicks ?? [];
1887
+ for (const s of states) {
1888
+ for (const call of s.setterCalls) {
1889
+ if (call.context !== "handler" || !call.enclosingName) continue;
1890
+ const click = clicks.find(
1891
+ (cl) => cl.binding.kind === "local" && cl.binding.localName === call.enclosingName,
1892
+ );
1893
+ if (!click) continue;
1894
+ const selector = emitVitestSelector(click);
1895
+ // Nothing in the source identifies the element; a query that resolves to
1896
+ // null would click nothing and pass.
1897
+ if (!selector) continue;
1898
+ const dispatch = emitVitestDispatch(click.event, selector, { tag: click.tag });
1899
+ if (!dispatch) continue;
1900
+ todo?.("other");
1901
+ parts.push(`
1902
+ test("${name}: ${click.event} via ${call.enclosingName} → ${s.setterName ?? s.varName} updates", async () => {
1903
+ const { container } = renderTarget();
1904
+ void container;
1905
+ ${dispatch};
1906
+ // TODO(flawless): assert the observable change for ${s.setterName ?? s.varName}(...) —
1907
+ // the handler fires, but no text witness could be derived statically.
1908
+ await waitFor(() => expect(document.body).toBeTruthy());
1909
+ });`);
1910
+ break; // one interaction test per state var is enough scaffolding
1911
+ }
1912
+ }
1913
+
1914
+ return `${parts.join("\n")}\n`;
1915
+ }
1916
+
1917
+ function initialWitness(s: StateInfo): string | undefined {
1918
+ if (typeof s.initialValue !== "string" || s.initialValue.trim().length < 2) return undefined;
1919
+ if (!s.flowsToJsx.some((f) => f.via === "child")) return undefined;
1920
+ return s.initialValue;
1921
+ }
1922
+
1923
+ /** Safe inside a double-quoted test name: no quotes, no newlines, bounded. */
1924
+ function escapeTestName(s: string): string {
1925
+ return s.replace(/\s+/g, " ").replace(/"/g, "'").trim().slice(0, 40);
1926
+ }
1927
+
1928
+ /** Safe inside a `//` comment: single line, bounded. */
1929
+ function escapeComment(s: string): string {
1930
+ return s.replace(/\s+/g, " ").trim().slice(0, 80);
1931
+ }
1932
+
1933
+ // ---------------------------------------------------------------------------
1934
+ // Effect-driven fetch tests (MSW)
1935
+ // ---------------------------------------------------------------------------
1936
+
1937
+ /**
1938
+ * Data effects are often gated behind UI state (open a panel → fetch). The
1939
+ * fetch specs fire the component's first detected click when it is present,
1940
+ * which unlocks the common launcher-gated pattern; a mount-triggered fetch is
1941
+ * unaffected (the click is skipped when the element is absent).
1942
+ *
1943
+ * Skipping is only honest if the query is non-throwing, so the selector is
1944
+ * rewritten to its query twin for EVERY shape it can take: getBy* → queryBy*,
1945
+ * getAllBy* → queryAllBy* (an empty result's [0] is undefined), and the
1946
+ * querySelector assertion dropped. The old bare getBy→queryBy rewrite left the
1947
+ * getAllBy* forms throwing on absence, and the throw failed all four fetch
1948
+ * scenarios on a correct component.
1949
+ */
1950
+ function firstClickTrigger(info: ComponentInfo): string | undefined {
1951
+ const click = info.interactions?.clicks.find((c) => c.event === "onClick");
1952
+ if (!click) return undefined;
1953
+ const selector = emitVitestSelector(click);
1954
+ if (!selector) return undefined;
1955
+ const query = selector
1956
+ .replace(".getAllBy", ".queryAllBy")
1957
+ .replace(".getBy", ".queryBy")
1958
+ .replace(/\)!$/, ")");
1959
+ return `const trigger = ${query};
1960
+ if (trigger) await userEvent.click(trigger as HTMLElement);`;
1961
+ }
1962
+
1963
+ function emitFetchTests(
1964
+ name: string,
1965
+ info: ComponentInfo,
1966
+ fetches: FetchCallInfo[],
1967
+ apiRoutes: NextjsApiRoute[] | undefined,
1968
+ trigger: string | undefined,
1969
+ todo?: TodoRecorder,
1970
+ ): string {
1971
+ const triggerLines = trigger
1972
+ ? `\n // Effects can be gated behind UI state — fire the first detected click.\n ${trigger}`
1973
+ : "";
1974
+ // Every marker below goes through this helper so the tally and the emitted
1975
+ // text cannot drift apart when a scenario is added or removed.
1976
+ const todoLine = (text: string): string => {
1977
+ todo?.("other");
1978
+ return `// TODO(flawless): ${text}`;
1979
+ };
1980
+ const propNames = new Set([...Object.keys(info.propsShape), ...info.unresolvedProps]);
1981
+ const parts: string[] = [];
1982
+ for (const f of fetches) {
1983
+ const mswPath = f.urlTemplate;
1984
+ const method = f.method.toLowerCase();
1985
+ const fixture = apiRouteFixture(f.urlTemplate, f.method, apiRoutes);
1986
+ const fixtureLiteral = fixture !== undefined ? JSON.stringify(fixture) : "{}";
1987
+ const fixtureComment =
1988
+ fixture !== undefined
1989
+ ? "// Fixture from the project's OpenAPI spec examples."
1990
+ : todoLine("no example found in the API spec — replace {} with a realistic fixture.");
1991
+
1992
+ // URL holes that are props default to "" and would collapse a path
1993
+ // segment (/api/x//y never matches :param) — render with real values.
1994
+ const holeOverrides = Object.fromEntries(
1995
+ f.holes.filter((h) => propNames.has(h)).map((h) => [h, `test-${h}`]),
1996
+ );
1997
+ const renderArg = Object.keys(holeOverrides).length > 0 ? JSON.stringify(holeOverrides) : "";
1998
+ parts.push(`
1999
+ describe("${name} ${f.method} ${mswPath}", () => {
2000
+ const URL = ${JSON.stringify(mswPath)};
2001
+ ${fixtureComment}
2002
+ const FIXTURE: unknown = ${fixtureLiteral};
2003
+
2004
+ test("mount fetches and renders with a successful response", async () => {
2005
+ let calls = 0;
2006
+ server.use(http.${method}(URL, () => { calls++; return HttpResponse.json(FIXTURE as never); }));
2007
+ const { container } = renderTarget(${renderArg});
2008
+ void container;${triggerLines}
2009
+ await waitFor(() => expect(calls).toBeGreaterThan(0), { timeout: 3000 });
2010
+ ${todoLine("assert rendered content derived from FIXTURE.")}
2011
+ });
2012
+
2013
+ test("stays stable while the response is delayed (loading state)", async () => {
2014
+ let calls = 0;
2015
+ server.use(http.${method}(URL, async () => { calls++; await delay(150); return HttpResponse.json(FIXTURE as never); }));
2016
+ const { container } = renderTarget(${renderArg});
2017
+ void container;${triggerLines}
2018
+ await waitFor(() => expect(calls).toBeGreaterThan(0), { timeout: 3000 });
2019
+ ${todoLine("assert the loading indicator while the response is pending.")}
2020
+ });
2021
+
2022
+ test("survives a 500 without crashing (error state)", async () => {
2023
+ let calls = 0;
2024
+ server.use(http.${method}(URL, () => { calls++; return new HttpResponse(null, { status: 500 }); }));
2025
+ const { container } = renderTarget(${renderArg});
2026
+ void container;${triggerLines}
2027
+ await waitFor(() => expect(calls).toBeGreaterThan(0), { timeout: 3000 });
2028
+ ${todoLine("assert the error-state UI.")}
2029
+ });
2030
+
2031
+ test("renders the empty state for an empty payload", async () => {
2032
+ let calls = 0;
2033
+ server.use(http.${method}(URL, () => { calls++; return HttpResponse.json(${emptyPayloadFor(fixture)}); }));
2034
+ const { container } = renderTarget(${renderArg});
2035
+ void container;${triggerLines}
2036
+ await waitFor(() => expect(calls).toBeGreaterThan(0), { timeout: 3000 });
2037
+ ${todoLine("assert the empty-state copy.")}
2038
+ });
2039
+ });`);
2040
+ }
2041
+ return `\n${parts.join("\n")}\n`;
2042
+ }
2043
+
2044
+ function emptyPayloadFor(fixture: unknown): string {
2045
+ if (Array.isArray(fixture)) return "[]";
2046
+ if (fixture && typeof fixture === "object") {
2047
+ // Preserve the envelope but empty every array member: { items: [] }.
2048
+ const emptied: Record<string, unknown> = {};
2049
+ for (const [k, v] of Object.entries(fixture as Record<string, unknown>)) {
2050
+ emptied[k] = Array.isArray(v) ? [] : v;
2051
+ }
2052
+ return JSON.stringify(emptied);
2053
+ }
2054
+ return "{}";
2055
+ }
2056
+
2057
+ // ---------------------------------------------------------------------------
2058
+ // RSC spec (async server components)
2059
+ // ---------------------------------------------------------------------------
2060
+
2061
+ /**
2062
+ * A component that cannot be mounted standalone. Rather than emit a render
2063
+ * assertion that can only fail, assert what IS verifiable — that the module
2064
+ * exports a component — and state the reason in the file so the gap is visible
2065
+ * rather than silently missing.
2066
+ */
2067
+ function constrainedSpec(
2068
+ c: FlatComponent,
2069
+ importPath: string,
2070
+ providerImport: string | undefined,
2071
+ mode: "compound-part" | "document-root",
2072
+ specDir?: string,
2073
+ todo?: TodoRecorder,
2074
+ apiRoutes?: NextjsApiRoute[],
2075
+ legacyServer = false,
2076
+ ): string {
2077
+ const info = c.info;
2078
+ const name = info.componentName;
2079
+ const note = (info.renderNotes ?? []).join(" ");
2080
+ void providerImport;
2081
+
2082
+ // Importing the module runs its module scope, so "server-only" and
2083
+ // next/font/* throw at collection time even for a spec that only asserts the
2084
+ // export exists. These specs emitted no mocks at all until now.
2085
+ const mocks = emitModuleMocks(info, "rsc", specDir, todo, apiRoutes).statements;
2086
+ const mockBlock = mocks.length > 0 ? `${mocks.join("\n")}\n` : "";
2087
+
2088
+ if (mode === "document-root") {
2089
+ const propsLiteral = defaultsJson(withRouteParams(synthesiseDefaults(info)));
2090
+ const promiseProps = info.promiseProps ?? [];
2091
+ // An async server component has to be invoked to get its element. A sync one
2092
+ // must NOT be: calling it directly runs its hooks with no React dispatcher
2093
+ // ("Cannot read properties of null (reading 'useEffect')"). Rendering it as
2094
+ // an element is safe either way — effects simply do not run during SSR.
2095
+ // `{...(props as never)}` is a type error — never is not an object type — so
2096
+ // the spread is cast to the component's own props instead.
2097
+ // `{...(props as never)}` is a type error — never is not an object type — so
2098
+ // the spread is cast to the component's own props instead.
2099
+ // resolveAsyncUi stays for what the streaming renderer cannot pre-resolve
2100
+ // cheaply: an ASYNC root must be invoked to obtain its element at all.
2101
+ const invoke = info.isAsync
2102
+ ? `await resolveAsyncUi(await ${name}(props as never))`
2103
+ : `await resolveAsyncUi(<${name} {...(props as React.ComponentProps<typeof ${name}>)} />)`;
2104
+ // The resolver is always in scope now: even the sync-root invoke awaits it.
2105
+ const asyncUiBits = {
2106
+ imp: `import { cloneElement, isValidElement } from "react";\n`,
2107
+ fn: RSC_RESOLVE_ASYNC_UI,
2108
+ };
2109
+ // Only a component that actually renders {children} can be asserted to pass
2110
+ // a slot through. A root error boundary takes {error, reset} and renders
2111
+ // neither, so injecting a slot and demanding it appear is a test the
2112
+ // component can never satisfy — and the failure reads as a real defect.
2113
+ const slotBlock = info.acceptsChildren
2114
+ ? {
2115
+ const: `const SLOT = "flawless-slot";\n`,
2116
+ prop: `\n children: <div data-testid={SLOT} />,`,
2117
+ title: " containing its children",
2118
+ assert: `\n expect(html).toContain(SLOT);`,
2119
+ }
2120
+ : { const: "", prop: "", title: "", assert: "" };
2121
+ // React 17 has no streaming renderer (renderToReadableStream is 18+) and
2122
+ // no promise-as-child support at all, so the markup assertion runs
2123
+ // through renderToStaticMarkup and the promise-prop wrapping is dropped.
2124
+ const serverImport = legacyServer
2125
+ ? `import { renderToStaticMarkup } from "react-dom/server";\n`
2126
+ : `import { renderToReadableStream } from "react-dom/server";\n`;
2127
+ const promiseBlock = legacyServer
2128
+ ? ""
2129
+ : `const PROMISE_PROPS: string[] = ${JSON.stringify(promiseProps)};\n`;
2130
+ const promiseLoop = legacyServer
2131
+ ? ""
2132
+ : ` for (const k of PROMISE_PROPS) {
2133
+ if (k in props) props[k] = Promise.resolve(props[k]);
2134
+ }\n`;
2135
+ const renderLines = legacyServer
2136
+ ? ` const html = renderToStaticMarkup(ui as React.ReactElement).replace(
2137
+ /^<!DOCTYPE html>/i,
2138
+ "",
2139
+ );
2140
+ `
2141
+ : ` const stream = await renderToReadableStream(ui as React.ReactElement);
2142
+ const markup = await new Response(stream).text();
2143
+ const html = markup.replace(/^<!DOCTYPE html>/i, "");
2144
+ `;
2145
+ return `import { test, expect, vi } from "vitest";
2146
+ ${serverImport}${asyncUiBits.imp}import type * as React from "react";
2147
+ ${mockBlock}${emitComponentImport(info, importPath)}
2148
+
2149
+ // ${name} ${note}.
2150
+ // Mounting it with Testing Library would nest <html> inside a container div:
2151
+ // jsdom tolerates that silently, so the test would pass while asserting
2152
+ // nothing true. Its contract is checked as server markup instead — with the
2153
+ // STREAMING renderer, because a document root is exactly where apps render
2154
+ // PROMISES as children ({getUser().then(u => <Pill user={u}/>)} is React 19's
2155
+ // suspense-data pattern) and nest async components inside sync client ones.
2156
+ // renderToStaticMarkup can await nothing and dies on all of that with "A
2157
+ // component suspended while responding to synchronous input"; the streaming
2158
+ // renderer awaits them the way Next's own SSR does.
2159
+ ${asyncUiBits.fn}const DEFAULTS: Record<string, unknown> = ${propsLiteral};
2160
+ ${promiseBlock}${slotBlock.const}
2161
+ test("${name} returns a document root${slotBlock.title}", async () => {
2162
+ const props: Record<string, unknown> = {
2163
+ ...DEFAULTS,${slotBlock.prop}
2164
+ };
2165
+ ${promiseLoop} const ui = ${invoke};
2166
+ ${renderLines} expect(html).toMatch(/^<html/);
2167
+ expect(html).toContain("<body");${slotBlock.assert}
2168
+ void vi;
2169
+ });
2170
+ `;
2171
+ }
2172
+
2173
+ if (compoundWrappable(info) && info.compoundFamily) {
2174
+ return compoundPartSpec(info, name, note, importPath, mocks, mockBlock);
2175
+ }
2176
+
2177
+ todo?.("compound-root-wrap");
2178
+ return `import { test, expect${mocks.length > 0 ? ", vi" : ""} } from "vitest";
2179
+ ${mockBlock}${emitComponentImport(info, importPath)}
2180
+
2181
+ // ${name} ${note}
2182
+ // Rendering it on its own throws, so this spec asserts only what is true in
2183
+ // isolation. TODO(flawless): wrap renderTarget() in the matching Root with an
2184
+ // open state to assert its content.
2185
+ test("${name} is exported as a component", () => {
2186
+ expect(typeof ${name}).toMatch(/function|object/);
2187
+ });
2188
+ `;
2189
+ }
2190
+
2191
+ /**
2192
+ * A compound part whose OWN file exports its family mounts the way call
2193
+ * sites do: the Trigger inside a closed Root (the state where a trigger is
2194
+ * visible), everything else inside <Root open> — with the Content sibling
2195
+ * in between for parts that live inside the popup. Portal content lands
2196
+ * outside the render container, so assertions read the whole document rather
2197
+ * than the container: through screen and baseElement both where the part takes
2198
+ * children and there is slot text to look for, and by counting what the mount
2199
+ * put in the document where it does not.
2200
+ */
2201
+ function compoundPartSpec(
2202
+ info: ComponentInfo,
2203
+ name: string,
2204
+ note: string,
2205
+ importPath: string,
2206
+ mocks: string[],
2207
+ mockBlock: string,
2208
+ ): string {
2209
+ const family = info.compoundFamily as NonNullable<ComponentInfo["compoundFamily"]>;
2210
+ const wrapNames = [
2211
+ family.root,
2212
+ ...(family.part === "other" && family.content ? [family.content] : []),
2213
+ ].filter((n) => n !== name);
2214
+ const wrapImport =
2215
+ wrapNames.length > 0
2216
+ ? `import { ${wrapNames.join(", ")} } from "${stripTsExt(importPath)}";\n`
2217
+ : "";
2218
+
2219
+ const propsSpread = `{...(DEFAULTS as React.ComponentProps<typeof ${name}>)}`;
2220
+ const child = info.acceptsChildren ? `<span>flawless-slot</span>` : undefined;
2221
+ const partJsx = child
2222
+ ? `<${name} ${propsSpread}>${child}</${name}>`
2223
+ : `<${name} ${propsSpread} />`;
2224
+ const open = family.part === "trigger" ? "" : " open";
2225
+ const wrapped =
2226
+ family.part === "other" && family.content
2227
+ ? `<${family.root}${open}><${family.content}>${partJsx}</${family.content}></${family.root}>`
2228
+ : `<${family.root}${open}>${partJsx}</${family.root}>`;
2229
+
2230
+ // Both portal surfaces. screen is bound to document.body, so it sees a part
2231
+ // that rendered through a portal and a part that rendered inline alike;
2232
+ // baseElement is the same document from the render result's side. A part
2233
+ // that rendered nowhere satisfies neither.
2234
+ //
2235
+ // getAllByText, not getByText: getByText additionally demands the child appear
2236
+ // EXACTLY once, which is not a property of the part under test. Real
2237
+ // primitives duplicate it — a radix Select mounted without its Trigger renders
2238
+ // a mirror <option> for the bubble select, so getByText made the emitted
2239
+ // SelectItem spec red against a perfectly healthy component. getAllByText
2240
+ // still throws "Unable to find an element with the text" on zero matches, so
2241
+ // a part that renders nothing is still caught.
2242
+ //
2243
+ // With no child to witness, count elements instead: render() always appends
2244
+ // its own container div to baseElement, so >1 means the composed mount put
2245
+ // something in the document. A bare not.toThrow() did not even ask that.
2246
+ //
2247
+ // Honest about its reach: a Root is a context provider and renders no DOM of
2248
+ // its own, so for a trigger or the content part this fails when the part
2249
+ // renders nothing. An "other" part is wrapped in the Content sibling too,
2250
+ // and that sibling's own DOM satisfies the count on its own — there the
2251
+ // assertion witnesses the mount, not the part.
2252
+ const body = child
2253
+ ? ` const { baseElement } = render(${wrapped});
2254
+ expect(screen.getAllByText("flawless-slot").length).toBeGreaterThan(0);
2255
+ expect(baseElement.textContent).toContain("flawless-slot");`
2256
+ : ` const { baseElement } = render(${wrapped});
2257
+ expect(baseElement.querySelectorAll("*").length).toBeGreaterThan(1);`;
2258
+
2259
+ return `import { test, expect${mocks.length > 0 ? ", vi" : ""} } from "vitest";
2260
+ import { render${child ? ", screen" : ""} } from "@testing-library/react";
2261
+ import type * as React from "react";
2262
+ ${mockBlock}${emitComponentImport(info, importPath)}
2263
+ ${wrapImport}
2264
+ // ${name} ${note}
2265
+ // Its own file exports the family, so the spec mounts the part the way call
2266
+ // sites do instead of skipping. Portal content lands outside the container, so
2267
+ ${
2268
+ child
2269
+ ? "// the assertions read the whole document — via screen and baseElement."
2270
+ : `// the assertion reads the whole document. This part takes no children, so
2271
+ // there is no slot text to look for: it asserts the mount put elements in the
2272
+ // document at all.`
2273
+ }
2274
+ const DEFAULTS: Record<string, unknown> = ${defaultsJson(synthesiseDefaults(info))};
2275
+
2276
+ test("${name} mounts inside its family wrapper", () => {
2277
+ ${body}
2278
+ });
2279
+ `;
2280
+ }
2281
+
2282
+ /**
2283
+ * Emitted into every spec that INVOKES an async server component, before its
2284
+ * first test. Invoking the target yields elements of OTHER async components —
2285
+ * react-dom refuses those outright ("<X> is an async Client Component") and
2286
+ * renderToStaticMarkup stops dead at them (the empty-markup failures). The
2287
+ * fix is mechanical: walk the element tree, invoke each async function in
2288
+ * place, await it, and recurse — the renderer receives an entirely sync tree.
2289
+ * Guard / static-text / inversion siblings in the same file share it.
2290
+ */
2291
+ const RSC_RESOLVE_ASYNC_UI = `
2292
+ // Server components compose server components: invoke the async ones in the
2293
+ // tree so react-dom / renderToStaticMarkup only ever see sync elements.
2294
+ async function resolveAsyncUi(
2295
+ node: React.ReactNode,
2296
+ depth = 0,
2297
+ ): Promise<React.ReactNode> {
2298
+ if (node === null || node === undefined || depth > 24) return node;
2299
+ if (Array.isArray(node)) {
2300
+ const out: React.ReactNode[] = [];
2301
+ for (const kid of await Promise.all(node.map((k) => resolveAsyncUi(k, depth + 1)))) {
2302
+ if (Array.isArray(kid)) out.push(...kid);
2303
+ else out.push(kid);
2304
+ }
2305
+ return out;
2306
+ }
2307
+ if (!isValidElement(node)) return node;
2308
+ const el = node as React.ReactElement;
2309
+ const ctor = el.type as unknown as { constructor?: { name?: string } } | null;
2310
+ if (typeof el.type === "function" && ctor?.constructor?.name === "AsyncFunction") {
2311
+ const inner = await (el.type as (props: unknown) => Promise<React.ReactNode>)(el.props);
2312
+ return resolveAsyncUi(inner, depth + 1);
2313
+ }
2314
+ const kids = (el.props as { children?: React.ReactNode }).children;
2315
+ if (kids === undefined || kids === null) return el;
2316
+ const resolved = await resolveAsyncUi(kids, depth + 1);
2317
+ return resolved === kids ? el : cloneElement(el, {}, resolved);
2318
+ }
2319
+ `;
2320
+
2321
+ function rscSpec(
2322
+ c: FlatComponent,
2323
+ importPath: string,
2324
+ providerImport: string | undefined,
2325
+ specDir?: string,
2326
+ todo?: TodoRecorder,
2327
+ apiRoutes?: NextjsApiRoute[],
2328
+ autoProv?: AutoProvBits,
2329
+ ): string {
2330
+ const info = c.info;
2331
+ const name = info.componentName;
2332
+ // synthesiseDefaults, not the bare shape: an unresolved prop (`drop: Drop`)
2333
+ // is absent from propsShape, so the RSC received undefined and its first
2334
+ // `drop.parentId` read threw. The usage-based {} stand-ins the unit plane
2335
+ // has always used apply to server components verbatim.
2336
+ const propsLiteral = defaultsJson(withRouteParams(synthesiseDefaults(info)));
2337
+ const promiseProps = info.promiseProps ?? [];
2338
+
2339
+ const mocks = emitModuleMocks(info, "rsc", specDir, todo, apiRoutes).statements;
2340
+
2341
+ const providerLine =
2342
+ (providerImport ? `import AppProvider from "${providerImport}";\n` : "") +
2343
+ (autoProv?.imports ?? "");
2344
+ // Captured: @testing-library appends its container to document.body on
2345
+ // every render, so asserting on document.body could not fail even when the
2346
+ // component rendered nothing. The container is what this component produced.
2347
+ const renderExpr = providerImport
2348
+ ? autoProv?.open
2349
+ ? `rendered = render(<AppProvider>${autoProv.open}{ui}${autoProv.close}</AppProvider>)`
2350
+ : `rendered = render(<AppProvider>{ui}</AppProvider>)`
2351
+ : autoProv?.open
2352
+ ? `rendered = render(${autoProv.open}{ui}${autoProv.close})`
2353
+ : `rendered = render(<>{ui}</>)`;
2354
+
2355
+ const callArgs = rscCallArgs(info);
2356
+ // A zero-parameter component never calls buildProps, and an unused helper
2357
+ // fails a project's lint gate (noUnusedVariables) just as a dead type alias
2358
+ // does. Emit the props plumbing only when something uses it.
2359
+ const propsBlock =
2360
+ callArgs === ""
2361
+ ? ""
2362
+ : `
2363
+ const DEFAULTS: Record<string, unknown> = ${propsLiteral};
2364
+ const PROMISE_PROPS: string[] = ${JSON.stringify(promiseProps)};
2365
+
2366
+ function buildProps(extra: Record<string, unknown> = {}): Record<string, unknown> {
2367
+ const merged: Record<string, unknown> = { ...DEFAULTS, ...extra };
2368
+ for (const k of PROMISE_PROPS) {
2369
+ if (k in merged) merged[k] = Promise.resolve(merged[k]);
2370
+ }
2371
+ return merged;
2372
+ }
2373
+ `;
2374
+ const guards = info.guards ?? [];
2375
+ const guardSummary =
2376
+ guards.length > 0 ? guards.map((g) => `${g.effect}()`).join(" / ") : "no early-exit guards";
2377
+ const staticTextTests = emitStaticTextTests(name, info, "rsc", providerImport, autoProv);
2378
+ const guardTests = emitGuardTests(name, info);
2379
+ const inversionTests = emitPredicateInversionTests(name, info, providerImport, specDir, autoProv);
2380
+
2381
+ return `import { describe, test, expect, vi } from "vitest";
2382
+ import { render, screen } from "@testing-library/react";
2383
+ import { cloneElement, isValidElement } from "react";
2384
+ import type * as React from "react";
2385
+ ${mocks.join("\n")}
2386
+ ${providerLine}${emitComponentImport(info, importPath)}
2387
+
2388
+ // ${name} is an async server component: it is invoked as a function and its
2389
+ // resolved JSX is rendered directly. Module-level data dependencies above are
2390
+ // mocked — fill in fixtures to make the assertions meaningful.
2391
+ ${propsBlock}${RSC_RESOLVE_ASYNC_UI}
2392
+ test("${name} (RSC): resolves, or takes one of its declared guard paths", async (t) => {
2393
+ // Hoisted so the assertion below stays OUTSIDE the try: an assertion
2394
+ // failure must be reported as itself, never swallowed by the guard catch.
2395
+ let rendered: ReturnType<typeof render> | undefined;
2396
+ try {
2397
+ const ui: React.ReactNode = await resolveAsyncUi(await ${name}(${callArgs}));
2398
+ ${renderExpr};
2399
+ } catch (err) {
2400
+ // notFound()/redirect() are legitimate outcomes when the mocked data layer
2401
+ // returns nothing — this component declares ${guardSummary}.
2402
+ if (/notFound\\(\\) called|NEXT_REDIRECT|NEXT_NOT_FOUND/.test(String(err))) return;
2403
+ ${uncertainEscape(info, apiRoutes)} // Not a guard and not an admitted gap: a real failure, reported as itself
2404
+ // rather than as a confusing "expected TypeError to match /notFound/".
2405
+ throw err;
2406
+ }
2407
+ expect(rendered?.container.innerHTML.length ?? 0).toBeGreaterThan(0);
2408
+ void screen;
2409
+ });
2410
+ ${staticTextTests}${guardTests}${inversionTests}`;
2411
+ }
2412
+
2413
+ /**
2414
+ * When the analyser could not infer a data dependency's return shape, it says so
2415
+ * in the mock and stands the value up as `{}`. If the component — or a child it
2416
+ * renders — then reads a field off that, the crash is the tool reporting its own
2417
+ * admitted gap, not a defect in the code under test.
2418
+ *
2419
+ * Reporting it as skipped keeps that distinction visible. Passing would claim
2420
+ * coverage that does not exist; failing would blame the application for a
2421
+ * fixture the tool never had.
2422
+ */
2423
+ function uncertainEscape(info: ComponentInfo, apiRoutes?: NextjsApiRoute[]): string {
2424
+ // A dep with an inferred return (annotation, or the emit-time OpenAPI
2425
+ // join) is coverage, not an admitted gap — its crash must report as a real
2426
+ // failure, or the inferred fixture could be wrong forever without anyone
2427
+ // seeing red.
2428
+ const uncertain =
2429
+ (info.dataDeps ?? []).some(
2430
+ (d) => (d.uncertain || d.shape.kind === "unknown") && !inferredDepReturn(d, apiRoutes),
2431
+ ) ||
2432
+ // A dotted-mock conflict falls back to {} even when every dep's shape is
2433
+ // certain — the crash on the missing member is the tool's admitted gap,
2434
+ // not the application's failure.
2435
+ dottedMockConflicts(info.dataDeps ?? []);
2436
+ if (!uncertain) return "";
2437
+ // t.skip() takes no argument in this vitest major, so the reason goes to the
2438
+ // console — losing it entirely would leave a bare skip with no explanation.
2439
+ return ` // A data mock whose shape could not be inferred (see the TODO above).
2440
+ console.warn(\`[flawless] data fixture missing, skipping: \${String(err)}\`);
2441
+ t.skip();
2442
+ `;
2443
+ }
2444
+
2445
+ /**
2446
+ * A zero-parameter component must be invoked with no arguments, and one that
2447
+ * declares a parameter must get one. `children` is tracked separately from
2448
+ * propsShape, but a layout taking `{ children }` still has a required param.
2449
+ */
2450
+ function rscCallArgs(info: ComponentInfo): string {
2451
+ const takesProps =
2452
+ Object.keys(info.propsShape).length > 0 ||
2453
+ info.unresolvedProps.length > 0 ||
2454
+ info.acceptsChildren === true;
2455
+ return takesProps ? "buildProps() as never" : "";
2456
+ }
2457
+
2458
+ /**
2459
+ * Literal copy the component renders unconditionally. `getByText` matches an
2460
+ * element's whole normalised text, which is why the analyser only keeps
2461
+ * sole-child text — anything else would assert a string that never appears.
2462
+ */
2463
+ function emitStaticTextTests(
2464
+ name: string,
2465
+ info: ComponentInfo,
2466
+ mode: "unit" | "rsc",
2467
+ providerImport?: string,
2468
+ autoProv?: AutoProvBits,
2469
+ ): string {
2470
+ const texts = info.staticText ?? [];
2471
+ if (texts.length === 0) return "";
2472
+ const callArgs = rscCallArgs(info);
2473
+ // An RSC may legitimately abort via notFound()/redirect() against empty
2474
+ // mocks. The main test asserts that outcome, so bail out here instead of
2475
+ // reporting a second failure for the same known behaviour.
2476
+ //
2477
+ // The render must carry the SAME provider wrapping as the main test: a
2478
+ // child reading a context (useBoundaryMode → BoundaryProvider) throws
2479
+ // "must be used within X" against a bare render, and the throw lands in
2480
+ // render() — outside the guard-catch — so the bare version failed for a
2481
+ // reason the main test had already solved.
2482
+ const open = `${providerImport ? "<AppProvider>" : ""}${autoProv?.open ?? ""}`;
2483
+ const close = `${autoProv?.close ?? ""}${providerImport ? "</AppProvider>" : ""}`;
2484
+ const render =
2485
+ mode === "rsc"
2486
+ ? `let ui: React.ReactNode;
2487
+ try {
2488
+ ui = await resolveAsyncUi(await ${name}(${callArgs}));
2489
+ } catch {
2490
+ return;
2491
+ }
2492
+ render(${open || "<>"}{ui}${close || "</>"});`
2493
+ : "renderTarget();";
2494
+ return `
2495
+ describe("${name} static content", () => {
2496
+ for (const t of ${JSON.stringify(texts)}) {
2497
+ test(\`renders \${JSON.stringify(t)}\`, async () => {
2498
+ ${render}
2499
+ // getAllByText, not getByText: the claim is "this text renders", and a
2500
+ // page legitimately rendering the same literal twice (an eyebrow above
2501
+ // a card title) must not fail it on multiplicity.
2502
+ expect(screen.getAllByText(t).length).toBeGreaterThan(0);
2503
+ });
2504
+ }
2505
+ });
2506
+ `;
2507
+ }
2508
+
2509
+ /**
2510
+ * A guard whose trigger value is known can be asserted directly: make the dep
2511
+ * it guards return nothing, and the component must abort the way its own
2512
+ * source says it will.
2513
+ */
2514
+ function emitGuardTests(name: string, info: ComponentInfo): string {
2515
+ const deps = info.dataDeps ?? [];
2516
+ const callArgs = rscCallArgs(info);
2517
+ const parts: string[] = [];
2518
+ for (const g of info.guards ?? []) {
2519
+ if (g.triggerValue === undefined || g.depVars.length !== 1) continue;
2520
+ const varName = g.depVars[0] as string;
2521
+ const dep = deps.find((d) => d.varName === varName);
2522
+ if (!dep?.specifier || dep.calleeRoot.length === 0) continue;
2523
+ const value =
2524
+ g.triggerValue === "empty-array" ? "[]" : g.triggerValue === "null" ? "null" : "undefined";
2525
+ // Emitted verbatim into the spec, so the escaping here is the escaping the
2526
+ // generated regex literal gets — double-escaping produced /notFound\\(\\)/,
2527
+ // which matches nothing.
2528
+ const expected = g.effect === "notFound" ? "/notFound\\(\\) called/" : "/NEXT_REDIRECT/";
2529
+ parts.push(`
2530
+ test("${name}: ${g.effect}() when ${dep.calleeRoot} returns ${value}", async () => {
2531
+ vi.mocked(${dep.calleeRoot}).mockResolvedValueOnce(${value} as never);
2532
+ await expect(${name}(${callArgs})).rejects.toThrow(${expected});
2533
+ });`);
2534
+ }
2535
+ if (parts.length === 0) return "";
2536
+ const imports = [
2537
+ ...new Set(
2538
+ (info.guards ?? [])
2539
+ .filter((g) => g.triggerValue !== undefined && g.depVars.length === 1)
2540
+ .map((g) => deps.find((d) => d.varName === g.depVars[0]))
2541
+ .filter((d): d is NonNullable<typeof d> => Boolean(d?.specifier))
2542
+ .map((d) => `import { ${d.calleeRoot} } from ${JSON.stringify(d.specifier)};`),
2543
+ ),
2544
+ ];
2545
+ // vi.mock is hoisted, so these resolve to the mocks declared above.
2546
+ return `\n${imports.join("\n")}\n${parts.join("\n")}\n`;
2547
+ }
2548
+
2549
+ /** The import names emitGuardTests already emitted, so nothing binds twice. */
2550
+ function guardTestImportNames(info: ComponentInfo): Set<string> {
2551
+ const deps = info.dataDeps ?? [];
2552
+ return new Set(
2553
+ (info.guards ?? [])
2554
+ .filter((g) => g.triggerValue !== undefined && g.depVars.length === 1)
2555
+ .map((g) => deps.find((d) => d.varName === g.depVars[0]))
2556
+ .filter((d): d is NonNullable<typeof d> => Boolean(d?.specifier))
2557
+ .map((d) => d.calleeRoot),
2558
+ );
2559
+ }
2560
+
2561
+ /**
2562
+ * B-19: the OTHER side of every certified predicate. One test per control,
2563
+ * two legs in it: the defaults leg proves each witness the inverted leg will
2564
+ * assert against actually renders (B-26's lesson — an absence over text that
2565
+ * never rendered passes vacuously), then a single Once-override flips the
2566
+ * predicate and the second mount asserts the sides that changed. No guard
2567
+ * catch on the inverted mount: the analyser certified the flip stays on the
2568
+ * render path, so an exit there is the certification being wrong — a tool
2569
+ * gap that must fail red, never pass.
2570
+ */
2571
+ function emitPredicateInversionTests(
2572
+ name: string,
2573
+ info: ComponentInfo,
2574
+ providerImport: string | undefined,
2575
+ specDir?: string,
2576
+ autoProv?: AutoProvBits,
2577
+ ): string {
2578
+ const controls = info.predicateInversions ?? [];
2579
+ const refusals = info.predicateRefusals ?? [];
2580
+ if (controls.length === 0 && refusals.length === 0) return "";
2581
+
2582
+ const callArgs = rscCallArgs(info);
2583
+ const renderOf = (uiVar: string): string => {
2584
+ if (autoProv?.open) {
2585
+ const uiExpr = `${autoProv.open}{${uiVar}}${autoProv.close}`;
2586
+ return providerImport ? `render(<AppProvider>${uiExpr}</AppProvider>)` : `render(${uiExpr})`;
2587
+ }
2588
+ return providerImport
2589
+ ? `render(<AppProvider>{${uiVar}}</AppProvider>)`
2590
+ : `render(<>{${uiVar}}</>)`;
2591
+ };
2592
+ const staticText = info.staticText ?? [];
2593
+ const mountProof = staticText[0];
2594
+ const parts: string[] = [];
2595
+ const importNames = new Map<string, string>(); // callee → import specifier
2596
+ const already = guardTestImportNames(info);
2597
+
2598
+ // The import must spell the module the way the vi.mock above did — a
2599
+ // relative specifier re-based onto the spec's own directory — or an
2600
+ // out-dir spec imports a path that does not exist there.
2601
+ const needImport = (callee: string | undefined): boolean => {
2602
+ if (!callee) return false;
2603
+ const mi = (info.moduleImports ?? []).find((m) => !m.isTypeOnly && m.names.includes(callee));
2604
+ if (!mi) return false;
2605
+ if (!already.has(callee)) importNames.set(callee, mockSpecifier(mi, specDir));
2606
+ return true;
2607
+ };
2608
+
2609
+ for (const c of controls) {
2610
+ const flippedWitnessOf = (s: PredicateSiteInfo): string | undefined =>
2611
+ s.baselineSide ? s.falseWitness : s.trueWitness;
2612
+ const baselineWitnessOf = (s: PredicateSiteInfo): string | undefined =>
2613
+ s.baselineSide ? s.trueWitness : s.falseWitness;
2614
+ // An absence claim over text the page also renders unconditionally would
2615
+ // red on correct code; those witnesses assert presence only.
2616
+ const absenceSafe = (w: string): boolean => !staticText.some((t) => t.includes(w));
2617
+
2618
+ if (c.kind === "dep-null") {
2619
+ if (!c.calleeRoot || !needImport(c.calleeRoot) || !mountProof) continue;
2620
+ const texts = staticText.slice(0, 3);
2621
+ parts.push(`
2622
+ test("${name}: ${c.varName} = null — the guard's other side renders", async () => {
2623
+ // null is a legal return of ${c.calleeRoot}'s own annotation, so the page
2624
+ // below the guard is reachable by contract — and only this flip reaches it.
2625
+ vi.mocked(${c.calleeRoot}).mockResolvedValueOnce(null as never);
2626
+ const ui: React.ReactNode = await resolveAsyncUi(await ${name}(${callArgs}));
2627
+ const view = ${renderOf("ui")};
2628
+ const text = (view.container.textContent ?? "").replace(/\\s+/g, " ");
2629
+ ${texts.map((t) => ` expect(text).toContain(${JSON.stringify(t)});`).join("\n")}
2630
+ });`);
2631
+ continue;
2632
+ }
2633
+
2634
+ // The single override that flips this predicate.
2635
+ let override = "";
2636
+ let mountExpr = `${name}(${callArgs})`;
2637
+ if (c.kind === "prop") {
2638
+ const path = c.propPath ?? [];
2639
+ const lit = c.flip === undefined ? "undefined" : JSON.stringify(c.flip);
2640
+ if (path.length === 1) {
2641
+ mountExpr = `${name}(buildProps({ ${path[0]}: ${lit} }) as never)`;
2642
+ } else if (path.length === 2) {
2643
+ const root = JSON.stringify(path[0]);
2644
+ mountExpr = `${name}(buildProps({ ${path[0]}: { ...(DEFAULTS[${root}] as Record<string, unknown>), ${path[1]}: ${lit} } }) as never)`;
2645
+ } else {
2646
+ continue;
2647
+ }
2648
+ } else {
2649
+ if (!c.calleeRoot || !needImport(c.calleeRoot)) continue;
2650
+ const lit = c.kind === "dep-empty" ? "[]" : "false";
2651
+ override = c.awaited
2652
+ ? `vi.mocked(${c.calleeRoot}).mockResolvedValueOnce(${lit} as never);`
2653
+ : `vi.mocked(${c.calleeRoot}).mockReturnValueOnce(${lit} as never);`;
2654
+ }
2655
+
2656
+ const leg1: string[] = [];
2657
+ const leg2: string[] = [];
2658
+ const counters: string[] = [];
2659
+ for (const [i, s] of c.sites.entries()) {
2660
+ const bw = baselineWitnessOf(s);
2661
+ const fw = flippedWitnessOf(s);
2662
+ if (s.reachableAtDefaults && bw !== undefined) {
2663
+ leg1.push(` expect(t0).toContain(${JSON.stringify(bw)});`);
2664
+ if (s.reachableWhenFlipped && absenceSafe(bw)) {
2665
+ leg2.push(` expect(t1).not.toContain(${JSON.stringify(bw)});`);
2666
+ }
2667
+ }
2668
+ if (s.reachableWhenFlipped && fw !== undefined) {
2669
+ leg2.push(` expect(t1).toContain(${JSON.stringify(fw)});`);
2670
+ }
2671
+ const bc = s.baselineSide ? s.trueCallee : s.falseCallee;
2672
+ const fc = s.baselineSide ? s.falseCallee : s.trueCallee;
2673
+ if (bc && needImport(bc)) {
2674
+ counters.push(` const n${i}b = vi.mocked(${bc}).mock.calls.length;`);
2675
+ leg2.push(` expect(vi.mocked(${bc}).mock.calls.length).toBe(n${i}b);`);
2676
+ }
2677
+ if (fc && needImport(fc)) {
2678
+ counters.push(` const n${i}f = vi.mocked(${fc}).mock.calls.length;`);
2679
+ leg2.push(` expect(vi.mocked(${fc}).mock.calls.length).toBeGreaterThan(n${i}f);`);
2680
+ }
2681
+ }
2682
+ if (leg2.length === 0) continue;
2683
+ if (mountProof) leg2.unshift(` expect(t1).toContain(${JSON.stringify(mountProof)});`);
2684
+
2685
+ const what =
2686
+ c.kind === "dep-empty" ? `${c.varName} = [] — the empty side` : `${c.varName} flipped`;
2687
+ const textLine =
2688
+ leg1.length > 0
2689
+ ? ` const t0 = (first.container.textContent ?? "").replace(/\\s+/g, " ");\n`
2690
+ : "";
2691
+ const legOne =
2692
+ leg1.length + counters.length === 0
2693
+ ? ""
2694
+ : ` // Leg 1 — defaults. Asserting the witnesses HERE is what keeps the
2695
+ // inverted leg's absences honest: text that never rendered cannot
2696
+ // meaningfully "go absent".
2697
+ const ui0: React.ReactNode = await resolveAsyncUi(await ${name}(${callArgs}));
2698
+ const first = ${renderOf("ui0")};
2699
+ ${textLine}${[...leg1, ...counters].join("\n")}
2700
+ first.unmount();
2701
+ `;
2702
+ parts.push(`
2703
+ test("${name}: ${what} — ${c.sites.length} branch site(s) change side", async () => {
2704
+ ${legOne} // ${legOne ? "Leg 2 — one" : "One"} override, the other side. No guard catch: the flip was
2705
+ // certified to stay on the render path, so an exit here is a tool gap
2706
+ // and must be red.
2707
+ ${override}
2708
+ const ui1: React.ReactNode = await ${mountExpr};
2709
+ const second = ${renderOf("ui1")};
2710
+ const t1 = (second.container.textContent ?? "").replace(/\\s+/g, " ");
2711
+ ${leg2.join("\n")}
2712
+ });`);
2713
+ }
2714
+
2715
+ for (const r of refusals) {
2716
+ const reason: Record<typeof r.reason, string> = {
2717
+ "multi-call": "its callee is called more than once, and a Once-override flips only the first",
2718
+ "guard-entangled": "its value decides an exit guard — that false side exits, not renders",
2719
+ invisible: "flipping it provably changes no rendered branch",
2720
+ unwitnessed: "no branch it moves carries an assertable witness",
2721
+ capped: "certified, but over this spec's inversion cap",
2722
+ };
2723
+ parts.push(`
2724
+ test.skip("${name}: ${r.varName} inversion refused — ${reason[r.reason]}", () => {});`);
2725
+ }
2726
+
2727
+ if (parts.length === 0) return "";
2728
+ const importLines = [...importNames].map(
2729
+ ([callee, spec]) => `import { ${callee} } from ${JSON.stringify(spec)};`,
2730
+ );
2731
+ // vi.mock is hoisted, so these resolve to the mocks declared above.
2732
+ return `\n${importLines.join("\n")}\n${parts.join("\n")}\n`;
2733
+ }
2734
+
2735
+ // ---------------------------------------------------------------------------
2736
+ // Interaction tests
2737
+ // ---------------------------------------------------------------------------
2738
+
2739
+ function collectPropHandlers(info: ComponentInfo): string[] {
2740
+ const out = new Set<string>();
2741
+ // Anything matching /^on[A-Z]/ that flowed through extractProps as "unresolved"
2742
+ // (callback type) or appears explicitly in interactions.
2743
+ for (const u of info.unresolvedProps) if (/^on[A-Z]/.test(u)) out.add(u);
2744
+ for (const k of Object.keys(info.propsShape)) if (/^on[A-Z]/.test(k)) out.add(k);
2745
+ const inter = info.interactions;
2746
+ if (inter) {
2747
+ for (const c of inter.clicks) {
2748
+ if (c.binding.kind === "prop") out.add(c.binding.propName);
2749
+ }
2750
+ for (const f of inter.forms) {
2751
+ if (f.submit?.kind === "prop") out.add(f.submit.propName);
2752
+ }
2753
+ }
2754
+ return [...out];
2755
+ }
2756
+
2757
+ function emitInteractionTests(
2758
+ name: string,
2759
+ info: NonNullable<ComponentInfo["interactions"]>,
2760
+ allHandlers: string[],
2761
+ trigger: string | undefined,
2762
+ claimCtx: { info: ComponentInfo; ctx: PostDispatchContext },
2763
+ renderGuarded = false,
2764
+ todo?: TodoRecorder,
2765
+ ): string {
2766
+ // `if (!user) return null` with an unresolvable `user` means the component may
2767
+ // render nothing at all at the props a spec can synthesise. The JSX below the
2768
+ // guard is still the best description of the component, but driving it needs a
2769
+ // fixture the tool cannot build — so the interaction tests say that instead of
2770
+ // failing on an element that was never going to mount.
2771
+ if (renderGuarded) {
2772
+ todo?.("other");
2773
+ return `
2774
+ test.skip("${name}: interactions need a fixture — an early return may render nothing", () => {
2775
+ // TODO(flawless): give the prop its guard tests (see the component's first
2776
+ // \`if (!…) return\`) a real value in DEFAULTS, and these become live tests.
2777
+ });
2778
+ `;
2779
+ }
2780
+ const firstClickIdx = info.clicks.findIndex((c) => c.event === "onClick");
2781
+ const parts: string[] = [];
2782
+ for (let i = 0; i < info.clicks.length; i++) {
2783
+ const click = info.clicks[i];
2784
+ if (!click) continue;
2785
+ // Later interactions often live behind UI state the first click opens.
2786
+ const prelude = i !== firstClickIdx && trigger ? trigger : undefined;
2787
+ parts.push(emitClickTest(name, click, allHandlers, i, claimCtx, prelude, todo));
2788
+ }
2789
+ for (let i = 0; i < info.forms.length; i++) {
2790
+ const form = info.forms[i];
2791
+ if (!form) continue;
2792
+ parts.push(
2793
+ emitFormTest(
2794
+ name,
2795
+ form,
2796
+ allHandlers,
2797
+ i,
2798
+ claimCtx.ctx.mockedBindings,
2799
+ claimCtx.info.acceptsChildren === true,
2800
+ todo,
2801
+ ),
2802
+ );
2803
+ }
2804
+ if (parts.length === 0) {
2805
+ parts.push(`// no interactions detected — render-only smoke is above.`);
2806
+ }
2807
+ return parts.join("\n\n");
2808
+ }
2809
+
2810
+ function emitClickTest(
2811
+ name: string,
2812
+ click: ClickHandler,
2813
+ allHandlers: string[],
2814
+ idx: number,
2815
+ /** The component and the spec's mocking surface, for decidePostDispatch. */
2816
+ claimCtx: { info: ComponentInfo; ctx: PostDispatchContext },
2817
+ prelude?: string,
2818
+ todo?: TodoRecorder,
2819
+ ): string {
2820
+ const labelDesc = describeClickLabel(click);
2821
+ const selector = emitVitestSelector(click);
2822
+ if (!selector) {
2823
+ // No test id, no aria-label, no role, no id, no literal copy, and either a
2824
+ // component tag or a host tag with siblings of its kind — nothing in the
2825
+ // source identifies this element. Say which; the previous fallback queried
2826
+ // the component NAME as a CSS selector, matched nothing, clicked null and
2827
+ // passed.
2828
+ todo?.("unaddressable-target");
2829
+ return `
2830
+ test.skip("${name}: ${click.event} on ${labelDesc} #${idx} — ${describeUnlocatable(click)}", () => {
2831
+ // TODO(flawless): give <${click.tag}> a data-testid, an aria-label or literal
2832
+ // text, and this becomes a real interaction test.
2833
+ });
2834
+ `;
2835
+ }
2836
+ // A menu item, a dialog body: the element does not exist until its trigger is
2837
+ // clicked, so the spec opens it first and then queries with findBy*, which
2838
+ // retries. getBy* asserts against the DOM as it stands the instant the click
2839
+ // returns — before the content has mounted.
2840
+ // The "children" handle clicks CHILD_TEXT, which only exists in a spec whose
2841
+ // component accepts children — without that, the trigger never rendered.
2842
+ const gateSelector =
2843
+ click.gatedBy && !(click.gatedBy.kind === "children" && !claimCtx.info.acceptsChildren)
2844
+ ? emitTriggerSelector(click.gatedBy)
2845
+ : undefined;
2846
+ const dispatch = emitVitestDispatch(
2847
+ click.event,
2848
+ gateSelector ? awaitSelector(selector) : selector,
2849
+ { tag: click.tag },
2850
+ );
2851
+ if (!dispatch) {
2852
+ // A change/input handler on something that holds no text — React's onChange
2853
+ // bubbles, so `<form onChange>` is idiomatic delegation. Typing into the form
2854
+ // element itself is not a thing; its fields are covered by the form test.
2855
+ return `
2856
+ test.skip("${name}: ${click.event} on ${labelDesc} #${idx} — <${click.tag}> holds no value to change", () => {
2857
+ // The handler is delegation from a field inside it; the form test drives those.
2858
+ });
2859
+ `;
2860
+ }
2861
+ const gateLines = gateSelector ? `\n await userEvent.click(${gateSelector});` : "";
2862
+ // The prelude opens UI that later interactions live behind — but it is itself
2863
+ // a click, so it can fire a handler this test then asserts was NOT called.
2864
+ // Reset the spies after it so the isolation assertions stay meaningful.
2865
+ const preludeLines = gateSelector
2866
+ ? `${gateLines}\n for (const v of Object.values(handlers)) v.mockClear();`
2867
+ : prelude
2868
+ ? `\n ${prelude}\n for (const v of Object.values(handlers)) v.mockClear();`
2869
+ : "";
2870
+ if (click.binding.kind === "prop") {
2871
+ const propName = click.binding.propName;
2872
+ const others = allHandlers.filter((h) => h !== propName);
2873
+ const isolationAsserts = others
2874
+ .map((h) => ` expect(handlers.${h}).not.toHaveBeenCalled();`)
2875
+ .join("\n");
2876
+ return `test("${name} fires ${propName} when ${labelDesc} (${click.event})", async () => {
2877
+ const { container } = renderTarget();
2878
+ void container;${preludeLines}
2879
+ ${dispatch};
2880
+ expect(handlers.${propName}).toHaveBeenCalled();
2881
+ ${isolationAsserts || " // no other handlers to isolate"}
2882
+ });`;
2883
+ }
2884
+ // local / inline — smoke: no thrown error, no prop handler accidentally called.
2885
+ const isolationAsserts = allHandlers
2886
+ .map((h) => ` expect(handlers.${h}).not.toHaveBeenCalled();`)
2887
+ .join("\n");
2888
+ // What this click provably does — a module the spec mocks, or the router — or
2889
+ // an admission that it does nothing observable. The old floor here was
2890
+ // `expect(container.firstElementChild).not.toBeNull()`, which under a provider
2891
+ // is answered by the PROVIDER's node and so cannot fail, and without one goes
2892
+ // red on a component that legitimately unmounts. See postDispatch.ts.
2893
+ const claim = decidePostDispatch(claimCtx.info, click, claimCtx.ctx);
2894
+ if (claim.kind === "skip") {
2895
+ todo?.("other");
2896
+ return `test.skip("${name} ${click.event} on ${labelDesc} #${idx} — ${titleSafe(describeNoClaim(claim.reason))}", () => {
2897
+ // TODO(flawless): ${describeNoClaim(claim.reason)}, so any assertion after the
2898
+ // dispatch would be about something this spec never caused. Supply props that
2899
+ // make it reachable and this becomes a real interaction test.
2900
+ });`;
2901
+ }
2902
+ // The isolation asserts are a real claim and are APPENDED to whichever rung
2903
+ // fires — they never replace one.
2904
+ const isolation = isolationAsserts ? `\n${isolationAsserts}` : "";
2905
+ if (claim.kind === "module-spy") {
2906
+ return `test("${name} ${click.event} on ${labelDesc} #${idx} → ${claim.binding} called", async () => {
2907
+ const { container } = renderTarget();
2908
+ void container;${preludeLines}
2909
+ // The mount path may already have called this module and nothing clears module
2910
+ // mocks between tests, so without this the assertion is about the file rather
2911
+ // than about this click.
2912
+ vi.mocked(${claim.binding}).mockClear();
2913
+ ${dispatch};
2914
+ await waitFor(() => expect(vi.mocked(${claim.binding})).toHaveBeenCalled());${isolation}
2915
+ });`;
2916
+ }
2917
+ if (claim.kind === "router") {
2918
+ // toHaveBeenCalledWith(target) when the source names a literal, and a bare
2919
+ // toHaveBeenCalled() when it does not — asserting an argument the source
2920
+ // never states would be inventing one.
2921
+ const matcher =
2922
+ claim.target === undefined
2923
+ ? "toHaveBeenCalled()"
2924
+ : `toHaveBeenCalledWith(${JSON.stringify(claim.target)})`;
2925
+ // Single quotes in the TITLE: it is interpolated into a double-quoted test
2926
+ // name, and a JSON.stringify'd path ends it.
2927
+ const shown = claim.target === undefined ? "" : `'${titleSafe(claim.target)}'`;
2928
+ return `test("${name} ${click.event} on ${labelDesc} #${idx} → router.${claim.method}(${shown})", async () => {
2929
+ const { container } = renderTarget();
2930
+ void container;${preludeLines}
2931
+ routerMock.${claim.method}.mockClear();
2932
+ ${dispatch};
2933
+ await waitFor(() => expect(routerMock.${claim.method}).${matcher});${isolation}
2934
+ });`;
2935
+ }
2936
+ // Canary: drive it, and assert only that nothing escaped — which the old floor
2937
+ // did NOT do. Executed: a throwing handler leaves today's vitest test green and
2938
+ // fails only the run, so this is strictly more than the floor it replaces.
2939
+ // prop-spy is handled by the branch above, which runs before decide is called.
2940
+ const reason = claim.kind === "canary" ? claim.reason : "opaque-callback";
2941
+ todo?.("other");
2942
+ return `test("${name} ${click.event} on ${labelDesc} #${idx} — drives the ${click.event}; asserts only that nothing escaped it", async () => {
2943
+ // TODO(flawless): ${describeNoClaim(reason)}. Nothing in the source says
2944
+ // what this ${click.event} should DO, and "the component still rendered" would
2945
+ // pass whatever the handler did. It becomes a real test as soon as the callback
2946
+ // is a prop (the spec passes a spy), or the work goes through a module this
2947
+ // spec mocks.
2948
+ const escaped: string[] = [];
2949
+ // Bubble phase only: a failed <img> load also fires \`error\` on the element and
2950
+ // must not be read as the handler throwing.
2951
+ const onError = (e: ErrorEvent) => { escaped.push(e.message); e.preventDefault(); };
2952
+ window.addEventListener("error", onError);
2953
+ try {
2954
+ const { container } = renderTarget();
2955
+ void container;${preludeLines}
2956
+ ${dispatch};
2957
+ } finally {
2958
+ window.removeEventListener("error", onError);
2959
+ }
2960
+ expect(escaped, "the ${click.event} threw out of its handler").toEqual([]);${isolation}
2961
+ });`;
2962
+ }
2963
+
2964
+ function describeClickLabel(c: ClickHandler): string {
2965
+ // Used inside double-quoted test names AND template-literal titles: a double
2966
+ // quote would end the former, a backtick or ${ the latter — titleSafe defuses
2967
+ // both (locators are untouched; they JSON.stringify the raw label).
2968
+ const safe = (s: string): string => titleSafe(s.replace(/"/g, "'"));
2969
+ if (c.label.kind === "text") return `'${safe(c.label.value)}'`;
2970
+ if (c.label.kind === "prop") return `the {${c.label.propName}} ${c.tag}`;
2971
+ if (c.label.kind === "aria") return `[aria-label='${safe(c.label.value)}']`;
2972
+ if (c.label.kind === "testid") return `[data-testid='${safe(c.label.value)}']`;
2973
+ return `the ${c.tag}`;
2974
+ }
2975
+
2976
+ function emitFormTest(
2977
+ name: string,
2978
+ form: FormInfo,
2979
+ allHandlers: string[],
2980
+ idx: number,
2981
+ /** Bindings this spec mocks, so an action claim is only made when observable. */
2982
+ mockedBindings: Set<string>,
2983
+ /** Whether the spec renders CHILD_TEXT — the only handle a children-trigger has. */
2984
+ acceptsChildren: boolean,
2985
+ todo?: TodoRecorder,
2986
+ ): string {
2987
+ const fillSteps: string[] = [];
2988
+ const valueAsserts: string[] = [];
2989
+ for (const field of form.fields) {
2990
+ const step = emitVitestFill(field);
2991
+ if (!step) continue;
2992
+ fillSteps.push(` ${step}`);
2993
+ const assertion = emitVitestFillAssertion(field);
2994
+ if (assertion) valueAsserts.push(` ${assertion}`);
2995
+ }
2996
+ // The submit control is read out of the markup — a <button> whose type says it
2997
+ // submits. When the source has none, there is no submit path to drive; say so
2998
+ // rather than hunting for a button whose wording matches a list of verbs.
2999
+ const submitTarget = emitVitestSubmit(form);
3000
+ if (!submitTarget) {
3001
+ todo?.("other");
3002
+ return `test.skip("${name} form #${idx} — no submit control in the markup", () => {
3003
+ // TODO(flawless): the form declares no <button type="submit">, so nothing here
3004
+ // identifies how it is submitted. Add one (or an onSubmit handler on a named
3005
+ // control) and this becomes a real submit test.
3006
+ });`;
3007
+ }
3008
+ if (form.submitControl?.disabled) {
3009
+ todo?.("other");
3010
+ return `test.skip("${name} form #${idx} — its submit control is disabled at these props", () => {
3011
+ // TODO(flawless): the button's \`disabled\` expression is true at DEFAULTS.
3012
+ // Supply props that enable it and this becomes a real submit test.
3013
+ });`;
3014
+ }
3015
+ const submitClick = `await userEvent.click(${submitTarget});`;
3016
+ // A form inside a closed dialog is not in the DOM until the trigger opens
3017
+ // it — every field query below would run against a tree that never
3018
+ // mounted (RenameModal). Same gate the click tests click.
3019
+ const formGate =
3020
+ form.gatedBy && (form.gatedBy.kind !== "children" || acceptsChildren)
3021
+ ? emitTriggerSelector(form.gatedBy)
3022
+ : undefined;
3023
+ const openStep = formGate ? ` await userEvent.click(${formGate});\n` : "";
3024
+ if (form.submit?.kind === "prop") {
3025
+ const propName = form.submit.propName;
3026
+ const others = allHandlers.filter((h) => h !== propName);
3027
+ const isolation = others
3028
+ .map((h) => ` expect(handlers.${h}).not.toHaveBeenCalled();`)
3029
+ .join("\n");
3030
+ return `test("${name} submits form #${idx} → ${propName} called once", async () => {
3031
+ const { container } = renderTarget();
3032
+ void container;
3033
+ ${openStep}${fillSteps.join("\n")}
3034
+ ${valueAsserts.join("\n") || " // no text field whose value could be read back"}
3035
+ ${submitClick}
3036
+ expect(handlers.${propName}).toHaveBeenCalled();
3037
+ ${isolation || " // no other handlers to isolate"}
3038
+ });`;
3039
+ }
3040
+ // No prop handler on the form. The floor here was
3041
+ // `expect(container.firstElementChild).not.toBeNull()` — and unlike the smoke
3042
+ // click it had no isolation fallback at all, so it fired even when prop spies
3043
+ // existed and something precise WAS available. Under a provider it cannot
3044
+ // fail; without one it reddens a form that navigates away on submit.
3045
+ //
3046
+ // The isolation asserts are a real claim and are used where there are any. The
3047
+ // value assertions above already carry the test on their own, which is how 13
3048
+ // of the dogfood app's 23 forms keep a meaningful test with no floor at all.
3049
+ // The one post-dispatch claim this app's own shapes support. `<form
3050
+ // action={action}>` where `const [state, action] = useActionState(loginAction,
3051
+ // …)` — React invokes loginAction on submit with NO guard in between, unlike
3052
+ // every click handler here, which puts its action behind a guard and inside a
3053
+ // startTransition callback. Verified end to end before it was emitted: green on
3054
+ // the real component, red once the form's action is disconnected.
3055
+ const action = form.actionBinding;
3056
+ if (action !== undefined && mockedBindings.has(action)) {
3057
+ return `test("${name} submits form #${idx} → ${action} called", async () => {
3058
+ const { container } = renderTarget();
3059
+ void container;
3060
+ ${openStep}${fillSteps.join("\n")}
3061
+ ${valueAsserts.join("\n") || " // no text field whose value could be read back"}
3062
+ // Nothing clears module mocks between tests and a mount effect may already have
3063
+ // called this one, so without the clear the assertion is about the file.
3064
+ vi.mocked(${action}).mockClear();
3065
+ // fireEvent.submit, NOT a click on the submit button.
3066
+ //
3067
+ // \`action={…}\` means "this runs when the form submits", and that is the claim.
3068
+ // Clicking the button additionally requires every REQUIRED field to be filled —
3069
+ // and this app's fields are <Input> component tags, which collectFields cannot
3070
+ // see, so the emitter fills nothing and the browser's constraint validation
3071
+ // silently blocks the submit. Executed: the click form of this test failed on a
3072
+ // completely correct LoginForm.
3073
+ //
3074
+ // Whether the BUTTON submits is a separate claim, and the submit-control
3075
+ // detection above is what covers it.
3076
+ fireEvent.submit(container.querySelectorAll("form")[${idx}] as HTMLFormElement);
3077
+ await waitFor(() => expect(vi.mocked(${action})).toHaveBeenCalled());
3078
+ ${allHandlers.map((h) => ` expect(handlers.${h}).not.toHaveBeenCalled();`).join("\n") || " // no other handlers to isolate"}
3079
+ });`;
3080
+ }
3081
+ const submitAfter =
3082
+ allHandlers.length > 0
3083
+ ? allHandlers.map((h) => ` expect(handlers.${h}).not.toHaveBeenCalled();`).join("\n")
3084
+ : valueAsserts.length > 0
3085
+ ? " // the value assertions above are the claim; the submit itself asserts nothing\n // this spec can observe — see postDispatch.ts for the ladder that decides."
3086
+ : " // TODO(flawless): nothing observable follows this submit — no prop handler,\n // no field whose value can be read back, and no module this spec mocks. Give\n // the form an onSubmit prop or route it through a mocked action to make it real.";
3087
+ return `test("${name} form #${idx} accepts input and submits", async () => {
3088
+ const { container } = renderTarget();
3089
+ void container;
3090
+ ${openStep}${fillSteps.join("\n")}
3091
+ ${valueAsserts.join("\n") || " // no text field whose value could be read back"}
3092
+ ${submitClick}
3093
+ ${submitAfter}
3094
+ });`;
3095
+ }
3096
+
3097
+ function emitFallbackHandlerTests(
3098
+ name: string,
3099
+ propsShape: PropsShape,
3100
+ unresolvedProps: string[],
3101
+ ): string {
3102
+ const handlers = [
3103
+ ...Object.keys(propsShape).filter((k) => /^on[A-Z]/.test(k)),
3104
+ ...unresolvedProps.filter((k) => /^on[A-Z]/.test(k)),
3105
+ ];
3106
+ if (handlers.length === 0) return `// no interactions and no handler props detected.`;
3107
+ return handlers
3108
+ .map(
3109
+ (p) => `
3110
+ test("${name} invokes ${p} when first button clicked (fallback)", async () => {
3111
+ const { container } = renderTarget();
3112
+ const button = container.querySelector("button");
3113
+ if (button) {
3114
+ await userEvent.click(button);
3115
+ expect(handlers.${p}).toHaveBeenCalled();
3116
+ }
3117
+ });`,
3118
+ )
3119
+ .join("\n");
3120
+ }
3121
+
3122
+ // ---------------------------------------------------------------------------
3123
+ // Browser specs
3124
+ // ---------------------------------------------------------------------------
3125
+
3126
+ function browserSpec(c: FlatComponent, specName: string, todo?: TodoRecorder): string {
3127
+ // The registry key, not the component name: two components called Card live
3128
+ // under different keys in the perf host, and mounting by the bare name drove
3129
+ // the wrong one.
3130
+ const name = specName;
3131
+ // synthesiseDefaults, not the raw propsShape. The perf scaffolder has always
3132
+ // called it; the browser one passed `{}` for a component whose props are only
3133
+ // known through their member reads, and ProfileForm — green in its profile
3134
+ // spec — died in its browser spec with "did not render within 5000ms".
3135
+ const propsLiteral = defaultsJson(synthesiseDefaults(c.info));
3136
+ const interactions = c.info.interactions;
3137
+
3138
+ // An undecidable guard — `if (!user) return null` where `user` comes from a
3139
+ // store hook — means the component may legitimately render nothing here. Its
3140
+ // interactions were harvested from the tail return, so every one of them has
3141
+ // to check that it is on screen before driving it. UserMenu is this shape:
3142
+ // the host has no session, the component returns null, and the spec waited
3143
+ // 30 seconds for a trigger that was never going to exist.
3144
+ const guarded = c.info.renderGuarded === true;
3145
+ // No blind fallback. `button:not([disabled])` names whichever enabled button
3146
+ // comes first, not one the analysis found a handler on — in a form component
3147
+ // that is the submit control, and clicking it runs an action. The reason
3148
+ // wording comes from the same helper the profile suite skips with, and it
3149
+ // goes in the title because the skip is decided here, at scaffold time.
3150
+ let interactionTests: string;
3151
+ if (interactions) {
3152
+ interactionTests = emitBrowserInteractionTests(interactions, guarded, todo);
3153
+ } else {
3154
+ todo?.("other");
3155
+ interactionTests = `
3156
+ test.skip(\`\${NAME}: click — ${titleSafe(emitPlaywrightClickTarget(interactions).reason ?? "no clickable target could be derived")}\`, () => {
3157
+ // TODO(flawless): the mount smoke above is the only claim the source
3158
+ // supports. If this component is interactive, name the control — a
3159
+ // data-testid, an aria-label or literal text — and regenerate.
3160
+ });`;
3161
+ }
3162
+
3163
+ // An error boundary logs what it caught, by design. Failing it for that is the
3164
+ // harness marking a component wrong for meeting its contract — so the console
3165
+ // assertion goes and the reason is written into the spec. The uncaught-error
3166
+ // and rendered-output assertions stay: those are still real claims.
3167
+ const consoleAssertion = c.info.logsErrors
3168
+ ? ` // ${name} calls ${c.info.logsErrors} itself, so "no console errors" is not a
3169
+ // claim its source supports. Uncaught exceptions are still asserted below.
3170
+ expect(errors.filter((e) => !e.startsWith("[console]"))).toEqual([]);`
3171
+ : ` expect(errors).toEqual([]);`;
3172
+
3173
+ return `import { test, expect, type Page } from "@playwright/test";
3174
+
3175
+ const NAME = ${JSON.stringify(name)};
3176
+ const DEFAULTS = ${propsLiteral} as Record<string, unknown>;
3177
+
3178
+ async function mount(page: Page): Promise<void> {
3179
+ await page.evaluate(
3180
+ ([n, p]) => window.__profile.mount(n as string, p as Record<string, unknown>),
3181
+ [NAME, DEFAULTS],
3182
+ );
3183
+ }
3184
+
3185
+ test.beforeEach(async ({ page }) => {
3186
+ await page.goto("/");
3187
+ await page.waitForFunction(() => typeof window.__profile?.mount === "function");
3188
+ });
3189
+
3190
+ test(\`\${NAME}: mounts in the browser without console errors\`, async ({ page }) => {
3191
+ const errors: string[] = [];
3192
+ page.on("console", (msg) => {
3193
+ if (msg.type() === "error") errors.push("[console] " + msg.text());
3194
+ });
3195
+ page.on("pageerror", (err) => errors.push(err.message));
3196
+ await mount(page);
3197
+ await page.waitForTimeout(100);
3198
+ ${consoleAssertion}
3199
+ ${guardedMountAssertion(guarded, c.info)}
3200
+ });
3201
+ ${interactionTests}
3202
+ `;
3203
+ }
3204
+
3205
+ function emitBrowserInteractionTests(
3206
+ info: NonNullable<ComponentInfo["interactions"]>,
3207
+ guarded = false,
3208
+ todo?: TodoRecorder,
3209
+ ): string {
3210
+ const parts: string[] = [];
3211
+ /** For a guarded component, nothing on screen is a legitimate outcome. */
3212
+ const presenceCheck = (locator: string): string =>
3213
+ guarded
3214
+ ? `
3215
+ if ((await ${locator}.count()) === 0) {
3216
+ test.skip(true, "an early return the analyser could not decide left nothing to drive");
3217
+ }`
3218
+ : "";
3219
+ for (let i = 0; i < info.clicks.length; i++) {
3220
+ const click = info.clicks[i];
3221
+ if (!click) continue;
3222
+ const labelDesc = describeClickLabel(click);
3223
+ const rawSelector = emitPlaywrightSelector(click);
3224
+ if (!rawSelector) {
3225
+ // Saying "no interactions detected" here would be false — one was
3226
+ // detected and could not be addressed, which is a different fact and the
3227
+ // one worth reporting.
3228
+ const why = titleSafe(describeUnlocatable(click));
3229
+ todo?.("unaddressable-target");
3230
+ parts.push(`
3231
+ test.skip(\`\${NAME}: ${click.event} on ${labelDesc} #${i} — ${why}\`, () => {
3232
+ // TODO(flawless): give it a data-testid, an aria-label or literal text.
3233
+ });`);
3234
+ continue;
3235
+ }
3236
+ // .first() on every target. A control inside .map() resolves to one locator
3237
+ // per row, and Playwright's strict mode makes that an error rather than a
3238
+ // choice — "getByText('Remove') resolved to 2 elements" is the list working.
3239
+ const selector = firstOf(rawSelector);
3240
+
3241
+ // Disabled at the props this spec mounts with. Clicking it would wait for it
3242
+ // to become enabled until the test times out; that it is disabled is the
3243
+ // thing the source actually says, so assert that instead.
3244
+ if (click.hints.disabled === "always") {
3245
+ parts.push(`
3246
+ test(\`\${NAME}: ${labelDesc} #${i} is disabled at these props\`, async ({ page }) => {
3247
+ await mount(page);${presenceCheck(selector)}
3248
+ await expect(${selector}).toBeDisabled();
3249
+ });`);
3250
+ continue;
3251
+ }
3252
+
3253
+ const dispatch = emitPlaywrightDispatch(click.event, selector, { tag: click.tag });
3254
+ if (!dispatch) {
3255
+ parts.push(`
3256
+ test.skip(\`\${NAME}: ${click.event} on ${labelDesc} #${i} — <${click.tag}> holds no value to change\`, () => {
3257
+ // React's change events bubble, so this is delegation from a field inside it.
3258
+ // The form test drives those fields directly.
3259
+ });`);
3260
+ continue;
3261
+ }
3262
+
3263
+ // A menu item inside a portal does not exist until its trigger opens it.
3264
+ // The unit emitter learned to click the trigger first; this one did not, and
3265
+ // waited 30s for a menu that was never going to appear.
3266
+ const trigger = click.gatedBy ? emitPlaywrightTrigger(click.gatedBy) : undefined;
3267
+ const openLines = trigger
3268
+ ? `${presenceCheck(firstOf(trigger))}\n await ${firstOf(trigger)}.click();`
3269
+ : presenceCheck(selector);
3270
+ // The source guards this control with an expression that did not reduce —
3271
+ // `disabled={pending || !picked}` where `picked` starts from an unresolved
3272
+ // prop. Playwright's actionability wait runs to the test timeout before
3273
+ // reporting it, so ask first and skip with the reason instead.
3274
+ const disabledGuard =
3275
+ click.hints.disabled === "conditional"
3276
+ ? `
3277
+ if (await ${selector}.isDisabled()) {
3278
+ test.skip(true, "disabled at these props; the source's guard could not be decided statically");
3279
+ }`
3280
+ : "";
3281
+ parts.push(`
3282
+ test(\`\${NAME}: ${click.event} on ${labelDesc} #${i} fires without error\`, async ({ page }) => {
3283
+ const errors: string[] = [];
3284
+ page.on("pageerror", (err) => errors.push(err.message));
3285
+ await mount(page);${openLines}${disabledGuard}
3286
+ ${dispatch};
3287
+ await page.waitForTimeout(50);
3288
+ expect(errors).toEqual([]);
3289
+ });`);
3290
+ }
3291
+ for (let i = 0; i < info.forms.length; i++) {
3292
+ const form = info.forms[i];
3293
+ if (!form) continue;
3294
+ const fillSteps: string[] = [];
3295
+ for (const field of form.fields) {
3296
+ const step = emitPlaywrightFill(field);
3297
+ if (step) fillSteps.push(` ${step}`);
3298
+ }
3299
+ const submitTarget = emitPlaywrightSubmit(form);
3300
+ if (!submitTarget) {
3301
+ todo?.("other");
3302
+ parts.push(`
3303
+ test.skip(\`\${NAME}: form #${i} — no submit control in the markup\`, () => {
3304
+ // TODO(flawless): nothing in the form's JSX declares how it submits. The old
3305
+ // emitter guessed at button copy (/submit|save|send|.../) and waited out the
3306
+ // 30s timeout whenever the wording differed.
3307
+ });`);
3308
+ continue;
3309
+ }
3310
+ // There IS a submit control and it is disabled at these props — every one of
3311
+ // them, or findSubmitControl would have preferred an enabled one. Saying
3312
+ // "no submit control" here would send someone looking for a button that is
3313
+ // plainly in the file.
3314
+ if (form.submitControl?.disabled) {
3315
+ todo?.("other");
3316
+ parts.push(`
3317
+ test.skip(\`\${NAME}: form #${i} — its submit control is disabled at these props\`, () => {
3318
+ // TODO(flawless): the button's \`disabled\` expression is true at the props
3319
+ // DEFAULTS passes. Supply props (or drive the UI) that enable it, and this
3320
+ // becomes a real submit test.
3321
+ });`);
3322
+ continue;
3323
+ }
3324
+ // The same guard the click tests carry. A submit button is very often
3325
+ // `disabled={pending || nothingChosen}`, and when the expression does not
3326
+ // reduce the only way to find out is to look.
3327
+ parts.push(`
3328
+ test(\`\${NAME}: form #${i} fills + submits without error\`, async ({ page }) => {
3329
+ const errors: string[] = [];
3330
+ page.on("pageerror", (err) => errors.push(err.message));
3331
+ await mount(page);${presenceCheck(`page.locator("form").first()`)}
3332
+ ${fillSteps.join("\n") || " // no field carried a type a blind fill could supply"}
3333
+ const submit = ${submitTarget};
3334
+ if (await submit.isDisabled()) {
3335
+ test.skip(true, "the submit control is disabled at these props — nothing here can enable it");
3336
+ }
3337
+ await submit.click();
3338
+ await page.waitForTimeout(50);
3339
+ expect(errors).toEqual([]);
3340
+ });`);
3341
+ }
3342
+ if (parts.length === 0) {
3343
+ parts.push(`
3344
+ test(\`\${NAME}: no interactions detected — mount-only smoke above is sufficient\`, () => {});`);
3345
+ }
3346
+ return parts.join("\n");
3347
+ }
3348
+
3349
+ /**
3350
+ * What a mount test may claim about the markup.
3351
+ *
3352
+ * An unguarded component must render something. A guarded one may legitimately
3353
+ * render nothing — `if (!user) return null` behind a store hook — so "it
3354
+ * produced markup" is not a claim its source supports. Dropping the assertion
3355
+ * entirely was too much, though: when the guard DOES pass, the component's
3356
+ * literal copy has to be there, and that is still checkable. The conditional is
3357
+ * the honest shape.
3358
+ *
3359
+ * The third case is delegation. A component whose whole output is a primitive
3360
+ * from an open-state library (Radix's `Root`, Ark's, Headless UI's) mounts to an
3361
+ * EMPTY container at rest — the library decides whether anything renders until
3362
+ * something opens it. invoify's Toast failed the floor there while its mount was
3363
+ * clean, and its five sibling exports passed it, so no static rule separates
3364
+ * them: the stand-down is taken at RUNTIME, only when the container really is
3365
+ * empty, and only for a component with no literal copy of its own (a component
3366
+ * that owns copy and renders nothing IS broken, and still goes red).
3367
+ */
3368
+ function guardedMountAssertion(guarded: boolean, info: ComponentInfo): string {
3369
+ if (!guarded) {
3370
+ const delegated = delegatedRenderModules(info);
3371
+ if (delegated.length === 0 || (info.staticText ?? []).some((t) => t.trim().length > 0)) {
3372
+ return ` const rootHtml = await page.locator("#root").innerHTML();
3373
+ expect(rootHtml.length).toBeGreaterThan(0);`;
3374
+ }
3375
+ return ` const rootHtml = await page.locator("#root").innerHTML();
3376
+ if (rootHtml.trim().length === 0) {
3377
+ // An empty container is not evidence about this component: its output is
3378
+ // ${delegated.join(", ")}, whose primitives render nothing until they are
3379
+ // opened. What is asserted here is the mount — no console error above, and
3380
+ // mount() itself resolved, which means the host saw a committed render of
3381
+ // THIS component by name. Markup stays a claim the source cannot make.
3382
+ test.skip(true, "renders nothing into the container at these props — ${delegated.join(", ")} is closed at rest");
3383
+ }
3384
+ expect(rootHtml.length).toBeGreaterThan(0);`;
3385
+ }
3386
+ // The longest literal is the most identifying thing the source offers.
3387
+ const witness = [...(info.staticText ?? [])]
3388
+ .filter((t) => t.trim().length >= 3)
3389
+ .sort((a, b) => b.length - a.length)[0];
3390
+ if (!witness) {
3391
+ return ` // No markup assertion: this component's first statement is a guard the
3392
+ // analyser could not decide, and it renders no literal copy that a passing
3393
+ // guard would put on the page. That the mount produced no errors is the only
3394
+ // claim the source supports.`;
3395
+ }
3396
+ return ` // An undecidable guard may legitimately render nothing here. When it does
3397
+ // render, this copy is unconditional in that branch — so the claim is
3398
+ // conditional, not absent.
3399
+ const rootHtml = await page.locator("#root").innerHTML();
3400
+ if (rootHtml.trim().length > 0) {
3401
+ expect(await page.getByText(${JSON.stringify(witness)}).count()).toBeGreaterThan(0);
3402
+ }`;
3403
+ }
3404
+
3405
+ /**
3406
+ * Radix primitives whose `Root` renders NOTHING until something opens them —
3407
+ * the library's documented contract, and invoify's Toast: a clean, error-free
3408
+ * mount into an empty container while its five sibling exports in the same file
3409
+ * rendered fine. Deliberately NOT `@radix-ui/` wholesale: Slot, Label, Switch,
3410
+ * Avatar and the rest render eagerly, and a component made of those which came
3411
+ * back empty WOULD be broken. Headless UI and Ark UI are left out for the same
3412
+ * reason — no corpus evidence, and their buttons and triggers render eagerly too.
3413
+ */
3414
+ const LIBRARY_DECIDED_RENDER_PREFIXES = [
3415
+ "@radix-ui/react-toast",
3416
+ "@radix-ui/react-dialog",
3417
+ "@radix-ui/react-alert-dialog",
3418
+ "@radix-ui/react-popover",
3419
+ "@radix-ui/react-tooltip",
3420
+ "@radix-ui/react-hover-card",
3421
+ "@radix-ui/react-dropdown-menu",
3422
+ "@radix-ui/react-context-menu",
3423
+ "@radix-ui/react-menubar",
3424
+ "@radix-ui/react-navigation-menu",
3425
+ "@radix-ui/react-menu",
3426
+ // Injected through Next's router, outside the mounted container, so an empty
3427
+ // container is the expected shape of a component built only out of them.
3428
+ "next/script",
3429
+ "next/head",
3430
+ ];
3431
+
3432
+ /**
3433
+ * Which of those this component's file imports, for the mount-test reason. The
3434
+ * import is the only evidence available here — the sibling that renders eagerly
3435
+ * is separated from the closed-at-rest one at RUNTIME, not by this list.
3436
+ */
3437
+ function delegatedRenderModules(info: ComponentInfo): string[] {
3438
+ const seen = new Set<string>();
3439
+ for (const m of info.moduleImports ?? []) {
3440
+ if (LIBRARY_DECIDED_RENDER_PREFIXES.some((p) => m.specifier.startsWith(p)))
3441
+ seen.add(m.specifier);
3442
+ }
3443
+ return [...seen].sort();
3444
+ }
3445
+
3446
+ /** The Playwright form of a disclosure trigger, so a browser spec can open it. */
3447
+ function emitPlaywrightTrigger(label: LabelSource): string | undefined {
3448
+ switch (label.kind) {
3449
+ case "testid":
3450
+ return `page.getByTestId(${JSON.stringify(label.value)})`;
3451
+ case "aria":
3452
+ return `page.getByLabel(${JSON.stringify(label.value)})`;
3453
+ case "text":
3454
+ return `page.getByText(${JSON.stringify(label.value)}, { exact: true })`;
3455
+ default:
3456
+ return undefined;
3457
+ }
3458
+ }
3459
+
3460
+ function browserPlaywrightConfig(): string {
3461
+ return `import { defineConfig, devices } from "@playwright/test";
3462
+
3463
+ export default defineConfig({
3464
+ testDir: ".",
3465
+ testMatch: "*.spec.ts",
3466
+ // Relative, which playwright resolves against THIS FILE (only the unset
3467
+ // default resolves against the invocation directory). Never the CJS
3468
+ // dirname global: a "type":"module" suite is loaded as ESM, where it
3469
+ // does not exist — the whole config died at import (FR-022).
3470
+ outputDir: "test-results",
3471
+ // One worker gives the cleanest timings; more give a fast verification run.
3472
+ // Budgets only warn, so parallel measurement never fails a run — but it does
3473
+ // make the numbers noisier, so CI (where a recorded number usually matters)
3474
+ // keeps one, and FLAWLESS_WORKERS=1 forces it anywhere else.
3475
+ fullyParallel: true,
3476
+ workers: process.env.FLAWLESS_WORKERS
3477
+ ? Number(process.env.FLAWLESS_WORKERS)
3478
+ : process.env.CI
3479
+ ? 1
3480
+ : undefined,
3481
+ reporter: [["list"]],
3482
+ use: {
3483
+ baseURL: "http://127.0.0.1:5173",
3484
+ trace: "retain-on-failure",
3485
+ },
3486
+ webServer: {
3487
+ command: "bun ../host/server.ts",
3488
+ url: "http://127.0.0.1:5173",
3489
+ reuseExistingServer: !process.env.CI,
3490
+ stdout: "ignore",
3491
+ stderr: "pipe",
3492
+ },
3493
+ projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
3494
+ });
3495
+ `;
3496
+ }
3497
+
3498
+ /**
3499
+ * The whole spec for a component whose initial render could not be decided.
3500
+ *
3501
+ * Deliberately not "a mount test plus skips": the mount test asserts the same
3502
+ * unknowable thing — that SOMETHING renders at defaults — and for a class with
3503
+ * required props it does not compile, because DEFAULTS is `{}` and the props
3504
+ * type demands children.
3505
+ *
3506
+ * Everything the analyser DID learn is still reported through REPORT.md and the
3507
+ * --ci blob; this file's job is to say why there is nothing to run.
3508
+ */
3509
+ function undecidableRenderSpec(name: string, importPath: string, info: ComponentInfo): string {
3510
+ return `${emitComponentImport(info, importPath)}
3511
+ import { test } from "vitest";
3512
+
3513
+ void ${name};
3514
+
3515
+ // render() branches on a condition the analyser could not decide, and every
3516
+ // branch returns content — so there is no single render to describe, and any
3517
+ // assertion here would be about a branch the defaults may never take.
3518
+ //
3519
+ // To make it assertable, give the deciding value a knowable initial state (a
3520
+ // useState with a literal, or a class \`state = { … }\` field this can read), or
3521
+ // pass it as a prop the spec controls.
3522
+ test.skip("${name} — render() branches on a condition this analysis could not decide (class state, or a value from a hook it does not follow); no render can be described", () => {});
3523
+ `;
3524
+ }
3525
+
3526
+ /**
3527
+ * The spec for a React error boundary.
3528
+ *
3529
+ * Every other undecidable render is refused, because a spec cannot know which
3530
+ * branch the defaults take. This one it can: `getDerivedStateFromError` is
3531
+ * React's contract for "a child threw", so rendering a child that throws reaches
3532
+ * the fallback and not doing so reaches the children. Both paths are asserted.
3533
+ *
3534
+ * console.error is silenced for the caught case only. React logs every error a
3535
+ * boundary catches, and a spec whose own output is drowned in an expected stack
3536
+ * is a spec people stop reading — but silencing it around the HAPPY path would
3537
+ * hide a real one.
3538
+ */
3539
+ function errorBoundarySpec(
3540
+ name: string,
3541
+ importPath: string,
3542
+ info: ComponentInfo,
3543
+ fallbackText: string[],
3544
+ ): string {
3545
+ // The first line of the caught branch. Matched as a substring, since the
3546
+ // surrounding copy may interpolate.
3547
+ const witness = fallbackText[0] as string;
3548
+ return `${emitComponentImport(info, importPath)}
3549
+ import { describe, expect, test, vi } from "vitest";
3550
+ import { render, screen } from "@testing-library/react";
3551
+
3552
+ const CHILD_TEXT = "flawless child";
3553
+
3554
+ /** A child that throws on render — the only way into a boundary's caught state. */
3555
+ function Boom(): never {
3556
+ throw new Error("flawless: thrown by a child");
3557
+ }
3558
+
3559
+ describe(${JSON.stringify(name)}, () => {
3560
+ test("renders its children when nothing throws", () => {
3561
+ render(<${name}><span>{CHILD_TEXT}</span></${name}>);
3562
+ expect(screen.getByText(CHILD_TEXT)).toBeInTheDocument();
3563
+ });
3564
+
3565
+ test("renders its fallback when a child throws", () => {
3566
+ // React logs every error a boundary catches. Silenced HERE only — around the
3567
+ // happy path it would hide a real one.
3568
+ const logged = vi.spyOn(console, "error").mockImplementation(() => {});
3569
+ try {
3570
+ render(<${name}><Boom /></${name}>);
3571
+ expect(screen.getByText(${JSON.stringify(witness)}, { exact: false })).toBeInTheDocument();
3572
+ } finally {
3573
+ logged.mockRestore();
3574
+ }
3575
+ });
3576
+ });
3577
+ `;
3578
+ }