@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,794 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vi.mock() emission, shared by every spec kind.
|
|
3
|
+
*
|
|
4
|
+
* This used to live inline in rscSpec, which meant client specs got no mocks at
|
|
5
|
+
* all: a client component importing next/navigation, or calling a server action
|
|
6
|
+
* through useActionState, blew up on import. constrainedSpec had the same hole —
|
|
7
|
+
* a layout importing "server-only" failed at collection time.
|
|
8
|
+
*
|
|
9
|
+
* The two modes differ in exactly one way, and it matters:
|
|
10
|
+
*
|
|
11
|
+
* rsc — allowlist, then mock every remaining project-local import. A server
|
|
12
|
+
* component's whole job is fetching, so its imports are data access
|
|
13
|
+
* and stubbing them is the point.
|
|
14
|
+
* client — allowlist ONLY. A client component's imports are mostly UI and
|
|
15
|
+
* helpers: mocking `cn` would strip every className off the markup,
|
|
16
|
+
* and mocking @/components/ui/button would replace the very button
|
|
17
|
+
* the interaction tests click. Anything not on the list is left real.
|
|
18
|
+
*/
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import type {
|
|
21
|
+
ComponentInfo,
|
|
22
|
+
DataDepInfo,
|
|
23
|
+
ModuleImportInfo,
|
|
24
|
+
NextjsApiRoute,
|
|
25
|
+
PropValue,
|
|
26
|
+
TodoRecorder,
|
|
27
|
+
} from "../types.ts";
|
|
28
|
+
import { type DepFieldResolver, fieldValueLiteral, mockLiteral } from "./mockValues.ts";
|
|
29
|
+
import { withRouteParams } from "./registry.ts";
|
|
30
|
+
|
|
31
|
+
export type MockMode = "client" | "rsc";
|
|
32
|
+
|
|
33
|
+
export interface EmittedMocks {
|
|
34
|
+
/** vi.mock(...) statements, in import source order. */
|
|
35
|
+
statements: string[];
|
|
36
|
+
/** Specifiers actually mocked — for the run report. */
|
|
37
|
+
mocked: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Modules whose real implementation must survive: React itself, asset imports,
|
|
42
|
+
* and the Next.js surface that is not on the allowlist below.
|
|
43
|
+
*
|
|
44
|
+
* Only consulted in "rsc" mode — "client" mode reaches nothing past the
|
|
45
|
+
* allowlist, so this regex never has to grow to protect it.
|
|
46
|
+
*/
|
|
47
|
+
const MOCK_SKIP =
|
|
48
|
+
/^(react($|\/)|next\/(?!navigation|headers|cache)|.*\.(css|scss|sass|less|svg|png|jpe?g|gif|woff2?)$)/;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Bindings whose modules must stay real even in "rsc" mode: pure, synchronous,
|
|
52
|
+
* class-string producers. Mocking them strips every className off the markup —
|
|
53
|
+
* or, with the generic async stub, injects a Promise where a string belongs and
|
|
54
|
+
* className renders as "[object Promise]".
|
|
55
|
+
*
|
|
56
|
+
* Membership is checked against the module-side (IMPORTED) name, so
|
|
57
|
+
* `import { cn as cx }` is still recognised. The decision is made per MODULE,
|
|
58
|
+
* merging every import statement of the same specifier: collectModuleImports
|
|
59
|
+
* emits one ModuleImportInfo per statement, and exempting one statement while
|
|
60
|
+
* another statement's mock replaces the whole module factory would erase the
|
|
61
|
+
* very binding the exemption promised to keep.
|
|
62
|
+
*
|
|
63
|
+
* every imported binding pure → the module is not mocked at all;
|
|
64
|
+
* mixed pure + data → the module IS mocked, and each pure binding
|
|
65
|
+
* gets a synchronous join stub (under the
|
|
66
|
+
* module-side export name) beside the data stubs.
|
|
67
|
+
*
|
|
68
|
+
* Names the render graph adds (ModuleImportInfo.mergedNames) are COVERED by a
|
|
69
|
+
* forced factory but never FORCE one: the mock decision is evidence of what
|
|
70
|
+
* this render needs, and no file relying on the mock named them.
|
|
71
|
+
*/
|
|
72
|
+
const PURE_UTILITY_BINDINGS = new Set(["cn", "clsx", "cva", "twMerge", "tw"]);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Class-string producer stand-in for a pure binding inside a mocked module:
|
|
76
|
+
* synchronous and string-valued, because the component drops its result
|
|
77
|
+
* straight into className — vi.fn(async …) there renders "[object Promise]".
|
|
78
|
+
*/
|
|
79
|
+
const PURE_UTILITY_STUB = `vi.fn((...args: unknown[]) => args.filter(Boolean).join(" "))`;
|
|
80
|
+
|
|
81
|
+
interface MergedBinding {
|
|
82
|
+
/** Module-side export name — `cn` for `import { cn as cx }`. The returns map
|
|
83
|
+
* is keyed by it, since that is what the declaration is named. */
|
|
84
|
+
moduleName?: string;
|
|
85
|
+
local: string;
|
|
86
|
+
isDefault: boolean;
|
|
87
|
+
pure: boolean;
|
|
88
|
+
/** The declaration is a module-side data constant (ModuleImportInfo
|
|
89
|
+
* .dataExports): a value read, never a dep to stub. */
|
|
90
|
+
data: boolean;
|
|
91
|
+
/** The name only exists because the render graph imports it — no file that
|
|
92
|
+
* relies on the MOCK named it. It must be COVERED by the factory (a mocked
|
|
93
|
+
* module serves every importer), but it must never FORCE the mock: lib/utils,
|
|
94
|
+
* pure for the unit's `{ cn }`, gained a graph-only `timeAgo` and got mocked
|
|
95
|
+
* `async () => ({})` — a promise rendered as a child, an empty container. */
|
|
96
|
+
graphOnly?: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface MergedModule {
|
|
100
|
+
/** Original specifier texts (dataDeps records these, not the re-based one). */
|
|
101
|
+
specifiers: Set<string>;
|
|
102
|
+
bindings: MergedBinding[];
|
|
103
|
+
/** Module-side export name → its own return annotation, merged across the
|
|
104
|
+
* declarations that import from this module. See ModuleImportInfo. */
|
|
105
|
+
returns: Map<string, { value: PropValue; sync: boolean }>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Default and namespace entries have no module-side name; the local stands in. */
|
|
109
|
+
function membershipName(mi: ModuleImportInfo, index: number): string {
|
|
110
|
+
return mi.importedNames?.[index] ?? mi.names[index] ?? "";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function mergeInto(groups: Map<string, MergedModule>, mi: ModuleImportInfo, key: string): void {
|
|
114
|
+
let group = groups.get(key);
|
|
115
|
+
if (!group) {
|
|
116
|
+
group = { specifiers: new Set(), bindings: [], returns: new Map() };
|
|
117
|
+
groups.set(key, group);
|
|
118
|
+
}
|
|
119
|
+
group.specifiers.add(mi.specifier);
|
|
120
|
+
for (const [name, r] of Object.entries(mi.importedReturns ?? {})) group.returns.set(name, r);
|
|
121
|
+
mi.names.forEach((local, i) => {
|
|
122
|
+
// A `type` specifier is erased at compile time, so the module does not
|
|
123
|
+
// export that name at RUNTIME. It can neither force a mock — every shadcn
|
|
124
|
+
// `ui/*` module's `import { cva, type VariantProps }` did, and the factory
|
|
125
|
+
// that follows replaced `cva` with a class-string stub, which turned the
|
|
126
|
+
// app's own `const buttonVariants = cva(…)` into a string — nor be a factory
|
|
127
|
+
// entry: `VariantProps: vi.fn(…)` bound a name no importer can call.
|
|
128
|
+
if (mi.typeOnlyNames?.includes(local)) return;
|
|
129
|
+
const moduleName = membershipName(mi, i);
|
|
130
|
+
const isDefault = mi.hasDefault && i === 0;
|
|
131
|
+
const graphOnly = !isDefault && mi.mergedNames?.includes(local) === true;
|
|
132
|
+
// Dedup on the MODULE-side name: the factory keys on it, and two files
|
|
133
|
+
// importing { toast } and { toast as t } must produce ONE `toast:` entry,
|
|
134
|
+
// not two — a duplicate key in the factory object is at best noise. A
|
|
135
|
+
// default is NOT that binding: its module-side name is only the local
|
|
136
|
+
// text, and `default:` coexists with a same-named export.
|
|
137
|
+
const existing =
|
|
138
|
+
group.bindings.find(
|
|
139
|
+
(b) =>
|
|
140
|
+
!isDefault &&
|
|
141
|
+
!b.isDefault &&
|
|
142
|
+
(b.moduleName ?? b.local) === moduleName &&
|
|
143
|
+
(!b.graphOnly || graphOnly),
|
|
144
|
+
) ??
|
|
145
|
+
(!graphOnly ? group.bindings.find((b) => b.local === local) : undefined) ??
|
|
146
|
+
(graphOnly
|
|
147
|
+
? group.bindings.find((b) => b.local === local && (b.moduleName ?? b.local) === moduleName)
|
|
148
|
+
: undefined);
|
|
149
|
+
if (existing) return;
|
|
150
|
+
group.bindings.push({
|
|
151
|
+
local,
|
|
152
|
+
moduleName,
|
|
153
|
+
isDefault,
|
|
154
|
+
pure: PURE_UTILITY_BINDINGS.has(membershipName(mi, i)) || mi.isPureModule === true,
|
|
155
|
+
data: mi.dataExports?.includes(membershipName(mi, i)) ?? false,
|
|
156
|
+
graphOnly,
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* `specDir` is the absolute directory the spec will be written to. It matters:
|
|
163
|
+
* vitest resolves a relative vi.mock() specifier against the file containing the
|
|
164
|
+
* call, not against the component under test. Emitting the component's own
|
|
165
|
+
* "./actions" verbatim would mock a sibling of the SPEC — usually a module that
|
|
166
|
+
* does not exist, so the mock silently does nothing and the real module loads.
|
|
167
|
+
* Colocated specs happen to be immune; specs under flawless/unit/ are not.
|
|
168
|
+
*/
|
|
169
|
+
/**
|
|
170
|
+
* OpenAPI response example for a fetch shape, when the route table has one.
|
|
171
|
+
* Shared by the mock emitter (Path B of the dep-return inference) and the
|
|
172
|
+
* fetch-spec fixtures in templates.ts — one join, one normalisation.
|
|
173
|
+
*/
|
|
174
|
+
export function apiRouteFixture(
|
|
175
|
+
urlTemplate: string,
|
|
176
|
+
method: string,
|
|
177
|
+
apiRoutes: NextjsApiRoute[] | undefined,
|
|
178
|
+
): unknown {
|
|
179
|
+
if (!apiRoutes) return undefined;
|
|
180
|
+
const wanted = normalizeApiPath(urlTemplate);
|
|
181
|
+
for (const r of apiRoutes) {
|
|
182
|
+
if (normalizeApiPath(r.path) !== wanted) continue;
|
|
183
|
+
const body = r.responseBodies?.[method.toUpperCase()] ?? r.responseBodies?.GET;
|
|
184
|
+
if (body !== undefined) return body;
|
|
185
|
+
}
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Compare /api/x/{id}/y, /api/x/:id/y and /api/x/:buildingId/y as the same shape. */
|
|
190
|
+
export function normalizeApiPath(p: string): string {
|
|
191
|
+
return p
|
|
192
|
+
.replace(/\{[^}]+\}/g, ":_")
|
|
193
|
+
.replace(/:[A-Za-z0-9_]+/g, ":_")
|
|
194
|
+
.replace(/\/+$/, "");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Source interpolated into an emitted block comment: a star-slash inside it
|
|
198
|
+
// (a literal * in a fetch path, say) would close the comment early and leave
|
|
199
|
+
// the tail as broken code in the generated spec.
|
|
200
|
+
function commentSafe(text: string): string {
|
|
201
|
+
return text.split("*/").join("* /");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The fixture an uncertain/unknown dep still gets: the annotation-derived
|
|
206
|
+
* value the analyser stored, or the OpenAPI example joined here — emit time
|
|
207
|
+
* is where the route table lives. Undefined means the dep really is a gap:
|
|
208
|
+
* the {} stub, the TODO and the RSC skip escape all key off that.
|
|
209
|
+
*/
|
|
210
|
+
export function inferredDepReturn(
|
|
211
|
+
dep: DataDepInfo,
|
|
212
|
+
apiRoutes?: NextjsApiRoute[],
|
|
213
|
+
): { value: PropValue; provenance: "annotation" | "openapi" } | undefined {
|
|
214
|
+
if (dep.inferredReturn) return dep.inferredReturn;
|
|
215
|
+
if (dep.wrapsFetch) {
|
|
216
|
+
const body = apiRouteFixture(dep.wrapsFetch.path, dep.wrapsFetch.method, apiRoutes);
|
|
217
|
+
if (body !== undefined) return { provenance: "openapi", value: body as PropValue };
|
|
218
|
+
}
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function emitModuleMocks(
|
|
223
|
+
info: ComponentInfo,
|
|
224
|
+
mode: MockMode,
|
|
225
|
+
specDir?: string,
|
|
226
|
+
todo?: TodoRecorder,
|
|
227
|
+
apiRoutes?: NextjsApiRoute[],
|
|
228
|
+
): EmittedMocks {
|
|
229
|
+
const statements: string[] = [];
|
|
230
|
+
const mocked: string[] = [];
|
|
231
|
+
const seen = new Set<string>();
|
|
232
|
+
|
|
233
|
+
const push = (mi: ModuleImportInfo, make: (spec: string) => string): void => {
|
|
234
|
+
const spec = mockSpecifier(mi, specDir);
|
|
235
|
+
if (seen.has(spec)) return;
|
|
236
|
+
seen.add(spec);
|
|
237
|
+
statements.push(make(spec));
|
|
238
|
+
mocked.push(spec);
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
// RSC specs render their descendants, which import their own data modules;
|
|
242
|
+
// the tree's imports join the unit's (deduped by specifier at analyse time,
|
|
243
|
+
// and again through `seen`/`mergeInto` here). Client mode renders no async
|
|
244
|
+
// server tree, so it stays on the unit's imports alone.
|
|
245
|
+
const mockInputs =
|
|
246
|
+
mode === "rsc"
|
|
247
|
+
? [...(info.moduleImports ?? []), ...(info.treeImports ?? [])]
|
|
248
|
+
: (info.moduleImports ?? []);
|
|
249
|
+
|
|
250
|
+
// Merge the data-path statements per module BEFORE deciding anything: the
|
|
251
|
+
// pure-utility exemption and the mock factory must both see every binding the
|
|
252
|
+
// component imports from a module, however many statements deliver them.
|
|
253
|
+
const dataGroups = new Map<string, MergedModule>();
|
|
254
|
+
if (mode === "rsc") {
|
|
255
|
+
for (const mi of mockInputs) {
|
|
256
|
+
if (!reachesDataPath(mi)) continue;
|
|
257
|
+
// EVERY name a data-path statement imports is a module-side data
|
|
258
|
+
// constant → the module stays out of the groups, and the mock loop
|
|
259
|
+
// below passes it by entirely. The statement never stubs anything,
|
|
260
|
+
// and a factory replacement for it would starve transitive importers
|
|
261
|
+
// (json-ld reading SITE_INFO off a mock that only knew
|
|
262
|
+
// SOURCE_CODE_GITHUB_REPO — the chanhdai collection wall).
|
|
263
|
+
if (
|
|
264
|
+
mi.dataExports !== undefined &&
|
|
265
|
+
mi.names.length > 0 &&
|
|
266
|
+
mi.names.every((_, i) => mi.dataExports?.includes(membershipName(mi, i)))
|
|
267
|
+
) {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
mergeInto(dataGroups, mi, mockSpecifier(mi, specDir));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
for (const mi of mockInputs) {
|
|
275
|
+
if (mi.isTypeOnly) continue;
|
|
276
|
+
|
|
277
|
+
// --- allowlist, evaluated before the kind/MOCK_SKIP gates ------------------
|
|
278
|
+
// next/link is kind:"component" and is matched by MOCK_SKIP, so it would be
|
|
279
|
+
// excluded twice over if these ran in the other order.
|
|
280
|
+
if (mi.specifier === "server-only") {
|
|
281
|
+
push(mi, () => `vi.mock("server-only", () => ({}));`);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (mi.specifier === "next/navigation") {
|
|
285
|
+
push(mi, navigationMock);
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (mi.specifier === "next/headers") {
|
|
289
|
+
push(mi, headersMock);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (mi.specifier === "next/link") {
|
|
293
|
+
push(mi, (spec) => linkPassthroughMock(mi, spec));
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (mi.specifier === "swr") {
|
|
297
|
+
push(mi, () => swrMock());
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (mi.specifier.startsWith("next/font/")) {
|
|
301
|
+
push(mi, (spec) => fontMock(mi, spec));
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (mi.isServerAction) {
|
|
305
|
+
push(mi, (spec) => serverActionMock(mi, spec, todo));
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// --- everything else: server specs only -----------------------------------
|
|
310
|
+
if (mode === "client") continue;
|
|
311
|
+
// An async server CHILD cannot mount here: React's client renderer refuses
|
|
312
|
+
// an async function component, and a page containing one renders an empty
|
|
313
|
+
// container — every assertion after that is against nothing. The child is
|
|
314
|
+
// a data-fetching unit with its own spec, so this spec stubs it to null
|
|
315
|
+
// rather than claiming anything about its markup. Only when EVERY name
|
|
316
|
+
// imported from the module is such a component — a module mixing async and
|
|
317
|
+
// sync exports would need a factory half stub, half real, which lies about
|
|
318
|
+
// one of the halves.
|
|
319
|
+
if (mi.kind === "component" && allAsyncComponents(mi)) {
|
|
320
|
+
push(mi, (spec) => asyncChildStubMock(mi, spec));
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
if (!reachesDataPath(mi)) continue;
|
|
324
|
+
const group = dataGroups.get(mockSpecifier(mi, specDir));
|
|
325
|
+
if (!group) continue;
|
|
326
|
+
// Pure/data-only module → keep it real. GRAPH-ONLY names do not count:
|
|
327
|
+
// the exemption asks "does any file that renders NEED this mocked?" —
|
|
328
|
+
// lib/utils (pure `cn` for the unit) gained a graph-only `timeAgo` and
|
|
329
|
+
// was mocked `async () => ({})`; a promise rendered as a child suspends
|
|
330
|
+
// the tree and the spec asserted against an empty container.
|
|
331
|
+
if (group.bindings.length > 0 && group.bindings.every((b) => b.pure || b.data || b.graphOnly))
|
|
332
|
+
continue;
|
|
333
|
+
push(mi, (spec) => dataModuleMock(group, info, spec, todo, apiRoutes));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return { statements, mocked };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Roots whose deps cannot be mocked coherently: bare + dotted callees mixed
|
|
341
|
+
* on one binding, or one dotted path prefixing another. The emitter falls
|
|
342
|
+
* back to the honest {} for these — and the RSC skip escape must fire too,
|
|
343
|
+
* or the runtime crash on the missing member reads as an application
|
|
344
|
+
* failure (FR-014's exact failure mode, confined to the conflict shapes).
|
|
345
|
+
* One predicate, consumed by dataModuleMock AND uncertainEscape, so the
|
|
346
|
+
* fallback and the escape can never disagree.
|
|
347
|
+
*/
|
|
348
|
+
export function dottedMockConflicts(deps: DataDepInfo[]): boolean {
|
|
349
|
+
const byRoot = new Map<string, DataDepInfo[]>();
|
|
350
|
+
for (const d of deps) {
|
|
351
|
+
const group = byRoot.get(d.calleeRoot) ?? [];
|
|
352
|
+
group.push(d);
|
|
353
|
+
byRoot.set(d.calleeRoot, group);
|
|
354
|
+
}
|
|
355
|
+
for (const group of byRoot.values()) {
|
|
356
|
+
const dotted = group.filter((d) => d.callee !== d.calleeRoot);
|
|
357
|
+
if (dotted.length === 0) continue;
|
|
358
|
+
if (dotted.length !== group.length) return true;
|
|
359
|
+
if (dottedDepTree(dotted, () => "conflict-probe") === undefined) return true;
|
|
360
|
+
}
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Nested object source for dotted deps sharing one root binding:
|
|
366
|
+
* db.query.users.findMany + db.query.posts.findFirst →
|
|
367
|
+
* `{ query: { users: { findMany: <stub> }, posts: { findFirst: <stub> } } }`.
|
|
368
|
+
* Undefined on any conflict — one path prefixing another, or two deps
|
|
369
|
+
* claiming the same leaf differently — so the caller can fall back honestly.
|
|
370
|
+
*/
|
|
371
|
+
function dottedDepTree(
|
|
372
|
+
deps: DataDepInfo[],
|
|
373
|
+
stubOf: (dep: DataDepInfo, seed: string) => string,
|
|
374
|
+
): string | undefined {
|
|
375
|
+
interface TreeNode {
|
|
376
|
+
children: Map<string, TreeNode>;
|
|
377
|
+
leaf?: string;
|
|
378
|
+
}
|
|
379
|
+
const root: TreeNode = { children: new Map() };
|
|
380
|
+
for (const dep of deps) {
|
|
381
|
+
const segs = dep.callee.split(".").slice(1);
|
|
382
|
+
let cur = root;
|
|
383
|
+
for (const [i, seg] of segs.entries()) {
|
|
384
|
+
if (cur.leaf !== undefined) return undefined;
|
|
385
|
+
const last = i === segs.length - 1;
|
|
386
|
+
let next = cur.children.get(seg);
|
|
387
|
+
if (!next) {
|
|
388
|
+
next = { children: new Map() };
|
|
389
|
+
cur.children.set(seg, next);
|
|
390
|
+
}
|
|
391
|
+
if (last) {
|
|
392
|
+
if (next.children.size > 0) return undefined;
|
|
393
|
+
// Two deps on the same full path (the call appears twice): the first
|
|
394
|
+
// stub stands — same callee, same shape source.
|
|
395
|
+
next.leaf ??= stubOf(dep, seg);
|
|
396
|
+
}
|
|
397
|
+
cur = next;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
const print = (node: TreeNode): string => {
|
|
401
|
+
if (node.leaf !== undefined) return node.leaf;
|
|
402
|
+
const entries = [...node.children.entries()].map(([k, v]) => `${k}: ${print(v)}`);
|
|
403
|
+
return `{ ${entries.join(", ")} }`;
|
|
404
|
+
};
|
|
405
|
+
return print(root);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* The rsc-mode gates ahead of the data-module mock, shared by the merge
|
|
410
|
+
* pre-pass — grouping an allowlisted statement here would double-mock it.
|
|
411
|
+
*/
|
|
412
|
+
/** Every name this declaration imports resolves to an async function. */
|
|
413
|
+
function allAsyncComponents(mi: ModuleImportInfo): boolean {
|
|
414
|
+
const asyncSet = new Set(mi.asyncComponents ?? []);
|
|
415
|
+
if (asyncSet.size === 0 || mi.names.length === 0) return false;
|
|
416
|
+
return mi.names.every((_, i) => {
|
|
417
|
+
const moduleName = mi.hasDefault && i === 0 ? "default" : membershipName(mi, i);
|
|
418
|
+
return asyncSet.has(moduleName);
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Replaces a module whose every imported name is an async server component
|
|
424
|
+
* with null-rendering stubs. `() => null` and not a marker element: the page
|
|
425
|
+
* spec claims nothing about the child, and any markup here could collide with
|
|
426
|
+
* text the page's own assertions look for.
|
|
427
|
+
*/
|
|
428
|
+
function asyncChildStubMock(mi: ModuleImportInfo, spec: string): string {
|
|
429
|
+
const entries = mi.names.map((_, i) => {
|
|
430
|
+
const key = mi.hasDefault && i === 0 ? "default" : membershipName(mi, i);
|
|
431
|
+
return `${key}: () => null`;
|
|
432
|
+
});
|
|
433
|
+
return `// ${mi.names.join(", ")}: async server child(ren) with their own specs — a client render cannot await them.
|
|
434
|
+
vi.mock(${JSON.stringify(spec)}, () => ({ ${entries.join(", ")} }));`;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function reachesDataPath(mi: ModuleImportInfo): boolean {
|
|
438
|
+
if (mi.isTypeOnly || mi.isServerAction) return false;
|
|
439
|
+
if (
|
|
440
|
+
["server-only", "next/navigation", "next/headers", "next/link"].includes(mi.specifier) ||
|
|
441
|
+
mi.specifier.startsWith("next/font/")
|
|
442
|
+
) {
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
return mi.kind === "value" && !MOCK_SKIP.test(mi.specifier);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Bare and aliased specifiers ("next/link", "@/lib/dal") mean the same thing from
|
|
450
|
+
* any file, so they pass through. Only a relative specifier has to be re-based
|
|
451
|
+
* onto the spec's own directory.
|
|
452
|
+
*/
|
|
453
|
+
export function mockSpecifier(mi: ModuleImportInfo, specDir: string | undefined): string {
|
|
454
|
+
if (!mi.specifier.startsWith(".")) return mi.specifier;
|
|
455
|
+
if (!specDir || !mi.resolvedPath) return mi.specifier;
|
|
456
|
+
let rel = path.relative(specDir, mi.resolvedPath).split(path.sep).join("/");
|
|
457
|
+
if (!rel.startsWith(".")) rel = `./${rel}`;
|
|
458
|
+
return rel.replace(/\.(tsx|ts|jsx|js)$/, "");
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* swr@2.5 beta reads its cache with React 19's use(). The root layout's
|
|
463
|
+
* preload() puts a LIVE Promise into the cache; a client badge's first
|
|
464
|
+
* read hits it, use() throws the thenable, and renderToStaticMarkup —
|
|
465
|
+
* which cannot await one — dies with "A component suspended while
|
|
466
|
+
* responding to synchronous input" (aurorascharff RootLayout). A
|
|
467
|
+
* deterministic sync surface fixes it for good: config is a pass-
|
|
468
|
+
* through, preload does nothing, and reads answer empty-cache shapes
|
|
469
|
+
* instead of thenables. The factory lists the module's public surface:
|
|
470
|
+
* a missing export is a loud import-time error, so the superset is the
|
|
471
|
+
* safe direction.
|
|
472
|
+
*/
|
|
473
|
+
function swrMock(): string {
|
|
474
|
+
const read =
|
|
475
|
+
"vi.fn(() => ({ data: undefined, error: undefined, isLoading: false, isValidating: false, mutate: vi.fn() }))";
|
|
476
|
+
return `vi.mock("swr", () => ({
|
|
477
|
+
default: ${read},
|
|
478
|
+
useSWR: ${read},
|
|
479
|
+
SWRConfig: (props: { children?: unknown }) => props.children ?? null,
|
|
480
|
+
SWRProvider: (props: { children?: unknown }) => props.children ?? null,
|
|
481
|
+
preload: vi.fn(),
|
|
482
|
+
mutate: vi.fn(),
|
|
483
|
+
useSWRConfig: vi.fn(() => ({ mutate: vi.fn(), revalidate: vi.fn() })),
|
|
484
|
+
}));`;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function navigationMock(): string {
|
|
488
|
+
// `routerMock` is hoisted, and it has to be.
|
|
489
|
+
//
|
|
490
|
+
// useRouter used to return a FRESH object with FRESH spies on every call, so
|
|
491
|
+
// there was no handle to assert against: `router.push("/x")` inside a handler
|
|
492
|
+
// wrote to a spy the spec could never see. A navigation is one of the few
|
|
493
|
+
// things a click provably does, and it was unobservable.
|
|
494
|
+
//
|
|
495
|
+
// vi.hoisted is the only way to share a value INTO the factory: vi.mock
|
|
496
|
+
// factories are lifted above every import in the module, so a plain const
|
|
497
|
+
// declared here is still in the temporal dead zone when the factory runs.
|
|
498
|
+
return `const routerMock = vi.hoisted(() => ({
|
|
499
|
+
push: vi.fn(),
|
|
500
|
+
replace: vi.fn(),
|
|
501
|
+
refresh: vi.fn(),
|
|
502
|
+
back: vi.fn(),
|
|
503
|
+
forward: vi.fn(),
|
|
504
|
+
prefetch: vi.fn(),
|
|
505
|
+
}));
|
|
506
|
+
vi.mock("next/navigation", () => ({
|
|
507
|
+
notFound: vi.fn(() => { throw new Error("notFound() called"); }),
|
|
508
|
+
// These abort rendering in Next; a plain vi.fn() would let a guarded
|
|
509
|
+
// component run straight past its own guard and crash on the data it guards.
|
|
510
|
+
redirect: vi.fn(() => { throw new Error("NEXT_REDIRECT"); }),
|
|
511
|
+
permanentRedirect: vi.fn(() => { throw new Error("NEXT_REDIRECT"); }),
|
|
512
|
+
usePathname: vi.fn(() => "/"),
|
|
513
|
+
useRouter: vi.fn(() => routerMock),
|
|
514
|
+
useSearchParams: vi.fn(() => new URLSearchParams()),
|
|
515
|
+
useParams: vi.fn(() => ({})),
|
|
516
|
+
// Layout-segment hooks: a component that imports next/navigation gets this
|
|
517
|
+
// per-spec mock INSTEAD of the global one, so every hook the global factory
|
|
518
|
+
// exports has to be here too — a missing name is a "[vitest] No export"
|
|
519
|
+
// error at import time.
|
|
520
|
+
useSelectedLayoutSegment: vi.fn(() => null),
|
|
521
|
+
useSelectedLayoutSegments: vi.fn(() => []),
|
|
522
|
+
}));`;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function headersMock(): string {
|
|
526
|
+
return `vi.mock("next/headers", () => ({
|
|
527
|
+
headers: vi.fn(async () => new Headers()),
|
|
528
|
+
// The real cookie jar is Map-like AND has get/getAll/set/delete: which one a
|
|
529
|
+
// page calls is the page's choice ((await cookies()).has(SLOW) in
|
|
530
|
+
// ProfileFeed), so the stub answers every spelling.
|
|
531
|
+
cookies: vi.fn(async () => ({
|
|
532
|
+
get: vi.fn(() => undefined),
|
|
533
|
+
getAll: vi.fn(() => []),
|
|
534
|
+
has: vi.fn(() => false),
|
|
535
|
+
set: vi.fn(),
|
|
536
|
+
delete: vi.fn(),
|
|
537
|
+
})),
|
|
538
|
+
}));`;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* The async factory form is required, not stylistic: vi.mock factories are
|
|
543
|
+
* hoisted above every import in the module, so a synchronous factory that closed
|
|
544
|
+
* over a top-level `createElement` binding would throw
|
|
545
|
+
* "Cannot access 'createElement' before initialization".
|
|
546
|
+
*/
|
|
547
|
+
function linkPassthroughMock(mi: ModuleImportInfo, spec: string): string {
|
|
548
|
+
// Named exports ride along: the real hooks read the same AppRouterContext,
|
|
549
|
+
// and a name the file imports but the factory omits is a "[vitest] No
|
|
550
|
+
// export" error the first time the component calls it (useLinkStatus in
|
|
551
|
+
// nav-link.tsx). Keys are module-side names — aliasing is the importer's.
|
|
552
|
+
const keys = mi.importedNames ?? mi.names;
|
|
553
|
+
const named = keys
|
|
554
|
+
.filter((_, i) => !(mi.hasDefault && i === 0))
|
|
555
|
+
.map((n) =>
|
|
556
|
+
n === "useLinkStatus" ? `useLinkStatus: vi.fn(() => ({ pending: false }))` : `${n}: vi.fn()`,
|
|
557
|
+
);
|
|
558
|
+
return `vi.mock(${JSON.stringify(spec)}, async () => {
|
|
559
|
+
// The real <Link> reads Next's AppRouterContext, which jsdom has no way to
|
|
560
|
+
// provide. A passthrough keeps the anchor, its href and its text queryable.
|
|
561
|
+
const { createElement } = await import("react");
|
|
562
|
+
return {
|
|
563
|
+
default: (props: { href?: string | { pathname?: string }; children?: unknown }) => {
|
|
564
|
+
const { href, children, ...rest } = props;
|
|
565
|
+
const to = typeof href === "string" ? href : (href?.pathname ?? "#");
|
|
566
|
+
return createElement("a", { ...rest, href: to } as never, children as never);
|
|
567
|
+
},${named.length > 0 ? `\n ${named.join(", ")},` : ""}
|
|
568
|
+
};
|
|
569
|
+
});`;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* next/font loaders run at module scope and reach for the filesystem, so the
|
|
574
|
+
* import throws before any test body runs. The stub is keyed on the names the
|
|
575
|
+
* file actually imports rather than a catch-all Proxy — we know them exactly.
|
|
576
|
+
*/
|
|
577
|
+
function fontMock(mi: ModuleImportInfo, spec: string): string {
|
|
578
|
+
const names = mi.hasDefault ? mi.names.slice(1) : mi.names;
|
|
579
|
+
const loader = `() => ({ className: "", variable: "", style: { fontFamily: "" } })`;
|
|
580
|
+
const entries = names.map((n) => `${n}: ${loader}`);
|
|
581
|
+
if (mi.hasDefault) entries.unshift(`default: ${loader}`);
|
|
582
|
+
if (entries.length === 0) return `vi.mock(${JSON.stringify(spec)}, () => ({}));`;
|
|
583
|
+
return `vi.mock(${JSON.stringify(spec)}, () => ({ ${entries.join(", ")} }));`;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* A "use server" module. An unresolved name's stub resolves to {} rather than
|
|
588
|
+
* undefined because these land in useActionState, whose state is read as
|
|
589
|
+
* `state && "error" in state` — undefined would crash the first render.
|
|
590
|
+
* vi.fn() is a real function, so `action.bind(null, id)` in the JSX still
|
|
591
|
+
* works. A name the analyser resolved (serverActionReturns) gets the action's
|
|
592
|
+
* real literal return instead of the {} stand-in.
|
|
593
|
+
*
|
|
594
|
+
* The factory is keyed by MODULE-side names, never the local aliases: the
|
|
595
|
+
* module exports "createPost" whether the component called it that or
|
|
596
|
+
* "saveIt" (or reached it through `import * as actions`), and a factory
|
|
597
|
+
* keyed by the local binding exports a name the module never had while
|
|
598
|
+
* leaving the real one unmocked.
|
|
599
|
+
*/
|
|
600
|
+
function serverActionMock(mi: ModuleImportInfo, spec: string, todo?: TodoRecorder): string {
|
|
601
|
+
if (mi.names.length === 0) return `vi.mock(${JSON.stringify(spec)}, () => ({}));`;
|
|
602
|
+
const returns = mi.serverActionReturns ?? {};
|
|
603
|
+
const stub = (moduleName: string): string => {
|
|
604
|
+
const v = returns[moduleName];
|
|
605
|
+
return v === undefined
|
|
606
|
+
? "vi.fn(async () => ({}))"
|
|
607
|
+
: `vi.fn(async () => (${JSON.stringify(v)}))`;
|
|
608
|
+
};
|
|
609
|
+
// The factory replaces the WHOLE module, so it binds the module's whole
|
|
610
|
+
// export surface: the component's own imports and every sibling a
|
|
611
|
+
// transitive importer might pull. The imported names are only the fallback
|
|
612
|
+
// for when the export list could not be read.
|
|
613
|
+
const namedIndices = mi.names.map((_, i) => i).filter((i) => !(mi.hasDefault && i === 0));
|
|
614
|
+
const surface = mi.serverActionExports?.length
|
|
615
|
+
? mi.serverActionExports
|
|
616
|
+
: namedIndices.map((i) => membershipName(mi, i));
|
|
617
|
+
const exportNames = [...new Set(surface)].filter((n) => n !== "default");
|
|
618
|
+
const entries = exportNames.map((n) => `${n}: ${stub(n)}`);
|
|
619
|
+
const wantsDefault = mi.hasDefault || (mi.serverActionExports ?? []).includes("default");
|
|
620
|
+
if (wantsDefault) entries.unshift(`default: ${stub("default")}`);
|
|
621
|
+
// The TODO retires only when EVERY export of the module resolved (and the
|
|
622
|
+
// surface is non-empty: exportedNames cannot see `export * from` barrels,
|
|
623
|
+
// and an empty list proves nothing resolved, not that everything did).
|
|
624
|
+
const required = [...exportNames, ...(wantsDefault ? ["default"] : [])];
|
|
625
|
+
if (required.length > 0 && required.every((n) => returns[n] !== undefined)) {
|
|
626
|
+
return `vi.mock(${JSON.stringify(spec)}, () => ({ ${entries.join(", ")} }));`;
|
|
627
|
+
}
|
|
628
|
+
todo?.("server-action-stub");
|
|
629
|
+
return `// TODO(flawless): server action stubbed — give it a realistic return value.
|
|
630
|
+
vi.mock(${JSON.stringify(spec)}, () => ({ ${entries.join(", ")} }));`;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* A project-local module a server component reads data from — every import
|
|
635
|
+
* statement of it merged, so the factory covers every binding at once.
|
|
636
|
+
*/
|
|
637
|
+
function dataModuleMock(
|
|
638
|
+
group: MergedModule,
|
|
639
|
+
info: ComponentInfo,
|
|
640
|
+
spec: string,
|
|
641
|
+
todo?: TodoRecorder,
|
|
642
|
+
apiRoutes?: NextjsApiRoute[],
|
|
643
|
+
): string {
|
|
644
|
+
if (group.bindings.length === 0) return `vi.mock(${JSON.stringify(spec)}, () => ({}));`;
|
|
645
|
+
|
|
646
|
+
// The descendants' call-site evidence joins the unit's: the mock is shared
|
|
647
|
+
// by the whole render, and it is the CHILD — Drop, not the page — whose
|
|
648
|
+
// `interactions.liked` says what getUserDropInteractions has to return.
|
|
649
|
+
// Analyse already dropped any callee the unit itself deps on.
|
|
650
|
+
const allDeps = [...(info.dataDeps ?? []), ...(info.treeDataDeps ?? [])];
|
|
651
|
+
const depsHere = allDeps.filter(
|
|
652
|
+
(d) => d.specifier !== undefined && group.specifiers.has(d.specifier),
|
|
653
|
+
);
|
|
654
|
+
let uncertain = false;
|
|
655
|
+
|
|
656
|
+
// Resolves an `equalsDep` note — `m.user_id === user.id` — to the literal
|
|
657
|
+
// the referenced dep's own fixture holds, whichever channel that fixture
|
|
658
|
+
// came from. All of the component's deps count, not just this module's:
|
|
659
|
+
// `members` and `user` usually come from different modules. One hop only
|
|
660
|
+
// (fieldValueLiteral's rule); anything unreadable drops the note.
|
|
661
|
+
const depFieldValue: DepFieldResolver = (varName, field) => {
|
|
662
|
+
const dep = allDeps.find((d) => d.varName === varName);
|
|
663
|
+
if (!dep) return undefined;
|
|
664
|
+
if (dep.uncertain || dep.shape.kind === "unknown") {
|
|
665
|
+
const v = inferredDepReturn(dep, apiRoutes)?.value;
|
|
666
|
+
if (v !== null && typeof v === "object" && !Array.isArray(v)) {
|
|
667
|
+
const fv = (v as Record<string, unknown>)[field];
|
|
668
|
+
if (fv !== undefined) return JSON.stringify(fv);
|
|
669
|
+
}
|
|
670
|
+
return undefined;
|
|
671
|
+
}
|
|
672
|
+
if (dep.shape.kind === "object") {
|
|
673
|
+
const f = dep.shape.fields.find((x) => x.name === field);
|
|
674
|
+
if (f) return fieldValueLiteral(f, withRouteParams(info.propsShape));
|
|
675
|
+
}
|
|
676
|
+
return undefined;
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
// The stub expression for ONE dep — shared by root-level bindings and the
|
|
680
|
+
// leaves of a dotted tree. Sets the module-level `uncertain` flag exactly
|
|
681
|
+
// where the old inline logic did. `fill` is the dep's own return-annotation
|
|
682
|
+
// value, when one resolved: usage testimony still decides the SHAPE and any
|
|
683
|
+
// compared value, but a field usage could only synthesise a placeholder for
|
|
684
|
+
// takes the declaration's value — `job.status` read into a lookup says
|
|
685
|
+
// nothing about legal statuses, while `Promise<Job>`'s z.enum names one.
|
|
686
|
+
const depStub = (dep: DataDepInfo, seed: string, fill?: unknown): string => {
|
|
687
|
+
if (dep.uncertain || dep.shape.kind === "unknown") {
|
|
688
|
+
// Usage said nothing, but the dep's own declaration may have: the
|
|
689
|
+
// annotation-derived value, or the OpenAPI example for the fetch the
|
|
690
|
+
// wrapper performs. Only a dep with neither is an admitted gap.
|
|
691
|
+
const inf = inferredDepReturn(dep, apiRoutes);
|
|
692
|
+
if (inf) {
|
|
693
|
+
const provenance =
|
|
694
|
+
inf.provenance === "annotation"
|
|
695
|
+
? `/* inferred from ${commentSafe(dep.callee)}'s return annotation */`
|
|
696
|
+
: `/* inferred from the OpenAPI example for ${commentSafe(`${dep.wrapsFetch?.method} ${dep.wrapsFetch?.path}`)} */`;
|
|
697
|
+
const lit = JSON.stringify(inf.value);
|
|
698
|
+
return dep.awaited
|
|
699
|
+
? `vi.fn(async () => (${lit})) ${provenance}`
|
|
700
|
+
: `vi.fn(() => (${lit})) ${provenance}`;
|
|
701
|
+
}
|
|
702
|
+
uncertain = true;
|
|
703
|
+
return dep.awaited ? "vi.fn(async () => ({}))" : "vi.fn(() => ({}))";
|
|
704
|
+
}
|
|
705
|
+
// Shape inferred from how the awaited value is actually used in the body.
|
|
706
|
+
// The props fixture the spec's own DEFAULTS is built from, so a field
|
|
707
|
+
// compared against a route param gets that param's value.
|
|
708
|
+
const literal = mockLiteral(
|
|
709
|
+
dep.shape,
|
|
710
|
+
seed,
|
|
711
|
+
withRouteParams(info.propsShape),
|
|
712
|
+
depFieldValue,
|
|
713
|
+
fill,
|
|
714
|
+
);
|
|
715
|
+
return dep.awaited ? `vi.fn(async () => (${literal}))` : `vi.fn(() => (${literal}))`;
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
const entry = (b: MergedBinding): string => {
|
|
719
|
+
// The factory REPLACES the module's exports: importers read the
|
|
720
|
+
// MODULE-side name. A renamed import ({ Toaster as SonnerToaster }) under
|
|
721
|
+
// the local key would leave the real importer facing a missing export.
|
|
722
|
+
const key = b.isDefault ? "default" : b.moduleName || b.local;
|
|
723
|
+
// The factory replaces the whole module, so a pure class-string helper
|
|
724
|
+
// beside the data exports has to be re-supplied here — synchronously.
|
|
725
|
+
if (b.pure) return `${key}: ${PURE_UTILITY_STUB}`;
|
|
726
|
+
// A default-imported dep is still a dep: its calleeRoot is the local
|
|
727
|
+
// binding name. The old `default: vi.fn()` shortcut resolved undefined —
|
|
728
|
+
// worse than {} — and, once deps could be enriched, dropped the very
|
|
729
|
+
// fixture whose presence had suppressed the RSC skip escape.
|
|
730
|
+
const deps = depsHere.filter((d) => d.calleeRoot === b.local);
|
|
731
|
+
const dep = deps[0];
|
|
732
|
+
if (!dep) {
|
|
733
|
+
if (b.isDefault) return "default: vi.fn()";
|
|
734
|
+
// An UPPER-CASE import never called as a function is a component by
|
|
735
|
+
// the JSX naming convention (lucide icons, and every icon set that
|
|
736
|
+
// reaches a const map: `const Icon = ICONS[kind]; <Icon/>`). The
|
|
737
|
+
// {} fallback made it `vi.fn(async () => ({}))` — mounted as JSX it
|
|
738
|
+
// returns a PROMISE, React 19 awaits components that then, and the
|
|
739
|
+
// whole tree suspends: the spec asserted against an empty container.
|
|
740
|
+
// Pass children through synchronously; the tree stays queryable.
|
|
741
|
+
if (/^[A-Z]/.test(b.local))
|
|
742
|
+
return `${key}: vi.fn((props: { children?: React.ReactNode }) => props.children ?? null)`;
|
|
743
|
+
// No usage evidence — but the DECLARATION may still say. A formatter
|
|
744
|
+
// called inside JSX never becomes a dataDep, and `formatZAR(cents:
|
|
745
|
+
// number): string` was stubbed `async () => ({})`: asynchronous for a
|
|
746
|
+
// synchronous function, and an object where the page renders a string.
|
|
747
|
+
const ann = group.returns.get(b.moduleName ?? b.local);
|
|
748
|
+
if (ann) {
|
|
749
|
+
const lit = JSON.stringify(ann.value);
|
|
750
|
+
const prov = `/* inferred from ${commentSafe(b.local)}'s return annotation */`;
|
|
751
|
+
return ann.sync
|
|
752
|
+
? `${key}: vi.fn(() => (${lit})) ${prov}`
|
|
753
|
+
: `${key}: vi.fn(async () => (${lit})) ${prov}`;
|
|
754
|
+
}
|
|
755
|
+
// Neither usage nor declaration. {} is a better stand-in than undefined:
|
|
756
|
+
// it survives member access and only fails on .length/.map, which is a
|
|
757
|
+
// legible failure rather than a crash.
|
|
758
|
+
uncertain = true;
|
|
759
|
+
// A graph-only name (some other file imports it; nothing here awaits it —
|
|
760
|
+
// awaiting makes a dep) renders at worst, and a promise child silently
|
|
761
|
+
// suspends the whole tree. SYNC {} fails loudly where it appears, and
|
|
762
|
+
// `await {}` still resolves.
|
|
763
|
+
if (b.graphOnly) return `${key}: vi.fn(() => ({}))`;
|
|
764
|
+
return `${key}: vi.fn(async () => ({}))`;
|
|
765
|
+
}
|
|
766
|
+
// `db.query.users.findMany()` needs the vi.fn at the LEAF of the dotted
|
|
767
|
+
// path — mocking the root as a function made `.query` undefined and the
|
|
768
|
+
// spec threw on correct code with no escape (FR-014).
|
|
769
|
+
const dotted = deps.filter((d) => d.callee !== d.calleeRoot);
|
|
770
|
+
if (dotted.length > 0) {
|
|
771
|
+
const tree = dottedDepTree(dotted, depStub);
|
|
772
|
+
if (tree !== undefined && deps.length === dotted.length) return `${key}: ${tree}`;
|
|
773
|
+
// Bare + dotted on one binding, or one path prefixing another: a
|
|
774
|
+
// callable object stub would be inventing semantics. The honest {}
|
|
775
|
+
// keeps the TODO and the RSC skip escape alive.
|
|
776
|
+
uncertain = true;
|
|
777
|
+
return `${key}: vi.fn(async () => ({}))`;
|
|
778
|
+
}
|
|
779
|
+
return `${key}: ${depStub(dep, b.local, group.returns.get(b.moduleName ?? b.local)?.value)}`;
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
const entries = [
|
|
783
|
+
...group.bindings.filter((b) => b.isDefault).map(entry),
|
|
784
|
+
...group.bindings.filter((b) => !b.isDefault).map(entry),
|
|
785
|
+
];
|
|
786
|
+
|
|
787
|
+
// One marker (and one tally) per module mock, however many of its bindings
|
|
788
|
+
// were uninferrable — the marker is what the reader answers, not the binding.
|
|
789
|
+
if (uncertain) todo?.("uninferred-return-shape");
|
|
790
|
+
const note = uncertain
|
|
791
|
+
? "// TODO(flawless): some return shapes could not be inferred — give them fixtures.\n"
|
|
792
|
+
: "";
|
|
793
|
+
return `${note}vi.mock(${JSON.stringify(spec)}, () => ({ ${entries.join(", ")} }));`;
|
|
794
|
+
}
|