@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.
- package/.agents/skills/flawless-a11y/SKILL.md +113 -0
- package/.agents/skills/flawless-api/SKILL.md +159 -0
- package/.agents/skills/flawless-api-fixtures/SKILL.md +144 -0
- package/.agents/skills/flawless-e2e/SKILL.md +155 -0
- package/.agents/skills/flawless-expo/SKILL.md +283 -0
- package/.agents/skills/flawless-findings/SKILL.md +116 -0
- package/.agents/skills/flawless-load/SKILL.md +112 -0
- package/.agents/skills/flawless-nextjs/SKILL.md +198 -0
- package/.agents/skills/flawless-perf-harness/SKILL.md +158 -0
- package/.agents/skills/flawless-perf-static/SKILL.md +131 -0
- package/.agents/skills/flawless-react/SKILL.md +403 -0
- package/.agents/skills/flawless-react-rules/SKILL.md +96 -0
- package/.agents/skills/flawless-regen/SKILL.md +135 -0
- package/.agents/skills/flawless-unit-browser/SKILL.md +178 -0
- package/LICENSE +21 -0
- package/README.md +611 -2
- package/bin/flawless-react +4 -0
- package/package.json +67 -4
- package/scripts/postinstall.d.mts +46 -0
- package/scripts/postinstall.mjs +127 -0
- package/src/analyse/authShims.ts +220 -0
- package/src/analyse/bootability.ts +257 -0
- package/src/analyse/childBridge.ts +302 -0
- package/src/analyse/childrenIntoSlot.ts +66 -0
- package/src/analyse/compoundFamily.ts +100 -0
- package/src/analyse/dataExports.ts +200 -0
- package/src/analyse/extractChildAttrs.ts +112 -0
- package/src/analyse/extractDataDeps.ts +857 -0
- package/src/analyse/extractDisclosures.ts +186 -0
- package/src/analyse/extractFetchCalls.ts +81 -0
- package/src/analyse/extractFlows.ts +239 -0
- package/src/analyse/extractGuards.ts +270 -0
- package/src/analyse/extractHooks.ts +237 -0
- package/src/analyse/extractInteractions.ts +1421 -0
- package/src/analyse/extractLogging.ts +45 -0
- package/src/analyse/extractNativeA11y.ts +138 -0
- package/src/analyse/extractPredicates.ts +784 -0
- package/src/analyse/extractPropUsage.ts +162 -0
- package/src/analyse/extractProps.ts +1212 -0
- package/src/analyse/extractRenderMode.ts +165 -0
- package/src/analyse/extractRootTag.ts +117 -0
- package/src/analyse/extractState.ts +361 -0
- package/src/analyse/extractStaticText.ts +212 -0
- package/src/analyse/extractVariants.ts +186 -0
- package/src/analyse/harvestApiEndpoints.ts +284 -0
- package/src/analyse/hostProps.ts +185 -0
- package/src/analyse/hostTag.ts +72 -0
- package/src/analyse/htmlContentModel.ts +94 -0
- package/src/analyse/index.ts +744 -0
- package/src/analyse/mountPathCalls.ts +442 -0
- package/src/analyse/nativeContentModel.ts +129 -0
- package/src/analyse/nativeDialect.ts +83 -0
- package/src/analyse/nativeProps.ts +339 -0
- package/src/analyse/ownReturns.ts +31 -0
- package/src/analyse/parseFile.ts +919 -0
- package/src/analyse/pos.ts +8 -0
- package/src/analyse/providerWraps.ts +1977 -0
- package/src/analyse/pureModule.ts +88 -0
- package/src/analyse/reexportTable.ts +148 -0
- package/src/analyse/refs.ts +195 -0
- package/src/analyse/renderPath.ts +441 -0
- package/src/analyse/resolveDepReturns.ts +370 -0
- package/src/analyse/resolveIndexKeys.ts +98 -0
- package/src/analyse/resolveModule.ts +431 -0
- package/src/analyse/resolveTagRootTags.ts +103 -0
- package/src/analyse/serverActionReturns.ts +213 -0
- package/src/analyse/sourceCache.ts +263 -0
- package/src/analyse/spreadSiblings.ts +98 -0
- package/src/analyse/walkChildren.ts +98 -0
- package/src/analyse/zodSchema.ts +424 -0
- package/src/bin.ts +23 -0
- package/src/changed.ts +221 -0
- package/src/cli.ts +560 -0
- package/src/cliFlags.ts +281 -0
- package/src/core.ts +1229 -0
- package/src/docLine.ts +122 -0
- package/src/emitPaths.ts +51 -0
- package/src/expo/detect.ts +184 -0
- package/src/expo/refusal.ts +62 -0
- package/src/expo/routes.ts +145 -0
- package/src/index.ts +95 -0
- package/src/mcp/analysisTools.ts +720 -0
- package/src/mcp/infoTools.ts +135 -0
- package/src/mcp/protocol.ts +130 -0
- package/src/mcp/run.ts +269 -0
- package/src/mcp/runTools.ts +362 -0
- package/src/mcp/server.ts +279 -0
- package/src/mcp/spec.ts +86 -0
- package/src/mcp/support.ts +268 -0
- package/src/mcp/tools.ts +75 -0
- package/src/nextjs/apiRoutes.ts +116 -0
- package/src/nextjs/detect.ts +397 -0
- package/src/nextjs/emit.ts +106 -0
- package/src/nextjs/liveSpec.ts +79 -0
- package/src/nextjs/mswHandlers.ts +55 -0
- package/src/nextjs/providerWrapper.ts +603 -0
- package/src/nextjs/routes.ts +86 -0
- package/src/nextjs/swagger.ts +32 -0
- package/src/packageJson.ts +414 -0
- package/src/platform.ts +18 -0
- package/src/prompt.ts +132 -0
- package/src/reactCompiler.ts +165 -0
- package/src/reporters/findings.ts +156 -0
- package/src/reporters/report.ts +463 -0
- package/src/runner/runTests.ts +402 -0
- package/src/scaffold/adoptStyle.ts +181 -0
- package/src/scaffold/index.ts +253 -0
- package/src/scaffold/openapi.ts +183 -0
- package/src/scaffold/templates.ts +2379 -0
- package/src/scaffold-tests/index.ts +463 -0
- package/src/scaffold-tests/mockValues.ts +208 -0
- package/src/scaffold-tests/mocks.ts +794 -0
- package/src/scaffold-tests/nativeIndex.ts +52 -0
- package/src/scaffold-tests/nativeTemplates.ts +1528 -0
- package/src/scaffold-tests/postDispatch.ts +152 -0
- package/src/scaffold-tests/registry.ts +443 -0
- package/src/scaffold-tests/selectors.ts +493 -0
- package/src/scaffold-tests/selectorsNative.ts +273 -0
- package/src/scaffold-tests/stamp.ts +79 -0
- package/src/scaffold-tests/templates.ts +3578 -0
- package/src/skills/cli.ts +248 -0
- package/src/skills/compat.ts +166 -0
- package/src/skills/data.ts +192 -0
- package/src/skills/md.d.ts +7 -0
- package/src/skills/menu.ts +55 -0
- package/src/skills/spec.ts +227 -0
- package/src/suiteScripts.ts +110 -0
- package/src/tasks/a11yNative.ts +281 -0
- package/src/tasks/a11yScan.ts +67 -0
- package/src/tasks/biomeRunner.ts +301 -0
- package/src/tasks/biomeSweep.ts +166 -0
- package/src/tasks/e2eExpo.ts +436 -0
- package/src/tasks/e2eRoutes.ts +756 -0
- package/src/tasks/eslintReactRules.ts +67 -0
- package/src/tasks/index.ts +24 -0
- package/src/tasks/loadExpo.ts +273 -0
- package/src/tasks/loadPipes.ts +750 -0
- package/src/tasks/perfHarness.ts +145 -0
- package/src/tasks/staticPerfAnalysis.ts +510 -0
- package/src/tasks/types.ts +1 -0
- package/src/tasks/unitAndBrowser.ts +462 -0
- package/src/types.ts +1419 -0
- 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
|
+
}
|