@lariocpt/flawless-react 0.0.0-stage → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/.agents/skills/flawless-a11y/SKILL.md +113 -0
  2. package/.agents/skills/flawless-api/SKILL.md +159 -0
  3. package/.agents/skills/flawless-api-fixtures/SKILL.md +144 -0
  4. package/.agents/skills/flawless-e2e/SKILL.md +155 -0
  5. package/.agents/skills/flawless-expo/SKILL.md +283 -0
  6. package/.agents/skills/flawless-findings/SKILL.md +116 -0
  7. package/.agents/skills/flawless-load/SKILL.md +112 -0
  8. package/.agents/skills/flawless-nextjs/SKILL.md +198 -0
  9. package/.agents/skills/flawless-perf-harness/SKILL.md +158 -0
  10. package/.agents/skills/flawless-perf-static/SKILL.md +131 -0
  11. package/.agents/skills/flawless-react/SKILL.md +403 -0
  12. package/.agents/skills/flawless-react-rules/SKILL.md +96 -0
  13. package/.agents/skills/flawless-regen/SKILL.md +135 -0
  14. package/.agents/skills/flawless-unit-browser/SKILL.md +178 -0
  15. package/LICENSE +21 -0
  16. package/README.md +611 -2
  17. package/bin/flawless-react +4 -0
  18. package/package.json +67 -4
  19. package/scripts/postinstall.d.mts +46 -0
  20. package/scripts/postinstall.mjs +127 -0
  21. package/src/analyse/authShims.ts +220 -0
  22. package/src/analyse/bootability.ts +257 -0
  23. package/src/analyse/childBridge.ts +302 -0
  24. package/src/analyse/childrenIntoSlot.ts +66 -0
  25. package/src/analyse/compoundFamily.ts +100 -0
  26. package/src/analyse/dataExports.ts +200 -0
  27. package/src/analyse/extractChildAttrs.ts +112 -0
  28. package/src/analyse/extractDataDeps.ts +857 -0
  29. package/src/analyse/extractDisclosures.ts +186 -0
  30. package/src/analyse/extractFetchCalls.ts +81 -0
  31. package/src/analyse/extractFlows.ts +239 -0
  32. package/src/analyse/extractGuards.ts +270 -0
  33. package/src/analyse/extractHooks.ts +237 -0
  34. package/src/analyse/extractInteractions.ts +1421 -0
  35. package/src/analyse/extractLogging.ts +45 -0
  36. package/src/analyse/extractNativeA11y.ts +138 -0
  37. package/src/analyse/extractPredicates.ts +784 -0
  38. package/src/analyse/extractPropUsage.ts +162 -0
  39. package/src/analyse/extractProps.ts +1212 -0
  40. package/src/analyse/extractRenderMode.ts +165 -0
  41. package/src/analyse/extractRootTag.ts +117 -0
  42. package/src/analyse/extractState.ts +361 -0
  43. package/src/analyse/extractStaticText.ts +212 -0
  44. package/src/analyse/extractVariants.ts +186 -0
  45. package/src/analyse/harvestApiEndpoints.ts +284 -0
  46. package/src/analyse/hostProps.ts +185 -0
  47. package/src/analyse/hostTag.ts +72 -0
  48. package/src/analyse/htmlContentModel.ts +94 -0
  49. package/src/analyse/index.ts +744 -0
  50. package/src/analyse/mountPathCalls.ts +442 -0
  51. package/src/analyse/nativeContentModel.ts +129 -0
  52. package/src/analyse/nativeDialect.ts +83 -0
  53. package/src/analyse/nativeProps.ts +339 -0
  54. package/src/analyse/ownReturns.ts +31 -0
  55. package/src/analyse/parseFile.ts +919 -0
  56. package/src/analyse/pos.ts +8 -0
  57. package/src/analyse/providerWraps.ts +1977 -0
  58. package/src/analyse/pureModule.ts +88 -0
  59. package/src/analyse/reexportTable.ts +148 -0
  60. package/src/analyse/refs.ts +195 -0
  61. package/src/analyse/renderPath.ts +441 -0
  62. package/src/analyse/resolveDepReturns.ts +370 -0
  63. package/src/analyse/resolveIndexKeys.ts +98 -0
  64. package/src/analyse/resolveModule.ts +431 -0
  65. package/src/analyse/resolveTagRootTags.ts +103 -0
  66. package/src/analyse/serverActionReturns.ts +213 -0
  67. package/src/analyse/sourceCache.ts +263 -0
  68. package/src/analyse/spreadSiblings.ts +98 -0
  69. package/src/analyse/walkChildren.ts +98 -0
  70. package/src/analyse/zodSchema.ts +424 -0
  71. package/src/bin.ts +23 -0
  72. package/src/changed.ts +221 -0
  73. package/src/cli.ts +560 -0
  74. package/src/cliFlags.ts +281 -0
  75. package/src/core.ts +1229 -0
  76. package/src/docLine.ts +122 -0
  77. package/src/emitPaths.ts +51 -0
  78. package/src/expo/detect.ts +184 -0
  79. package/src/expo/refusal.ts +62 -0
  80. package/src/expo/routes.ts +145 -0
  81. package/src/index.ts +95 -0
  82. package/src/mcp/analysisTools.ts +720 -0
  83. package/src/mcp/infoTools.ts +135 -0
  84. package/src/mcp/protocol.ts +130 -0
  85. package/src/mcp/run.ts +269 -0
  86. package/src/mcp/runTools.ts +362 -0
  87. package/src/mcp/server.ts +279 -0
  88. package/src/mcp/spec.ts +86 -0
  89. package/src/mcp/support.ts +268 -0
  90. package/src/mcp/tools.ts +75 -0
  91. package/src/nextjs/apiRoutes.ts +116 -0
  92. package/src/nextjs/detect.ts +397 -0
  93. package/src/nextjs/emit.ts +106 -0
  94. package/src/nextjs/liveSpec.ts +79 -0
  95. package/src/nextjs/mswHandlers.ts +55 -0
  96. package/src/nextjs/providerWrapper.ts +603 -0
  97. package/src/nextjs/routes.ts +86 -0
  98. package/src/nextjs/swagger.ts +32 -0
  99. package/src/packageJson.ts +414 -0
  100. package/src/platform.ts +18 -0
  101. package/src/prompt.ts +132 -0
  102. package/src/reactCompiler.ts +165 -0
  103. package/src/reporters/findings.ts +156 -0
  104. package/src/reporters/report.ts +463 -0
  105. package/src/runner/runTests.ts +402 -0
  106. package/src/scaffold/adoptStyle.ts +181 -0
  107. package/src/scaffold/index.ts +253 -0
  108. package/src/scaffold/openapi.ts +183 -0
  109. package/src/scaffold/templates.ts +2379 -0
  110. package/src/scaffold-tests/index.ts +463 -0
  111. package/src/scaffold-tests/mockValues.ts +208 -0
  112. package/src/scaffold-tests/mocks.ts +794 -0
  113. package/src/scaffold-tests/nativeIndex.ts +52 -0
  114. package/src/scaffold-tests/nativeTemplates.ts +1528 -0
  115. package/src/scaffold-tests/postDispatch.ts +152 -0
  116. package/src/scaffold-tests/registry.ts +443 -0
  117. package/src/scaffold-tests/selectors.ts +493 -0
  118. package/src/scaffold-tests/selectorsNative.ts +273 -0
  119. package/src/scaffold-tests/stamp.ts +79 -0
  120. package/src/scaffold-tests/templates.ts +3578 -0
  121. package/src/skills/cli.ts +248 -0
  122. package/src/skills/compat.ts +166 -0
  123. package/src/skills/data.ts +192 -0
  124. package/src/skills/md.d.ts +7 -0
  125. package/src/skills/menu.ts +55 -0
  126. package/src/skills/spec.ts +227 -0
  127. package/src/suiteScripts.ts +110 -0
  128. package/src/tasks/a11yNative.ts +281 -0
  129. package/src/tasks/a11yScan.ts +67 -0
  130. package/src/tasks/biomeRunner.ts +301 -0
  131. package/src/tasks/biomeSweep.ts +166 -0
  132. package/src/tasks/e2eExpo.ts +436 -0
  133. package/src/tasks/e2eRoutes.ts +756 -0
  134. package/src/tasks/eslintReactRules.ts +67 -0
  135. package/src/tasks/index.ts +24 -0
  136. package/src/tasks/loadExpo.ts +273 -0
  137. package/src/tasks/loadPipes.ts +750 -0
  138. package/src/tasks/perfHarness.ts +145 -0
  139. package/src/tasks/staticPerfAnalysis.ts +510 -0
  140. package/src/tasks/types.ts +1 -0
  141. package/src/tasks/unitAndBrowser.ts +462 -0
  142. package/src/types.ts +1419 -0
  143. package/src/version.ts +12 -0
