@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,750 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { outDirRel, underOutDir } from "../emitPaths.ts";
|
|
5
|
+
import { findRouterRoot, pageFileToRoute } from "../nextjs/routes.ts";
|
|
6
|
+
import type { ComponentTree, RunContext, Task, TaskResult } from "../types.ts";
|
|
7
|
+
import { ensureParamDefaults } from "./e2eRoutes.ts";
|
|
8
|
+
import { runExpoLoad } from "./loadExpo.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Emit-only crust load pipes: page routes plus the API routes the analysed
|
|
12
|
+
* components actually fetch. Mirrors the CI-gate conventions from crust's
|
|
13
|
+
* docs — one assert per threshold, baseline comparison via stats --out.
|
|
14
|
+
* Works without crust on PATH (files are emitted; running them needs crust).
|
|
15
|
+
*/
|
|
16
|
+
export const loadPipes: Task = {
|
|
17
|
+
id: "load",
|
|
18
|
+
label: "Generate crust load pipes",
|
|
19
|
+
description: "Load-test pipes for analysed routes + their API calls (crust)",
|
|
20
|
+
needsTree: true,
|
|
21
|
+
async run(ctx: RunContext): Promise<TaskResult> {
|
|
22
|
+
// The wave-3/5/6 shape: one TaskId, a sibling engine per platform. An Expo
|
|
23
|
+
// app has no server-rendered page routes to GET, so the native engine emits
|
|
24
|
+
// API pipes only — from the endpoints the screens reach through the app's
|
|
25
|
+
// fetch wrapper (ComponentInfo.apiCalls). B-16 wave 7.
|
|
26
|
+
if (ctx.expo) return runExpoLoad(ctx);
|
|
27
|
+
if (!ctx.nextjs) {
|
|
28
|
+
return {
|
|
29
|
+
id: "load",
|
|
30
|
+
ok: false,
|
|
31
|
+
outputs: [],
|
|
32
|
+
findings: [],
|
|
33
|
+
summary: "load task requires --nextjs mode",
|
|
34
|
+
error: "not a Next.js run",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Bound after the guard: `ctx.nextjs` narrows for this body but not inside
|
|
39
|
+
// the closures below, where the property could have moved.
|
|
40
|
+
const nextjs = ctx.nextjs;
|
|
41
|
+
const loadDir = path.join(ctx.outDir, "load");
|
|
42
|
+
await mkdir(loadDir, { recursive: true });
|
|
43
|
+
const written: string[] = [];
|
|
44
|
+
|
|
45
|
+
const routerRoot = findRouterRoot(nextjs.root);
|
|
46
|
+
// Fallback for default Next.js mode where the entry glob is components/**,
|
|
47
|
+
// so ctx.trees contains no page files. Discover pages directly so load
|
|
48
|
+
// pipes still emit route targets.
|
|
49
|
+
let trees = ctx.trees;
|
|
50
|
+
const hasPage = trees.some((t) => pageFileToRoute(t.entry.filePath, nextjs.router, routerRoot));
|
|
51
|
+
if (!hasPage) {
|
|
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, 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
|
+
trees = [...trees, tree];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const targets = collectTargets(trees, nextjs.router, routerRoot);
|
|
72
|
+
|
|
73
|
+
// Every id these pipes reference gets a slot in params.json, including the
|
|
74
|
+
// API-route ones the e2e task never sees (/api/buildings/[buildingId] beside
|
|
75
|
+
// the page's /dashboard/buildings/[id]). Without the slot there is nowhere
|
|
76
|
+
// to put the value, and the pipe silently requests a malformed URL.
|
|
77
|
+
const paramsPath = path.join(ctx.outDir, "e2e", "params.json");
|
|
78
|
+
const needed = [...new Set(targets.flatMap((t) => t.params))].sort();
|
|
79
|
+
if (await ensureParamDefaults(paramsPath, needed)) written.push(paramsPath);
|
|
80
|
+
|
|
81
|
+
// Which ids the user has actually filled in. A pipe whose parameters are
|
|
82
|
+
// blank does not measure the route, it measures the app's 404 handler —
|
|
83
|
+
// GET /invite/ with an empty token answered 500 on every one of 500
|
|
84
|
+
// requests and was reported as the route failing. Silence is worse than a
|
|
85
|
+
// comment: this way the file itself says which id is missing.
|
|
86
|
+
const filled = await readFilledParams(paramsPath);
|
|
87
|
+
|
|
88
|
+
const pipesPath = path.join(loadDir, "routes.pipes");
|
|
89
|
+
let keptPipes = false;
|
|
90
|
+
let pipesText: string;
|
|
91
|
+
if (!existsSync(pipesPath)) {
|
|
92
|
+
pipesText = renderPipes(targets, ctx.outDir, nextjs.root, filled);
|
|
93
|
+
await Bun.write(pipesPath, pipesText);
|
|
94
|
+
written.push(pipesPath);
|
|
95
|
+
} else if (ctx.force) {
|
|
96
|
+
// Merged, not overwritten. A tuned rate, a hand-added POST flow and a pipe
|
|
97
|
+
// you commented out yourself cannot be recovered if lost, and --force is
|
|
98
|
+
// the only way to turn a gated pipe back on — so it has to do both.
|
|
99
|
+
pipesText = mergePipes(
|
|
100
|
+
await Bun.file(pipesPath).text(),
|
|
101
|
+
targets,
|
|
102
|
+
ctx.outDir,
|
|
103
|
+
nextjs.root,
|
|
104
|
+
filled,
|
|
105
|
+
);
|
|
106
|
+
await Bun.write(pipesPath, pipesText);
|
|
107
|
+
written.push(pipesPath);
|
|
108
|
+
} else {
|
|
109
|
+
pipesText = await Bun.file(pipesPath).text();
|
|
110
|
+
keptPipes = true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Write-once, like the e2e global-setup stub: a hand-written module that
|
|
114
|
+
// knows the app is better than this one, and a regeneration must never take
|
|
115
|
+
// it. Absent, it IS written — so a suite that predates the guard gets it,
|
|
116
|
+
// which a write-once file only manages because "absent" is the common case.
|
|
117
|
+
const setupPath = path.join(loadDir, "routes.setup.ts");
|
|
118
|
+
if (!existsSync(setupPath)) {
|
|
119
|
+
await Bun.write(setupPath, renderLoadSetup(targets, outDirRel(ctx.outDir, nextjs.root)));
|
|
120
|
+
written.push(setupPath);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const envPath = path.join(loadDir, "env.sh");
|
|
124
|
+
await Bun.write(envPath, loadEnvScript(outDirRel(ctx.outDir, nextjs.root)));
|
|
125
|
+
written.push(envPath);
|
|
126
|
+
|
|
127
|
+
// Regenerated under --force, unlike routes.pipes beside it: this README is
|
|
128
|
+
// generated prose with nothing of yours in it, so protecting it forever only
|
|
129
|
+
// means existing installs never learn what a later version worked out — such
|
|
130
|
+
// as that LOAD_BASE has to be a production build.
|
|
131
|
+
const readmePath = path.join(loadDir, "README.md");
|
|
132
|
+
if (!existsSync(readmePath) || ctx.force) {
|
|
133
|
+
await Bun.write(readmePath, loadReadme());
|
|
134
|
+
written.push(readmePath);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const crustPresent = Bun.which("crust") !== null || hasLocalCrust(nextjs.root);
|
|
138
|
+
const tally = countPipes(pipesText);
|
|
139
|
+
return {
|
|
140
|
+
id: "load",
|
|
141
|
+
ok: true,
|
|
142
|
+
outputs: written,
|
|
143
|
+
findings: [],
|
|
144
|
+
summary: keptPipes
|
|
145
|
+
? `kept existing load/routes.pipes (--force to merge in ${targets.length} target(s))`
|
|
146
|
+
: `load pipes for ${targets.length} target(s) — ${describeTally(tally, filled, targets)}${
|
|
147
|
+
crustPresent ? " — run with bun run flawless:load" : " — crust not found on PATH"
|
|
148
|
+
}`,
|
|
149
|
+
};
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export interface PipeTally {
|
|
154
|
+
live: number;
|
|
155
|
+
gated: number;
|
|
156
|
+
userCommented: number;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* What the file on disk actually measures, which is not `targets.length`.
|
|
161
|
+
*
|
|
162
|
+
* A gated pipe measures nothing, so counting it as a covered target is the same
|
|
163
|
+
* mistake REPORT.md exists to prevent — this string is what lands in its Tasks
|
|
164
|
+
* table.
|
|
165
|
+
*/
|
|
166
|
+
export function countPipes(text: string): PipeTally {
|
|
167
|
+
const tally: PipeTally = { live: 0, gated: 0, userCommented: 0 };
|
|
168
|
+
for (const item of parsePipes(text).body) {
|
|
169
|
+
if (item.kind !== "pipe") continue;
|
|
170
|
+
if (item.state === "live") tally.live += 1;
|
|
171
|
+
else if (item.state === "gated") tally.gated += 1;
|
|
172
|
+
else tally.userCommented += 1;
|
|
173
|
+
}
|
|
174
|
+
return tally;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function describeTally(
|
|
178
|
+
tally: PipeTally,
|
|
179
|
+
filled: Set<string>,
|
|
180
|
+
targets: LoadTarget[],
|
|
181
|
+
): string {
|
|
182
|
+
const parts = [`${tally.live} live`];
|
|
183
|
+
if (tally.gated > 0) {
|
|
184
|
+
const ids = [...new Set(targets.flatMap((t) => missingParams(t, filled)))].sort();
|
|
185
|
+
parts.push(`${tally.gated} commented out pending ids (${ids.join(", ")})`);
|
|
186
|
+
}
|
|
187
|
+
if (tally.userCommented > 0) parts.push(`${tally.userCommented} commented out by you`);
|
|
188
|
+
return parts.join(", ");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface LoadTarget {
|
|
192
|
+
name: string;
|
|
193
|
+
/** URL path with $VARS for dynamic segments. */
|
|
194
|
+
urlPath: string;
|
|
195
|
+
/**
|
|
196
|
+
* The dynamic segments this target needs, in the casing the source used.
|
|
197
|
+
*
|
|
198
|
+
* $LOAD_BUILDINGID cannot be turned back into `buildingId` — the shouting is
|
|
199
|
+
* lossy — and params.json is keyed by the source name. Deriving the key from
|
|
200
|
+
* the variable added a second, lowercased slot beside every existing one.
|
|
201
|
+
*/
|
|
202
|
+
params: string[];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** crust installed as a project dependency (bun run resolves node_modules/.bin). */
|
|
206
|
+
function hasLocalCrust(fromDir: string): boolean {
|
|
207
|
+
let dir = path.resolve(fromDir);
|
|
208
|
+
for (;;) {
|
|
209
|
+
if (existsSync(path.join(dir, "node_modules", ".bin", "crust"))) return true;
|
|
210
|
+
const parent = path.dirname(dir);
|
|
211
|
+
if (parent === dir) return false;
|
|
212
|
+
dir = parent;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function collectTargets(
|
|
217
|
+
trees: ComponentTree[],
|
|
218
|
+
router: "app" | "pages",
|
|
219
|
+
routerRoot: string,
|
|
220
|
+
): LoadTarget[] {
|
|
221
|
+
const out: LoadTarget[] = [];
|
|
222
|
+
const seen = new Set<string>();
|
|
223
|
+
const usedNames = new Set<string>();
|
|
224
|
+
const add = (name: string, urlPath: string, params: string[]): void => {
|
|
225
|
+
if (seen.has(urlPath)) return;
|
|
226
|
+
seen.add(urlPath);
|
|
227
|
+
// Two distinct routes can want the same name: slugify turns every dynamic
|
|
228
|
+
// segment into a bare `x`, so /api/a/[p]/b and /api/a/[q]/b both arrive as
|
|
229
|
+
// `api-a-x-b`. They would then share one last-*.json — each overwriting the
|
|
230
|
+
// other's numbers — and the merge, which keys on this name, would treat the
|
|
231
|
+
// second route as an update of the first and never emit a pipe for it.
|
|
232
|
+
//
|
|
233
|
+
// The first claimant keeps the plain name, so regenerating an existing suite
|
|
234
|
+
// does not rename anything already on disk.
|
|
235
|
+
let unique = name;
|
|
236
|
+
for (let n = 2; usedNames.has(unique); n++) unique = `${name}-${n}`;
|
|
237
|
+
usedNames.add(unique);
|
|
238
|
+
out.push({ name: unique, urlPath, params });
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
for (const tree of trees) {
|
|
242
|
+
const route = pageFileToRoute(tree.entry.filePath, router, routerRoot);
|
|
243
|
+
if (route) {
|
|
244
|
+
let p = route.urlPath;
|
|
245
|
+
for (const param of route.params) {
|
|
246
|
+
p = p.replace(`[...${param}]`, `$LOAD_${param.toUpperCase()}`);
|
|
247
|
+
p = p.replace(`[${param}]`, `$LOAD_${param.toUpperCase()}`);
|
|
248
|
+
}
|
|
249
|
+
add(route.specStem, p, route.params);
|
|
250
|
+
}
|
|
251
|
+
// API routes the UI actually calls (GET only — load-testing mutations by
|
|
252
|
+
// default would write junk into the target DB).
|
|
253
|
+
const walk = (t: ComponentTree): void => {
|
|
254
|
+
for (const f of t.entry.fetchCalls ?? []) {
|
|
255
|
+
if (f.method !== "GET") continue;
|
|
256
|
+
let p = f.urlTemplate;
|
|
257
|
+
for (const hole of f.holes) {
|
|
258
|
+
p = p.replace(`:${hole}`, `$LOAD_${hole.toUpperCase()}`);
|
|
259
|
+
}
|
|
260
|
+
add(slugify(p), p, f.holes);
|
|
261
|
+
}
|
|
262
|
+
for (const c of t.children) walk(c);
|
|
263
|
+
};
|
|
264
|
+
walk(tree);
|
|
265
|
+
}
|
|
266
|
+
return out;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function slugify(p: string): string {
|
|
270
|
+
return (
|
|
271
|
+
p
|
|
272
|
+
.replace(/\$LOAD_[A-Z0-9_]+/g, "x")
|
|
273
|
+
.replace(/[^a-zA-Z0-9]+/g, "-")
|
|
274
|
+
.replace(/^-|-$/g, "") || "root"
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The ids in e2e/params.json that hold a value.
|
|
280
|
+
*
|
|
281
|
+
* Written by the e2e task earlier in the same run, and merge-preserving across
|
|
282
|
+
* runs, so by the time load renders its pipes this is exactly the set of
|
|
283
|
+
* fixtures the user has supplied.
|
|
284
|
+
*/
|
|
285
|
+
export async function readFilledParams(paramsPath: string): Promise<Set<string>> {
|
|
286
|
+
const out = new Set<string>();
|
|
287
|
+
if (!existsSync(paramsPath)) return out;
|
|
288
|
+
try {
|
|
289
|
+
const parsed = (await Bun.file(paramsPath).json()) as {
|
|
290
|
+
defaults?: Record<string, string>;
|
|
291
|
+
routes?: Record<string, Record<string, string>>;
|
|
292
|
+
};
|
|
293
|
+
for (const [k, v] of Object.entries(parsed.defaults ?? {})) {
|
|
294
|
+
if (typeof v === "string" && v !== "") out.add(k.toUpperCase());
|
|
295
|
+
}
|
|
296
|
+
for (const route of Object.values(parsed.routes ?? {})) {
|
|
297
|
+
for (const [k, v] of Object.entries(route)) {
|
|
298
|
+
if (typeof v === "string" && v !== "") out.add(k.toUpperCase());
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
} catch {
|
|
302
|
+
// Unreadable params.json: emit every pipe and let the run report the truth,
|
|
303
|
+
// rather than commenting out the whole file over a parse error.
|
|
304
|
+
}
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** First line of a header this tool wrote — the signal that it may be refreshed. */
|
|
309
|
+
const HEADER_MARKER = "# flawless-react load pipes — generated from the analysed component tree.";
|
|
310
|
+
/** Prefix of the gate comment, which identifies a pipe as commented out BY the tool. */
|
|
311
|
+
const GATE_MARKER = "# TODO(flawless): no value for ";
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* The ids a target needs that nothing has supplied.
|
|
315
|
+
*
|
|
316
|
+
* Compared in upper case, reported in the source's: `filled` is keyed by the
|
|
317
|
+
* shouting form the pipe uses, but the fix a reader has to make is in
|
|
318
|
+
* params.json, which is keyed by the name as written.
|
|
319
|
+
*/
|
|
320
|
+
export function missingParams(t: LoadTarget, filled: Set<string>): string[] {
|
|
321
|
+
return t.params.filter((p) => !filled.has(p.toUpperCase()));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function renderPipeBody(t: LoadTarget, rel: string): string {
|
|
325
|
+
const statsOut = `${rel}/load/last-${t.name}.json`;
|
|
326
|
+
const baseline = `${rel}/load/baseline-${t.name}.json`;
|
|
327
|
+
return (
|
|
328
|
+
`load 10s 50/s | parallel 16 | GET $LOAD_BASE${t.urlPath} -H "$LOAD_AUTH" --timeout 2s | expect 200 ` +
|
|
329
|
+
`| stats --out ${statsOut} ` +
|
|
330
|
+
`| assert (async s => { const b = Bun.file("${baseline}"); return !(await b.exists()) || s.p95 < 2 * (await b.json()).summary.p95 })`
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* The routes the setup module can probe: static ones only.
|
|
336
|
+
*
|
|
337
|
+
* params.length === 0 also means urlPath carries no $VARS, so it is the literal
|
|
338
|
+
* route path. A dynamic route is excluded because an unfilled id would read as
|
|
339
|
+
* an auth failure; "/" because it redirects by design in most apps and would
|
|
340
|
+
* fail before the user has had a chance to record where to.
|
|
341
|
+
*/
|
|
342
|
+
function guardTargets(targets: LoadTarget[]): LoadTarget[] {
|
|
343
|
+
return targets.filter((t) => t.params.length === 0 && t.urlPath !== "/");
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* The setup module crust imports before a single pipe runs.
|
|
348
|
+
*
|
|
349
|
+
* An expiring $LOAD_AUTH is THE failure mode of this plane, and it is silent.
|
|
350
|
+
* crust follows redirects, so a dead session turns every route into the login
|
|
351
|
+
* page answering 200: `expect 200` passes, `stats` reports healthy percentiles,
|
|
352
|
+
* and the file goes green having measured a login form. Reproduced on a real
|
|
353
|
+
* app — 20/20 status 200, p50 19.7ms, exit 0, every request served /login.
|
|
354
|
+
*
|
|
355
|
+
* Nothing downstream can see it. In load mode (`parallel N`) crust's record is
|
|
356
|
+
* {status, ms, url} and that url is the REQUESTED one, byte-identical whether
|
|
357
|
+
* the session is live or dead. Only a plain fetch shows where a request landed.
|
|
358
|
+
*
|
|
359
|
+
* A setup module rather than guard lines in routes.pipes: it fails once, before
|
|
360
|
+
* anything is measured, instead of N times among the results — and the first
|
|
361
|
+
* app to hit this had already been fixed BY HAND exactly this way, which is
|
|
362
|
+
* where the shape comes from.
|
|
363
|
+
*
|
|
364
|
+
* Every static route is probed, not one. Nothing in the analysis says which
|
|
365
|
+
* routes need a session, and an earlier cut of this picked a single static
|
|
366
|
+
* route, drew /forgot-password on the dogfood app, and would have passed with a
|
|
367
|
+
* dead cookie — a check that cannot fail. Dynamic routes are skipped: an
|
|
368
|
+
* unfilled id would read as an auth failure.
|
|
369
|
+
*
|
|
370
|
+
* WRITE-ONCE. A hand-written module that knows the app (an authenticated
|
|
371
|
+
* /api/me answering 401 is a sharper signal than a redirect) is strictly better
|
|
372
|
+
* than this one, and no regeneration may overwrite it. Absent, it is written —
|
|
373
|
+
* so suites that predate it do get the guard.
|
|
374
|
+
*/
|
|
375
|
+
export function renderLoadSetup(targets: LoadTarget[], rel: string): string {
|
|
376
|
+
const routes = guardTargets(targets).map((t) => t.urlPath);
|
|
377
|
+
return `// Setup module for routes.pipes. test-pipes imports the sibling
|
|
378
|
+
// <name>.setup.ts and awaits its default export before any line runs.
|
|
379
|
+
//
|
|
380
|
+
// Why: crust follows redirects, so an expired LOAD_AUTH turns every route into
|
|
381
|
+
// a login page answering 200 — "expect 200" passes, stats reports healthy
|
|
382
|
+
// percentiles, and the whole file goes green having measured a login form.
|
|
383
|
+
// Nothing downstream can see it: a load-mode record carries the REQUESTED url,
|
|
384
|
+
// not the landed one. So the check has to happen here, before any measurement.
|
|
385
|
+
//
|
|
386
|
+
// This file is written once and never regenerated. Replace it with a check that
|
|
387
|
+
// knows your app if you have a better signal — an authenticated endpoint
|
|
388
|
+
// answering 401 is sharper than a redirect — and nothing will overwrite yours.
|
|
389
|
+
|
|
390
|
+
/** Static routes only: a dynamic one with an unfilled id would look like an auth failure. */
|
|
391
|
+
const ROUTES: string[] = ${JSON.stringify(routes)};
|
|
392
|
+
/** Every route the pipes target, so "no ids" can be told from "no dynamic routes". */
|
|
393
|
+
const TOTAL_ROUTES = ${targets.length};
|
|
394
|
+
|
|
395
|
+
export default async () => {
|
|
396
|
+
const base = process.env.LOAD_BASE;
|
|
397
|
+
const auth = process.env.LOAD_AUTH;
|
|
398
|
+
if (!base || !auth) {
|
|
399
|
+
throw new Error(
|
|
400
|
+
"routes.setup: LOAD_BASE and LOAD_AUTH are required — run via \`crust --env-file ${underOutDir(rel, "load/.env.load")}\` or export them",
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
const sep = auth.indexOf(":");
|
|
404
|
+
if (sep < 0) {
|
|
405
|
+
throw new Error(\`routes.setup: LOAD_AUTH must be a header line, e.g. "cookie: session=…" — got \${auth}\`);
|
|
406
|
+
}
|
|
407
|
+
const headers = { [auth.slice(0, sep).trim()]: auth.slice(sep + 1).trim() };
|
|
408
|
+
|
|
409
|
+
const paramsFile = Bun.file("${underOutDir(rel, "e2e/params.json")}");
|
|
410
|
+
if (!(await paramsFile.exists())) {
|
|
411
|
+
throw new Error(
|
|
412
|
+
"routes.setup: ${underOutDir(rel, "e2e/params.json")} not found — its ids are what these pipes target",
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
const params = (await paramsFile.json()) as {
|
|
416
|
+
defaults?: Record<string, string>;
|
|
417
|
+
routes?: Record<string, Record<string, string>>;
|
|
418
|
+
landing?: Record<string, string>;
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
// LOAD_<PARAM> ids, seeded here rather than by sourcing env.sh. That shim is a
|
|
422
|
+
// bash/zsh-portability script whose own comments record two incidents where it
|
|
423
|
+
// exported NOTHING silently — the pipes then ran against URLs with empty path
|
|
424
|
+
// segments and measured the 404 handler while reporting the application. This
|
|
425
|
+
// has one failure mode, and it is loud.
|
|
426
|
+
const merged: Record<string, string> = { ...(params.defaults ?? {}) };
|
|
427
|
+
for (const route of Object.values(params.routes ?? {})) {
|
|
428
|
+
for (const [k, v] of Object.entries(route)) if (v !== "") merged[k] = v;
|
|
429
|
+
}
|
|
430
|
+
let exported = 0;
|
|
431
|
+
for (const [k, v] of Object.entries(merged)) {
|
|
432
|
+
if (v === "") continue;
|
|
433
|
+
process.env[\`LOAD_\${k.toUpperCase()}\`] = v;
|
|
434
|
+
exported++;
|
|
435
|
+
}
|
|
436
|
+
if (exported === 0 && ROUTES.length < TOTAL_ROUTES) {
|
|
437
|
+
throw new Error(
|
|
438
|
+
"routes.setup: no ids in ${underOutDir(rel, "e2e/params.json")} — every dynamic route below would request a URL with an empty segment and measure the 404 handler",
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// The same landing map the e2e specs use, so a route that redirects by design
|
|
443
|
+
// is recorded once rather than in two places that drift.
|
|
444
|
+
const landing: Record<string, string> = params.landing ?? {};
|
|
445
|
+
|
|
446
|
+
for (const route of ROUTES) {
|
|
447
|
+
const res = await fetch(new URL(route, base), { headers });
|
|
448
|
+
const landed = new URL(res.url).pathname;
|
|
449
|
+
const want = landing[route] ?? route;
|
|
450
|
+
if (!landed.startsWith(want)) {
|
|
451
|
+
throw new Error(
|
|
452
|
+
\`routes.setup: \${route} landed on \${landed} (status \${res.status}). Either LOAD_AUTH no longer \` +
|
|
453
|
+
\`authenticates, or this route redirects by design — record the destination under "landing" in \` +
|
|
454
|
+
\`${underOutDir(rel, "e2e/params.json")}. Every measurement below would be of \${landed}, not \${route}.\`,
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
`;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function gateComment(missing: string[], rel: string): string {
|
|
463
|
+
return (
|
|
464
|
+
`${GATE_MARKER}${missing.map((m) => `LOAD_${m.toUpperCase()}`).join(", ")}. ` +
|
|
465
|
+
`Fill ${missing.map((m) => `"${m}"`).join(", ")} in ${rel}/e2e/params.json`
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function pipesHeader(rel: string): string[] {
|
|
470
|
+
return [
|
|
471
|
+
HEADER_MARKER,
|
|
472
|
+
"# Env contract (export before running, or provide a crust setup module):",
|
|
473
|
+
"# LOAD_BASE — e.g. http://localhost:3001. Point this at a PRODUCTION build",
|
|
474
|
+
"# (bun run build && bun run start), not at next dev: the dev",
|
|
475
|
+
"# server compiles routes on demand, so a load run against it",
|
|
476
|
+
"# measures the compiler rather than the app.",
|
|
477
|
+
'# LOAD_AUTH — a header line, e.g. "cookie: session=<value>" for a cookie',
|
|
478
|
+
'# session, or "authorization: Bearer <token>" for a token one.',
|
|
479
|
+
'# Use "accept: */*" if the route needs no auth.',
|
|
480
|
+
`# LOAD_<PARAM> — one per dynamic segment below (e.g. LOAD_ID, LOAD_BUILDINGID).`,
|
|
481
|
+
`# You do not set these by hand: routes.setup.ts seeds them from`,
|
|
482
|
+
`# ${underOutDir(rel, "e2e/params.json")} — the same ids the e2e specs use — and fails`,
|
|
483
|
+
`# loudly when a dynamic route has no id. Source ${underOutDir(rel, "load/env.sh")}`,
|
|
484
|
+
`# only when running crust directly, past the setup module.`,
|
|
485
|
+
"# Baseline gating: copy a good last-*.json to baseline-*.json; runs then fail",
|
|
486
|
+
"# when p95 degrades beyond 2x baseline. One assert per threshold.",
|
|
487
|
+
"# This header is regenerated by --force. Everything below it is merged, not",
|
|
488
|
+
"# replaced: an edited pipe, a pipe you commented out and a flow you added by",
|
|
489
|
+
"# hand all survive. Keep your own notes below this line.",
|
|
490
|
+
"# TODO(flawless): tune rate/duration to your app; add POST flows deliberately.",
|
|
491
|
+
];
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
export function renderPipes(
|
|
495
|
+
targets: LoadTarget[],
|
|
496
|
+
outDir: string,
|
|
497
|
+
nextRoot: string,
|
|
498
|
+
filled: Set<string>,
|
|
499
|
+
): string {
|
|
500
|
+
const rel = outDirRel(outDir, nextRoot);
|
|
501
|
+
const entries = targets.map((t) => {
|
|
502
|
+
const body = renderPipeBody(t, rel);
|
|
503
|
+
const missing = missingParams(t, filled);
|
|
504
|
+
if (missing.length === 0) return body;
|
|
505
|
+
// Commented out rather than dropped: the route is still visible, and filling
|
|
506
|
+
// the id in e2e/params.json plus a --force regeneration turns it back on.
|
|
507
|
+
return `${gateComment(missing, rel)}\n# ${body}`;
|
|
508
|
+
});
|
|
509
|
+
return `${pipesHeader(rel).join("\n")}\n\n${entries.join("\n\n")}\n`;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
type PipeState = "live" | "gated" | "userCommented";
|
|
513
|
+
|
|
514
|
+
type BodyItem =
|
|
515
|
+
| { kind: "raw"; line: string }
|
|
516
|
+
| { kind: "pipe"; state: PipeState; name?: string; todo?: string; body: string };
|
|
517
|
+
|
|
518
|
+
interface ParsedPipes {
|
|
519
|
+
header: string[];
|
|
520
|
+
body: BodyItem[];
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* A pipe's stable identity: the `<name>` in `stats --out .../last-<name>.json`.
|
|
525
|
+
*
|
|
526
|
+
* Not the URL — that changes when a param is renamed — and not the line number,
|
|
527
|
+
* which changes whenever anything above it does.
|
|
528
|
+
*/
|
|
529
|
+
function pipeName(body: string): string | undefined {
|
|
530
|
+
const out = /--out\s+(\S+)/.exec(body);
|
|
531
|
+
if (!out?.[1]) return undefined;
|
|
532
|
+
const base = out[1].split("/").pop() ?? "";
|
|
533
|
+
return /^last-(.+)\.json$/.exec(base)?.[1];
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Split routes.pipes into the header, the pipes it declares, and everything
|
|
538
|
+
* else — which is preserved verbatim and in place, because a note you wrote and
|
|
539
|
+
* a POST flow you added by hand are indistinguishable from each other to a
|
|
540
|
+
* line-oriented parser, and both are yours.
|
|
541
|
+
*/
|
|
542
|
+
export function parsePipes(text: string): ParsedPipes {
|
|
543
|
+
const lines = text.split("\n");
|
|
544
|
+
const header: string[] = [];
|
|
545
|
+
let i = 0;
|
|
546
|
+
if (lines[0]?.startsWith("#")) {
|
|
547
|
+
while (i < lines.length && lines[i]?.startsWith("#")) header.push(lines[i++] as string);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const body: BodyItem[] = [];
|
|
551
|
+
for (; i < lines.length; i++) {
|
|
552
|
+
const line = lines[i] as string;
|
|
553
|
+
const next = lines[i + 1];
|
|
554
|
+
if (line.startsWith(GATE_MARKER) && next?.startsWith("# load ")) {
|
|
555
|
+
const pipe = next.slice(2);
|
|
556
|
+
body.push({ kind: "pipe", state: "gated", name: pipeName(pipe), todo: line, body: pipe });
|
|
557
|
+
i++;
|
|
558
|
+
} else if (line.startsWith("load ")) {
|
|
559
|
+
body.push({ kind: "pipe", state: "live", name: pipeName(line), body: line });
|
|
560
|
+
} else if (line.startsWith("# load ")) {
|
|
561
|
+
const pipe = line.slice(2);
|
|
562
|
+
body.push({ kind: "pipe", state: "userCommented", name: pipeName(pipe), body: pipe });
|
|
563
|
+
} else {
|
|
564
|
+
body.push({ kind: "raw", line });
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return { header, body };
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function renderParsed(parsed: ParsedPipes): string {
|
|
571
|
+
const out = [...parsed.header];
|
|
572
|
+
for (const item of parsed.body) {
|
|
573
|
+
if (item.kind === "raw") {
|
|
574
|
+
out.push(item.line);
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
if (item.state === "live") out.push(item.body);
|
|
578
|
+
else if (item.state === "gated") out.push(item.todo as string, `# ${item.body}`);
|
|
579
|
+
else out.push(`# ${item.body}`);
|
|
580
|
+
}
|
|
581
|
+
return out.join("\n");
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Fold this run's targets into the routes.pipes already on disk.
|
|
586
|
+
*
|
|
587
|
+
* Only ever toggles a pipe's comment state — the body is never rewritten, so a
|
|
588
|
+
* rate you tuned survives being gated and un-gated. Nothing is pruned: a stale
|
|
589
|
+
* generated pipe and a flow you added by hand look the same from here, and
|
|
590
|
+
* losing the second to tidy up the first is not a trade worth making.
|
|
591
|
+
*/
|
|
592
|
+
export function mergePipes(
|
|
593
|
+
existing: string,
|
|
594
|
+
targets: LoadTarget[],
|
|
595
|
+
outDir: string,
|
|
596
|
+
nextRoot: string,
|
|
597
|
+
filled: Set<string>,
|
|
598
|
+
): string {
|
|
599
|
+
const rel = outDirRel(outDir, nextRoot);
|
|
600
|
+
const parsed = parsePipes(existing);
|
|
601
|
+
|
|
602
|
+
const byName = new Map<string, Extract<BodyItem, { kind: "pipe" }>>();
|
|
603
|
+
for (const item of parsed.body) {
|
|
604
|
+
if (item.kind === "pipe" && item.name !== undefined && !byName.has(item.name)) {
|
|
605
|
+
byName.set(item.name, item);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const fresh: BodyItem[] = [];
|
|
610
|
+
for (const t of targets) {
|
|
611
|
+
const missing = missingParams(t, filled);
|
|
612
|
+
const found = byName.get(t.name);
|
|
613
|
+
if (!found) {
|
|
614
|
+
fresh.push({
|
|
615
|
+
kind: "pipe",
|
|
616
|
+
state: missing.length === 0 ? "live" : "gated",
|
|
617
|
+
name: t.name,
|
|
618
|
+
todo: missing.length === 0 ? undefined : gateComment(missing, rel),
|
|
619
|
+
body: renderPipeBody(t, rel),
|
|
620
|
+
});
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
// A pipe you commented out yourself was a decision; the tool does not undo it.
|
|
624
|
+
if (found.state === "userCommented") continue;
|
|
625
|
+
if (missing.length === 0) {
|
|
626
|
+
found.state = "live";
|
|
627
|
+
found.todo = undefined;
|
|
628
|
+
} else {
|
|
629
|
+
// Re-stated rather than left alone: filling one of two ids should leave a
|
|
630
|
+
// TODO naming only the one still missing.
|
|
631
|
+
found.state = "gated";
|
|
632
|
+
found.todo = gateComment(missing, rel);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// The header is generated prose, so a run that recognises its own marker
|
|
637
|
+
// refreshes it. Notes belong below it, where they are preserved.
|
|
638
|
+
if (parsed.header[0] === HEADER_MARKER) parsed.header = pipesHeader(rel);
|
|
639
|
+
|
|
640
|
+
// Normalise the trailing blank run before appending, so repeated --force runs
|
|
641
|
+
// do not accumulate empty lines at the end of the file.
|
|
642
|
+
while (
|
|
643
|
+
parsed.body.at(-1)?.kind === "raw" &&
|
|
644
|
+
(parsed.body.at(-1) as { line: string }).line === ""
|
|
645
|
+
) {
|
|
646
|
+
parsed.body.pop();
|
|
647
|
+
}
|
|
648
|
+
for (const item of fresh) parsed.body.push({ kind: "raw", line: "" }, item);
|
|
649
|
+
parsed.body.push({ kind: "raw", line: "" });
|
|
650
|
+
|
|
651
|
+
return renderParsed(parsed);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function loadReadme(): string {
|
|
655
|
+
return `# flawless-react load pipes
|
|
656
|
+
|
|
657
|
+
Generated crust pipelines targeting the analysed pages and the GET API routes
|
|
658
|
+
their components fetch. Mutating endpoints are deliberately excluded — add
|
|
659
|
+
them by hand when you have a safe target environment.
|
|
660
|
+
|
|
661
|
+
Run: \`bun run flawless:load\` (crust must be installed — see the repo's crust
|
|
662
|
+
setup or https://github.com/lariocpt/crust).
|
|
663
|
+
|
|
664
|
+
Point \`LOAD_BASE\` at a PRODUCTION build — \`bun run build && bun run start\` —
|
|
665
|
+
not at \`next dev\`. The dev server compiles routes on demand, so the first
|
|
666
|
+
request to each one measures the compiler and every later one measures a warm
|
|
667
|
+
cache; neither is the number you want. \`routes.setup.ts\` seeds the dynamic-segment
|
|
668
|
+
ids from \`../e2e/params.json\` before any pipe runs — a missing id fails the run
|
|
669
|
+
naming the route, and an expired \`LOAD_AUTH\` fails it too, so a green run is
|
|
670
|
+
proven authenticated. Set \`LOAD_AUTH\` to a header line (\`cookie: session=…\`, or
|
|
671
|
+
\`accept: */*\` for a public app). Source \`load/env.sh\` only when running crust
|
|
672
|
+
directly, past the setup module.
|
|
673
|
+
|
|
674
|
+
Baselines: after a good run, copy \`last-<name>.json\` to \`baseline-<name>.json\`.
|
|
675
|
+
Subsequent runs fail when p95 exceeds 2x the baseline.
|
|
676
|
+
|
|
677
|
+
\`routes.pipes\` is yours after generation. \`--force\` merges this run's routes
|
|
678
|
+
into it rather than replacing it: a rate you tuned, a pipe you commented out and
|
|
679
|
+
a POST flow you added by hand all survive, and a pipe that was gated on a missing
|
|
680
|
+
id turns back on once you fill that id in \`../e2e/params.json\`.
|
|
681
|
+
|
|
682
|
+
This README is not yours — it is regenerated by \`--force\`, so that a later
|
|
683
|
+
version of flawless-react can tell you something it worked out after your suite
|
|
684
|
+
was generated. Put your own notes in \`routes.pipes\`, below its header.
|
|
685
|
+
`;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Exports LOAD_<PARAM> from the ids already filled in for the e2e task.
|
|
690
|
+
*
|
|
691
|
+
* The load pipes and the e2e specs need the same seeded ids. Two hand-maintained
|
|
692
|
+
* copies of the same eight UUIDs drift on the first change, and the drift is
|
|
693
|
+
* silent — a load run against a stale id just reports a 404 rate.
|
|
694
|
+
*/
|
|
695
|
+
export function loadEnvScript(rel: string): string {
|
|
696
|
+
return `#!/usr/bin/env bash
|
|
697
|
+
# source ${underOutDir(rel, "load/env.sh")}
|
|
698
|
+
#
|
|
699
|
+
# Exports LOAD_<PARAM> for every id in ${underOutDir(rel, "e2e/params.json")}, so the load
|
|
700
|
+
# pipes and the e2e specs cannot disagree about which records they target. The
|
|
701
|
+
# bundled routes.setup.ts seeds those same ids before every pipe — this file is
|
|
702
|
+
# for running crust directly, or for any other tool that wants the ids as
|
|
703
|
+
# environment variables.
|
|
704
|
+
# Do not edit: this file is regenerated on every run. Set LOAD_BASE and
|
|
705
|
+
# LOAD_AUTH in your shell (or a .env you source alongside this); see README.md.
|
|
706
|
+
set -a
|
|
707
|
+
# Locate params.json without depending on which shell sourced this file. The
|
|
708
|
+
# shebang above does NOT apply to a sourced script, so \${BASH_SOURCE[0]} is
|
|
709
|
+
# empty under zsh — dirname then returned ".", the lookup missed, and every
|
|
710
|
+
# LOAD_<PARAM> silently went unset. The pipes still ran, requesting URLs with
|
|
711
|
+
# empty path segments: 22 of 33 measured the 404 handler and reported the app.
|
|
712
|
+
_self=""
|
|
713
|
+
if [ -n "\${BASH_SOURCE:-}" ]; then
|
|
714
|
+
_self="\${BASH_SOURCE[0]}"
|
|
715
|
+
elif [ -n "\${ZSH_VERSION:-}" ]; then
|
|
716
|
+
# Hidden behind eval so bash never parses zsh-only syntax.
|
|
717
|
+
eval '_self="\${(%):-%x}"'
|
|
718
|
+
fi
|
|
719
|
+
if [ -n "$_self" ] && [ -d "$(dirname "$_self")/../e2e" ]; then
|
|
720
|
+
_params="$(cd "$(dirname "$_self")/../e2e" && pwd)/params.json"
|
|
721
|
+
else
|
|
722
|
+
# Fall back to the working directory the pipes themselves assume: their
|
|
723
|
+
# --out ${underOutDir(rel, "load/last-*.json")} paths are relative to the project root.
|
|
724
|
+
# This has to track the real out dir — hardcoded "flawless", a run with
|
|
725
|
+
# --out qa left the one fallback that exists for this case resolving to a
|
|
726
|
+
# directory that was never created, so it silently exported nothing.
|
|
727
|
+
_params="$PWD/${underOutDir(rel, "e2e/params.json")}"
|
|
728
|
+
fi
|
|
729
|
+
if ! command -v jq >/dev/null 2>&1; then
|
|
730
|
+
# Without this the only signal is the shell's own "jq: command not found",
|
|
731
|
+
# which does not say what it costs: the pipes still run, requesting URLs with
|
|
732
|
+
# empty path segments, and report the 404 handler's numbers as the app's.
|
|
733
|
+
echo "flawless: jq not found — no LOAD_<PARAM> exported. The pipes would request URLs with empty path segments and measure your 404 handler. Install jq, or export the ids yourself." >&2
|
|
734
|
+
elif [ -f "$_params" ]; then
|
|
735
|
+
while IFS="=" read -r _k _v; do
|
|
736
|
+
[ -n "$_k" ] && [ -n "$_v" ] && export "LOAD_$(echo "$_k" | tr '[:lower:]' '[:upper:]')=$_v"
|
|
737
|
+
done < <(
|
|
738
|
+
# Defaults first, then per-route overrides — but only the ones actually
|
|
739
|
+
# filled in. Every route entry starts as "", and an unfiltered merge would
|
|
740
|
+
# let those blanks overwrite a default that IS set.
|
|
741
|
+
jq -r '(.defaults // {}) + (reduce (.routes // {} | to_entries[]) as $r
|
|
742
|
+
({}; . + ($r.value | with_entries(select(.value != "")))))
|
|
743
|
+
| to_entries[] | select(.value != "") | "\\(.key)=\\(.value)"' "$_params"
|
|
744
|
+
)
|
|
745
|
+
else
|
|
746
|
+
echo "flawless: no e2e/params.json — set LOAD_<PARAM> yourself" >&2
|
|
747
|
+
fi
|
|
748
|
+
set +a
|
|
749
|
+
`;
|
|
750
|
+
}
|