@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.
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 +611 -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,436 @@
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 { allocateRouteStems, type ExpoRoute, expoFileToRoute } from "../expo/routes.ts";
6
+ import { emitPlaywrightDispatch, emitPlaywrightSelector } from "../scaffold-tests/selectors.ts";
7
+ import type { ComponentTree, ExpoContext, RunContext, TaskResult, TodoTally } from "../types.ts";
8
+ import { paramsHelper, writeParams } from "./e2eRoutes.ts";
9
+
10
+ /**
11
+ * Route-level Playwright e2e for an expo-router app, against `expo start --web`
12
+ * — B-16 wave 6, the sibling of e2eRoutes.ts (one TaskId, one engine per
13
+ * platform, the wave-3/wave-5 shape).
14
+ *
15
+ * The design leans on facts measured against the real stack on 2026-09-12
16
+ * (docs/archive/2026-09-expo-probes.md):
17
+ *
18
+ * - Metro under SDK 54's `web.output: "static"` serves PER-ROUTE rendered
19
+ * HTML with real status semantics — an unknown path answers 404, a dynamic
20
+ * deep link 200 — so the status assertion keeps its teeth on a dev server.
21
+ * - On web the app authenticates with a COOKIE against the API origin
22
+ * (`credentials: "include"`), so Playwright's storageState is mechanically
23
+ * the right vehicle; there is no localStorage token to inject.
24
+ * - A dead session has two faces, and the specs catch both: a route that
25
+ * client-redirects to the login screen fails the landing assertion, and a
26
+ * wall rendered IN PLACE at the same URL fails the console assertion (the
27
+ * API's 401s land there) and the copy assertion (the screen's own text
28
+ * never renders).
29
+ *
30
+ * What the specs deliberately do NOT assert: anything about styling (NativeWind
31
+ * runs under Metro here, but the DOM is react-native-web's atomic classes), and
32
+ * server 3xx redirects, which a SPA dev server does not produce.
33
+ */
34
+
35
+ export async function runExpoE2e(ctx: RunContext): Promise<TaskResult> {
36
+ const expo = ctx.expo as ExpoContext;
37
+ if (expo.routerRoot === undefined) {
38
+ return {
39
+ id: "e2e",
40
+ ok: false,
41
+ outputs: [],
42
+ findings: [],
43
+ summary: "e2e under --expo needs expo-router — no router, no routes to test",
44
+ error:
45
+ "expo-router is not a dependency of this app, so there are no URL-addressable " +
46
+ "routes for a Playwright spec to visit. The e2e task tests routes; an Expo app " +
47
+ "without expo-router has none.",
48
+ };
49
+ }
50
+
51
+ const routed: Array<{ tree: ComponentTree; route: ExpoRoute }> = [];
52
+ for (const tree of ctx.trees) {
53
+ const route = expoFileToRoute(tree.entry.filePath, expo.routerRoot);
54
+ if (route) routed.push({ tree, route });
55
+ }
56
+ // Stems allocated over the FULL list: `(a)/x` and `(b)/x` are different
57
+ // routes that both stem to "x" because groups vanish — a per-route stem
58
+ // would silently overwrite one spec with the other.
59
+ const stems = allocateRouteStems(routed.map((r) => r.route));
60
+
61
+ const e2eDir = path.join(ctx.outDir, "e2e");
62
+ await mkdir(e2eDir, { recursive: true });
63
+ const written: string[] = [];
64
+ const skipped: string[] = [];
65
+
66
+ const webCommand = await readWebScript(expo.root);
67
+
68
+ const configPath = path.join(e2eDir, "playwright.config.ts");
69
+ await Bun.write(
70
+ configPath,
71
+ expoE2eConfig(relativeToEmitted(e2eDir, expo.root), expo.webPort, webCommand),
72
+ );
73
+ written.push(configPath);
74
+
75
+ const setupPath = path.join(e2eDir, "global-setup.ts");
76
+ if (!existsSync(setupPath)) {
77
+ await Bun.write(setupPath, expoGlobalSetupStub());
78
+ written.push(setupPath);
79
+ }
80
+ // B-12's visibility half, unchanged from the web task: byte-identity against
81
+ // the stub, so the REPORT says the auth hook is a stub until a human edits it.
82
+ const authUnfilled = readFileSync(setupPath, "utf8") === expoGlobalSetupStub();
83
+
84
+ const paramsJsonPath = path.join(e2eDir, "params.json");
85
+ const wroteParams = await writeParams(paramsJsonPath, routed, ctx.force);
86
+ if (wroteParams) written.push(paramsJsonPath);
87
+
88
+ const paramsHelperPath = path.join(e2eDir, "params.ts");
89
+ await Bun.write(paramsHelperPath, paramsHelper());
90
+ written.push(paramsHelperPath);
91
+
92
+ const readmePath = path.join(e2eDir, "README.md");
93
+ if (!existsSync(readmePath) || ctx.force) {
94
+ await Bun.write(readmePath, expoE2eReadme(expo.webPort));
95
+ written.push(readmePath);
96
+ }
97
+
98
+ const rel = outDirRel(ctx.outDir, expo.root);
99
+ for (const { tree, route } of routed) {
100
+ const stem = stems.get(route) as string;
101
+ const specPath = path.join(e2eDir, `${stem}.spec.ts`);
102
+ if (existsSync(specPath) && !ctx.force) {
103
+ skipped.push(specPath);
104
+ continue;
105
+ }
106
+ await Bun.write(specPath, expoRouteSpec(tree, route, rel));
107
+ written.push(specPath);
108
+ }
109
+
110
+ const skippedNote =
111
+ skipped.length > 0 ? `; kept ${skipped.length} existing spec(s) (--force to regenerate)` : "";
112
+ const handWork: TodoTally[] =
113
+ authUnfilled && routed.length > 0
114
+ ? [{ kind: "e2e-auth-unfilled", count: 1, exemplar: setupPath }]
115
+ : [];
116
+ return {
117
+ id: "e2e",
118
+ ok: true,
119
+ outputs: written,
120
+ findings: [],
121
+ handWork,
122
+ summary:
123
+ routed.length === 0
124
+ ? "no analysed entry is an expo-router route — config emitted, no specs"
125
+ : `e2e specs for ${routed.length} route(s): ${routed.map((r) => r.route.urlPath).join(", ")}${skippedNote}`,
126
+ };
127
+ }
128
+
129
+ async function readWebScript(expoRoot: string): Promise<string> {
130
+ try {
131
+ const pkg = JSON.parse(await readFile(path.join(expoRoot, "package.json"), "utf8")) as {
132
+ scripts?: Record<string, string>;
133
+ };
134
+ // The app's own spelling when it has one, so env plumbing the script does
135
+ // (dotenv wrappers, EXPO_PUBLIC_* exports) rides along.
136
+ if (pkg.scripts?.web) return "bun run web";
137
+ } catch {
138
+ // fall through to the direct invocation
139
+ }
140
+ return "bunx expo start --web";
141
+ }
142
+
143
+ function expoE2eConfig(toExpoRoot: string, port: number, webCommand: string): string {
144
+ return `import path from "node:path";
145
+ import { defineConfig, devices } from "@playwright/test";
146
+
147
+ // Runs against \`expo start --web\` (Metro, port ${port}). Two servers matter and
148
+ // this config boots only ONE of them:
149
+ //
150
+ // - Metro serves the UI. Booted below unless FLAWLESS_E2E_BASEURL points at a
151
+ // running instance (or a crust validation proxy in front of one).
152
+ // - Your API is a PREREQUISITE, not something this config can know how to
153
+ // start. The app reaches it through the origin baked into the bundle at
154
+ // Metro start time (EXPO_PUBLIC_* vars are inlined), so set
155
+ // EXPO_PUBLIC_API_URL before running — an unset value falls back to
156
+ // whatever the source hardcodes, which on one dogfooded LAN was a
157
+ // PRODUCTION container. global-setup.ts is where to fail loudly.
158
+ const baseURL = process.env.FLAWLESS_E2E_BASEURL ?? "http://localhost:${port}";
159
+ // global-setup.ts always (re)writes this file — empty until you wire up login.
160
+ // __dirname on purpose, with the same known limit as the Next.js flavour: fine
161
+ // under the CJS transpile, absent in a "type":"module" app.
162
+ const authState = path.join(__dirname, ".auth.json");
163
+
164
+ export default defineConfig({
165
+ testDir: ".",
166
+ testMatch: "*.spec.ts",
167
+ outputDir: path.join(__dirname, "test-results"),
168
+ workers: 1,
169
+ reporter: [["list"]],
170
+ globalSetup: "./global-setup.ts",
171
+ use: {
172
+ baseURL,
173
+ storageState: authState,
174
+ trace: "retain-on-failure",
175
+ // Metro bundles a route the first time something asks for it, and the
176
+ // first navigation of a cold run pays that build. 30s (the default) is a
177
+ // flake at exactly one spec per run; this is not.
178
+ navigationTimeout: 90_000,
179
+ },
180
+ webServer: process.env.FLAWLESS_E2E_BASEURL
181
+ ? undefined
182
+ : {
183
+ command: ${JSON.stringify(webCommand)},
184
+ cwd: path.resolve(__dirname, ${JSON.stringify(toExpoRoot)}),
185
+ url: "http://localhost:${port}",
186
+ reuseExistingServer: true,
187
+ // Cold Metro start + first bundle, not a dev-server ping.
188
+ timeout: 180_000,
189
+ },
190
+ projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
191
+ });
192
+ `;
193
+ }
194
+
195
+ /**
196
+ * Exported for tests ONLY, to pin the bytes — same contract as the Next.js
197
+ * stub: the hand-work tally fires on byte-identity, the file is never
198
+ * overwritten, so editing this text retires the tally for every generated,
199
+ * never-filled suite. tests/e2eExpo.test.ts makes that loud.
200
+ */
201
+ export function expoGlobalSetupStubForTest(): string {
202
+ return expoGlobalSetupStub();
203
+ }
204
+
205
+ function expoGlobalSetupStub(): string {
206
+ return `import { writeFileSync } from "node:fs";
207
+ import path from "node:path";
208
+
209
+ // TODO(flawless): authenticate here if your routes need a session. On Expo web
210
+ // the app talks to its API with credentials:"include", so the session is a
211
+ // COOKIE on the API origin — a request-context login captures it and
212
+ // storageState carries it into every spec's browser context. Example:
213
+ //
214
+ // const api = process.env.EXPO_PUBLIC_API_URL;
215
+ // if (!api) {
216
+ // // Metro inlines EXPO_PUBLIC_* at bundle time; unset, the app falls back
217
+ // // to whatever origin its source hardcodes. Refuse rather than test a
218
+ // // server nobody chose.
219
+ // throw new Error("EXPO_PUBLIC_API_URL is not set");
220
+ // }
221
+ // const ctx = await request.newContext();
222
+ // await ctx.post(\`\${api}/api/auth/login\`, { data: { email, password } });
223
+ // await ctx.storageState({ path: authState });
224
+ // await ctx.dispose();
225
+ //
226
+ // flawless-react never overwrites this file.
227
+ export default async function globalSetup(config: { rootDir: string }): Promise<void> {
228
+ // rootDir is the CONFIG's directory, handed in at runtime — the one anchor
229
+ // that exists under both module loaders.
230
+ const authState = path.join(config.rootDir, ".auth.json");
231
+ // Empty state so the config's storageState reference always resolves.
232
+ writeFileSync(authState, JSON.stringify({ cookies: [], origins: [] }));
233
+ }
234
+ `;
235
+ }
236
+
237
+ function expoE2eReadme(port: number): string {
238
+ return `# flawless-react e2e (Expo)
239
+
240
+ Route-level Playwright specs against \`expo start --web\` (Metro, port ${port}).
241
+
242
+ - **Two servers.** Metro serves the UI (the emitted config boots it); your API
243
+ is a prerequisite the config cannot start. Set \`EXPO_PUBLIC_API_URL\` before
244
+ running — Metro bakes it into the bundle, and an unset value falls back to
245
+ whatever the source hardcodes.
246
+ - Seed the database first (whatever your repo uses — e.g. \`bun run db:reset\`).
247
+ - Dynamic segments read \`e2e/params.json\` (same machinery as the Next.js
248
+ flavour): \`FLAWLESS_E2E_<PARAM>\` env vars override it.
249
+ - Auth: fill in \`global-setup.ts\` (never overwritten). On Expo web the app
250
+ authenticates with a cookie against the API origin, so the stub's
251
+ request-context login + storageState example is the whole job. Until you do,
252
+ a protected route fails on its landing assertion (client redirect to the
253
+ login screen), or on its console/copy assertions (a wall rendered in place
254
+ surfaces the API's 401s and never renders the screen's own text).
255
+ - A route that redirects ON PURPOSE — including an app whose cold start always
256
+ lands on the login screen because it restores no session — records the truth
257
+ in \`params.json\` under \`landing\`, e.g. \`{"landing": {"/": "/login"}}\`.
258
+ Not in the spec: \`--force\` rewrites specs and leaves this file alone.
259
+ - Status codes are real here: Metro under \`web.output: "static"\` answers 404
260
+ for unknown routes and renders each route's own HTML, so the specs' status
261
+ and copy assertions have teeth on a dev server.
262
+ `;
263
+ }
264
+
265
+ function expoRouteSpec(tree: ComponentTree, route: ExpoRoute, rel: string): string {
266
+ const dynamic = route.params.length > 0;
267
+ const paramLines = dynamic
268
+ ? `import { expectedLanding, routeUrl } from "./params";\n\nconst ROUTE_TEMPLATE = ${JSON.stringify(route.urlPath)};`
269
+ : `import { expectedLanding } from "./params";`;
270
+ const urlExpr = dynamic ? `routeUrl(ROUTE_TEMPLATE) as string` : JSON.stringify(route.urlPath);
271
+ const skipLine = dynamic
272
+ ? `\n test.skip(\n ROUTE === undefined,\n \`no id fixture for ${route.urlPath} — fill ${underOutDir(rel, "e2e/params.json")}\`,\n );`
273
+ : "";
274
+
275
+ // The longest literal the analysis proved this screen renders on its
276
+ // happy path. It is the anti-vacuous rung the Next.js flavour never needed:
277
+ // an auth wall rendered IN PLACE keeps the URL, answers 200 and can keep a
278
+ // clean console — but it never renders the screen's own copy.
279
+ const witness = witnessFor(tree);
280
+
281
+ const clicks = collectPressClicks(tree).slice(0, 3);
282
+ const interactionBody = clicks
283
+ .map((c) => {
284
+ const locator = emitPlaywrightSelector(c.click);
285
+ if (!locator) return "";
286
+ // An e2e spec has no DEFAULTS table — the prop-labelled rung belongs to
287
+ // the unit/browser planes, where synthesised props exist to read.
288
+ if (locator.includes("DEFAULTS[")) return "";
289
+ const dispatch = emitPlaywrightDispatch(c.click.event, locator, { tag: c.click.tag });
290
+ if (!dispatch) return "";
291
+ return ` // from <${c.component}> analysis
292
+ {
293
+ const target = ${locator};
294
+ if (await target.count() > 0 && await target.first().isVisible()) {
295
+ ${dispatch.replace(locator, `${locator}.first()`)};
296
+ await page.waitForTimeout(150);
297
+ }
298
+ }`;
299
+ })
300
+ .filter((s) => s !== "")
301
+ .join("\n");
302
+
303
+ return `import { test, expect, type Page } from "@playwright/test";
304
+
305
+ ${paramLines}
306
+ const ROUTE = ${urlExpr};
307
+ const ROUTE_KEY = ${JSON.stringify(route.urlPath)};
308
+
309
+ /** Trailing slashes are not a difference: "/items/" and "/items" are one route. */
310
+ function normalisePath(p: string): string {
311
+ return p.replace(/\\/+$/, "") || "/";
312
+ }
313
+
314
+ /**
315
+ * Assert the browser is on the route this spec asked for.
316
+ *
317
+ * On Expo web an auth wall has two shapes, and this catches ONE of them: the
318
+ * client-side redirect that changes the URL (an unauthenticated "/" landing on
319
+ * "/login"). The other shape — a wall rendered IN PLACE at the requested URL —
320
+ * keeps the URL, so the console assertion (the API's 401s land there) and the
321
+ * copy assertion below are what catch it. Between the three, an
322
+ * unauthenticated suite cannot pass.
323
+ */
324
+ async function assertOnRoute(page: Page): Promise<void> {
325
+ const landed = normalisePath(new URL(page.url()).pathname);
326
+ const wanted = normalisePath(
327
+ expectedLanding(ROUTE_KEY, new URL(ROUTE, "http://flawless.invalid").pathname),
328
+ );
329
+ expect(
330
+ landed,
331
+ \`navigation left the route: asked for \\\`\${wanted}\\\`, landed on \\\`\${landed}\\\`. \` +
332
+ \`If this is an auth wall, authenticate in ${underOutDir(rel, "e2e/global-setup.ts")} — \` +
333
+ \`on Expo web that is a request-context login against the API origin plus storageState. \` +
334
+ \`If the redirect is what this route is SUPPOSED to do (a cold start that lands on the \` +
335
+ \`login screen included), record the destination in ${underOutDir(rel, "e2e/params.json")} \` +
336
+ \`under "landing": { "\${ROUTE_KEY}": "\${landed}" } — that file survives --force, this spec does not.\`,
337
+ ).toBe(wanted);
338
+ }
339
+
340
+ // The route rides on the describe: a --changed run's "\\bGET <route> " pattern
341
+ // must select EVERY test in this file.
342
+ test.describe("GET ${route.urlPath}", () => {
343
+ test("renders without console or page errors", async ({ page }) => {${skipLine}
344
+ const errors: string[] = [];
345
+ page.on("pageerror", (e) => errors.push(e.message));
346
+ page.on("console", (m) => {
347
+ if (m.type() === "error") errors.push(m.text());
348
+ });
349
+ const resp = await page.goto(ROUTE);
350
+ expect(resp, "navigation returned a response").toBeTruthy();
351
+ // Metro's statuses are real: unknown routes 404, served routes 200 —
352
+ // measured, not assumed (docs/archive/2026-09-expo-probes.md).
353
+ expect(resp?.status(), "no 5xx").toBeLessThan(500);
354
+ await page.waitForLoadState("networkidle");
355
+ await assertOnRoute(page);
356
+ // A dead session's other face lands here: a wall rendered in place
357
+ // surfaces every API call as a 401 console error.
358
+ expect(errors).toEqual([]);
359
+ });
360
+ ${
361
+ witness === undefined
362
+ ? ""
363
+ : `
364
+ test("renders its own copy", async ({ page }) => {${skipLine}
365
+ // A route that DELIBERATELY lands elsewhere (a params.json landing
366
+ // override — the redirect-only index of an app with no session hydration
367
+ // is the dogfood shape) never renders its own copy THERE, and asserting
368
+ // this screen's text on the screen it redirects to would be a red spec
369
+ // about a recorded decision. The other two tests still run: the landing
370
+ // assertion proves the redirect goes where the override says.
371
+ {
372
+ const wanted = normalisePath(new URL(ROUTE, "http://flawless.invalid").pathname);
373
+ test.skip(
374
+ normalisePath(expectedLanding(ROUTE_KEY, wanted)) !== wanted,
375
+ "this route deliberately lands elsewhere (params.json landing) — its own copy cannot be asserted there",
376
+ );
377
+ }
378
+ await page.goto(ROUTE);
379
+ await page.waitForLoadState("networkidle");
380
+ await assertOnRoute(page);
381
+ // The longest literal the analysis proved this screen renders on its happy
382
+ // path. An auth wall, an error state or the wrong screen renders none of
383
+ // it — this is the claim that cannot be true of a login page.
384
+ await expect(page.getByText(${JSON.stringify(witness)}).first()).toBeVisible();
385
+ });
386
+ `
387
+ }${
388
+ clicks.length > 0
389
+ ? `
390
+ test("basic interactions from analysis stay error-free", async ({ page }) => {${skipLine}
391
+ const errors: string[] = [];
392
+ page.on("pageerror", (e) => errors.push(e.message));
393
+ await page.goto(ROUTE);
394
+ await page.waitForLoadState("networkidle");
395
+ // Carries real weight here: every harvested press is guarded by
396
+ // count()/isVisible(), so on a login screen this test would do NOTHING and
397
+ // report an empty error list — a green claim about work that never happened.
398
+ await assertOnRoute(page);
399
+ ${interactionBody}
400
+ expect(errors).toEqual([]);
401
+ });
402
+ `
403
+ : ""
404
+ }});
405
+ `;
406
+ }
407
+
408
+ /**
409
+ * The screen's most identifying happy-path literal, from the same analysis the
410
+ * unit plane trusts. Fragments below 4 characters identify nothing ("OK" is on
411
+ * half the screens of any app).
412
+ */
413
+ function witnessFor(tree: ComponentTree): string | undefined {
414
+ const texts = tree.entry.staticText ?? [];
415
+ const best = [...texts].sort((a, b) => b.length - a.length)[0];
416
+ return best !== undefined && best.length >= 4 ? best : undefined;
417
+ }
418
+
419
+ interface HarvestedClick {
420
+ component: string;
421
+ click: NonNullable<ComponentTree["entry"]["interactions"]>["clicks"][number];
422
+ }
423
+
424
+ /** onPress, not onClick: the native event vocabulary — the sibling of the web
425
+ * task's collectClicks for the same reason the emitters are siblings. */
426
+ function collectPressClicks(tree: ComponentTree): HarvestedClick[] {
427
+ const out: HarvestedClick[] = [];
428
+ const walk = (t: ComponentTree): void => {
429
+ for (const c of t.entry.interactions?.clicks ?? []) {
430
+ if (c.event === "onPress") out.push({ component: t.entry.componentName, click: c });
431
+ }
432
+ for (const child of t.children) walk(child);
433
+ };
434
+ walk(tree);
435
+ return out;
436
+ }