@lariocpt/flawless-react 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +608 -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,462 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { rendersNativeSource } from "../analyse/nativeDialect.ts";
|
|
5
|
+
import { ModuleResolver } from "../analyse/resolveModule.ts";
|
|
6
|
+
import { appPackageRoot } from "../packageJson.ts";
|
|
7
|
+
import {
|
|
8
|
+
findOrphanUnitSpecs,
|
|
9
|
+
type ScaffoldUnitBrowserOptions,
|
|
10
|
+
scaffoldUnitBrowser,
|
|
11
|
+
} from "../scaffold-tests/index.ts";
|
|
12
|
+
import { scaffoldNativeUnit } from "../scaffold-tests/nativeIndex.ts";
|
|
13
|
+
import { flattenTrees } from "../scaffold-tests/registry.ts";
|
|
14
|
+
import type { AliasEntry, MultiRootAlias, ReactExportShim } from "../scaffold-tests/templates.ts";
|
|
15
|
+
import type { RunContext, Task, TaskResult } from "../types.ts";
|
|
16
|
+
import { TodoCounter } from "../types.ts";
|
|
17
|
+
|
|
18
|
+
export const unitAndBrowser: Task = {
|
|
19
|
+
id: "unit-browser",
|
|
20
|
+
label: "Generate React unit + browser tests",
|
|
21
|
+
description: "Vitest + @testing-library/react units, Playwright functional browser specs",
|
|
22
|
+
needsTree: true,
|
|
23
|
+
async run(ctx: RunContext): Promise<TaskResult> {
|
|
24
|
+
if (ctx.trees.length === 0) {
|
|
25
|
+
return {
|
|
26
|
+
id: "unit-browser",
|
|
27
|
+
ok: false,
|
|
28
|
+
outputs: [],
|
|
29
|
+
findings: [],
|
|
30
|
+
summary: "no components to scaffold",
|
|
31
|
+
error: "no components",
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
// Expo runs the native plane instead of the DOM one. Not "as well as": the
|
|
35
|
+
// DOM suite pointed at React Native emits specs that run, pass and touch
|
|
36
|
+
// nothing, which is the one outcome this tool must never produce.
|
|
37
|
+
if (ctx.expo) {
|
|
38
|
+
const todos = new TodoCounter();
|
|
39
|
+
const res = await scaffoldNativeUnit({
|
|
40
|
+
trees: ctx.trees,
|
|
41
|
+
outDir: ctx.outDir,
|
|
42
|
+
expo: ctx.expo,
|
|
43
|
+
force: ctx.force,
|
|
44
|
+
todos,
|
|
45
|
+
});
|
|
46
|
+
for (const kept of res.kept) todos.drop(kept);
|
|
47
|
+
const names = flattenTrees(ctx.trees).map((c) => c.info.componentName);
|
|
48
|
+
const keptNote =
|
|
49
|
+
res.kept.length > 0
|
|
50
|
+
? ` (${res.kept.length} write-once file(s) kept — delete one to regenerate it)`
|
|
51
|
+
: "";
|
|
52
|
+
return {
|
|
53
|
+
id: "unit-browser",
|
|
54
|
+
ok: true,
|
|
55
|
+
outputs: res.written,
|
|
56
|
+
findings: [],
|
|
57
|
+
summary: `native unit specs for ${names.length} component(s)${keptNote}`,
|
|
58
|
+
handWork: todos.toTallies(),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const projectRoot = ctx.nextjs?.root;
|
|
63
|
+
// Paths relative to where the user reads the summary from, capped so one
|
|
64
|
+
// big app can't turn the summary line into a wall of text.
|
|
65
|
+
const rel = (p: string): string => path.relative(projectRoot ?? process.cwd(), p) || p;
|
|
66
|
+
const listKept = (paths: string[]): string => {
|
|
67
|
+
const shown = paths.slice(0, 10).map(rel).join(", ");
|
|
68
|
+
return paths.length > 10 ? `${shown}, +${paths.length - 10} more` : shown;
|
|
69
|
+
};
|
|
70
|
+
// An orphan means the same thing at both exits of this task, so it is named
|
|
71
|
+
// the same way at both: the runner WILL execute this file and no component
|
|
72
|
+
// backs it any more. Named, never removed — a suite-dir spec carries no
|
|
73
|
+
// stamp, so nothing proves it is not hand work. See findOrphanUnitSpecs.
|
|
74
|
+
const orphanNote = (orphans: string[]): string =>
|
|
75
|
+
orphans.length > 0
|
|
76
|
+
? ` (${orphans.length} unit spec(s) with no component behind them — kept, delete to drop: ${listKept(orphans)})`
|
|
77
|
+
: "";
|
|
78
|
+
// React Native source is not DOM-plane material. A spec imports the
|
|
79
|
+
// component, so its WHOLE graph loads, and anywhere in a native graph sits
|
|
80
|
+
// react-native: Flow source vite cannot parse, so the spec dies at collect
|
|
81
|
+
// with `SyntaxError: Unexpected token 'typeof'` and the run ends
|
|
82
|
+
// `Test Files no tests`. Measured on ospfranco/sol, a bare-RN app: 16
|
|
83
|
+
// specs, 16 collect failures, zero tests ever ran — a suite that cannot
|
|
84
|
+
// report anything is not a suite. The tool's native plane is `--expo`, and
|
|
85
|
+
// `--expo` refuses a non-Expo project by design, so there is no plane to
|
|
86
|
+
// emit for these and a NAMED skip is the honest answer. The perf side of the
|
|
87
|
+
// same rule lives in hostEligibility, which the host registry, the profile
|
|
88
|
+
// specs and REPORT.md all read, so the app is excluded the same way in all
|
|
89
|
+
// three rather than crashing in one and lying in the other two.
|
|
90
|
+
const nativeTrees = ctx.trees.filter(rendersNativeSource);
|
|
91
|
+
const trees =
|
|
92
|
+
nativeTrees.length === 0 ? ctx.trees : ctx.trees.filter((t) => !rendersNativeSource(t));
|
|
93
|
+
if (trees.length === 0) {
|
|
94
|
+
const who = flattenTrees(ctx.trees);
|
|
95
|
+
// The exit that strands them: specs written by an earlier run are still
|
|
96
|
+
// in `unit/`, and this run will not overwrite a single one of them.
|
|
97
|
+
const orphans = await findOrphanUnitSpecs(ctx.outDir, new Set());
|
|
98
|
+
return {
|
|
99
|
+
id: "unit-browser",
|
|
100
|
+
ok: true,
|
|
101
|
+
outputs: [],
|
|
102
|
+
findings: [],
|
|
103
|
+
summary:
|
|
104
|
+
`no unit specs: all ${who.length} component(s) render React Native source, which ` +
|
|
105
|
+
`no DOM plane can load (the native plane is --expo, Expo projects only)` +
|
|
106
|
+
orphanNote(orphans),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
// Alias / react-pin anchor. A Next app is anchored at the app root; a
|
|
110
|
+
// plain vite project has no such marker, but its tsconfig sits right next
|
|
111
|
+
// to the analysed sources — anchor at each source directory and let
|
|
112
|
+
// nearestConfig() walk up from there. Without this, non-Next specs lost
|
|
113
|
+
// every tsconfig `paths` alias AND the shared-react pin, so `@/...`
|
|
114
|
+
// imports failed to resolve and the suite crashed on dual React.
|
|
115
|
+
const aliasRoots = ctx.nextjs
|
|
116
|
+
? [ctx.nextjs.root]
|
|
117
|
+
: [...new Set(flattenTrees(trees).map((c) => path.dirname(c.info.filePath)))];
|
|
118
|
+
// Browser specs drive the perf-harness host; emitting them without it
|
|
119
|
+
// would hand the user a suite that cannot start.
|
|
120
|
+
const browserSpecs =
|
|
121
|
+
ctx.selectedTasks === undefined || ctx.selectedTasks.includes("perf-harness");
|
|
122
|
+
// Tallies the TODO(flawless) markers the templates plant, counted at each
|
|
123
|
+
// emission site — never by grepping the emitted text (see TodoCounter).
|
|
124
|
+
// The collector travels inside the options object: scaffoldUnitBrowser
|
|
125
|
+
// hands that object to renderUnitBrowser verbatim, whose options type
|
|
126
|
+
// declares `todos`. Built as a variable, not an inline literal, so the
|
|
127
|
+
// extra property passes the excess-property check; todoCounts.test.ts pins
|
|
128
|
+
// the passthrough end to end, because a rebuild of the object anywhere on
|
|
129
|
+
// the path would silently drop the counter and report zero debt.
|
|
130
|
+
const todos = new TodoCounter();
|
|
131
|
+
// The shared-react anchor follows the SOURCES, not the suite directory.
|
|
132
|
+
// Externalized deps (next-themes & co.) resolve "react" from the APP's
|
|
133
|
+
// tree — node's require, no alias involved — so the alias must anchor at
|
|
134
|
+
// that same physical copy or the app's libraries render with one React
|
|
135
|
+
// while the suite renders with another ("Cannot read properties of null
|
|
136
|
+
// (reading 'useContext')"). The suite's own copy would win this lookup
|
|
137
|
+
// the moment it is installed, splitting the graph exactly there; the
|
|
138
|
+
// harness closure (@testing-library, react, react-dom) is INLINED in the
|
|
139
|
+
// vitest config instead, so its bare specifiers ride the alias too. An
|
|
140
|
+
// app with no installed react falls through to the suite's copy, which
|
|
141
|
+
// now carries the app's version pin.
|
|
142
|
+
const pinFrom = ctx.nextjs ? ctx.nextjs.root : (aliasRoots[0] ?? ctx.outDir);
|
|
143
|
+
const pinFallback = ctx.nextjs ? undefined : ctx.outDir;
|
|
144
|
+
const aliasRes =
|
|
145
|
+
aliasRoots.length > 0
|
|
146
|
+
? computeResolveAliases(aliasRoots, pinFrom, pinFallback)
|
|
147
|
+
: { aliases: [] as AliasEntry[], multiRoot: [] as MultiRootAlias[] };
|
|
148
|
+
// The react named-export shim (ReactExportShim): the app's own imports
|
|
149
|
+
// scanned against its installed react's exports, filled only with what
|
|
150
|
+
// Next's bundled copy proves to exist. Undefined for every app whose
|
|
151
|
+
// imports the core already answers.
|
|
152
|
+
const reactShim = detectCanaryReactExports(
|
|
153
|
+
flattenTrees(trees).map((c) => c.info.filePath),
|
|
154
|
+
nearestReactNodeModules(pinFrom) ??
|
|
155
|
+
(pinFallback ? nearestReactNodeModules(pinFallback) : undefined),
|
|
156
|
+
);
|
|
157
|
+
// Annotated, not inferred: the object is passed to scaffoldUnitBrowser as a
|
|
158
|
+
// variable, and TypeScript checks a variable's shape against a parameter
|
|
159
|
+
// only for what the TYPE declares — a field this literal invents, or one the
|
|
160
|
+
// scaffolder stops reading, is invisible. This is the hole that let the
|
|
161
|
+
// manifest's reactPinDir anchor exist at runtime while the scaffolder's own
|
|
162
|
+
// options type did not name it.
|
|
163
|
+
const scaffoldOptions: ScaffoldUnitBrowserOptions = {
|
|
164
|
+
browserSpecs,
|
|
165
|
+
trees,
|
|
166
|
+
outDir: ctx.outDir,
|
|
167
|
+
variants: ctx.variants,
|
|
168
|
+
mswSetup: ctx.nextjs !== undefined,
|
|
169
|
+
nextjs: ctx.nextjs !== undefined,
|
|
170
|
+
provider: ctx.provider,
|
|
171
|
+
resolveAliases: aliasRes.aliases.length > 0 ? aliasRes.aliases : undefined,
|
|
172
|
+
multiRootAliases: aliasRes.multiRoot.length > 0 ? aliasRes.multiRoot : undefined,
|
|
173
|
+
// The single-React anchor: vitest aliases only reach code VITE loads,
|
|
174
|
+
// so setup.ts redirects every NATIVE react/react-dom load (an
|
|
175
|
+
// externalised @testing-library/react requires its peer-linked suite
|
|
176
|
+
// copy otherwise — physically a second React, null dispatcher on every
|
|
177
|
+
// mount) to this same pair.
|
|
178
|
+
reactPinDir: aliasRes.pinDir,
|
|
179
|
+
reactShim,
|
|
180
|
+
// The app package's .env files for the emitted vitest config (server
|
|
181
|
+
// modules read DATABASE_URL & co. at import time; unset env crashes
|
|
182
|
+
// every importing spec at collection).
|
|
183
|
+
appEnvRoot: ctx.nextjs
|
|
184
|
+
? ctx.nextjs.root
|
|
185
|
+
: aliasRoots[0]
|
|
186
|
+
? appPackageRoot(aliasRoots[0])
|
|
187
|
+
: undefined,
|
|
188
|
+
colocate: ctx.colocate,
|
|
189
|
+
force: ctx.force,
|
|
190
|
+
apiRoutes: ctx.nextjs?.apiRoutes,
|
|
191
|
+
providerWraps: ctx.providerWraps,
|
|
192
|
+
todos,
|
|
193
|
+
};
|
|
194
|
+
const { written, skipped, keptEdited, keptLegacy, staleRemoved, staleKept, orphanUnitSpecs } =
|
|
195
|
+
await scaffoldUnitBrowser(scaffoldOptions);
|
|
196
|
+
// Tallying happened while rendering, but a kept colocated spec's render
|
|
197
|
+
// was discarded — its markers were never written this run, and may name
|
|
198
|
+
// TODOs the user already resolved in the file the writer kept. All three
|
|
199
|
+
// kept buckets discard their render, so all three retract their tallies.
|
|
200
|
+
for (const kept of [...skipped, ...keptEdited, ...keptLegacy]) todos.drop(kept);
|
|
201
|
+
// flattenTrees, not a raw walk: the same component appears in as many trees
|
|
202
|
+
// as there are pages rendering it, and counting the walk reported "unit
|
|
203
|
+
// tests for 606 component(s)" against an app the report itself says has 181.
|
|
204
|
+
const names = flattenTrees(trees).map((c) => c.info.componentName);
|
|
205
|
+
// Named, not silent: the count the user expected to see is in this line
|
|
206
|
+
// WITH the reason it is smaller, the same rule the auth-skip and kept-spec
|
|
207
|
+
// notes follow.
|
|
208
|
+
const nativeNote =
|
|
209
|
+
nativeTrees.length > 0
|
|
210
|
+
? ` (${flattenTrees(nativeTrees).length} React Native component(s) skipped — no DOM plane can load react-native)`
|
|
211
|
+
: "";
|
|
212
|
+
const skippedNote =
|
|
213
|
+
skipped.length > 0
|
|
214
|
+
? ` (${skipped.length} colocated spec(s) kept — rerun with --force to regenerate)`
|
|
215
|
+
: "";
|
|
216
|
+
const editedNote =
|
|
217
|
+
keptEdited.length > 0
|
|
218
|
+
? ` (${keptEdited.length} hand-edited or reformatted colocated spec(s) kept — delete to regenerate: ${listKept(keptEdited)})`
|
|
219
|
+
: "";
|
|
220
|
+
const legacyNote =
|
|
221
|
+
keptLegacy.length > 0
|
|
222
|
+
? ` (${keptLegacy.length} pre-stamp colocated spec(s) kept — delete to regenerate: ${listKept(keptLegacy)})`
|
|
223
|
+
: "";
|
|
224
|
+
const staleRemovedNote =
|
|
225
|
+
staleRemoved.length > 0
|
|
226
|
+
? ` (${staleRemoved.length} stale browser spec(s) removed — the component is no longer in the host: ${listKept(staleRemoved)})`
|
|
227
|
+
: "";
|
|
228
|
+
const staleKeptNote =
|
|
229
|
+
staleKept.length > 0
|
|
230
|
+
? ` (${staleKept.length} stale browser spec(s) kept — the component is no longer in the host and the file is hand-edited or unprovable; delete to remove: ${listKept(staleKept.map((k) => k.abs))})`
|
|
231
|
+
: "";
|
|
232
|
+
const browserNote = browserSpecs
|
|
233
|
+
? ""
|
|
234
|
+
: " (browser specs skipped — add perf-harness to the task list for the host they need)";
|
|
235
|
+
const shimNote = reactShim
|
|
236
|
+
? ` (react shim: ${[...reactShim.components, ...reactShim.functions].join(", ")} from Next's bundled react@${reactShim.canaryVersion})`
|
|
237
|
+
: "";
|
|
238
|
+
// Notes before the name list: REPORT.md truncates each task summary at
|
|
239
|
+
// 120 chars and the component list runs to thousands — a note appended
|
|
240
|
+
// after it would exist only in summary.json and stdout, and the kept
|
|
241
|
+
// listing is the one part of this line a user must act on.
|
|
242
|
+
return {
|
|
243
|
+
id: "unit-browser",
|
|
244
|
+
ok: true,
|
|
245
|
+
outputs: written,
|
|
246
|
+
findings: [],
|
|
247
|
+
summary: `unit tests for ${names.length} component(s)${nativeNote}${skippedNote}${editedNote}${legacyNote}${staleRemovedNote}${staleKeptNote}${orphanNote(orphanUnitSpecs)}${shimNote}${browserNote}: ${names.join(", ")}`,
|
|
248
|
+
handWork: todos.toTallies(),
|
|
249
|
+
};
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* vitest resolve.alias entries for a Next.js project: the tsconfig path
|
|
255
|
+
* aliases, plus exact react/react-dom pins so a monorepo with several React
|
|
256
|
+
* copies never pairs mismatched react + react-dom under jsdom.
|
|
257
|
+
*/
|
|
258
|
+
function computeResolveAliases(
|
|
259
|
+
roots: string[],
|
|
260
|
+
pinFrom?: string,
|
|
261
|
+
pinFallback?: string,
|
|
262
|
+
): {
|
|
263
|
+
aliases: AliasEntry[];
|
|
264
|
+
multiRoot: MultiRootAlias[];
|
|
265
|
+
pinDir?: string;
|
|
266
|
+
} {
|
|
267
|
+
const entries: AliasEntry[] = [];
|
|
268
|
+
const multiRoot: MultiRootAlias[] = [];
|
|
269
|
+
const targets = new Map<string, string[]>();
|
|
270
|
+
const resolver = new ModuleResolver();
|
|
271
|
+
// A multi-package run may put sources under several tsconfigs; collect every
|
|
272
|
+
// prefix's candidate targets first. One root → plain alias. Several roots
|
|
273
|
+
// claiming the same prefix with DIFFERENT targets cannot collapse into the
|
|
274
|
+
// one alias map vite offers — those become importer-aware plugin entries.
|
|
275
|
+
for (const projectRoot of roots) {
|
|
276
|
+
for (const p of resolver.aliasPatternsFor(projectRoot)) {
|
|
277
|
+
if (p.exact || p.suffix !== "") continue;
|
|
278
|
+
const target = p.targets[0];
|
|
279
|
+
if (!target) continue;
|
|
280
|
+
const dir = path.resolve(p.baseDir, target.replace(/\*.*$/, "")).replace(/\/+$/, "");
|
|
281
|
+
const arr = targets.get(p.prefix);
|
|
282
|
+
if (arr) {
|
|
283
|
+
if (!arr.includes(dir)) arr.push(dir);
|
|
284
|
+
} else targets.set(p.prefix, [dir]);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
for (const [prefix, dirs] of targets) {
|
|
288
|
+
const only = dirs[0];
|
|
289
|
+
// An EMPTY prefix is a tsconfig catch-all — `"*": ["./src/*"]`, which is how
|
|
290
|
+
// React Native's shared tsconfig maps imports (ospfranco/sol). As a vite
|
|
291
|
+
// `resolve.alias` it is `{ find: /^/, replacement: <src>/ }`, and `^` matches
|
|
292
|
+
// EVERY id: vite's own `/@vite/env`, `/@id/…`, a relative `./Foo`, all
|
|
293
|
+
// rewritten into `src/`. The run then dies before one spec loads — sol: 16
|
|
294
|
+
// "Failed to load url /@vite/env" errors and `Test Files no tests`. The
|
|
295
|
+
// importer-aware plugin below is the safe spelling of the same mapping: it
|
|
296
|
+
// probes for the file and answers null when there is nothing there, so
|
|
297
|
+
// `react` and `/@vite/env` fall through to normal resolution untouched.
|
|
298
|
+
if (dirs.length === 1 && only !== undefined && prefix !== "")
|
|
299
|
+
entries.push({ find: prefix, replacement: only, prefix: true });
|
|
300
|
+
else multiRoot.push({ prefix, dirs });
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// The shared-react pin. A Next suite runs on the repo's own vitest/RTL
|
|
304
|
+
// stack, so it anchors at the app root. A non-Next standalone suite
|
|
305
|
+
// anchors at the SOURCES' copy first (see the call site): externalized
|
|
306
|
+
// deps resolve react from the app tree through plain node require, so
|
|
307
|
+
// that copy is the one the whole graph must share. Only when the app
|
|
308
|
+
// tree carries no react at all does pinFallback join — the suite's own
|
|
309
|
+
// installed test stack, which is version-matched to the app by
|
|
310
|
+
// detectAppReact. nearestReactNodeModules's preference (the dir that
|
|
311
|
+
// carries @testing-library/react wins) keeps each leg honest.
|
|
312
|
+
const nmDir =
|
|
313
|
+
(pinFrom ? nearestReactNodeModules(pinFrom) : undefined) ??
|
|
314
|
+
(pinFallback ? nearestReactNodeModules(pinFallback) : undefined);
|
|
315
|
+
if (nmDir) {
|
|
316
|
+
const pin = (spec: string, rel: string): void => {
|
|
317
|
+
const abs = path.join(nmDir, rel);
|
|
318
|
+
if (existsSync(abs)) entries.push({ find: spec, replacement: abs, prefix: false });
|
|
319
|
+
};
|
|
320
|
+
pin("react/jsx-runtime", "react/jsx-runtime.js");
|
|
321
|
+
pin("react/jsx-dev-runtime", "react/jsx-dev-runtime.js");
|
|
322
|
+
pin("react-dom/client", "react-dom/client.js");
|
|
323
|
+
pin("react-dom/test-utils", "react-dom/test-utils.js");
|
|
324
|
+
// The document-root specs render through react-dom/server. Pinning every
|
|
325
|
+
// other react-dom subpath but not this one is worse than pinning none: the
|
|
326
|
+
// spec's React is the aliased copy while its renderer is a second one, so
|
|
327
|
+
// the SSR hook dispatcher is set on an instance the component cannot see and
|
|
328
|
+
// every hook call fails with "Cannot read properties of null".
|
|
329
|
+
pin("react-dom/server", "react-dom/server.js");
|
|
330
|
+
pin("react-dom/server.browser", "react-dom/server.browser.js");
|
|
331
|
+
pin("react-dom", "react-dom");
|
|
332
|
+
pin("react", "react");
|
|
333
|
+
// pnpm's realpath layout escapes every request-level pin above. When
|
|
334
|
+
// vitest INTERNALIZES a package (react-dom, testing-library — see the
|
|
335
|
+
// deps.inline note in the vitest config), the package's own
|
|
336
|
+
// require("react") runs from its realpath under
|
|
337
|
+
// <nm>/.pnpm/<name>@<ver>/node_modules/<name>/ and resolves by plain
|
|
338
|
+
// node rules — a second physical react beside the aliased one, same
|
|
339
|
+
// version, separate dispatcher ("Cannot read properties of null
|
|
340
|
+
// (reading 'useSyncExternalStore')"). A pattern against the RESOLVED
|
|
341
|
+
// path is the only hook that sees those loads.
|
|
342
|
+
const pnpmPin = (name: string): void => {
|
|
343
|
+
const abs = path.join(nmDir, name);
|
|
344
|
+
if (existsSync(abs))
|
|
345
|
+
entries.push({
|
|
346
|
+
find: `\\.pnpm/${name}@[^/]*/node_modules/${name}$`,
|
|
347
|
+
replacement: abs,
|
|
348
|
+
prefix: false,
|
|
349
|
+
raw: true,
|
|
350
|
+
});
|
|
351
|
+
};
|
|
352
|
+
pnpmPin("react");
|
|
353
|
+
pnpmPin("react-dom");
|
|
354
|
+
}
|
|
355
|
+
// The anchor travels with the aliases: setup.ts needs the SAME directory to
|
|
356
|
+
// pin the loads the aliases cannot reach (see ReactPinDir).
|
|
357
|
+
return { aliases: entries, multiRoot, pinDir: nmDir };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The node_modules whose react pair the whole suite should share. Prefer the
|
|
362
|
+
* dir that ALSO carries @testing-library/react: hoisted monorepos put the
|
|
363
|
+
* test stack at the workspace root, and its CJS require() will always find
|
|
364
|
+
* that root pair — so aliasing sources to any other copy guarantees the
|
|
365
|
+
* dual-React invalid-hook-call crash.
|
|
366
|
+
*/
|
|
367
|
+
function nearestReactNodeModules(fromDir: string): string | undefined {
|
|
368
|
+
let fallback: string | undefined;
|
|
369
|
+
let dir = path.resolve(fromDir);
|
|
370
|
+
for (;;) {
|
|
371
|
+
const nm = path.join(dir, "node_modules");
|
|
372
|
+
if (existsSync(path.join(nm, "react")) && existsSync(path.join(nm, "react-dom"))) {
|
|
373
|
+
if (existsSync(path.join(nm, "@testing-library", "react"))) return nm;
|
|
374
|
+
fallback ??= nm;
|
|
375
|
+
}
|
|
376
|
+
const parent = path.dirname(dir);
|
|
377
|
+
if (parent === dir) return fallback;
|
|
378
|
+
dir = parent;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Detect React named exports the analysed sources import that the app's
|
|
384
|
+
* installed react does not carry but the copy Next bundles does, and
|
|
385
|
+
* describe the additive shim for them (ReactExportShim in
|
|
386
|
+
* scaffold-tests/templates.ts explains why this is not a core swap).
|
|
387
|
+
* Returns undefined when the core answers every import — the common case,
|
|
388
|
+
* and the cheap exit. The scan is a regex over the import statements, not a
|
|
389
|
+
* type-aware pass: only the set of names taken from the bare "react"
|
|
390
|
+
* specifier matters. A name NEITHER react exports stays unfilled — that is
|
|
391
|
+
* a genuine app bug and should fail the suite as loudly as it fails the
|
|
392
|
+
* app.
|
|
393
|
+
*/
|
|
394
|
+
function detectCanaryReactExports(
|
|
395
|
+
sourceFiles: string[],
|
|
396
|
+
nmDir: string | undefined,
|
|
397
|
+
): ReactExportShim | undefined {
|
|
398
|
+
if (!nmDir) return undefined;
|
|
399
|
+
const imported = new Set<string>();
|
|
400
|
+
// Default clause optional, `type` markers optional; braces capture every
|
|
401
|
+
// name including multiline ones ([^}]* spans newlines).
|
|
402
|
+
const importRe = /import\s+(?:type\s+)?[\w$]*\s*,?\s*\{([^}]*)\}\s*from\s*["']react["']/g;
|
|
403
|
+
for (const file of sourceFiles) {
|
|
404
|
+
let src: string;
|
|
405
|
+
try {
|
|
406
|
+
src = readFileSync(file, "utf8");
|
|
407
|
+
} catch {
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
for (const m of src.matchAll(importRe)) {
|
|
411
|
+
const clause = m[1] ?? "";
|
|
412
|
+
for (const raw of clause.split(",")) {
|
|
413
|
+
const first = raw
|
|
414
|
+
.trim()
|
|
415
|
+
.replace(/^type\s+/, "")
|
|
416
|
+
.split(/\s+as\s+/)[0];
|
|
417
|
+
const name = first?.trim() ?? "";
|
|
418
|
+
if (/^[A-Za-z_$][\w$]*$/.test(name)) imported.add(name);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (imported.size === 0) return undefined;
|
|
423
|
+
const coreDir = path.join(nmDir, "react");
|
|
424
|
+
const core = reactRuntimeExports(coreDir);
|
|
425
|
+
if (!core || core.size === 0) return undefined;
|
|
426
|
+
const missing = [...imported].filter((n) => !core.has(n));
|
|
427
|
+
if (missing.length === 0) return undefined;
|
|
428
|
+
// Evidence gate: fill only what the bundled copy actually exports, which
|
|
429
|
+
// is what the app was compiled and ships against.
|
|
430
|
+
const bundled = reactRuntimeExports(path.join(nmDir, "next", "dist", "compiled", "react"));
|
|
431
|
+
if (!bundled) return undefined;
|
|
432
|
+
const fills = missing.filter((n) => bundled.has(n));
|
|
433
|
+
if (fills.length === 0) return undefined;
|
|
434
|
+
const versionOf = (dir: string): string => {
|
|
435
|
+
try {
|
|
436
|
+
const pkg = JSON.parse(readFileSync(path.join(dir, "package.json"), "utf8")) as {
|
|
437
|
+
version?: string;
|
|
438
|
+
};
|
|
439
|
+
return pkg.version ?? "unknown";
|
|
440
|
+
} catch {
|
|
441
|
+
return "unknown";
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
return {
|
|
445
|
+
coreIndex: path.join(coreDir, "index.js"),
|
|
446
|
+
coreVersion: versionOf(coreDir),
|
|
447
|
+
canaryVersion: versionOf(path.join(nmDir, "next")),
|
|
448
|
+
components: fills.filter((n) => /^[A-Z]/.test(n)),
|
|
449
|
+
functions: fills.filter((n) => !/^[A-Z]/.test(n)),
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Runtime export names of a CommonJS react build, loaded from its directory. */
|
|
454
|
+
function reactRuntimeExports(reactDir: string): Set<string> | undefined {
|
|
455
|
+
try {
|
|
456
|
+
const req = createRequire(path.join(reactDir, "resolver.cjs"));
|
|
457
|
+
const mod = req(path.join(reactDir, "index.js")) as Record<string, unknown>;
|
|
458
|
+
return new Set(Object.keys(mod));
|
|
459
|
+
} catch {
|
|
460
|
+
return undefined;
|
|
461
|
+
}
|
|
462
|
+
}
|