@@ -0,0 +1,1528 @@
1
+ import path from "node:path";
2
+ import {
3
+ collectionRenderingCalls,
4
+ componentNode,
5
+ mountPathCalls,
6
+ referencedNames,
7
+ } from "../analyse/mountPathCalls.ts";
8
+ import { requiredNativeProviders } from "../analyse/nativeContentModel.ts";
9
+ import { annotatedReturnValue, VOID_RETURN } from "../analyse/resolveDepReturns.ts";
10
+ import { ModuleResolver } from "../analyse/resolveModule.ts";
11
+ import { exportedFunctions } from "../analyse/serverActionReturns.ts";
12
+ import { SourceFileCache } from "../analyse/sourceCache.ts";
13
+ import { relativeToEmitted } from "../emitPaths.ts";
14
+ import { expoFileToRoute, routeModuleId } from "../expo/routes.ts";
15
+ import { titleSafe } from "../scaffold/templates.ts";
16
+ import { JEST_CONFIG } from "../suiteScripts.ts";
17
+ import type { ComponentTree, ExpoContext, TodoRecorder } from "../types.ts";
18
+ import { describeNoClaim } from "./postDispatch.ts";
19
+ import {
20
+ allocateSpecNames,
21
+ defaultsJson,
22
+ flattenTrees,
23
+ functionProps,
24
+ synthesiseDefaults,
25
+ } from "./registry.ts";
26
+ import {
27
+ awaitNativeSelector,
28
+ describeUnlocatableNative,
29
+ emitNativeDispatch,
30
+ emitNativeSelector,
31
+ } from "./selectorsNative.ts";
32
+ import type { RenderedFile } from "./templates.ts";
33
+
34
+ /**
35
+ * The jest-expo unit plane.
36
+ *
37
+ * Everything here is a string literal in `src/`, never a template file read at
38
+ * runtime. `bun build --compile` bundles MODULES, not sibling assets, so a
39
+ * template loaded from disk works perfectly from the checkout and produces
40
+ * nothing at all from the published binary — and the dogfood loop runs from the
41
+ * checkout, so nothing else would notice.
42
+ */
43
+
44
+ export interface NativeRenderOptions {
45
+ trees: ComponentTree[];
46
+ outDir: string;
47
+ expo: ExpoContext;
48
+ todos?: TodoCounterLike;
49
+ /** Shared across the whole render: an api module is read once, not once per spec. */
50
+ resolver?: ModuleResolver;
51
+ cache?: SourceFileCache;
52
+ }
53
+
54
+ interface ResolvedNativeOptions extends NativeRenderOptions {
55
+ resolver: ModuleResolver;
56
+ cache: SourceFileCache;
57
+ }
58
+
59
+ interface TodoCounterLike {
60
+ for(file: string): TodoRecorder;
61
+ }
62
+
63
+ const JEST_SETUP = "jest.setup.ts";
64
+
65
+ export function renderNativeUnit(input: NativeRenderOptions): RenderedFile[] {
66
+ const opts: ResolvedNativeOptions = {
67
+ ...input,
68
+ resolver: input.resolver ?? new ModuleResolver(),
69
+ cache: input.cache ?? new SourceFileCache(),
70
+ };
71
+ const files: RenderedFile[] = [
72
+ { relPath: JEST_CONFIG, content: jestConfig(opts) },
73
+ { relPath: JEST_SETUP, content: jestSetup(opts), skipIfExists: true },
74
+ ];
75
+
76
+ const flat = flattenTrees(opts.trees);
77
+ // Field names this component's SUBTREE renders as text — `{building.name}` in a
78
+ // row component records propUsage { kind: "jsx-child", member: "name" }.
79
+ //
80
+ // Real evidence, replacing a guess. The first cut picked the witness from a
81
+ // hand-written list of display-ish key names, and 16 of 31 loaded-state tests
82
+ // asserted a field the row does not render.
83
+ const renderedFields = new Map<string, Set<string>>();
84
+ for (const tree of opts.trees) {
85
+ const members = new Set<string>();
86
+ const walk = (t: ComponentTree): void => {
87
+ for (const u of t.entry.propUsage ?? []) {
88
+ if (u.kind === "jsx-child" && u.member) members.add(u.member);
89
+ }
90
+ for (const child of t.children) walk(child);
91
+ };
92
+ walk(tree);
93
+ const existing = renderedFields.get(tree.entry.filePath) ?? new Set<string>();
94
+ for (const m of members) existing.add(m);
95
+ renderedFields.set(tree.entry.filePath, existing);
96
+ }
97
+ const specNames = allocateSpecNames(flat);
98
+ // A component with no export cannot be imported by a spec — the same rule the
99
+ // DOM path applies, and on this app it is 16 of 47 components, every one of
100
+ // them covered through the parent that renders it.
101
+ const targets = flat.filter((c) => c.info.exportKind !== "none");
102
+
103
+ for (const c of targets) {
104
+ const specName = specNames.get(c) as string;
105
+ // Specs live under unit/, NOT a directory of their own. Two reasons, and the
106
+ // second is the sharp one: docsLint's EMITTED_DIRS is a FILTER, so a claim
107
+ // about a path under an unlisted directory is silently skipped rather than
108
+ // checked — a new directory would make every native path claim unverified
109
+ // forever instead of turning something red.
110
+ const rel = `unit/${specName}.flawless.test.tsx`;
111
+ const target = path.join(path.resolve(opts.outDir), rel);
112
+ files.push({
113
+ relPath: rel,
114
+ content: renderNativeSpec(
115
+ c.info,
116
+ specName,
117
+ opts,
118
+ path.dirname(target),
119
+ renderedFields.get(c.info.filePath) ?? new Set<string>(),
120
+ opts.todos?.for(target),
121
+ ),
122
+ });
123
+ }
124
+ return files;
125
+ }
126
+
127
+ /**
128
+ * jest.config.cjs — `.cjs`, not `.js`.
129
+ *
130
+ * Jest loads `jest.config.js` through `require`, which is CJS unless the nearest
131
+ * package.json says `"type": "module"` — and in Expo mode the nearest one is the
132
+ * TARGET APP's, whose type we do not control. `.cjs` is unambiguous wherever it
133
+ * sits, which is exactly why VITEST_CONFIG is `.mts`.
134
+ */
135
+ function jestConfig(opts: NativeRenderOptions): string {
136
+ const sdk = opts.expo.sdkMajor;
137
+ const rootRel = relativeToEmitted(path.resolve(opts.outDir), opts.expo.root);
138
+ return `// Generated by flawless-react. Rewritten on every run — put hand edits in
139
+ // ${JEST_SETUP} beside it, which is write-once.
140
+ const path = require("node:path");
141
+ const preset = require("jest-expo/jest-preset");
142
+
143
+ const projectRoot = path.resolve(__dirname, ${JSON.stringify(rootRel)});
144
+
145
+ // jest-expo's own pattern is \`node_modules/(?!(<alt>|<alt>|…)/)\`. Ours has to be
146
+ // the UNION of its allowlist and the extra packages this app ships untranspiled —
147
+ // and TWO patterns in the array give the INTERSECTION (a path is left
148
+ // untransformed when EITHER matches), which is the opposite of what is wanted.
149
+ // So the alternation is spliced, and it THROWS rather than silently narrowing if
150
+ // jest-expo's shape ever changes: a narrowed pattern surfaces as
151
+ // "SyntaxError: Cannot use import statement outside a module" from deep inside a
152
+ // dependency, which names nothing.
153
+ const EXTRA = ${JSON.stringify(extraTransformPackages(opts))};
154
+
155
+ function widen(patterns, extra) {
156
+ const one = Array.isArray(patterns) ? patterns[0] : patterns;
157
+ if (typeof one !== "string" || !one.includes("(?!")) {
158
+ throw new Error(
159
+ "flawless: jest-expo's transformIgnorePatterns is not the expected " +
160
+ "node_modules/(?!(a|b)/) shape — refusing to guess. Got: " + String(one),
161
+ );
162
+ }
163
+ if (extra.length === 0) return [one];
164
+ return [one.replace("(?!(", "(?!(" + extra.join("|") + "|")];
165
+ }
166
+
167
+ module.exports = {
168
+ ...preset,
169
+ // Spread rather than named: the bare "jest-expo" preset is multi-project and
170
+ // runs every spec once per platform, which multiplies the suite for no extra
171
+ // signal on a codebase whose only Platform branching is a keyboard behaviour.
172
+ preset: undefined,
173
+ rootDir: projectRoot,
174
+ setupFilesAfterEnv: [
175
+ ...(preset.setupFilesAfterEnv ?? []),
176
+ path.join(__dirname, ${JSON.stringify(JEST_SETUP)}),
177
+ ],
178
+ transformIgnorePatterns: widen(preset.transformIgnorePatterns, EXTRA),
179
+ testMatch: ["<rootDir>/**/*.flawless.test.{ts,tsx}"],
180
+ testPathIgnorePatterns: ["/node_modules/", "/\\\\.expo/", "/dist/", "/android/", "/ios/"],
181
+ moduleNameMapper: {
182
+ ...preset.moduleNameMapper,
183
+ // app/_layout.tsx imports global.css for NativeWind. Jest cannot parse CSS,
184
+ // and without this every route spec dies at import time.
185
+ "\\\\.(css)$": path.join(__dirname, "styleStub.js"),
186
+ },
187
+ };
188
+ ${sdk === undefined ? "// NOTE: the Expo SDK major could not be read, so jest-expo is unpinned in package.json.\n" : `// Paired with jest-expo@~${sdk} — the major MUST match Expo SDK ${sdk}.\n`}`;
189
+ }
190
+
191
+ /**
192
+ * Packages jest must transform beyond jest-expo's own allowlist.
193
+ *
194
+ * Derived from the project, not guessed: NativeWind ships untranspiled and its
195
+ * absence here is a SyntaxError from inside a dependency. Everything under a
196
+ * `react-native*` prefix is already covered by jest-expo's alternation, so
197
+ * listing those again would only make the pattern longer.
198
+ */
199
+ function extraTransformPackages(opts: NativeRenderOptions): string[] {
200
+ return opts.expo.nativewind ? ["nativewind", "react-native-css-interop"] : [];
201
+ }
202
+
203
+ /**
204
+ * jest.setup.ts — write-once, because it is where hand-written module mocks go
205
+ * and nothing can rebuild those from the analysis.
206
+ *
207
+ * The 70 lines of jsdom shims the DOM setup carries (matchMedia, ResizeObserver,
208
+ * IntersectionObserver, scrollIntoView, storage) are DELETED rather than ported.
209
+ * jest-expo runs under `node` with no `window` at all, and adding one would be
210
+ * inventing a DOM the app never sees.
211
+ *
212
+ * Deliberately NOT mocked here: react-native-reanimated, react-native-gesture-handler
213
+ * and expo-linking. expo-router/testing-library's own mocks.js already wires all
214
+ * three, and duplicating them is dead code that will rot.
215
+ *
216
+ * NEVER mocked, in any form: react-native itself. The dogfood app's own
217
+ * tests/setup.ts does exactly that, and it is precisely why its testing skill
218
+ * says "screens are not testable". jest-expo exists so you do not have to.
219
+ */
220
+ function jestSetup(opts: NativeRenderOptions): string {
221
+ const router = opts.expo.routerMajor !== undefined;
222
+ return `// Generated by flawless-react — WRITE-ONCE. A re-run will not overwrite this,
223
+ // so hand-written module mocks belong here. Delete the file to regenerate it.
224
+ import { jest } from "@jest/globals";
225
+
226
+ // Icons render null and forward their handles.
227
+ //
228
+ // The tempting alternative — rendering <Text>{name}</Text> so an icon button
229
+ // becomes findable by its icon name — is a LIE: it invents an accessible name
230
+ // the shipped app does not have, so the spec would pass on a control a real user
231
+ // cannot reach. This choice and the selector ladder's refusal to guess a
232
+ // last-resort locator have to agree, or one of them is wrong.
233
+ jest.mock("@expo/vector-icons", () => {
234
+ // jest hoists jest.mock factories above imports, so a factory may not
235
+ // reference an imported binding — require is the only legal way to reach
236
+ // React from inside one. The suppression has to be the LAST comment line
237
+ // before the statement or biome does not attach it.
238
+ // biome-ignore lint/style/noCommonJs: jest.mock factory; require is mandatory here.
239
+ const React = require("react");
240
+ const handle = (props: Record<string, unknown>) =>
241
+ React.createElement("Icon", {
242
+ testID: props.testID,
243
+ accessibilityLabel: props.accessibilityLabel,
244
+ });
245
+ return new Proxy({}, { get: () => handle });
246
+ });
247
+ ${
248
+ router
249
+ ? `
250
+ // expo-secure-store reaches for a native module that does not exist under jest.
251
+ // Mocked GLOBALLY rather than per-spec: the component-level allowlist only sees
252
+ // a component's OWN imports, and a screen importing lib/api transitively pulls
253
+ // this in — which throws at collection time, before any test runs.
254
+ jest.mock("expo-secure-store", () => ({
255
+ getItemAsync: async () => null,
256
+ setItemAsync: async () => {},
257
+ deleteItemAsync: async () => {},
258
+ }));
259
+ `
260
+ : ""
261
+ }
262
+ // react-native-safe-area-context's provider renders NOTHING until it measures,
263
+ // and under jest it never measures — so without initialMetrics every screen
264
+ // renders an empty tree and every assertion fails for a reason that has nothing
265
+ // to do with the component.
266
+ jest.mock("react-native-safe-area-context", () => {
267
+ const actual = jest.requireActual("react-native-safe-area-context") as Record<string, unknown>;
268
+ const inset = { top: 0, right: 0, bottom: 0, left: 0 };
269
+ const frame = { x: 0, y: 0, width: 390, height: 844 };
270
+ return {
271
+ ...actual,
272
+ initialWindowMetrics: { insets: inset, frame },
273
+ useSafeAreaInsets: () => inset,
274
+ useSafeAreaFrame: () => frame,
275
+ };
276
+ });
277
+ `;
278
+ }
279
+
280
+ /**
281
+ * Which of the two native templates a component gets.
282
+ *
283
+ * A ROUTE SCREEN mounts through renderRouter at its own URL. That is not a
284
+ * stylistic preference: useFocusEffect reaches useNavigation(), which throws
285
+ * "Couldn't find a navigation object" outside a navigator, and useLocalSearchParams
286
+ * is empty. On the dogfood app 19 of 31 route files use useFocusEffect, so a
287
+ * plain render() would fail them all — and worse, would fail them in a way that
288
+ * looks like the component is broken.
289
+ *
290
+ * Everything else mounts through RNTL's render().
291
+ */
292
+ function routeOf(filePath: string, expo: ExpoContext) {
293
+ if (!expo.routerRoot) return undefined;
294
+ return expoFileToRoute(filePath, expo.routerRoot);
295
+ }
296
+
297
+ /**
298
+ * A `_layout` file, mounted through its own navigator.
299
+ *
300
+ * `render(<TabsLayout/>)` throws "No filename found. This is likely a bug in
301
+ * expo-router" — <Tabs> and <Stack> call useContextKey, which needs a route
302
+ * context. So a layout gets the ROUTE template with a synthesised stub leaf
303
+ * inside it, mounted at that leaf's URL: the navigator then has exactly one
304
+ * child to render, which is the smallest honest way to exercise it.
305
+ *
306
+ * Found by executing the emitted suite. Nothing about the source says a layout
307
+ * is different from any other default-exported component.
308
+ */
309
+ function layoutRouteOf(filePath: string, expo: ExpoContext) {
310
+ if (!expo.routerRoot) return undefined;
311
+ const base = path.basename(filePath).replace(/\.(tsx|jsx|ts|js)$/, "");
312
+ if (base !== "_layout") return undefined;
313
+ const id = routeModuleId(filePath, expo.routerRoot);
314
+ const dir = id.replace(/_layout$/, "");
315
+ const leafId = `${dir}flawless-probe`;
316
+ const urlPath = `/${dir
317
+ .replace(/\/$/, "")
318
+ .split("/")
319
+ .filter((seg) => seg && !/^\(.*\)$/.test(seg))
320
+ .concat("flawless-probe")
321
+ .join("/")}`;
322
+ return { id, leafId, urlPath };
323
+ }
324
+
325
+ function renderNativeSpec(
326
+ info: Parameters<typeof synthesiseDefaults>[0],
327
+ specName: string,
328
+ opts: ResolvedNativeOptions,
329
+ fromDir: string,
330
+ /** Field names this component's subtree renders as text. */
331
+ renderedFields: Set<string>,
332
+ todo: TodoRecorder | undefined,
333
+ ): string {
334
+ const route = routeOf(info.filePath, opts.expo);
335
+ const layout = layoutRouteOf(info.filePath, opts.expo);
336
+ // Native read renderMode nowhere at all — grep was empty — so an undecidable
337
+ // render reached the emitter as though it were an ordinary one and produced a
338
+ // spec driving controls from a branch the defaults never take. A layout is
339
+ // exempt: it is mounted through its own navigator with a synthesised child and
340
+ // never described from its own render.
341
+ if (info.renderUndecidable === true && !layout) {
342
+ // An error boundary is the one undecidable render whose BOTH branches are
343
+ // reachable: React's getDerivedStateFromError contract says a throwing child
344
+ // puts it into the caught state. The dogfood app's ErrorBoundary is the only
345
+ // named export in it, and this is what turns its refusal into a real spec.
346
+ const fallback = info.errorBoundary?.fallbackText ?? [];
347
+ if (info.errorBoundary && fallback.length > 0) {
348
+ return errorBoundaryNativeSpec(
349
+ specName,
350
+ info.exportKind === "default" ? specName : `{ ${info.componentName} as ${specName} }`,
351
+ importPathOf(fromDir, info),
352
+ fallback[0] as string,
353
+ );
354
+ }
355
+ todo?.("other");
356
+ return undecidableNativeSpec(
357
+ specName,
358
+ info.exportKind === "default" ? specName : `{ ${info.componentName} as ${specName} }`,
359
+ importPathOf(fromDir, info),
360
+ );
361
+ }
362
+ const importPath = importPathOf(fromDir, info);
363
+ const imported =
364
+ info.exportKind === "default" ? specName : `{ ${info.componentName} as ${specName} }`;
365
+ const defaults = synthesiseDefaults(info);
366
+ const clicks = info.interactions?.clicks ?? [];
367
+ const providers = requiredNativeProviders(info.hooks ?? []);
368
+ const loadedFixtures: LoadedFixture[] = [];
369
+ const rejectedFixtures: RejectedFixture[] = [];
370
+ const mocks = networkModuleMocks(
371
+ info,
372
+ fromDir,
373
+ opts.resolver,
374
+ opts.cache,
375
+ loadedFixtures,
376
+ rejectedFixtures,
377
+ renderedFields,
378
+ todo,
379
+ );
380
+ // Evidence, not a guess: the component awaits data iff a module the emitter
381
+ // just mocked performs fetch(), or it registers a focus effect.
382
+ const loadsData = mocks.length > 0 || (info.hooks ?? []).includes("useFocusEffect");
383
+
384
+ const header = `// @flawless-generated
385
+ // ${route ? `Route screen for ${route.urlPath}` : "Component"} — regenerate with --force.
386
+ //
387
+ // expo-router/testing-library is imported FIRST, before the component under
388
+ // test. Its mocks.js calls jest.mock() at REQUIRE time rather than hoisted, so
389
+ // importing the component first would load the real module before the mock is
390
+ // installed.
391
+ `;
392
+
393
+ const lines: string[] = [header];
394
+ if (layout) {
395
+ lines.push(
396
+ `import { renderRouter, screen, fireEvent, waitFor } from "expo-router/testing-library";`,
397
+ `import ${imported} from ${JSON.stringify(importPath)};`,
398
+ "",
399
+ `const DEFAULTS: Record<string, unknown> = {};`,
400
+ `const SAMPLE_TEXT = "flawless";`,
401
+ "",
402
+ "// A layout is not mountable on its own: <Tabs>/<Stack> call useContextKey,",
403
+ '// which throws "No filename found" without a route context. So it is',
404
+ "// mounted through its own navigator with one synthesised child, which is the",
405
+ "// smallest honest way to exercise it.",
406
+ "const CONTEXT = {",
407
+ ` ${JSON.stringify(layout.id)}: { default: ${specName} },`,
408
+ ` ${JSON.stringify(layout.leafId)}: { default: () => null },`,
409
+ "};",
410
+ "",
411
+ `describe(${JSON.stringify(specName)}, () => {`,
412
+ ` test("mounts as a navigator", () => {`,
413
+ ` renderRouter(CONTEXT, { initialUrl: ${JSON.stringify(layout.urlPath)} });`,
414
+ " expect(screen.toJSON()).not.toBeNull();",
415
+ " });",
416
+ "});",
417
+ "",
418
+ );
419
+ return withoutUnreferencedConsts(lines).join("\n");
420
+ }
421
+ if (route) {
422
+ // testRouter only when a handler calls router.back() and therefore needs a
423
+ // history entry pushed first. Named unconditionally, it is an unused import
424
+ // in every one of the other specs.
425
+ const needsRouter = clicks.some((c) => c.navBack);
426
+ lines.push(
427
+ `import { renderRouter, screen, fireEvent, waitFor${needsRouter ? ", testRouter" : ""} } from "expo-router/testing-library";`,
428
+ );
429
+ } else {
430
+ lines.push(
431
+ `import { render, screen, fireEvent, userEvent, waitFor } from "@testing-library/react-native";`,
432
+ // Type-only, and a NAMED import rather than a React namespace: the spec
433
+ // has no other reason to name React, and `import type` is erased so it
434
+ // cannot affect what jest loads or in what order.
435
+ `import type { ComponentProps } from "react";`,
436
+ );
437
+ }
438
+ lines.push(`import ${imported} from ${JSON.stringify(importPath)};`);
439
+ if (route) lines.push(...routeContextImports(opts, fromDir, route));
440
+ lines.push("");
441
+ // The loaded-state tests drive the mock directly, so the spec imports the
442
+ // mocked names — jest.mock has replaced the module, so these ARE the mocks.
443
+ for (const imp of [...new Set(loadedFixtures.map((f) => `${f.fn}::${f.from}`))]) {
444
+ const [fn, from] = imp.split("::") as [string, string];
445
+ lines.push(`import { ${fn} } from ${JSON.stringify(from)};`);
446
+ }
447
+ lines.push(...mocks);
448
+ // Record<string, unknown>, not `as const`: the prop rung emits
449
+ // String(DEFAULTS["x"] ?? ""), which under Expo's strict tsconfig is TS7053
450
+ // against a const-asserted literal — so an app running `tsc --noEmit` in CI
451
+ // goes red on the generated file. The DOM emitter has always typed it this way.
452
+ lines.push(`const DEFAULTS: Record<string, unknown> = ${defaultsJson(defaults)};`);
453
+ // Function props, spread SEPARATELY because DEFAULTS is JSON and a function
454
+ // cannot ride in it. Without these a prop-bound handler is undefined at mount:
455
+ // pressing it asserted nothing, and deleting `onPress={onPress}` from the source
456
+ // left the test green. The DOM emitter has passed spies for exactly this.
457
+ const fnProps = route ? [] : functionProps(info);
458
+ if (fnProps.length > 0) {
459
+ lines.push(
460
+ "const handlers: Record<string, jest.Mock> = {",
461
+ ...fnProps.map((n) => ` ${JSON.stringify(n)}: jest.fn(),`),
462
+ "};",
463
+ );
464
+ }
465
+ lines.push(`const SAMPLE_TEXT = "flawless";`);
466
+ lines.push("");
467
+ if (route) lines.push(...routeContext(info, specName, opts, route));
468
+ lines.push(`describe(${JSON.stringify(specName)}, () => {`);
469
+ lines.push(
470
+ ...mountTest(specName, route, providers, fnProps, Object.keys(defaults).length > 0, todo),
471
+ );
472
+ lines.push(...staticTextTests(info));
473
+ lines.push(...interactionTests(clicks, Boolean(route), loadsData, todo));
474
+ lines.push(...loadedStateTests(loadedFixtures, rejectedFixtures, mocks.length > 0, todo));
475
+ lines.push("});");
476
+ lines.push("");
477
+ return withoutUnreferencedConsts(lines).join("\n");
478
+ }
479
+
480
+ /**
481
+ * Drop `const X = …` lines the emitted body never reads.
482
+ *
483
+ * The two banner constants are pushed unconditionally, but whether anything
484
+ * USES them depends on the rungs that fire: DEFAULTS is read only when props
485
+ * are spread or a prop-rung locator resolves, and SAMPLE_TEXT only when a
486
+ * TextInput gets filled. A props-less component with no text input therefore
487
+ * carried both as dead code — 55 `noUnusedVariables` errors across the 32
488
+ * specs of the dogfood app, which is a lint failure in the ADOPTING repo, not
489
+ * here. The DOM emitter never had this because it spreads DEFAULTS on every
490
+ * mount.
491
+ *
492
+ * Written as "keep what is referenced" rather than "predict who needs it", so
493
+ * a new rung that starts reading SAMPLE_TEXT keeps it automatically instead of
494
+ * silently losing its declaration.
495
+ *
496
+ * Only SELF-CONTAINED entries are eligible — a complete statement whose
497
+ * brackets balance and which ends in `;`. The multi-line openers pushed as
498
+ * their own entries (`const CONTEXT = {`, `const handlers… = {`) are left
499
+ * alone; dropping one would orphan the lines that close it.
500
+ */
501
+ /**
502
+ * The "where did this navigate to" claim, or the named reason there isn't one.
503
+ *
504
+ * Two defects met here, both invisible on a dogfood app whose screens are all
505
+ * routes:
506
+ *
507
+ * 1. It was emitted as `expect(screen).toHavePathname(x)`. expo-router 6 ships
508
+ * that matcher at RUNTIME but its `expect.d.ts` is `export {}` — the type
509
+ * augmentation is empty. So the assertion is correct and passes, and the
510
+ * adopting app's `tsc --noEmit` reports TS2339 on every one of them (30 in
511
+ * mybodycorpai/apps/mobile). `getPathname()` is declared on renderRouter's
512
+ * OWN return type, so reading it off the captured result asserts the same
513
+ * thing through typed public API — no ambient patch of a dependency, and
514
+ * nothing to conflict with if expo-router later ships the missing types.
515
+ *
516
+ * 2. It was not gated on isRoute. A non-route component that calls
517
+ * router.push() mounts through plain RNTL render(), which has no router and
518
+ * no pathname — the claim would have been asserted against a store nothing
519
+ * populated. Refusing by name is the honest answer; the dispatch itself is
520
+ * still covered by the post-dispatch ladder above.
521
+ */
522
+ function pathnameClaim(
523
+ h: { pushTargets?: string[] },
524
+ assertsPathname: boolean,
525
+ isRoute: boolean,
526
+ ): string[] {
527
+ if (assertsPathname && h.pushTargets?.length) {
528
+ return [
529
+ " // The push target is a literal in the source, so where it lands is",
530
+ " // knowable — the React Native analogue of asserting a link's href.",
531
+ ` expect(nav.getPathname()).toBe(${JSON.stringify(h.pushTargets[0])});`,
532
+ ];
533
+ }
534
+ if (h.pushTargets?.length && !isRoute) {
535
+ return [
536
+ " // Where this push LANDS is not asserted: a component spec mounts through",
537
+ ` // render(), not renderRouter(), so there is no router store holding a`,
538
+ ` // pathname. The target is known (${h.pushTargets[0]}) — reaching it needs`,
539
+ " // this component mounted as part of a route.",
540
+ ];
541
+ }
542
+ return [];
543
+ }
544
+
545
+ export function withoutUnreferencedConsts(lines: string[]): string[] {
546
+ const balanced = (s: string): boolean => {
547
+ let depth = 0;
548
+ for (const ch of s) {
549
+ if (ch === "{" || ch === "[" || ch === "(") depth++;
550
+ else if (ch === "}" || ch === "]" || ch === ")") depth--;
551
+ }
552
+ return depth === 0;
553
+ };
554
+ const declaredName = (entry: string): string | undefined => {
555
+ const m = /^const ([A-Za-z_$][\w$]*)(?::[^=]*)? = ([\s\S]*);$/.exec(entry.trim());
556
+ if (!m) return undefined;
557
+ if (!balanced(entry)) return undefined;
558
+ // Only a PURE initialiser may be dropped. `const nav = mount();` is
559
+ // unreferenced whenever nothing reads the render result, and deleting it
560
+ // deletes the mount — the screen is then never rendered and the spec fails
561
+ // with `screen.getPathname is not a function`, which names nothing a reader
562
+ // could act on. Found by probe: reverting an assertion to its old form made
563
+ // this filter silently remove the mount call underneath it.
564
+ const init = (m[2] as string).trim();
565
+ const pure =
566
+ /^(\{[\s\S]*\}|\[[\s\S]*\]|"[^"]*"|'[^']*'|-?\d[\d._]*|true|false|null|undefined)$/.test(
567
+ init,
568
+ );
569
+ return pure ? m[1] : undefined;
570
+ };
571
+
572
+ // Fixpoint: dropping one declaration can make another unreferenced.
573
+ let kept = lines;
574
+ for (;;) {
575
+ const next = kept.filter((entry, i) => {
576
+ const name = declaredName(entry);
577
+ if (!name) return true;
578
+ const used = new RegExp(`\\b${name}\\b`);
579
+ return kept.some((other, j) => j !== i && used.test(other));
580
+ });
581
+ if (next.length === kept.length) return kept;
582
+ kept = next;
583
+ }
584
+ }
585
+
586
+ /**
587
+ * The layout modules a route mounts inside, as imports.
588
+ *
589
+ * Only the layouts that actually EXIST — layoutChain reports every ancestor that
590
+ * may have one, and asking renderRouter for a module that is not there is an
591
+ * error rather than a no-op.
592
+ */
593
+ function existingLayouts(opts: NativeRenderOptions, route: { layouts: string[] }): string[] {
594
+ const fs = require("node:fs") as typeof import("node:fs");
595
+ const root = opts.expo.routerRoot as string;
596
+ return route.layouts.filter((id) =>
597
+ [".tsx", ".jsx", ".ts", ".js"].some((ext) => fs.existsSync(path.join(root, `${id}${ext}`))),
598
+ );
599
+ }
600
+
601
+ function routeContextImports(
602
+ opts: NativeRenderOptions,
603
+ fromDir: string,
604
+ route: NonNullable<ReturnType<typeof routeOf>>,
605
+ ): string[] {
606
+ const root = opts.expo.routerRoot as string;
607
+ return existingLayouts(opts, route).map((id, i) => {
608
+ const abs = path.join(root, id);
609
+ // A namespace import: expo-router reads `ErrorBoundary` and
610
+ // `unstable_settings` off a layout module, not just its default export.
611
+ return `import * as Layout${i} from ${JSON.stringify(relativeToEmitted(fromDir, abs))};`;
612
+ });
613
+ }
614
+
615
+ /**
616
+ * renderRouter's context, as a plain OBJECT.
617
+ *
618
+ * Never the string form and never `{ appDir }`: expo-router resolves both with
619
+ * `path.resolve(process.cwd(), …)` (build/testing-library/mock-config.js), so a
620
+ * spec passing "./app" loads the wrong directory the moment jest runs from the
621
+ * monorepo root rather than the app. The object form resolves nothing.
622
+ */
623
+ function routeContext(
624
+ info: Parameters<typeof synthesiseDefaults>[0],
625
+ specName: string,
626
+ opts: NativeRenderOptions,
627
+ route: NonNullable<ReturnType<typeof routeOf>>,
628
+ ): string[] {
629
+ const root = opts.expo.routerRoot as string;
630
+ const selfId = routeModuleId(info.filePath, root);
631
+ const entries = existingLayouts(opts, route).map(
632
+ (id, i) => ` ${JSON.stringify(id)}: Layout${i},`,
633
+ );
634
+ // specName, not componentName: the import above binds the ALIASED name when
635
+ // allocateSpecNames renamed on a collision, so referencing componentName here
636
+ // emitted a spec that parses fine and throws ReferenceError at module load.
637
+ entries.push(` ${JSON.stringify(selfId)}: { default: ${specName} },`);
638
+ // A literal push target that is not in the context lands on expo-router's
639
+ // injected +not-found, so a navigation assertion would fail for the wrong
640
+ // reason. Stub every one this component navigates to.
641
+ for (const target of literalPushTargets(info)) {
642
+ const id = target.replace(/^\//, "") || "index";
643
+ if (id === selfId) continue;
644
+ entries.push(` ${JSON.stringify(id)}: { default: () => null },`);
645
+ }
646
+ return [
647
+ "// The OBJECT form of renderRouter's context. The string and { appDir } forms",
648
+ "// both resolve against process.cwd(), so a spec using one breaks the moment",
649
+ "// jest runs from the monorepo root instead of the app directory.",
650
+ "const CONTEXT = {",
651
+ ...dedupe(entries),
652
+ "};",
653
+ "",
654
+ `const INITIAL_URL = ${JSON.stringify(seededUrl(route))};`,
655
+ "",
656
+ ];
657
+ }
658
+
659
+ function dedupe(lines: string[]): string[] {
660
+ const seen = new Set<string>();
661
+ return lines.filter((l) => {
662
+ const key = l.slice(0, l.indexOf(":"));
663
+ if (seen.has(key)) return false;
664
+ seen.add(key);
665
+ return true;
666
+ });
667
+ }
668
+
669
+ /** A concrete URL for the route, with a deterministic literal per dynamic segment. */
670
+ function seededUrl(route: { urlPath: string }): string {
671
+ return route.urlPath
672
+ .replace(/\[\.\.\.(.+?)\]/g, "flawless-$1")
673
+ .replace(/\[(.+?)\]/g, "flawless-$1");
674
+ }
675
+
676
+ /** `router.push("/literal")` targets, which the context must carry a stub for. */
677
+ function literalPushTargets(info: Parameters<typeof synthesiseDefaults>[0]): string[] {
678
+ const out: string[] = [];
679
+ for (const c of info.interactions?.clicks ?? []) {
680
+ for (const t of c.pushTargets ?? []) out.push(t);
681
+ }
682
+ return [...new Set(out)];
683
+ }
684
+
685
+ /** The mount test — the one assertion every native spec can honestly make. */
686
+ function mountTest(
687
+ specName: string,
688
+ route: ReturnType<typeof routeOf>,
689
+ providers: ReturnType<typeof requiredNativeProviders>,
690
+ fnProps: string[],
691
+ /** Whether DEFAULTS has anything in it — see the no-props note below. */
692
+ hasProps: boolean,
693
+ todo: TodoRecorder | undefined,
694
+ ): string[] {
695
+ const out: string[] = [];
696
+ if (route) {
697
+ out.push(
698
+ ` const mount = () => renderRouter(CONTEXT, { initialUrl: INITIAL_URL });`,
699
+ "",
700
+ ` test("mounts at ${route.urlPath}", () => {`,
701
+ " const nav = mount();",
702
+ " // The pathname is the claim. `expect(screen.toJSON()).not.toBeNull()`",
703
+ ' // used to stand here calling itself "the direct analogue of the DOM',
704
+ " // spec's container.firstElementChild check\" — it is not. renderRouter",
705
+ " // mounts the screen inside a navigator, so the NAVIGATOR's tree answers",
706
+ " // it whatever the screen returns. Executed: a route screen whose whole",
707
+ " // body is `return null` passes that assertion.",
708
+ ` expect(nav.getPathname()).toBe(${JSON.stringify(seededUrl(route))});`,
709
+ " });",
710
+ "",
711
+ );
712
+ if (providers.some((p) => p.hook === "useFocusEffect")) {
713
+ // Mounted inside a real navigation container the screen is FOCUSED, so
714
+ // useFocusEffect runs — which is the whole reason route screens use
715
+ // renderRouter rather than render.
716
+ // No "its focus effect runs on mount" test.
717
+ //
718
+ // It used to assert `screen.toJSON()).not.toBeNull()`, which is answered by
719
+ // the navigator's own tree under renderRouter — so it could not fail, AND it
720
+ // said nothing whatever about whether the focus effect ran. A test whose
721
+ // NAME is a claim its body does not make is worse than no test.
722
+ //
723
+ // What actually proves the focus effect ran is the loaded-state test below:
724
+ // it mocks one api function to return a row and asserts the row appears,
725
+ // which cannot happen unless the effect fired and the fetch resolved. Where
726
+ // that test does not qualify, this emitter has no proof to offer and says so
727
+ // rather than staging one.
728
+ // Blur/refocus is the reason the app uses useFocusEffect over useEffect, and
729
+ // a single mount never blurs. Whether a refetch SHOULD happen depends on
730
+ // state this emitter cannot reason about, so it is hand-work, named.
731
+ todo?.("other");
732
+ out.push(
733
+ " // TODO(flawless): this screen refetches on REFOCUS, which a single mount",
734
+ " // never exercises. To cover it:",
735
+ ' // testRouter.push("/somewhere-else"); testRouter.back();',
736
+ " // expect(<your api mock>).toHaveBeenCalledTimes(2);",
737
+ " // Whether a second call is correct depends on the screen's own guard, so",
738
+ " // the assertion is yours to make.",
739
+ "",
740
+ );
741
+ }
742
+ return out;
743
+ }
744
+ if (fnProps.length > 0) {
745
+ // Cleared between tests, or "was this handler called" is answered by an
746
+ // earlier test in the same file rather than by this one.
747
+ out.push(
748
+ " beforeEach(() => {",
749
+ " for (const h of Object.values(handlers)) h.mockClear();",
750
+ " });",
751
+ "",
752
+ );
753
+ }
754
+ // `{...(x as never)}` is TS2698 — never is not an object type — so every
755
+ // component spec this emitter wrote failed `tsc --noEmit` in the adopting app,
756
+ // which is the one place a generated file being unbuildable actually bites.
757
+ //
758
+ // The DOM emitter has known this since its own RSC branch hit it
759
+ // (templates.ts: "`{...(props as never)}` is a type error … so the spread is
760
+ // cast to the component's own props instead") and casts to
761
+ // React.ComponentProps<typeof X>. The native copy used the exact form that
762
+ // comment names as broken, one line down from a comment about keeping
763
+ // DEFAULTS a Record so Expo's strict tsconfig stays green.
764
+ //
765
+ // Nothing caught it: parse-level checks are happy with a cast, and there was
766
+ // no ts.Program gate over the native emitter at all until now.
767
+ const propsCast = `as unknown as ComponentProps<typeof ${specName}>`;
768
+ // A component that declares NO props makes ComponentProps<typeof X> resolve to
769
+ // `unknown` — a zero-parameter function does not match JSXElementConstructor —
770
+ // and spreading that is TS2698 again, plus TS2322 for good measure. The DOM
771
+ // emitter has always omitted the spread entirely in that case
772
+ // (`const spread = hasProps ? … : ""`), which is also simply more honest:
773
+ // there is nothing to pass.
774
+ const defaultsSpread = hasProps ? ` {...(DEFAULTS ${propsCast})}` : "";
775
+ out.push(
776
+ fnProps.length > 0
777
+ ? ` const mount = () => render(<${specName}${defaultsSpread} {...(handlers ${propsCast})} />);`
778
+ : ` const mount = () => render(<${specName}${defaultsSpread} />);`,
779
+ "",
780
+ ` test("mounts and renders something", () => {`,
781
+ " mount();",
782
+ " expect(screen.toJSON()).not.toBeNull();",
783
+ " });",
784
+ "",
785
+ );
786
+ for (const p of providers) {
787
+ // A non-route component that still needs a navigator cannot be mounted by
788
+ // render() at all — say which hook and which wrapper, rather than emitting a
789
+ // spec that throws.
790
+ todo?.("other");
791
+ out.push(` // TODO(flawless): this component ${p.note}`, "");
792
+ }
793
+ return out;
794
+ }
795
+
796
+ /**
797
+ * Static copy the component renders unconditionally.
798
+ *
799
+ * Ports unchanged from the DOM emitter in mechanism, and it is the one part that
800
+ * needed no rethinking: RNTL's getByText matches a <Text>'s composed content,
801
+ * which is where React Native copy lives.
802
+ */
803
+ function staticTextTests(info: Parameters<typeof synthesiseDefaults>[0]): string[] {
804
+ const texts = (info.staticText ?? []).filter((t) => t.trim().length > 1).slice(0, 5);
805
+ if (texts.length === 0) return [];
806
+ return [
807
+ ` test("renders its static copy", () => {`,
808
+ " mount();",
809
+ ...texts.map(
810
+ (t) => ` expect(screen.getAllByText(${JSON.stringify(t)}).length).toBeGreaterThan(0);`,
811
+ ),
812
+ " });",
813
+ "",
814
+ ];
815
+ }
816
+
817
+ /**
818
+ * One test per handler the ladder can address, and a NAMED SKIP for each one it
819
+ * cannot.
820
+ *
821
+ * The skip is the point. A locator that matches nothing would let
822
+ * fireEvent.press run against undefined and the test pass having touched
823
+ * nothing — the exact failure `emitVitestSelector`'s undefined return was
824
+ * written to prevent, arriving on a second platform.
825
+ */
826
+ /**
827
+ * What a spec may honestly assert AFTER firing an event.
828
+ *
829
+ * Without these the interaction tests were `expect(el).toBeTruthy(); fireEvent(…)`
830
+ * and stopped — proving the element is addressable and nothing else. Measured on
831
+ * the dogfood apps: 17 of 87 live mobile tests and 25 of 341 web tests fired an
832
+ * event and asserted nothing after it, which is a test that cannot fail.
833
+ *
834
+ * Every rule below was PROBED under real jest-expo before being emitted, because
835
+ * reasoning about RNTL's behaviour is how the last three defects got in:
836
+ *
837
+ * controlled TextInput -> display value reflects the typed text (1 match)
838
+ * uncontrolled TextInput -> so does it (1 match)
839
+ * frozen (value, no onChangeText) -> it does NOT (0 matches)
840
+ * prop spy -> called (1 call)
841
+ * throwing handler -> propagates out of fireEvent
842
+ *
843
+ * The frozen case is why the display-value assertion is safe to emit: the only
844
+ * shape it fails on is an input the app has genuinely wired wrong, and the
845
+ * analyser can already tell that one apart.
846
+ */
847
+ function postDispatchAssertions(
848
+ h: Parameters<typeof emitNativeSelector>[0],
849
+ el: string,
850
+ /**
851
+ * A route spec declares no `handlers`. `const fnProps = route ? [] : …` above
852
+ * suppresses the whole block for route files, and this returned
853
+ * `expect(handlers["onX"]).toHaveBeenCalled()` regardless — an undeclared
854
+ * identifier, so TS2304 in the emitted file and a ReferenceError in the one
855
+ * interaction test that runs it.
856
+ *
857
+ * Latent rather than shipped-broken: expo-router hands a route screen no
858
+ * props, so nothing in the fixture app or the dogfood app has the shape. It
859
+ * became reachable the moment a route file's default export declared one.
860
+ */
861
+ hasHandlers: boolean,
862
+ ): string[] {
863
+ // A handler bound to a PROP is the precise case: pass a spy, assert it ran.
864
+ if (h.binding.kind === "prop") {
865
+ if (!hasHandlers) return [];
866
+ return [` expect(handlers[${JSON.stringify(h.binding.propName)}]).toHaveBeenCalled();`];
867
+ }
868
+ if (h.event === "onChangeText") {
869
+ // Typing into a wired input changes what it displays. Probed above for all
870
+ // three wirings; only the frozen one fails, and that is an app defect.
871
+ return [` expect(${el}).toHaveDisplayValue(SAMPLE_TEXT);`];
872
+ }
873
+ // The floor, for a handler whose effect is not statically knowable: the tree
874
+ // survived. Weak, but it is a real assertion — a handler that throws, or that
875
+ // sets state the next render cannot cope with, fails here. It is strictly more
876
+ // than the nothing that was emitted before.
877
+ // The native floor, and the half of E5 that goes RED rather than vacuous: this
878
+ // plane has no provider, so `screen.toJSON()` really is the screen's own tree
879
+ // and a component that legitimately unmounts fails here on correct code.
880
+ //
881
+ // Replaced by the canary: drive the press, and assert only that nothing escaped
882
+ // it. Strictly more than the floor — a throwing handler previously failed the
883
+ // RUN with no assertion involved, and now names the test that caused it.
884
+ // The native floor, and the half of E5 that goes RED rather than vacuous: this
885
+ // plane has no provider, so `screen.toJSON()` really is the screen's own tree
886
+ // and a component that legitimately unmounts fails here on correct code.
887
+ //
888
+ // Nothing replaces it, deliberately. RNTL's fireEvent calls the handler
889
+ // SYNCHRONOUSLY, so a throw already fails this test at the dispatch line above
890
+ // — the test has a falsifier without inventing a claim. The locator assertion
891
+ // that precedes the dispatch is the other one. Adding a survival check here
892
+ // would be re-adding the thing this removes.
893
+ return [
894
+ ` // TODO(flawless): ${describeNoClaim("opaque-callback")}, so nothing is`,
895
+ " // asserted after the press. The press itself is still checked: it throws",
896
+ " // out of this test if the handler throws, and the locator above must find",
897
+ " // the control. It becomes a real test as soon as the callback is a prop",
898
+ " // (the spec passes a spy) or the work goes through a module this spec mocks.",
899
+ ];
900
+ }
901
+
902
+ function interactionTests(
903
+ clicks: Parameters<typeof emitNativeSelector>[0][],
904
+ isRoute: boolean,
905
+ /**
906
+ * The component awaits data before its final render — it has a focus effect,
907
+ * or a module the emitter mocked performs fetch(). Its targets may not exist
908
+ * at mount, so the query has to be the async one.
909
+ */
910
+ loadsData: boolean,
911
+ todo: TodoRecorder | undefined,
912
+ ): string[] {
913
+ const out: string[] = [];
914
+ // renderRouter turns on fake timers internally, and userEvent's press timing
915
+ // under fake timers is not something this emitter can vouch for. fireEvent has
916
+ // no timers in it.
917
+ const useUserEvent = !isRoute;
918
+ let n = 0;
919
+ for (const h of clicks) {
920
+ // A conditionally-reached target on a data-loading screen is one row of a
921
+ // list the fixture leaves empty, so it does not render and cannot be driven.
922
+ // The analyser is right to keep it — dropping every mapped row would lose most
923
+ // of a list screen — but a test that presses it fails for a reason that is
924
+ // neither the tool's nor the component's. A skip that names the gap is the
925
+ // honest form, and the fix is a fixture with rows, which is hand-work.
926
+ if (h.conditional && loadsData) {
927
+ todo?.("other");
928
+ out.push(
929
+ ` test.skip("${h.event} on <${h.tag}> — renders once per row, and the generated ` +
930
+ `fixture has no rows; give the mock a non-empty collection to drive it", () => {});`,
931
+ "",
932
+ );
933
+ continue;
934
+ }
935
+ const target = emitNativeSelector(h);
936
+ if (!target) {
937
+ todo?.("unaddressable-target");
938
+ out.push(
939
+ ` test.skip("${h.event} on <${h.tag}> — ${safeTitle(describeUnlocatableNative(h))}", () => {});`,
940
+ "",
941
+ );
942
+ continue;
943
+ }
944
+ const el = `el${n++}`;
945
+ // A screen that loads in a focus effect renders its empty state first and
946
+ // its rows only after the awaited fetch resolves, so a synchronous query at
947
+ // mount fails with "Unable to find an element with text" — which reads as a
948
+ // missing element and is really a missing await.
949
+ const locator = loadsData ? awaitNativeSelector(target.locator) : target.locator;
950
+ // `(await …)[0]` as well as `await …`: the indexed form needs the parens for
951
+ // precedence, and a startsWith("await") check missed it — emitting an `await`
952
+ // inside a non-async callback, which is a SyntaxError that takes the whole
953
+ // suite file out of the run.
954
+ const locatorIsAsync = /^\(?await /.test(locator);
955
+ // The handle, so two identical handlers on two different elements do not
956
+ // produce two identically-named tests — a duplicate name makes a failure
957
+ // report ambiguous about which one broke.
958
+ // Sanitised, because it is interpolated into a double-quoted test title and
959
+ // its content is the APP's: a testID or a label containing a `"` produced an
960
+ // unparseable spec, and jest then failed to collect the whole file. The DOM
961
+ // emitter has done `titleSafe(s.replace(/"/g, "'"))` since FR-012 for exactly
962
+ // this; the native copy did not.
963
+ const handle = safeTitle(describeHandle(h, target));
964
+ const dispatch = emitNativeDispatch(h.event, target, el, useUserEvent);
965
+ if (!dispatch) {
966
+ todo?.("other");
967
+ out.push(
968
+ ` test.skip("${h.event} on <${h.tag}> — no honest way to dispatch this event", () => {});`,
969
+ "",
970
+ );
971
+ continue;
972
+ }
973
+ // Two separate questions that used to share one answer. `isAsync` decides
974
+ // whether the callback is `async`; `usesUserEvent` decides whether to set up
975
+ // a user instance. Conflating them emitted `userEvent.setup()` into route
976
+ // specs — which import from expo-router/testing-library and never name
977
+ // userEvent — as soon as an async LOCATOR made isAsync true.
978
+ const usesUserEvent = dispatch.startsWith("await user.");
979
+ const isAsync = usesUserEvent || locatorIsAsync;
980
+ // `router.back()` needs a history entry, and renderRouter mounts ONE screen
981
+ // at ONE url with no stack behind it — so the press dispatches GO_BACK into
982
+ // a navigator that has nowhere to go, and react-navigation logs "The action
983
+ // 'GO_BACK' was not handled by any navigator", which RNTL surfaces as a
984
+ // failure. Twelve dogfood screens failed that way the moment their back
985
+ // buttons became addressable: a missing PRECONDITION reported as a defect.
986
+ // Pushing the same url first makes the stack two deep, so back() has a
987
+ // destination and the handler is exercised for real. Route specs only —
988
+ // a component spec has no router to push into, and there the skip below is
989
+ // the honest answer.
990
+ const needsHistory = Boolean(h.navBack) && isRoute;
991
+ // A pathname belongs to a ROUTER, and only a route spec mounts one. A
992
+ // component spec renders through plain RNTL render(), so there is no
993
+ // router store to read and the claim is not available to it — see
994
+ // pathnameClaim below for what it emits instead.
995
+ const assertsPathname = isRoute && Boolean(h.pushTargets?.length);
996
+ if (h.navBack && !isRoute) {
997
+ todo?.("other");
998
+ out.push(
999
+ ` test.skip("${h.event} on <${h.tag}> ${handle} — calls router.back(), which needs a ` +
1000
+ `navigation stack this component spec does not mount", () => {});`,
1001
+ "",
1002
+ );
1003
+ continue;
1004
+ }
1005
+ out.push(
1006
+ ` test("${h.event} on <${h.tag}> ${handle} can be driven", ${isAsync ? "async " : ""}() => {`,
1007
+ ...(usesUserEvent ? [" const user = userEvent.setup();"] : []),
1008
+ assertsPathname ? " const nav = mount();" : " mount();",
1009
+ ...(needsHistory
1010
+ ? [
1011
+ " // Give back() somewhere to go — see the note in the emitter: a freshly",
1012
+ " // mounted router has no history, and GO_BACK into an empty stack fails.",
1013
+ " testRouter.push(INITIAL_URL);",
1014
+ ]
1015
+ : []),
1016
+ ` const ${el} = ${locator};`,
1017
+ ` expect(${el}).toBeTruthy();`,
1018
+ ` ${dispatch};`,
1019
+ ...postDispatchAssertions(h, el, !isRoute),
1020
+ ...pathnameClaim(h, assertsPathname, isRoute),
1021
+ " });",
1022
+ "",
1023
+ );
1024
+ }
1025
+ return out;
1026
+ }
1027
+
1028
+ /** How the target was found, for a test name that identifies WHICH element. */
1029
+ function describeHandle(
1030
+ h: Parameters<typeof emitNativeSelector>[0],
1031
+ target: NonNullable<ReturnType<typeof emitNativeSelector>>,
1032
+ ): string {
1033
+ switch (target.rung) {
1034
+ case "testID":
1035
+ return `[testID=${h.hints.testid}]`;
1036
+ case "label":
1037
+ return `[${h.hints.ariaLabel}]`;
1038
+ case "role":
1039
+ return `[role=${h.hints.role}]`;
1040
+ case "placeholder":
1041
+ return `[placeholder=${h.hints.placeholder}]`;
1042
+ case "displayValue":
1043
+ return `[value=${h.hints.displayValue}]`;
1044
+ default:
1045
+ return `[${"value" in h.label ? h.label.value : "text"}]`;
1046
+ }
1047
+ }
1048
+
1049
+ /**
1050
+ * `jest.mock(...)` for the modules a component's render would otherwise send to
1051
+ * the network.
1052
+ *
1053
+ * This is not a nicety. Without it the emitted suite makes LIVE HTTP CALLS: the
1054
+ * fixture run hit a real server on localhost and crashed the jest worker with
1055
+ * "api_error 405". A spec whose result depends on what is listening on a port is
1056
+ * not a test, and on a machine with nothing listening it would fail differently
1057
+ * — which is worse, because it would look like the component.
1058
+ *
1059
+ * Evidence, not a name convention: the specifier resolves to a project-local
1060
+ * file, and that file performs a `fetch(`. A helper module that does no I/O is
1061
+ * left alone, because mocking it would strip behaviour the component genuinely
1062
+ * has — the same reason the DOM emitter's allowlist refuses to mock `cn`.
1063
+ */
1064
+ /** A mocked call whose shape carries a collection, and the one-row fixture for it. */
1065
+ /** A collection-returning function that did NOT qualify, and why. */
1066
+ export interface RejectedFixture {
1067
+ fn: string;
1068
+ reason: string;
1069
+ }
1070
+
1071
+ export interface LoadedFixture {
1072
+ /** Local name of the mocked function, as the component imports it. */
1073
+ fn: string;
1074
+ /** Specifier to import it from, already relativised to the spec. */
1075
+ from: string;
1076
+ /** The resolved shape with ONE row in each collection, as emitted JSON. */
1077
+ rows: string;
1078
+ /** A literal string from inside that row, which the loaded render must display. */
1079
+ witness: string | undefined;
1080
+ }
1081
+
1082
+ function networkModuleMocks(
1083
+ info: Parameters<typeof synthesiseDefaults>[0],
1084
+ fromDir: string,
1085
+ resolver: ModuleResolver,
1086
+ cache: SourceFileCache,
1087
+ loaded: LoadedFixture[],
1088
+ rejected: RejectedFixture[],
1089
+ renderedFields: Set<string>,
1090
+ todo: TodoRecorder | undefined,
1091
+ ): string[] {
1092
+ const fs = require("node:fs") as typeof import("node:fs");
1093
+ const out: string[] = [];
1094
+ const self = cache.get(info.filePath);
1095
+ // THIS component's subtree, not the whole file. Two exported components in one
1096
+ // file share moduleImports and every other file-level fact, so a file-scoped
1097
+ // walk handed a component that calls nothing a skip naming a function from its
1098
+ // sibling — a statement about the component that was simply untrue.
1099
+ const scope = self ? componentNode(self, info.componentName) : undefined;
1100
+ const mountCalls = scope ? mountPathCalls(scope) : new Set<string>();
1101
+ const renderCalls = scope ? collectionRenderingCalls(scope) : new Map<string, Set<string>>();
1102
+ // Names THIS component actually mentions. Everything else about a mocked module
1103
+ // is file-level, so without this a component that references none of an
1104
+ // import's names is still told why one of them did not qualify.
1105
+ const mine = scope ? referencedNames(scope) : undefined;
1106
+ for (const mi of info.moduleImports ?? []) {
1107
+ if (mi.isTypeOnly || !mi.resolvedPath) continue;
1108
+ let src: string;
1109
+ try {
1110
+ src = fs.readFileSync(mi.resolvedPath, "utf8");
1111
+ } catch {
1112
+ continue;
1113
+ }
1114
+ if (!/\bfetch\s*\(/.test(src)) continue;
1115
+ // EVERY exported function of the module, not just the ones this component
1116
+ // imports — the same rule serverActionExports follows on the web path, for
1117
+ // the same reason its comment gives: "a transitive importer needs the
1118
+ // others". jest.mock replaces the module in the whole registry, so a name
1119
+ // this component happens not to import still has to be there, or the other
1120
+ // importer receives undefined and fails somewhere unrelated.
1121
+ //
1122
+ // Taking the module's own exports rather than the import list ALSO drops
1123
+ // type-only names. `import { listMessages, type AshaMessage }` is not a
1124
+ // type-only DECLARATION, so mi.names carries "AshaMessage" and the mock was
1125
+ // stubbing a type as though it were a function.
1126
+ const target = cache.get(mi.resolvedPath);
1127
+ const names = target
1128
+ ? [...new Map(exportedFunctions(target)).keys()]
1129
+ : mi.names.filter((n) => n !== "default");
1130
+ if (names.length === 0) continue;
1131
+ // The mocked function's OWN `Promise<T>` annotation, read by exactly the rules
1132
+ // resolveDepReturns' Path A uses — including through a workspace package's
1133
+ // barrel, which is what B-8 and B-9 were built for.
1134
+ //
1135
+ // This is not a nicety either. A `{}` placeholder is not merely uninformative:
1136
+ // `const { items } = await listBuildings()` then binds `items` to undefined,
1137
+ // `setBuildings(undefined)` follows, and the next `buildings.length` throws
1138
+ // into the app's own ErrorBoundary — so the screen renders "Something went
1139
+ // wrong" and every copy assertion fails. A freshly generated suite was RED on
1140
+ // 3 of 31 real screens for that reason alone, and the failure looked like the
1141
+ // component rather than like the fixture.
1142
+ // Relative to the SPEC, not to the component. `app/login.tsx` writes
1143
+ // "../lib/api"; from flawless/unit/ that names flawless/lib/api, which does
1144
+ // not exist. A bare specifier (a package, a tsconfig alias) is left as
1145
+ // written, because jest resolves those the same way from anywhere.
1146
+ const spec = mi.specifier.startsWith(".")
1147
+ ? relativeToEmitted(fromDir, mi.resolvedPath).replace(/\.(tsx|jsx|ts|js)$/, "")
1148
+ : mi.specifier;
1149
+ // A name this component actually imports gets a REAL fixture; every other
1150
+ // export of the module gets a bare stub.
1151
+ //
1152
+ // Both halves matter. The full surface is what stops a transitive importer
1153
+ // receiving undefined from a module jest.mock has replaced wholesale. But
1154
+ // resolving a fixture for all ~70 exports of a real api client duplicates
1155
+ // those bodies into every spec — measured at 793 KB across 31 specs against
1156
+ // 113 KB, with a single 30 KB file. An export nobody in this render calls
1157
+ // only needs to EXIST.
1158
+ // MODULE-SIDE names, because `names` above comes from exportedFunctions and
1159
+ // is what the module exports. `mi.names` is what THIS file bound them to, so
1160
+ // comparing the two silently misses every aliased import:
1161
+ //
1162
+ // import { listItems as fetchItems } from "../lib/api";
1163
+ //
1164
+ // `used.has("listItems")` was false, so listItems fell to the bare `{}` stub
1165
+ // — with NO marker — while the identical unaliased import got its real
1166
+ // inferred shape. That is R-3's poisoning shape exactly: the destructure
1167
+ // binds undefined, the screen renders its error state, and the failure looks
1168
+ // like the component. The web emitter has read importedNames since it was
1169
+ // added (mocks.ts membershipName); the native copy never did.
1170
+ const used = new Set(mi.names.map((local, i) => mi.importedNames?.[i] ?? local));
1171
+ // The reverse: module-side name -> what THIS file calls it. The three lookups
1172
+ // below all read facts collected from the component's own subtree, where the
1173
+ // name that appears is the LOCAL one. Querying them with the module-side name
1174
+ // made an aliased import lose its loaded-state test AND its skip — the
1175
+ // component simply vanished from that half of the emitter.
1176
+ const localOf = new Map(
1177
+ mi.names.map((local, i) => [mi.importedNames?.[i] ?? local, local] as const),
1178
+ );
1179
+ const bodies = names.map((n) => {
1180
+ if (!used.has(n)) return ` ${n}: jest.fn(async () => ({})),`;
1181
+ const value = annotatedReturnValue(mi.resolvedPath as string, n, resolver, cache);
1182
+ // Annotated `Promise<void>`: undefined IS the realistic return value, so
1183
+ // no marker. `logout`, `changePassword` and `forgotPassword` in the
1184
+ // dogfood app each carried a TODO telling the reader to invent a shape
1185
+ // for a function that returns nothing.
1186
+ if (value === VOID_RETURN) {
1187
+ return ` ${n}: jest.fn(async () => undefined), /* annotated Promise<void> */`;
1188
+ }
1189
+ if (value === undefined) {
1190
+ // No resolvable annotation: an honest empty object plus the marker, which
1191
+ // is the same bail resolveDepReturns makes rather than inventing a shape.
1192
+ todo?.("uninferred-return-shape");
1193
+ return ` ${n}: jest.fn(async () => ({})), // TODO(flawless): give this a realistic return value`;
1194
+ }
1195
+ // The same shape with ONE row, kept for a loaded-state test. The default
1196
+ // mock stays empty so the assertions derived from the initial render hold;
1197
+ // this is the declared, deliberate other state.
1198
+ // Only a function this component actually CALLS when it mounts. The
1199
+ // loaded-state test mocks one to return a row and asserts the row shows;
1200
+ // if mounting never reaches the call, the mock is irrelevant and the test
1201
+ // fails on a healthy screen. Eleven of the dogfood app's thirty-one failed
1202
+ // exactly so — `reportJob` is a submit handler's mutation, and
1203
+ // `listBuildingInvitations` sits behind `if (isTrustee)`.
1204
+ const local = localOf.get(n) ?? n;
1205
+ const rowFields = renderCalls.get(local);
1206
+ if (
1207
+ hasCollection(value) &&
1208
+ (mine?.has(local) ?? true) &&
1209
+ !(mountCalls.has(local) && rowFields !== undefined)
1210
+ ) {
1211
+ rejected.push({
1212
+ fn: n,
1213
+ reason: !mountCalls.has(local)
1214
+ ? "mounting this screen never calls it — it runs from an event handler, or behind a branch"
1215
+ : // Deliberately "nothing this emitter can trace" rather than "it is
1216
+ // never rendered". Both are reasons the test cannot be written, and
1217
+ // the emitter cannot tell them apart: it follows
1218
+ // `const { x } = await fn()` and Promise.all into a useState setter,
1219
+ // and not a `.then()` callback. Claiming the screen does not render
1220
+ // the rows is a statement about the COMPONENT, and on a screen that
1221
+ // loads through .then() it is simply false.
1222
+ "nothing this emitter can trace connects what it returns to a rendered list",
1223
+ });
1224
+ }
1225
+ if (hasCollection(value) && mountCalls.has(local) && rowFields !== undefined) {
1226
+ loaded.push({
1227
+ fn: n,
1228
+ from: spec,
1229
+ rows: JSON.stringify(value),
1230
+ // The row's OWN fields when this collection is mapped inline, and the
1231
+ // subtree's file-wide set only when it is handed to a FlatList, whose
1232
+ // renderItem is not read here.
1233
+ witness: displayWitness(value, rowFields.size > 0 ? rowFields : renderedFields),
1234
+ });
1235
+ }
1236
+ return ` ${n}: jest.fn(async () => (${JSON.stringify(emptyCollections(value))})), /* shape inferred from its Promise<T> annotation */`;
1237
+ });
1238
+ out.push(
1239
+ `// ${spec} performs fetch(), so an unmocked render would make a LIVE`,
1240
+ "// HTTP call — which is not a test.",
1241
+ `jest.mock(${JSON.stringify(spec)}, () => ({`,
1242
+ ...bodies,
1243
+ "}));",
1244
+ "",
1245
+ );
1246
+ }
1247
+ return out;
1248
+ }
1249
+
1250
+ /** A string safe to interpolate into a double-quoted, non-template test title. */
1251
+ function safeTitle(s: string): string {
1252
+ return titleSafe(s.replace(/"/g, "'"));
1253
+ }
1254
+
1255
+ /**
1256
+ * The resolved shape, with every array emptied.
1257
+ *
1258
+ * Both halves matter, and neither is optional.
1259
+ *
1260
+ * The SHAPE has to be real: a bare `{}` means `const { items } = await list()`
1261
+ * binds items to undefined, `setItems(undefined)` follows, and the next
1262
+ * `items.length` throws into the app's own ErrorBoundary — so the screen renders
1263
+ * "Something went wrong" and every assertion fails for a reason that looks like
1264
+ * the component.
1265
+ *
1266
+ * The arrays have to be EMPTY, and this is the subtler half. Every assertion in
1267
+ * the spec describes the render `renderPath` evaluated, and it evaluates at the
1268
+ * component's INITIAL state — `useState([])`, an empty store. A fixture carrying
1269
+ * one row flips the component into its loaded branch, so the empty-state copy the
1270
+ * analyser harvested is genuinely no longer on screen and the spec fails while
1271
+ * both halves are behaving correctly. Measured: a one-row fixture put three of
1272
+ * the dogfood app's screens into exactly that mismatch.
1273
+ *
1274
+ * So the fixture is the richest one that does not change which branch renders.
1275
+ * Describing the loaded branch means teaching the analyser about it, which is a
1276
+ * different piece of work than mocking.
1277
+ */
1278
+ function emptyCollections(value: unknown): unknown {
1279
+ if (Array.isArray(value)) return [];
1280
+ if (value && typeof value === "object") {
1281
+ return Object.fromEntries(
1282
+ Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, emptyCollections(v)]),
1283
+ );
1284
+ }
1285
+ return value;
1286
+ }
1287
+
1288
+ /** Whether a resolved shape contains a collection anywhere. */
1289
+ function hasCollection(value: unknown): boolean {
1290
+ if (Array.isArray(value)) return true;
1291
+ if (value && typeof value === "object") {
1292
+ return Object.values(value as Record<string, unknown>).some(hasCollection);
1293
+ }
1294
+ return false;
1295
+ }
1296
+
1297
+ /**
1298
+ * A string from the fixture that a row is actually likely to DISPLAY.
1299
+ *
1300
+ * The first string leaf is usually `id`, which almost nothing renders — the first
1301
+ * cut asserted `flawless-id` and 21 suites went red because the row shows a name,
1302
+ * not a key. So the search is by FIELD NAME, over the handful of keys that carry
1303
+ * display text, and it gives up rather than guessing when none is present.
1304
+ *
1305
+ * The synthesised values are `flawless-<field>`, which appear nowhere else in the
1306
+ * app — so finding one on screen proves the ROW rendered, rather than that some
1307
+ * other copy happened to match.
1308
+ */
1309
+ /**
1310
+ * Fallback key names, used only when the subtree's own evidence is unavailable —
1311
+ * a component whose rows come from a child the analyser could not reach.
1312
+ */
1313
+ const DISPLAY_KEYS = [
1314
+ "name",
1315
+ "title",
1316
+ "label",
1317
+ "subject",
1318
+ "heading",
1319
+ "business_name",
1320
+ "full_name",
1321
+ "display_name",
1322
+ "text",
1323
+ "body",
1324
+ "message",
1325
+ "description",
1326
+ "email",
1327
+ ];
1328
+
1329
+ function displayWitness(value: unknown, rendered: Set<string>): string | undefined {
1330
+ if (Array.isArray(value)) {
1331
+ for (const v of value) {
1332
+ const found = displayWitness(v, rendered);
1333
+ if (found) return found;
1334
+ }
1335
+ return undefined;
1336
+ }
1337
+ if (!value || typeof value !== "object") return undefined;
1338
+ const obj = value as Record<string, unknown>;
1339
+ // The subtree's own evidence first: a field some component renders as text.
1340
+ for (const [key, v] of Object.entries(obj)) {
1341
+ if (rendered.has(key) && typeof v === "string" && v.startsWith("flawless-")) return v;
1342
+ }
1343
+ for (const key of DISPLAY_KEYS) {
1344
+ const v = obj[key];
1345
+ if (typeof v === "string" && v.startsWith("flawless-")) return v;
1346
+ }
1347
+ for (const v of Object.values(obj)) {
1348
+ const found = displayWitness(v, rendered);
1349
+ if (found) return found;
1350
+ }
1351
+ return undefined;
1352
+ }
1353
+
1354
+ /**
1355
+ * A test for the screen's LOADED state.
1356
+ *
1357
+ * The default mock returns empty collections on purpose — every assertion the
1358
+ * analyser derived describes the render `renderPath` evaluated, which is the
1359
+ * component's initial state. But that leaves the loaded branch of every list
1360
+ * screen unexecuted, and on the dogfood app that branch is where most of the
1361
+ * missing BRANCH coverage lives (39.2% on mobile).
1362
+ *
1363
+ * So the loaded state gets its own test, which declares the state it is setting
1364
+ * up instead of changing the state every other test runs in. The witness is a
1365
+ * synthesised `flawless-<field>` value from the fixture, which appears nowhere
1366
+ * else in the app — so finding it on screen proves the row rendered, rather than
1367
+ * that some other copy happened to match.
1368
+ *
1369
+ * Skipped rather than asserted when the fixture has no string leaf to witness:
1370
+ * a collection of numbers renders nothing uniquely identifiable, and a test that
1371
+ * cannot tell the loaded render from the empty one is not a test.
1372
+ */
1373
+ function loadedStateTests(
1374
+ fixtures: LoadedFixture[],
1375
+ rejected: RejectedFixture[],
1376
+ mocked: boolean,
1377
+ todo: TodoRecorder | undefined,
1378
+ ): string[] {
1379
+ if (!mocked) return [];
1380
+ if (fixtures.length === 0) {
1381
+ // Nothing qualified. Say which of the three it was, per function, rather
1382
+ // than emitting nothing — the absence of a loaded-state test on a list
1383
+ // screen is the kind of gap that reads as "covered" if it is silent.
1384
+ if (rejected.length === 0) return [];
1385
+ const out: string[] = [];
1386
+ for (const r of rejected.slice(0, 3)) {
1387
+ todo?.("other");
1388
+ out.push(` test.skip("loaded state via ${r.fn} — ${safeTitle(r.reason)}", () => {});`, "");
1389
+ }
1390
+ return out;
1391
+ }
1392
+ const out: string[] = [];
1393
+ // ONE recipe per spec, not one per mocked collection. A detail screen mocks
1394
+ // half a dozen api functions and the rest are the same recipe with a different
1395
+ // fixture — repeating it turns a useful pointer into noise, and noise is how a
1396
+ // skip list stops being read. Prefer one with a witness.
1397
+ const chosen = fixtures.find((f) => f.witness !== undefined) ?? fixtures[0];
1398
+ for (const f of chosen ? [chosen] : []) {
1399
+ if (f.witness === undefined) {
1400
+ out.push(
1401
+ ` test.skip("${f.fn}: loaded state — its fixture has no unique string to witness, ` +
1402
+ `so a loaded render cannot be told from an empty one", () => {});`,
1403
+ "",
1404
+ );
1405
+ continue;
1406
+ }
1407
+ // A REAL test now, where it used to be a skip carrying a recipe.
1408
+ //
1409
+ // The skip existed because enabling it failed on eleven of the dogfood app's
1410
+ // thirty-one screens, and the note here blamed "an external store the
1411
+ // harness does not reach". That was a guess and it was wrong. Three causes,
1412
+ // all visible in the AST and all now filtered on:
1413
+ //
1414
+ // the function is never called on mount `reportJob` is a submit handler
1415
+ // the call is behind a branch `if (isTrustee) listBuilding…()`
1416
+ // what it returns is never rendered memberships decide a boolean
1417
+ //
1418
+ // plus one that was not a filter but a defect: a SIBLING call on the same
1419
+ // mount path whose shape could not be inferred mocked to `{}`, so
1420
+ // `const [{ poll: p }] = await Promise.all([getPoll(…), …])` bound p to
1421
+ // undefined, `p.my_vote_option_id` threw into the screen's own catch, and
1422
+ // the error state rendered while the test looked for a row. That is fixed at
1423
+ // the source — interface heritage and Record<K,V> resolve now — and the
1424
+ // dogfood app has no uninferred api shapes left.
1425
+ //
1426
+ // Fourteen screens qualify and all fourteen pass. The seventeen that do not
1427
+ // qualify get the skip below, which names which of the three it was.
1428
+ out.push(
1429
+ ` test("renders the loaded state when ${f.fn} returns a row", async () => {`,
1430
+ ` (${f.fn} as unknown as jest.Mock).mockResolvedValue(${f.rows});`,
1431
+ " mount();",
1432
+ " // The witness is a synthesised value from a field THIS list's row",
1433
+ " // renders as a JSX child — so finding it proves the row rendered,",
1434
+ " // rather than that some other element in the file happened to match.",
1435
+ ` expect((await screen.findAllByText(${JSON.stringify(f.witness)}, { exact: false })).length).toBeGreaterThan(0);`,
1436
+ " });",
1437
+ "",
1438
+ );
1439
+ }
1440
+ return out;
1441
+ }
1442
+
1443
+ /** The spec's import path for a component, relative to where the spec is written. */
1444
+ function importPathOf(fromDir: string, info: { filePath: string }): string {
1445
+ return relativeToEmitted(fromDir, info.filePath).replace(/\.(tsx|jsx|ts|js)$/, "");
1446
+ }
1447
+
1448
+ /**
1449
+ * The native peer of undecidableRenderSpec.
1450
+ *
1451
+ * Same reasoning: with no decidable initial render there is nothing to assert
1452
+ * that is not a claim about a branch the defaults may never take. On this plane
1453
+ * the mount test is `expect(screen.toJSON()).not.toBeNull()`, which is exactly
1454
+ * the assertion that fails on a boundary rendering its children — so emitting it
1455
+ * would be the false-red half of the same defect.
1456
+ */
1457
+ function undecidableNativeSpec(specName: string, imported: string, importPath: string): string {
1458
+ return `// @flawless-generated
1459
+ // Component — regenerate with --force.
1460
+
1461
+ import ${imported} from ${JSON.stringify(importPath)};
1462
+
1463
+ void ${specName};
1464
+
1465
+ // render() branches on a condition the analyser could not decide, and every
1466
+ // branch returns content — so there is no single render to describe.
1467
+ //
1468
+ // To make it assertable, give the deciding value a knowable initial state (a
1469
+ // useState with a literal, or a class \`state = { … }\` field this can read), or
1470
+ // pass it as a prop the spec controls.
1471
+ test.skip("${specName} — 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", () => {});
1472
+ `;
1473
+ }
1474
+
1475
+ /**
1476
+ * The native peer of errorBoundarySpec.
1477
+ *
1478
+ * Same contract, same two paths. RNTL's getByText throws when it finds nothing,
1479
+ * so `toBeTruthy()` on the result is a real assertion rather than a formality.
1480
+ */
1481
+ function errorBoundaryNativeSpec(
1482
+ specName: string,
1483
+ imported: string,
1484
+ importPath: string,
1485
+ witness: string,
1486
+ ): string {
1487
+ return `// @flawless-generated
1488
+ // Error boundary — regenerate with --force.
1489
+
1490
+ import { render, screen } from "@testing-library/react-native";
1491
+ import { Text } from "react-native";
1492
+ import ${imported} from ${JSON.stringify(importPath)};
1493
+
1494
+ const CHILD_TEXT = "flawless child";
1495
+
1496
+ /** A child that throws on render — the only way into a boundary's caught state. */
1497
+ function Boom(): never {
1498
+ throw new Error("flawless: thrown by a child");
1499
+ }
1500
+
1501
+ describe(${JSON.stringify(specName)}, () => {
1502
+ test("renders its children when nothing throws", () => {
1503
+ render(
1504
+ <${specName}>
1505
+ <Text>{CHILD_TEXT}</Text>
1506
+ </${specName}>,
1507
+ );
1508
+ expect(screen.getByText(CHILD_TEXT)).toBeTruthy();
1509
+ });
1510
+
1511
+ test("renders its fallback when a child throws", () => {
1512
+ // React logs every error a boundary catches. Silenced HERE only — around the
1513
+ // happy path it would hide a real one.
1514
+ const logged = jest.spyOn(console, "error").mockImplementation(() => {});
1515
+ try {
1516
+ render(
1517
+ <${specName}>
1518
+ <Boom />
1519
+ </${specName}>,
1520
+ );
1521
+ expect(screen.getByText(${JSON.stringify(witness)}, { exact: false })).toBeTruthy();
1522
+ } finally {
1523
+ logged.mockRestore();
1524
+ }
1525
+ });
1526
+ });
1527
+ `;
1528
+ }