@lariocpt/flawless-react 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/.agents/skills/flawless-a11y/SKILL.md +113 -0
  2. package/.agents/skills/flawless-api/SKILL.md +159 -0
  3. package/.agents/skills/flawless-api-fixtures/SKILL.md +144 -0
  4. package/.agents/skills/flawless-e2e/SKILL.md +155 -0
  5. package/.agents/skills/flawless-expo/SKILL.md +283 -0
  6. package/.agents/skills/flawless-findings/SKILL.md +116 -0
  7. package/.agents/skills/flawless-load/SKILL.md +112 -0
  8. package/.agents/skills/flawless-nextjs/SKILL.md +198 -0
  9. package/.agents/skills/flawless-perf-harness/SKILL.md +158 -0
  10. package/.agents/skills/flawless-perf-static/SKILL.md +131 -0
  11. package/.agents/skills/flawless-react/SKILL.md +403 -0
  12. package/.agents/skills/flawless-react-rules/SKILL.md +96 -0
  13. package/.agents/skills/flawless-regen/SKILL.md +135 -0
  14. package/.agents/skills/flawless-unit-browser/SKILL.md +178 -0
  15. package/LICENSE +21 -0
  16. package/README.md +608 -2
  17. package/bin/flawless-react +4 -0
  18. package/package.json +67 -4
  19. package/scripts/postinstall.d.mts +46 -0
  20. package/scripts/postinstall.mjs +127 -0
  21. package/src/analyse/authShims.ts +220 -0
  22. package/src/analyse/bootability.ts +257 -0
  23. package/src/analyse/childBridge.ts +302 -0
  24. package/src/analyse/childrenIntoSlot.ts +66 -0
  25. package/src/analyse/compoundFamily.ts +100 -0
  26. package/src/analyse/dataExports.ts +200 -0
  27. package/src/analyse/extractChildAttrs.ts +112 -0
  28. package/src/analyse/extractDataDeps.ts +857 -0
  29. package/src/analyse/extractDisclosures.ts +186 -0
  30. package/src/analyse/extractFetchCalls.ts +81 -0
  31. package/src/analyse/extractFlows.ts +239 -0
  32. package/src/analyse/extractGuards.ts +270 -0
  33. package/src/analyse/extractHooks.ts +237 -0
  34. package/src/analyse/extractInteractions.ts +1421 -0
  35. package/src/analyse/extractLogging.ts +45 -0
  36. package/src/analyse/extractNativeA11y.ts +138 -0
  37. package/src/analyse/extractPredicates.ts +784 -0
  38. package/src/analyse/extractPropUsage.ts +162 -0
  39. package/src/analyse/extractProps.ts +1212 -0
  40. package/src/analyse/extractRenderMode.ts +165 -0
  41. package/src/analyse/extractRootTag.ts +117 -0
  42. package/src/analyse/extractState.ts +361 -0
  43. package/src/analyse/extractStaticText.ts +212 -0
  44. package/src/analyse/extractVariants.ts +186 -0
  45. package/src/analyse/harvestApiEndpoints.ts +284 -0
  46. package/src/analyse/hostProps.ts +185 -0
  47. package/src/analyse/hostTag.ts +72 -0
  48. package/src/analyse/htmlContentModel.ts +94 -0
  49. package/src/analyse/index.ts +744 -0
  50. package/src/analyse/mountPathCalls.ts +442 -0
  51. package/src/analyse/nativeContentModel.ts +129 -0
  52. package/src/analyse/nativeDialect.ts +83 -0
  53. package/src/analyse/nativeProps.ts +339 -0
  54. package/src/analyse/ownReturns.ts +31 -0
  55. package/src/analyse/parseFile.ts +919 -0
  56. package/src/analyse/pos.ts +8 -0
  57. package/src/analyse/providerWraps.ts +1977 -0
  58. package/src/analyse/pureModule.ts +88 -0
  59. package/src/analyse/reexportTable.ts +148 -0
  60. package/src/analyse/refs.ts +195 -0
  61. package/src/analyse/renderPath.ts +441 -0
  62. package/src/analyse/resolveDepReturns.ts +370 -0
  63. package/src/analyse/resolveIndexKeys.ts +98 -0
  64. package/src/analyse/resolveModule.ts +431 -0
  65. package/src/analyse/resolveTagRootTags.ts +103 -0
  66. package/src/analyse/serverActionReturns.ts +213 -0
  67. package/src/analyse/sourceCache.ts +263 -0
  68. package/src/analyse/spreadSiblings.ts +98 -0
  69. package/src/analyse/walkChildren.ts +98 -0
  70. package/src/analyse/zodSchema.ts +424 -0
  71. package/src/bin.ts +23 -0
  72. package/src/changed.ts +221 -0
  73. package/src/cli.ts +560 -0
  74. package/src/cliFlags.ts +281 -0
  75. package/src/core.ts +1229 -0
  76. package/src/docLine.ts +122 -0
  77. package/src/emitPaths.ts +51 -0
  78. package/src/expo/detect.ts +184 -0
  79. package/src/expo/refusal.ts +62 -0
  80. package/src/expo/routes.ts +145 -0
  81. package/src/index.ts +95 -0
  82. package/src/mcp/analysisTools.ts +720 -0
  83. package/src/mcp/infoTools.ts +135 -0
  84. package/src/mcp/protocol.ts +130 -0
  85. package/src/mcp/run.ts +269 -0
  86. package/src/mcp/runTools.ts +362 -0
  87. package/src/mcp/server.ts +279 -0
  88. package/src/mcp/spec.ts +86 -0
  89. package/src/mcp/support.ts +268 -0
  90. package/src/mcp/tools.ts +75 -0
  91. package/src/nextjs/apiRoutes.ts +116 -0
  92. package/src/nextjs/detect.ts +397 -0
  93. package/src/nextjs/emit.ts +106 -0
  94. package/src/nextjs/liveSpec.ts +79 -0
  95. package/src/nextjs/mswHandlers.ts +55 -0
  96. package/src/nextjs/providerWrapper.ts +603 -0
  97. package/src/nextjs/routes.ts +86 -0
  98. package/src/nextjs/swagger.ts +32 -0
  99. package/src/packageJson.ts +414 -0
  100. package/src/platform.ts +18 -0
  101. package/src/prompt.ts +132 -0
  102. package/src/reactCompiler.ts +165 -0
  103. package/src/reporters/findings.ts +156 -0
  104. package/src/reporters/report.ts +463 -0
  105. package/src/runner/runTests.ts +402 -0
  106. package/src/scaffold/adoptStyle.ts +181 -0
  107. package/src/scaffold/index.ts +253 -0
  108. package/src/scaffold/openapi.ts +183 -0
  109. package/src/scaffold/templates.ts +2379 -0
  110. package/src/scaffold-tests/index.ts +463 -0
  111. package/src/scaffold-tests/mockValues.ts +208 -0
  112. package/src/scaffold-tests/mocks.ts +794 -0
  113. package/src/scaffold-tests/nativeIndex.ts +52 -0
  114. package/src/scaffold-tests/nativeTemplates.ts +1528 -0
  115. package/src/scaffold-tests/postDispatch.ts +152 -0
  116. package/src/scaffold-tests/registry.ts +443 -0
  117. package/src/scaffold-tests/selectors.ts +493 -0
  118. package/src/scaffold-tests/selectorsNative.ts +273 -0
  119. package/src/scaffold-tests/stamp.ts +79 -0
  120. package/src/scaffold-tests/templates.ts +3578 -0
  121. package/src/skills/cli.ts +248 -0
  122. package/src/skills/compat.ts +166 -0
  123. package/src/skills/data.ts +192 -0
  124. package/src/skills/md.d.ts +7 -0
  125. package/src/skills/menu.ts +55 -0
  126. package/src/skills/spec.ts +227 -0
  127. package/src/suiteScripts.ts +110 -0
  128. package/src/tasks/a11yNative.ts +281 -0
  129. package/src/tasks/a11yScan.ts +67 -0
  130. package/src/tasks/biomeRunner.ts +301 -0
  131. package/src/tasks/biomeSweep.ts +166 -0
  132. package/src/tasks/e2eExpo.ts +436 -0
  133. package/src/tasks/e2eRoutes.ts +756 -0
  134. package/src/tasks/eslintReactRules.ts +67 -0
  135. package/src/tasks/index.ts +24 -0
  136. package/src/tasks/loadExpo.ts +273 -0
  137. package/src/tasks/loadPipes.ts +750 -0
  138. package/src/tasks/perfHarness.ts +145 -0
  139. package/src/tasks/staticPerfAnalysis.ts +510 -0
  140. package/src/tasks/types.ts +1 -0
  141. package/src/tasks/unitAndBrowser.ts +462 -0
  142. package/src/types.ts +1419 -0
  143. package/src/version.ts +12 -0
