@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,603 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import ts from "typescript";
|
|
4
|
+
import { isAuthLibSpecifier } from "../analyse/authShims.ts";
|
|
5
|
+
import { ModuleResolver } from "../analyse/resolveModule.ts";
|
|
6
|
+
import { readPrologueDirective } from "../analyse/sourceCache.ts";
|
|
7
|
+
import type { NextjsContext } from "../types.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Next.js app-router layouts are usually async server components importing
|
|
11
|
+
* next/font, next/headers, or server-only modules — none of which can run
|
|
12
|
+
* under jsdom. Even a sync layout renders the <html>/<body> document shell,
|
|
13
|
+
* which nests a second <html> inside RTL's container div and wedges React's
|
|
14
|
+
* event dispatch. When the auto-detected app resolver is such a layout, we
|
|
15
|
+
* derive a client-safe provider wrapper from the JSX chain that encloses
|
|
16
|
+
* `{children}` and emit it once as <outDir>/provider.tsx.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const SERVER_ONLY_SPECIFIERS = /^(next\/font(\/|$)|next\/headers$|server-only$)/;
|
|
20
|
+
|
|
21
|
+
/** A file is usable as a unit-test provider iff its default export is a sync component and it pulls in no server-only modules. */
|
|
22
|
+
export function isUsableUnitProvider(file: string): boolean {
|
|
23
|
+
let sf: ts.SourceFile;
|
|
24
|
+
try {
|
|
25
|
+
sf = ts.createSourceFile(
|
|
26
|
+
file,
|
|
27
|
+
readFileSync(file, "utf8"),
|
|
28
|
+
ts.ScriptTarget.ES2022,
|
|
29
|
+
true,
|
|
30
|
+
ts.ScriptKind.TSX,
|
|
31
|
+
);
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// A root layout renders <html> — a document shell. RTL mounts into a <div>
|
|
37
|
+
// inside jsdom's own <body>, so importing it as the provider nests a second
|
|
38
|
+
// <html> in the tree (React's "In HTML, <html> cannot be a child of <div>").
|
|
39
|
+
// jsdom's node-list invariants break under that nesting, and React's
|
|
40
|
+
// continuous-event dispatch (pointer) walks them — the dispatch never
|
|
41
|
+
// terminates and the worker burns 100% CPU forever. Mount only specs that
|
|
42
|
+
// never fire pointer events survive; any click test wedges the whole run.
|
|
43
|
+
// Deriving the client provider chain instead keeps what tests need.
|
|
44
|
+
if (rendersDocumentShell(sf)) return false;
|
|
45
|
+
|
|
46
|
+
// A pages-router `_app` is not a provider: it renders the route element with
|
|
47
|
+
// `AppProps`, which a spec cannot supply and a wrapper cannot fake. Derive
|
|
48
|
+
// the chain it wraps instead, anchored on `<Component />`.
|
|
49
|
+
if (rendersRouteElement(sf)) return false;
|
|
50
|
+
|
|
51
|
+
for (const stmt of sf.statements) {
|
|
52
|
+
if (
|
|
53
|
+
ts.isImportDeclaration(stmt) &&
|
|
54
|
+
ts.isStringLiteral(stmt.moduleSpecifier) &&
|
|
55
|
+
SERVER_ONLY_SPECIFIERS.test(stmt.moduleSpecifier.text)
|
|
56
|
+
) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
if (ts.isFunctionDeclaration(stmt)) {
|
|
60
|
+
const mods = stmt.modifiers ?? [];
|
|
61
|
+
const isDefault =
|
|
62
|
+
mods.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) &&
|
|
63
|
+
mods.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword);
|
|
64
|
+
if (isDefault && mods.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword)) return false;
|
|
65
|
+
}
|
|
66
|
+
if (ts.isExportAssignment(stmt) && !stmt.isExportEquals) {
|
|
67
|
+
const expr = stmt.expression;
|
|
68
|
+
if (
|
|
69
|
+
(ts.isArrowFunction(expr) || ts.isFunctionExpression(expr)) &&
|
|
70
|
+
(expr.modifiers ?? []).some((m) => m.kind === ts.SyntaxKind.AsyncKeyword)
|
|
71
|
+
) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface RootShellWalker {
|
|
80
|
+
found: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** True when the file's JSX opens a <html> element — the root-layout marker. */
|
|
84
|
+
function rendersDocumentShell(sf: ts.SourceFile): boolean {
|
|
85
|
+
const marker: RootShellWalker = { found: false };
|
|
86
|
+
const walk = (n: ts.Node): void => {
|
|
87
|
+
if (marker.found) return;
|
|
88
|
+
if (
|
|
89
|
+
(ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) &&
|
|
90
|
+
ts.isIdentifier(n.tagName) &&
|
|
91
|
+
n.tagName.text === "html"
|
|
92
|
+
) {
|
|
93
|
+
marker.found = true;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
ts.forEachChild(n, walk);
|
|
97
|
+
};
|
|
98
|
+
walk(sf);
|
|
99
|
+
return marker.found;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface ProviderWrapperResult {
|
|
103
|
+
/** Absolute path of the wrapper to use as --provider. */
|
|
104
|
+
path: string;
|
|
105
|
+
/** False when the file already existed and was left untouched. */
|
|
106
|
+
created: boolean;
|
|
107
|
+
/** Local names of the providers included in the wrapper. */
|
|
108
|
+
included: string[];
|
|
109
|
+
/** Providers the layout's chain has and the wrapper does NOT mount. */
|
|
110
|
+
dropped: Array<{ name: string; reason: DropReason }>;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Why a provider in the layout's chain could not be mounted, and what to say about it. */
|
|
114
|
+
const DROP_REASONS = {
|
|
115
|
+
auth: {
|
|
116
|
+
short: "auth-gated",
|
|
117
|
+
long:
|
|
118
|
+
"imports an auth library, and the unit plane replaces those libraries with a signed-out " +
|
|
119
|
+
"stub — mounted, a provider that gates its children on a session renders nothing, and every " +
|
|
120
|
+
"wrapped spec inherits the blank tree",
|
|
121
|
+
},
|
|
122
|
+
server: {
|
|
123
|
+
short: "not a client module",
|
|
124
|
+
long: 'is not a "use client" module, so its own render cannot run under jsdom',
|
|
125
|
+
},
|
|
126
|
+
unresolved: {
|
|
127
|
+
short: "unresolved import",
|
|
128
|
+
long: "does not resolve to a readable module in this repo (a package export, or an alias the workspace map does not cover)",
|
|
129
|
+
},
|
|
130
|
+
} as const;
|
|
131
|
+
export type DropReason = keyof typeof DROP_REASONS;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Specifiers whose JSX is React's own coordination machinery, not an app
|
|
135
|
+
* provider: `<Suspense>`, `<Fragment>`, `<StrictMode>`. They appear in layout
|
|
136
|
+
* chains the same way providers do and resolve to node_modules, so without this
|
|
137
|
+
* filter every suite's drop list opened with "Suspense — unresolved import",
|
|
138
|
+
* which is neither a provider nor a thing the reader can act on.
|
|
139
|
+
*/
|
|
140
|
+
const REACT_OWN_COMPONENTS = /^react(-dom)?(\/|$)/;
|
|
141
|
+
|
|
142
|
+
/** Short label for a drop reason, for the run summary (the file itself spells it out). */
|
|
143
|
+
export function dropReasonLabel(reason: DropReason): string {
|
|
144
|
+
return DROP_REASONS[reason].short;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Derive `<outDir>/provider.tsx` from the layout's `{children}`-enclosing JSX
|
|
149
|
+
* chain. Only client components ("use client" target files) are kept; literal
|
|
150
|
+
* attributes are copied; everything else is dropped with a TODO comment.
|
|
151
|
+
* Refreshed by --force, so a change to the layout's provider chain reaches an
|
|
152
|
+
* existing suite. The values you supply live in provider.fixtures.ts beside it,
|
|
153
|
+
* which is never regenerated under any flag.
|
|
154
|
+
*/
|
|
155
|
+
export function ensureClientProviderWrapper(
|
|
156
|
+
ctx: NextjsContext,
|
|
157
|
+
outDir: string,
|
|
158
|
+
layoutFile?: string,
|
|
159
|
+
force = false,
|
|
160
|
+
): ProviderWrapperResult | undefined {
|
|
161
|
+
const layout = layoutFile ?? ctx.appResolver;
|
|
162
|
+
if (!layout || !existsSync(layout)) return undefined;
|
|
163
|
+
|
|
164
|
+
const outPath = path.join(outDir, "provider.tsx");
|
|
165
|
+
// provider.tsx is derived from the layout, so --force may refresh it when the
|
|
166
|
+
// layout's provider chain changes. provider.fixtures.ts beside it is not
|
|
167
|
+
// regenerated under any flag — see renderFixtures.
|
|
168
|
+
if (existsSync(outPath) && !force) {
|
|
169
|
+
return { path: outPath, created: false, included: [], dropped: [] };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const sf = ts.createSourceFile(
|
|
173
|
+
layout,
|
|
174
|
+
readFileSync(layout, "utf8"),
|
|
175
|
+
ts.ScriptTarget.ES2022,
|
|
176
|
+
true,
|
|
177
|
+
ts.ScriptKind.TSX,
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const chain = childrenEnclosingChain(sf);
|
|
181
|
+
const resolver = new ModuleResolver();
|
|
182
|
+
const imports = collectImportLines(sf);
|
|
183
|
+
|
|
184
|
+
const included: Array<{
|
|
185
|
+
name: string;
|
|
186
|
+
importLine: string;
|
|
187
|
+
attrs: string;
|
|
188
|
+
dropped: DroppedAttr[];
|
|
189
|
+
}> = [];
|
|
190
|
+
/** Providers in the chain that will NOT be mounted, in chain order. */
|
|
191
|
+
const dropped: Array<{ name: string; specifier: string; reason: DropReason }> = [];
|
|
192
|
+
|
|
193
|
+
for (const el of chain) {
|
|
194
|
+
const tagName = el.tagText;
|
|
195
|
+
if (!/^[A-Z]/.test(tagName)) continue; // html/body/div — skip intrinsics
|
|
196
|
+
const root = tagName.split(".")[0] as string;
|
|
197
|
+
const imp = imports.get(root);
|
|
198
|
+
if (!imp) continue;
|
|
199
|
+
// React's own coordination components sit in the same JSX chain as app
|
|
200
|
+
// providers and own no app context: naming "Suspense — unresolved import"
|
|
201
|
+
// above a real drop buries the one line the reader came for.
|
|
202
|
+
if (REACT_OWN_COMPONENTS.test(imp.specifier)) continue;
|
|
203
|
+
// The auth library IS the provider in the tutorial-shaped apps: a pages
|
|
204
|
+
// `_app` wrapping its tree in `<SessionContextProvider>` from
|
|
205
|
+
// @supabase/auth-helpers-react. Asking that specifier to resolve walks
|
|
206
|
+
// node_modules and reports "not a client module", which is true of the
|
|
207
|
+
// dist file and misses the point — the unit plane stubs this package
|
|
208
|
+
// signed-out, so the accurate reason, and the one with a remedy, is auth.
|
|
209
|
+
if (isAuthLibSpecifier(imp.specifier)) {
|
|
210
|
+
dropped.push({ name: tagName, specifier: imp.specifier, reason: "auth" });
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const target = resolver.resolve(imp.specifier, layout);
|
|
214
|
+
if (!target) {
|
|
215
|
+
dropped.push({ name: tagName, specifier: imp.specifier, reason: "unresolved" });
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (!fileStartsWithUseClient(target)) {
|
|
219
|
+
dropped.push({ name: tagName, specifier: imp.specifier, reason: "server" });
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (moduleImportsAuthLib(target)) {
|
|
223
|
+
dropped.push({ name: tagName, specifier: imp.specifier, reason: "auth" });
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const { attrs, dropped: droppedAttrs } = literalAttrs(el.attributes, sf);
|
|
228
|
+
included.push({ name: tagName, importLine: imp.line, attrs, dropped: droppedAttrs });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const holes = included.flatMap((i) =>
|
|
232
|
+
i.dropped.filter((d) => d.name !== "...spread").map((d) => ({ owner: i.name, ...d })),
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
mkdirSync(outDir, { recursive: true });
|
|
236
|
+
|
|
237
|
+
// The asymmetry is the whole design. provider.tsx is DERIVED from the layout,
|
|
238
|
+
// so --force may refresh it. provider.fixtures.ts holds values a human typed
|
|
239
|
+
// and nothing can reconstruct, so --force must not touch it.
|
|
240
|
+
const fixturesPath = path.join(outDir, "provider.fixtures.ts");
|
|
241
|
+
if (holes.length > 0 && !existsSync(fixturesPath)) {
|
|
242
|
+
writeFileSync(fixturesPath, renderFixtures(layout, holes, imports));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
writeFileSync(outPath, renderWrapper(layout, included, holes.length > 0, dropped));
|
|
246
|
+
return {
|
|
247
|
+
path: outPath,
|
|
248
|
+
created: true,
|
|
249
|
+
included: included.map((i) => i.name),
|
|
250
|
+
dropped: dropped.map((d) => ({ name: d.name, reason: d.reason })),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
interface ChainElement {
|
|
255
|
+
tagText: string;
|
|
256
|
+
attributes: ts.JsxAttributes;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The pages-router `_app` marker: the file renders the ROUTE element
|
|
261
|
+
* (`<Component {...pageProps} />`) rather than `{children}`. Such a file cannot
|
|
262
|
+
* be mounted as a provider at all — a spec passes `{children}`, so `Component`
|
|
263
|
+
* is `undefined` and React fails with "Element type is invalid" before an
|
|
264
|
+
* assertion runs, and the wrapper cannot supply `AppProps` either. Its provider
|
|
265
|
+
* chain is still worth mounting, with the route element as the anchor.
|
|
266
|
+
*/
|
|
267
|
+
function routeElementAnchor(sf: ts.SourceFile): ts.Node | undefined {
|
|
268
|
+
let found: ts.Node | undefined;
|
|
269
|
+
const walk = (n: ts.Node): void => {
|
|
270
|
+
if (found) return;
|
|
271
|
+
if (
|
|
272
|
+
(ts.isJsxSelfClosingElement(n) || ts.isJsxOpeningElement(n)) &&
|
|
273
|
+
ts.isIdentifier(n.tagName) &&
|
|
274
|
+
n.tagName.text === "Component"
|
|
275
|
+
) {
|
|
276
|
+
found = n;
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
ts.forEachChild(n, walk);
|
|
280
|
+
};
|
|
281
|
+
walk(sf);
|
|
282
|
+
return found;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** True when the file renders a route element and never `{children}`. */
|
|
286
|
+
function rendersRouteElement(sf: ts.SourceFile): boolean {
|
|
287
|
+
let usesChildren = false;
|
|
288
|
+
const walk = (n: ts.Node): void => {
|
|
289
|
+
if (usesChildren) return;
|
|
290
|
+
if (
|
|
291
|
+
ts.isJsxExpression(n) &&
|
|
292
|
+
n.expression &&
|
|
293
|
+
ts.isIdentifier(n.expression) &&
|
|
294
|
+
n.expression.text === "children"
|
|
295
|
+
)
|
|
296
|
+
usesChildren = true;
|
|
297
|
+
ts.forEachChild(n, walk);
|
|
298
|
+
};
|
|
299
|
+
walk(sf);
|
|
300
|
+
return !usesChildren && routeElementAnchor(sf) !== undefined;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* The JSX elements that enclose the file's rendered content, outermost first.
|
|
305
|
+
* `{children}` is the anchor for a layout or provider; a pages-router `_app`
|
|
306
|
+
* has none, so its `<Component />` route element stands in.
|
|
307
|
+
*/
|
|
308
|
+
function childrenEnclosingChain(sf: ts.SourceFile): ChainElement[] {
|
|
309
|
+
let anchor: ts.Node | undefined;
|
|
310
|
+
const find = (n: ts.Node): void => {
|
|
311
|
+
if (anchor) return;
|
|
312
|
+
if (ts.isJsxExpression(n) && n.expression && ts.isIdentifier(n.expression)) {
|
|
313
|
+
if (n.expression.text === "children") {
|
|
314
|
+
anchor = n;
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
ts.forEachChild(n, find);
|
|
319
|
+
};
|
|
320
|
+
find(sf);
|
|
321
|
+
if (!anchor) anchor = routeElementAnchor(sf);
|
|
322
|
+
if (!anchor) return [];
|
|
323
|
+
|
|
324
|
+
const chain: ChainElement[] = [];
|
|
325
|
+
let cur: ts.Node | undefined = anchor.parent;
|
|
326
|
+
while (cur) {
|
|
327
|
+
if (ts.isJsxElement(cur)) {
|
|
328
|
+
const opening = cur.openingElement;
|
|
329
|
+
chain.unshift({ tagText: opening.tagName.getText(sf), attributes: opening.attributes });
|
|
330
|
+
}
|
|
331
|
+
cur = cur.parent;
|
|
332
|
+
}
|
|
333
|
+
return chain;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function collectImportLines(sf: ts.SourceFile): Map<string, { specifier: string; line: string }> {
|
|
337
|
+
const out = new Map<string, { specifier: string; line: string }>();
|
|
338
|
+
for (const stmt of sf.statements) {
|
|
339
|
+
if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier)) continue;
|
|
340
|
+
const specifier = stmt.moduleSpecifier.text;
|
|
341
|
+
const clause = stmt.importClause;
|
|
342
|
+
if (!clause) continue;
|
|
343
|
+
if (clause.name) {
|
|
344
|
+
out.set(clause.name.text, {
|
|
345
|
+
specifier,
|
|
346
|
+
line: `import ${clause.name.text} from "${specifier}";`,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) {
|
|
350
|
+
for (const el of clause.namedBindings.elements) {
|
|
351
|
+
const orig = el.propertyName?.text;
|
|
352
|
+
const spec = orig ? `${orig} as ${el.name.text}` : el.name.text;
|
|
353
|
+
out.set(el.name.text, {
|
|
354
|
+
specifier,
|
|
355
|
+
line: `import { ${spec} } from "${specifier}";`,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (clause.namedBindings && ts.isNamespaceImport(clause.namedBindings)) {
|
|
360
|
+
out.set(clause.namedBindings.name.text, {
|
|
361
|
+
specifier,
|
|
362
|
+
line: `import * as ${clause.namedBindings.name.text} from "${specifier}";`,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return out;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function fileStartsWithUseClient(file: string): boolean {
|
|
370
|
+
return readPrologueDirective(file) === "use client";
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* True when the module directly imports a provider-bound auth library
|
|
375
|
+
* (analyse/authShims.ts). The unit plane stubs those libraries globally with
|
|
376
|
+
* a signed-out snapshot, and an app's auth-aggregate provider (Clerk/Convex
|
|
377
|
+
* or next-auth SessionProvider chains) then turns the derived wrapper into an
|
|
378
|
+
* auth wall: signed-out, its Authenticated/SignedIn gates render nothing, so
|
|
379
|
+
* every wrapped spec mounts an empty tree and every child assertion fails —
|
|
380
|
+
* while the stubs already satisfy the context reads auth-consuming components
|
|
381
|
+
* actually make. Including such a provider buys the unit mount nothing and
|
|
382
|
+
* wedges it (miro-clone: ConvexClientProvider constructs a ConvexReactClient
|
|
383
|
+
* at module scope AND gates children behind auth).
|
|
384
|
+
*/
|
|
385
|
+
function moduleImportsAuthLib(file: string): boolean {
|
|
386
|
+
let sf: ts.SourceFile;
|
|
387
|
+
try {
|
|
388
|
+
sf = ts.createSourceFile(
|
|
389
|
+
file,
|
|
390
|
+
readFileSync(file, "utf8"),
|
|
391
|
+
ts.ScriptTarget.ES2022,
|
|
392
|
+
true,
|
|
393
|
+
ts.ScriptKind.TSX,
|
|
394
|
+
);
|
|
395
|
+
} catch {
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
398
|
+
for (const stmt of sf.statements) {
|
|
399
|
+
if (ts.isImportDeclaration(stmt) && ts.isStringLiteral(stmt.moduleSpecifier)) {
|
|
400
|
+
if (isAuthLibSpecifier(stmt.moduleSpecifier.text)) return true;
|
|
401
|
+
}
|
|
402
|
+
// `export { SessionProvider } from "next-auth/react"` binds just as well.
|
|
403
|
+
if (
|
|
404
|
+
ts.isExportDeclaration(stmt) &&
|
|
405
|
+
stmt.moduleSpecifier &&
|
|
406
|
+
ts.isStringLiteral(stmt.moduleSpecifier) &&
|
|
407
|
+
isAuthLibSpecifier(stmt.moduleSpecifier.text)
|
|
408
|
+
) {
|
|
409
|
+
return true;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** A prop the layout passed that is not literal-composable, and its source text. */
|
|
416
|
+
export interface DroppedAttr {
|
|
417
|
+
name: string;
|
|
418
|
+
exprText: string;
|
|
419
|
+
line: number;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function literalAttrs(
|
|
423
|
+
attrs: ts.JsxAttributes,
|
|
424
|
+
sf: ts.SourceFile,
|
|
425
|
+
): { attrs: string; dropped: DroppedAttr[] } {
|
|
426
|
+
const kept: string[] = [];
|
|
427
|
+
const dropped: DroppedAttr[] = [];
|
|
428
|
+
const lineOf = (n: ts.Node): number => sf.getLineAndCharacterOfPosition(n.getStart(sf)).line + 1;
|
|
429
|
+
for (const a of attrs.properties) {
|
|
430
|
+
if (ts.isJsxSpreadAttribute(a)) {
|
|
431
|
+
dropped.push({
|
|
432
|
+
name: "...spread",
|
|
433
|
+
exprText: `{...${a.expression.getText(sf)}}`,
|
|
434
|
+
line: lineOf(a),
|
|
435
|
+
});
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
if (!ts.isJsxAttribute(a) || !ts.isIdentifier(a.name)) continue;
|
|
439
|
+
const name = a.name.text;
|
|
440
|
+
const init = a.initializer;
|
|
441
|
+
if (!init) {
|
|
442
|
+
kept.push(name);
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
if (ts.isStringLiteral(init)) {
|
|
446
|
+
kept.push(`${name}=${JSON.stringify(init.text)}`);
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
if (ts.isJsxExpression(init) && init.expression) {
|
|
450
|
+
const e = init.expression;
|
|
451
|
+
if (ts.isStringLiteral(e)) {
|
|
452
|
+
kept.push(`${name}=${JSON.stringify(e.text)}`);
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
if (ts.isNumericLiteral(e) || e.kind === ts.SyntaxKind.TrueKeyword) {
|
|
456
|
+
kept.push(`${name}={${e.getText(sf)}}`);
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
dropped.push({ name, exprText: init ? init.getText(sf) : name, line: lineOf(a) });
|
|
461
|
+
}
|
|
462
|
+
return { attrs: kept.length > 0 ? ` ${kept.join(" ")}` : "", dropped };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function renderWrapper(
|
|
466
|
+
layoutFile: string,
|
|
467
|
+
included: Array<{ name: string; importLine: string; attrs: string; dropped: DroppedAttr[] }>,
|
|
468
|
+
hasFixtures: boolean,
|
|
469
|
+
droppedLayers: Array<{ name: string; specifier: string; reason: DropReason }> = [],
|
|
470
|
+
): string {
|
|
471
|
+
const importLines = [...new Set(included.map((i) => i.importLine))].join("\n");
|
|
472
|
+
const layoutRel = path.basename(layoutFile);
|
|
473
|
+
|
|
474
|
+
let open = "";
|
|
475
|
+
let close = "";
|
|
476
|
+
const todos: string[] = [];
|
|
477
|
+
for (const [idx, el] of included.entries()) {
|
|
478
|
+
const indent = ` ${" ".repeat(idx)}`;
|
|
479
|
+
// A non-literal prop becomes a typed hole in provider.fixtures.ts rather
|
|
480
|
+
// than a comment: an editor red-squiggle naming the exact shape to supply is
|
|
481
|
+
// worth more than a TODO nobody sees.
|
|
482
|
+
const fixtureAttrs = el.dropped
|
|
483
|
+
.filter((d) => d.name !== "...spread")
|
|
484
|
+
.map((d) => ` ${d.name}={providerFixtures.${d.name}}`)
|
|
485
|
+
.join("");
|
|
486
|
+
for (const d of el.dropped.filter((x) => x.name === "...spread")) {
|
|
487
|
+
todos.push(
|
|
488
|
+
`${indent}{/* TODO(flawless): layout spread ${commentSafe(d.exprText)} into <${el.name}> — not reconstructable */}`,
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
open += `${indent}<${el.name}${el.attrs}${fixtureAttrs}>\n`;
|
|
492
|
+
close = `${indent}</${el.name}>\n${close}`;
|
|
493
|
+
}
|
|
494
|
+
const inner = ` ${" ".repeat(included.length)}{children}`;
|
|
495
|
+
|
|
496
|
+
const tree =
|
|
497
|
+
included.length > 0
|
|
498
|
+
? `${todos.length > 0 ? `${todos.join("\n")}\n` : ""}${open}${inner}\n${close}`.replace(
|
|
499
|
+
/\n$/,
|
|
500
|
+
"",
|
|
501
|
+
)
|
|
502
|
+
: " <>{children}</>";
|
|
503
|
+
|
|
504
|
+
// A dropped layer is the loudest thing in this file and was silent: an empty
|
|
505
|
+
// wrapper whose specs all fail on "could not find X in the context" reads as a
|
|
506
|
+
// broken component, not as a provider chain the generator declined to mount.
|
|
507
|
+
// Name each one, why, and where the fix goes (here is not a durable place).
|
|
508
|
+
const droppedNote =
|
|
509
|
+
droppedLayers.length === 0
|
|
510
|
+
? ""
|
|
511
|
+
: `\n// DROPPED from ${layoutRel}'s provider chain — specs mount WITHOUT these:\n${droppedLayers
|
|
512
|
+
.map(
|
|
513
|
+
(d) =>
|
|
514
|
+
`// ${d.name}, from ${commentSafe(d.specifier)}, ${DROP_REASONS[d.reason].long}.`,
|
|
515
|
+
)
|
|
516
|
+
.join(
|
|
517
|
+
"\n",
|
|
518
|
+
)}\n// A spec that needs one of these contexts wants its own --provider: this file is\n// derived from the layout and regenerated by --force, so editing it here does not stick.\n`;
|
|
519
|
+
|
|
520
|
+
return `"use client";
|
|
521
|
+
// flawless-react provider wrapper — auto-derived from ${layoutRel}, which cannot
|
|
522
|
+
// be mounted as it stands: an async or server-only module, the <html> document
|
|
523
|
+
// shell, or a pages \`_app\` that renders the route element instead of
|
|
524
|
+
// {children}. REGENERATED by --force: put values you want to keep in
|
|
525
|
+
// provider.fixtures.ts, which is never rewritten.${droppedNote}
|
|
526
|
+
import type { ReactNode } from "react";
|
|
527
|
+
${importLines}${hasFixtures ? '\nimport { providerFixtures } from "./provider.fixtures";' : ""}
|
|
528
|
+
|
|
529
|
+
export default function FlawlessProvider({ children }: { children: ReactNode }) {
|
|
530
|
+
return (
|
|
531
|
+
<>
|
|
532
|
+
${tree}
|
|
533
|
+
</>
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
`;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Typed holes for the props the layout passed that could not be copied.
|
|
541
|
+
*
|
|
542
|
+
* Typing each against `ComponentProps<typeof Provider>[prop]` costs nothing —
|
|
543
|
+
* the import line was already captured — and turns "fill this in" into a red
|
|
544
|
+
* squiggle naming the exact shape. The alternative considered was synthesising a
|
|
545
|
+
* plausible session object; that is precisely the guessing this project refuses,
|
|
546
|
+
* and a wrong-shaped fixture is worse than an empty one because the specs go
|
|
547
|
+
* green against a fiction.
|
|
548
|
+
*/
|
|
549
|
+
/**
|
|
550
|
+
* Quoted source is illustrative only, but it sits inside a comment — and a
|
|
551
|
+
* star-slash pair in it (globs: "**" + "/*") would terminate that comment and
|
|
552
|
+
* break the emitted file. A zero-width space between the two keeps the text
|
|
553
|
+
* readable while making the pair inert. Applies to both comment forms: the
|
|
554
|
+
* fixtures' doc comments and the wrapper's JSX spread TODOs.
|
|
555
|
+
*/
|
|
556
|
+
function commentSafe(text: string): string {
|
|
557
|
+
return text.replaceAll("*/", "*\u200b/");
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Source text on one line and comment-inert, so it can sit inside a doc comment. */
|
|
561
|
+
function flatten(text: string): string {
|
|
562
|
+
const one = text
|
|
563
|
+
.replace(/^\{|\}$/g, "")
|
|
564
|
+
.replace(/\s+/g, " ")
|
|
565
|
+
.trim();
|
|
566
|
+
return commentSafe(one.length > 90 ? `${one.slice(0, 87)}…` : one);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function renderFixtures(
|
|
570
|
+
layoutFile: string,
|
|
571
|
+
holes: Array<{ owner: string; name: string; exprText: string; line: number }>,
|
|
572
|
+
imports: Map<string, { specifier: string; line: string }>,
|
|
573
|
+
): string {
|
|
574
|
+
const layoutRel = path.basename(layoutFile);
|
|
575
|
+
const importLines = [
|
|
576
|
+
...new Set(
|
|
577
|
+
holes
|
|
578
|
+
.map((h) => imports.get(h.owner.split(".")[0] as string)?.line)
|
|
579
|
+
.filter((l): l is string => l !== undefined),
|
|
580
|
+
),
|
|
581
|
+
].join("\n");
|
|
582
|
+
|
|
583
|
+
const entries = holes
|
|
584
|
+
.map(
|
|
585
|
+
(h) =>
|
|
586
|
+
` /** ${layoutRel}:${h.line} passed ${h.name}={${flatten(h.exprText)}} to <${h.owner}>. */\n` +
|
|
587
|
+
` ${h.name}: {} as ComponentProps<typeof ${h.owner.split(".")[0]}>[${JSON.stringify(h.name)}],`,
|
|
588
|
+
)
|
|
589
|
+
.join("\n\n");
|
|
590
|
+
|
|
591
|
+
return `// Fixtures for the props flawless-react could not copy out of ${layoutRel},
|
|
592
|
+
// because they are computed rather than literal.
|
|
593
|
+
//
|
|
594
|
+
// Owned by you: flawless-react writes this file once and never again — not even
|
|
595
|
+
// with --force. provider.tsx beside it IS regenerated, so keep values here.
|
|
596
|
+
import type { ComponentProps } from "react";
|
|
597
|
+
${importLines}
|
|
598
|
+
|
|
599
|
+
export const providerFixtures = {
|
|
600
|
+
${entries}
|
|
601
|
+
};
|
|
602
|
+
`;
|
|
603
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { NextjsRouter } from "../types.ts";
|
|
4
|
+
|
|
5
|
+
export interface PageRoute {
|
|
6
|
+
/** URL path with dynamic segments kept in brackets: /dashboard/buildings/[id]. */
|
|
7
|
+
urlPath: string;
|
|
8
|
+
/** Dynamic segment names in order: ["id"]. */
|
|
9
|
+
params: string[];
|
|
10
|
+
/** Safe file stem for a spec: dashboard-buildings-id. */
|
|
11
|
+
specStem: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Map an app-router page file to its route. Returns undefined when the file is
|
|
16
|
+
* not a page.tsx/page.jsx under the router root (or when the router is pages —
|
|
17
|
+
* only the app router is supported for e2e emission in v1).
|
|
18
|
+
*/
|
|
19
|
+
export function pageFileToRoute(
|
|
20
|
+
file: string,
|
|
21
|
+
router: NextjsRouter,
|
|
22
|
+
routerRoot: string,
|
|
23
|
+
): PageRoute | undefined {
|
|
24
|
+
if (router !== "app") return undefined;
|
|
25
|
+
const abs = path.resolve(file);
|
|
26
|
+
const rel = path.relative(path.resolve(routerRoot), abs);
|
|
27
|
+
if (rel.startsWith("..")) return undefined;
|
|
28
|
+
const parts = rel.split(path.sep);
|
|
29
|
+
const last = parts.pop();
|
|
30
|
+
if (!last || !/^page\.(tsx|jsx|ts|js)$/.test(last)) return undefined;
|
|
31
|
+
|
|
32
|
+
const segments: string[] = [];
|
|
33
|
+
const params: string[] = [];
|
|
34
|
+
for (const part of parts) {
|
|
35
|
+
if (/^\(.*\)$/.test(part)) continue; // route groups vanish from the URL
|
|
36
|
+
if (part.startsWith("@")) continue; // parallel-route slots vanish too
|
|
37
|
+
const catchAll = part.match(/^\[\[?\.\.\.(.+?)\]?\]$/);
|
|
38
|
+
if (catchAll?.[1]) {
|
|
39
|
+
params.push(catchAll[1]);
|
|
40
|
+
segments.push(`[...${catchAll[1]}]`);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const dynamic = part.match(/^\[(.+)\]$/);
|
|
44
|
+
if (dynamic?.[1]) {
|
|
45
|
+
params.push(dynamic[1]);
|
|
46
|
+
segments.push(`[${dynamic[1]}]`);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
segments.push(part);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const urlPath = `/${segments.join("/")}`;
|
|
53
|
+
const specStem =
|
|
54
|
+
segments.length === 0
|
|
55
|
+
? "index"
|
|
56
|
+
: segments
|
|
57
|
+
.map((s) => s.replace(/\[\.\.\.(.+)\]/, "$1").replace(/\[(.+)\]/, "$1"))
|
|
58
|
+
.join("-")
|
|
59
|
+
.replace(/[^a-zA-Z0-9-]+/g, "-")
|
|
60
|
+
.replace(/-+/g, "-")
|
|
61
|
+
.replace(/^-|-$/g, "");
|
|
62
|
+
return { urlPath: urlPath === "/" ? "/" : urlPath, params, specStem };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Parse the port from a Next.js dev script ("next dev -p 3001" → 3001).
|
|
67
|
+
*
|
|
68
|
+
* The separator is optional after `-p`, and `PORT=` is the other common form.
|
|
69
|
+
* Both used to fall through to 3000, and the emitted e2e config then waited out
|
|
70
|
+
* its full 120s webServer timeout on a URL nobody was serving.
|
|
71
|
+
*/
|
|
72
|
+
export function devScriptPort(devScript: string | undefined): number {
|
|
73
|
+
if (!devScript) return 3000;
|
|
74
|
+
const m = devScript.match(/(?:-p|--port)[=\s]*(\d+)/) ?? devScript.match(/\bPORT=(\d+)/);
|
|
75
|
+
const port = m?.[1] ? Number(m[1]) : Number.NaN;
|
|
76
|
+
return Number.isFinite(port) ? port : 3000;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The app/ or pages/ directory a Next.js project routes from. */
|
|
80
|
+
export function findRouterRoot(nextRoot: string): string {
|
|
81
|
+
for (const rel of ["app", "src/app", "pages", "src/pages"]) {
|
|
82
|
+
const abs = path.join(nextRoot, rel);
|
|
83
|
+
if (existsSync(abs)) return abs;
|
|
84
|
+
}
|
|
85
|
+
return nextRoot;
|
|
86
|
+
}
|