@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,463 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { appReactPin, mergePackageJson, repairReactPin } from "../packageJson.ts";
|
|
5
|
+
import { PNPM_SUITE_SETTINGS, tsConfig } from "../scaffold/templates.ts";
|
|
6
|
+
import { SUITE_COMMAND, SUITE_SCRIPT, VITEST_CONFIG } from "../suiteScripts.ts";
|
|
7
|
+
import { classifyStamped, stamp } from "./stamp.ts";
|
|
8
|
+
import { renderUnitBrowser, type UnitBrowserRenderOptions } from "./templates.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Everything the unit-browser scaffolder takes.
|
|
12
|
+
*
|
|
13
|
+
* It EXTENDS the renderer's options instead of re-declaring a subset, because
|
|
14
|
+
* the two types describe ONE object: `scaffoldUnitBrowser` hands this same
|
|
15
|
+
* options value to `renderUnitBrowser` unchanged. A hand-copied subset is a
|
|
16
|
+
* silently shrinking contract — `unitAndBrowser.ts` builds the options in a
|
|
17
|
+
* variable and sets `multiRootAliases`, `appEnvRoot` and `todos` on it, and
|
|
18
|
+
* TypeScript accepts that even while this type does not name them, because
|
|
19
|
+
* excess-property checks run on object literals and not on variables. That is
|
|
20
|
+
* how the emitted manifest lost its `reactPinDir` anchor: the field was live at
|
|
21
|
+
* runtime, read by the config, and absent from the type the caller supposedly
|
|
22
|
+
* satisfied.
|
|
23
|
+
*
|
|
24
|
+
* What remains here is only what the scaffolder itself has an opinion about.
|
|
25
|
+
* Every other field — providers, aliases, the React pin and its anchor,
|
|
26
|
+
* colocation, the todo counter, the app's env root — is documented once, on
|
|
27
|
+
* UnitBrowserRenderOptions.
|
|
28
|
+
*/
|
|
29
|
+
export interface ScaffoldUnitBrowserOptions extends UnitBrowserRenderOptions {
|
|
30
|
+
skipInnerPackageJson?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* The run is a Next.js suite living under the app. The manifest is emitted
|
|
33
|
+
* there too (a self-contained install needs it), but WITHOUT its "type"
|
|
34
|
+
* field: the playwright configs around it are written assuming the app's
|
|
35
|
+
* CJS transpile default (the nextjs e2e config anchors on __dirname), and
|
|
36
|
+
* a package.json placed above them decides how Playwright loads them.
|
|
37
|
+
*/
|
|
38
|
+
nextjs?: boolean;
|
|
39
|
+
/** Overwrite existing colocated specs (in-outDir files are always rewritten). */
|
|
40
|
+
force?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ScaffoldUnitBrowserResult {
|
|
44
|
+
written: string[];
|
|
45
|
+
/** Colocated spec targets left untouched because they already exist (no --force). */
|
|
46
|
+
skipped: string[];
|
|
47
|
+
/** Colocated specs --force kept because their stamp no longer matches the body. */
|
|
48
|
+
keptEdited: string[];
|
|
49
|
+
/** Colocated specs --force kept because they carry no stamp at all (pre-stamp). */
|
|
50
|
+
keptLegacy: string[];
|
|
51
|
+
/** Browser specs removed: their component left the mountable set and the file was untouched tool output. */
|
|
52
|
+
staleRemoved: string[];
|
|
53
|
+
/** Browser specs whose component left the mountable set, kept because a human may have worked in them. */
|
|
54
|
+
staleKept: StaleBrowserSpec[];
|
|
55
|
+
/** Unit specs the runner will pick up with no component behind them. Named, never removed — see findOrphanUnitSpecs. */
|
|
56
|
+
orphanUnitSpecs: string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function scaffoldUnitBrowser(
|
|
60
|
+
opts: ScaffoldUnitBrowserOptions,
|
|
61
|
+
): Promise<ScaffoldUnitBrowserResult> {
|
|
62
|
+
const outDir = path.resolve(opts.outDir);
|
|
63
|
+
await mkdir(outDir, { recursive: true });
|
|
64
|
+
|
|
65
|
+
const files = renderUnitBrowser(opts);
|
|
66
|
+
const written: string[] = [];
|
|
67
|
+
const skipped: string[] = [];
|
|
68
|
+
const keptEdited: string[] = [];
|
|
69
|
+
const keptLegacy: string[] = [];
|
|
70
|
+
for (const f of files) {
|
|
71
|
+
const abs = f.absPath ?? path.join(outDir, f.relPath);
|
|
72
|
+
if (f.skipIfExists && existsSync(abs)) {
|
|
73
|
+
if (!opts.force) {
|
|
74
|
+
skipped.push(abs);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
// --force refreshes what is still exactly ours and keeps everything a
|
|
78
|
+
// human may have worked on. "Ours" is what the stamp proves; a file
|
|
79
|
+
// from before stamping existed can't prove it either way, so it is
|
|
80
|
+
// kept too and reported separately (delete it to regenerate).
|
|
81
|
+
const verdict = classifyStamped(await readFile(abs, "utf8"));
|
|
82
|
+
if (verdict === "edited") {
|
|
83
|
+
keptEdited.push(abs);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (verdict === "stampless") {
|
|
87
|
+
keptLegacy.push(abs);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
await mkdir(path.dirname(abs), { recursive: true });
|
|
92
|
+
// Only colocated specs (the skipIfExists set) are stamped: the in-outDir
|
|
93
|
+
// files are identified by being rewritten every run, not by provenance.
|
|
94
|
+
// This stamp hashes the raw render; core.ts restamps after the host
|
|
95
|
+
// project's formatter has had its turn.
|
|
96
|
+
await Bun.write(abs, f.skipIfExists ? stamp(f.content) : f.content);
|
|
97
|
+
written.push(abs);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The config used to be emitted as vitest.config.ts and is now .mts, so a
|
|
101
|
+
// suite generated before this leaves the old one sitting beside the new one.
|
|
102
|
+
// Two configs in one directory is worse than either: vitest picks by its own
|
|
103
|
+
// precedence, so the file a reader edits and the file that runs can differ
|
|
104
|
+
// silently. This name has only ever been ours, and it has never been
|
|
105
|
+
// writeOnce, so removing the stale one is safe.
|
|
106
|
+
// Compared as paths rather than against the literal, so that if VITEST_CONFIG
|
|
107
|
+
// ever goes back to ".ts" this deletes nothing instead of deleting the file
|
|
108
|
+
// the loop above just wrote.
|
|
109
|
+
const stale = path.join(outDir, "vitest.config.ts");
|
|
110
|
+
if (stale !== path.join(outDir, VITEST_CONFIG) && existsSync(stale)) {
|
|
111
|
+
await rm(stale);
|
|
112
|
+
written.push(stale);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Browser specs are rewritten every run ONLY while their component stays
|
|
116
|
+
// host-mountable. When it leaves the mountable set (a new guard, a render
|
|
117
|
+
// mode change, a deleted file) the spec is simply no longer rendered — and
|
|
118
|
+
// without this sweep it sits in browser/ forever, failing at mount with
|
|
119
|
+
// "unknown component": a suite red by the tool's own doing. Provenance
|
|
120
|
+
// decides, exactly as for the colocated specs: an untouched stamp is pure
|
|
121
|
+
// tool output (removed and reported); a mismatched stamp is hand work
|
|
122
|
+
// (kept, reported); no stamp at all is unprovable (kept, reported).
|
|
123
|
+
const staleRemoved: string[] = [];
|
|
124
|
+
const staleKept: StaleBrowserSpec[] = [];
|
|
125
|
+
if (opts.browserSpecs !== false) {
|
|
126
|
+
const rendered = new Set(
|
|
127
|
+
files
|
|
128
|
+
.filter((f) => f.relPath.startsWith("browser/") && f.relPath.endsWith(".spec.ts"))
|
|
129
|
+
.map((f) => f.relPath),
|
|
130
|
+
);
|
|
131
|
+
const browserDir = path.join(outDir, "browser");
|
|
132
|
+
if (existsSync(browserDir)) {
|
|
133
|
+
for (const entry of (await readdir(browserDir)).sort()) {
|
|
134
|
+
if (!entry.endsWith(".spec.ts")) continue;
|
|
135
|
+
if (rendered.has(`browser/${entry}`)) continue;
|
|
136
|
+
const abs = path.join(browserDir, entry);
|
|
137
|
+
const verdict = classifyStamped(await readFile(abs, "utf8"));
|
|
138
|
+
if (verdict === "untouched") {
|
|
139
|
+
await rm(abs);
|
|
140
|
+
staleRemoved.push(abs);
|
|
141
|
+
} else {
|
|
142
|
+
staleKept.push({ abs, verdict });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Unit specs the suite will still run with no component behind them. Naming
|
|
149
|
+
// them, never deleting them — see findOrphanUnitSpecs for why.
|
|
150
|
+
const orphanUnitSpecs = await findOrphanUnitSpecs(
|
|
151
|
+
outDir,
|
|
152
|
+
new Set(files.filter((f) => f.relPath.startsWith("unit/")).map((f) => f.relPath)),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
if (!opts.skipInnerPackageJson) {
|
|
156
|
+
await writeSuitePackageJson(
|
|
157
|
+
outDir,
|
|
158
|
+
opts.browserSpecs !== false,
|
|
159
|
+
Boolean(opts.mswSetup),
|
|
160
|
+
Boolean(opts.nextjs),
|
|
161
|
+
opts.reactPinDir,
|
|
162
|
+
);
|
|
163
|
+
written.push(path.join(outDir, "package.json"));
|
|
164
|
+
if (await ensurePnpmSuiteSettings(path.join(outDir, "pnpm-workspace.yaml"))) {
|
|
165
|
+
written.push(path.join(outDir, "pnpm-workspace.yaml"));
|
|
166
|
+
}
|
|
167
|
+
// The suite type-checks in isolation (docs promise); emitted once, then
|
|
168
|
+
// the user's — the same write-once promise as the yaml above.
|
|
169
|
+
const tsPath = path.join(outDir, "tsconfig.json");
|
|
170
|
+
if (!existsSync(tsPath)) {
|
|
171
|
+
await writeFile(tsPath, tsConfig());
|
|
172
|
+
written.push(tsPath);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return { written, skipped, keptEdited, keptLegacy, staleRemoved, staleKept, orphanUnitSpecs };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Unit specs the suite will still run, with no component behind them.
|
|
180
|
+
*
|
|
181
|
+
* Report-only, and the reason is provenance. In-outDir specs are identified by
|
|
182
|
+
* being rewritten every run and therefore carry NO stamp (see the write loop),
|
|
183
|
+
* which makes a hand-hardened spec byte-identical to an orphan one. The tool can
|
|
184
|
+
* name those files; it cannot prove any of them is safe to delete — and
|
|
185
|
+
* deleting a suite spec because an analysis stopped pointing at it is how
|
|
186
|
+
* coverage disappears quietly.
|
|
187
|
+
*
|
|
188
|
+
* Naming is still worth having, because an orphan is not coverage: it is a red
|
|
189
|
+
* import at the start of every run. Components get orphaned by refactors, by
|
|
190
|
+
* renames, and now by the React Native rule (nativeDialect.ts), which can strand
|
|
191
|
+
* a whole directory of specs at once.
|
|
192
|
+
*
|
|
193
|
+
* Recursive to match the glob the suite emits over its own `unit/` directory, and keyed
|
|
194
|
+
* on the writer's own relPath (`unit/<spec>.test.tsx`), so what is reported is exactly
|
|
195
|
+
* what the runner will pick up.
|
|
196
|
+
*/
|
|
197
|
+
export async function findOrphanUnitSpecs(
|
|
198
|
+
outDir: string,
|
|
199
|
+
rendered: Set<string>,
|
|
200
|
+
): Promise<string[]> {
|
|
201
|
+
const dir = path.join(outDir, "unit");
|
|
202
|
+
if (!existsSync(dir)) return [];
|
|
203
|
+
const orphans: string[] = [];
|
|
204
|
+
const walk = async (at: string, rel: string): Promise<void> => {
|
|
205
|
+
for (const entry of await readdir(at, { withFileTypes: true })) {
|
|
206
|
+
const childRel = `${rel}/${entry.name}`;
|
|
207
|
+
if (entry.isDirectory()) {
|
|
208
|
+
await walk(path.join(at, entry.name), childRel);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (!/\.test\.(ts|tsx)$/.test(entry.name)) continue;
|
|
212
|
+
if (rendered.has(childRel)) continue;
|
|
213
|
+
orphans.push(path.join(at, entry.name));
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
await walk(dir, "unit");
|
|
217
|
+
return orphans.sort();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** A stale browser spec the writer refused to remove, and why. */
|
|
221
|
+
export interface StaleBrowserSpec {
|
|
222
|
+
abs: string;
|
|
223
|
+
/** "edited" = stamp present, body differs (hand work); "stampless" = no provenance at all. */
|
|
224
|
+
verdict: "edited" | "stampless";
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* What unit-browser contributes to the suite's package.json.
|
|
229
|
+
*
|
|
230
|
+
* Exported so tests/core.test.ts can compare it against perf-harness's without
|
|
231
|
+
* going through disk — the only place two emitters' declarations can be seen
|
|
232
|
+
* side by side, and so the only place a disagreement between them is visible.
|
|
233
|
+
*
|
|
234
|
+
* `test:perf` is deliberately absent. It names perf-harness's config and
|
|
235
|
+
* directory, and `browserSpecs` is set from exactly `selectedTasks.includes
|
|
236
|
+
* ("perf-harness")` (unitAndBrowser.ts), so this emitter could only ever declare
|
|
237
|
+
* it in the runs where perf-harness declares it too. A second, differently
|
|
238
|
+
* spelled copy of a key that is never needed alone is how the two files came to
|
|
239
|
+
* disagree in the first place.
|
|
240
|
+
*/
|
|
241
|
+
export function suitePackageJson(
|
|
242
|
+
outDir: string,
|
|
243
|
+
withBrowser: boolean,
|
|
244
|
+
msw = false,
|
|
245
|
+
nextjs = false,
|
|
246
|
+
reactPinDir?: string,
|
|
247
|
+
): string {
|
|
248
|
+
// The suite's react IS the copy every plane renders with (aliases + host
|
|
249
|
+
// resolution point at the suite's node_modules), so it must be the
|
|
250
|
+
// version the app's component code was written against — and it must be the
|
|
251
|
+
// SAME copy the config's react aliases anchor on, or the two derivations
|
|
252
|
+
// disagree and one plane ends up with two Reacts. See detectAppReact /
|
|
253
|
+
// appReactPin.
|
|
254
|
+
const pin = appReactPin(outDir, reactPinDir);
|
|
255
|
+
const typesPin = pin ? `^${pin.react.split(".")[0]}.0.0` : "^18.3.0";
|
|
256
|
+
// RTL majors split at React 18: 13+ peer-require react ^18 and their
|
|
257
|
+
// dist/pure.js hard-requires the react-dom 18 client entry (a canary
|
|
258
|
+
// proved 14.3.1 fails at collection under React 17). 12.1.5 is the last
|
|
259
|
+
// major whose peers carry <18; its bundled dom-v8 covers every API the
|
|
260
|
+
// spec templates emit.
|
|
261
|
+
const legacyRtl = pin && Number(pin.react.split(".")[0]) <= 17;
|
|
262
|
+
const rtlPin = legacyRtl ? "^12.1.5" : "^16.0.0";
|
|
263
|
+
return `${JSON.stringify(
|
|
264
|
+
{
|
|
265
|
+
name: `${path.basename(outDir).replace(/[^a-zA-Z0-9_-]+/g, "-")}-suite`,
|
|
266
|
+
version: "0.0.0",
|
|
267
|
+
private: true,
|
|
268
|
+
// A standalone suite owns its loader: "module", with configs and
|
|
269
|
+
// sources written for ESM. A nextjs suite's manifest must NOT say it:
|
|
270
|
+
// the file sits above playwright configs emitted against the app's
|
|
271
|
+
// CJS transpile default (the nextjs e2e config anchors on __dirname),
|
|
272
|
+
// and the nearest package.json picks Playwright's loader — inheriting
|
|
273
|
+
// "module" here crashed those configs at import with "__dirname is not
|
|
274
|
+
// defined in ES module scope".
|
|
275
|
+
...(nextjs ? {} : { type: "module" }),
|
|
276
|
+
scripts: {
|
|
277
|
+
[SUITE_SCRIPT.unit]: SUITE_COMMAND.unit,
|
|
278
|
+
// browser/ is only rendered when browserSpecs is on. Declared
|
|
279
|
+
// unconditionally, `--tasks unit-browser` wrote a script pointing at a
|
|
280
|
+
// config that run never created, and --run-tests then invoked it and
|
|
281
|
+
// reported a correct suite as failing.
|
|
282
|
+
...(withBrowser ? { [SUITE_SCRIPT.browser]: SUITE_COMMAND.browser } : {}),
|
|
283
|
+
},
|
|
284
|
+
dependencies: {
|
|
285
|
+
react: pin?.react ?? "^18.3.0",
|
|
286
|
+
"react-dom": pin?.reactDom ?? "^18.3.0",
|
|
287
|
+
// The perf host's main.tsx imports react-scan; withBrowser IS
|
|
288
|
+
// "perf-harness ran", so the host is in this suite. Same pin as
|
|
289
|
+
// perf-harness's own emitter — divergent pins meant the file's
|
|
290
|
+
// bytes depended on which task ran last.
|
|
291
|
+
...(withBrowser ? { "react-scan": "^0.4.3" } : {}),
|
|
292
|
+
// mswSetup IS "setup.ts wires the MSW server", and every nextjs
|
|
293
|
+
// suite fetches through it — a self-contained install cannot
|
|
294
|
+
// assume the APP ships msw (chanhdai happened to; that is luck,
|
|
295
|
+
// not a contract).
|
|
296
|
+
...(msw ? { msw: "^2.7.0" } : {}),
|
|
297
|
+
},
|
|
298
|
+
devDependencies: {
|
|
299
|
+
"@playwright/test": "^1.49.0",
|
|
300
|
+
// next's trace tracer require()s this OPTIONAL peer at module load.
|
|
301
|
+
// webpack tolerates the miss; the perf host's bun transpiler hard-
|
|
302
|
+
// errors on it. Ship the real package — without a registered
|
|
303
|
+
// provider it is a faithful no-op, identical to production behavior.
|
|
304
|
+
"@opentelemetry/api": "^1.9.0",
|
|
305
|
+
"@types/node": "^22.0.0",
|
|
306
|
+
"@types/react": typesPin,
|
|
307
|
+
"@types/react-dom": typesPin,
|
|
308
|
+
typescript: "^5.6.0",
|
|
309
|
+
vitest: "^2.1.0",
|
|
310
|
+
"@vitest/ui": "^2.1.0",
|
|
311
|
+
jsdom: "^25.0.0",
|
|
312
|
+
"@testing-library/react": rtlPin,
|
|
313
|
+
// user-event 14 peers @testing-library/dom >=7.21.4. Modern RTL
|
|
314
|
+
// ships dom 10 as a dependency; RTL 12's dom 8 is only reachable
|
|
315
|
+
// transitively, and pnpm's strict linking does not expose it to a
|
|
316
|
+
// sibling — declare it so the peer resolves deterministically.
|
|
317
|
+
...(legacyRtl ? { "@testing-library/dom": "^8.20.1" } : {}),
|
|
318
|
+
"@testing-library/user-event": "^14.5.0",
|
|
319
|
+
"@testing-library/jest-dom": "^6.5.0",
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
null,
|
|
323
|
+
2,
|
|
324
|
+
)}\n`;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Through the shared merge, like perf-harness — the version pins above are
|
|
329
|
+
* defaults an existing file overrides, not values that overwrite it.
|
|
330
|
+
*/
|
|
331
|
+
async function writeSuitePackageJson(
|
|
332
|
+
outDir: string,
|
|
333
|
+
withBrowser: boolean,
|
|
334
|
+
msw: boolean,
|
|
335
|
+
nextjs = false,
|
|
336
|
+
reactPinDir?: string,
|
|
337
|
+
): Promise<void> {
|
|
338
|
+
const pkgPath = path.join(outDir, "package.json");
|
|
339
|
+
const generated = suitePackageJson(outDir, withBrowser, msw, nextjs, reactPinDir);
|
|
340
|
+
if (!existsSync(pkgPath)) {
|
|
341
|
+
await writeFile(pkgPath, generated);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
// Same anchor as the emitted config's react aliases (see appReactPin).
|
|
345
|
+
const pin = appReactPin(outDir, reactPinDir);
|
|
346
|
+
const merged = repairRtlPin(
|
|
347
|
+
repairReactPin(mergePackageJson(await readFile(pkgPath, "utf8"), generated), pin),
|
|
348
|
+
pin,
|
|
349
|
+
);
|
|
350
|
+
// The merge lets an existing file win on every key — except in nextjs
|
|
351
|
+
// mode, where the loader switch is the TOOL's key (see suitePackageJson):
|
|
352
|
+
// a manifest that already says "module" would pin ESM onto the playwright
|
|
353
|
+
// configs around it and kill their __dirname at import. Everything else
|
|
354
|
+
// in the file stays the user's.
|
|
355
|
+
if (nextjs && /"type":/.test(merged)) {
|
|
356
|
+
const parsed = JSON.parse(merged) as Record<string, unknown>;
|
|
357
|
+
delete parsed.type;
|
|
358
|
+
await writeFile(pkgPath, `${JSON.stringify(parsed, null, 2)}\n`);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
await writeFile(pkgPath, merged);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* The merge lets an existing manifest out-vote every pin — but a pin that
|
|
366
|
+
* peer-requires react ^18 (RTL 13+) inside a React-17 suite cannot even be
|
|
367
|
+
* collected: dist/pure.js requires the react-dom 18 client entry that 17
|
|
368
|
+
* does not ship. When the surviving range is one this tool itself emitted
|
|
369
|
+
* before that was known (a canary proved ^14.0.0 fails at collection), the
|
|
370
|
+
* merge yields that one key back. Ranges the tool never offered, or that
|
|
371
|
+
* merely degrade rather than fail outright (RTL 12 on react 18 renders in
|
|
372
|
+
* legacy mode with a warning), are left alone.
|
|
373
|
+
*/
|
|
374
|
+
export function repairRtlPin(
|
|
375
|
+
merged: string,
|
|
376
|
+
pin: { react: string; reactDom: string } | undefined,
|
|
377
|
+
): string {
|
|
378
|
+
const appMajor = pin ? Number(pin.react.split(".")[0]) : undefined;
|
|
379
|
+
if (appMajor === undefined || !(appMajor <= 17)) return merged;
|
|
380
|
+
let parsed: Record<string, unknown>;
|
|
381
|
+
try {
|
|
382
|
+
parsed = JSON.parse(merged) as Record<string, unknown>;
|
|
383
|
+
} catch {
|
|
384
|
+
return merged;
|
|
385
|
+
}
|
|
386
|
+
const dev = parsed.devDependencies as Record<string, string> | undefined;
|
|
387
|
+
const range = dev?.["@testing-library/react"];
|
|
388
|
+
const rtlMajor = range ? /^\D*(\d+)/.exec(range)?.[1] : undefined;
|
|
389
|
+
if (!dev || rtlMajor === undefined || Number(rtlMajor) < 13) return merged;
|
|
390
|
+
dev["@testing-library/react"] = "^12.1.5";
|
|
391
|
+
if (!dev["@testing-library/dom"]) dev["@testing-library/dom"] = "^8.20.1";
|
|
392
|
+
return `${JSON.stringify(parsed, null, 2)}\n`;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Build scripts a suite install must run (pnpm 10+ refuses them by default
|
|
397
|
+
* and EXITS 1 doing so). esbuild's postinstall IS its binary — without it
|
|
398
|
+
* vitest dies at startup; msw's copies its service worker.
|
|
399
|
+
*/
|
|
400
|
+
const SUITE_BUILD_ALLOWLIST = ["esbuild", "msw"];
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* The suite's pnpm-workspace.yaml: write-once in spirit, self-healing in
|
|
404
|
+
* fact. Every dogfood suite install died on ERR_PNPM_IGNORED_BUILDS because
|
|
405
|
+
* the allowlist had not grown with the suite's dependencies; a file that is
|
|
406
|
+
* merely re-written-when-absent repeats that on existing suites. A missing
|
|
407
|
+
* file is written whole; an existing one gains only the entries it lacks —
|
|
408
|
+
* user keys are untouched, nothing is ever duplicated, and a key the user
|
|
409
|
+
* rewrote inline (flow style, not a block) is theirs to keep, so the pass
|
|
410
|
+
* hands off entirely.
|
|
411
|
+
*/
|
|
412
|
+
export async function ensurePnpmSuiteSettings(wsPath: string): Promise<boolean> {
|
|
413
|
+
if (!existsSync(wsPath)) {
|
|
414
|
+
await writeFile(wsPath, PNPM_SUITE_SETTINGS);
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
const body = await readFile(wsPath, "utf8");
|
|
418
|
+
const inList = (name: string) => new RegExp(`^[ \\t]*-[ \\t]+${name}[ \\t]*$`, "m").test(body);
|
|
419
|
+
const flagged = (name: string) =>
|
|
420
|
+
new RegExp(`^[ \\t]*${name}:[ \\t]*true[ \\t]*$`, "m").test(body);
|
|
421
|
+
const items = SUITE_BUILD_ALLOWLIST.filter((n) => !inList(n)).map((n) => ` - ${n}`);
|
|
422
|
+
const flags = SUITE_BUILD_ALLOWLIST.filter((n) => !flagged(n)).map((n) => ` ${n}: true`);
|
|
423
|
+
if (items.length === 0 && flags.length === 0) return false;
|
|
424
|
+
let changed = false;
|
|
425
|
+
let next = body;
|
|
426
|
+
if (items.length > 0) {
|
|
427
|
+
const merged = appendToYamlBlock(next, "onlyBuiltDependencies:", items);
|
|
428
|
+
if (merged !== null) {
|
|
429
|
+
next = merged;
|
|
430
|
+
changed = true;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if (flags.length > 0) {
|
|
434
|
+
const merged = appendToYamlBlock(next, "allowBuilds:", flags);
|
|
435
|
+
if (merged !== null) {
|
|
436
|
+
next = merged;
|
|
437
|
+
changed = true;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
if (!changed) return false;
|
|
441
|
+
await writeFile(wsPath, next);
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Insert lines at the end of a top-level YAML block (creating it at EOF when
|
|
447
|
+
* absent). Null means hands off: the key carries an inline value the user
|
|
448
|
+
* wrote — duplicating the key would make the file invalid YAML.
|
|
449
|
+
*/
|
|
450
|
+
function appendToYamlBlock(body: string, header: string, lines: string[]): string | null {
|
|
451
|
+
const all = body.split("\n");
|
|
452
|
+
const at = all.findIndex((l) => l.trimStart().startsWith(header));
|
|
453
|
+
if (at === -1) {
|
|
454
|
+
const base = body.endsWith("\n") ? body : `${body}\n`;
|
|
455
|
+
return `${base}${header}\n${lines.join("\n")}\n`;
|
|
456
|
+
}
|
|
457
|
+
const atLine = all[at] ?? "";
|
|
458
|
+
if (atLine.slice(atLine.indexOf(header) + header.length).trim() !== "") return null;
|
|
459
|
+
let end = at + 1;
|
|
460
|
+
while (end < all.length && /^[ \t]+\S/.test(all[end] ?? "")) end++;
|
|
461
|
+
all.splice(end, 0, ...lines);
|
|
462
|
+
return all.join("\n");
|
|
463
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import type { MockField, MockShape, PropsShape, PropValue } from "../types.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolves an `equalsDep` note — the field of another dependency this one is
|
|
5
|
+
* compared against — to the literal text that dep's own fixture holds there.
|
|
6
|
+
* Undefined when the name is not a dep, or the field has no readable value.
|
|
7
|
+
*/
|
|
8
|
+
export type DepFieldResolver = (dep: string, field: string) => string | undefined;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Renders an inferred MockShape as JavaScript source text for a generated
|
|
12
|
+
* spec. Every branch answers the same question: which literal keeps the
|
|
13
|
+
* component rendering when the inference was only half right?
|
|
14
|
+
*/
|
|
15
|
+
export function mockLiteral(
|
|
16
|
+
shape: MockShape,
|
|
17
|
+
seed: string,
|
|
18
|
+
props?: PropsShape,
|
|
19
|
+
depValues?: DepFieldResolver,
|
|
20
|
+
fill?: unknown,
|
|
21
|
+
): string {
|
|
22
|
+
switch (shape.kind) {
|
|
23
|
+
case "array":
|
|
24
|
+
// Exactly one element. An empty array would let a spec pass without ever
|
|
25
|
+
// rendering the row markup it claims to check.
|
|
26
|
+
return shape.element
|
|
27
|
+
? `[${mockLiteral(shape.element, seed, props, depValues, Array.isArray(fill) ? fill[0] : undefined)}]`
|
|
28
|
+
: "[]";
|
|
29
|
+
case "object":
|
|
30
|
+
return objectLiteral(shape.fields, props, depValues, plainObject(fill));
|
|
31
|
+
case "number":
|
|
32
|
+
return "1";
|
|
33
|
+
case "date-string":
|
|
34
|
+
return quote("2024-01-01T00:00:00.000Z");
|
|
35
|
+
case "string":
|
|
36
|
+
return quote(`flawless-${seed}`);
|
|
37
|
+
case "truthy-object":
|
|
38
|
+
// The only evidence was `if (x)` / `!x`: any non-null object satisfies
|
|
39
|
+
// that, and nothing in the component reads a field off it.
|
|
40
|
+
return "{}";
|
|
41
|
+
case "set":
|
|
42
|
+
// The field's only use was `.has(x)` / `.add(x)` — a live empty Set
|
|
43
|
+
// answers every one of them; an object with a fake `has` property dies
|
|
44
|
+
// on the first call.
|
|
45
|
+
return "new Set()";
|
|
46
|
+
case "map":
|
|
47
|
+
return "new Map()";
|
|
48
|
+
case "date":
|
|
49
|
+
// Only use was Date methods (`.getTime()`, `.toISOString()`). A string
|
|
50
|
+
// has none of them; this has all of them.
|
|
51
|
+
return `new Date("2024-01-01T00:00:00.000Z")`;
|
|
52
|
+
case "unknown":
|
|
53
|
+
// NEVER `undefined`. `undefined` throws on *any* member access, so a
|
|
54
|
+
// single `dep.anything` anywhere in the component fails the generated
|
|
55
|
+
// spec. `{}` only gives way when the component calls an array method on
|
|
56
|
+
// it, which is strictly rarer. This is the fix for the "items.length
|
|
57
|
+
// crashes the generated test" failure mode.
|
|
58
|
+
return "{}";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function objectLiteral(
|
|
63
|
+
fields: MockField[],
|
|
64
|
+
props?: PropsShape,
|
|
65
|
+
depValues?: DepFieldResolver,
|
|
66
|
+
fill?: Record<string, unknown>,
|
|
67
|
+
): string {
|
|
68
|
+
if (fields.length === 0) return "{}";
|
|
69
|
+
return `{ ${fields.map((f) => `${keyText(f.name)}: ${fieldLiteral(f, props, depValues, fill?.[f.name])}`).join(", ")} }`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function fieldLiteral(
|
|
73
|
+
field: MockField,
|
|
74
|
+
props?: PropsShape,
|
|
75
|
+
depValues?: DepFieldResolver,
|
|
76
|
+
fill?: unknown,
|
|
77
|
+
): string {
|
|
78
|
+
// A literal the component compares this field against wins over any
|
|
79
|
+
// synthesised value: `m.status === "active"` against `status:
|
|
80
|
+
// "flawless-status"` is a row the page's own lookup rejects, and twenty-two
|
|
81
|
+
// of the dogfood app's page specs rendered nothing because of it.
|
|
82
|
+
if (field.equals !== undefined) return JSON.stringify(field.equals);
|
|
83
|
+
// The same, where the comparison is against one of the component's own props:
|
|
84
|
+
// `m.building_id === id`, with `id` the route param. Read out of the fixture
|
|
85
|
+
// the spec's DEFAULTS is built from, so the two agree by construction rather
|
|
86
|
+
// than by both spelling "flawless-id".
|
|
87
|
+
if (field.equalsProp && props) {
|
|
88
|
+
const v = atPath(props, field.equalsProp);
|
|
89
|
+
if (v !== undefined) return JSON.stringify(v);
|
|
90
|
+
}
|
|
91
|
+
// And against a field of ANOTHER dep: `m.user_id === user.id`. Resolved to
|
|
92
|
+
// the literal the other dep's fixture actually holds, for the same reason —
|
|
93
|
+
// two independently synthesised spellings never match, and the lookup that
|
|
94
|
+
// compares them guard-exits the page.
|
|
95
|
+
if (field.equalsDep && depValues) {
|
|
96
|
+
const v = depValues(field.equalsDep.var, field.equalsDep.field);
|
|
97
|
+
if (v !== undefined) return v;
|
|
98
|
+
}
|
|
99
|
+
// A value the dep's own return ANNOTATION carries beats a synthesised
|
|
100
|
+
// placeholder, and only a placeholder: usage evidence above already won, and
|
|
101
|
+
// a field whose annotation resolved to another "flawless-" seed gains
|
|
102
|
+
// nothing from the swap (the name-seeded synthesis reads better in a
|
|
103
|
+
// failure). `job.status` fed into a lookup table says nothing about legal
|
|
104
|
+
// statuses; `Promise<Job>`'s z.enum names one, and the child that indexes
|
|
105
|
+
// `ALLOWED_TRANSITIONS[status]` crashes on anything else.
|
|
106
|
+
if (fill !== undefined) {
|
|
107
|
+
const kind = field.shape.kind;
|
|
108
|
+
if (
|
|
109
|
+
(kind === "string" || kind === "unknown") &&
|
|
110
|
+
typeof fill === "string" &&
|
|
111
|
+
!fill.startsWith("flawless-")
|
|
112
|
+
) {
|
|
113
|
+
return JSON.stringify(fill);
|
|
114
|
+
}
|
|
115
|
+
if (kind === "number" && typeof fill === "number") return JSON.stringify(fill);
|
|
116
|
+
}
|
|
117
|
+
// A field with no evidence is a leaf, and a string survives the most uses: it
|
|
118
|
+
// renders as text, it is truthy, and an unexpected property read off it is
|
|
119
|
+
// `undefined` rather than a throw. The bare-`{}` rule above is for a whole
|
|
120
|
+
// dependency, where the component is far more likely to be reading fields.
|
|
121
|
+
if (field.shape.kind === "unknown") return quote(`flawless-${field.name}`);
|
|
122
|
+
return mockLiteral(field.shape, field.name, props, depValues, fill);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The literal ONE field of an object-shaped fixture resolves to — what an
|
|
127
|
+
* `equalsDep` reference reads. Deliberately resolved WITHOUT a dep resolver of
|
|
128
|
+
* its own: one hop is a value two fixtures can agree on, a chain is a graph
|
|
129
|
+
* this has no business walking.
|
|
130
|
+
*/
|
|
131
|
+
export function fieldValueLiteral(field: MockField, props?: PropsShape): string {
|
|
132
|
+
return fieldLiteral(field, props);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The VALUE mirror of mockLiteral — the same fixture, as a PropValue instead
|
|
137
|
+
* of source text, so the predicate certifier can evaluate a page's conditions
|
|
138
|
+
* against the values the spec will actually mock with. Kept beside
|
|
139
|
+
* mockLiteral on purpose: if the two ever disagree, the certifier claims a
|
|
140
|
+
* side the mount does not take. `equalsDep` notes resolve to their synthesised
|
|
141
|
+
* default here (the emit-side resolver is one hop of emit-only knowledge);
|
|
142
|
+
* for evaluating a CONDITION that is the right call — both fixtures hold the
|
|
143
|
+
* SAME value there, whichever it is.
|
|
144
|
+
*/
|
|
145
|
+
export function mockValue(shape: MockShape, seed: string, props?: PropsShape): PropValue {
|
|
146
|
+
switch (shape.kind) {
|
|
147
|
+
case "array":
|
|
148
|
+
return shape.element ? [mockValue(shape.element, seed, props)] : [];
|
|
149
|
+
case "object": {
|
|
150
|
+
const out: Record<string, PropValue> = {};
|
|
151
|
+
for (const f of shape.fields) out[f.name] = fieldValue(f, props);
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
case "number":
|
|
155
|
+
return 1;
|
|
156
|
+
case "date-string":
|
|
157
|
+
return "2024-01-01T00:00:00.000Z";
|
|
158
|
+
case "string":
|
|
159
|
+
return `flawless-${seed}`;
|
|
160
|
+
case "truthy-object":
|
|
161
|
+
return {};
|
|
162
|
+
case "set":
|
|
163
|
+
case "map":
|
|
164
|
+
case "date":
|
|
165
|
+
// The SOURCE literal (mockLiteral) is a live Set/Map/Date. The value
|
|
166
|
+
// mirror only feeds condition evaluation, which asks truthiness — and
|
|
167
|
+
// all three are truthy objects. JSON cannot carry the real thing.
|
|
168
|
+
return {};
|
|
169
|
+
case "unknown":
|
|
170
|
+
return {};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function fieldValue(field: MockField, props?: PropsShape): PropValue {
|
|
175
|
+
if (field.equals !== undefined) return field.equals;
|
|
176
|
+
if (field.equalsProp && props) {
|
|
177
|
+
const v = atPath(props, field.equalsProp);
|
|
178
|
+
if (v !== undefined) return v;
|
|
179
|
+
}
|
|
180
|
+
if (field.shape.kind === "unknown") return `flawless-${field.name}`;
|
|
181
|
+
return mockValue(field.shape, field.name, props);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function plainObject(v: unknown): Record<string, unknown> | undefined {
|
|
185
|
+
return v !== null && typeof v === "object" && !Array.isArray(v)
|
|
186
|
+
? (v as Record<string, unknown>)
|
|
187
|
+
: undefined;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** A prop path into the fixture; undefined at the first hop that is not there. */
|
|
191
|
+
function atPath(props: PropsShape, path: string[]): PropValue | undefined {
|
|
192
|
+
let cur: PropValue | undefined = props as unknown as PropValue;
|
|
193
|
+
for (const key of path) {
|
|
194
|
+
if (!cur || typeof cur !== "object" || Array.isArray(cur)) return undefined;
|
|
195
|
+
cur = (cur as Record<string, PropValue>)[key];
|
|
196
|
+
}
|
|
197
|
+
return cur;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const BARE_KEY = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
201
|
+
|
|
202
|
+
function keyText(name: string): string {
|
|
203
|
+
return BARE_KEY.test(name) ? name : quote(name);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function quote(value: string): string {
|
|
207
|
+
return JSON.stringify(value);
|
|
208
|
+
}
|