@@ -0,0 +1,756 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { mkdir, readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { outDirRel, relativeToEmitted, underOutDir } from "../emitPaths.ts";
5
+ import { devScriptPort, findRouterRoot, pageFileToRoute } from "../nextjs/routes.ts";
6
+ import { emitPlaywrightDispatch, emitPlaywrightSelector } from "../scaffold-tests/selectors.ts";
7
+ import type { ComponentTree, RunContext, Task, TaskResult, TodoTally } from "../types.ts";
8
+ import { runExpoE2e } from "./e2eExpo.ts";
9
+
10
+ /**
11
+ * Route-level Playwright e2e against the target's real `next dev` server.
12
+ * MSW cannot intercept RSC data access (direct DB calls), so v1 runs against
13
+ * the app with its real backing services — seed the DB first. The emitted
14
+ * config honors FLAWLESS_E2E_BASEURL so the whole suite can be pointed at a
15
+ * crust validation proxy for contract-checked runs.
16
+ */
17
+ export const e2eRoutes: Task = {
18
+ id: "e2e",
19
+ label: "Generate route-level Playwright e2e specs",
20
+ description: "One spec per analysed app-router page: error-free load + basic interactions",
21
+ needsTree: true,
22
+ async run(ctx: RunContext): Promise<TaskResult> {
23
+ // The wave-3/wave-5 shape: one TaskId, a sibling engine per platform —
24
+ // expo-router routes are leaf files, not page.tsx directories, so this is
25
+ // a different route model, not a flag on this one.
26
+ if (ctx.expo) return runExpoE2e(ctx);
27
+ if (!ctx.nextjs) {
28
+ return {
29
+ id: "e2e",
30
+ ok: false,
31
+ outputs: [],
32
+ findings: [],
33
+ summary: "e2e task requires --nextjs mode",
34
+ error: "not a Next.js run",
35
+ };
36
+ }
37
+ const routerRoot = findRouterRoot(ctx.nextjs.root);
38
+ const pages: Array<{
39
+ tree: ComponentTree;
40
+ route: NonNullable<ReturnType<typeof pageFileToRoute>>;
41
+ }> = [];
42
+ for (const tree of ctx.trees) {
43
+ const route = pageFileToRoute(tree.entry.filePath, ctx.nextjs.router, routerRoot);
44
+ if (route) pages.push({ tree, route });
45
+ }
46
+ // Fallback: if the analysed entry set contains no app-router pages (the
47
+ // default Next.js mode defaults to components/**/ *.tsx), discover page files
48
+ // directly so e2e still emits specs. Minimal trees are sufficient — the
49
+ // e2e spec only needs the file path for route mapping and an empty interaction
50
+ // harvest.
51
+ if (pages.length === 0) {
52
+ const pageGlob = new Bun.Glob("**/page.{tsx,jsx,ts,js}");
53
+ for await (const file of pageGlob.scan({ cwd: routerRoot, absolute: true })) {
54
+ const route = pageFileToRoute(file, ctx.nextjs.router, routerRoot);
55
+ if (!route) continue;
56
+ const tree: ComponentTree = {
57
+ entry: {
58
+ componentName: "Page",
59
+ filePath: file,
60
+ importSpecifier: "",
61
+ propsShape: {},
62
+ variants: [],
63
+ hooks: [],
64
+ unresolvedProps: [],
65
+ },
66
+ children: [],
67
+ };
68
+ pages.push({ tree, route });
69
+ }
70
+ }
71
+
72
+ const e2eDir = path.join(ctx.outDir, "e2e");
73
+ await mkdir(e2eDir, { recursive: true });
74
+ const written: string[] = [];
75
+ const skipped: string[] = [];
76
+
77
+ const dev = await resolveDevLaunch(ctx.nextjs.root);
78
+ const port = devScriptPort(dev.script);
79
+
80
+ const configPath = path.join(e2eDir, "playwright.config.ts");
81
+ await Bun.write(configPath, e2eConfig(relativeToEmitted(e2eDir, dev.cwd), dev.command, port));
82
+ written.push(configPath);
83
+
84
+ const setupPath = path.join(e2eDir, "global-setup.ts");
85
+ if (!existsSync(setupPath)) {
86
+ await Bun.write(setupPath, globalSetupStub());
87
+ written.push(setupPath);
88
+ }
89
+ // B-12. Every route spec now asserts it landed on the route it asked for,
90
+ // so an unauthenticated suite fails loudly instead of testing the login
91
+ // screen. That is the correctness half; this is the VISIBILITY half — the
92
+ // REPORT should say the auth hook is still a stub before anyone runs it.
93
+ //
94
+ // Byte-identity against the stub, not a grep for the TODO marker: the
95
+ // marker wording is presentation (types.ts says so), and a user who filled
96
+ // the file in while leaving the comment would be reported as unfilled
97
+ // forever. Identity clears the moment a human edits the file, and stays
98
+ // honest across re-runs because the stub is never overwritten.
99
+ const authUnfilled = readFileSync(setupPath, "utf8") === globalSetupStub();
100
+
101
+ const paramsJsonPath = path.join(e2eDir, "params.json");
102
+ const wroteParams = await writeParams(paramsJsonPath, pages, ctx.force);
103
+ if (wroteParams) written.push(paramsJsonPath);
104
+
105
+ const paramsHelperPath = path.join(e2eDir, "params.ts");
106
+ await Bun.write(paramsHelperPath, paramsHelper());
107
+ written.push(paramsHelperPath);
108
+
109
+ // Refreshed by --force, like load/README.md beside it: generated prose with
110
+ // nothing of yours in it, and freezing it means an existing suite never
111
+ // learns what a later version worked out.
112
+ const readmePath = path.join(e2eDir, "README.md");
113
+ if (!existsSync(readmePath) || ctx.force) {
114
+ await Bun.write(readmePath, e2eReadme(port));
115
+ written.push(readmePath);
116
+ }
117
+
118
+ for (const { tree, route } of pages) {
119
+ const specPath = path.join(e2eDir, `${route.specStem}.spec.ts`);
120
+ if (existsSync(specPath) && !ctx.force) {
121
+ skipped.push(specPath);
122
+ continue;
123
+ }
124
+ await Bun.write(specPath, routeSpec(tree, route, outDirRel(ctx.outDir, ctx.nextjs.root)));
125
+ written.push(specPath);
126
+ }
127
+
128
+ const skippedNote =
129
+ skipped.length > 0 ? `; kept ${skipped.length} existing spec(s) (--force to regenerate)` : "";
130
+ // One tally for the run, not one per spec: the stub being unfilled is a
131
+ // single fact about the suite, and counting it per route would report the
132
+ // same hand-work N times.
133
+ const handWork: TodoTally[] =
134
+ authUnfilled && pages.length > 0
135
+ ? [{ kind: "e2e-auth-unfilled", count: 1, exemplar: setupPath }]
136
+ : [];
137
+ return {
138
+ id: "e2e",
139
+ ok: true,
140
+ outputs: written,
141
+ findings: [],
142
+ handWork,
143
+ summary:
144
+ pages.length === 0
145
+ ? "no analysed entry is an app-router page.tsx — config emitted, no specs"
146
+ : `e2e specs for ${pages.length} route(s): ${pages.map((p) => p.route.urlPath).join(", ")}${skippedNote}`,
147
+ };
148
+ },
149
+ };
150
+
151
+ /** The dev launcher, per where the app actually lives. */
152
+ const DEV_SCRIPT_NAMES = ["dev", "dev:web", "dev:www"] as const;
153
+
154
+ interface DevLaunch {
155
+ /** Command Playwright's webServer runs. */
156
+ command: string;
157
+ /** The package directory that owns the script (cwd for webServer). */
158
+ cwd: string;
159
+ /** The script body — source of the port. */
160
+ script: string | undefined;
161
+ }
162
+
163
+ async function scriptsOf(dir: string): Promise<Record<string, string> | undefined> {
164
+ try {
165
+ const pkg = JSON.parse(await readFile(path.join(dir, "package.json"), "utf8")) as {
166
+ scripts?: Record<string, string>;
167
+ };
168
+ return pkg.scripts;
169
+ } catch {
170
+ return undefined;
171
+ }
172
+ }
173
+
174
+ async function isWorkspaceRoot(dir: string): Promise<boolean> {
175
+ if (existsSync(path.join(dir, "pnpm-workspace.yaml"))) return true;
176
+ try {
177
+ const pkg = JSON.parse(await readFile(path.join(dir, "package.json"), "utf8")) as {
178
+ workspaces?: unknown;
179
+ };
180
+ return Array.isArray(pkg.workspaces);
181
+ } catch {
182
+ return false;
183
+ }
184
+ }
185
+
186
+ /**
187
+ * The package manager the repo declares — `bun run dev` against a pnpm repo
188
+ * with `verifyDepsBeforeRun` gated scripts fails on the wrong runner, and the
189
+ * runner must match the lockfile the dependencies were installed from.
190
+ */
191
+ function packageManagerFor(dir: string): string {
192
+ try {
193
+ const pkg = JSON.parse(readFileSync(path.join(dir, "package.json"), "utf8")) as {
194
+ packageManager?: unknown;
195
+ };
196
+ const m =
197
+ typeof pkg.packageManager === "string"
198
+ ? pkg.packageManager.match(/^(npm|pnpm|yarn|bun)/)
199
+ : null;
200
+ const tool = m?.[1];
201
+ if (tool) return tool;
202
+ } catch {
203
+ // no package.json: fall through to lockfiles
204
+ }
205
+ if (existsSync(path.join(dir, "bun.lockb")) || existsSync(path.join(dir, "bun.lock"))) {
206
+ return "bun";
207
+ }
208
+ if (existsSync(path.join(dir, "pnpm-lock.yaml"))) return "pnpm";
209
+ if (existsSync(path.join(dir, "yarn.lock"))) return "yarn";
210
+ if (existsSync(path.join(dir, "package-lock.json"))) return "npm";
211
+ return "bun";
212
+ }
213
+
214
+ /**
215
+ * Apps usually launch themselves; a monorepo app package is often scriptless
216
+ * with the launcher at the workspace root (`dev:web` through turbo). Walk up —
217
+ * passing through plain directories (`apps/`) — but consult only a workspace
218
+ * root, and stop there: an unrelated package.json further out (a developer's
219
+ * dotfiles package) must never hijack the launch.
220
+ */
221
+ async function resolveDevLaunch(nextRoot: string): Promise<DevLaunch> {
222
+ const mine = await scriptsOf(nextRoot);
223
+ const local = pickDevScript(mine, nextRoot);
224
+ if (local) return local;
225
+ let dir = nextRoot;
226
+ for (let hop = 0; hop < 4; hop++) {
227
+ const up = path.dirname(dir);
228
+ if (up === dir) break;
229
+ dir = up;
230
+ if (!(await isWorkspaceRoot(dir))) continue;
231
+ return (
232
+ (await pickDevScript(await scriptsOf(dir), dir)) ?? {
233
+ command: "bun run dev",
234
+ cwd: nextRoot,
235
+ script: undefined,
236
+ }
237
+ );
238
+ }
239
+ return { command: "bun run dev", cwd: nextRoot, script: undefined };
240
+ }
241
+
242
+ function pickDevScript(
243
+ scripts: Record<string, string> | undefined,
244
+ dir: string,
245
+ ): DevLaunch | undefined {
246
+ if (!scripts) return undefined;
247
+ for (const name of DEV_SCRIPT_NAMES) {
248
+ const body = scripts[name];
249
+ if (body) {
250
+ return { command: `${packageManagerFor(dir)} run ${name}`, cwd: dir, script: body };
251
+ }
252
+ }
253
+ return undefined;
254
+ }
255
+
256
+ /**
257
+ * @param toDevCwd Path from this config's own directory to the package that
258
+ * owns the dev launcher (the app root, unless only a workspace root has a
259
+ * dev script). Relative, deliberately: the config is committed, and an
260
+ * absolute path in it names the machine that generated it. The same repo
261
+ * lives under a different root on other machines, so the checked-in file
262
+ * would both fail to resolve there and rewrite itself on every regeneration.
263
+ */
264
+ function e2eConfig(toDevCwd: string, command: string, port: number): string {
265
+ return `import path from "node:path";
266
+ import { defineConfig, devices } from "@playwright/test";
267
+
268
+ // Runs against the target app's real dev server (RSC pages hit the real DB —
269
+ // seed it first). Override the base URL to point at a crust validation proxy
270
+ // for contract-checked runs: FLAWLESS_E2E_BASEURL=http://localhost:4747.
271
+ const baseURL = process.env.FLAWLESS_E2E_BASEURL ?? "http://localhost:${port}";
272
+ // global-setup.ts always (re)writes this file — empty until you wire up login.
273
+ // __dirname on purpose, with a known limit: in a CJS package (every Next.js
274
+ // default) playwright transpiles this config and __dirname exists; a
275
+ // "type":"module" app loads it as ESM where it does not — and playwright
276
+ // offers no loader-neutral anchor a config can use for use.storageState
277
+ // (relative storageState resolves against the CWD, probed, and import.meta
278
+ // breaks the CJS transpile). Standalone suites never emit this file.
279
+ const authState = path.join(__dirname, ".auth.json");
280
+
281
+ export default defineConfig({
282
+ testDir: ".",
283
+ testMatch: "*.spec.ts",
284
+ // Keep traces/screenshots inside flawless/ instead of littering the project root.
285
+ outputDir: path.join(__dirname, "test-results"),
286
+ workers: 1,
287
+ reporter: [["list"]],
288
+ globalSetup: "./global-setup.ts",
289
+ use: {
290
+ baseURL,
291
+ storageState: authState,
292
+ trace: "retain-on-failure",
293
+ },
294
+ // Readiness means "2xx or 3xx from this url" — a 404 is a server that is not
295
+ // up, and the app's own middleware can make the root answer exactly that
296
+ // (Clerk's auth.protect() with placeholder keys 404s every route it guards).
297
+ // Then the run dies here, before a single spec, on "Timed out waiting
298
+ // 120000ms from config.webServer". Credentials in global-setup.ts fix it
299
+ // properly; to drive a server you started yourself, export
300
+ // FLAWLESS_E2E_BASEURL (which drops this whole block) or point this url at a
301
+ // route that answers 2xx/3xx.
302
+ webServer: process.env.FLAWLESS_E2E_BASEURL
303
+ ? undefined
304
+ : {
305
+ command: ${JSON.stringify(command)},
306
+ cwd: path.resolve(__dirname, ${JSON.stringify(toDevCwd)}),
307
+ url: "http://localhost:${port}",
308
+ reuseExistingServer: true,
309
+ timeout: 120_000,
310
+ },
311
+ projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
312
+ });
313
+ `;
314
+ }
315
+
316
+ /**
317
+ * Exported for tests ONLY, to pin the bytes.
318
+ *
319
+ * The B-12 hand-work tally fires on byte-identity with this string, and this
320
+ * file is never overwritten once emitted — so editing the text here retires
321
+ * the tally for every already-generated, never-filled suite. That coupling is
322
+ * invisible from either side; tests/tasks.test.ts makes it fail loudly.
323
+ */
324
+ export function globalSetupStubForTest(): string {
325
+ return globalSetupStub();
326
+ }
327
+
328
+ function globalSetupStub(): string {
329
+ return `import { writeFileSync } from "node:fs";
330
+ import path from "node:path";
331
+
332
+ // TODO(flawless): authenticate here if your routes need a session — request
333
+ // your login endpoint with a playwright request context and persist its
334
+ // storageState to .auth.json (the config already points at it). Example:
335
+ //
336
+ // const ctx = await request.newContext({ baseURL });
337
+ // await ctx.post("/api/auth/login", { data: { email, password } });
338
+ // await ctx.storageState({ path: authState });
339
+ //
340
+ // flawless-react never overwrites this file.
341
+ export default async function globalSetup(config: { rootDir: string }): Promise<void> {
342
+ // rootDir is the CONFIG's directory, handed in at runtime — the one anchor
343
+ // that exists under both module loaders (__dirname is CJS-only and this
344
+ // file is loaded as ESM in a "type":"module" app).
345
+ const authState = path.join(config.rootDir, ".auth.json");
346
+ // Empty state so the config's storageState reference always resolves.
347
+ writeFileSync(authState, JSON.stringify({ cookies: [], origins: [] }));
348
+ }
349
+ `;
350
+ }
351
+
352
+ function e2eReadme(port: number): string {
353
+ return `# flawless-react e2e
354
+
355
+ Route-level Playwright specs against the real \`next dev\` server (port ${port}).
356
+
357
+ - Seed the database first (whatever your repo uses — e.g. \`bun run db:reset\`).
358
+ - Dynamic segments read env vars: \`FLAWLESS_E2E_ID=<seeded-id> bun run flawless:e2e\`.
359
+ - Auth: fill in \`global-setup.ts\` (never overwritten). Until you do, every
360
+ spec fails on its landing assertion rather than testing your login page —
361
+ an unauthenticated route redirects there and it answers 200.
362
+ - A route that redirects ON PURPOSE: record where it really ends up in
363
+ \`params.json\` under \`landing\`, e.g. \`{"landing": {"/dash": "/dash/overview"}}\`.
364
+ Not in the spec — \`--force\` rewrites specs and leaves this file alone.
365
+
366
+ ## Contract-checked runs (crust)
367
+
368
+ Put the crust validation proxy in front of the dev server, run the suite
369
+ through it, then assert zero response violations:
370
+
371
+ \`\`\`bash
372
+ crust -c 'mock-server --swagger openapi.json --port 4747 --proxy http://localhost:${port}' &
373
+ FLAWLESS_E2E_BASEURL=http://localhost:4747 bun run flawless:e2e
374
+ crust -c 'GET :4747/__crust/violations | (v => v.violations.filter(x => x.direction === "response")) | assert (a => a.length === 0)'
375
+ \`\`\`
376
+ `;
377
+ }
378
+
379
+ function routeSpec(
380
+ tree: ComponentTree,
381
+ route: NonNullable<ReturnType<typeof pageFileToRoute>>,
382
+ rel: string,
383
+ ): string {
384
+ // A parameterised route reads its ids from the shared fixture rather than from
385
+ // a const per spec. The old form defaulted to "REPLACE_WITH_SEEDED_ID", which
386
+ // navigated to a URL that 404s — so an unfilled fixture was reported as an
387
+ // application bug, thirty times over.
388
+ const dynamic = route.params.length > 0;
389
+ // expectedLanding is imported by EVERY spec, dynamic or not: the redirect
390
+ // assertion needs its override, and a static route is exactly as likely to
391
+ // redirect on purpose as a parameterised one.
392
+ const paramLines = dynamic
393
+ ? `import { expectedLanding, routeUrl } from "./params";\n\nconst ROUTE_TEMPLATE = ${JSON.stringify(route.urlPath)};`
394
+ : `import { expectedLanding } from "./params";`;
395
+ // `as string` after the skip below: test.skip() ends the test at runtime but
396
+ // TypeScript cannot see that, and every navigation would otherwise be an error.
397
+ const urlExpr = dynamic ? `routeUrl(ROUTE_TEMPLATE) as string` : JSON.stringify(route.urlPath);
398
+ const skipLine = dynamic
399
+ ? `\n test.skip(\n ROUTE === undefined,\n \`no id fixture for ${route.urlPath} — fill ${underOutDir(rel, "e2e/params.json")}\`,\n );`
400
+ : "";
401
+
402
+ // Up to 3 interactions harvested from the page's component tree.
403
+ const clicks = collectClicks(tree).slice(0, 3);
404
+ const interactionBody = clicks
405
+ .map((c) => {
406
+ const locator = emitPlaywrightSelector(c.click);
407
+ // Nothing in the source identifies the element — a locator built from a
408
+ // component name matches nothing and the step would silently no-op.
409
+ if (!locator) return "";
410
+ // A change event on something that holds no text (a delegating <form>)
411
+ // has no drivable target here either.
412
+ const dispatch = emitPlaywrightDispatch(c.click.event, locator, { tag: c.click.tag });
413
+ if (!dispatch) return "";
414
+ return ` // from <${c.component}> analysis
415
+ {
416
+ const target = ${locator};
417
+ if (await target.count() > 0 && await target.first().isVisible()) {
418
+ ${dispatch.replace(locator, `${locator}.first()`)};
419
+ await page.waitForTimeout(150);
420
+ }
421
+ }`;
422
+ })
423
+ .filter((s) => s !== "")
424
+ .join("\n");
425
+
426
+ return `import { test, expect, type Page } from "@playwright/test";
427
+
428
+ ${paramLines}
429
+ const ROUTE = ${urlExpr};
430
+ const ROUTE_KEY = ${JSON.stringify(route.urlPath)};
431
+
432
+ /** Trailing slashes are not a difference: "/items/" and "/items" are one route. */
433
+ function normalisePath(p: string): string {
434
+ return p.replace(/\\/+$/, "") || "/";
435
+ }
436
+
437
+ /**
438
+ * Assert the browser is on the route this spec asked for.
439
+ *
440
+ * Without it the spec's other claims — a response came back, status < 500, a
441
+ * clean console — are all true of a LOGIN SCREEN: an unauthenticated request to
442
+ * a protected page redirects, and the login page answers 200.
443
+ *
444
+ * Scope, stated precisely because the failure mode here is an overclaim: this
445
+ * catches redirects that CHANGE THE URL and have landed by the time it runs —
446
+ * a server 3xx, and a client-side navigation that completes before the caller's
447
+ * await does. It does NOT catch a middleware rewrite that serves the login page
448
+ * at the requested path, a 401 body, or a redirect that fires later than the
449
+ * caller waited. Call it after whatever settle the caller needs.
450
+ */
451
+ async function assertOnRoute(page: Page): Promise<void> {
452
+ const landed = normalisePath(new URL(page.url()).pathname);
453
+ const wanted = normalisePath(
454
+ expectedLanding(ROUTE_KEY, new URL(ROUTE, "http://flawless.invalid").pathname),
455
+ );
456
+ expect(
457
+ landed,
458
+ \`navigation left the route: asked for \\\`\${wanted}\\\`, landed on \\\`\${landed}\\\`. \` +
459
+ \`If this is an auth wall, authenticate in ${underOutDir(rel, "e2e/global-setup.ts")} — \` +
460
+ \`an unauthenticated request redirects to a login page, which answers 200, so the \` +
461
+ \`status check above cannot see it. If the redirect is what this route is SUPPOSED \` +
462
+ \`to do, record the destination in ${underOutDir(rel, "e2e/params.json")} under \` +
463
+ \`"landing": { "\${ROUTE_KEY}": "\${landed}" } — that file survives --force, this spec does not.\`,
464
+ ).toBe(wanted);
465
+ }
466
+
467
+ // The route rides on the describe: playwright matches --grep against the
468
+ // space-joined full title path, so a --changed run's "\\bGET <route> " pattern
469
+ // (grepForRoutes) must select EVERY test in this file — a route embedded in
470
+ // only one test's own title left the others invisible to a narrowed run.
471
+ test.describe("GET ${route.urlPath}", () => {
472
+ test("renders without console or page errors", async ({ page }) => {${skipLine}
473
+ const errors: string[] = [];
474
+ page.on("pageerror", (e) => errors.push(e.message));
475
+ page.on("console", (m) => {
476
+ if (m.type() === "error") errors.push(m.text());
477
+ });
478
+ const resp = await page.goto(ROUTE);
479
+ expect(resp, "navigation returned a response").toBeTruthy();
480
+ expect(resp?.status(), "no 5xx").toBeLessThan(500);
481
+ await page.waitForLoadState("networkidle");
482
+ await assertOnRoute(page);
483
+ expect(errors).toEqual([]);
484
+ });
485
+ ${
486
+ clicks.length > 0
487
+ ? `
488
+ test("basic interactions from analysis stay error-free", async ({ page }) => {${skipLine}
489
+ const errors: string[] = [];
490
+ page.on("pageerror", (e) => errors.push(e.message));
491
+ await page.goto(ROUTE);
492
+ await page.waitForLoadState("networkidle");
493
+ // Same check as above, and it carries more weight here: every harvested
494
+ // click is guarded by count()/isVisible(), so on a login screen this test
495
+ // does NOTHING and then reports an empty error list — a green claim about
496
+ // work that never happened.
497
+ await assertOnRoute(page);
498
+ ${interactionBody}
499
+ expect(errors).toEqual([]);
500
+ });
501
+ `
502
+ : ""
503
+ }});
504
+ `;
505
+ }
506
+
507
+ interface HarvestedClick {
508
+ component: string;
509
+ click: NonNullable<ComponentTree["entry"]["interactions"]>["clicks"][number];
510
+ }
511
+
512
+ function collectClicks(tree: ComponentTree): HarvestedClick[] {
513
+ const out: HarvestedClick[] = [];
514
+ const walk = (t: ComponentTree): void => {
515
+ for (const c of t.entry.interactions?.clicks ?? []) {
516
+ if (c.event === "onClick") out.push({ component: t.entry.componentName, click: c });
517
+ }
518
+ for (const child of t.children) walk(child);
519
+ };
520
+ walk(tree);
521
+ return out;
522
+ }
523
+
524
+ /** Key order is part of the bytes: two runs must produce the same file. */
525
+ function sortKeys<T>(obj: Record<string, T>): Record<string, T> {
526
+ const out: Record<string, T> = {};
527
+ for (const k of Object.keys(obj).sort()) out[k] = obj[k] as T;
528
+ return out;
529
+ }
530
+
531
+ /**
532
+ * The shared id fixture, merged rather than overwritten.
533
+ *
534
+ * A value here was typed by a human against a seeded database and cannot be
535
+ * recovered from the tool's inputs, so nothing this function does may lose one.
536
+ * That rules out rebuilding the file from a single run's view of the project:
537
+ *
538
+ * - `defaults` also holds the load task's API-route ids, which no page route
539
+ * claims. Dropping the blank ones each run left writeParams and
540
+ * ensureParamDefaults deleting and re-adding the same key forever, both
541
+ * reporting a write on every run of the suite's life.
542
+ * - `routes` rows were rebuilt from the pages in THIS run, so pointing
543
+ * flawless at one page deleted the ids you had filled for every other one.
544
+ * A narrower glob is a smaller view of the project, not an instruction to
545
+ * discard the rest of it.
546
+ *
547
+ * So it is additive by default. `prune` — set from `--force` — is the one door
548
+ * that tidies, and even then it only removes entries that hold nothing: a
549
+ * filled id survives every flag, the same rule routes.pipes follows for a pipe
550
+ * you commented out yourself.
551
+ *
552
+ * Two levels because most params are global — one seeded building — but some
553
+ * routes need a consistent PAIR: a poll that actually belongs to that building.
554
+ * Per-route overrides express that without forcing it everywhere.
555
+ */
556
+ export async function writeParams(
557
+ file: string,
558
+ pages: Array<{ route: { urlPath: string; params: string[] } }>,
559
+ prune = false,
560
+ ): Promise<boolean> {
561
+ interface ParamsFile {
562
+ defaults: Record<string, string>;
563
+ routes: Record<string, Record<string, string>>;
564
+ /**
565
+ * Route template -> the path that route is SUPPOSED to end on, for pages
566
+ * that redirect on purpose (a default sub-tab, a locale landing). Never
567
+ * written by the tool, only carried: the emitted spec's landing assertion
568
+ * reads it, and this file survives --force while the spec does not.
569
+ */
570
+ landing: Record<string, string>;
571
+ }
572
+ let existing: ParamsFile = { defaults: {}, routes: {}, landing: {} };
573
+ if (existsSync(file)) {
574
+ try {
575
+ const parsed = JSON.parse(await readFile(file, "utf8")) as Partial<ParamsFile>;
576
+ existing = {
577
+ defaults: parsed.defaults ?? {},
578
+ routes: parsed.routes ?? {},
579
+ landing: parsed.landing ?? {},
580
+ };
581
+ } catch {
582
+ // A hand-edited file with a syntax error must not be silently replaced.
583
+ throw new Error(`${file} is not valid JSON — fix or delete it and re-run`);
584
+ }
585
+ }
586
+
587
+ const next: ParamsFile = { defaults: {}, routes: {}, landing: {} };
588
+ const allParams = new Set<string>();
589
+ for (const p of pages) for (const name of p.route.params) allParams.add(name);
590
+ // Every id already in the file, blank ones included.
591
+ //
592
+ // The load task adds slots for API-route params the page router never sees —
593
+ // `buildingId` beside the page's `id` — and rebuilding `defaults` from pages
594
+ // plus only the FILLED existing ones deleted those blanks on every run. The
595
+ // two writers then fought forever: writeParams dropped the slot,
596
+ // ensureParamDefaults put it back, both reporting the file as written on
597
+ // every run for the lifetime of the suite. `--tasks e2e` on its own just
598
+ // deleted it, and the id the user was about to fill was gone.
599
+ //
600
+ // Blanks are only noise once nothing needs them, and this function cannot
601
+ // know that: the task that needs them runs after it. So it keeps them, and
602
+ // `--prune` (--force) is the one door that tidies.
603
+ for (const name of Object.keys(existing.defaults)) {
604
+ if (prune && (existing.defaults[name] ?? "") === "" && !allParams.has(name)) continue;
605
+ allParams.add(name);
606
+ }
607
+ for (const name of [...allParams].sort()) next.defaults[name] = existing.defaults[name] ?? "";
608
+
609
+ // Rows this run did not look at are carried over untouched. Rebuilding
610
+ // `routes` from `pages` alone meant running flawless on one page deleted the
611
+ // ids you had filled for every other one — not a blank slot, a value you
612
+ // typed. A narrower glob is a smaller view of the project, not a decision to
613
+ // discard the rest of it.
614
+ for (const [urlPath, row] of Object.entries(existing.routes)) {
615
+ const seen = pages.some((p) => p.route.urlPath === urlPath);
616
+ if (seen) continue;
617
+ // Under --prune a row is dropped only when it holds nothing. A filled id
618
+ // survives every flag: the tool does not delete what a person typed.
619
+ if (prune && Object.values(row).every((v) => v === "")) continue;
620
+ next.routes[urlPath] = row;
621
+ }
622
+ for (const p of pages) {
623
+ if (p.route.params.length === 0) continue;
624
+ const prior = existing.routes[p.route.urlPath] ?? {};
625
+ const row: Record<string, string> = {};
626
+ for (const name of [...p.route.params].sort()) row[name] = prior[name] ?? "";
627
+ // An id this route no longer declares, but which someone filled in, is kept
628
+ // for the same reason.
629
+ for (const [name, value] of Object.entries(prior)) {
630
+ if (!(name in row) && (value !== "" || !prune)) row[name] = value;
631
+ }
632
+ next.routes[p.route.urlPath] = sortKeys(row);
633
+ }
634
+ next.routes = sortKeys(next.routes);
635
+ // Carried verbatim, never invented and never pruned. The tool has no way to
636
+ // know a route redirects on purpose — that is the user telling it something —
637
+ // so an entry here is hand-work, and hand-work in this file is the one thing
638
+ // --force must not touch. Written even when empty, so the knob is visible in
639
+ // the file rather than only in the README.
640
+ next.landing = sortKeys(existing.landing);
641
+
642
+ const body = `${JSON.stringify(next, null, 2)}\n`;
643
+ if (existsSync(file) && (await readFile(file, "utf8")) === body) return false;
644
+ await Bun.write(file, body);
645
+ return true;
646
+ }
647
+
648
+ /**
649
+ * Add ids another task needs to `defaults`, without touching anything else.
650
+ *
651
+ * The load pipes cover API routes as well as pages, and an API route's segment
652
+ * is often named differently from the page's — `/api/buildings/[buildingId]`
653
+ * beside `/dashboard/buildings/[id]`. params.json only knew about the page
654
+ * ones, so `$LOAD_BUILDINGID` expanded to nothing, the URL had an empty segment,
655
+ * and 500 requests to a malformed path were reported as the route failing.
656
+ *
657
+ * Additive only: existing values, route overrides and key order are preserved,
658
+ * so this is safe to call after writeParams in the same run and idempotent
659
+ * across runs.
660
+ */
661
+ export async function ensureParamDefaults(file: string, names: string[]): Promise<boolean> {
662
+ if (names.length === 0) return false;
663
+ interface ParamsFile {
664
+ defaults: Record<string, string>;
665
+ routes: Record<string, Record<string, string>>;
666
+ }
667
+ let parsed: ParamsFile = { defaults: {}, routes: {} };
668
+ if (existsSync(file)) {
669
+ try {
670
+ parsed = JSON.parse(await readFile(file, "utf8")) as ParamsFile;
671
+ } catch {
672
+ return false;
673
+ }
674
+ } else {
675
+ // Created rather than skipped. `--tasks load` on its own never runs the e2e
676
+ // task, so nothing else writes this file — while every piece of prose that
677
+ // run emits (the gate comment on each pipe, load/README.md, env.sh) tells
678
+ // the reader to fill in ids in a file that was not there. The pipes stayed
679
+ // commented out and the instruction for turning them on pointed at nothing.
680
+ await mkdir(path.dirname(file), { recursive: true });
681
+ }
682
+ const defaults = { ...(parsed.defaults ?? {}) };
683
+ let added = false;
684
+ for (const n of names) {
685
+ if (!(n in defaults)) {
686
+ defaults[n] = "";
687
+ added = true;
688
+ }
689
+ }
690
+ if (!added) return false;
691
+ const sorted: Record<string, string> = {};
692
+ for (const k of Object.keys(defaults).sort()) sorted[k] = defaults[k] as string;
693
+ await Bun.write(file, `${JSON.stringify({ ...parsed, defaults: sorted }, null, 2)}\n`);
694
+ return true;
695
+ }
696
+
697
+ /** Shared with the Expo e2e sibling (e2eExpo.ts): the params machinery is the
698
+ * platform-free part — both routers emit the same bracket templates. */
699
+ export function paramsHelper(): string {
700
+ return `import params from "./params.json" with { type: "json" };
701
+
702
+ /**
703
+ * Ids for parameterised routes, resolved in this order:
704
+ *
705
+ * 1. FLAWLESS_E2E_<PARAM> per-param environment override
706
+ * 2. params.json routes[<route>][<p>] when this route needs a consistent set
707
+ * 3. params.json defaults[<p>] the usual case: one seeded record
708
+ * 4. FLAWLESS_E2E_ID legacy single-id escape hatch
709
+ *
710
+ * undefined when nothing resolves — the spec skips with a reason rather than
711
+ * navigating to a URL that 404s and blaming the app for it.
712
+ */
713
+ type ParamsFile = {
714
+ defaults: Record<string, string>;
715
+ routes: Record<string, Record<string, string>>;
716
+ /** Route template -> the path that route is SUPPOSED to end on. */
717
+ landing?: Record<string, string>;
718
+ };
719
+
720
+ const FIXTURE = params as ParamsFile;
721
+
722
+ export function param(route: string, name: string): string | undefined {
723
+ const env = process.env[\`FLAWLESS_E2E_\${name.toUpperCase()}\`];
724
+ if (env) return env;
725
+ const perRoute = FIXTURE.routes[route]?.[name];
726
+ if (perRoute) return perRoute;
727
+ const fallback = FIXTURE.defaults[name];
728
+ if (fallback) return fallback;
729
+ return process.env.FLAWLESS_E2E_ID || undefined;
730
+ }
731
+
732
+ /**
733
+ * Where this route is expected to end up, which is normally the route itself.
734
+ *
735
+ * A page that deliberately redirects — a default sub-tab, a locale landing —
736
+ * would otherwise fail the landing assertion forever. Record the real
737
+ * destination here rather than editing the spec: specs are regenerated by
738
+ * --force, this file is not.
739
+ */
740
+ export function expectedLanding(route: string, fallback: string): string {
741
+ return FIXTURE.landing?.[route] ?? fallback;
742
+ }
743
+
744
+ /** The template with every [param] / [...param] filled in, or undefined. */
745
+ export function routeUrl(template: string): string | undefined {
746
+ let url = template;
747
+ for (const match of template.matchAll(/\\[(?:\\.\\.\\.)?([^\\]]+)\\]/g)) {
748
+ const name = match[1] as string;
749
+ const value = param(template, name);
750
+ if (!value) return undefined;
751
+ url = url.replace(match[0], encodeURIComponent(value));
752
+ }
753
+ return url;
754
+ }
755
+ `;
756
+ }