@ultimat3/cli 22.6.1 → 22.7.0
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/package.json +28 -28
- package/src/app-load.ts +88 -47
- package/src/app-reload-graph.ts +228 -0
- package/src/cmd-build-spec.ts +8 -1
- package/src/cmd-build.ts +23 -8
- package/src/cmd-dev-fixture.ts +26 -0
- package/src/cmd-dev.ts +10 -0
- package/src/cmd-test-spec.ts +9 -3
- package/src/cmd-test.ts +25 -2
- package/src/cmd-verify-spec.ts +37 -7
- package/src/cmd-verify.ts +81 -5
- package/src/dev-route-table.ts +7 -0
- package/src/error-codes.ts +8 -0
- package/src/mcp-errors.ts +5 -0
- package/src/messages.ts +3 -0
- package/src/output.ts +26 -0
- package/src/templates/scaffold-docs.ts +3 -1
- package/src/test-batches.ts +145 -0
- package/src/test-shards.ts +111 -23
- package/src/test-slots.ts +168 -0
- package/src/test-workers.ts +175 -63
- package/src/verify-errors.ts +49 -0
- package/src/verify-floor.ts +28 -0
- package/src/verify-merge.ts +235 -0
- package/src/verify-run.ts +16 -9
- package/src/verify-shard.ts +145 -0
- package/src/verify-step.ts +20 -1
- package/src/verify-test-run.ts +63 -11
- package/src/verify-tests.ts +39 -4
package/src/verify-run.ts
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
import { ERROR_DOCS_URL, renderThrowable } from '@ultimat3/core';
|
|
6
6
|
import { msg } from './messages';
|
|
7
7
|
import type { CommandResult, Finding, StepResult } from './output';
|
|
8
|
-
import { sharedWorkers } from './test-workers';
|
|
9
8
|
import {
|
|
10
9
|
floorRequires,
|
|
11
10
|
readVerifyFloor,
|
|
@@ -41,23 +40,27 @@ export async function runVerify(
|
|
|
41
40
|
await pending;
|
|
42
41
|
pending = undefined;
|
|
43
42
|
};
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
43
|
+
// Isolation is the floor file's unless the caller said: absent in both is off (22.7).
|
|
44
|
+
const base: VerifyContext =
|
|
45
|
+
ctx.isolate === undefined && floor?.isolate !== undefined
|
|
46
|
+
? { ...ctx, isolate: floor.isolate }
|
|
47
|
+
: ctx;
|
|
48
|
+
// A parallel suite inside the overlap window used to be narrowed to one worker per core; the
|
|
49
|
+
// default is at most that already (`test-workers.ts`), so the window runs the same plan.
|
|
50
|
+
const shared: VerifyContext = base;
|
|
48
51
|
for (const step of selected) {
|
|
49
52
|
if (beside.includes(step)) continue;
|
|
50
53
|
if (step.name === SERIAL_SUITES[0]) {
|
|
51
54
|
pending = Promise.all(
|
|
52
55
|
beside.map(async (other) => {
|
|
53
|
-
byName.set(other.name, await runStep(other,
|
|
56
|
+
byName.set(other.name, await runStep(other, base, floor));
|
|
54
57
|
}),
|
|
55
58
|
).then(() => undefined);
|
|
56
59
|
} else if (!SERIAL_SUITES.includes(step.name)) {
|
|
57
60
|
await join();
|
|
58
61
|
}
|
|
59
62
|
const inWindow = pending !== undefined && SERIAL_SUITES.includes(step.name);
|
|
60
|
-
byName.set(step.name, await runStep(step, inWindow ? shared :
|
|
63
|
+
byName.set(step.name, await runStep(step, inWindow ? shared : base, floor));
|
|
61
64
|
}
|
|
62
65
|
await join();
|
|
63
66
|
// Reported in the declared order, whatever order the steps finished in: the table, `--json` and
|
|
@@ -113,7 +116,7 @@ const onlyList = (only: VerifyContext['only']): readonly string[] | undefined =>
|
|
|
113
116
|
* vacuous gate stayed invisible. It names the skipped steps, not just how many: "17/17" is worth
|
|
114
117
|
* something only when the gap is visible in the same glance.
|
|
115
118
|
*/
|
|
116
|
-
function verifySummary(input: {
|
|
119
|
+
export function verifySummary(input: {
|
|
117
120
|
readonly results: readonly StepResult[];
|
|
118
121
|
readonly failed: readonly string[];
|
|
119
122
|
readonly skipped: readonly string[];
|
|
@@ -196,7 +199,9 @@ async function runStep(
|
|
|
196
199
|
// ONE definition of "nothing ran", read twice, because the floor decides which of the two
|
|
197
200
|
// things it means — exactly as it already does for a step whose `applies` said no.
|
|
198
201
|
const tests = outcome.tests;
|
|
199
|
-
|
|
202
|
+
// A shard's slice may hold nothing, or only skipped tests, and still be a correct slice: the
|
|
203
|
+
// "did the suite run at all" question is asked by `x verify merge`, on the summed counts.
|
|
204
|
+
const nothingRan = tests !== undefined && tests.ran === 0 && outcome.shard === undefined;
|
|
200
205
|
const required = floorRequires(floor, step.name);
|
|
201
206
|
// A step the floor requires whose suite executed nothing is the same vanished suite as a step
|
|
202
207
|
// with no files at all — the run just had to finish before it could be seen. Appended to the
|
|
@@ -213,6 +218,8 @@ async function runStep(
|
|
|
213
218
|
findings: [...outcome.findings, ...vanished],
|
|
214
219
|
...(outcome.output === undefined ? {} : { output: outcome.output }),
|
|
215
220
|
...(outcome.workers === undefined ? {} : { workers: outcome.workers }),
|
|
221
|
+
...(outcome.widthReason === undefined ? {} : { widthReason: outcome.widthReason }),
|
|
222
|
+
...(outcome.shard === undefined ? {} : { shard: outcome.shard }),
|
|
216
223
|
...(tests === undefined ? {} : { tests }),
|
|
217
224
|
};
|
|
218
225
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// `x verify --only <step> --shard i/n`: one CI job's slice of a parallel test step, and the facts
|
|
2
|
+
// `x verify merge` needs to prove that n jobs' slices were the whole corpus exactly once.
|
|
3
|
+
//
|
|
4
|
+
// The split is a PURE function of the sorted file list, n and (optionally) a timings file — never
|
|
5
|
+
// of the machine, the clock or the order a glob yielded — so job 3 of 8 is the same files on every
|
|
6
|
+
// runner, and a failing shard reproduces locally with the same flags.
|
|
7
|
+
|
|
8
|
+
import { renderFixShellArg } from '@ultimat3/core';
|
|
9
|
+
import { CryptoHasher } from 'bun';
|
|
10
|
+
import type { ShardFacts } from './output';
|
|
11
|
+
import { VerifyShardInvalidError } from './verify-errors';
|
|
12
|
+
import type { VerifyStepName } from './verify-step';
|
|
13
|
+
|
|
14
|
+
/** The steps a shard may split: the parallel suites. `live` and `e2e` are serial by design. */
|
|
15
|
+
export const SHARDABLE_STEPS: readonly VerifyStepName[] = ['unit', 'contract', 'job'];
|
|
16
|
+
|
|
17
|
+
export interface ShardSpec {
|
|
18
|
+
/** 1-based, as a CI matrix counts. */
|
|
19
|
+
readonly index: number;
|
|
20
|
+
readonly total: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** `3/8` → `{ index: 3, total: 8 }`, refused unless `1 <= i <= n`. */
|
|
24
|
+
export function parseShard(raw: string): ShardSpec {
|
|
25
|
+
const match = /^\s*(\d+)\s*\/\s*(\d+)\s*$/.exec(raw);
|
|
26
|
+
const index = match === null ? Number.NaN : Number(match[1]);
|
|
27
|
+
const total = match === null ? Number.NaN : Number(match[2]);
|
|
28
|
+
if (!Number.isSafeInteger(index) || !Number.isSafeInteger(total) || total < 1 || index < 1) {
|
|
29
|
+
throw new VerifyShardInvalidError({ reason: `"${raw}" is not i/n (e.g. 2/4)` });
|
|
30
|
+
}
|
|
31
|
+
if (index > total) {
|
|
32
|
+
throw new VerifyShardInvalidError({
|
|
33
|
+
reason: `shard ${String(index)} does not exist in a ${String(total)}-way split (1..${String(total)})`,
|
|
34
|
+
fix: `x verify --only unit --shard ${renderFixShellArg(`${String(total)}/${String(total)}`, '<i/n>')} --json`,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return { index, total };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The steps a shard run may name: every one must be shardable. Refused with the step named, and a
|
|
42
|
+
* fix that keeps the shardable ones — `live`/`e2e` belong in a job of their own.
|
|
43
|
+
*/
|
|
44
|
+
export function assertShardable(only: readonly VerifyStepName[] | undefined): void {
|
|
45
|
+
if (only === undefined || only.length === 0) {
|
|
46
|
+
throw new VerifyShardInvalidError({
|
|
47
|
+
reason: 'a shard is one slice of ONE parallel step and needs --only to name it',
|
|
48
|
+
fix: 'x verify --only unit --shard 1/4 --json',
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
const refused = only.filter((step) => !SHARDABLE_STEPS.includes(step));
|
|
52
|
+
if (refused.length > 0) {
|
|
53
|
+
const kept = only.filter((step) => SHARDABLE_STEPS.includes(step));
|
|
54
|
+
throw new VerifyShardInvalidError({
|
|
55
|
+
reason: `${refused.join(', ')} cannot be split (only ${SHARDABLE_STEPS.join(', ')}); run ${
|
|
56
|
+
refused.length === 1 ? 'it' : 'them'
|
|
57
|
+
} in a job of its own without --shard`,
|
|
58
|
+
fix:
|
|
59
|
+
kept.length === 0
|
|
60
|
+
? `x verify --only ${renderFixShellArg(refused.join(','), '<step,step>')} --json`
|
|
61
|
+
: `x verify --only ${renderFixShellArg(kept.join(','), '<step,step>')} --shard 1/4 --json`,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** sha256 of the sorted list, one path a line: equal across shards iff they split one corpus. */
|
|
67
|
+
export const corpusHash = (files: readonly string[]): string => {
|
|
68
|
+
const hasher = new CryptoHasher('sha256');
|
|
69
|
+
hasher.update([...files].sort().join('\n'));
|
|
70
|
+
return hasher.digest('hex');
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Per-file durations in ms — `{ "path": ms }`, as in Bun's `--timings` file's `files`. */
|
|
74
|
+
export type Timings = Readonly<Record<string, number>>;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Which files shard `index` of `total` runs. Round-robin over the sorted list by default — every
|
|
78
|
+
* shard gets a slice of every directory. With `timings`, greedy longest-first: each file, slowest
|
|
79
|
+
* first (ties by path), goes to the shard with the least total so far (ties to the lowest index);
|
|
80
|
+
* a file the timings do not know is costed at their median, so a new file cannot pile onto one job.
|
|
81
|
+
*/
|
|
82
|
+
export function shardFiles(
|
|
83
|
+
files: readonly string[],
|
|
84
|
+
spec: ShardSpec,
|
|
85
|
+
timings?: Timings,
|
|
86
|
+
): ShardFacts {
|
|
87
|
+
const sorted = [...new Set(files)].sort();
|
|
88
|
+
const hash = corpusHash(sorted);
|
|
89
|
+
let picked: string[];
|
|
90
|
+
if (timings === undefined) {
|
|
91
|
+
picked = sorted.filter((_, position) => position % spec.total === spec.index - 1);
|
|
92
|
+
} else {
|
|
93
|
+
const known = sorted
|
|
94
|
+
.map((file) => timings[file])
|
|
95
|
+
.filter((ms): ms is number => typeof ms === 'number' && Number.isFinite(ms) && ms >= 0)
|
|
96
|
+
.sort((a, b) => a - b);
|
|
97
|
+
const median = known.length === 0 ? 1 : (known[Math.floor(known.length / 2)] as number);
|
|
98
|
+
const cost = (file: string): number => {
|
|
99
|
+
const ms = Object.hasOwn(timings, file) ? timings[file] : undefined;
|
|
100
|
+
return typeof ms === 'number' && Number.isFinite(ms) && ms >= 0 ? ms : median;
|
|
101
|
+
};
|
|
102
|
+
const order = [...sorted].sort((a, b) => cost(b) - cost(a) || (a < b ? -1 : a > b ? 1 : 0));
|
|
103
|
+
const loads = Array.from({ length: spec.total }, () => 0);
|
|
104
|
+
const owner = new Map<string, number>();
|
|
105
|
+
for (const file of order) {
|
|
106
|
+
let best = 0;
|
|
107
|
+
for (let shard = 1; shard < spec.total; shard += 1) {
|
|
108
|
+
if ((loads[shard] as number) < (loads[best] as number)) best = shard;
|
|
109
|
+
}
|
|
110
|
+
loads[best] = (loads[best] as number) + cost(file);
|
|
111
|
+
owner.set(file, best);
|
|
112
|
+
}
|
|
113
|
+
picked = sorted.filter((file) => owner.get(file) === spec.index - 1);
|
|
114
|
+
}
|
|
115
|
+
return { index: spec.index, total: spec.total, corpusHash: hash, files: picked };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A timings file, or a refusal naming it — a CI job must not silently fall back to round-robin. */
|
|
119
|
+
export async function readTimings(path: string): Promise<Timings> {
|
|
120
|
+
const file = Bun.file(path);
|
|
121
|
+
if (!(await file.exists())) {
|
|
122
|
+
throw new VerifyShardInvalidError({
|
|
123
|
+
reason: `--timings ${path} does not exist`,
|
|
124
|
+
fix: 'bun test --timings=timings.json --update-timings # writes it, then commit or cache it',
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
let parsed: unknown;
|
|
128
|
+
try {
|
|
129
|
+
parsed = JSON.parse(await file.text());
|
|
130
|
+
} catch {
|
|
131
|
+
parsed = undefined;
|
|
132
|
+
}
|
|
133
|
+
// Bun's own `--update-timings` writes `{ "version": 1, "files": { "path": ms } }`; a bare map
|
|
134
|
+
// is accepted too.
|
|
135
|
+
if (typeof parsed === 'object' && parsed !== null && 'files' in parsed) {
|
|
136
|
+
parsed = (parsed as { files: unknown }).files;
|
|
137
|
+
}
|
|
138
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
139
|
+
throw new VerifyShardInvalidError({
|
|
140
|
+
reason: `--timings ${path} is not a JSON object of { "path": ms }`,
|
|
141
|
+
fix: 'bun test --timings=timings.json --update-timings',
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return parsed as Timings;
|
|
145
|
+
}
|
package/src/verify-step.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
6
6
|
import type { ExecResult, Runner } from './exec';
|
|
7
7
|
import { execOutput } from './exec';
|
|
8
|
-
import type { Finding } from './output';
|
|
8
|
+
import type { Finding, ShardFacts } from './output';
|
|
9
9
|
import type { TestCounts } from './test-counts';
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -78,6 +78,21 @@ export interface VerifyContext {
|
|
|
78
78
|
* narrowing: no value of it changes which steps run or what "green" means.
|
|
79
79
|
*/
|
|
80
80
|
readonly workers?: number;
|
|
81
|
+
/**
|
|
82
|
+
* A fresh global per test file (`bun test --isolate`). Absent means `x.verify.json`'s
|
|
83
|
+
* `"isolate"`, and absent there means off — the default since 22.7.
|
|
84
|
+
*/
|
|
85
|
+
readonly isolate?: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* One slice of an n-way CI split of each selected parallel step's file list (`--shard i/n`,
|
|
88
|
+
* 1-based `index`). Only with `only`; `x verify merge` reassembles the verdict.
|
|
89
|
+
*/
|
|
90
|
+
readonly shard?: {
|
|
91
|
+
readonly index: number;
|
|
92
|
+
readonly total: number;
|
|
93
|
+
/** Bun `--timings` durations: greedy longest-first instead of round-robin. */
|
|
94
|
+
readonly timings?: Readonly<Record<string, number>>;
|
|
95
|
+
};
|
|
81
96
|
/**
|
|
82
97
|
* Some steps, by name — an iteration loop, and the one thing here that IS a narrowing. The
|
|
83
98
|
* whole gate costs minutes (3m19s at the framework root on 12 cores, `As of 2026-09-23`), which
|
|
@@ -103,6 +118,10 @@ export interface StepOutcome {
|
|
|
103
118
|
* reading the step list's source.
|
|
104
119
|
*/
|
|
105
120
|
readonly workers?: number;
|
|
121
|
+
/** Why `workers` is what it is — `4 workers (budget 4.0 GB)`. Printed on the step line. */
|
|
122
|
+
readonly widthReason?: string;
|
|
123
|
+
/** The CI slice this step ran, under `--shard`: which files, and a hash of the whole list. */
|
|
124
|
+
readonly shard?: ShardFacts;
|
|
106
125
|
/**
|
|
107
126
|
* What the suite executed, for the steps that run one. Absent means this step spawned no test
|
|
108
127
|
* process at all — a check step, or the `eval` step answering with declarations alone — and is
|
package/src/verify-test-run.ts
CHANGED
|
@@ -2,13 +2,16 @@
|
|
|
2
2
|
// files belong to a type and this one owns what happens to them once selected — a wrong file list
|
|
3
3
|
// is never a race, and a race is never a selection bug.
|
|
4
4
|
|
|
5
|
+
// why: Bun ships no path-join primitive; the timings cache lives under the run's root.
|
|
6
|
+
import { join } from 'node:path';
|
|
5
7
|
import type { Runner } from './exec';
|
|
6
|
-
import { execOutput } from './exec';
|
|
7
8
|
import type { Finding } from './output';
|
|
9
|
+
import { runBatches, testBatches } from './test-batches';
|
|
8
10
|
import { countsOf } from './test-counts';
|
|
9
11
|
import { testEnvOverrides } from './test-dotenv';
|
|
10
12
|
import type { TestFile } from './test-select';
|
|
11
|
-
import { failureOf, testArgs } from './test-shards';
|
|
13
|
+
import { failureOf, ISOLATED_TEST_ENV, TEST_TIMINGS_FILE, testArgs } from './test-shards';
|
|
14
|
+
import { BATCH_FILES_PER_WORKER, SHARED_BATCH_FILES_PER_WORKER } from './test-workers';
|
|
12
15
|
import type { StepOutcome } from './verify-step';
|
|
13
16
|
// Type-only, so nothing here evaluates verify-tests.ts and the two files cannot form a cycle.
|
|
14
17
|
import type { TestType } from './verify-tests';
|
|
@@ -23,10 +26,17 @@ export interface ParallelRunOptions {
|
|
|
23
26
|
readonly type: TestType;
|
|
24
27
|
/** This process's own environment. Optional and defaulted to `Bun.env` — see `VerifyContext.env`. */
|
|
25
28
|
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
29
|
+
/** A fresh global per file — `x.verify.json` `"isolate": true` or `--isolate`. Default off. */
|
|
30
|
+
readonly isolate?: boolean;
|
|
31
|
+
/** Why the width is what it is — printed on the step line. */
|
|
32
|
+
readonly widthReason?: string;
|
|
33
|
+
/** The machine-pool lease (`test-slots.ts`), for a default-width run. */
|
|
34
|
+
readonly lease?: (want: number) => Promise<{ readonly count: number; release(): void }>;
|
|
26
35
|
}
|
|
27
36
|
|
|
28
37
|
/**
|
|
29
|
-
*
|
|
38
|
+
* `bun test --parallel=N` per batch (`test-batches.ts`), not N processes this file spawns and
|
|
39
|
+
* packs itself.
|
|
30
40
|
*
|
|
31
41
|
* Two things make an arbitrary partition safe here and neither is optional. `--parallel` implies
|
|
32
42
|
* `--isolate`, so every FILE gets a fresh module registry — half a dozen registries in this
|
|
@@ -41,24 +51,66 @@ export interface ParallelRunOptions {
|
|
|
41
51
|
export async function runParallel(options: ParallelRunOptions): Promise<StepOutcome> {
|
|
42
52
|
const files = options.files.map((file) => file.path);
|
|
43
53
|
const workers = Math.max(1, Math.min(Math.trunc(options.workers), files.length || 1));
|
|
44
|
-
const envOverrides =
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
54
|
+
const envOverrides = {
|
|
55
|
+
...testEnvOverrides(options.root, options.env ?? Bun.env),
|
|
56
|
+
...(options.isolate === true ? { [ISOLATED_TEST_ENV]: '1' } : {}),
|
|
57
|
+
};
|
|
58
|
+
// Batched exactly as `x test` batches the same selection at the same width, so the gate's
|
|
59
|
+
// `fix:` line — `x test <type> --workers N` — reruns the very processes that failed.
|
|
60
|
+
const batches = testBatches(
|
|
61
|
+
files,
|
|
62
|
+
workers,
|
|
63
|
+
options.isolate === true ? BATCH_FILES_PER_WORKER : SHARED_BATCH_FILES_PER_WORKER,
|
|
64
|
+
);
|
|
65
|
+
const result = await runBatches({
|
|
66
|
+
runner: options.runner,
|
|
67
|
+
batches,
|
|
68
|
+
workers,
|
|
69
|
+
...(options.lease === undefined || workers === 1 ? {} : { lease: options.lease }),
|
|
70
|
+
argsFor: (batch, width) =>
|
|
71
|
+
testArgs({
|
|
72
|
+
files: batch,
|
|
73
|
+
workers: width,
|
|
74
|
+
isolate: options.isolate === true,
|
|
75
|
+
timings: join(options.root, TEST_TIMINGS_FILE),
|
|
76
|
+
}),
|
|
77
|
+
options: {
|
|
78
|
+
cwd: options.root,
|
|
79
|
+
...(Object.keys(envOverrides).length === 0 ? {} : { env: envOverrides }),
|
|
80
|
+
},
|
|
48
81
|
});
|
|
49
82
|
// `failureOf` is `x test`'s own, imported rather than restated: the two paths report the SAME
|
|
50
83
|
// failed `bun test`, so a second literal here is two `cause:` strings and two `fix:` lines free
|
|
51
84
|
// to drift — and the one that drifts is the gate's, which is the one an agent reads first.
|
|
52
85
|
const findings: readonly Finding[] = result.ok
|
|
53
86
|
? []
|
|
54
|
-
: [
|
|
87
|
+
: [
|
|
88
|
+
failureOf(result.code, files.length, {
|
|
89
|
+
workers,
|
|
90
|
+
type: options.type,
|
|
91
|
+
...(options.isolate === true ? { isolate: true } : {}),
|
|
92
|
+
}),
|
|
93
|
+
];
|
|
94
|
+
// The width the run REACHED: a batch the machine pool narrowed ran narrower than planned, and a
|
|
95
|
+
// step line that printed the plan would name a width that never ran.
|
|
96
|
+
const reached = Math.max(1, ...result.widths);
|
|
97
|
+
const narrowed = reached < workers;
|
|
55
98
|
return {
|
|
56
99
|
ok: findings.length === 0,
|
|
57
100
|
findings,
|
|
58
|
-
workers,
|
|
59
|
-
|
|
101
|
+
workers: reached,
|
|
102
|
+
...(options.widthReason === undefined
|
|
103
|
+
? {}
|
|
104
|
+
: {
|
|
105
|
+
widthReason: narrowed
|
|
106
|
+
? `${String(reached)} of ${options.widthReason} — machine slots held by another run`
|
|
107
|
+
: options.widthReason,
|
|
108
|
+
}),
|
|
109
|
+
// Per batch: each process printed its own summary, and one parse of the joined text would
|
|
110
|
+
// read only the last one.
|
|
111
|
+
tests: countsOf(result.results),
|
|
60
112
|
// Only on failure: a green run's summary is already the step table's, and the reader of a red
|
|
61
113
|
// gate needs the assertion diff.
|
|
62
|
-
...(result.ok ? {} : { output:
|
|
114
|
+
...(result.ok ? {} : { output: result.output }),
|
|
63
115
|
};
|
|
64
116
|
}
|
package/src/verify-tests.ts
CHANGED
|
@@ -18,8 +18,11 @@ import { countsOf } from './test-counts';
|
|
|
18
18
|
import { testEnvOverrides } from './test-dotenv';
|
|
19
19
|
import type { TestFile } from './test-select';
|
|
20
20
|
import { discoverTests } from './test-select';
|
|
21
|
-
import {
|
|
21
|
+
import { machineLease } from './test-slots';
|
|
22
|
+
import type { WorkerPlan } from './test-workers';
|
|
23
|
+
import { availableCpus, SERIAL_TYPES, totalMemory, workerPlan } from './test-workers';
|
|
22
24
|
import { withE2eApp } from './verify-e2e';
|
|
25
|
+
import { shardFiles } from './verify-shard';
|
|
23
26
|
import type { StepOutcome, VerifyContext, VerifyStep } from './verify-step';
|
|
24
27
|
import { fromExec, fromFindings } from './verify-step';
|
|
25
28
|
import { runParallel } from './verify-test-run';
|
|
@@ -212,16 +215,48 @@ const runSerial = async (ctx: VerifyContext, type: TestType): Promise<StepOutcom
|
|
|
212
215
|
|
|
213
216
|
const runType = async (ctx: VerifyContext, type: TestType): Promise<StepOutcome> => {
|
|
214
217
|
if (isSerial(type)) return runSerial(ctx, type);
|
|
215
|
-
const
|
|
218
|
+
const discoveredFiles = await filesFor(ctx.root, type);
|
|
219
|
+
// Under `--shard`, this job runs its slice and says which — `x verify merge` checks the slices.
|
|
220
|
+
// An EMPTY slice is a pass with zero tests, never the serial fallback below (which would run
|
|
221
|
+
// the whole type): the zero-tests floor is `merge`'s to apply, on the summed counts.
|
|
222
|
+
const shard =
|
|
223
|
+
ctx.shard === undefined
|
|
224
|
+
? undefined
|
|
225
|
+
: shardFiles(
|
|
226
|
+
discoveredFiles.map((file) => file.path),
|
|
227
|
+
ctx.shard,
|
|
228
|
+
ctx.shard.timings,
|
|
229
|
+
);
|
|
230
|
+
if (shard !== undefined && shard.files.length === 0) {
|
|
231
|
+
return { ok: true, findings: [], workers: 0, tests: { ran: 0, skipped: 0 }, shard };
|
|
232
|
+
}
|
|
233
|
+
const files =
|
|
234
|
+
shard === undefined
|
|
235
|
+
? discoveredFiles
|
|
236
|
+
: discoveredFiles.filter((file) => shard.files.includes(file.path));
|
|
216
237
|
if (files.length === 0) return runSerial(ctx, type);
|
|
217
|
-
|
|
238
|
+
const env = ctx.env ?? Bun.env;
|
|
239
|
+
const plan =
|
|
240
|
+
ctx.workers === undefined ? workerPlan(availableCpus(), totalMemory(), env) : undefined;
|
|
241
|
+
const workers = ctx.workers ?? (plan as WorkerPlan).workers;
|
|
242
|
+
const lease = plan === undefined ? undefined : machineLease(plan.workers, env);
|
|
243
|
+
const outcome = await runParallel({
|
|
218
244
|
root: ctx.root,
|
|
219
245
|
runner: ctx.runner,
|
|
220
246
|
files,
|
|
221
|
-
workers
|
|
247
|
+
workers,
|
|
222
248
|
type,
|
|
249
|
+
isolate: ctx.isolate === true,
|
|
250
|
+
widthReason:
|
|
251
|
+
plan === undefined
|
|
252
|
+
? `${String(Math.min(workers, files.length))} workers (--workers)`
|
|
253
|
+
: files.length < plan.workers
|
|
254
|
+
? `${String(files.length)} of ${plan.reason}`
|
|
255
|
+
: plan.reason,
|
|
256
|
+
...(lease === undefined ? {} : { lease }),
|
|
223
257
|
...(ctx.env === undefined ? {} : { env: ctx.env }),
|
|
224
258
|
});
|
|
259
|
+
return shard === undefined ? outcome : { ...outcome, shard };
|
|
225
260
|
};
|
|
226
261
|
|
|
227
262
|
const isApp = (root: string): boolean => existsSync(join(root, APP_CONFIG_FILE));
|