@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,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
|
+
}
|