@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,2379 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { type AuthStub, authShimEsm, collectAuthStubs } from "../analyse/authShims.ts";
|
|
4
|
+
import { requiredAncestors } from "../analyse/htmlContentModel.ts";
|
|
5
|
+
import { libraryShimSource, referencedLibraryShims } from "../analyse/providerWraps.ts";
|
|
6
|
+
import { relativeToEmitted } from "../emitPaths.ts";
|
|
7
|
+
import { appReactMajor, detectAppReact } from "../packageJson.ts";
|
|
8
|
+
import {
|
|
9
|
+
allocateSpecNames,
|
|
10
|
+
compoundWrappable,
|
|
11
|
+
defaultsJson,
|
|
12
|
+
flattenTrees,
|
|
13
|
+
functionProps,
|
|
14
|
+
hostEligibility,
|
|
15
|
+
synthesiseDefaults,
|
|
16
|
+
} from "../scaffold-tests/registry.ts";
|
|
17
|
+
import { emitPlaywrightClickTarget } from "../scaffold-tests/selectors.ts";
|
|
18
|
+
import { stamp } from "../scaffold-tests/stamp.ts";
|
|
19
|
+
import { NEXT_SCRIPT, SUITE_COMMAND, SUITE_SCRIPT } from "../suiteScripts.ts";
|
|
20
|
+
import type {
|
|
21
|
+
ComponentInfo,
|
|
22
|
+
ComponentTree,
|
|
23
|
+
HeapMode,
|
|
24
|
+
MockRoute,
|
|
25
|
+
PropsShape,
|
|
26
|
+
PropValue,
|
|
27
|
+
ProviderRef,
|
|
28
|
+
} from "../types.ts";
|
|
29
|
+
import { VERSION } from "../version.ts";
|
|
30
|
+
|
|
31
|
+
const NL = "\n";
|
|
32
|
+
|
|
33
|
+
interface FlatComponent {
|
|
34
|
+
info: ComponentInfo;
|
|
35
|
+
importPath: string;
|
|
36
|
+
/** Registry key. Differs from componentName when two components share a name. */
|
|
37
|
+
specName: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ExcludedComponent {
|
|
41
|
+
name: string;
|
|
42
|
+
file: string;
|
|
43
|
+
reason: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface RenderedFile {
|
|
47
|
+
relPath: string;
|
|
48
|
+
content: string;
|
|
49
|
+
/** When set, write here instead of <outDir>/<relPath> (library shim files). */
|
|
50
|
+
absPath?: string;
|
|
51
|
+
executable?: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Never overwrite an existing file here, `--force` included.
|
|
54
|
+
*
|
|
55
|
+
* For the files whose contents cannot be recovered from the analysis — the
|
|
56
|
+
* same criterion `provider.fixtures.ts` and `e2e/global-setup.ts` are held to.
|
|
57
|
+
* Everything else in the suite is derived and must stay regenerable, or an
|
|
58
|
+
* existing suite never receives a later version's improvements.
|
|
59
|
+
*
|
|
60
|
+
* **The set is smaller than it looks: in renderSuite only `host/main.tsx` and
|
|
61
|
+
* `budgets.json` carry this.** `profile/_helpers.ts` reads like somewhere to
|
|
62
|
+
* keep your own scenarios and is rebuilt on every `--force`. Check this flag
|
|
63
|
+
* before telling a user, in emitted prose or in docs, that their edits to a
|
|
64
|
+
* file will survive — the emitted README claimed exactly that about
|
|
65
|
+
* `_helpers.ts` in a draft of the commit that fixed the README for claiming
|
|
66
|
+
* it about itself. Rendering the file and reading it caught that; no test did.
|
|
67
|
+
*/
|
|
68
|
+
writeOnce?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface RenderOptions {
|
|
72
|
+
tree: ComponentTree;
|
|
73
|
+
/** How much of the V8 heap the emitted profile suite keeps. */
|
|
74
|
+
heaps?: HeapMode;
|
|
75
|
+
extraTrees?: ComponentTree[];
|
|
76
|
+
outDir: string;
|
|
77
|
+
variants: number;
|
|
78
|
+
report: boolean;
|
|
79
|
+
providerImportPath?: string;
|
|
80
|
+
/**
|
|
81
|
+
* Absolute path of the same provider, for reading its import table: the host
|
|
82
|
+
* mounts it around every component, so its provider-bound auth imports need
|
|
83
|
+
* stubbing too (analyse/authShims.ts).
|
|
84
|
+
*/
|
|
85
|
+
providerFile?: string;
|
|
86
|
+
mockRoutes?: MockRoute[];
|
|
87
|
+
suiteName?: string;
|
|
88
|
+
nextjsHost?: boolean;
|
|
89
|
+
mswSetup?: boolean;
|
|
90
|
+
/** Context providers to mount around each component; see ScaffoldOptions. */
|
|
91
|
+
providerWraps?: Map<string, ProviderRef[]>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function renderSuite(opts: RenderOptions): RenderedFile[] {
|
|
95
|
+
const trees: ComponentTree[] = [opts.tree, ...(opts.extraTrees ?? [])];
|
|
96
|
+
const {
|
|
97
|
+
hosted: flat,
|
|
98
|
+
excluded,
|
|
99
|
+
serverActionStubs,
|
|
100
|
+
authStubs,
|
|
101
|
+
} = selectHostComponents(trees, opts.outDir, opts.providerFile);
|
|
102
|
+
const hasMock = Array.isArray(opts.mockRoutes);
|
|
103
|
+
const useMsw = Boolean(opts.mswSetup);
|
|
104
|
+
const suiteName = opts.suiteName ?? trees[0]?.entry.componentName ?? "suite";
|
|
105
|
+
const files: RenderedFile[] = [
|
|
106
|
+
{ relPath: "playwright.config.ts", content: playwrightConfig(hasMock) },
|
|
107
|
+
{
|
|
108
|
+
relPath: "host/server.ts",
|
|
109
|
+
content: hostServer(hasMock, serverActionStubs, path.join(opts.outDir, "host"), authStubs),
|
|
110
|
+
},
|
|
111
|
+
{ relPath: "host/index.html", content: hostHtml() },
|
|
112
|
+
{ relPath: "host/_polyfill.ts", content: hostPolyfill() },
|
|
113
|
+
// Regenerated (FR-016): a re-run must be able to register newly analysed
|
|
114
|
+
// components over a kept wrapper.
|
|
115
|
+
{
|
|
116
|
+
relPath: "host/registry.ts",
|
|
117
|
+
content: hostRegistry(flat, opts.providerWraps, path.resolve(opts.outDir, "host")),
|
|
118
|
+
},
|
|
119
|
+
// writeOnce: docs/extending.md tells you to edit this to add providers,
|
|
120
|
+
// MSW and hand registrations in the ALL spread, and nothing can rebuild
|
|
121
|
+
// that wiring from the analysis. The tables it reads live in ./registry.
|
|
122
|
+
{
|
|
123
|
+
relPath: "host/main.tsx",
|
|
124
|
+
// legacyMount: a React 17 host renders through ReactDOM.render — there
|
|
125
|
+
// is no react-dom/client to import, so this cannot be runtime-detected.
|
|
126
|
+
content: hostMain(opts.providerImportPath, useMsw, (appReactMajor(opts.outDir) ?? 18) <= 17),
|
|
127
|
+
writeOnce: true,
|
|
128
|
+
},
|
|
129
|
+
{ relPath: "profile/_helpers.ts", content: helpers() },
|
|
130
|
+
{ relPath: "profile/_heap.ts", content: heapHelper(opts.heaps ?? "stress") },
|
|
131
|
+
// writeOnce: docs/generated-suite.md tells you to tighten these to your
|
|
132
|
+
// observed p95. A regenerated default silently un-tightens the gate.
|
|
133
|
+
{ relPath: "budgets.json", content: budgets(flat, opts.variants), writeOnce: true },
|
|
134
|
+
{
|
|
135
|
+
relPath: "README.md",
|
|
136
|
+
content: readme(
|
|
137
|
+
suiteName,
|
|
138
|
+
flat.map((c) => c.specName),
|
|
139
|
+
excluded,
|
|
140
|
+
{ nextjsHost: opts.nextjsHost, report: opts.report },
|
|
141
|
+
),
|
|
142
|
+
},
|
|
143
|
+
];
|
|
144
|
+
// Generated library-provider shims the wrap map mounts: the file lives at
|
|
145
|
+
// the IMPORTING PACKAGE's root, not the outDir — a bare library import only
|
|
146
|
+
// resolves through that package's node_modules (bun workspaces hoist
|
|
147
|
+
// nothing). Tool-owned content, rewritten per run.
|
|
148
|
+
for (const shim of referencedLibraryShims(
|
|
149
|
+
opts.providerWraps,
|
|
150
|
+
flat.map((c) => c.info.filePath),
|
|
151
|
+
)) {
|
|
152
|
+
files.push({
|
|
153
|
+
relPath: path.relative(opts.outDir, shim.filePath),
|
|
154
|
+
absPath: shim.filePath,
|
|
155
|
+
content: libraryShimSource(shim.module, shim.componentName, shim.filePath),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
if (!opts.nextjsHost) {
|
|
159
|
+
files.unshift({ relPath: "tsconfig.json", content: tsConfig() });
|
|
160
|
+
files.unshift({
|
|
161
|
+
relPath: "package.json",
|
|
162
|
+
content: pkgJson(suiteName, opts.report, opts.outDir),
|
|
163
|
+
});
|
|
164
|
+
files.push({ relPath: "pnpm-workspace.yaml", content: PNPM_SUITE_SETTINGS, writeOnce: true });
|
|
165
|
+
}
|
|
166
|
+
for (const c of flat) {
|
|
167
|
+
files.push({
|
|
168
|
+
relPath: `profile/${c.specName}.profile.spec.ts`,
|
|
169
|
+
// Stamped like the colocated and browser specs: this file is only
|
|
170
|
+
// rewritten while the component STAYS host-mountable, so the stamp is
|
|
171
|
+
// what lets a later sweep tell "our own stale output" (safe to remove)
|
|
172
|
+
// from "someone worked on this" (kept). Restamped after the host
|
|
173
|
+
// formatter runs, same as the colocated specs (core.ts restampGenerated).
|
|
174
|
+
content: stamp(spec(c, opts.variants)),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
if (hasMock) {
|
|
178
|
+
files.push({ relPath: "mock/server.ts", content: mockServer() });
|
|
179
|
+
// writeOnce: its emitted header says no regeneration step overwrites it —
|
|
180
|
+
// hand-tuned fixture bodies cannot be rebuilt from the OpenAPI spec.
|
|
181
|
+
files.push({
|
|
182
|
+
relPath: "mock/routes.ts",
|
|
183
|
+
content: mockRoutesFile(opts.mockRoutes ?? []),
|
|
184
|
+
writeOnce: true,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
if (opts.report) {
|
|
188
|
+
files.push({
|
|
189
|
+
relPath: "scripts/report.mjs",
|
|
190
|
+
content: reportScript(
|
|
191
|
+
opts.nextjsHost ? NEXT_SCRIPT.report : SUITE_SCRIPT.report,
|
|
192
|
+
opts.nextjsHost ? NEXT_SCRIPT.perf : SUITE_SCRIPT.test,
|
|
193
|
+
),
|
|
194
|
+
executable: true,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return files;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function toHostRelImport(outDir: string, absFile: string): string {
|
|
201
|
+
const hostDir = path.resolve(outDir, "host");
|
|
202
|
+
return toRel(hostDir, path.resolve(absFile));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The components the host can actually mount, plus what was left out and why.
|
|
207
|
+
*
|
|
208
|
+
* The old implementation deduped on filePath alone, so a file exporting seven
|
|
209
|
+
* components contributed one — 84 of the dogfood app's 181 vanished without a
|
|
210
|
+
* word. It also keyed imports and the REGISTRY on the bare component name, which
|
|
211
|
+
* does not compile when two files both export a CommentForm, and it emitted
|
|
212
|
+
* `import { X }` for default exports and for components that are not exported at
|
|
213
|
+
* all.
|
|
214
|
+
*
|
|
215
|
+
* Names are allocated over the FULL list before filtering, so they match the
|
|
216
|
+
* unit scaffolder's — the browser specs look components up by these keys.
|
|
217
|
+
*/
|
|
218
|
+
/** One "use server" module to stub for the browser bundle. */
|
|
219
|
+
interface ServerActionStub {
|
|
220
|
+
/** Every export name the stub must bind. */
|
|
221
|
+
names: string[];
|
|
222
|
+
/** Module-side name → the literal that action returns, where resolved. */
|
|
223
|
+
returns: Record<string, PropValue>;
|
|
224
|
+
/**
|
|
225
|
+
* Bare (non-relative) specifiers that import this module. The analyser
|
|
226
|
+
* resolves a workspace package to its SOURCE tree — that is where the
|
|
227
|
+
* annotations live — but the bundler resolves the same bare specifier
|
|
228
|
+
* through package.json `main`/`exports` to `dist/`, so a path-keyed stub
|
|
229
|
+
* never fires for it (B-11). These specifiers are intercepted BEFORE
|
|
230
|
+
* resolution instead, the way the unit plane's vi.mock always has.
|
|
231
|
+
*/
|
|
232
|
+
specifiers: string[];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function selectHostComponents(
|
|
236
|
+
trees: ComponentTree[],
|
|
237
|
+
outDir: string,
|
|
238
|
+
providerFile?: string,
|
|
239
|
+
): {
|
|
240
|
+
hosted: FlatComponent[];
|
|
241
|
+
excluded: ExcludedComponent[];
|
|
242
|
+
/** Absolute path → the stub for the module at that path. */
|
|
243
|
+
serverActionStubs: Map<string, ServerActionStub>;
|
|
244
|
+
/** Bare specifier → the signed-out auth-library stub (analyse/authShims.ts). */
|
|
245
|
+
authStubs: Map<string, AuthStub>;
|
|
246
|
+
} {
|
|
247
|
+
const hostDir = path.resolve(outDir, "host");
|
|
248
|
+
const flat = flattenTrees(trees);
|
|
249
|
+
const specNames = allocateSpecNames(flat);
|
|
250
|
+
// EVERY analysed component contributes imports here, hosted or not: an
|
|
251
|
+
// excluded component still sits in a hosted neighbour's import chain, and
|
|
252
|
+
// one library that throws at module init takes the whole bundle down. The
|
|
253
|
+
// provider wrapper joins them for the same reason — the host renders it around
|
|
254
|
+
// every component, so its auth imports are in that bundle whether the
|
|
255
|
+
// analysis visited the file or not.
|
|
256
|
+
const authStubs = collectAuthStubs(
|
|
257
|
+
flat.map((c) => c.info),
|
|
258
|
+
providerFile ? [providerFile] : [],
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
const hosted: FlatComponent[] = [];
|
|
262
|
+
const excluded: ExcludedComponent[] = [];
|
|
263
|
+
const serverActionStubs = new Map<string, ServerActionStub>();
|
|
264
|
+
for (const c of flat) {
|
|
265
|
+
const verdict = hostEligibility(c.info);
|
|
266
|
+
if (!verdict.eligible) {
|
|
267
|
+
excluded.push({
|
|
268
|
+
name: c.info.componentName,
|
|
269
|
+
file: c.info.filePath,
|
|
270
|
+
reason: verdict.reason ?? "not eligible",
|
|
271
|
+
});
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
for (const mi of c.info.moduleImports ?? []) {
|
|
275
|
+
if (!mi.isServerAction || !mi.resolvedPath || mi.isTypeOnly) continue;
|
|
276
|
+
const entry = serverActionStubs.get(mi.resolvedPath) ?? {
|
|
277
|
+
names: [],
|
|
278
|
+
returns: {},
|
|
279
|
+
specifiers: [],
|
|
280
|
+
};
|
|
281
|
+
const names = new Set(entry.names);
|
|
282
|
+
for (const n of mi.serverActionExports ?? mi.names) names.add(n);
|
|
283
|
+
const specifiers = new Set(entry.specifiers);
|
|
284
|
+
if (!mi.specifier.startsWith(".")) specifiers.add(mi.specifier);
|
|
285
|
+
// Sorted, not Set order: the emitted file has to be byte-identical across
|
|
286
|
+
// runs or the idempotence gate churns. Returns merge trivially — every
|
|
287
|
+
// importer's record was extracted from the same file.
|
|
288
|
+
serverActionStubs.set(mi.resolvedPath, {
|
|
289
|
+
names: [...names].sort(),
|
|
290
|
+
returns: { ...entry.returns, ...(mi.serverActionReturns ?? {}) },
|
|
291
|
+
specifiers: [...specifiers].sort(),
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
hosted.push({
|
|
295
|
+
info: c.info,
|
|
296
|
+
// No .tsx: the host is type-checked by the target project, which does not
|
|
297
|
+
// enable allowImportingTsExtensions.
|
|
298
|
+
importPath: stripTsExt(toRel(hostDir, c.info.filePath)),
|
|
299
|
+
specName: specNames.get(c) as string,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
return { hosted, excluded, serverActionStubs, authStubs };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function stripTsExt(spec: string): string {
|
|
306
|
+
return spec.replace(/\.(tsx|ts|jsx|js)$/, "");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Safe inside an emitted `test(\`...\`)` / `test.skip(\`...\`)` template literal.
|
|
311
|
+
*
|
|
312
|
+
* A title quotes the source — a tag name, a prop name, a clickable label — so
|
|
313
|
+
* it can carry a backtick or a `${`: the backtick would end the literal early
|
|
314
|
+
* and emit a spec that does not parse, the `${` would become a live
|
|
315
|
+
* substitution. Exported for the unit/browser emitter, whose titles quote the
|
|
316
|
+
* same source text.
|
|
317
|
+
*/
|
|
318
|
+
export function titleSafe(reason: string): string {
|
|
319
|
+
return reason.replace(/`/g, "'").replace(/\$\{/g, "$ {");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Named vs default import, aliased when the registry key differs from the name. */
|
|
323
|
+
function hostComponentImport(c: FlatComponent): string {
|
|
324
|
+
const { componentName, exportKind } = c.info;
|
|
325
|
+
if (exportKind === "default") return `import ${c.specName} from "${c.importPath}";`;
|
|
326
|
+
// `export { Internal as Public }` — only the alias is a module binding.
|
|
327
|
+
const boundary = c.info.exportedAs ?? componentName;
|
|
328
|
+
if (c.specName === boundary) {
|
|
329
|
+
return `import { ${boundary} } from "${c.importPath}";`;
|
|
330
|
+
}
|
|
331
|
+
return `import { ${boundary} as ${c.specName} } from "${c.importPath}";`;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function toRel(fromDir: string, absFile: string): string {
|
|
335
|
+
let rel = path.relative(fromDir, absFile);
|
|
336
|
+
if (!rel.startsWith(".")) rel = `./${rel}`;
|
|
337
|
+
return rel.split(path.sep).join("/");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// pnpm >=10 refuses to run a dependency's build scripts unless the manager
|
|
341
|
+
// declares them, and esbuild's postinstall IS its binary: a suite installed
|
|
342
|
+
// with plain `pnpm install` died at vitest startup with a confusing
|
|
343
|
+
// "esbuild binary missing" from inside vite. pnpm >=10 reads the allow-list
|
|
344
|
+
// from pnpm-workspace.yaml (onlyBuiltDependencies) and pnpm >=12 renamed it
|
|
345
|
+
// (allowBuilds); the file spells both, and each manager ignores the key it
|
|
346
|
+
// does not know. The suite-local file also keeps `pnpm install` run inside
|
|
347
|
+
// the suite from being captured by the application's workspace.
|
|
348
|
+
export const PNPM_SUITE_SETTINGS = `# Generated by flawless-react (write-once: add your own keys freely).
|
|
349
|
+
# esbuild's postinstall IS its binary — without this allowance pnpm refuses
|
|
350
|
+
# it and vitest dies at startup; msw's copies its service worker. Both
|
|
351
|
+
# spellings: pnpm >=10 and >=12 renamed.
|
|
352
|
+
onlyBuiltDependencies:
|
|
353
|
+
- esbuild
|
|
354
|
+
- msw
|
|
355
|
+
allowBuilds:
|
|
356
|
+
esbuild: true
|
|
357
|
+
msw: true
|
|
358
|
+
`;
|
|
359
|
+
|
|
360
|
+
function pkgJson(name: string, report: boolean, outDir: string): string {
|
|
361
|
+
// The suite's react IS the copy the perf host bundles and renders with
|
|
362
|
+
// (resolveFromSuite resolves from the suite directory), so it must be the
|
|
363
|
+
// version the app's component code was written against. See detectAppReact.
|
|
364
|
+
const pin = detectAppReact(outDir);
|
|
365
|
+
const typesPin = pin ? `^${pin.react.split(".")[0]}.0.0` : "^18.3.0";
|
|
366
|
+
const dashed = name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
367
|
+
return `${JSON.stringify(
|
|
368
|
+
{
|
|
369
|
+
name: `${dashed}-profile`,
|
|
370
|
+
version: "0.0.0",
|
|
371
|
+
private: true,
|
|
372
|
+
type: "module",
|
|
373
|
+
scripts: {
|
|
374
|
+
[SUITE_SCRIPT.test]: SUITE_COMMAND.perf,
|
|
375
|
+
// Same command under the name --run-tests looks for. Without it, a
|
|
376
|
+
// standalone perf-harness run with --run-tests reported "failed suites:
|
|
377
|
+
// perf" on a suite that was fine, because `bun run test:perf` did not
|
|
378
|
+
// exist — the key is only added when unit-browser also ran.
|
|
379
|
+
[SUITE_SCRIPT.perf]: SUITE_COMMAND.perf,
|
|
380
|
+
[SUITE_SCRIPT.headed]: SUITE_COMMAND.headed,
|
|
381
|
+
// Only when scripts/report.mjs is emitted beside it. Declared
|
|
382
|
+
// unconditionally, `bun run report` on a suite generated without
|
|
383
|
+
// --report failed with a module-not-found naming a file the user never
|
|
384
|
+
// asked for and cannot restore without regenerating.
|
|
385
|
+
...(report ? { [SUITE_SCRIPT.report]: SUITE_COMMAND.report } : {}),
|
|
386
|
+
},
|
|
387
|
+
dependencies: {
|
|
388
|
+
react: pin?.react ?? "^18.3.0",
|
|
389
|
+
"react-dom": pin?.reactDom ?? "^18.3.0",
|
|
390
|
+
"react-scan": "^0.4.3",
|
|
391
|
+
},
|
|
392
|
+
devDependencies: {
|
|
393
|
+
"@playwright/test": "^1.49.0",
|
|
394
|
+
"@types/node": "^22.0.0",
|
|
395
|
+
"@types/react": typesPin,
|
|
396
|
+
"@types/react-dom": typesPin,
|
|
397
|
+
typescript: "^5.6.0",
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
null,
|
|
401
|
+
2,
|
|
402
|
+
)}\n`;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function tsConfig(): string {
|
|
406
|
+
return `${JSON.stringify(
|
|
407
|
+
{
|
|
408
|
+
compilerOptions: {
|
|
409
|
+
target: "ESNext",
|
|
410
|
+
lib: ["ESNext", "DOM"],
|
|
411
|
+
module: "ESNext",
|
|
412
|
+
moduleResolution: "bundler",
|
|
413
|
+
jsx: "react-jsx",
|
|
414
|
+
strict: true,
|
|
415
|
+
skipLibCheck: true,
|
|
416
|
+
esModuleInterop: true,
|
|
417
|
+
allowImportingTsExtensions: true,
|
|
418
|
+
noEmit: true,
|
|
419
|
+
resolveJsonModule: true,
|
|
420
|
+
},
|
|
421
|
+
// mock/ is emitted TypeScript like the rest and was outside this list, so
|
|
422
|
+
// `bunx tsc --noEmit` inside a generated suite type-checked everything
|
|
423
|
+
// except the server it tells you to run. scripts/ is deliberately still
|
|
424
|
+
// out: it holds one .mjs, and tsc ignores JS without allowJs/checkJs, so
|
|
425
|
+
// the glob would look like coverage and give none — that file is covered
|
|
426
|
+
// by running it under node in tests/emittedExecution.test.ts instead.
|
|
427
|
+
include: ["host/**/*", "profile/**/*", "mock/**/*", "playwright.config.ts"],
|
|
428
|
+
},
|
|
429
|
+
null,
|
|
430
|
+
2,
|
|
431
|
+
)}\n`;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function playwrightConfig(hasMock: boolean): string {
|
|
435
|
+
const hostServerBlock = `{
|
|
436
|
+
command: "bun host/server.ts",
|
|
437
|
+
url: "http://127.0.0.1:5173",
|
|
438
|
+
reuseExistingServer: !process.env.CI,
|
|
439
|
+
stdout: "ignore",
|
|
440
|
+
stderr: "pipe",
|
|
441
|
+
}`;
|
|
442
|
+
const mockServerBlock = `{
|
|
443
|
+
command: "bun mock/server.ts",
|
|
444
|
+
url: "http://127.0.0.1:5174",
|
|
445
|
+
reuseExistingServer: !process.env.CI,
|
|
446
|
+
stdout: "ignore",
|
|
447
|
+
stderr: "pipe",
|
|
448
|
+
}`;
|
|
449
|
+
const webServerField = hasMock
|
|
450
|
+
? `webServer: [\n ${hostServerBlock},\n ${mockServerBlock},\n ]`
|
|
451
|
+
: `webServer: ${hostServerBlock}`;
|
|
452
|
+
|
|
453
|
+
// The first line is this suite's provenance record: it is the marker file a
|
|
454
|
+
// re-run checks for, and a changed-mode run reads the version back out of
|
|
455
|
+
// this banner (scaffold/index.ts parseExistingVersion) to report which tool
|
|
456
|
+
// generated the suite it is keeping. The literal phrase is load-bearing:
|
|
457
|
+
// BANNER_RE in scaffold/index.ts matches these exact words, so rewording
|
|
458
|
+
// it degrades every future suite to "unknown version". Change both or
|
|
459
|
+
// neither.
|
|
460
|
+
return `// Generated by flawless-react ${VERSION}
|
|
461
|
+
import path from "node:path";
|
|
462
|
+
import { defineConfig, devices } from "@playwright/test";
|
|
463
|
+
|
|
464
|
+
export default defineConfig({
|
|
465
|
+
testDir: "./profile",
|
|
466
|
+
// Relative, which playwright resolves against THIS FILE. The unset default
|
|
467
|
+
// resolves elsewhere (the package.json directory) and once put 347MB of
|
|
468
|
+
// traces in <app>/test-results/ outside the emitted .gitignore.
|
|
469
|
+
// Never the CJS dirname global: a "type":"module" suite loads this config
|
|
470
|
+
// as ESM, where it does not exist and the whole config dies at import.
|
|
471
|
+
outputDir: "test-results",
|
|
472
|
+
// One worker gives the cleanest timings; more give a fast verification run.
|
|
473
|
+
// Budgets only warn, so parallel measurement never fails a run — but it does
|
|
474
|
+
// make the numbers noisier, so CI (where a recorded number usually matters)
|
|
475
|
+
// keeps one, and FLAWLESS_WORKERS=1 forces it anywhere else.
|
|
476
|
+
fullyParallel: true,
|
|
477
|
+
workers: process.env.FLAWLESS_WORKERS
|
|
478
|
+
? Number(process.env.FLAWLESS_WORKERS)
|
|
479
|
+
: process.env.CI
|
|
480
|
+
? 1
|
|
481
|
+
: undefined,
|
|
482
|
+
// The JSON entry is what scripts/report.mjs reads. It goes under outputDir,
|
|
483
|
+
// which is already gitignored and which Playwright clears at the START of a
|
|
484
|
+
// run — the reporter writes at the end, so the file survives the run that
|
|
485
|
+
// produced it. The HTML report cannot serve this purpose: it stores stdout
|
|
486
|
+
// inside a base64 zip blob.
|
|
487
|
+
reporter: [
|
|
488
|
+
["list"],
|
|
489
|
+
["json", { outputFile: "test-results/results.json" }],
|
|
490
|
+
["html", { open: "never", outputFolder: "playwright-report" }],
|
|
491
|
+
],
|
|
492
|
+
preserveOutput: "always",
|
|
493
|
+
use: {
|
|
494
|
+
baseURL: "http://127.0.0.1:5173",
|
|
495
|
+
trace: "retain-on-failure",
|
|
496
|
+
},
|
|
497
|
+
${webServerField},
|
|
498
|
+
projects: [
|
|
499
|
+
// Exactly ONE project. A second, headed "interactive" project used to
|
|
500
|
+
// live here for manual debugging — and a bare \`playwright test\` then
|
|
501
|
+
// ran every spec twice, the second pass in a real browser window, per
|
|
502
|
+
// spec (a 2,590-spec dogfood run turned into a window storm). Headed
|
|
503
|
+
// probing is what Playwright's own --headed flag is for; \`bun run
|
|
504
|
+
// test:headed\` carries it.
|
|
505
|
+
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
|
506
|
+
],
|
|
507
|
+
});
|
|
508
|
+
`;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// The app's module scope runs BEFORE any spec: a top-level
|
|
512
|
+
// `new URL(process.env.DATABASE_URL)` throws and takes the whole host bundle
|
|
513
|
+
// down (one registry imports every component). The runtime polyfill gives
|
|
514
|
+
// `process` an empty env, so the values must be inlined as build defines.
|
|
515
|
+
// Repo-root .env files, Next's precedence order (later wins). The host binds
|
|
516
|
+
// 127.0.0.1 and the bundle never leaves the suite's disk.
|
|
517
|
+
function collectEnvDefines(): string {
|
|
518
|
+
const files = [".env", ".env.local", ".env.development", ".env.development.local"];
|
|
519
|
+
const merged = new Map<string, string>();
|
|
520
|
+
for (const f of files) {
|
|
521
|
+
let txt: string;
|
|
522
|
+
try {
|
|
523
|
+
txt = readFileSync(path.resolve(process.cwd(), f), "utf8");
|
|
524
|
+
} catch {
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
for (const line of txt.split("\n")) {
|
|
528
|
+
const m = line.match(/^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/);
|
|
529
|
+
if (!m) continue;
|
|
530
|
+
const name = m[1];
|
|
531
|
+
if (name === undefined) continue;
|
|
532
|
+
let v = (m[2] ?? "").trim();
|
|
533
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
|
534
|
+
v = v.slice(1, -1);
|
|
535
|
+
}
|
|
536
|
+
if (v) merged.set(name, v);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return [...merged].map(([k, v]) => `"process.env.${k}": ${JSON.stringify(v)},`).join("\n ");
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function hostServer(
|
|
543
|
+
hasMock: boolean,
|
|
544
|
+
serverActionStubs = new Map<string, ServerActionStub>(),
|
|
545
|
+
hostDir = "",
|
|
546
|
+
authStubs = new Map<string, AuthStub>(),
|
|
547
|
+
): string {
|
|
548
|
+
const envDefineLines = collectEnvDefines();
|
|
549
|
+
const mockProxyBlock = hasMock
|
|
550
|
+
? `
|
|
551
|
+
// Proxy any other request to the mock server so the component's relative
|
|
552
|
+
// fetch calls (\`/api/...\`) reach the OpenAPI-derived mock without code
|
|
553
|
+
// changes. Headers and body pass through unchanged.
|
|
554
|
+
if (url.pathname !== "/" && url.pathname !== "/index.html" && url.pathname !== "/bundle.js") {
|
|
555
|
+
const target = "http://127.0.0.1:" + MOCK_PORT + url.pathname + url.search;
|
|
556
|
+
return fetch(target, {
|
|
557
|
+
method: req.method,
|
|
558
|
+
headers: req.headers,
|
|
559
|
+
body: req.method === "GET" || req.method === "HEAD" ? undefined : req.body,
|
|
560
|
+
});
|
|
561
|
+
}`
|
|
562
|
+
: "";
|
|
563
|
+
const mockPortConst = hasMock ? `const MOCK_PORT = Number(process.env.MOCK_PORT ?? 5174);\n` : "";
|
|
564
|
+
|
|
565
|
+
// "use server" modules a hosted component imports. Bundling them for the
|
|
566
|
+
// browser pulls in the database driver, the mailer and Node builtins — the
|
|
567
|
+
// build fails outright, and since the host is ONE bundle that takes every
|
|
568
|
+
// profile test with it. They are intercepted by absolute path so no resolution
|
|
569
|
+
// guessing is involved, and each stub carries the real export names: ESM named
|
|
570
|
+
// imports have to bind, which a catch-all default export cannot satisfy.
|
|
571
|
+
const stubEntries = [...serverActionStubs.entries()].sort(([a], [b]) => (a < b ? -1 : 1));
|
|
572
|
+
const specifierSources = new Map<string, string>();
|
|
573
|
+
const stubSources = stubEntries
|
|
574
|
+
.map(([abs, stub]) => {
|
|
575
|
+
// A name the analyser resolved returns the action's real literal; the
|
|
576
|
+
// rest keep the {} stand-in the comment above justifies.
|
|
577
|
+
const stubValue = (n: string): string =>
|
|
578
|
+
stub.returns[n] === undefined ? "{}" : JSON.stringify(stub.returns[n]);
|
|
579
|
+
const body = stub.names
|
|
580
|
+
.map((n) =>
|
|
581
|
+
n === "default"
|
|
582
|
+
? `export default async () => (${stubValue(n)});`
|
|
583
|
+
: `export const ${n} = async () => (${stubValue(n)});`,
|
|
584
|
+
)
|
|
585
|
+
.join("\n");
|
|
586
|
+
// A module imported by a BARE specifier (a workspace package) is also
|
|
587
|
+
// stubbed at the specifier itself: the analyser resolved it to src, the
|
|
588
|
+
// bundler would resolve it to package.json's dist — two paths, neither
|
|
589
|
+
// of which the path key below can cover (B-11).
|
|
590
|
+
for (const spec of stub.specifiers) specifierSources.set(spec, body || "export {};");
|
|
591
|
+
// Relative to this file, resolved back to absolute when the map is built.
|
|
592
|
+
// Bun's onLoad hands us an absolute `args.path`, so the KEYS must be
|
|
593
|
+
// absolute at runtime — but an absolute key in the committed source names
|
|
594
|
+
// the machine that generated it, and every stub would miss on any other
|
|
595
|
+
// checkout, putting pg and nodemailer back into the browser bundle.
|
|
596
|
+
const key = hostDir ? relativeToEmitted(hostDir, abs) : abs;
|
|
597
|
+
return ` ${JSON.stringify(key)}: ${JSON.stringify(body || "export {};")},`;
|
|
598
|
+
})
|
|
599
|
+
.join("\n");
|
|
600
|
+
// Provider-bound auth libraries ride the same bare-specifier interception:
|
|
601
|
+
// they resolve fine in the suite's node_modules — that's the problem. The
|
|
602
|
+
// real module lands in the bundle and throws at import or first hook call
|
|
603
|
+
// without ClerkProvider/SessionProvider keys nobody hands a perf host.
|
|
604
|
+
for (const [spec, stub] of authStubs) specifierSources.set(spec, authShimEsm(spec, stub));
|
|
605
|
+
const specifierEntries = [...specifierSources.entries()].sort(([a], [b]) => (a < b ? -1 : 1));
|
|
606
|
+
const specifierSourceLines = specifierEntries
|
|
607
|
+
.map(([spec, body]) => ` ${JSON.stringify(spec)}: ${JSON.stringify(body)},`)
|
|
608
|
+
.join("\n");
|
|
609
|
+
// Escaped at GENERATION time so the emitted file carries a plain literal;
|
|
610
|
+
// /$^/ when empty, so the (never-entered) registration still parses.
|
|
611
|
+
const specifierFilter = specifierEntries
|
|
612
|
+
.map(([spec]) => spec.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
613
|
+
.join("|");
|
|
614
|
+
const specifierFilterLiteral =
|
|
615
|
+
specifierEntries.length > 0
|
|
616
|
+
? `new RegExp(${JSON.stringify(`^(?:${specifierFilter})$`)})`
|
|
617
|
+
: "/$^/";
|
|
618
|
+
// next/navigation's client hooks throw "invariant expected app router to be
|
|
619
|
+
// mounted" outside a Next runtime, and the host IS a bare browser. Same
|
|
620
|
+
// stand-ins the unit plane's setup.ts installs (redirect/notFound throw on
|
|
621
|
+
// purpose: a component that wants to leave should fail the mount, not
|
|
622
|
+
// profile a page that never rendered). Fixed export list — the Next API
|
|
623
|
+
// surface is stable, so this needs no analysis pass, unlike authShims.
|
|
624
|
+
const navStubSource = [
|
|
625
|
+
"const __noopRouter = { push: () => {}, replace: () => {}, refresh: () => {}, back: () => {}, forward: () => {}, prefetch: () => {} };",
|
|
626
|
+
'export const notFound = () => { throw new Error("notFound() called"); };',
|
|
627
|
+
'export const redirect = () => { throw new Error("NEXT_REDIRECT"); };',
|
|
628
|
+
'export const permanentRedirect = () => { throw new Error("NEXT_REDIRECT"); };',
|
|
629
|
+
'export const usePathname = () => "/";',
|
|
630
|
+
"export const useRouter = () => __noopRouter;",
|
|
631
|
+
"export const useSearchParams = () => new URLSearchParams();",
|
|
632
|
+
"export const useParams = () => ({});",
|
|
633
|
+
"export const useSelectedLayoutSegment = () => null;",
|
|
634
|
+
"export const useSelectedLayoutSegments = () => [];",
|
|
635
|
+
'export const useLinkStatus = () => "idle";',
|
|
636
|
+
].join("\n");
|
|
637
|
+
// next/router — the PAGES-router twin of the stub above. Its useRouter
|
|
638
|
+
// throws "NextRouter was not mounted" outside Next's App container, so a
|
|
639
|
+
// pages app's component dies at render in the host exactly as it does under
|
|
640
|
+
// jsdom (talkformai: NavBar, DashboardMode, SignInForm — every mount). Navigate
|
|
641
|
+
// nowhere, but define every field the router API documents: `router.query.id`
|
|
642
|
+
// and `router.pathname.split("/")` are what a render actually touches, and an
|
|
643
|
+
// undefined one is a different crash, not a quieter one.
|
|
644
|
+
const pagesRouterStubSource = [
|
|
645
|
+
"import * as React from 'react';",
|
|
646
|
+
"const __router = { pathname: '/', route: '/', query: {}, asPath: '/', basePath: '', locale: undefined, locales: [], isLocaleDomain: false, isReady: true, isPreview: false, push: () => Promise.resolve(true), replace: () => Promise.resolve(true), prefetch: () => Promise.resolve(), pushScope: () => [], popScope: () => false, beforePopState: () => {}, reload: () => {}, back: () => {}, forward: () => {}, events: { on: () => {}, off: () => {}, emit: () => {} } };",
|
|
647
|
+
"export const router = __router;",
|
|
648
|
+
"export const useRouter = () => __router;",
|
|
649
|
+
"export const withRouter = (Component) => (props) => React.createElement(Component, Object.assign({}, props, { router: __router }));",
|
|
650
|
+
].join("\n");
|
|
651
|
+
// next/script renders NOTHING into the tree: it injects its tag through
|
|
652
|
+
// Next's client router context, which a bare host does not have. A component
|
|
653
|
+
// whose whole output is <Script> therefore mounted as an empty container and
|
|
654
|
+
// failed the browser smoke on its markup floor — talkformai's GoogleAnalytics
|
|
655
|
+
// and PostHogScript, both expect(rootHtml.length).toBeGreaterThan(0) on an
|
|
656
|
+
// otherwise clean, error-free mount. Answer with a script element rendered IN
|
|
657
|
+
// PLACE so the component's own render is what gets measured, marked
|
|
658
|
+
// type="text/plain" so the browser neither fetches src nor executes inline
|
|
659
|
+
// code: third-party analytics has no business running inside a render
|
|
660
|
+
// benchmark. An inert stand-in is this host's answer everywhere else (DB
|
|
661
|
+
// drivers, fonts, both routers) — a silent blank was the outlier.
|
|
662
|
+
const nextScriptStubSource = [
|
|
663
|
+
"import * as React from 'react';",
|
|
664
|
+
"function FlawlessScript(props) { return React.createElement('script', { type: 'text/plain', src: typeof props.src === 'string' ? props.src : undefined, id: props.id, 'data-flawless-stub': 'next/script' }); }",
|
|
665
|
+
"export default FlawlessScript;",
|
|
666
|
+
].join("\n");
|
|
667
|
+
const stubPlugin = `
|
|
668
|
+
// Path relative to this file -> replacement source, resolved to the absolute
|
|
669
|
+
// paths Bun's onLoad reports. See the comment where this was generated.
|
|
670
|
+
const SERVER_STUB_SOURCES: Record<string, string> = {
|
|
671
|
+
${stubSources}
|
|
672
|
+
};
|
|
673
|
+
const SERVER_STUBS: Record<string, string> = Object.fromEntries(
|
|
674
|
+
Object.entries(SERVER_STUB_SOURCES).map(([rel, src]) => [path.resolve(HOST_DIR, rel), src]),
|
|
675
|
+
);
|
|
676
|
+
|
|
677
|
+
// Bare specifiers (workspace packages) -> replacement source. Intercepted at
|
|
678
|
+
// RESOLUTION, before package.json main/exports can route them to a dist build
|
|
679
|
+
// the path-keyed map has never heard of.
|
|
680
|
+
const SERVER_STUB_SPECIFIERS: Record<string, string> = {
|
|
681
|
+
${specifierSourceLines}
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
const stubServerModules: BunPlugin = {
|
|
685
|
+
name: "flawless-server-stubs",
|
|
686
|
+
setup(build) {
|
|
687
|
+
// Node/bun built-ins are not available in the browser target. Stub them
|
|
688
|
+
// so component deps that conditionally import fs/stream/zlib don't crash
|
|
689
|
+
// the build.
|
|
690
|
+
build.onResolve({ filter: /^(node:|bun:)/ }, (args) => ({
|
|
691
|
+
path: args.path,
|
|
692
|
+
namespace: "flawless-node-stub",
|
|
693
|
+
}));
|
|
694
|
+
build.onLoad({ filter: /.*/, namespace: "flawless-node-stub" }, () => ({
|
|
695
|
+
contents:
|
|
696
|
+
"const make = () => { const f = function () {}; return new Proxy(f, { get: (t, p) => (p === '__esModule' ? false : p === 'then' ? undefined : (t[p] ??= make())), apply: () => make(), construct: () => new Proxy({}, { get: () => () => undefined }) }); }; const KNOWN = ['default', 'Buffer', 'URL', 'URLSearchParams', 'TextEncoder', 'TextDecoder', 'performance', 'platform', 'arch', 'env', 'version', 'cwd', 'dirname', 'join', 'resolve', 'sep', 'delimiter', 'normalize', 'extname', 'basename', 'relative', 'isAbsolute', 'tmpdir', 'homedir', 'readFileSync', 'writeFileSync', 'existsSync', 'statSync', 'readdirSync', 'mkdirSync', 'rmSync', 'promisify', 'createReadStream', 'createWriteStream', 'createGzip', 'createDeflate', 'pipeline', 'fileURLToPath', 'pathToFileURL', 'randomUUID', 'execSync', 'spawnSync', 'execFileSync', 'fetch', 'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval', 'AbortController', 'AbortSignal', 'EventEmitter', 'once', 'Stream', 'inspect', 'format', 'types', 'isDeepStrictEqual', 'createInterface', 'question', 'getLogger', 'Drizzle', 'drizzle', 'Kysely', 'Pool', 'Client', 'sql', 'PrismaPg', 'PrismaBetterSqlite3', 'PrismaBunSql', 'PrismaLibSql', 'PrismaD1', 'PrismaSqlite', 'SupabaseClient', 'createClient', 'globalThis']; const root = new Proxy({}, { get: (t, p) => (p === '__esModule' ? false : p === 'then' ? undefined : (t[p] ??= make())), ownKeys: () => KNOWN, getOwnPropertyDescriptor: (t, p) => ({ value: t[p] ??= make(), enumerable: true, configurable: true, writable: true }) }); module.exports = root;",
|
|
697
|
+
loader: "js",
|
|
698
|
+
}));
|
|
699
|
+
// Native .node addons cannot build for a browser target at all, and
|
|
700
|
+
// ?module wasm (next's vendored resvg) is not loadable here either. A
|
|
701
|
+
// component that actually CALLS into one throws at render and fails its
|
|
702
|
+
// own spec — acceptable; a hard BUILD error takes the whole host down.
|
|
703
|
+
build.onResolve({ filter: /\\.(node|wasm|so|dylib|dll)(\\?.*)?$/ }, (args) => ({
|
|
704
|
+
path: args.path,
|
|
705
|
+
namespace: "flawless-node-stub",
|
|
706
|
+
}));
|
|
707
|
+
// Database clients: even their "pure JS" entries load native query
|
|
708
|
+
// engines at module init. A relative import of a generated client (e.g.
|
|
709
|
+
// generated/prisma/client.ts) still drags in these BARE runtime
|
|
710
|
+
// specifiers, which is where the bundle is cut. Perf then measures the
|
|
711
|
+
// component's render with inert data — the suite's mocking philosophy.
|
|
712
|
+
//
|
|
713
|
+
// Yield the ones with a generated stub of their own. The Supabase family
|
|
714
|
+
// is matched here by PACKAGE PATTERN and in SERVER_STUB_SPECIFIERS by
|
|
715
|
+
// EXACT NAME, and esbuild asks hooks in registration order — this one is
|
|
716
|
+
// registered first, so it used to win: the signed-out auth-helpers stub
|
|
717
|
+
// sat in the map unused while the component got the inert CJS Proxy, and
|
|
718
|
+
// talkformai's NavBar / SignInForm / DashboardMode all timed out on
|
|
719
|
+
// "useSessionContext is not a function". A named stub is a decision about
|
|
720
|
+
// that module's surface; a pattern stub is only a fallback.
|
|
721
|
+
build.onResolve(
|
|
722
|
+
{
|
|
723
|
+
filter:
|
|
724
|
+
/^(@supabase\\/[a-z0-9._-]+|@prisma\\/adapter[a-z0-9._-]*|mongodb|mongoose|pg|kysely|drizzle-orm)([\\/:].*)?$/,
|
|
725
|
+
},
|
|
726
|
+
(args) =>
|
|
727
|
+
SERVER_STUB_SPECIFIERS[args.path]
|
|
728
|
+
? undefined
|
|
729
|
+
: { path: args.path, namespace: "flawless-node-stub" },
|
|
730
|
+
);
|
|
731
|
+
// Prisma's generated client destructures the runtime namespace AT MODULE
|
|
732
|
+
// SCOPE — a Proxy'd CJS namespace loses its keys through bun's __toESM
|
|
733
|
+
// key-copy and kills the whole host bundle. A real ESM module with the
|
|
734
|
+
// runtime's export names declared survives the copy; each value is a
|
|
735
|
+
// recursive inert proxy (any call shape resolves, nothing connects).
|
|
736
|
+
build.onResolve({ filter: /^(prisma|@prisma\\/[a-z0-9._-]+)([\\/:].*)?$/ }, (args) => ({
|
|
737
|
+
path: args.path,
|
|
738
|
+
namespace: "flawless-prisma-stub",
|
|
739
|
+
}));
|
|
740
|
+
build.onLoad({ filter: /.*/, namespace: "flawless-prisma-stub" }, () => ({
|
|
741
|
+
contents:
|
|
742
|
+
"const make = () => { const f = function () {}; return new Proxy(f, { get: (t, p) => (p === '__esModule' ? true : p === 'then' ? undefined : (t[p] ??= make())), apply: () => make(), construct: () => new Proxy({}, { get: () => () => undefined }) }); };\\n" +
|
|
743
|
+
[
|
|
744
|
+
"AnyNull",
|
|
745
|
+
"Bytes",
|
|
746
|
+
"DbNull",
|
|
747
|
+
"Decimal",
|
|
748
|
+
"DecimalJsLike",
|
|
749
|
+
"DMMF",
|
|
750
|
+
"empty",
|
|
751
|
+
"Extensions",
|
|
752
|
+
"FieldRef",
|
|
753
|
+
"getPrismaClient",
|
|
754
|
+
"GetPrismaClientConfig",
|
|
755
|
+
"InputJsonArray",
|
|
756
|
+
"InputJsonObject",
|
|
757
|
+
"InputJsonValue",
|
|
758
|
+
"ITXClientDenyList",
|
|
759
|
+
"join",
|
|
760
|
+
"JsonArray",
|
|
761
|
+
"JsonNull",
|
|
762
|
+
"JsonObject",
|
|
763
|
+
"JsonValue",
|
|
764
|
+
"makeStrictEnum",
|
|
765
|
+
"NullTypes",
|
|
766
|
+
"Operation",
|
|
767
|
+
"PrismaClientInitializationError",
|
|
768
|
+
"PrismaClientKnownRequestError",
|
|
769
|
+
"PrismaClientRustPanicError",
|
|
770
|
+
"PrismaClientUnknownRequestError",
|
|
771
|
+
"PrismaClientValidationError",
|
|
772
|
+
"raw",
|
|
773
|
+
"Sql",
|
|
774
|
+
"SqlCommenterPlugin",
|
|
775
|
+
"SqlDriverAdapterFactory",
|
|
776
|
+
"sqltag",
|
|
777
|
+
"Types",
|
|
778
|
+
]
|
|
779
|
+
.map((n) => "export const " + n + " = make();")
|
|
780
|
+
.join("\\n") +
|
|
781
|
+
"\\nexport default make();",
|
|
782
|
+
loader: "js",
|
|
783
|
+
}));
|
|
784
|
+
// The \\.js variant is real: nuqs (and other tsm-compiled libs) ship
|
|
785
|
+
// pre-compiled JS importing "next/navigation.js" to satisfy TS module
|
|
786
|
+
// resolution — an exact-specifier filter lets the genuine module
|
|
787
|
+
// through, and its useRouter throws "expected app router to be
|
|
788
|
+
// mounted" on every mount.
|
|
789
|
+
build.onResolve({ filter: /^next\\/navigation(\\.js)?$/ }, (args) => ({
|
|
790
|
+
path: args.path,
|
|
791
|
+
namespace: "flawless-next-nav-stub",
|
|
792
|
+
}));
|
|
793
|
+
// next/link and next/image are REAL components that resolve from the
|
|
794
|
+
// app's node_modules — and the link's router hook needs the app-router
|
|
795
|
+
// context no host provides ("invariant expected app router to be
|
|
796
|
+
// mounted", every chanhdai mount including icons, because the layout
|
|
797
|
+
// tree in main.tsx renders SiteHeader). Answer with the DOM elements
|
|
798
|
+
// they compile down to; anchors keep hrefs clickable for e2e habits.
|
|
799
|
+
build.onResolve({ filter: /^next[\\/](link|image)(\\.js)?$/ }, (args) => ({
|
|
800
|
+
path: args.path.indexOf("image") >= 0 ? "flawless:next/image" : "flawless:next/link",
|
|
801
|
+
namespace: "flawless-next-component-stub",
|
|
802
|
+
}));
|
|
803
|
+
build.onLoad({ filter: /.*/, namespace: "flawless-next-component-stub" }, (args) => ({
|
|
804
|
+
contents: args.path.endsWith(":image")
|
|
805
|
+
? "import * as React from 'react'; function FlawlessImage(props) { var src = typeof props.src === 'string' ? props.src : (props.src && props.src.src) || ''; return React.createElement('img', { src: src, alt: props.alt ?? '', className: props.className, width: props.width, height: props.height }); } const Image = FlawlessImage; Image.loader = (s) => (typeof s === 'string' ? s : (s && s.src) || ''); export default Image;"
|
|
806
|
+
: "import * as React from 'react'; function FlawlessLink({ children, href, prefetch, scroll, replace, shallow, scrollBehavior, legacyBehavior, passHref, className, ...rest }) { return React.createElement('a', Object.assign({ href: href == null ? '#' : String(href) }, rest), children); } export default FlawlessLink;",
|
|
807
|
+
loader: "jsx",
|
|
808
|
+
}));
|
|
809
|
+
// next/font (google/local) factories RUN at module scope in every repo
|
|
810
|
+
// that imports a font package (geist et al.) — and the real loader is a
|
|
811
|
+
// compiler hook with no runtime. Answer with a callable that returns the
|
|
812
|
+
// same shape: className + CSS variable are all any consumer reads.
|
|
813
|
+
build.onResolve({ filter: /^next[\\/]font([\\/:].*)?$/ }, (args) => ({
|
|
814
|
+
path: args.path,
|
|
815
|
+
namespace: "flawless-font-stub",
|
|
816
|
+
}));
|
|
817
|
+
build.onLoad({ filter: /.*/, namespace: "flawless-font-stub" }, () => ({
|
|
818
|
+
contents:
|
|
819
|
+
"function makeFont(o) { return { className: 'flawless-stub-font', variable: '--flawless-stub-font', style: { fontFamily: (o && o.fallback && o.fallback.join(', ')) || '' } }; } const ns = new Proxy(makeFont, { get: (t, p) => (p === '__esModule' ? false : p === 'then' ? undefined : (t[p] ??= makeFont)), }); module.exports = ns;",
|
|
820
|
+
loader: "js",
|
|
821
|
+
}));
|
|
822
|
+
build.onLoad({ filter: /.*/, namespace: "flawless-next-nav-stub" }, () => ({
|
|
823
|
+
contents: ${JSON.stringify(navStubSource)},
|
|
824
|
+
loader: "js",
|
|
825
|
+
}));
|
|
826
|
+
// Pages-router twin of the nav stub, same \\.js reasoning.
|
|
827
|
+
build.onResolve({ filter: /^next[\\/]router(\\.js)?$/ }, (args) => ({
|
|
828
|
+
path: args.path,
|
|
829
|
+
namespace: "flawless-next-router-stub",
|
|
830
|
+
}));
|
|
831
|
+
build.onLoad({ filter: /.*/, namespace: "flawless-next-router-stub" }, () => ({
|
|
832
|
+
contents: ${JSON.stringify(pagesRouterStubSource)},
|
|
833
|
+
loader: "js",
|
|
834
|
+
}));
|
|
835
|
+
// next/script: renders nothing without Next's router context, so a component
|
|
836
|
+
// made only of <Script> would mount as an empty container. See the source.
|
|
837
|
+
build.onResolve({ filter: /^next[\\/]script(\\.js)?$/ }, (args) => ({
|
|
838
|
+
path: args.path,
|
|
839
|
+
namespace: "flawless-next-script-stub",
|
|
840
|
+
}));
|
|
841
|
+
build.onLoad({ filter: /.*/, namespace: "flawless-next-script-stub" }, () => ({
|
|
842
|
+
contents: ${JSON.stringify(nextScriptStubSource)},
|
|
843
|
+
loader: "js",
|
|
844
|
+
}));
|
|
845
|
+
// The server-component marker: absent in many repos (Bun's resolver
|
|
846
|
+
// fails outright) and throw-on-import where installed. Transitively
|
|
847
|
+
// importing modules still have to BUILD, so the specifier resolves to
|
|
848
|
+
// an empty module — same call the vitest config's alias makes.
|
|
849
|
+
build.onResolve({ filter: /^server-only$/ }, () => ({
|
|
850
|
+
path: "server-only",
|
|
851
|
+
namespace: "flawless-empty-stub",
|
|
852
|
+
}));
|
|
853
|
+
build.onLoad({ filter: /.*/, namespace: "flawless-empty-stub" }, () => ({
|
|
854
|
+
contents: "export {};",
|
|
855
|
+
loader: "ts",
|
|
856
|
+
}));
|
|
857
|
+
if (Object.keys(SERVER_STUB_SPECIFIERS).length > 0) {
|
|
858
|
+
build.onResolve({ filter: ${specifierFilterLiteral} }, (args) => ({
|
|
859
|
+
path: args.path,
|
|
860
|
+
namespace: "flawless-server-stub",
|
|
861
|
+
}));
|
|
862
|
+
build.onLoad({ filter: /.*/, namespace: "flawless-server-stub" }, (args) =>
|
|
863
|
+
SERVER_STUB_SPECIFIERS[args.path]
|
|
864
|
+
? { contents: SERVER_STUB_SPECIFIERS[args.path], loader: "ts" }
|
|
865
|
+
: // No generated surface for this specifier: a CJS Proxy answers ANY
|
|
866
|
+
// named import at runtime instead of failing the ESM build.
|
|
867
|
+
{
|
|
868
|
+
contents:
|
|
869
|
+
"const make = () => { const f = function () {}; return new Proxy(f, { get: (t, p) => (p === '__esModule' ? false : p === 'then' ? undefined : (t[p] ??= make())), apply: () => make(), construct: () => new Proxy({}, { get: () => () => undefined }) }); }; const KNOWN = ['default', 'Buffer', 'URL', 'URLSearchParams', 'TextEncoder', 'TextDecoder', 'performance', 'platform', 'arch', 'env', 'version', 'cwd', 'dirname', 'join', 'resolve', 'sep', 'delimiter', 'normalize', 'extname', 'basename', 'relative', 'isAbsolute', 'tmpdir', 'homedir', 'readFileSync', 'writeFileSync', 'existsSync', 'statSync', 'readdirSync', 'mkdirSync', 'rmSync', 'promisify', 'createReadStream', 'createWriteStream', 'createGzip', 'createDeflate', 'pipeline', 'fileURLToPath', 'pathToFileURL', 'randomUUID', 'execSync', 'spawnSync', 'execFileSync', 'fetch', 'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval', 'AbortController', 'AbortSignal', 'EventEmitter', 'once', 'Stream', 'inspect', 'format', 'types', 'isDeepStrictEqual', 'createInterface', 'question', 'getLogger', 'Drizzle', 'drizzle', 'Kysely', 'Pool', 'Client', 'sql', 'PrismaPg', 'PrismaBetterSqlite3', 'PrismaBunSql', 'PrismaLibSql', 'PrismaD1', 'PrismaSqlite', 'SupabaseClient', 'createClient', 'globalThis']; const root = new Proxy({}, { get: (t, p) => (p === '__esModule' ? false : p === 'then' ? undefined : (t[p] ??= make())), ownKeys: () => KNOWN, getOwnPropertyDescriptor: (t, p) => ({ value: t[p] ??= make(), enumerable: true, configurable: true, writable: true }) }); module.exports = root;",
|
|
870
|
+
loader: "js",
|
|
871
|
+
},
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
build.onLoad({ filter: /\\.(ts|tsx|js|jsx|mjs)$/, namespace: "file" }, (args) => {
|
|
875
|
+
const src = SERVER_STUBS[args.path];
|
|
876
|
+
if (src) return { contents: src, loader: "ts" };
|
|
877
|
+
// next/font's NAMED exports are a compiler fiction — no real module
|
|
878
|
+
// backs them at runtime, so the stub above answers the specifier with
|
|
879
|
+
// a CJS Proxy. But CJS->ESM interop copies the export object's OWN
|
|
880
|
+
// keys before reading any of them, and a Proxy without an ownKeys
|
|
881
|
+
// trap reports none: every named font import arrives as undefined
|
|
882
|
+
// and the host dies at boot with "IBM_Plex_Serif is not a function"
|
|
883
|
+
// (chanhdai's first perf run, 341 specs, one error). Rewrite the
|
|
884
|
+
// forms that break — named and namespace imports — onto the default
|
|
885
|
+
// import, which interop preserves verbatim, and which the stub's
|
|
886
|
+
// callable Proxy answers for every name.
|
|
887
|
+
let code;
|
|
888
|
+
try {
|
|
889
|
+
code = readFileSync(args.path, "utf8");
|
|
890
|
+
} catch {
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
if (!/next\\/font/.test(code)) return;
|
|
894
|
+
let flFontSeq = 0;
|
|
895
|
+
const rewritten = code
|
|
896
|
+
.replace(
|
|
897
|
+
/import\\s+(?:(\\w+)\\s*,\\s*)?\\{([^}]*)\\}\\s+from\\s+(["'])(next\\/font[^"']*)\\3/g,
|
|
898
|
+
(m, def, clause, quote, spec) => {
|
|
899
|
+
const text = String(clause).trim();
|
|
900
|
+
if (text.startsWith("type")) return m;
|
|
901
|
+
const parts = text
|
|
902
|
+
.split(",")
|
|
903
|
+
.map((s) => /^\\s*(\\w+)(?:\\s+as\\s+(\\w+))?\\s*$/.exec(s.trim()));
|
|
904
|
+
const named = parts.filter((p): p is RegExpExecArray => p !== null);
|
|
905
|
+
if (named.length !== parts.length) return m;
|
|
906
|
+
const alias = def || "__flFont" + flFontSeq++;
|
|
907
|
+
return (
|
|
908
|
+
"import " + alias + " from " + quote + spec + quote + "; const " +
|
|
909
|
+
named.map((p) => (p[2] || p[1]) + " = " + alias).join(", ") + ";"
|
|
910
|
+
);
|
|
911
|
+
},
|
|
912
|
+
)
|
|
913
|
+
.replace(
|
|
914
|
+
/import\\s+\\*\\s+as\\s+(\\w+)\\s+from\\s+(["'])(next\\/font[^"']*)\\2/g,
|
|
915
|
+
(m, name, quote, spec) => {
|
|
916
|
+
const alias = "__flFont" + flFontSeq++;
|
|
917
|
+
return "import " + alias + " from " + quote + spec + quote + "; const " + name + " = " + alias + ";";
|
|
918
|
+
},
|
|
919
|
+
);
|
|
920
|
+
if (rewritten === code) return;
|
|
921
|
+
const ext = args.path.slice(args.path.lastIndexOf(".") + 1);
|
|
922
|
+
const loader = ext === "tsx" || ext === "jsx" ? ext : ext === "js" || ext === "mjs" ? "js" : "ts";
|
|
923
|
+
return { contents: rewritten, loader };
|
|
924
|
+
});
|
|
925
|
+
},
|
|
926
|
+
};
|
|
927
|
+
const stubCount = Object.keys(SERVER_STUBS).length;
|
|
928
|
+
if (stubCount > 0) {
|
|
929
|
+
console.log(
|
|
930
|
+
"[flawless] stubbed " + stubCount + " server-action module(s) for the browser bundle" +
|
|
931
|
+
(Object.keys(SERVER_STUB_SPECIFIERS).length > 0
|
|
932
|
+
? " (" + Object.keys(SERVER_STUB_SPECIFIERS).length + " by bare specifier)"
|
|
933
|
+
: ""),
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
`;
|
|
937
|
+
|
|
938
|
+
return `import path from "node:path";
|
|
939
|
+
import { readFileSync } from "node:fs";
|
|
940
|
+
import type { BunPlugin } from "bun";
|
|
941
|
+
|
|
942
|
+
const HOST_DIR = import.meta.dir;
|
|
943
|
+
const SUITE_DIR = path.dirname(HOST_DIR);
|
|
944
|
+
const PORT = Number(process.env.PORT ?? 5173);
|
|
945
|
+
${mockPortConst}
|
|
946
|
+
|
|
947
|
+
// Resolve bare specifiers ("react", "react-dom/client", etc.) against the
|
|
948
|
+
// suite's node_modules instead of the importing file's directory. Component
|
|
949
|
+
// files often live outside the suite (e.g. in the user's repo, or in a
|
|
950
|
+
// fixtures directory with no React installed); without this plugin those
|
|
951
|
+
// imports fail to resolve.
|
|
952
|
+
const resolveFromSuite: BunPlugin = {
|
|
953
|
+
name: "resolve-from-suite",
|
|
954
|
+
setup(build) {
|
|
955
|
+
build.onResolve({ filter: /^[a-zA-Z@][^:]*$/ }, (args) => {
|
|
956
|
+
// msw ships its BROWSER WORKER entry behind a "browser" export
|
|
957
|
+
// CONDITION, and Bun.resolveSync resolves server-side conditions —
|
|
958
|
+
// answering this plugin for msw/browser would pin the node variant
|
|
959
|
+
// onto a browser bundle ("setupWorker failed in a non-browser
|
|
960
|
+
// environment", with global.process now faithfully present for
|
|
961
|
+
// scandir to split). Subpaths therefore fall through: the bundler's
|
|
962
|
+
// own resolver applies the target's conditions. The BARE specifier
|
|
963
|
+
// does NOT — with a catch-all plugin on bare specifiers attached, a
|
|
964
|
+
// fallen-through "msw" came back as a module shell whose named
|
|
965
|
+
// imports were silently dropped: miro-clone's host booted with
|
|
966
|
+
// "http is not defined" exactly where handlers.ts had written
|
|
967
|
+
// import { http } from "msw". Core msw is environment-neutral
|
|
968
|
+
// (its checkGlobals only checks URL), so resolving it like any other
|
|
969
|
+
// package is both correct and the only shape the bundler wires.
|
|
970
|
+
if (args.path.startsWith("msw/")) return;
|
|
971
|
+
try {
|
|
972
|
+
let resolved: string;
|
|
973
|
+
try {
|
|
974
|
+
resolved = Bun.resolveSync(args.path, SUITE_DIR);
|
|
975
|
+
} catch {
|
|
976
|
+
// A pnpm-style strict layout keeps transitive deps out of the
|
|
977
|
+
// suite's reach, but they ARE visible from the importer's own
|
|
978
|
+
// directory — resolve from there, the way node would. Letting the
|
|
979
|
+
// specifier fall through instead can silently drop the import:
|
|
980
|
+
// a transitive bare import that no plugin resolves never reaches
|
|
981
|
+
// the default resolver here, and the bundler emits the use site
|
|
982
|
+
// with the binding undefined ("toHtml is not defined" at boot).
|
|
983
|
+
if (!args.importer) return;
|
|
984
|
+
resolved = Bun.resolveSync(args.path, path.dirname(args.importer));
|
|
985
|
+
}
|
|
986
|
+
// A built-in comes back as its specifier ("node:fs"), not a file
|
|
987
|
+
// path. In the browser target there is no such file, and
|
|
988
|
+
// path.resolve("node:fs") would mangle it into "<suite>/node:fs"
|
|
989
|
+
// and crash the build with a bogus "File not found". Route it to the
|
|
990
|
+
// shared stub namespace (loaded below by stubServerModules) instead.
|
|
991
|
+
// This fires for bare imports like \`fs\`/\`stream\` — no \`node:\`
|
|
992
|
+
// prefix, so the /^(node:|bun:)/ filter above never sees them.
|
|
993
|
+
if (resolved.startsWith("node:") || resolved.startsWith("bun:")) {
|
|
994
|
+
return { path: args.path, namespace: "flawless-node-stub" };
|
|
995
|
+
}
|
|
996
|
+
return { path: resolved };
|
|
997
|
+
} catch {
|
|
998
|
+
return; // fall through to Bun's default resolver
|
|
999
|
+
}
|
|
1000
|
+
});
|
|
1001
|
+
},
|
|
1002
|
+
};
|
|
1003
|
+
|
|
1004
|
+
${stubPlugin}
|
|
1005
|
+
// Stylesheets are deliberately invisible to the perf host. Bun's CSS loader
|
|
1006
|
+
// cannot run the app's real pipeline — tailwind v4's \`@import "tailwindcss"\`
|
|
1007
|
+
// resolves to the package's JS entry ("Cannot import a .js file into a CSS
|
|
1008
|
+
// file") and @theme/@apply are custom at-rules it never compiles anyway.
|
|
1009
|
+
// Perf measures the JS render cost; style fidelity is e2e/load's business.
|
|
1010
|
+
const stubCss: BunPlugin = {
|
|
1011
|
+
name: "flawless-css-stub",
|
|
1012
|
+
setup(build) {
|
|
1013
|
+
build.onResolve({ filter: /\\.(css|scss|sass|less)(\\?.*)?$/ }, (args) => ({
|
|
1014
|
+
path: args.path,
|
|
1015
|
+
namespace: "flawless-css-stub",
|
|
1016
|
+
}));
|
|
1017
|
+
build.onResolve({ filter: /^(@?tailwindcss)([\\/:].*)?$/ }, () => ({
|
|
1018
|
+
path: "tailwind-import",
|
|
1019
|
+
namespace: "flawless-css-stub",
|
|
1020
|
+
}));
|
|
1021
|
+
build.onLoad({ filter: /.*/, namespace: "flawless-css-stub" }, () => ({
|
|
1022
|
+
contents: "/* flawless perf: css intentionally stubbed */",
|
|
1023
|
+
loader: "css",
|
|
1024
|
+
}));
|
|
1025
|
+
},
|
|
1026
|
+
};
|
|
1027
|
+
const built = await Bun.build({
|
|
1028
|
+
entrypoints: [path.join(HOST_DIR, "main.tsx")],
|
|
1029
|
+
target: "browser",
|
|
1030
|
+
format: "esm",
|
|
1031
|
+
splitting: false,
|
|
1032
|
+
sourcemap: "inline",
|
|
1033
|
+
minify: false,
|
|
1034
|
+
plugins: [stubCss, stubServerModules, resolveFromSuite],
|
|
1035
|
+
// Replace bare \`global\` references at build time. The runtime polyfill
|
|
1036
|
+
// (host/_polyfill.ts, imported first from main.tsx) handles \`process\`.
|
|
1037
|
+
define: {
|
|
1038
|
+
global: "globalThis",
|
|
1039
|
+
${envDefineLines}
|
|
1040
|
+
"process.env.NODE_ENV": '"development"',
|
|
1041
|
+
},
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
if (!built.success) {
|
|
1045
|
+
for (const log of built.logs) console.error(log);
|
|
1046
|
+
throw new Error("host bundle failed");
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
const bundle = await built.outputs[0]?.text();
|
|
1050
|
+
if (!bundle) throw new Error("host bundle produced no output");
|
|
1051
|
+
|
|
1052
|
+
const indexHtml = await Bun.file(path.join(HOST_DIR, "index.html")).text();
|
|
1053
|
+
|
|
1054
|
+
Bun.serve({
|
|
1055
|
+
port: PORT,
|
|
1056
|
+
hostname: "127.0.0.1",
|
|
1057
|
+
fetch(req) {
|
|
1058
|
+
const url = new URL(req.url);
|
|
1059
|
+
if (url.pathname === "/" || url.pathname === "/index.html") {
|
|
1060
|
+
return new Response(indexHtml, { headers: { "content-type": "text/html" } });
|
|
1061
|
+
}
|
|
1062
|
+
// MSW registers its worker from the page origin, so the host has to serve
|
|
1063
|
+
// it. Without this the registration 404s on every boot.
|
|
1064
|
+
if (url.pathname === "/mockServiceWorker.js") {
|
|
1065
|
+
try {
|
|
1066
|
+
const swPath = Bun.resolveSync("msw/mockServiceWorker.js", SUITE_DIR);
|
|
1067
|
+
return new Response(Bun.file(swPath), {
|
|
1068
|
+
headers: { "content-type": "text/javascript", "service-worker-allowed": "/" },
|
|
1069
|
+
});
|
|
1070
|
+
} catch {
|
|
1071
|
+
return new Response("// msw not installed", {
|
|
1072
|
+
status: 404,
|
|
1073
|
+
headers: { "content-type": "text/javascript" },
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
if (url.pathname === "/bundle.js") {
|
|
1078
|
+
return new Response(bundle, { headers: { "content-type": "application/javascript" } });
|
|
1079
|
+
}${mockProxyBlock}
|
|
1080
|
+
return new Response("not found", { status: 404 });
|
|
1081
|
+
},
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
console.log("host listening on http://127.0.0.1:" + PORT);
|
|
1085
|
+
`;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
function hostHtml(): string {
|
|
1089
|
+
return `<!doctype html>
|
|
1090
|
+
<html lang="en">
|
|
1091
|
+
<head>
|
|
1092
|
+
<meta charset="utf-8" />
|
|
1093
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
1094
|
+
<title>flawless-react host</title>
|
|
1095
|
+
<script>
|
|
1096
|
+
// Node-shim fill-in BEFORE any bundle code evaluates. The _polyfill
|
|
1097
|
+
// module cannot own this: the bundler decides module execution order,
|
|
1098
|
+
// and a CJS require chain reached through an app dependency (fast-glob
|
|
1099
|
+
// via @nodelib/fs.scandir splitting process.versions.node) ran before
|
|
1100
|
+
// it. An inline classic script is the only position that provably
|
|
1101
|
+
// precedes the bundle's module script.
|
|
1102
|
+
(function () {
|
|
1103
|
+
var g = globalThis;
|
|
1104
|
+
var p = (g.process = g.process || {});
|
|
1105
|
+
try {
|
|
1106
|
+
p.env = p.env || {};
|
|
1107
|
+
p.versions = p.versions || {};
|
|
1108
|
+
if (typeof p.versions.node !== "string") p.versions.node = "22.0.0";
|
|
1109
|
+
if (typeof p.platform !== "string") p.platform = "browser";
|
|
1110
|
+
if (!Array.isArray(p.argv)) p.argv = [];
|
|
1111
|
+
// versions.node is also msw's isNodeProcess fingerprint. The
|
|
1112
|
+
// renderer type is the Electron hatch msw and @mswjs/interceptors
|
|
1113
|
+
// built for exactly this: process-shaped-but-in-a-browser.
|
|
1114
|
+
if (typeof p.type !== "string") p.type = "renderer";
|
|
1115
|
+
if (typeof p.cwd !== "function") p.cwd = function () { return "/"; };
|
|
1116
|
+
if (typeof g.global === "undefined") g.global = g;
|
|
1117
|
+
if (typeof g.setImmediate === "undefined")
|
|
1118
|
+
g.setImmediate = function (fn) { return setTimeout(fn, 0); };
|
|
1119
|
+
} catch (e) {}
|
|
1120
|
+
})();
|
|
1121
|
+
</script>
|
|
1122
|
+
</head>
|
|
1123
|
+
<body>
|
|
1124
|
+
<div id="root"></div>
|
|
1125
|
+
<script type="module" src="/bundle.js"></script>
|
|
1126
|
+
</body>
|
|
1127
|
+
</html>
|
|
1128
|
+
`;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
function hostPolyfill(): string {
|
|
1132
|
+
return `// Browser polyfill for Node-only globals referenced at module init by some
|
|
1133
|
+
// runtime deps (e.g. react-performance-testing's CJS build reads
|
|
1134
|
+
// process.env.RPT_SKIP_AUTO_CLEANUP before any user code runs).
|
|
1135
|
+
//
|
|
1136
|
+
// Imported first from main.tsx — ESM evaluates imports in source order, so
|
|
1137
|
+
// globalThis is patched before any other module is initialised.
|
|
1138
|
+
const g = globalThis as unknown as {
|
|
1139
|
+
process?: {
|
|
1140
|
+
env: Record<string, string | undefined>;
|
|
1141
|
+
platform: string;
|
|
1142
|
+
versions: Record<string, string>;
|
|
1143
|
+
argv: string[];
|
|
1144
|
+
type?: string;
|
|
1145
|
+
cwd?: () => string;
|
|
1146
|
+
};
|
|
1147
|
+
global?: typeof globalThis;
|
|
1148
|
+
setImmediate?: (fn: (...args: unknown[]) => void, ...args: unknown[]) => number;
|
|
1149
|
+
};
|
|
1150
|
+
|
|
1151
|
+
if (typeof g.process === "undefined") {
|
|
1152
|
+
g.process = {
|
|
1153
|
+
env: { NODE_ENV: "development" },
|
|
1154
|
+
platform: "browser",
|
|
1155
|
+
versions: {},
|
|
1156
|
+
argv: [],
|
|
1157
|
+
type: "renderer",
|
|
1158
|
+
cwd: () => "/",
|
|
1159
|
+
};
|
|
1160
|
+
} else {
|
|
1161
|
+
// The bundler's own browser shim usually wins the race to define process,
|
|
1162
|
+
// and it is partial: no versions.node, which @nodelib/fs.scandir (and
|
|
1163
|
+
// every other fast-glob caller) splits at module scope. Fill the holes
|
|
1164
|
+
// rather than assume an all-or-nothing assignment.
|
|
1165
|
+
const p = g.process as Record<string, unknown>;
|
|
1166
|
+
try {
|
|
1167
|
+
p.env ??= {};
|
|
1168
|
+
p.versions ??= {};
|
|
1169
|
+
const v = p.versions as Record<string, string>;
|
|
1170
|
+
if (typeof v.node !== "string") v.node = "22.0.0";
|
|
1171
|
+
if (typeof p.platform !== "string") p.platform = "browser";
|
|
1172
|
+
if (!Array.isArray(p.argv)) p.argv = [];
|
|
1173
|
+
if (typeof p.type !== "string") p.type = "renderer";
|
|
1174
|
+
if (typeof p.cwd !== "function") p.cwd = () => "/";
|
|
1175
|
+
} catch {
|
|
1176
|
+
// A frozen shim is not this file's problem to solve.
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
if (typeof g.global === "undefined") {
|
|
1180
|
+
g.global = globalThis;
|
|
1181
|
+
}
|
|
1182
|
+
if (typeof g.setImmediate === "undefined") {
|
|
1183
|
+
// react-performance-testing schedules its task queue via setImmediate; falling
|
|
1184
|
+
// back to a microtask is good enough for our profiling needs.
|
|
1185
|
+
g.setImmediate = ((fn: (...args: unknown[]) => void, ...args: unknown[]) =>
|
|
1186
|
+
setTimeout(fn, 0, ...args)) as unknown as typeof g.setImmediate;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
export {};
|
|
1190
|
+
`;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/**
|
|
1194
|
+
* host/registry.ts — the mechanical mount tables, split out of the write-once
|
|
1195
|
+
* wrapper (FR-016: a kept main.tsx froze the registry with it, so a newly
|
|
1196
|
+
* analysed component's specs could never mount, and no flag could fix it).
|
|
1197
|
+
* Regenerated on every run; hand registrations belong in main.tsx's ALL
|
|
1198
|
+
* spread, which survives.
|
|
1199
|
+
*/
|
|
1200
|
+
function hostRegistry(
|
|
1201
|
+
flat: FlatComponent[],
|
|
1202
|
+
providerWraps: Map<string, ProviderRef[]> | undefined,
|
|
1203
|
+
hostDir: string,
|
|
1204
|
+
): string {
|
|
1205
|
+
// Sorted by registry key: emission is byte-identical for a given set of
|
|
1206
|
+
// specNames. (Byte-identity across PERMUTED entry order holds only until a
|
|
1207
|
+
// componentName collision — allocateSpecNames is delivery-order-dependent
|
|
1208
|
+
// in who keeps the bare name; that predates the split and lives upstream.)
|
|
1209
|
+
// Code-unit order (not localeCompare): locale rules interleave cases and
|
|
1210
|
+
// would reorder — churning — every adopted suite's registry on refresh.
|
|
1211
|
+
const sorted = [...flat].sort((a, b) =>
|
|
1212
|
+
a.specName < b.specName ? -1 : a.specName > b.specName ? 1 : 0,
|
|
1213
|
+
);
|
|
1214
|
+
// A wrappable compound part mounts through a composed entry: the family
|
|
1215
|
+
// Root (open, unless the part is the Trigger — a trigger's visible state
|
|
1216
|
+
// is a CLOSED root) and the Content sibling around the part itself. The
|
|
1217
|
+
// composition lives HERE, regenerated-side, so a kept main.tsx benefits
|
|
1218
|
+
// from a plain refresh — its ALL spread sees a Component like any other.
|
|
1219
|
+
// Wrapper imports are aliased per entry: the Root is often a registry
|
|
1220
|
+
// component itself, already imported under its own name from this module.
|
|
1221
|
+
const wrappedOf = (c: FlatComponent): NonNullable<ComponentInfo["compoundFamily"]> | undefined =>
|
|
1222
|
+
compoundWrappable(c.info) ? c.info.compoundFamily : undefined;
|
|
1223
|
+
const wrapImports = sorted
|
|
1224
|
+
.map((c) => {
|
|
1225
|
+
const fam = wrappedOf(c);
|
|
1226
|
+
if (!fam) return "";
|
|
1227
|
+
const content =
|
|
1228
|
+
fam.part === "other" && fam.content ? `, ${fam.content} as ${c.specName}__Content` : "";
|
|
1229
|
+
return `import { ${fam.root} as ${c.specName}__Root${content} } from "${c.importPath}";`;
|
|
1230
|
+
})
|
|
1231
|
+
.filter((l) => l !== "")
|
|
1232
|
+
.join(NL);
|
|
1233
|
+
const anyWrapped = wrapImports !== "";
|
|
1234
|
+
// Context providers to mount around each component, derived upstream in
|
|
1235
|
+
// analyse/providerWraps.ts from useX CALL evidence — the provider is often
|
|
1236
|
+
// exported by a file NO analysed component imports (route layouts import
|
|
1237
|
+
// SidebarProvider, and route files are not entries), so it is imported here
|
|
1238
|
+
// directly under a __Prov alias allocateSpecNames never produces. A
|
|
1239
|
+
// provider that cannot mount bare breaks the wraps it guards, and the
|
|
1240
|
+
// dogfood failure then names it.
|
|
1241
|
+
const hostedKey = new Map<string, string>();
|
|
1242
|
+
for (const c of sorted) {
|
|
1243
|
+
hostedKey.set(`${c.info.filePath}::${c.info.componentName}`, c.specName);
|
|
1244
|
+
}
|
|
1245
|
+
const provAlias = new Map<string, string>();
|
|
1246
|
+
const bindingLabel = new Map<string, string>();
|
|
1247
|
+
const provImportLines: string[] = [];
|
|
1248
|
+
const providersFor = (c: FlatComponent): string[] => {
|
|
1249
|
+
const out: string[] = [];
|
|
1250
|
+
const self = `${c.info.filePath}::${c.info.componentName}`;
|
|
1251
|
+
for (const ref of providerWraps?.get(self) ?? []) {
|
|
1252
|
+
const key = `${ref.filePath}::${ref.componentName}`;
|
|
1253
|
+
if (key === self) continue; // never wraps itself
|
|
1254
|
+
const hosted = hostedKey.get(key);
|
|
1255
|
+
if (hosted) {
|
|
1256
|
+
bindingLabel.set(hosted, ref.componentName);
|
|
1257
|
+
out.push(hosted);
|
|
1258
|
+
continue;
|
|
1259
|
+
}
|
|
1260
|
+
let binding = provAlias.get(key);
|
|
1261
|
+
if (!binding) {
|
|
1262
|
+
binding = `__Prov${ref.componentName}`;
|
|
1263
|
+
for (let n = 2; bindingLabel.has(binding); n++) binding = `__Prov${ref.componentName}${n}`;
|
|
1264
|
+
provAlias.set(key, binding);
|
|
1265
|
+
bindingLabel.set(binding, ref.componentName);
|
|
1266
|
+
const rel = stripTsExt(toRel(hostDir, ref.filePath));
|
|
1267
|
+
provImportLines.push(
|
|
1268
|
+
ref.exportKind === "default"
|
|
1269
|
+
? `import ${binding} from "${rel}";`
|
|
1270
|
+
: `import { ${ref.exportedAs ?? ref.componentName} as ${binding} } from "${rel}";`,
|
|
1271
|
+
);
|
|
1272
|
+
}
|
|
1273
|
+
out.push(binding);
|
|
1274
|
+
}
|
|
1275
|
+
return out;
|
|
1276
|
+
};
|
|
1277
|
+
const providersByEntry = new Map<string, { bindings: string[]; labels: string[] }>();
|
|
1278
|
+
for (const c of sorted) {
|
|
1279
|
+
const bindings = providersFor(c);
|
|
1280
|
+
if (bindings.length > 0) {
|
|
1281
|
+
providersByEntry.set(
|
|
1282
|
+
c.specName,
|
|
1283
|
+
bindings.reduce(
|
|
1284
|
+
(acc, b) => {
|
|
1285
|
+
acc.bindings.push(b);
|
|
1286
|
+
acc.labels.push(bindingLabel.get(b) ?? b);
|
|
1287
|
+
return acc;
|
|
1288
|
+
},
|
|
1289
|
+
{ bindings: [], labels: [] } as { bindings: string[]; labels: string[] },
|
|
1290
|
+
),
|
|
1291
|
+
);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
const anyProvider = providersByEntry.size > 0;
|
|
1295
|
+
const imports = [
|
|
1296
|
+
...(anyWrapped || anyProvider ? ['import { createElement } from "react";'] : []),
|
|
1297
|
+
sorted.map(hostComponentImport).join(NL),
|
|
1298
|
+
...(anyWrapped ? [wrapImports] : []),
|
|
1299
|
+
...(anyProvider ? [provImportLines.join(NL)] : []),
|
|
1300
|
+
].join(NL);
|
|
1301
|
+
|
|
1302
|
+
// Cast per entry: a component declaring required props is not assignable to
|
|
1303
|
+
// the host's loose (props: Record<string, unknown>) => unknown signature, and
|
|
1304
|
+
// supplying those props is exactly what the profile spec does at runtime.
|
|
1305
|
+
const withProvidersCall = (value: string, c: FlatComponent): string => {
|
|
1306
|
+
const entry = providersByEntry.get(c.specName);
|
|
1307
|
+
return entry === undefined ? value : `withProviders(${value}, [${entry.bindings.join(", ")}])`;
|
|
1308
|
+
};
|
|
1309
|
+
const registry = sorted
|
|
1310
|
+
.map((c) => {
|
|
1311
|
+
const fam = wrappedOf(c);
|
|
1312
|
+
const entry = fam
|
|
1313
|
+
? (() => {
|
|
1314
|
+
const inner = `createElement(${c.specName} as never, props as never)`;
|
|
1315
|
+
const chained =
|
|
1316
|
+
fam.part === "other" && fam.content
|
|
1317
|
+
? `createElement(${c.specName}__Content as never, null, ${inner})`
|
|
1318
|
+
: inner;
|
|
1319
|
+
const rootProps = fam.part === "trigger" ? "null" : "{ open: true } as never";
|
|
1320
|
+
return `((props: Record<string, unknown>) => createElement(${c.specName}__Root as never, ${rootProps}, ${chained})) as Component`;
|
|
1321
|
+
})()
|
|
1322
|
+
: `${c.specName} as unknown as Component`;
|
|
1323
|
+
return ` ${JSON.stringify(c.specName)}: ${withProvidersCall(entry, c)},`;
|
|
1324
|
+
})
|
|
1325
|
+
.join(NL);
|
|
1326
|
+
const providerHelper = anyProvider
|
|
1327
|
+
? `
|
|
1328
|
+
/** Nest the derived context providers around a component (outermost first). */
|
|
1329
|
+
const withProviders = (Component: Component, providers: Component[]): Component =>
|
|
1330
|
+
providers.length === 0
|
|
1331
|
+
? Component
|
|
1332
|
+
: (props) =>
|
|
1333
|
+
providers.reduceRight(
|
|
1334
|
+
(children, Provider) => createElement(Provider as never, null, children),
|
|
1335
|
+
createElement(Component as never, props as never),
|
|
1336
|
+
);
|
|
1337
|
+
`
|
|
1338
|
+
: "";
|
|
1339
|
+
const autoTable = sorted
|
|
1340
|
+
.map((c) => {
|
|
1341
|
+
const entry = providersByEntry.get(c.specName);
|
|
1342
|
+
return entry ? ` ${JSON.stringify(c.specName)}: ${JSON.stringify(entry.labels)},` : "";
|
|
1343
|
+
})
|
|
1344
|
+
.filter(Boolean)
|
|
1345
|
+
.join(NL);
|
|
1346
|
+
|
|
1347
|
+
// Function props cannot cross page.evaluate's structured clone, so a spec's
|
|
1348
|
+
// DEFAULTS can never carry one. The component then calls undefined: AuthError
|
|
1349
|
+
// renders <Button onClick={reset}> and threw the moment the click landed. The
|
|
1350
|
+
// host holds the names and supplies no-ops on this side of the boundary.
|
|
1351
|
+
const fnProps = sorted
|
|
1352
|
+
.map((c) => ({ name: c.specName, props: functionProps(c.info) }))
|
|
1353
|
+
.filter((e) => e.props.length > 0)
|
|
1354
|
+
.map((e) => ` ${JSON.stringify(e.name)}: ${JSON.stringify(e.props)},`)
|
|
1355
|
+
.join(NL);
|
|
1356
|
+
|
|
1357
|
+
// Components whose root tag HTML only permits inside a particular parent.
|
|
1358
|
+
// Rendering <tbody> into the host's #root div made React log "In HTML, <tbody>
|
|
1359
|
+
// cannot be a child of <div>", which failed seven table primitives for a
|
|
1360
|
+
// warning the harness itself caused.
|
|
1361
|
+
const wrapIn = sorted
|
|
1362
|
+
.map((c) => ({ name: c.specName, chain: requiredAncestors(c.info.rootTag) }))
|
|
1363
|
+
.filter((e) => e.chain.length > 0)
|
|
1364
|
+
.map((e) => ` ${JSON.stringify(e.name)}: ${JSON.stringify(e.chain)},`)
|
|
1365
|
+
.join(NL);
|
|
1366
|
+
|
|
1367
|
+
return `// Regenerated on every flawless-react run — derived tables only, so edits
|
|
1368
|
+
// here are lost. Hand-registered components belong in main.tsx's ALL spread,
|
|
1369
|
+
// which is never overwritten.
|
|
1370
|
+
${imports}
|
|
1371
|
+
|
|
1372
|
+
/** The loose signature the host mounts through; props arrive at runtime. */
|
|
1373
|
+
export type Component = (props: Record<string, unknown>) => unknown;
|
|
1374
|
+
${providerHelper}
|
|
1375
|
+
export const REGISTRY: Record<string, Component> = {
|
|
1376
|
+
${registry}
|
|
1377
|
+
};
|
|
1378
|
+
|
|
1379
|
+
/** Component → context providers the host mounts around it (useX call evidence). */
|
|
1380
|
+
export const AUTO_PROVIDERS: Record<string, string[]> = {
|
|
1381
|
+
${autoTable}
|
|
1382
|
+
};
|
|
1383
|
+
|
|
1384
|
+
/** Props the component calls, which a serialised props object cannot carry. */
|
|
1385
|
+
export const FN_PROPS: Record<string, string[]> = {
|
|
1386
|
+
${fnProps}
|
|
1387
|
+
};
|
|
1388
|
+
|
|
1389
|
+
/** Ancestors HTML requires around a component's root tag, outermost first. */
|
|
1390
|
+
export const WRAP_IN: Record<string, string[]> = {
|
|
1391
|
+
${wrapIn}
|
|
1392
|
+
};
|
|
1393
|
+
`;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
function hostMain(providerImportPath?: string, mswSetup = false, legacyMount = false): string {
|
|
1397
|
+
const providerImport = providerImportPath
|
|
1398
|
+
? `import AppProvider from ${JSON.stringify(stripTsExt(providerImportPath))};\n`
|
|
1399
|
+
: "";
|
|
1400
|
+
const wrapBody = providerImportPath ? "createElement(AppProvider as never, null, tree)" : "tree";
|
|
1401
|
+
// React 17 has no react-dom/client. The host speaks the legacy renderer
|
|
1402
|
+
// through the same small Root interface the 18+ path uses, so every
|
|
1403
|
+
// mount/rerender/unmount call site stays version-blind.
|
|
1404
|
+
const mountImport = legacyMount
|
|
1405
|
+
? `import * as ReactDOM from "react-dom";
|
|
1406
|
+
|
|
1407
|
+
interface Root {
|
|
1408
|
+
render(node: ReactNode): void;
|
|
1409
|
+
unmount(): void;
|
|
1410
|
+
}
|
|
1411
|
+
`
|
|
1412
|
+
: `import { createRoot, type Root } from "react-dom/client";
|
|
1413
|
+
`;
|
|
1414
|
+
const rootInit = legacyMount
|
|
1415
|
+
? ` // React 17 has no onUncaughtError: the legacy render throws synchronously.
|
|
1416
|
+
// Record and rethrow — the mount wait-loop reads the same variable the
|
|
1417
|
+
// concurrent renderer's callback fills, so failure reporting stays identical.
|
|
1418
|
+
activeRoot = {
|
|
1419
|
+
render: (node) => {
|
|
1420
|
+
try {
|
|
1421
|
+
ReactDOM.render(node as never, el);
|
|
1422
|
+
} catch (err) {
|
|
1423
|
+
lastRenderError = err;
|
|
1424
|
+
throw err;
|
|
1425
|
+
}
|
|
1426
|
+
},
|
|
1427
|
+
unmount: () => {
|
|
1428
|
+
ReactDOM.unmountComponentAtNode(el);
|
|
1429
|
+
},
|
|
1430
|
+
};
|
|
1431
|
+
`
|
|
1432
|
+
: ` activeRoot = createRoot(el, {
|
|
1433
|
+
onUncaughtError: (err: unknown) => {
|
|
1434
|
+
lastRenderError = err;
|
|
1435
|
+
},
|
|
1436
|
+
});
|
|
1437
|
+
`;
|
|
1438
|
+
const mswImport = mswSetup ? `import { startMockWorker } from "../mocks/browser";\n` : "";
|
|
1439
|
+
const mswBoot = mswSetup
|
|
1440
|
+
? `
|
|
1441
|
+
// Boot MSW before any component mounts so fetch() in render-time effects is
|
|
1442
|
+
// intercepted. Failure is NOT fatal: this await sits at module top level, so a
|
|
1443
|
+
// rejection would abort the module and leave window.__profile undefined —
|
|
1444
|
+
// every profile test then times out waiting for a harness that never loaded,
|
|
1445
|
+
// which is a very confusing way to learn that a service worker 404'd.
|
|
1446
|
+
try {
|
|
1447
|
+
await startMockWorker();
|
|
1448
|
+
} catch (err) {
|
|
1449
|
+
console.warn("[flawless] MSW did not start; fetches will hit the network:", err);
|
|
1450
|
+
}
|
|
1451
|
+
`
|
|
1452
|
+
: "";
|
|
1453
|
+
|
|
1454
|
+
return `import "./_polyfill";
|
|
1455
|
+
import { createElement, Profiler, type ReactNode } from "react";
|
|
1456
|
+
${mountImport}import { scan } from "react-scan";
|
|
1457
|
+
${mswImport}${providerImport}// ./registry last among the app imports: it pulls every component module,
|
|
1458
|
+
// and the monolithic host evaluated msw/provider modules FIRST — a component
|
|
1459
|
+
// with module-scope code leaning on a provider module's side effects (i18n
|
|
1460
|
+
// init, interceptor install) must keep that order across the split.
|
|
1461
|
+
import { type Component, FN_PROPS, REGISTRY, WRAP_IN } from "./registry";
|
|
1462
|
+
${mswBoot}
|
|
1463
|
+
|
|
1464
|
+
// We measure renders with React's built-in Profiler rather than
|
|
1465
|
+
// react-performance-testing's patching: the latter only instruments the
|
|
1466
|
+
// classic JSX runtime (\`React.createElement\`), and Bun.build emits JSX via
|
|
1467
|
+
// the automatic \`react/jsx-runtime\`. Profiler works with either runtime and
|
|
1468
|
+
// across React versions, so the host stays portable.
|
|
1469
|
+
|
|
1470
|
+
// Every lookup below goes through the ALL_* spreads, never the registry
|
|
1471
|
+
// tables directly: ./registry is regenerated on every run, so these spreads
|
|
1472
|
+
// are the one place hand-registered entries survive. All three exist for the
|
|
1473
|
+
// same reason — a hand-registered component may also need function-prop
|
|
1474
|
+
// stubs (functions cannot cross page.evaluate's structured clone) or a
|
|
1475
|
+
// wrapper chain, and without its own spread each table would silently drop
|
|
1476
|
+
// hand entries on the next regeneration.
|
|
1477
|
+
const ALL: Record<string, Component> = {
|
|
1478
|
+
...REGISTRY /* hand-registered components ride here and survive regeneration */,
|
|
1479
|
+
};
|
|
1480
|
+
const ALL_FN_PROPS: Record<string, readonly string[]> = {
|
|
1481
|
+
...FN_PROPS /* hand-registered components' function-prop names ride here */,
|
|
1482
|
+
};
|
|
1483
|
+
const ALL_WRAP_IN: Record<string, readonly string[]> = {
|
|
1484
|
+
...WRAP_IN /* hand-registered components' wrapper chains ride here */,
|
|
1485
|
+
};
|
|
1486
|
+
|
|
1487
|
+
function withFnProps(name: string, props: Record<string, unknown>): Record<string, unknown> {
|
|
1488
|
+
const names = ALL_FN_PROPS[name];
|
|
1489
|
+
if (!names) return props;
|
|
1490
|
+
const out = { ...props };
|
|
1491
|
+
for (const p of names) if (out[p] === undefined) out[p] = () => {};
|
|
1492
|
+
return out;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
function nest(name: string, tree: ReactNode): ReactNode {
|
|
1496
|
+
const chain = ALL_WRAP_IN[name];
|
|
1497
|
+
if (!chain) return tree;
|
|
1498
|
+
let out = tree;
|
|
1499
|
+
for (let i = chain.length - 1; i >= 0; i--) out = createElement(chain[i] as string, null, out);
|
|
1500
|
+
return out;
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
interface RenderCountField { value: number }
|
|
1504
|
+
interface RenderTimeField { mount?: number; updates?: number[] }
|
|
1505
|
+
|
|
1506
|
+
const renderCount: Record<string, RenderCountField | undefined> = {};
|
|
1507
|
+
const renderTime: Record<string, RenderTimeField | undefined> = {};
|
|
1508
|
+
|
|
1509
|
+
let activeRoot: Root | undefined;
|
|
1510
|
+
let activeName: string | undefined;
|
|
1511
|
+
|
|
1512
|
+
const scanEvents: Array<{ name: string; reason: string; ts: number }> = [];
|
|
1513
|
+
|
|
1514
|
+
// react-scan is measurement sugar: on a React it cannot instrument (17 is
|
|
1515
|
+
// supported but its internals differ) it must never take the host down —
|
|
1516
|
+
// render counts come from the Profiler, not from scan.
|
|
1517
|
+
try {
|
|
1518
|
+
scan({
|
|
1519
|
+
enabled: true,
|
|
1520
|
+
log: false,
|
|
1521
|
+
onRender: (fiber: { type?: { displayName?: string; name?: string } }, renders: unknown[]) => {
|
|
1522
|
+
const name = fiber.type?.displayName ?? fiber.type?.name ?? "anonymous";
|
|
1523
|
+
for (const r of renders as Array<{ changes?: unknown[]; unnecessary?: boolean; reason?: string }>) {
|
|
1524
|
+
if (r.unnecessary) {
|
|
1525
|
+
scanEvents.push({
|
|
1526
|
+
name,
|
|
1527
|
+
reason: r.reason ?? "unnecessary rerender",
|
|
1528
|
+
ts: performance.now(),
|
|
1529
|
+
});
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
},
|
|
1533
|
+
});
|
|
1534
|
+
} catch (err) {
|
|
1535
|
+
console.warn("[flawless] react-scan disabled:", err);
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
// The last exception React swallowed on our behalf. Without it every render
|
|
1539
|
+
// failure looked identical — "did not render within 5000ms" — no matter whether
|
|
1540
|
+
// the component threw on a missing prop, a missing provider or a bad import.
|
|
1541
|
+
let lastRenderError: unknown;
|
|
1542
|
+
|
|
1543
|
+
function ensureRoot(): Root {
|
|
1544
|
+
if (activeRoot) return activeRoot;
|
|
1545
|
+
const el = document.getElementById("root");
|
|
1546
|
+
if (!el) throw new Error("#root not found");
|
|
1547
|
+
${rootInit} return activeRoot;
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
// Wrap the profiled component tree in the user's Provider (if --provider was
|
|
1551
|
+
// passed at scaffold time). Without a provider this is the identity function,
|
|
1552
|
+
// so call sites stay uniform.
|
|
1553
|
+
function wrap(tree: ReactNode): ReactNode {
|
|
1554
|
+
return ${wrapBody};
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
function onProfile(id: string, phase: string, actualDuration: number): void {
|
|
1558
|
+
const c = renderCount[id] ?? (renderCount[id] = { value: 0 });
|
|
1559
|
+
c.value += 1;
|
|
1560
|
+
const t = renderTime[id] ?? (renderTime[id] = {});
|
|
1561
|
+
if (phase === "mount") {
|
|
1562
|
+
t.mount = actualDuration;
|
|
1563
|
+
} else {
|
|
1564
|
+
(t.updates ??= []).push(actualDuration);
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
declare global {
|
|
1569
|
+
interface Window {
|
|
1570
|
+
__profile: {
|
|
1571
|
+
mount(name: string, props: Record<string, unknown>): Promise<void>;
|
|
1572
|
+
rerender(name: string, props: Record<string, unknown>): Promise<void>;
|
|
1573
|
+
unmount(): Promise<void>;
|
|
1574
|
+
getRenderInfo(): { renderCount: unknown; renderTime: unknown };
|
|
1575
|
+
getScanEvents(): typeof scanEvents;
|
|
1576
|
+
clearScanEvents(): void;
|
|
1577
|
+
list(): string[];
|
|
1578
|
+
};
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
window.__profile = {
|
|
1583
|
+
list: () => Object.keys(ALL),
|
|
1584
|
+
async mount(name, props) {
|
|
1585
|
+
const Comp = ALL[name];
|
|
1586
|
+
if (!Comp) throw new Error("unknown component: " + name);
|
|
1587
|
+
activeName = name;
|
|
1588
|
+
lastRenderError = undefined;
|
|
1589
|
+
ensureRoot().render(
|
|
1590
|
+
wrap(
|
|
1591
|
+
createElement(
|
|
1592
|
+
Profiler,
|
|
1593
|
+
{ id: name, onRender: onProfile },
|
|
1594
|
+
nest(name, createElement(Comp as never, withFnProps(name, props))),
|
|
1595
|
+
),
|
|
1596
|
+
),
|
|
1597
|
+
);
|
|
1598
|
+
const deadline = performance.now() + 5000;
|
|
1599
|
+
while (performance.now() < deadline) {
|
|
1600
|
+
if ((renderCount[name]?.value ?? 0) >= 1) return;
|
|
1601
|
+
if (lastRenderError) break;
|
|
1602
|
+
await new Promise<void>((res) => requestAnimationFrame(() => res()));
|
|
1603
|
+
}
|
|
1604
|
+
const cause = lastRenderError
|
|
1605
|
+
? ": " + (lastRenderError instanceof Error ? lastRenderError.message : String(lastRenderError))
|
|
1606
|
+
: " within 5000ms";
|
|
1607
|
+
throw new Error("mount: " + name + " did not render" + cause);
|
|
1608
|
+
},
|
|
1609
|
+
async rerender(name, props) {
|
|
1610
|
+
if (!activeRoot) throw new Error("mount first");
|
|
1611
|
+
const Comp = ALL[name];
|
|
1612
|
+
if (!Comp) throw new Error("unknown component: " + name);
|
|
1613
|
+
activeRoot.render(
|
|
1614
|
+
wrap(
|
|
1615
|
+
createElement(
|
|
1616
|
+
Profiler,
|
|
1617
|
+
{ id: name, onRender: onProfile },
|
|
1618
|
+
nest(name, createElement(Comp as never, withFnProps(name, props))),
|
|
1619
|
+
),
|
|
1620
|
+
),
|
|
1621
|
+
);
|
|
1622
|
+
activeName = name;
|
|
1623
|
+
await new Promise<void>((res) => requestAnimationFrame(() => res()));
|
|
1624
|
+
},
|
|
1625
|
+
async unmount() {
|
|
1626
|
+
if (activeRoot) {
|
|
1627
|
+
activeRoot.unmount();
|
|
1628
|
+
activeRoot = undefined;
|
|
1629
|
+
activeName = undefined;
|
|
1630
|
+
for (const k of Object.keys(renderCount)) delete renderCount[k];
|
|
1631
|
+
for (const k of Object.keys(renderTime)) delete renderTime[k];
|
|
1632
|
+
}
|
|
1633
|
+
},
|
|
1634
|
+
getRenderInfo() {
|
|
1635
|
+
return { renderCount, renderTime };
|
|
1636
|
+
},
|
|
1637
|
+
getScanEvents() {
|
|
1638
|
+
return [...scanEvents];
|
|
1639
|
+
},
|
|
1640
|
+
clearScanEvents() {
|
|
1641
|
+
scanEvents.length = 0;
|
|
1642
|
+
},
|
|
1643
|
+
};
|
|
1644
|
+
|
|
1645
|
+
// Suppress unused-name warning during isolated builds.
|
|
1646
|
+
void activeName;
|
|
1647
|
+
`;
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
function helpers(): string {
|
|
1651
|
+
return `import type { Page } from "@playwright/test";
|
|
1652
|
+
|
|
1653
|
+
export interface RenderInfoSnapshot {
|
|
1654
|
+
renderCount: Record<string, { value: number } | undefined>;
|
|
1655
|
+
renderTime: Record<
|
|
1656
|
+
string,
|
|
1657
|
+
| {
|
|
1658
|
+
mount?: number;
|
|
1659
|
+
updates?: number[];
|
|
1660
|
+
}
|
|
1661
|
+
| undefined
|
|
1662
|
+
>;
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
export interface ScanEvent {
|
|
1666
|
+
name: string;
|
|
1667
|
+
reason: string;
|
|
1668
|
+
ts: number;
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
export async function mountComponent(page: Page, name: string, props: Record<string, unknown>): Promise<void> {
|
|
1672
|
+
await page.evaluate(
|
|
1673
|
+
([n, p]) => window.__profile.mount(n as string, p as Record<string, unknown>),
|
|
1674
|
+
[name, props],
|
|
1675
|
+
);
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
export async function rerenderComponent(
|
|
1679
|
+
page: Page,
|
|
1680
|
+
name: string,
|
|
1681
|
+
props: Record<string, unknown>,
|
|
1682
|
+
): Promise<void> {
|
|
1683
|
+
await page.evaluate(
|
|
1684
|
+
([n, p]) => window.__profile.rerender(n as string, p as Record<string, unknown>),
|
|
1685
|
+
[name, props],
|
|
1686
|
+
);
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
/**
|
|
1690
|
+
* N rerenders in one round trip.
|
|
1691
|
+
*
|
|
1692
|
+
* Driving the loop from Node cost a CDP round trip per iteration — 50 per
|
|
1693
|
+
* component, 4,700 across a whole-app sweep, and about 150s of a 13.5-minute
|
|
1694
|
+
* run spent in IPC rather than in React. The loop runs in the page instead, and
|
|
1695
|
+
* rerender still awaits a frame between commits, so the Profiler records
|
|
1696
|
+
* exactly the same renders.
|
|
1697
|
+
*/
|
|
1698
|
+
export async function rerenderMany(
|
|
1699
|
+
page: Page,
|
|
1700
|
+
name: string,
|
|
1701
|
+
pool: Array<Record<string, unknown>>,
|
|
1702
|
+
iterations: number,
|
|
1703
|
+
): Promise<void> {
|
|
1704
|
+
await page.evaluate(
|
|
1705
|
+
async ([n, p, count]) => {
|
|
1706
|
+
const props = p as Array<Record<string, unknown>>;
|
|
1707
|
+
for (let i = 0; i < (count as number); i++) {
|
|
1708
|
+
await window.__profile.rerender(n as string, props[i % props.length] as Record<string, unknown>);
|
|
1709
|
+
}
|
|
1710
|
+
},
|
|
1711
|
+
[name, pool, iterations] as [string, Array<Record<string, unknown>>, number],
|
|
1712
|
+
);
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
export async function unmount(page: Page): Promise<void> {
|
|
1716
|
+
await page.evaluate(() => window.__profile.unmount());
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
export async function getRenderInfo(page: Page): Promise<RenderInfoSnapshot> {
|
|
1720
|
+
return page.evaluate(() => window.__profile.getRenderInfo() as RenderInfoSnapshot);
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
export async function getScanEvents(page: Page): Promise<ScanEvent[]> {
|
|
1724
|
+
return page.evaluate(() => window.__profile.getScanEvents());
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
export async function clearScanEvents(page: Page): Promise<void> {
|
|
1728
|
+
await page.evaluate(() => window.__profile.clearScanEvents());
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
export function quantile(sorted: number[], q: number): number {
|
|
1732
|
+
if (sorted.length === 0) return 0;
|
|
1733
|
+
const pos = (sorted.length - 1) * q;
|
|
1734
|
+
const lo = Math.floor(pos);
|
|
1735
|
+
const hi = Math.ceil(pos);
|
|
1736
|
+
const a = sorted[lo] as number;
|
|
1737
|
+
if (lo === hi) return a;
|
|
1738
|
+
const b = sorted[hi] as number;
|
|
1739
|
+
return a + (b - a) * (pos - lo);
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
export function summarize(samples: number[]): {
|
|
1743
|
+
min: number;
|
|
1744
|
+
avg: number;
|
|
1745
|
+
p50: number;
|
|
1746
|
+
p95: number;
|
|
1747
|
+
max: number;
|
|
1748
|
+
n: number;
|
|
1749
|
+
} {
|
|
1750
|
+
if (samples.length === 0) return { min: 0, avg: 0, p50: 0, p95: 0, max: 0, n: 0 };
|
|
1751
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
1752
|
+
const sum = sorted.reduce((s, v) => s + v, 0);
|
|
1753
|
+
return {
|
|
1754
|
+
min: sorted[0] as number,
|
|
1755
|
+
avg: sum / sorted.length,
|
|
1756
|
+
p50: quantile(sorted, 0.5),
|
|
1757
|
+
p95: quantile(sorted, 0.95),
|
|
1758
|
+
max: sorted[sorted.length - 1] as number,
|
|
1759
|
+
n: sorted.length,
|
|
1760
|
+
};
|
|
1761
|
+
}
|
|
1762
|
+
`;
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
function heapHelper(mode: HeapMode): string {
|
|
1766
|
+
return `import { mkdir, writeFile } from "node:fs/promises";
|
|
1767
|
+
import path from "node:path";
|
|
1768
|
+
import type { CDPSession, Page, TestInfo } from "@playwright/test";
|
|
1769
|
+
|
|
1770
|
+
// Drives the browser's V8 heap profiler via CDP. Snapshots land in
|
|
1771
|
+
// <suite-root>/heaps/<spec>__<test>__<label>.heapsnapshot with predictable
|
|
1772
|
+
// names so you can grab them from the filesystem directly (no need to dig
|
|
1773
|
+
// through playwright-report/data/<sha1>). They're also attached to the
|
|
1774
|
+
// Playwright HTML report so you can navigate from a failed test to its
|
|
1775
|
+
// snapshot. Format is the standard V8 .heapsnapshot, viewable in Chrome
|
|
1776
|
+
// DevTools' Memory tab.
|
|
1777
|
+
|
|
1778
|
+
const HEAPS_DIR = "heaps";
|
|
1779
|
+
|
|
1780
|
+
/**
|
|
1781
|
+
* How much of the heap this suite keeps, from --heaps at scaffold time.
|
|
1782
|
+
*
|
|
1783
|
+
* "off" skips the CDP capture entirely. That is not only about disk: a heap
|
|
1784
|
+
* snapshot of a real app is tens of megabytes and takes real time, and one is
|
|
1785
|
+
* taken either side of every scenario — an observer that costs more than the
|
|
1786
|
+
* thing it observes, inside the measurement it is bracketing.
|
|
1787
|
+
*/
|
|
1788
|
+
const HEAP_MODE: "off" | "stress" | "all" = ${JSON.stringify(mode)};
|
|
1789
|
+
|
|
1790
|
+
/**
|
|
1791
|
+
* A heap snapshot, or an empty buffer.
|
|
1792
|
+
*
|
|
1793
|
+
* Snapshots are diagnostics, not the measurement. Opening a CDP session can fail
|
|
1794
|
+
* for reasons that have nothing to do with the component under test — a page
|
|
1795
|
+
* that has already navigated, a Playwright/Chromium pairing that disagrees about
|
|
1796
|
+
* a target guid — and letting that throw means the profiling extra breaks the
|
|
1797
|
+
* very tests it was added to observe. It warns and returns nothing instead.
|
|
1798
|
+
*/
|
|
1799
|
+
async function captureSnapshot(page: Page): Promise<Buffer> {
|
|
1800
|
+
if (HEAP_MODE === "off") return Buffer.alloc(0);
|
|
1801
|
+
let session: CDPSession;
|
|
1802
|
+
try {
|
|
1803
|
+
session = await page.context().newCDPSession(page);
|
|
1804
|
+
} catch (err) {
|
|
1805
|
+
console.warn("[flawless] heap snapshot skipped:", (err as Error).message);
|
|
1806
|
+
return Buffer.alloc(0);
|
|
1807
|
+
}
|
|
1808
|
+
const chunks: string[] = [];
|
|
1809
|
+
const onChunk = (e: { chunk: string }): void => {
|
|
1810
|
+
chunks.push(e.chunk);
|
|
1811
|
+
};
|
|
1812
|
+
session.on("HeapProfiler.addHeapSnapshotChunk", onChunk);
|
|
1813
|
+
try {
|
|
1814
|
+
// Force GC so the snapshot reflects retained-by-reachability, not
|
|
1815
|
+
// not-yet-collected garbage.
|
|
1816
|
+
await session.send("HeapProfiler.collectGarbage");
|
|
1817
|
+
await session.send("HeapProfiler.takeHeapSnapshot", {
|
|
1818
|
+
reportProgress: false,
|
|
1819
|
+
captureNumericValue: true,
|
|
1820
|
+
});
|
|
1821
|
+
} catch (err) {
|
|
1822
|
+
console.warn("[flawless] heap snapshot failed:", (err as Error).message);
|
|
1823
|
+
return Buffer.alloc(0);
|
|
1824
|
+
} finally {
|
|
1825
|
+
session.off("HeapProfiler.addHeapSnapshotChunk", onChunk);
|
|
1826
|
+
await session.detach().catch(() => undefined);
|
|
1827
|
+
}
|
|
1828
|
+
return Buffer.from(chunks.join(""), "utf8");
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
function slug(s: string): string {
|
|
1832
|
+
return s.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
async function persistSnapshot(buf: Buffer, label: string, testInfo: TestInfo): Promise<string> {
|
|
1836
|
+
// Anchored to the CONFIG FILE via the runtime FullConfig — the one anchor
|
|
1837
|
+
// that exists under both loaders. NOT rootDir: playwright resolves rootDir
|
|
1838
|
+
// to the testDir ("./profile" here), which would put snapshots in
|
|
1839
|
+
// profile/heaps/ outside the emitted .gitignore's <out>/heaps/ entry — the
|
|
1840
|
+
// 566MB-of-untracked-binaries class this path has already been through
|
|
1841
|
+
// twice (cwd first, the CJS dirname global second).
|
|
1842
|
+
const configFile = testInfo.config.configFile;
|
|
1843
|
+
const suiteRoot = configFile ? path.dirname(configFile) : testInfo.config.rootDir;
|
|
1844
|
+
const dir = path.join(suiteRoot, HEAPS_DIR);
|
|
1845
|
+
await mkdir(dir, { recursive: true });
|
|
1846
|
+
const specBase = slug(path.basename(testInfo.file).replace(/\\.spec\\.ts$/, ""));
|
|
1847
|
+
const title = slug(testInfo.title);
|
|
1848
|
+
const filename = \`\${specBase}__\${title}__\${slug(label)}.heapsnapshot\`;
|
|
1849
|
+
const out = path.join(dir, filename);
|
|
1850
|
+
await writeFile(out, buf);
|
|
1851
|
+
await testInfo.attach(filename, { path: out, contentType: "application/octet-stream" });
|
|
1852
|
+
return out;
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
export interface HeapSnapshotResult {
|
|
1856
|
+
byteSize: number;
|
|
1857
|
+
path: string;
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
export async function takeHeapSnapshot(
|
|
1861
|
+
page: Page,
|
|
1862
|
+
label: string,
|
|
1863
|
+
testInfo: TestInfo,
|
|
1864
|
+
): Promise<HeapSnapshotResult> {
|
|
1865
|
+
const buf = await captureSnapshot(page);
|
|
1866
|
+
const outPath = await persistSnapshot(buf, label, testInfo);
|
|
1867
|
+
return { byteSize: buf.byteLength, path: outPath };
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
// Brackets a scenario with before/after heap snapshots. Snapshots are only
|
|
1871
|
+
// persisted on failure (cheap signal when something breaks) or when the
|
|
1872
|
+
// scenario name starts with "stress-" (always kept so leak diffs are
|
|
1873
|
+
// available). On a clean non-stress pass, the buffers are dropped to keep
|
|
1874
|
+
// CI artifacts small.
|
|
1875
|
+
export async function bracketScenario(
|
|
1876
|
+
page: Page,
|
|
1877
|
+
name: string,
|
|
1878
|
+
testInfo: TestInfo,
|
|
1879
|
+
fn: () => Promise<void>,
|
|
1880
|
+
): Promise<void> {
|
|
1881
|
+
const before = await captureSnapshot(page);
|
|
1882
|
+
let threw: unknown;
|
|
1883
|
+
try {
|
|
1884
|
+
await fn();
|
|
1885
|
+
} catch (err) {
|
|
1886
|
+
threw = err;
|
|
1887
|
+
}
|
|
1888
|
+
const after = await captureSnapshot(page);
|
|
1889
|
+
const failed = threw !== undefined;
|
|
1890
|
+
const isStress = name.startsWith("stress-");
|
|
1891
|
+
const keep =
|
|
1892
|
+
HEAP_MODE !== "off" && (HEAP_MODE === "all" || failed || isStress) && after.byteLength > 0;
|
|
1893
|
+
if (keep) {
|
|
1894
|
+
const bp = await persistSnapshot(before, \`\${name}-before\`, testInfo);
|
|
1895
|
+
const ap = await persistSnapshot(after, \`\${name}-after\`, testInfo);
|
|
1896
|
+
const delta = after.byteLength - before.byteLength;
|
|
1897
|
+
const sign = delta >= 0 ? "+" : "";
|
|
1898
|
+
console.log(
|
|
1899
|
+
\`[heap] \${name}: \${before.byteLength} -> \${after.byteLength} bytes (delta \${sign}\${delta}) -> \${path.relative(process.cwd(), ap)}\`,
|
|
1900
|
+
);
|
|
1901
|
+
void bp;
|
|
1902
|
+
}
|
|
1903
|
+
if (threw) throw threw;
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
export async function repeatClick(page: Page, selector: string, n: number): Promise<void> {
|
|
1907
|
+
const locator = page.locator(selector).first();
|
|
1908
|
+
for (let i = 0; i < n; i++) {
|
|
1909
|
+
await locator.click();
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
`;
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
function budgets(flat: FlatComponent[], _variants: number): string {
|
|
1916
|
+
const obj: Record<string, { mountMs: number; updateMs: number }> = {};
|
|
1917
|
+
for (const c of flat) obj[c.specName] = { mountMs: 50, updateMs: 16 };
|
|
1918
|
+
return `${JSON.stringify(obj, null, 2)}\n`;
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
function spec(c: FlatComponent, variants: number): string {
|
|
1922
|
+
// The registry key, so the mount call finds the right component when two share
|
|
1923
|
+
// a name.
|
|
1924
|
+
const name = c.specName;
|
|
1925
|
+
const propsLiteral = defaultsJson(synthesiseDefaults(c.info));
|
|
1926
|
+
const variantsLiteral = JSON.stringify(c.info.variants.slice(0, variants));
|
|
1927
|
+
const hooksLiteral = JSON.stringify(c.info.hooks);
|
|
1928
|
+
const forcedIterations = Math.max(10, variants * 10);
|
|
1929
|
+
const targetedClick = emitPlaywrightClickTarget(c.info.interactions);
|
|
1930
|
+
// Clicking a purely presentational component re-renders nothing, so waiting
|
|
1931
|
+
// for the render count to rise times out after 30s and reports a stateless
|
|
1932
|
+
// Button as broken. Only assert a rerender when the analyser saw something
|
|
1933
|
+
// that could cause one: local state, or a handler the parent supplies.
|
|
1934
|
+
const hasAnalysedClick = (c.info.interactions?.clicks ?? []).some((cl) => cl.event === "onClick");
|
|
1935
|
+
const hasForm = (c.info.interactions?.forms ?? []).length > 0;
|
|
1936
|
+
const canRerender =
|
|
1937
|
+
((c.info.state ?? []).some((st) => st.kind !== "useRef") ||
|
|
1938
|
+
(c.info.interactions?.clicks ?? []).some((cl) => cl.binding.kind === "prop") ||
|
|
1939
|
+
(c.info.hooks ?? []).some((h) => /^use(State|Reducer|SyncExternalStore|Context)$/.test(h))) &&
|
|
1940
|
+
// With no analysed click handler the scenario falls back to "the first
|
|
1941
|
+
// button on the page". In a form component that button is the SUBMIT
|
|
1942
|
+
// control, and clicking it runs an action — against a stubbed server action
|
|
1943
|
+
// in a host with no backend, which is not a render measurement.
|
|
1944
|
+
!(hasForm && !hasAnalysedClick);
|
|
1945
|
+
// No blind fallback. `page.locator("button").first()` claims the first button
|
|
1946
|
+
// is the one the analysis found a handler on — true only when there is one
|
|
1947
|
+
// button, and in VoteCard it resolved to a disabled "Cast vote" while the
|
|
1948
|
+
// handler belonged to a vote option. When nothing names the target, the
|
|
1949
|
+
// scenario skips with the reason instead of clicking whatever is first:
|
|
1950
|
+
// an empty locator forces clickSkipReason below, so this expression is only
|
|
1951
|
+
// ever emitted with a derived target in it.
|
|
1952
|
+
const clickButtonExpr = targetedClick.locator;
|
|
1953
|
+
const openDisclosure = targetedClick.prelude ? `\n ${targetedClick.prelude}` : "";
|
|
1954
|
+
|
|
1955
|
+
// Whether a click scenario can run at all is decided HERE, not at run time.
|
|
1956
|
+
// `test.skip(cond, reason)` inside a body runs every beforeEach first and then
|
|
1957
|
+
// throws, so 259 of the dogfood app's 470 profile tests were paying a full
|
|
1958
|
+
// page load to discover they had nothing to do — 38% of a 13.5-minute run.
|
|
1959
|
+
// `test.skip(title, fn)` is declarative: Playwright never runs the hooks.
|
|
1960
|
+
//
|
|
1961
|
+
// The reason moves into the title, which is a reporting gain: the list
|
|
1962
|
+
// reporter prints titles and does not print runtime skip reasons at all.
|
|
1963
|
+
const clickSkipReason = !targetedClick.locator
|
|
1964
|
+
? (targetedClick.reason ?? "no clickable target could be derived")
|
|
1965
|
+
: !canRerender
|
|
1966
|
+
? hasForm && !hasAnalysedClick
|
|
1967
|
+
? "the only clickable target is a form submit, not a render measurement"
|
|
1968
|
+
: "no state or handler prop, so a click cannot re-render this component"
|
|
1969
|
+
: undefined;
|
|
1970
|
+
|
|
1971
|
+
const clickScenario = !clickSkipReason
|
|
1972
|
+
? `test(\`\${NAME}: click rerender\`, async ({ page }, testInfo) => {
|
|
1973
|
+
await mountComponent(page, NAME, DEFAULTS);${openDisclosure}
|
|
1974
|
+
const button = ${clickButtonExpr};
|
|
1975
|
+
const hasButton = (await button.count()) > 0;
|
|
1976
|
+
test.skip(!hasButton, "component does not render a clickable target");
|
|
1977
|
+
// A control that is disabled or covered is not a profiling failure; clicking
|
|
1978
|
+
// it would just wait out the 30s actionability timeout and report the
|
|
1979
|
+
// component as broken.
|
|
1980
|
+
const clickable = hasButton && (await button.isEnabled()) && (await button.isVisible());
|
|
1981
|
+
test.skip(!clickable, "the clickable target is disabled or not visible at defaults");
|
|
1982
|
+
await bracketScenario(page, "click-rerender", testInfo, async () => {
|
|
1983
|
+
await clearScanEvents(page);
|
|
1984
|
+
const before = (await getRenderInfo(page)).renderCount[NAME]?.value ?? 0;
|
|
1985
|
+
await button.click();
|
|
1986
|
+
await page.waitForFunction(
|
|
1987
|
+
([n, b]) => {
|
|
1988
|
+
const v = window.__profile.getRenderInfo().renderCount as Record<string, { value: number } | undefined>;
|
|
1989
|
+
return (v[n as string]?.value ?? 0) > (b as number);
|
|
1990
|
+
},
|
|
1991
|
+
[NAME, before],
|
|
1992
|
+
);
|
|
1993
|
+
const events = await getScanEvents(page);
|
|
1994
|
+
if (events.length > 0) {
|
|
1995
|
+
console.warn(\`[react-scan] \${NAME} click triggered \${events.length} unnecessary rerender(s)\`);
|
|
1996
|
+
}
|
|
1997
|
+
});
|
|
1998
|
+
});`
|
|
1999
|
+
: `test.skip(\`\${NAME}: click rerender — ${titleSafe(clickSkipReason)}\`, () => {});`;
|
|
2000
|
+
const stressScenario = !clickSkipReason
|
|
2001
|
+
? `test(\`\${NAME}: stress-click-rerender x 50\`, async ({ page }, testInfo) => {
|
|
2002
|
+
await mountComponent(page, NAME, DEFAULTS);${openDisclosure}
|
|
2003
|
+
const button = ${clickButtonExpr};
|
|
2004
|
+
const hasButton = (await button.count()) > 0;
|
|
2005
|
+
test.skip(!hasButton, "component does not render a clickable target");
|
|
2006
|
+
// A control that is disabled or covered is not a profiling failure; clicking
|
|
2007
|
+
// it would just wait out the 30s actionability timeout and report the
|
|
2008
|
+
// component as broken.
|
|
2009
|
+
const clickable = hasButton && (await button.isEnabled()) && (await button.isVisible());
|
|
2010
|
+
test.skip(!clickable, "the clickable target is disabled or not visible at defaults");
|
|
2011
|
+
await bracketScenario(page, "stress-click-rerender", testInfo, async () => {
|
|
2012
|
+
const startCount = (await getRenderInfo(page)).renderCount[NAME]?.value ?? 0;
|
|
2013
|
+
// Stop when the control goes away. A "Remove" button in a list removes its
|
|
2014
|
+
// own row, so it survives as many clicks as there are rows — and clicking a
|
|
2015
|
+
// locator that no longer resolves waits out the whole timeout, reporting a
|
|
2016
|
+
// working component as broken.
|
|
2017
|
+
let __clicks = 0;
|
|
2018
|
+
for (let __i = 0; __i < 50; __i++) {
|
|
2019
|
+
if ((await button.count()) === 0 || !(await button.isEnabled())) break;
|
|
2020
|
+
await button.click();
|
|
2021
|
+
__clicks++;
|
|
2022
|
+
}
|
|
2023
|
+
test.skip(__clicks === 0, "the clickable target vanished before the first click");
|
|
2024
|
+
await page.waitForFunction(
|
|
2025
|
+
([n, b]) => {
|
|
2026
|
+
const v = window.__profile.getRenderInfo().renderCount as Record<string, { value: number } | undefined>;
|
|
2027
|
+
return (v[n as string]?.value ?? 0) > (b as number);
|
|
2028
|
+
},
|
|
2029
|
+
[NAME, startCount],
|
|
2030
|
+
{ timeout: 30_000 },
|
|
2031
|
+
);
|
|
2032
|
+
console.log(\`[\${NAME} stress] \${__clicks} click(s) landed before the target went away\`);
|
|
2033
|
+
});
|
|
2034
|
+
});`
|
|
2035
|
+
: `test.skip(\`\${NAME}: stress-click-rerender x 50 — ${titleSafe(clickSkipReason)}\`, () => {});`;
|
|
2036
|
+
|
|
2037
|
+
// Same reasoning as the click scenarios: VARIANTS is emitted as a literal, so
|
|
2038
|
+
// "are there any" is answerable here rather than after a page load.
|
|
2039
|
+
const variantScenario =
|
|
2040
|
+
variantsLiteral === "[]"
|
|
2041
|
+
? `test.skip(\`\${NAME}: variant matrix — no union-of-literal props to vary\`, () => {});`
|
|
2042
|
+
: `test(\`\${NAME}: variant matrix\`, async ({ page }, testInfo) => {
|
|
2043
|
+
await bracketScenario(page, "variant-matrix", testInfo, async () => {
|
|
2044
|
+
await mountComponent(page, NAME, DEFAULTS);
|
|
2045
|
+
const samples: number[] = [];
|
|
2046
|
+
for (const v of VARIANTS) {
|
|
2047
|
+
await rerenderComponent(page, NAME, v);
|
|
2048
|
+
const info = await getRenderInfo(page);
|
|
2049
|
+
const updates = info.renderTime[NAME]?.updates ?? [];
|
|
2050
|
+
const last = updates[updates.length - 1];
|
|
2051
|
+
if (typeof last === "number") samples.push(last);
|
|
2052
|
+
}
|
|
2053
|
+
const stats = summarize(samples);
|
|
2054
|
+
console.log(\`[\${NAME} variants] \${JSON.stringify(stats)}\`);
|
|
2055
|
+
const budget = (budgets as Record<string, { updateMs: number } | undefined>)[NAME]?.updateMs ?? 16;
|
|
2056
|
+
if (stats.p95 > budget) {
|
|
2057
|
+
console.warn(\`[budget] \${NAME} p95 \${stats.p95.toFixed(2)}ms > \${budget}ms\`);
|
|
2058
|
+
}
|
|
2059
|
+
});
|
|
2060
|
+
});`;
|
|
2061
|
+
|
|
2062
|
+
return `import { test, expect } from "@playwright/test";
|
|
2063
|
+
import budgets from "../budgets.json" with { type: "json" };
|
|
2064
|
+
import {
|
|
2065
|
+
mountComponent,
|
|
2066
|
+
rerenderComponent,
|
|
2067
|
+
rerenderMany,
|
|
2068
|
+
unmount,
|
|
2069
|
+
getRenderInfo,
|
|
2070
|
+
getScanEvents,
|
|
2071
|
+
clearScanEvents,
|
|
2072
|
+
summarize,
|
|
2073
|
+
} from "./_helpers";
|
|
2074
|
+
import { bracketScenario, repeatClick, takeHeapSnapshot } from "./_heap";
|
|
2075
|
+
|
|
2076
|
+
const NAME = ${JSON.stringify(name)};
|
|
2077
|
+
const DEFAULTS = ${propsLiteral} as Record<string, unknown>;
|
|
2078
|
+
const VARIANTS = ${variantsLiteral} as Array<Record<string, unknown>>;
|
|
2079
|
+
const HOOKS = ${hooksLiteral} as string[];
|
|
2080
|
+
|
|
2081
|
+
test.beforeEach(async ({ page }) => {
|
|
2082
|
+
await page.goto("/");
|
|
2083
|
+
await page.waitForFunction(() => typeof window.__profile?.mount === "function");
|
|
2084
|
+
await clearScanEvents(page);
|
|
2085
|
+
});
|
|
2086
|
+
|
|
2087
|
+
test.afterEach(async ({ page }) => {
|
|
2088
|
+
await unmount(page);
|
|
2089
|
+
});
|
|
2090
|
+
|
|
2091
|
+
test(\`\${NAME}: mounts at defaults\`, async ({ page }) => {
|
|
2092
|
+
await mountComponent(page, NAME, DEFAULTS);
|
|
2093
|
+
const info = await getRenderInfo(page);
|
|
2094
|
+
const count = info.renderCount[NAME]?.value ?? 0;
|
|
2095
|
+
expect(count).toBeGreaterThanOrEqual(1);
|
|
2096
|
+
const mountMs = info.renderTime[NAME]?.mount ?? 0;
|
|
2097
|
+
const budget = (budgets as Record<string, { mountMs: number } | undefined>)[NAME]?.mountMs ?? 50;
|
|
2098
|
+
if (mountMs > budget) {
|
|
2099
|
+
console.warn(\`[budget] \${NAME} mount \${mountMs.toFixed(2)}ms > \${budget}ms\`);
|
|
2100
|
+
}
|
|
2101
|
+
});
|
|
2102
|
+
|
|
2103
|
+
${variantScenario}
|
|
2104
|
+
|
|
2105
|
+
${clickScenario}
|
|
2106
|
+
|
|
2107
|
+
test(\`\${NAME}: forced rerender x \${${forcedIterations}}\`, async ({ page }, testInfo) => {
|
|
2108
|
+
await mountComponent(page, NAME, DEFAULTS);
|
|
2109
|
+
await bracketScenario(page, "forced-rerender", testInfo, async () => {
|
|
2110
|
+
const pool: Array<Record<string, unknown>> =
|
|
2111
|
+
VARIANTS.length > 0 ? VARIANTS : [DEFAULTS];
|
|
2112
|
+
await rerenderMany(page, NAME, pool, ${forcedIterations});
|
|
2113
|
+
const info = await getRenderInfo(page);
|
|
2114
|
+
const updates = info.renderTime[NAME]?.updates ?? [];
|
|
2115
|
+
const stats = summarize(updates as number[]);
|
|
2116
|
+
console.log(\`[\${NAME} forced] \${JSON.stringify(stats)}\`);
|
|
2117
|
+
// Smoke: at least one update was timed.
|
|
2118
|
+
expect(stats.n).toBeGreaterThan(0);
|
|
2119
|
+
// HOOKS list is informational — surface it in the test output for context.
|
|
2120
|
+
if (HOOKS.length > 0) console.log(\`[\${NAME} hooks] \${HOOKS.join(", ")}\`);
|
|
2121
|
+
});
|
|
2122
|
+
});
|
|
2123
|
+
|
|
2124
|
+
// Repeat-N click stress: baseline snapshot, 50 clicks, final snapshot. Both
|
|
2125
|
+
// snapshots are always attached so a leak diff can be opened in Chrome DevTools
|
|
2126
|
+
// (Memory tab → load → Comparison view).
|
|
2127
|
+
${stressScenario}
|
|
2128
|
+
|
|
2129
|
+
// takeHeapSnapshot is exported so future analyses (e.g. byte-delta asserts) can
|
|
2130
|
+
// call it directly without bracketScenario.
|
|
2131
|
+
void takeHeapSnapshot;
|
|
2132
|
+
`;
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
/**
|
|
2136
|
+
* The profile suite's README, in the vocabulary of the mode that generated it.
|
|
2137
|
+
*
|
|
2138
|
+
* Standalone, the suite is its own package and `bun run test` is its script.
|
|
2139
|
+
* In Next.js mode there is no suite package.json — it is skipped in renderSuite
|
|
2140
|
+
* — so the same block told the user to run `bun run test`, which walks up to the
|
|
2141
|
+
* APPLICATION's package.json and runs its real test suite. That reads as
|
|
2142
|
+
* success, which is why it lasted.
|
|
2143
|
+
*
|
|
2144
|
+
* Only the perf scripts are named. This file IS the perf-harness output, so
|
|
2145
|
+
* those are the only ones guaranteed to have been declared by the run that
|
|
2146
|
+
* wrote it; naming flawless:e2e or flawless:load here would reintroduce the
|
|
2147
|
+
* defect one task over.
|
|
2148
|
+
*/
|
|
2149
|
+
function readme(
|
|
2150
|
+
suiteName: string,
|
|
2151
|
+
componentNames: string[],
|
|
2152
|
+
excluded: ExcludedComponent[] = [],
|
|
2153
|
+
opts: { nextjsHost?: boolean; report?: boolean } = {},
|
|
2154
|
+
): string {
|
|
2155
|
+
const nextjs = Boolean(opts.nextjsHost);
|
|
2156
|
+
const perfScript = nextjs ? NEXT_SCRIPT.perf : SUITE_SCRIPT.test;
|
|
2157
|
+
// Commented lines aligned on the longest command in THIS block, rather than a
|
|
2158
|
+
// fixed width — `bun run flawless:report` is wider than the standalone names
|
|
2159
|
+
// the constant was picked for, and overflowed it.
|
|
2160
|
+
const runLines: Array<[string, string]> = nextjs
|
|
2161
|
+
? [[`bun run ${perfScript}`, "headless"]]
|
|
2162
|
+
: [
|
|
2163
|
+
[`bun run ${perfScript}`, "headless"],
|
|
2164
|
+
[`bun run ${SUITE_SCRIPT.headed}`, "open Chromium"],
|
|
2165
|
+
];
|
|
2166
|
+
if (opts.report) {
|
|
2167
|
+
runLines.push([
|
|
2168
|
+
`bun run ${nextjs ? NEXT_SCRIPT.report : SUITE_SCRIPT.report}`,
|
|
2169
|
+
"aggregate the last run into report.md",
|
|
2170
|
+
]);
|
|
2171
|
+
}
|
|
2172
|
+
const width = Math.max(...runLines.map(([cmd]) => cmd.length));
|
|
2173
|
+
const commented = runLines.map(([cmd, note]) => `${cmd.padEnd(width)} # ${note}`).join("\n");
|
|
2174
|
+
// No `bun install` in Next.js mode: the host project owns node_modules, and
|
|
2175
|
+
// there is no suite package.json here to install from.
|
|
2176
|
+
const runBlock = nextjs ? commented : `bun install\n${commented}`;
|
|
2177
|
+
// The React-version note is about the suite's OWN package.json, which only
|
|
2178
|
+
// exists standalone. In Next.js mode the host project owns node_modules and
|
|
2179
|
+
// there is no `bun install` here to pin anything.
|
|
2180
|
+
const reactNote = nextjs
|
|
2181
|
+
? "- Components import their own React from the host project's `node_modules`, which is the only copy in play."
|
|
2182
|
+
: "- Components import their own React from the parent project's `node_modules`. The suite's own React must match the major version; `bun install` here pins the version your app declares (detected at generation; `^18.3.0` when nothing is detectable).";
|
|
2183
|
+
const componentList =
|
|
2184
|
+
componentNames.length > 1
|
|
2185
|
+
? `\n\nComponents in this suite: ${componentNames.map((n) => `\`${n}\``).join(", ")}.\n`
|
|
2186
|
+
: "";
|
|
2187
|
+
// Silence here would read as "everything is covered". Each line says what was
|
|
2188
|
+
// left out of the browser host and why.
|
|
2189
|
+
const excludedList =
|
|
2190
|
+
excluded.length > 0
|
|
2191
|
+
? `\n## Not in this host (${excluded.length})\n\n${excluded
|
|
2192
|
+
.map((e) => `- \`${e.name}\` — ${e.reason}`)
|
|
2193
|
+
.join("\n")}\n`
|
|
2194
|
+
: "";
|
|
2195
|
+
return `# ${suiteName}.profile${componentList}${excludedList}
|
|
2196
|
+
|
|
2197
|
+
Auto-generated by [flawless-react](https://github.com/lariocpt/flawless-react).
|
|
2198
|
+
|
|
2199
|
+
This README is not yours — it is regenerated by \`--force\`, so that a later
|
|
2200
|
+
version of flawless-react can tell you something it worked out after your suite
|
|
2201
|
+
was generated. Put your own notes in \`host/main.tsx\` or \`budgets.json\`: those
|
|
2202
|
+
two are written once and never overwritten, and everything else here is derived
|
|
2203
|
+
and will be rebuilt.
|
|
2204
|
+
|
|
2205
|
+
## Run
|
|
2206
|
+
|
|
2207
|
+
\`\`\`bash
|
|
2208
|
+
${runBlock}
|
|
2209
|
+
\`\`\`
|
|
2210
|
+
|
|
2211
|
+
## What's measured
|
|
2212
|
+
|
|
2213
|
+
- **Mount time** and **update times** per component, via React's built-in \`<Profiler>\`.
|
|
2214
|
+
- **Unnecessary rerenders** flagged by \`react-scan\`.
|
|
2215
|
+
- Wall-clock distribution (min / avg / p50 / p95 / max) over a forced-rerender loop.
|
|
2216
|
+
|
|
2217
|
+
## Customise
|
|
2218
|
+
|
|
2219
|
+
- \`budgets.json\` — tighten per-component mount/update budgets. Tests warn (not fail) on breach by default; switch the \`console.warn\` to \`expect(...).toBeLessThan(...)\` to make them hard.
|
|
2220
|
+
- \`profile/_helpers.ts\` — add scenarios; e.g. mock data sources before \`mountComponent\`.
|
|
2221
|
+
- \`host/main.tsx\` — hand-register extra components in the \`ALL\` spread. The tables in \`host/registry.ts\` are regenerated on every run, so entries added there are lost.
|
|
2222
|
+
|
|
2223
|
+
## Notes
|
|
2224
|
+
|
|
2225
|
+
${reactNote}
|
|
2226
|
+
- The Playwright \`webServer\` boots \`bun host/server.ts\` automatically; no manual server step.
|
|
2227
|
+
`;
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
/**
|
|
2231
|
+
* A .mjs file, so it is plain JavaScript — no type annotations.
|
|
2232
|
+
*
|
|
2233
|
+
* It carried `const sections: string[] = […]` for as long as it existed, which
|
|
2234
|
+
* node refuses outright ("Missing initializer in const declaration"), so
|
|
2235
|
+
* `bun run report` had never once worked. Nothing noticed because
|
|
2236
|
+
* tests/emittedSyntax.test.ts filters on /\.tsx?$/ and this file is neither.
|
|
2237
|
+
* tests/emittedExecution.test.ts now runs it under node.
|
|
2238
|
+
*/
|
|
2239
|
+
function reportScript(reportScriptName: string, perfScriptName: string): string {
|
|
2240
|
+
return `#!/usr/bin/env node
|
|
2241
|
+
// Aggregates the latest Playwright run's stdout into a markdown summary at report.md.
|
|
2242
|
+
// Re-run with: bun run ${reportScriptName}
|
|
2243
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2244
|
+
import path from "node:path";
|
|
2245
|
+
import { fileURLToPath } from "node:url";
|
|
2246
|
+
|
|
2247
|
+
// Anchored to this file, not to the working directory. Standalone the script is
|
|
2248
|
+
// run from the suite root and a relative path happened to work; in Next.js mode
|
|
2249
|
+
// the script is declared in the APP's package.json and runs from the app root,
|
|
2250
|
+
// where "test-results/..." resolves somewhere that does not exist.
|
|
2251
|
+
const SUITE_DIR = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
2252
|
+
|
|
2253
|
+
// The JSON reporter's output, not playwright-report/index.html. The HTML report
|
|
2254
|
+
// keeps every stdout line inside a base64 zip blob under
|
|
2255
|
+
// window.playwrightReportBase64, so the regex below matched nothing there and
|
|
2256
|
+
// this script wrote a report with no sections in it.
|
|
2257
|
+
const RESULTS = path.join(SUITE_DIR, "test-results", "results.json");
|
|
2258
|
+
|
|
2259
|
+
let report;
|
|
2260
|
+
try {
|
|
2261
|
+
report = JSON.parse(readFileSync(RESULTS, "utf8"));
|
|
2262
|
+
} catch {
|
|
2263
|
+
console.error("no " + RESULTS + " — run \`bun run ${perfScriptName}\` first");
|
|
2264
|
+
process.exit(1);
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
// Suites nest arbitrarily deep (file, describe, project), and the lines we want
|
|
2268
|
+
// are on the individual test results.
|
|
2269
|
+
function collectStdout(suites) {
|
|
2270
|
+
let text = "";
|
|
2271
|
+
for (const suite of suites ?? []) {
|
|
2272
|
+
for (const spec of suite.specs ?? []) {
|
|
2273
|
+
for (const t of spec.tests ?? []) {
|
|
2274
|
+
for (const r of t.results ?? []) {
|
|
2275
|
+
for (const chunk of r.stdout ?? []) {
|
|
2276
|
+
if (typeof chunk === "string") text += chunk;
|
|
2277
|
+
else if (chunk.text !== undefined) text += chunk.text;
|
|
2278
|
+
else if (chunk.buffer !== undefined) {
|
|
2279
|
+
text += Buffer.from(chunk.buffer, "base64").toString("utf8");
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
text += collectStdout(suite.suites);
|
|
2286
|
+
}
|
|
2287
|
+
return text;
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
const sections = ["# Profile report", ""];
|
|
2291
|
+
const stats = [...collectStdout(report.suites).matchAll(/\\[(.+?) (variants|forced)\\] (\\{.+?\\})/g)];
|
|
2292
|
+
for (const m of stats) {
|
|
2293
|
+
sections.push(\`## \${m[1]} (\${m[2]})\`);
|
|
2294
|
+
sections.push("\\n\`\`\`json");
|
|
2295
|
+
sections.push(m[3] ?? "{}");
|
|
2296
|
+
sections.push("\`\`\`\\n");
|
|
2297
|
+
}
|
|
2298
|
+
writeFileSync(path.join(SUITE_DIR, "report.md"), sections.join("\\n"));
|
|
2299
|
+
console.log("wrote report.md");
|
|
2300
|
+
`;
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
function mockServer(): string {
|
|
2304
|
+
return `import { routes } from "./routes";
|
|
2305
|
+
|
|
2306
|
+
const PORT = Number(process.env.MOCK_PORT ?? 5174);
|
|
2307
|
+
|
|
2308
|
+
// Convert an OpenAPI path template ("/users/{id}") into a RegExp matcher.
|
|
2309
|
+
// Captures the parameter names so handlers can read them via the second arg
|
|
2310
|
+
// even though we don't currently expose them (the response body is static).
|
|
2311
|
+
function compile(pattern: string): { re: RegExp; params: string[] } {
|
|
2312
|
+
const params: string[] = [];
|
|
2313
|
+
const reSrc =
|
|
2314
|
+
"^" +
|
|
2315
|
+
pattern.replace(/{([^}]+)}/g, (_, name) => {
|
|
2316
|
+
params.push(name);
|
|
2317
|
+
return "([^/]+)";
|
|
2318
|
+
}) +
|
|
2319
|
+
"$";
|
|
2320
|
+
return { re: new RegExp(reSrc), params };
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
const compiled = routes.map((r) => ({ ...r, ...compile(r.path) }));
|
|
2324
|
+
|
|
2325
|
+
Bun.serve({
|
|
2326
|
+
port: PORT,
|
|
2327
|
+
hostname: "127.0.0.1",
|
|
2328
|
+
fetch(req) {
|
|
2329
|
+
const url = new URL(req.url);
|
|
2330
|
+
const method = req.method.toUpperCase();
|
|
2331
|
+
// Root health endpoint — Playwright's webServer rejects 404s when probing
|
|
2332
|
+
// readiness, so we return a 200 with a route count for "/".
|
|
2333
|
+
if (url.pathname === "/") {
|
|
2334
|
+
return new Response(JSON.stringify({ ok: true, routes: compiled.length }), {
|
|
2335
|
+
status: 200,
|
|
2336
|
+
headers: { "content-type": "application/json" },
|
|
2337
|
+
});
|
|
2338
|
+
}
|
|
2339
|
+
for (const r of compiled) {
|
|
2340
|
+
if (r.method !== method) continue;
|
|
2341
|
+
if (!r.re.test(url.pathname)) continue;
|
|
2342
|
+
return new Response(r.body === null ? null : JSON.stringify(r.body), {
|
|
2343
|
+
status: r.status,
|
|
2344
|
+
headers: r.body === null ? {} : { "content-type": "application/json" },
|
|
2345
|
+
});
|
|
2346
|
+
}
|
|
2347
|
+
return new Response(JSON.stringify({ error: "not mocked", method, path: url.pathname }), {
|
|
2348
|
+
status: 404,
|
|
2349
|
+
headers: { "content-type": "application/json" },
|
|
2350
|
+
});
|
|
2351
|
+
},
|
|
2352
|
+
});
|
|
2353
|
+
|
|
2354
|
+
console.log("mock listening on http://127.0.0.1:" + PORT);
|
|
2355
|
+
`;
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
function mockRoutesFile(routes: MockRoute[]): string {
|
|
2359
|
+
const literal = JSON.stringify(routes, null, 2);
|
|
2360
|
+
return `// Routes derived at scaffold time from the OpenAPI spec passed to --api.
|
|
2361
|
+
// Each entry pairs a path template + method with a pre-computed response body
|
|
2362
|
+
// (either the spec's first example, or a schema-synthesised default).
|
|
2363
|
+
//
|
|
2364
|
+
// Edit freely — there is no regeneration step that will overwrite this file.
|
|
2365
|
+
|
|
2366
|
+
export interface MockRoute {
|
|
2367
|
+
method: string;
|
|
2368
|
+
path: string;
|
|
2369
|
+
status: number;
|
|
2370
|
+
body: unknown;
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
export const routes: MockRoute[] = ${literal};
|
|
2374
|
+
`;
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
export function _exportedPropsLiteral(p: PropsShape): string {
|
|
2378
|
+
return JSON.stringify(p);
|
|
2379
|
+
}
|