@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
package/src/core.ts
ADDED
|
@@ -0,0 +1,1229 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { findUnresolvableImports } from "./analyse/bootability.ts";
|
|
4
|
+
import { analyseFile, createAnalyseSharedContext } from "./analyse/index.ts";
|
|
5
|
+
import { deriveContextProviderWraps, libraryProviderShimsFor } from "./analyse/providerWraps.ts";
|
|
6
|
+
import { affectedSpecs, changedFiles, findRepoRoot } from "./changed.ts";
|
|
7
|
+
import { outDirRel, underOutDir } from "./emitPaths.ts";
|
|
8
|
+
import { detectExpo } from "./expo/detect.ts";
|
|
9
|
+
import {
|
|
10
|
+
detectComponentsGlob,
|
|
11
|
+
detectNextjs,
|
|
12
|
+
ensureFormatterIgnores,
|
|
13
|
+
ignoreEntries,
|
|
14
|
+
ignoreRootFor,
|
|
15
|
+
mergeIntoNextPackageJson,
|
|
16
|
+
updateGitignore,
|
|
17
|
+
} from "./nextjs/detect.ts";
|
|
18
|
+
import { emitNextjsMocks } from "./nextjs/emit.ts";
|
|
19
|
+
import { fetchLiveSwagger, loadLocalSpec } from "./nextjs/liveSpec.ts";
|
|
20
|
+
import {
|
|
21
|
+
dropReasonLabel,
|
|
22
|
+
ensureClientProviderWrapper,
|
|
23
|
+
isUsableUnitProvider,
|
|
24
|
+
} from "./nextjs/providerWrapper.ts";
|
|
25
|
+
import { appReactMajor } from "./packageJson.ts";
|
|
26
|
+
import { configRootFor, detectReactCompiler } from "./reactCompiler.ts";
|
|
27
|
+
import { printFindings, writeInlineSummaries, writeSummaryJson } from "./reporters/findings.ts";
|
|
28
|
+
import { type UnparseableFile, writeReportMarkdown } from "./reporters/report.ts";
|
|
29
|
+
import { runAffectedTests, runGeneratedTests } from "./runner/runTests.ts";
|
|
30
|
+
import { adoptProjectStyle } from "./scaffold/adoptStyle.ts";
|
|
31
|
+
import { restampGenerated } from "./scaffold-tests/stamp.ts";
|
|
32
|
+
import { EXPO_SCRIPT, JEST_CONFIG, NEXT_SCRIPT, VITEST_CONFIG } from "./suiteScripts.ts";
|
|
33
|
+
import { findTask, TASKS } from "./tasks/index.ts";
|
|
34
|
+
import type {
|
|
35
|
+
ComponentTree,
|
|
36
|
+
ExpoContext,
|
|
37
|
+
HeapMode,
|
|
38
|
+
NextjsApiRoute,
|
|
39
|
+
NextjsContext,
|
|
40
|
+
PropsShape,
|
|
41
|
+
RootMode,
|
|
42
|
+
RunContext,
|
|
43
|
+
Task,
|
|
44
|
+
TaskId,
|
|
45
|
+
TaskResult,
|
|
46
|
+
} from "./types.ts";
|
|
47
|
+
|
|
48
|
+
import { UsageError } from "./types.ts";
|
|
49
|
+
|
|
50
|
+
// Re-exported so `import { UsageError } from "…/core.ts"` keeps working — the
|
|
51
|
+
// class moved to types.ts so leaf modules can throw it without a cycle.
|
|
52
|
+
export { UsageError };
|
|
53
|
+
|
|
54
|
+
export interface CoreOptions {
|
|
55
|
+
/** Entry file path or glob. */
|
|
56
|
+
pattern: string;
|
|
57
|
+
outDir?: string;
|
|
58
|
+
taskIds?: TaskId[];
|
|
59
|
+
depth?: number;
|
|
60
|
+
variants?: number;
|
|
61
|
+
heaps?: HeapMode;
|
|
62
|
+
includeChildren?: boolean;
|
|
63
|
+
force?: boolean;
|
|
64
|
+
report?: boolean;
|
|
65
|
+
propsOverride?: PropsShape;
|
|
66
|
+
/** Explicit provider wrapper (.tsx default export). Used verbatim. */
|
|
67
|
+
provider?: string;
|
|
68
|
+
apiSpec?: string;
|
|
69
|
+
inlineSummary?: boolean;
|
|
70
|
+
/** Next.js project root; enables Next.js mode. */
|
|
71
|
+
nextjs?: string;
|
|
72
|
+
/** Expo project root; enables Expo mode. Mutually exclusive with `nextjs`. */
|
|
73
|
+
expo?: string;
|
|
74
|
+
swaggerUrl?: string;
|
|
75
|
+
colocate?: boolean;
|
|
76
|
+
runTests?: boolean;
|
|
77
|
+
/** Run only the specs affected by files changed since this ref (true = HEAD). */
|
|
78
|
+
changed?: string | true;
|
|
79
|
+
/** "exported" emits a tree per exported component in each entry file. */
|
|
80
|
+
roots?: RootMode;
|
|
81
|
+
/** Extra globs to skip, appended to DEFAULT_EXCLUDES. */
|
|
82
|
+
exclude?: readonly string[];
|
|
83
|
+
useDefaultExcludes?: boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Everything this run writes to STDOUT — the progress narration AND the
|
|
86
|
+
* per-file findings listing (`printFindings`). Default on. False is what
|
|
87
|
+
* --json/--ci and the programmatic API pass: stdout then belongs to the
|
|
88
|
+
* caller — a JSON blob that must parse whole — so nothing may go there.
|
|
89
|
+
*
|
|
90
|
+
* It is NOT a vow of silence. Diagnostics go to stderr and are gated by
|
|
91
|
+
* `quiet`, which is a separate decision: see below.
|
|
92
|
+
*/
|
|
93
|
+
log?: boolean;
|
|
94
|
+
/**
|
|
95
|
+
* Suppress `runCore`'s OWN diagnostics — the `warn()` lines: a skipped file,
|
|
96
|
+
* an unparseable spec, a script collision, a failed Next.js emit. Default
|
|
97
|
+
* off, so they always speak, including under --ci.
|
|
98
|
+
*
|
|
99
|
+
* Sharing one flag with `log` is how `--ci`, the mode built for machines,
|
|
100
|
+
* became the mode that lost "warning: skipping <file>" and "could not parse
|
|
101
|
+
* <path> as OpenAPI JSON — ignoring". Whether stdout is the caller's and
|
|
102
|
+
* whether the run may report a problem at all are different questions.
|
|
103
|
+
*
|
|
104
|
+
* Deliberately narrow, and NOT "silence stderr": the suite runner's own
|
|
105
|
+
* output (`› bun run flawless:unit`, `failed suites: …`, and every child
|
|
106
|
+
* suite's streams) is routed by a different flag, and task-level warnings
|
|
107
|
+
* like perfHarness's migration notice go through `console.warn` with no gate
|
|
108
|
+
* at all. If you need a fully silent process, redirect fd 2 yourself.
|
|
109
|
+
*/
|
|
110
|
+
quiet?: boolean;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface CoreResult {
|
|
114
|
+
exitCode: 0 | 1;
|
|
115
|
+
outDir: string;
|
|
116
|
+
results: TaskResult[];
|
|
117
|
+
trees: ComponentTree[];
|
|
118
|
+
nextjs: NextjsContext | undefined;
|
|
119
|
+
/** Path of the provider wrapper actually used for unit specs, if any. */
|
|
120
|
+
provider: string | undefined;
|
|
121
|
+
/**
|
|
122
|
+
* What the test-run phase did.
|
|
123
|
+
*
|
|
124
|
+
* - "affected" | "generated": suites actually RAN — that is the promise
|
|
125
|
+
* these two arms make, and `green` is what folded into exitCode. A phase
|
|
126
|
+
* that spawned nothing never wears them (FR-042/FR-043).
|
|
127
|
+
* - "none": zero suites ran; `reason` says why — no selected task generates
|
|
128
|
+
* one --run-tests executes, nothing was affected, or the selected tasks
|
|
129
|
+
* plan no run for the work that was.
|
|
130
|
+
* - absent: the run never REACHED the test-run phase. FR-046: that is not
|
|
131
|
+
* the same as "nothing was requested". It also covers a request blocked by
|
|
132
|
+
* a failed task, which exits 1 and is explained by `results`. Read absent
|
|
133
|
+
* as "look elsewhere", never as "no run was asked for".
|
|
134
|
+
*
|
|
135
|
+
* The invariant a consumer can rely on: `mode: "none"` if and only if zero
|
|
136
|
+
* suites executed, and a `green` field only ever appears on a phase that ran
|
|
137
|
+
* something.
|
|
138
|
+
*
|
|
139
|
+
* The whole point is that exit 0 has two meanings — "the suites passed" and
|
|
140
|
+
* "no suite ran" — and a caller reading only the exit code cannot tell them
|
|
141
|
+
* apart. That is the pair FR-017 hid between, and B-14 found `--run-tests`
|
|
142
|
+
* over a selection of non-runnable tasks reproducing it one flag over.
|
|
143
|
+
*
|
|
144
|
+
* Deliberately NOT set when a task failed: that run exits 1, so it is already
|
|
145
|
+
* telling the truth, and the failing TaskResult says which task.
|
|
146
|
+
*/
|
|
147
|
+
testRun?: { mode: "affected" | "generated"; green: boolean } | { mode: "none"; reason: string };
|
|
148
|
+
/**
|
|
149
|
+
* Failures that belong to no task and so appear nowhere in `results`
|
|
150
|
+
* (FR-041). Today's only producer is a Next.js emit that threw: it sets the
|
|
151
|
+
* exit code to 1 while every TaskResult still says ok:true, so without this
|
|
152
|
+
* a --ci consumer saw a red run whose blob contained no account of it.
|
|
153
|
+
* Empty on a clean run; with `testRun`, every red run is now explicable from
|
|
154
|
+
* the blob alone.
|
|
155
|
+
*/
|
|
156
|
+
errors: string[];
|
|
157
|
+
/**
|
|
158
|
+
* flawless:* scripts whose existing package.json value the merge did not
|
|
159
|
+
* recognize as the tool's own and therefore KEPT (FR-034). Durable on
|
|
160
|
+
* purpose: the log line is suppressed under --ci/--json and programmatic
|
|
161
|
+
* runs, exactly the modes an agent refreshes in.
|
|
162
|
+
*/
|
|
163
|
+
keptScripts: string[];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A command that reproduces this run, for REPORT.md's header.
|
|
168
|
+
*
|
|
169
|
+
* Rebuilt from the options rather than captured from argv. argv carries the
|
|
170
|
+
* interpreter and the absolute path of the script — `bun /home/…/src/bin.ts` —
|
|
171
|
+
* and REPORT.md is committed, timestamp-free, and checked out under a different
|
|
172
|
+
* root on every machine. An absolute path in it would churn `git status`
|
|
173
|
+
* everywhere except the machine that generated it, which is the same failure the
|
|
174
|
+
* no-timestamp rule exists to prevent, arriving through a different door.
|
|
175
|
+
*
|
|
176
|
+
* So: the published bin name, never the interpreter; every path relative, never
|
|
177
|
+
* absolute; and only the options that differ from their defaults, so two runs
|
|
178
|
+
* that did the same thing produce the same line.
|
|
179
|
+
*/
|
|
180
|
+
export function reproCommand(opts: CoreOptions, root: string, taskIds: TaskId[]): string {
|
|
181
|
+
// Only absolutes are rewritten. A pattern the caller wrote as `src/**/*.tsx`
|
|
182
|
+
// is already relative to where they ran it, and resolving it against `root`
|
|
183
|
+
// just to relativise it again would turn a correct string into a guess.
|
|
184
|
+
const rel = (p: string): string => {
|
|
185
|
+
const r = path.isAbsolute(p) ? path.relative(root, p) : p;
|
|
186
|
+
const posix = r.split(path.sep).join("/");
|
|
187
|
+
return posix === "" ? "." : posix;
|
|
188
|
+
};
|
|
189
|
+
// Globs and spaces need quoting or the invoking shell expands them before
|
|
190
|
+
// flawless sees them — which is a different run.
|
|
191
|
+
const quote = (s: string): string => (/[\s*?[\]{}()|$]/.test(s) ? `"${s}"` : s);
|
|
192
|
+
|
|
193
|
+
// Defensive: a caller that reaches here without a pattern gets a line that
|
|
194
|
+
// is honest about what it does not know, never a TypeError from path.relative.
|
|
195
|
+
const parts = [
|
|
196
|
+
"flawless-react",
|
|
197
|
+
opts.pattern === undefined ? "<entry>" : quote(rel(opts.pattern)),
|
|
198
|
+
];
|
|
199
|
+
const flag = (name: string, value?: string): void => {
|
|
200
|
+
parts.push(name);
|
|
201
|
+
if (value !== undefined) parts.push(value);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
if (opts.nextjs !== undefined) flag("--nextjs", quote(rel(opts.nextjs)));
|
|
205
|
+
if (opts.outDir !== undefined) flag("--out", quote(rel(opts.outDir)));
|
|
206
|
+
// The CLI's no-flag default is perf-harness alone, so anything else has to be
|
|
207
|
+
// spelled out or the line reproduces a different run.
|
|
208
|
+
if (!(taskIds.length === 1 && taskIds[0] === "perf-harness")) {
|
|
209
|
+
flag("--tasks", taskIds.join(","));
|
|
210
|
+
}
|
|
211
|
+
if (opts.roots !== undefined && opts.roots !== "primary") flag("--roots", opts.roots);
|
|
212
|
+
if (opts.apiSpec !== undefined) flag("--api", quote(rel(opts.apiSpec)));
|
|
213
|
+
if (opts.provider !== undefined) flag("--provider", quote(rel(opts.provider)));
|
|
214
|
+
if (opts.swaggerUrl !== undefined) flag("--swagger-url", quote(opts.swaggerUrl));
|
|
215
|
+
if (opts.depth !== undefined && opts.depth !== 3) flag("--depth", String(opts.depth));
|
|
216
|
+
if (opts.variants !== undefined && opts.variants !== 5) {
|
|
217
|
+
flag("--variants", String(opts.variants));
|
|
218
|
+
}
|
|
219
|
+
if (opts.heaps !== undefined && opts.heaps !== "stress") flag("--heaps", opts.heaps);
|
|
220
|
+
if (opts.includeChildren === false) flag("--include-children=false");
|
|
221
|
+
// Relativised like any other path: an exclude is usually already relative
|
|
222
|
+
// (`**/*.stories.tsx`), but nothing stops a caller passing an absolute one.
|
|
223
|
+
for (const g of opts.exclude ?? []) flag("--exclude", quote(rel(g)));
|
|
224
|
+
if (opts.useDefaultExcludes === false) flag("--no-default-excludes");
|
|
225
|
+
if (opts.propsOverride !== undefined) {
|
|
226
|
+
flag("--props", `'${JSON.stringify(opts.propsOverride)}'`);
|
|
227
|
+
}
|
|
228
|
+
if (opts.colocate === true) flag("--colocate");
|
|
229
|
+
if (opts.force === true) flag("--force");
|
|
230
|
+
if (opts.report === true) flag("--report");
|
|
231
|
+
if (opts.inlineSummary === false) flag("--no-inline-summary");
|
|
232
|
+
if (opts.changed !== undefined) {
|
|
233
|
+
flag("--changed", opts.changed === true ? undefined : quote(opts.changed));
|
|
234
|
+
}
|
|
235
|
+
if (opts.runTests === true) flag("--run-tests");
|
|
236
|
+
return parts.join(" ");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export async function runCore(opts: CoreOptions): Promise<CoreResult> {
|
|
240
|
+
const log = (line: string): void => {
|
|
241
|
+
if (opts.log !== false) process.stdout.write(`${line}\n`);
|
|
242
|
+
};
|
|
243
|
+
// Deliberately NOT `opts.log`: stdout purity and permission to report a
|
|
244
|
+
// problem are separate decisions (B-13). Gating both on one flag made --ci
|
|
245
|
+
// silent about work it skipped.
|
|
246
|
+
const warn = (line: string): void => {
|
|
247
|
+
if (opts.quiet !== true) process.stderr.write(`${line}\n`);
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
let pattern: string | undefined = opts.pattern;
|
|
251
|
+
let outDir = opts.outDir;
|
|
252
|
+
const taskIds: TaskId[] =
|
|
253
|
+
opts.taskIds === undefined || opts.taskIds.length === 0 ? ["perf-harness"] : opts.taskIds;
|
|
254
|
+
|
|
255
|
+
let expoCtx: ExpoContext | undefined;
|
|
256
|
+
let nextCtx: NextjsContext | undefined;
|
|
257
|
+
let liveSpec: unknown;
|
|
258
|
+
const providerExplicit = opts.provider !== undefined;
|
|
259
|
+
let providerOverride = opts.provider;
|
|
260
|
+
|
|
261
|
+
// Belt and braces: the CLI already refuses both flags, but runCore is a public
|
|
262
|
+
// API (src/index.ts) and a programmatic caller reaches here without parseArgs.
|
|
263
|
+
if (opts.nextjs !== undefined && opts.expo !== undefined) {
|
|
264
|
+
throw new UsageError(
|
|
265
|
+
"nextjs and expo are mutually exclusive: a project is a Next.js app or an Expo app, not both.",
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (opts.expo !== undefined) {
|
|
270
|
+
expoCtx = await detectExpo(opts.expo); // throws UsageError on bad input
|
|
271
|
+
if (outDir === undefined) outDir = path.join(expoCtx.root, "flawless");
|
|
272
|
+
else assertOutDirInsideProject(outDir, expoCtx.root, "--expo");
|
|
273
|
+
if (pattern === undefined && expoCtx.routerRoot !== undefined) {
|
|
274
|
+
pattern = path.join(expoCtx.routerRoot, "**/*.tsx");
|
|
275
|
+
log(`expo: no entry passed — defaulting to ${path.relative(expoCtx.root, pattern)}`);
|
|
276
|
+
}
|
|
277
|
+
log(
|
|
278
|
+
`expo: ${expoCtx.root} (sdk=${expoCtx.sdkMajor ?? "unknown"}` +
|
|
279
|
+
`, expo-router=${expoCtx.routerMajor ?? "none"}` +
|
|
280
|
+
`, nativewind=${expoCtx.nativewind}` +
|
|
281
|
+
`, web-port=${expoCtx.webPort})`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (opts.nextjs !== undefined) {
|
|
286
|
+
nextCtx = await detectNextjs(opts.nextjs); // throws UsageError on bad input
|
|
287
|
+
if (outDir === undefined) outDir = path.join(nextCtx.root, "flawless");
|
|
288
|
+
else assertOutDirInsideProject(outDir, nextCtx.root, "--nextjs");
|
|
289
|
+
if (providerOverride === undefined) providerOverride = nextCtx.appResolver;
|
|
290
|
+
if (pattern === undefined) {
|
|
291
|
+
const detected = detectComponentsGlob(nextCtx.root);
|
|
292
|
+
if (detected) {
|
|
293
|
+
pattern = detected;
|
|
294
|
+
log(`nextjs: no entry passed — defaulting to ${path.relative(nextCtx.root, detected)}`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
log(
|
|
298
|
+
`nextjs: ${nextCtx.root} (router=${nextCtx.router}, api-routes=${nextCtx.apiRoutes.length}, app-resolver=${nextCtx.appResolver ?? "<none>"})`,
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
if (opts.swaggerUrl !== "off") {
|
|
302
|
+
const live = await fetchLiveSwagger(opts.swaggerUrl);
|
|
303
|
+
if (live.ok && live.routes) {
|
|
304
|
+
mergeRoutes(nextCtx, live.routes);
|
|
305
|
+
liveSpec = live.rawSpec;
|
|
306
|
+
log(
|
|
307
|
+
`nextjs: enriched routes from live OpenAPI at ${live.url} (paths=${live.routes.length})`,
|
|
308
|
+
);
|
|
309
|
+
} else if (opts.swaggerUrl !== undefined) {
|
|
310
|
+
warn(
|
|
311
|
+
`nextjs: live spec at ${live.url} unavailable (${live.error}); using static AST output`,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// A local OpenAPI file beats both the AST walk and the live spec — it is
|
|
317
|
+
// the only reliable source when the API is a catch-all (Hono, tRPC, ...).
|
|
318
|
+
if (opts.apiSpec !== undefined) {
|
|
319
|
+
const local = await loadLocalSpec(opts.apiSpec);
|
|
320
|
+
if (local) {
|
|
321
|
+
mergeRoutes(nextCtx, local.routes);
|
|
322
|
+
liveSpec = local.rawSpec;
|
|
323
|
+
log(`nextjs: enriched routes from ${opts.apiSpec} (paths=${local.routes.length})`);
|
|
324
|
+
} else {
|
|
325
|
+
warn(`nextjs: could not parse ${opts.apiSpec} as OpenAPI JSON — ignoring`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// The auto-detected app resolver is usually an async server layout that
|
|
330
|
+
// cannot run under jsdom — derive a client-safe wrapper instead. An
|
|
331
|
+
// explicit --provider is respected verbatim (warn when it looks unusable).
|
|
332
|
+
if (providerOverride !== undefined && !isUsableUnitProvider(providerOverride)) {
|
|
333
|
+
if (providerExplicit) {
|
|
334
|
+
warn(
|
|
335
|
+
`provider: ${providerOverride} cannot mount under jsdom (async, server-only imports, or a pages \`_app\` rendering the route element) — unit specs will fail`,
|
|
336
|
+
);
|
|
337
|
+
} else {
|
|
338
|
+
const wrapped = ensureClientProviderWrapper(
|
|
339
|
+
nextCtx,
|
|
340
|
+
outDir as string,
|
|
341
|
+
providerOverride,
|
|
342
|
+
opts.force === true,
|
|
343
|
+
);
|
|
344
|
+
if (wrapped) {
|
|
345
|
+
providerOverride = wrapped.path;
|
|
346
|
+
const droppedText =
|
|
347
|
+
wrapped.dropped.length === 0
|
|
348
|
+
? ""
|
|
349
|
+
: ` — dropped ${wrapped.dropped
|
|
350
|
+
.map((d) => `${d.name} (${dropReasonLabel(d.reason)})`)
|
|
351
|
+
.join(", ")}`;
|
|
352
|
+
log(
|
|
353
|
+
wrapped.created
|
|
354
|
+
? `nextjs: app resolver cannot mount under jsdom — derived ${path.relative(nextCtx.root, wrapped.path)} (providers: ${wrapped.included.join(", ") || "none mounted"}${droppedText})`
|
|
355
|
+
: `nextjs: using existing ${path.relative(nextCtx.root, wrapped.path)}`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (pattern === undefined) {
|
|
363
|
+
throw new UsageError(
|
|
364
|
+
"missing entry path or glob. Pass a .tsx path, a glob, or run in a TTY for the interactive menu. See --help.",
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
outDir = path.resolve(outDir ?? path.join(process.cwd(), ".flawless"));
|
|
369
|
+
|
|
370
|
+
// Read once, here, for every mode. `perf-static`'s three memo-family rules
|
|
371
|
+
// name work the React Compiler does by itself, and web mode had no route to
|
|
372
|
+
// this fact at all — so the dogfood app's web side reported 127 findings
|
|
373
|
+
// recommending the compiler's own output be written by hand. It is logged in
|
|
374
|
+
// every state, including `off`: a task that quietly changes what it reports
|
|
375
|
+
// because of a flag nobody saw read is the same lie wearing a quieter outfit.
|
|
376
|
+
const reactCompiler = detectReactCompiler(
|
|
377
|
+
expoCtx?.root ?? nextCtx?.root ?? configRootFor(outDir),
|
|
378
|
+
);
|
|
379
|
+
log(`react-compiler=${reactCompiler.state} (${reactCompiler.evidence})`);
|
|
380
|
+
|
|
381
|
+
const files = await expandGlob(pattern, {
|
|
382
|
+
...(opts.exclude ? { exclude: opts.exclude } : {}),
|
|
383
|
+
...(opts.useDefaultExcludes === undefined
|
|
384
|
+
? {}
|
|
385
|
+
: { useDefaultExcludes: opts.useDefaultExcludes }),
|
|
386
|
+
});
|
|
387
|
+
if (files.length === 0) {
|
|
388
|
+
throw new UsageError(`no files matched: ${pattern}`);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const trees: ComponentTree[] = [];
|
|
392
|
+
const unparseable: UnparseableFile[] = [];
|
|
393
|
+
// One parse cache + resolver for the whole sweep: a leaf shared by many
|
|
394
|
+
// entries (ui/card.tsx under a dogfood sweep) parses once, not once per
|
|
395
|
+
// entry. Per-entry state (visited sets) stays inside analyseFile.
|
|
396
|
+
const analyseShared = createAnalyseSharedContext();
|
|
397
|
+
for (const file of files) {
|
|
398
|
+
try {
|
|
399
|
+
trees.push(
|
|
400
|
+
...(await analyseFile(
|
|
401
|
+
{
|
|
402
|
+
entry: file,
|
|
403
|
+
depth: opts.depth ?? 3,
|
|
404
|
+
includeChildren: opts.includeChildren ?? true,
|
|
405
|
+
roots: opts.roots ?? "primary",
|
|
406
|
+
},
|
|
407
|
+
analyseShared,
|
|
408
|
+
)),
|
|
409
|
+
);
|
|
410
|
+
} catch (err) {
|
|
411
|
+
// Also recorded so REPORT.md can account for it. A stderr line is lost in
|
|
412
|
+
// a hundred-file sweep, and a component that vanished silently is exactly
|
|
413
|
+
// what makes a coverage number meaningless.
|
|
414
|
+
unparseable.push({ file, reason: (err as Error).message });
|
|
415
|
+
warn(`warning: skipping ${file}: ${(err as Error).message}`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
if (trees.length === 0) {
|
|
419
|
+
throw new UsageError("no analysable components found");
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Derived before any task runs: the context providers a mount needs are
|
|
423
|
+
// frequently imported by NO analysed component (the route layout imports
|
|
424
|
+
// SidebarProvider, and route files are not component entries), so the host
|
|
425
|
+
// cannot find them in the trees — it needs this map instead.
|
|
426
|
+
const providerWraps = await deriveContextProviderWraps(trees, analyseShared.cache, (c) =>
|
|
427
|
+
libraryProviderShimsFor([c.info.filePath]),
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
const firstTree = trees[0];
|
|
431
|
+
if (firstTree && opts.propsOverride) {
|
|
432
|
+
firstTree.entry.propsShape = { ...firstTree.entry.propsShape, ...opts.propsOverride };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// An unknown id used to be filtered away, so a typo ran ZERO tasks and
|
|
436
|
+
// still exited 0 with an empty results array — a green run that tested
|
|
437
|
+
// nothing, which is the one outcome this tool must never produce.
|
|
438
|
+
const unknownTaskIds = taskIds.filter((id) => findTask(id) === undefined);
|
|
439
|
+
if (unknownTaskIds.length > 0) {
|
|
440
|
+
throw new UsageError(
|
|
441
|
+
`unknown task id(s): ${unknownTaskIds.join(", ")}. Valid ids: ${TASKS.map((t) => t.id).join(", ")}.`,
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
const selected = taskIds.map((id) => findTask(id)).filter((t): t is Task => Boolean(t));
|
|
445
|
+
|
|
446
|
+
const ctx: RunContext = {
|
|
447
|
+
trees,
|
|
448
|
+
outDir,
|
|
449
|
+
variants: opts.variants ?? 5,
|
|
450
|
+
heaps: opts.heaps ?? "stress",
|
|
451
|
+
force: opts.force ?? false,
|
|
452
|
+
report: opts.report ?? false,
|
|
453
|
+
provider: providerOverride,
|
|
454
|
+
apiSpec: opts.apiSpec,
|
|
455
|
+
inlineSummary: opts.inlineSummary ?? true,
|
|
456
|
+
nextjs: nextCtx,
|
|
457
|
+
...(expoCtx ? { expo: expoCtx } : {}),
|
|
458
|
+
reactCompiler,
|
|
459
|
+
colocate: opts.colocate ?? false,
|
|
460
|
+
selectedTasks: taskIds,
|
|
461
|
+
// Changed-mode reaches the tasks so perf-harness can keep an existing
|
|
462
|
+
// suite instead of churning it — the affected-run gate below stays on
|
|
463
|
+
// !anyFailed, and a kept harness is not a failure.
|
|
464
|
+
...(opts.changed === undefined ? {} : { changed: opts.changed }),
|
|
465
|
+
...(providerWraps.size > 0 ? { providerWraps } : {}),
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
const results: TaskResult[] = [];
|
|
469
|
+
let anyFailed = false;
|
|
470
|
+
// FR-041: failures that belong to no task, and so appear nowhere in
|
|
471
|
+
// `results`. A caller reading the blob saw exitCode 1 with every task ok:true
|
|
472
|
+
// and no account of why; the stderr line was the only record, and before B-13
|
|
473
|
+
// even that was suppressed under --ci.
|
|
474
|
+
const runErrors: string[] = [];
|
|
475
|
+
for (const task of selected) {
|
|
476
|
+
try {
|
|
477
|
+
const r = await task.run(ctx);
|
|
478
|
+
results.push(r);
|
|
479
|
+
if (!r.ok) anyFailed = true;
|
|
480
|
+
if (opts.log !== false) printFindings(r.findings);
|
|
481
|
+
} catch (err) {
|
|
482
|
+
anyFailed = true;
|
|
483
|
+
results.push({
|
|
484
|
+
id: task.id,
|
|
485
|
+
ok: false,
|
|
486
|
+
outputs: [],
|
|
487
|
+
findings: [],
|
|
488
|
+
summary: "task threw",
|
|
489
|
+
error: (err as Error).message,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const nextjsWritten: string[] = [];
|
|
495
|
+
// Kept flawless:* scripts must outlive the log line: --ci/--json and
|
|
496
|
+
// programmatic runs suppress logging, and those are exactly the modes an
|
|
497
|
+
// agent refreshes in (the FR-034 dogfood bite happened under one).
|
|
498
|
+
let keptScripts: string[] = [];
|
|
499
|
+
if (nextCtx) {
|
|
500
|
+
try {
|
|
501
|
+
const nm = await emitNextjsMocks(nextCtx, outDir, { liveSpec, force: opts.force });
|
|
502
|
+
nextjsWritten.push(...nm.written, nm.swaggerPath);
|
|
503
|
+
log(
|
|
504
|
+
`nextjs: wrote ${nm.written.length} mock file(s) under ${path.relative(process.cwd(), outDir)}/mocks/`,
|
|
505
|
+
);
|
|
506
|
+
// POSIX separators, like every other emitted path (loadPipes.ts,
|
|
507
|
+
// scaffold/templates.ts, scaffold-tests/mocks.ts). These land in a file;
|
|
508
|
+
// the rest of path.relative's uses in this file are log lines.
|
|
509
|
+
const outRel = outDirRel(outDir, nextCtx.root);
|
|
510
|
+
// React 17 apps get @testing-library/react 12 — 13+ peer-require
|
|
511
|
+
// react ^18 and hard-require the react-dom 18 client entry at load.
|
|
512
|
+
// Mirrors suitePackageJson's branch.
|
|
513
|
+
const appMajor = appReactMajor(path.join(nextCtx.root, "package.json"));
|
|
514
|
+
const legacyRtl = appMajor !== undefined && appMajor <= 17;
|
|
515
|
+
const scriptMerge = await mergeIntoNextPackageJson(
|
|
516
|
+
nextCtx.root,
|
|
517
|
+
{},
|
|
518
|
+
{
|
|
519
|
+
msw: "^2.6.0",
|
|
520
|
+
vitest: "^2.1.0",
|
|
521
|
+
jsdom: "^25.0.0",
|
|
522
|
+
"@testing-library/react": legacyRtl ? "^12.1.5" : "^16.0.0",
|
|
523
|
+
...(legacyRtl ? { "@testing-library/dom": "^8.20.1" } : {}),
|
|
524
|
+
"@testing-library/user-event": "^14.5.0",
|
|
525
|
+
"@testing-library/jest-dom": "^6.5.0",
|
|
526
|
+
"@playwright/test": "^1.49.0",
|
|
527
|
+
"react-scan": "^0.4.3",
|
|
528
|
+
},
|
|
529
|
+
{
|
|
530
|
+
// Every one of these is gated on the task that emits what it points
|
|
531
|
+
// at. This block runs on EVERY --nextjs invocation, so declaring them
|
|
532
|
+
// unconditionally wrote `flawless:perf` — and unit, and browser — into
|
|
533
|
+
// the app's package.json after a `--tasks load` run that never emitted
|
|
534
|
+
// a playwright.config.ts or a profile/ for them to find. `bun run
|
|
535
|
+
// flawless:perf` then failed on a config the user never asked for and
|
|
536
|
+
// cannot produce without regenerating. Same defect as the standalone
|
|
537
|
+
// suite's missing `test:perf`, from the other direction: there the
|
|
538
|
+
// script was absent, here its target is.
|
|
539
|
+
...(taskIds.includes("unit-browser")
|
|
540
|
+
? {
|
|
541
|
+
"flawless:unit": opts.colocate
|
|
542
|
+
? `vitest run --config ${outRel}/${VITEST_CONFIG}`
|
|
543
|
+
: `vitest run --root ${outRel}`,
|
|
544
|
+
}
|
|
545
|
+
: {}),
|
|
546
|
+
// browser/ is rendered only when perf-harness is also in the run —
|
|
547
|
+
// the browser specs drive its host (unitAndBrowser.ts sets
|
|
548
|
+
// browserSpecs from exactly this condition). Gated on unit-browser
|
|
549
|
+
// alone, `--nextjs --tasks unit-browser` declared this script for a
|
|
550
|
+
// config that was never written, and --run-tests then invoked it and
|
|
551
|
+
// reported `failed suites: browser` for a correct run.
|
|
552
|
+
...(taskIds.includes("unit-browser") && taskIds.includes("perf-harness")
|
|
553
|
+
? {
|
|
554
|
+
"flawless:browser": `playwright test --config ${outRel}/browser/playwright.config.ts`,
|
|
555
|
+
}
|
|
556
|
+
: {}),
|
|
557
|
+
// --project=chromium is a no-op against the emitted config's single
|
|
558
|
+
// project — kept because it says what this script IS (the headless
|
|
559
|
+
// perf plane) and keeps working on configs where the user added
|
|
560
|
+
// projects of their own. The config once shipped a headed second
|
|
561
|
+
// project, which turned every bare \`playwright test\` into a
|
|
562
|
+
// double pass with real windows; that project is gone.
|
|
563
|
+
...(taskIds.includes("perf-harness")
|
|
564
|
+
? {
|
|
565
|
+
"flawless:perf": `playwright test --config ${outRel}/playwright.config.ts --project=chromium ${outRel}/profile`,
|
|
566
|
+
}
|
|
567
|
+
: {}),
|
|
568
|
+
...(taskIds.includes("e2e")
|
|
569
|
+
? {
|
|
570
|
+
"flawless:e2e": `playwright test --config ${outRel}/e2e/playwright.config.ts`,
|
|
571
|
+
}
|
|
572
|
+
: {}),
|
|
573
|
+
...(taskIds.includes("load")
|
|
574
|
+
? {
|
|
575
|
+
"flawless:load": `crust -c 'test-pipes --target ${outRel}/load/routes.pipes --timeout 60000'`,
|
|
576
|
+
}
|
|
577
|
+
: {}),
|
|
578
|
+
// --report emits scripts/report.mjs into the suite, but in Next.js
|
|
579
|
+
// mode there is no suite package.json to declare a `report` script,
|
|
580
|
+
// so the file sat there with nothing anywhere able to invoke it while
|
|
581
|
+
// its own banner said "Re-run with bun run report". Same gate as
|
|
582
|
+
// pkgJson's standalone `report` key, and same pairing with the task
|
|
583
|
+
// that writes the file.
|
|
584
|
+
...(taskIds.includes("perf-harness") && opts.report === true
|
|
585
|
+
? { [NEXT_SCRIPT.report]: `node ${outRel}/scripts/report.mjs` }
|
|
586
|
+
: {}),
|
|
587
|
+
},
|
|
588
|
+
{
|
|
589
|
+
// Both spellings are the tool's own — which one a project holds
|
|
590
|
+
// depends only on whether --colocate was passed LAST run, and the
|
|
591
|
+
// stale one silently skips colocated specs under --run-tests.
|
|
592
|
+
"flawless:unit": [
|
|
593
|
+
`vitest run --config ${outRel}/${VITEST_CONFIG}`,
|
|
594
|
+
`vitest run --root ${outRel}`,
|
|
595
|
+
],
|
|
596
|
+
},
|
|
597
|
+
);
|
|
598
|
+
log(
|
|
599
|
+
`nextjs: merged dev deps + flawless:* scripts into ${path.join(nextCtx.root, "package.json")}`,
|
|
600
|
+
);
|
|
601
|
+
keptScripts = scriptMerge.keptScripts;
|
|
602
|
+
if (keptScripts.length > 0) {
|
|
603
|
+
log(
|
|
604
|
+
`nextjs: kept ${keptScripts.length} script(s) with commands this tool does not ` +
|
|
605
|
+
`recognize: ${keptScripts.join(", ")} — delete a key from package.json to take ` +
|
|
606
|
+
`the tool's current command`,
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
// Colocated specs sit in src/ next to their components, where an
|
|
610
|
+
// unscoped test runner will collect them and run them under the wrong
|
|
611
|
+
// runner. Silently creating that collision is not acceptable — it turns
|
|
612
|
+
// a green project suite red for a reason that looks nothing like the
|
|
613
|
+
// cause.
|
|
614
|
+
for (const collision of detectTestScriptCollisions(nextCtx.root, ctx.colocate ?? false)) {
|
|
615
|
+
warn(`warning: ${collision}`);
|
|
616
|
+
}
|
|
617
|
+
// outRel, not a literal "flawless": every script twelve lines above is
|
|
618
|
+
// already written relative to the real out dir, and these were not. Under
|
|
619
|
+
// `--out qa` the run wrote qa/ and ignored flawless/, so the heaps (566MB
|
|
620
|
+
// of them), the Playwright output and — worst — qa/e2e/.auth.json were
|
|
621
|
+
// left untracked and unignored. That file is where global-setup.ts tells
|
|
622
|
+
// you to persist a logged-in storageState, i.e. session cookies staged
|
|
623
|
+
// for commit. `*.flawless.json` stays literal: it is the inline-summary
|
|
624
|
+
// sidecar pattern, written beside the analysed sources, not under outDir.
|
|
625
|
+
const added = updateGitignore(nextCtx.root, ignoreEntries(outRel));
|
|
626
|
+
if (added.length > 0) {
|
|
627
|
+
log(`nextjs: added ${added.length} entry(ies) to .gitignore (${added.join(", ")})`);
|
|
628
|
+
}
|
|
629
|
+
for (const t of ensureFormatterIgnores(nextCtx.root, outRel)) {
|
|
630
|
+
log(
|
|
631
|
+
`nextjs: added ${t.entry} to ${t.file} — the suite is tool-owned, not project lint input`,
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
} catch (err) {
|
|
635
|
+
anyFailed = true;
|
|
636
|
+
const message = `nextjs emit failed: ${(err as Error).message}`;
|
|
637
|
+
runErrors.push(message);
|
|
638
|
+
warn(message);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// Expo mode merges the native test stack into the app, the way Next.js mode
|
|
643
|
+
// merges the DOM one. The version pairing is the whole point and it is not
|
|
644
|
+
// cosmetic: a bare `jest` resolves to 30 while jest-expo 54's tooling is all
|
|
645
|
+
// ^29.2.1, and the mismatch surfaces as
|
|
646
|
+
// "TypeError: this._moduleMocker.clearMocksOnScope is not a function"
|
|
647
|
+
// from inside jest-runtime — which names nothing a user could act on. Found by
|
|
648
|
+
// running the emitted suite, not by reading the manifest.
|
|
649
|
+
if (expoCtx) {
|
|
650
|
+
try {
|
|
651
|
+
const outRel = outDirRel(outDir, expoCtx.root);
|
|
652
|
+
const merge = await mergeIntoNextPackageJson(
|
|
653
|
+
expoCtx.root,
|
|
654
|
+
{},
|
|
655
|
+
{
|
|
656
|
+
// Pinned to the project's OWN SDK major. jest-expo@54 pairs with Expo
|
|
657
|
+
// SDK 54; a fixed range breaks every other adopter, and a guessed one
|
|
658
|
+
// fails deep inside expo-modules-core.
|
|
659
|
+
...(expoCtx.sdkMajor === undefined ? {} : { "jest-expo": `~${expoCtx.sdkMajor}.0.0` }),
|
|
660
|
+
// ^29, matching jest-expo's own babel-jest/@jest/globals/jest-snapshot.
|
|
661
|
+
jest: "^29.7.0",
|
|
662
|
+
// 13, not 14: expo-router 6's testing-library is built against
|
|
663
|
+
// react-test-renderer, which RNTL 14 replaced with `test-renderer`.
|
|
664
|
+
"@testing-library/react-native": "^13.3.3",
|
|
665
|
+
"react-test-renderer": "19.1.0",
|
|
666
|
+
// The suite does not type-check without it. jest ships no types of its
|
|
667
|
+
// own, so an adopting app running `tsc --noEmit` over the emitted
|
|
668
|
+
// specs reports TS2593 on every describe/test and TS2304 on every
|
|
669
|
+
// expect — 2366 errors across the 32 specs of the dogfood app, none of
|
|
670
|
+
// them the adopter's fault. Majored to the jest pin above: @types/jest
|
|
671
|
+
// 30 against jest 29 is a mismatch nobody asked for.
|
|
672
|
+
"@types/jest": "^29.5.14",
|
|
673
|
+
// The e2e specs' runner. Same pin as the Next.js merge — an Expo app
|
|
674
|
+
// has no Playwright of its own (the dogfood app's lived in a sibling
|
|
675
|
+
// workspace the mobile package cannot resolve from).
|
|
676
|
+
...(taskIds.includes("e2e") ? { "@playwright/test": "^1.49.0" } : {}),
|
|
677
|
+
},
|
|
678
|
+
{
|
|
679
|
+
...(taskIds.includes("unit-browser")
|
|
680
|
+
? { [EXPO_SCRIPT.unit]: `jest --config ${underOutDir(outRel, JEST_CONFIG)}` }
|
|
681
|
+
: {}),
|
|
682
|
+
...(taskIds.includes("e2e")
|
|
683
|
+
? {
|
|
684
|
+
[EXPO_SCRIPT.e2e]: `playwright test --config ${underOutDir(outRel, "e2e/playwright.config.ts")}`,
|
|
685
|
+
}
|
|
686
|
+
: {}),
|
|
687
|
+
// crust is a CLI, not an npm dep — same as the Next.js load script,
|
|
688
|
+
// which is why no @-dep rides alongside this one.
|
|
689
|
+
...(taskIds.includes("load")
|
|
690
|
+
? {
|
|
691
|
+
[EXPO_SCRIPT.load]: `crust -c 'test-pipes --target ${underOutDir(outRel, "load/routes.pipes")} --timeout 60000'`,
|
|
692
|
+
}
|
|
693
|
+
: {}),
|
|
694
|
+
},
|
|
695
|
+
);
|
|
696
|
+
keptScripts = merge.keptScripts;
|
|
697
|
+
log(`expo: merged the native test stack into ${path.join(expoCtx.root, "package.json")}`);
|
|
698
|
+
if (keptScripts.length > 0) {
|
|
699
|
+
log(
|
|
700
|
+
`expo: kept ${keptScripts.length} script(s) with commands this tool does not ` +
|
|
701
|
+
`recognize: ${keptScripts.join(", ")} — delete a key from package.json to take ` +
|
|
702
|
+
`the tool's current command`,
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
for (const collision of detectTestScriptCollisions(expoCtx.root, true)) {
|
|
706
|
+
warn(`warning: ${collision}`);
|
|
707
|
+
}
|
|
708
|
+
const added = updateGitignore(expoCtx.root, ignoreEntries(outRel));
|
|
709
|
+
if (added.length > 0) {
|
|
710
|
+
log(`expo: added ${added.length} entry(ies) to .gitignore (${added.join(", ")})`);
|
|
711
|
+
}
|
|
712
|
+
for (const t of ensureFormatterIgnores(expoCtx.root, outRel)) {
|
|
713
|
+
log(
|
|
714
|
+
`expo: added ${t.entry} to ${t.file} — the suite is tool-owned, not project lint input`,
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
} catch (err) {
|
|
718
|
+
anyFailed = true;
|
|
719
|
+
const message = `expo emit failed: ${(err as Error).message}`;
|
|
720
|
+
runErrors.push(message);
|
|
721
|
+
warn(message);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Outside Next.js mode this used to run at all: updateGitignore was reached
|
|
726
|
+
// only from the branch above, so a plain `flawless-react "src/**/*.tsx"` run
|
|
727
|
+
// ignored NOTHING it generated — not reports/, not heaps/, not
|
|
728
|
+
// playwright-report/, and not e2e/.auth.json, which the emitted global-setup
|
|
729
|
+
// itself describes as session cookies staged for commit. One run in a fresh
|
|
730
|
+
// repo left 28 generated files untracked and unignored.
|
|
731
|
+
//
|
|
732
|
+
// The design call this needed: WHERE does a non-Next .gitignore belong? At the
|
|
733
|
+
// git root — that is where git reads a repo-wide ignore and where the user's
|
|
734
|
+
// existing one already is. Writing beside the out dir instead would create a
|
|
735
|
+
// second, easily-missed ignore file inside generated output. Failing that (a
|
|
736
|
+
// package that is not a repo) the nearest package.json, and failing that the
|
|
737
|
+
// out dir's parent, so something is always ignored rather than nothing.
|
|
738
|
+
if (!nextCtx && !expoCtx) {
|
|
739
|
+
const ignoreRoot = ignoreRootFor(outDir);
|
|
740
|
+
const added = updateGitignore(ignoreRoot, ignoreEntries(outDirRel(outDir, ignoreRoot)));
|
|
741
|
+
if (added.length > 0) {
|
|
742
|
+
log(
|
|
743
|
+
`added ${added.length} entry(ies) to ${path.join(ignoreRoot, ".gitignore")} ` +
|
|
744
|
+
`(${added.join(", ")})`,
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
for (const t of ensureFormatterIgnores(ignoreRoot, outDirRel(outDir, ignoreRoot))) {
|
|
748
|
+
log(`added ${t.entry} to ${path.join(ignoreRoot, t.file)} — tool-owned suite`);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (ctx.inlineSummary) {
|
|
753
|
+
await writeInlineSummaries(trees, results, outDir);
|
|
754
|
+
}
|
|
755
|
+
await writeSummaryJson(outDir, results);
|
|
756
|
+
// A suite whose specs cannot even be collected should say so at generation
|
|
757
|
+
// time: the vitest log for that run is 401 lines of "failed (0 test)" with
|
|
758
|
+
// the reason buried after them (see analyse/bootability.ts). Only when this
|
|
759
|
+
// run writes specs — a `perf-static`-only run collects nothing, and telling
|
|
760
|
+
// it that an import would fail at collect would be a claim about a run it
|
|
761
|
+
// did not make.
|
|
762
|
+
const unresolvable = taskIds.includes("unit-browser")
|
|
763
|
+
? findUnresolvableImports(
|
|
764
|
+
trees,
|
|
765
|
+
// The suite's own node_modules (the test stack lives there) and the
|
|
766
|
+
// project root; each importing file's own directory is added inside.
|
|
767
|
+
nextCtx ? [outDir, nextCtx.root] : [outDir],
|
|
768
|
+
)
|
|
769
|
+
: [];
|
|
770
|
+
if (unresolvable.length > 0) {
|
|
771
|
+
const shown = unresolvable
|
|
772
|
+
.slice(0, 3)
|
|
773
|
+
.map((b) => b.specifier)
|
|
774
|
+
.join(", ");
|
|
775
|
+
warn(
|
|
776
|
+
`${unresolvable.length} imported package(s) are not installed where the suite resolves them ` +
|
|
777
|
+
`(${shown}${unresolvable.length > 3 ? ", …" : ""}) — specs importing those fail at ` +
|
|
778
|
+
`collect, before any test runs. REPORT.md lists them.`,
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
const reportPath = await writeReportMarkdown({
|
|
782
|
+
trees,
|
|
783
|
+
results,
|
|
784
|
+
outDir,
|
|
785
|
+
projectRoot: nextCtx?.root,
|
|
786
|
+
unparseable,
|
|
787
|
+
unresolvable,
|
|
788
|
+
keptScripts,
|
|
789
|
+
allTaskIds: TASKS.map((t) => t.id),
|
|
790
|
+
// Only in Next.js mode, where there is a project root that both the command
|
|
791
|
+
// and the report's file paths are relative to, and which is where the user
|
|
792
|
+
// ran the command from. Standalone, the report lands in outDir while the
|
|
793
|
+
// pattern is relative to the caller's cwd — two different directories, so a
|
|
794
|
+
// line claiming to be runnable "from the project root" would not be.
|
|
795
|
+
// The RESOLVED pattern, not opts.pattern: Next.js mode auto-detects the
|
|
796
|
+
// entry glob, leaving opts.pattern undefined despite its type — which
|
|
797
|
+
// crashed the report line AFTER every task had already run.
|
|
798
|
+
command: nextCtx ? reproCommand({ ...opts, pattern }, nextCtx.root, taskIds) : undefined,
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
// Format everything we wrote with the host project's own formatter so
|
|
802
|
+
// generation never churns the target repo's lint/format state.
|
|
803
|
+
const styleRoot = nextCtx?.root ?? outDir;
|
|
804
|
+
const writtenFiles = [
|
|
805
|
+
reportPath,
|
|
806
|
+
...results.flatMap((r) => r.outputs),
|
|
807
|
+
// swagger.json + mocks are rewritten on every run; if they are not written
|
|
808
|
+
// in the project's own style, its formatter (or a pre-commit hook) will
|
|
809
|
+
// reformat them and the next run will churn the diff straight back.
|
|
810
|
+
...nextjsWritten,
|
|
811
|
+
...(providerOverride && isInside(outDir, providerOverride) ? [providerOverride] : []),
|
|
812
|
+
];
|
|
813
|
+
try {
|
|
814
|
+
const adopted = await adoptProjectStyle(styleRoot, writtenFiles);
|
|
815
|
+
if (adopted.formatter !== "none") {
|
|
816
|
+
log(
|
|
817
|
+
`style: formatted ${adopted.formatted} generated file(s) with the project's ${adopted.formatter}`,
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
if (adopted.warning) warn(`style: ${adopted.warning}`);
|
|
821
|
+
} catch (err) {
|
|
822
|
+
warn(`style adoption failed (non-fatal): ${(err as Error).message}`);
|
|
823
|
+
}
|
|
824
|
+
// Second phase of the colocated-spec stamps: the writer stamped the raw
|
|
825
|
+
// render, and the style pass above may just have reformatted those bytes —
|
|
826
|
+
// leaving stamps that would classify every untouched spec as hand-edited on
|
|
827
|
+
// the next --force. Restamp AFTER (and regardless of) style adoption; when
|
|
828
|
+
// the formatter didn't run or didn't change a file this is a no-op, and
|
|
829
|
+
// restampGenerated ignores everything that doesn't open with a stamp line.
|
|
830
|
+
try {
|
|
831
|
+
await restampGenerated(writtenFiles);
|
|
832
|
+
} catch (err) {
|
|
833
|
+
warn(`stamp refresh failed (non-fatal): ${(err as Error).message}`);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// The tasks that generate a suite something can run. Named once, so the
|
|
837
|
+
// message below cannot drift from the predicate that produced it.
|
|
838
|
+
const RUNNABLE_TASK_IDS: readonly TaskId[] = ["perf-harness", "unit-browser", "e2e"];
|
|
839
|
+
const runnableSelected = selected.some((t) => RUNNABLE_TASK_IDS.includes(t.id));
|
|
840
|
+
|
|
841
|
+
// --changed narrows the RUN, never the generation. The profiling host is a
|
|
842
|
+
// single bundle of every component, so regenerating only part of it would
|
|
843
|
+
// leave a host that no longer matches its specs — and generation is not where
|
|
844
|
+
// the time goes anyway: analysing 99 files takes half a second, while running
|
|
845
|
+
// every spec they produce took seventeen minutes.
|
|
846
|
+
// A suite failure must reach the exit code: --ci --run-tests previously
|
|
847
|
+
// discarded both runners' outcomes and exited 0 on red suites.
|
|
848
|
+
let suitesGreen = true;
|
|
849
|
+
// Recorded on CoreResult so a caller (or a test) can tell the affected run
|
|
850
|
+
// actually FIRED — exit 0 alone is equally consistent with the branch never
|
|
851
|
+
// executing, which is exactly how FR-017 hid.
|
|
852
|
+
let testRun: CoreResult["testRun"];
|
|
853
|
+
// FR-044. --changed is checked first, so passing both flags silently runs the
|
|
854
|
+
// NARROW set and drops the full suite the caller also asked for. Say so: the
|
|
855
|
+
// outcome is defensible, choosing it in silence is not.
|
|
856
|
+
if (opts.changed !== undefined && opts.runTests === true) {
|
|
857
|
+
warn(
|
|
858
|
+
"--changed and --run-tests were both given: running only the affected specs. " +
|
|
859
|
+
"Drop --changed to run the full generated suites.",
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
if (!anyFailed && opts.changed !== undefined && runnableSelected) {
|
|
863
|
+
const projectRoot = nextCtx?.root ?? expoCtx?.root;
|
|
864
|
+
const repoRoot = (await findRepoRoot(projectRoot ?? outDir)) ?? projectRoot ?? outDir;
|
|
865
|
+
const files = await changedFiles(repoRoot, opts.changed === true ? "HEAD" : opts.changed);
|
|
866
|
+
const affected = affectedSpecs(
|
|
867
|
+
trees,
|
|
868
|
+
files,
|
|
869
|
+
nextCtx ? { router: nextCtx.router, root: nextCtx.root } : undefined,
|
|
870
|
+
expoCtx?.routerRoot !== undefined ? { routerRoot: expoCtx.routerRoot } : undefined,
|
|
871
|
+
);
|
|
872
|
+
log(
|
|
873
|
+
`changed: ${affected.changedFiles.length} source file(s) → ${affected.components.length} component spec(s), ${affected.routes.length} route(s)` +
|
|
874
|
+
(affected.withoutSpecs.length > 0
|
|
875
|
+
? ` (${affected.withoutSpecs.length} unexported component(s) covered through their parent)`
|
|
876
|
+
: ""),
|
|
877
|
+
);
|
|
878
|
+
const outcome = await runAffectedTests(
|
|
879
|
+
outDir,
|
|
880
|
+
selected.map((t) => t.id),
|
|
881
|
+
affected,
|
|
882
|
+
// The project whose package.json carries the flawless:* scripts — the
|
|
883
|
+
// Expo root serves the same role the Next root does.
|
|
884
|
+
nextCtx?.root ?? expoCtx?.root,
|
|
885
|
+
// log:false means stdout belongs to the caller (--json's blob, or a
|
|
886
|
+
// programmatic consumer) — the runner and its children use stderr.
|
|
887
|
+
//
|
|
888
|
+
// This argument is NOT opts.quiet, and must never become it. The runner's
|
|
889
|
+
// `quiet` has the OPPOSITE polarity to CoreOptions.quiet: there it means
|
|
890
|
+
// "route everything to stderr", here it would mean "say nothing at all".
|
|
891
|
+
// Wiring opts.quiet into this slot inverts the --json stdout-purity
|
|
892
|
+
// guarantee (a quiet:false caller would get suite output on stdout, in
|
|
893
|
+
// front of the blob), and tests/runTests.test.ts drives the runner
|
|
894
|
+
// directly, so nothing would catch it. Same word, one file apart.
|
|
895
|
+
opts.log !== true,
|
|
896
|
+
);
|
|
897
|
+
suitesGreen = outcome.green;
|
|
898
|
+
// ran === 0 FIRST, always. A run arm of this union is a promise that suites
|
|
899
|
+
// executed, so "green because nothing ran" must never wear one (FR-042).
|
|
900
|
+
testRun =
|
|
901
|
+
outcome.ran === 0
|
|
902
|
+
? { mode: "none", reason: outcome.reason ?? "changed: no specs to run" }
|
|
903
|
+
: { mode: "affected", green: outcome.green };
|
|
904
|
+
} else if (!anyFailed && opts.runTests === true && runnableSelected) {
|
|
905
|
+
const outcome = await runGeneratedTests(
|
|
906
|
+
outDir,
|
|
907
|
+
selected.map((t) => t.id),
|
|
908
|
+
// Same role either way: the project whose package.json holds flawless:*.
|
|
909
|
+
nextCtx?.root ?? expoCtx?.root,
|
|
910
|
+
opts.log !== true,
|
|
911
|
+
);
|
|
912
|
+
suitesGreen = outcome.green;
|
|
913
|
+
testRun =
|
|
914
|
+
outcome.ran === 0
|
|
915
|
+
? { mode: "none", reason: outcome.reason ?? "no generated suite matched the selection" }
|
|
916
|
+
: { mode: "generated", green: outcome.green };
|
|
917
|
+
} else if (!anyFailed && (opts.runTests === true || opts.changed !== undefined)) {
|
|
918
|
+
// B-14: a run WAS asked for and cannot happen, because nothing selected
|
|
919
|
+
// generates a suite. Previously this fell off the end of the chain with no
|
|
920
|
+
// else — exit 0, empty stderr, and a blob indistinguishable from a green
|
|
921
|
+
// suite run. The reason names the ids that would have run something: told
|
|
922
|
+
// only that nothing ran, a caller has to go and read the source to learn
|
|
923
|
+
// what to select instead.
|
|
924
|
+
//
|
|
925
|
+
// The !anyFailed guard is deliberate and load-bearing. A run where a task
|
|
926
|
+
// failed exits 1 and its TaskResult says which — that run is already
|
|
927
|
+
// telling the truth, and marking it "none" would relabel a real failure as
|
|
928
|
+
// a no-op.
|
|
929
|
+
const asked = opts.runTests === true ? "--run-tests" : "--changed";
|
|
930
|
+
const selectedIds = selected.map((t) => t.id).join(", ");
|
|
931
|
+
// FR-045. "generates no suite" was false for `load`, which generates pipes
|
|
932
|
+
// AND declares the flawless:load script to run them in the same breath —
|
|
933
|
+
// --run-tests simply does not execute them. Saying a generated artefact is
|
|
934
|
+
// not one is exactly the kind of claim this repo's rules forbid.
|
|
935
|
+
const loadNote = selected.some((t) => t.id === "load")
|
|
936
|
+
? " load pipes are a suite, but not one --run-tests executes — run them with `bun run flawless:load`."
|
|
937
|
+
: "";
|
|
938
|
+
const reason =
|
|
939
|
+
`${asked} ran no suite: none of the selected task(s) (${selectedIds}) generates one ` +
|
|
940
|
+
`that ${asked} executes.${loadNote} Select at least one of ${RUNNABLE_TASK_IDS.join(", ")}.`;
|
|
941
|
+
warn(reason);
|
|
942
|
+
testRun = { mode: "none", reason };
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
return {
|
|
946
|
+
keptScripts,
|
|
947
|
+
exitCode: anyFailed || !suitesGreen ? 1 : 0,
|
|
948
|
+
outDir,
|
|
949
|
+
results,
|
|
950
|
+
trees,
|
|
951
|
+
nextjs: nextCtx,
|
|
952
|
+
provider: providerOverride,
|
|
953
|
+
testRun,
|
|
954
|
+
errors: runErrors,
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* Test scripts that would collect colocated `*.flawless.test.tsx` files and run
|
|
960
|
+
* them under a runner they were not written for. Bun's and Jest's default globs
|
|
961
|
+
* both match `*.test.tsx` anywhere, so an unscoped script silently absorbs
|
|
962
|
+
* every generated spec.
|
|
963
|
+
*/
|
|
964
|
+
export function detectTestScriptCollisions(nextRoot: string, colocate: boolean): string[] {
|
|
965
|
+
if (!colocate) return [];
|
|
966
|
+
const pkgPath = path.join(nextRoot, "package.json");
|
|
967
|
+
if (!existsSync(pkgPath)) return [];
|
|
968
|
+
let scripts: Record<string, string> = {};
|
|
969
|
+
try {
|
|
970
|
+
scripts =
|
|
971
|
+
(JSON.parse(readFileSync(pkgPath, "utf8")) as { scripts?: Record<string, string> }).scripts ??
|
|
972
|
+
{};
|
|
973
|
+
} catch {
|
|
974
|
+
return [];
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
const out: string[] = [];
|
|
978
|
+
for (const [name, cmd] of Object.entries(scripts)) {
|
|
979
|
+
if (name.startsWith("flawless:")) continue;
|
|
980
|
+
const trimmed = cmd.trim();
|
|
981
|
+
// A bare runner with no path/pattern argument globs the whole project.
|
|
982
|
+
if (selectsAllTests(trimmed)) {
|
|
983
|
+
out.push(
|
|
984
|
+
`"${name}": "${trimmed}" names no path, so it will also collect the ` +
|
|
985
|
+
`*.flawless.test.tsx specs this run emits and execute them under the wrong ` +
|
|
986
|
+
`runner — which fails on a defect that is not theirs. Scope it ` +
|
|
987
|
+
`(e.g. "bun test tests"), or point it at a config whose testMatch already does.`,
|
|
988
|
+
);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
return out;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* Flags whose argument is NOT a test-selection filter.
|
|
996
|
+
*
|
|
997
|
+
* A runner invoked with only these still globs the whole project, so it will
|
|
998
|
+
* collect the specs this tool emits and run them under the wrong runner.
|
|
999
|
+
*/
|
|
1000
|
+
const VALUE_FLAGS = new Set([
|
|
1001
|
+
"--preload",
|
|
1002
|
+
"--config",
|
|
1003
|
+
"-c",
|
|
1004
|
+
"--root",
|
|
1005
|
+
"--reporter",
|
|
1006
|
+
"--timeout",
|
|
1007
|
+
"--env-file",
|
|
1008
|
+
"--setup-files",
|
|
1009
|
+
"--coverage-reporter",
|
|
1010
|
+
"--max-workers",
|
|
1011
|
+
"-w",
|
|
1012
|
+
// vitest's workspace selector: it chooses WHICH project runs, not which
|
|
1013
|
+
// tests — `vitest --project unit` still globs that project's whole suite,
|
|
1014
|
+
// so its argument must not be mistaken for a path filter.
|
|
1015
|
+
"--project",
|
|
1016
|
+
]);
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* Flags that take NO argument.
|
|
1020
|
+
*
|
|
1021
|
+
* Kept separate from VALUE_FLAGS because conflating them was wrong in the
|
|
1022
|
+
* expensive direction: treating `--coverage` as value-taking made
|
|
1023
|
+
* `jest --coverage tests/` consume "tests/" as the flag's argument, so a
|
|
1024
|
+
* correctly-scoped script was reported as a collision.
|
|
1025
|
+
*/
|
|
1026
|
+
const BOOLEAN_FLAGS = new Set([
|
|
1027
|
+
"--coverage",
|
|
1028
|
+
"--bail",
|
|
1029
|
+
"--silent",
|
|
1030
|
+
"--watch",
|
|
1031
|
+
"--watchAll",
|
|
1032
|
+
"--ci",
|
|
1033
|
+
"--verbose",
|
|
1034
|
+
"--run",
|
|
1035
|
+
]);
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* Whether a test command selects EVERYTHING, rather than a scoped path.
|
|
1039
|
+
*
|
|
1040
|
+
* The old test was a four-spelling regex — `bun test`, `jest`, `vitest run`,
|
|
1041
|
+
* `vitest` — which the dogfood app walked straight past: its script is
|
|
1042
|
+
* `bun test --preload ./tests/setup.ts`, a bare runner with a flag, no path, and
|
|
1043
|
+
* no match. Bun's default discovery then sweeps every emitted spec into the
|
|
1044
|
+
* project suite, where it fails on a runner it was not written for.
|
|
1045
|
+
*
|
|
1046
|
+
* `--config` is the interesting exception: a runner given an explicit config is
|
|
1047
|
+
* scoped BY that config's own testMatch, so `jest --config flawless/jest.config.cjs`
|
|
1048
|
+
* is not a collision even though it names no path.
|
|
1049
|
+
*/
|
|
1050
|
+
export function selectsAllTests(cmd: string): boolean {
|
|
1051
|
+
const tokens = cmd.trim().split(/\s+/);
|
|
1052
|
+
const head = tokens[0] ?? "";
|
|
1053
|
+
if (!/^(bun|jest|vitest|bunx|npx)$/.test(head)) return false;
|
|
1054
|
+
|
|
1055
|
+
// Where the runner's ARGUMENTS begin.
|
|
1056
|
+
//
|
|
1057
|
+
// This was `bun test ? 2 : 1`, which counted the runner's own name as a
|
|
1058
|
+
// positional path filter for every `npx`/`bunx` form — so `npx jest` and
|
|
1059
|
+
// `bunx vitest` were reported as scoped when they glob the whole project,
|
|
1060
|
+
// which is precisely the collision this function exists to catch. `npx` and
|
|
1061
|
+
// `bunx` are in the accepted-runner regex above, so covering them was always
|
|
1062
|
+
// the intent.
|
|
1063
|
+
let i: number;
|
|
1064
|
+
if (head === "bun") {
|
|
1065
|
+
if (tokens[1] !== "test") return false;
|
|
1066
|
+
i = 2;
|
|
1067
|
+
} else if (head === "npx" || head === "bunx") {
|
|
1068
|
+
// `npx jest`, `bunx vitest run`, and `bunx bun test` — skip the launcher and
|
|
1069
|
+
// whatever runner it names.
|
|
1070
|
+
if (!/^(jest|vitest|bun)$/.test(tokens[1] ?? "")) return false;
|
|
1071
|
+
i = tokens[1] === "bun" ? (tokens[2] === "test" ? 3 : -1) : 2;
|
|
1072
|
+
if (i === -1) return false;
|
|
1073
|
+
} else {
|
|
1074
|
+
i = 1;
|
|
1075
|
+
}
|
|
1076
|
+
let positionals = 0;
|
|
1077
|
+
let configScoped = false;
|
|
1078
|
+
while (i < tokens.length) {
|
|
1079
|
+
const t = tokens[i] as string;
|
|
1080
|
+
if (t === "run" && positionals === 0) {
|
|
1081
|
+
i++;
|
|
1082
|
+
continue;
|
|
1083
|
+
}
|
|
1084
|
+
if (t.startsWith("-")) {
|
|
1085
|
+
const [flag, inline] = t.includes("=") ? t.split("=", 2) : [t, undefined];
|
|
1086
|
+
if (flag === "--config" || flag === "-c") configScoped = true;
|
|
1087
|
+
// A flag that takes a SEPARATE argument consumes it, so the argument is not
|
|
1088
|
+
// mistaken for a path filter. Boolean flags must not, or the path after one
|
|
1089
|
+
// disappears and a scoped script reads as a collision.
|
|
1090
|
+
if (
|
|
1091
|
+
inline === undefined &&
|
|
1092
|
+
VALUE_FLAGS.has(flag as string) &&
|
|
1093
|
+
!BOOLEAN_FLAGS.has(flag as string)
|
|
1094
|
+
) {
|
|
1095
|
+
i++;
|
|
1096
|
+
}
|
|
1097
|
+
i++;
|
|
1098
|
+
continue;
|
|
1099
|
+
}
|
|
1100
|
+
positionals++;
|
|
1101
|
+
i++;
|
|
1102
|
+
}
|
|
1103
|
+
return positionals === 0 && !configScoped;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* Refuse an out dir outside the project root, in a mode that writes INTO that project.
|
|
1108
|
+
*
|
|
1109
|
+
* `--nextjs`/`--expo` do more than choose a directory: they merge scripts into the
|
|
1110
|
+
* project's package.json and add entries to its .gitignore, both computed relative
|
|
1111
|
+
* to the project. With the out dir outside it, those come out as
|
|
1112
|
+
* `../../../../tmp/…` — a gitignore pattern git ignores, and a `flawless:unit`
|
|
1113
|
+
* script naming a path that exists on one machine. Both were observed: a run with
|
|
1114
|
+
* `--out` in a tmpdir left
|
|
1115
|
+
*
|
|
1116
|
+
* "flawless:unit": "jest --config ../../../../../../../tmp/mockfix2/out/jest.config.cjs"
|
|
1117
|
+
*
|
|
1118
|
+
* in the app's package.json, and ten `../../../../tmp/…` lines in its .gitignore.
|
|
1119
|
+
*
|
|
1120
|
+
* A UsageError rather than a warning: the run would otherwise write those into a
|
|
1121
|
+
* repo the user then commits, and every emitted path in the suite has the same
|
|
1122
|
+
* problem. The escape is to leave --out off, which defaults to <root>/flawless.
|
|
1123
|
+
*/
|
|
1124
|
+
function assertOutDirInsideProject(outDir: string, root: string, flag: string): void {
|
|
1125
|
+
if (isInside(root, outDir) || path.resolve(outDir) === path.resolve(root)) return;
|
|
1126
|
+
throw new UsageError(
|
|
1127
|
+
`--out ${outDir} is outside the ${flag} project at ${root}. ` +
|
|
1128
|
+
`In ${flag} mode the run writes into that project — package.json scripts and ` +
|
|
1129
|
+
`.gitignore entries are both relative to it — so an out dir outside it produces ` +
|
|
1130
|
+
`paths that escape the repository and name this machine. ` +
|
|
1131
|
+
`Drop --out to use ${path.join(root, "flawless")}, or point it inside the project.`,
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/** True when `file` lives under `dir` (not merely shares its path prefix). */
|
|
1136
|
+
function isInside(dir: string, file: string): boolean {
|
|
1137
|
+
const rel = path.relative(dir, file);
|
|
1138
|
+
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function mergeRoutes(nextCtx: NextjsContext, routes: NextjsApiRoute[]): void {
|
|
1142
|
+
const byPath = new Map(routes.map((r) => [r.path, r] as const));
|
|
1143
|
+
for (const ar of nextCtx.apiRoutes) {
|
|
1144
|
+
const match = byPath.get(ar.path);
|
|
1145
|
+
if (match) {
|
|
1146
|
+
ar.responseBodies = match.responseBodies;
|
|
1147
|
+
for (const m of match.methods) {
|
|
1148
|
+
if (!ar.methods.includes(m)) ar.methods.push(m);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
for (const r of routes) {
|
|
1153
|
+
if (!nextCtx.apiRoutes.some((ar) => ar.path === r.path)) nextCtx.apiRoutes.push(r);
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* Never analysed by a glob. The first entry is the important one: without it a
|
|
1159
|
+
* second `src/**\/*.tsx` run analyses the specs the first run generated, and
|
|
1160
|
+
* writes specs for its own specs.
|
|
1161
|
+
*/
|
|
1162
|
+
export const DEFAULT_EXCLUDES: readonly string[] = [
|
|
1163
|
+
"**/*.flawless.test.*",
|
|
1164
|
+
"**/*.test.*",
|
|
1165
|
+
"**/*.spec.*",
|
|
1166
|
+
"**/*.stories.*",
|
|
1167
|
+
"**/*.d.ts",
|
|
1168
|
+
"**/node_modules/**",
|
|
1169
|
+
"**/.next/**",
|
|
1170
|
+
"**/dist/**",
|
|
1171
|
+
"**/build/**",
|
|
1172
|
+
"**/coverage/**",
|
|
1173
|
+
"**/flawless/**",
|
|
1174
|
+
];
|
|
1175
|
+
|
|
1176
|
+
export interface ExpandGlobOptions {
|
|
1177
|
+
/** Appended to the defaults unless `useDefaultExcludes` is false. */
|
|
1178
|
+
exclude?: readonly string[];
|
|
1179
|
+
useDefaultExcludes?: boolean;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
export async function expandGlob(pattern: string, opts: ExpandGlobOptions = {}): Promise<string[]> {
|
|
1183
|
+
const abs = path.resolve(pattern);
|
|
1184
|
+
// An explicit path wins over every exclusion — otherwise you could never
|
|
1185
|
+
// deliberately point the tool at a file the defaults would skip. A directory
|
|
1186
|
+
// is a usage mistake, not an entry: without this check it fell through to
|
|
1187
|
+
// "Directories cannot be read like files" + "no analysable components found",
|
|
1188
|
+
// neither of which names the fix. "" resolves to cwd, so the interactive
|
|
1189
|
+
// prompt's empty submit landed here too.
|
|
1190
|
+
if (existsSync(abs)) {
|
|
1191
|
+
if (statSync(abs).isDirectory()) {
|
|
1192
|
+
// Shell tab-completion appends a trailing slash ("src/"); keep it out of
|
|
1193
|
+
// the suggested glob or the fix reads "src//**/*.tsx".
|
|
1194
|
+
const shown = pattern.replace(/[\\/]+$/, "");
|
|
1195
|
+
throw new UsageError(
|
|
1196
|
+
`${shown === "" ? abs : shown} is a directory — pass a .tsx file or a glob like "${shown === "" ? "src" : shown}/**/*.tsx"`,
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1199
|
+
return [abs];
|
|
1200
|
+
}
|
|
1201
|
+
const hasGlobChar = /[*?[\]{}]/.test(pattern);
|
|
1202
|
+
if (!hasGlobChar) {
|
|
1203
|
+
return [];
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
const excludes = [
|
|
1207
|
+
...(opts.useDefaultExcludes === false ? [] : DEFAULT_EXCLUDES),
|
|
1208
|
+
...(opts.exclude ?? []),
|
|
1209
|
+
].map((p) => new Bun.Glob(p));
|
|
1210
|
+
const cwd = process.cwd();
|
|
1211
|
+
const isExcluded = (file: string): boolean => {
|
|
1212
|
+
const rel = path.relative(cwd, file);
|
|
1213
|
+
// Match both forms: users write "**/*.test.*" and "src/**/*.test.tsx".
|
|
1214
|
+
return excludes.some((g) => g.match(file) || g.match(rel));
|
|
1215
|
+
};
|
|
1216
|
+
|
|
1217
|
+
const glob = new Bun.Glob(pattern);
|
|
1218
|
+
const out: string[] = [];
|
|
1219
|
+
try {
|
|
1220
|
+
for await (const f of glob.scan({ cwd, absolute: true })) {
|
|
1221
|
+
if (!isExcluded(f)) out.push(f);
|
|
1222
|
+
}
|
|
1223
|
+
} catch {
|
|
1224
|
+
// A glob rooted at a directory that doesn't exist throws ENOENT rather
|
|
1225
|
+
// than yielding nothing; "no files matched" is the useful message.
|
|
1226
|
+
return [];
|
|
1227
|
+
}
|
|
1228
|
+
return out.sort();
|
|
1229
|
+
}
|