@ultimat3/cli 22.6.0 → 22.6.2
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/api-registration.ts +1 -3
- package/src/app-load.ts +16 -8
- package/src/app-root.ts +6 -0
- package/src/cmd-test-spec.ts +9 -2
- package/src/cmd-verify-spec.ts +8 -2
- package/src/messages.ts +2 -0
- package/src/test-batches.ts +114 -0
- package/src/test-shards.ts +40 -16
- package/src/test-workers.ts +43 -20
- package/src/verify-test-run.ts +18 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "22.6.
|
|
3
|
+
"version": "22.6.2",
|
|
4
4
|
"description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -38,33 +38,33 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@babel/core": "^8.0.1",
|
|
41
|
-
"@ultimat3/action": "22.6.
|
|
42
|
-
"@ultimat3/admin": "22.6.
|
|
43
|
-
"@ultimat3/ai": "22.6.
|
|
44
|
-
"@ultimat3/auth": "22.6.
|
|
45
|
-
"@ultimat3/cache": "22.6.
|
|
46
|
-
"@ultimat3/core": "22.6.
|
|
47
|
-
"@ultimat3/db": "22.6.
|
|
48
|
-
"@ultimat3/entity": "22.6.
|
|
49
|
-
"@ultimat3/flags": "22.6.
|
|
50
|
-
"@ultimat3/http": "22.6.
|
|
51
|
-
"@ultimat3/i18n": "22.6.
|
|
52
|
-
"@ultimat3/jobs": "22.6.
|
|
53
|
-
"@ultimat3/mail": "22.6.
|
|
54
|
-
"@ultimat3/manifest": "22.6.
|
|
55
|
-
"@ultimat3/mcp": "22.6.
|
|
56
|
-
"@ultimat3/money": "22.6.
|
|
57
|
-
"@ultimat3/notify": "22.6.
|
|
58
|
-
"@ultimat3/policy": "22.6.
|
|
59
|
-
"@ultimat3/pwa": "22.6.
|
|
60
|
-
"@ultimat3/query": "22.6.
|
|
61
|
-
"@ultimat3/realtime": "22.6.
|
|
62
|
-
"@ultimat3/render": "22.6.
|
|
63
|
-
"@ultimat3/schema": "22.6.
|
|
64
|
-
"@ultimat3/seo": "22.6.
|
|
65
|
-
"@ultimat3/storage": "22.6.
|
|
66
|
-
"@ultimat3/testing": "22.6.
|
|
67
|
-
"@ultimat3/time": "22.6.
|
|
41
|
+
"@ultimat3/action": "22.6.2",
|
|
42
|
+
"@ultimat3/admin": "22.6.2",
|
|
43
|
+
"@ultimat3/ai": "22.6.2",
|
|
44
|
+
"@ultimat3/auth": "22.6.2",
|
|
45
|
+
"@ultimat3/cache": "22.6.2",
|
|
46
|
+
"@ultimat3/core": "22.6.2",
|
|
47
|
+
"@ultimat3/db": "22.6.2",
|
|
48
|
+
"@ultimat3/entity": "22.6.2",
|
|
49
|
+
"@ultimat3/flags": "22.6.2",
|
|
50
|
+
"@ultimat3/http": "22.6.2",
|
|
51
|
+
"@ultimat3/i18n": "22.6.2",
|
|
52
|
+
"@ultimat3/jobs": "22.6.2",
|
|
53
|
+
"@ultimat3/mail": "22.6.2",
|
|
54
|
+
"@ultimat3/manifest": "22.6.2",
|
|
55
|
+
"@ultimat3/mcp": "22.6.2",
|
|
56
|
+
"@ultimat3/money": "22.6.2",
|
|
57
|
+
"@ultimat3/notify": "22.6.2",
|
|
58
|
+
"@ultimat3/policy": "22.6.2",
|
|
59
|
+
"@ultimat3/pwa": "22.6.2",
|
|
60
|
+
"@ultimat3/query": "22.6.2",
|
|
61
|
+
"@ultimat3/realtime": "22.6.2",
|
|
62
|
+
"@ultimat3/render": "22.6.2",
|
|
63
|
+
"@ultimat3/schema": "22.6.2",
|
|
64
|
+
"@ultimat3/seo": "22.6.2",
|
|
65
|
+
"@ultimat3/storage": "22.6.2",
|
|
66
|
+
"@ultimat3/testing": "22.6.2",
|
|
67
|
+
"@ultimat3/time": "22.6.2",
|
|
68
68
|
"babel-preset-solid": "^1.9.15"
|
|
69
69
|
}
|
|
70
70
|
}
|
package/src/api-registration.ts
CHANGED
|
@@ -3,13 +3,11 @@
|
|
|
3
3
|
// nothing lists keeps the positional `anonymous-job-2` that `job()` minted, on the queue row, in
|
|
4
4
|
// `x.manifest.json` and in every dead-letter trace — under a green gate (plan 101 slice 11 b).
|
|
5
5
|
|
|
6
|
+
import { API_INDEX } from './app-root';
|
|
6
7
|
import { containedPath } from './generate-write';
|
|
7
8
|
import { camel } from './templates/naming';
|
|
8
9
|
import { wrapList } from './templates/wrap';
|
|
9
10
|
|
|
10
|
-
/** The one file `defineApi` is called from in a scaffolded app. */
|
|
11
|
-
export const API_INDEX = 'apps/web/api/index.ts';
|
|
12
|
-
|
|
13
11
|
/** One module to import as a namespace and list under a `defineApi` key. */
|
|
14
12
|
export interface ApiEntry {
|
|
15
13
|
readonly key: 'jobs' | 'tasks';
|
package/src/app-load.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
// Bun ships no `Bun.*` path API: `relative`/`sep` turn an absolute scan hit into the app-root-
|
|
7
7
|
// relative POSIX path every finding and every manifest fact is keyed by.
|
|
8
|
-
import { join, relative, sep } from 'node:path';
|
|
8
|
+
import { join, relative, resolve, sep } from 'node:path';
|
|
9
9
|
import { listActions, registerActions } from '@ultimat3/action';
|
|
10
10
|
import { describeEntities } from '@ultimat3/entity';
|
|
11
11
|
import { localeConfig } from '@ultimat3/i18n';
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
// accident one refactor away from compiling every app's `.tsx` to `React.createElement`. The named
|
|
29
29
|
// import is a value import, so the side effect holds without the bare line it replaced.
|
|
30
30
|
import { setStylesheetRoot } from '@ultimat3/render/server';
|
|
31
|
-
import { APP_CONFIG_FILE } from './app-root';
|
|
31
|
+
import { API_INDEX, APP_CONFIG_FILE } from './app-root';
|
|
32
32
|
import { collectDeclaredCodes } from './error-contract';
|
|
33
33
|
import type { Finding } from './output';
|
|
34
34
|
import { findingFrom } from './output';
|
|
@@ -116,11 +116,17 @@ export function resetAppLoad(): void {
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
/**
|
|
119
|
-
* Every module path the app globs match, SORTED. `Bun.Glob` answers in
|
|
120
|
-
* the filesystem's — ext4 hashes names with a per-filesystem seed — so
|
|
121
|
-
* imported the app in two orders, registered its stylesheets in two orders,
|
|
122
|
-
* different `/styles/<hash>.css` for one page (notificado.co, 22.3.2). Import order
|
|
123
|
-
* stylesheet cascade's order, so it must be the same everywhere.
|
|
119
|
+
* Every module path the app globs match, SORTED — with the API index first. `Bun.Glob` answers in
|
|
120
|
+
* directory order, which is the filesystem's — ext4 hashes names with a per-filesystem seed — so
|
|
121
|
+
* two pods of one image imported the app in two orders, registered its stylesheets in two orders,
|
|
122
|
+
* and served two different `/styles/<hash>.css` for one page (notificado.co, 22.3.2). Import order
|
|
123
|
+
* is the stylesheet cascade's order, so it must be the same everywhere.
|
|
124
|
+
*
|
|
125
|
+
* The API index leads because `defineApi({ http: { pathStyle } })` is what every action URL is
|
|
126
|
+
* derived under: sorted, `apps/admin/**` evaluated before it, and a module-level
|
|
127
|
+
* `derivePath('signOut').path` there captured the default style's `/api/outs/sign` — a form
|
|
128
|
+
* posting to a URL no route served (notificado.co, 22.6.0). Anything still evaluated before the
|
|
129
|
+
* declaration (the index's own imports) is refused at it, `X_ACTION_PATH_DERIVED_EARLY`.
|
|
124
130
|
*/
|
|
125
131
|
export async function appModulePaths(root: string): Promise<readonly string[]> {
|
|
126
132
|
// Pattern by pattern, as before — only the order WITHIN a pattern was the filesystem's.
|
|
@@ -134,7 +140,9 @@ export async function appModulePaths(root: string): Promise<readonly string[]> {
|
|
|
134
140
|
}
|
|
135
141
|
found.push(...matched.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)));
|
|
136
142
|
}
|
|
137
|
-
|
|
143
|
+
// Resolved, like the glob's own answers: `loadApp('.')` is a legal call.
|
|
144
|
+
const index = resolve(root, API_INDEX);
|
|
145
|
+
return found.includes(index) ? [index, ...found.filter((path) => path !== index)] : found;
|
|
138
146
|
}
|
|
139
147
|
|
|
140
148
|
export async function loadApp(root: string): Promise<LoadedApp> {
|
package/src/app-root.ts
CHANGED
|
@@ -6,6 +6,12 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
6
6
|
import { BunVersionError, NotInAppError } from './errors';
|
|
7
7
|
|
|
8
8
|
export const APP_CONFIG_FILE = 'app.config.ts';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The one file `defineApi` is called from in a scaffolded app — here, beside the config file, so
|
|
12
|
+
* the module scan can import it first without pulling the generators into the serve graph.
|
|
13
|
+
*/
|
|
14
|
+
export const API_INDEX = 'apps/web/api/index.ts';
|
|
9
15
|
export const MANIFEST_FILE = 'x.manifest.json';
|
|
10
16
|
/**
|
|
11
17
|
* The floor the shipped `x` enforces, and it must not sit below what `x` EMITS. It said `1.3.0`
|
package/src/cmd-test-spec.ts
CHANGED
|
@@ -4,7 +4,14 @@
|
|
|
4
4
|
import { TEST_TYPES } from '@ultimat3/testing/test-types';
|
|
5
5
|
import { DEFAULT_BASE } from './affected';
|
|
6
6
|
import type { CommandSpec } from './parse';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
MEMORY_SHARE,
|
|
9
|
+
SERIAL_TYPES,
|
|
10
|
+
WORKER_BYTES,
|
|
11
|
+
WORKER_CEILING,
|
|
12
|
+
WORKER_FLOOR,
|
|
13
|
+
WORKER_OVERSUBSCRIBE,
|
|
14
|
+
} from './test-workers';
|
|
8
15
|
|
|
9
16
|
export const testSpec: CommandSpec = {
|
|
10
17
|
name: 'test',
|
|
@@ -20,7 +27,7 @@ export const testSpec: CommandSpec = {
|
|
|
20
27
|
{
|
|
21
28
|
name: 'workers',
|
|
22
29
|
type: 'string',
|
|
23
|
-
summary: `bun worker count (default: ${WORKER_OVERSUBSCRIBE}x CPUs rounded up, held to free memory, min ${WORKER_FLOOR}; max ${WORKER_CEILING}); clamped to the file count, and to 1 for ${SERIAL_TYPES.join(' and ')}`,
|
|
30
|
+
summary: `bun worker count (default: ${WORKER_OVERSUBSCRIBE}x CPUs rounded up, held to ${MEMORY_SHARE * 100}% of free memory at ${WORKER_BYTES / 2 ** 30} GiB a worker, min ${WORKER_FLOOR}; max ${WORKER_CEILING}); clamped to the file count, and to 1 for ${SERIAL_TYPES.join(' and ')}`,
|
|
24
31
|
},
|
|
25
32
|
{
|
|
26
33
|
name: 'worker',
|
package/src/cmd-verify-spec.ts
CHANGED
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
// read it without loading `cmd-verify.ts`, which `registry.ts` imports only when the command runs.
|
|
3
3
|
|
|
4
4
|
import type { CommandSpec } from './parse';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
MEMORY_SHARE,
|
|
7
|
+
WORKER_BYTES,
|
|
8
|
+
WORKER_CEILING,
|
|
9
|
+
WORKER_FLOOR,
|
|
10
|
+
WORKER_OVERSUBSCRIBE,
|
|
11
|
+
} from './test-workers';
|
|
6
12
|
|
|
7
13
|
export const verifySpec: CommandSpec = {
|
|
8
14
|
name: 'verify',
|
|
@@ -16,7 +22,7 @@ export const verifySpec: CommandSpec = {
|
|
|
16
22
|
{
|
|
17
23
|
name: 'workers',
|
|
18
24
|
type: 'string',
|
|
19
|
-
summary: `test processes per parallel step (default: ${WORKER_OVERSUBSCRIBE}x CPUs rounded up, held to free memory; min ${WORKER_FLOOR}, max ${WORKER_CEILING})`,
|
|
25
|
+
summary: `test processes per parallel step (default: ${WORKER_OVERSUBSCRIBE}x CPUs rounded up, held to ${MEMORY_SHARE * 100}% of free memory at ${WORKER_BYTES / 2 ** 30} GiB a worker; min ${WORKER_FLOOR}, max ${WORKER_CEILING})`,
|
|
20
26
|
},
|
|
21
27
|
{
|
|
22
28
|
name: 'only',
|
package/src/messages.ts
CHANGED
|
@@ -265,6 +265,8 @@ const CATALOG = {
|
|
|
265
265
|
'cli.test.affected.none': 'nothing is affected by {base}...HEAD — 0 test file(s) ran',
|
|
266
266
|
'cli.test.empty': 'no test file matches {selection} — 0 test file(s) ran (--allow-empty)',
|
|
267
267
|
'cli.test.pass': '{files} test file(s) on {workers} worker(s) passed in {ms}ms',
|
|
268
|
+
'cli.test.batch': '── batch {batch} of {batches} · {files} file(s) ──',
|
|
269
|
+
'cli.test.batches': ' · {batches} batches',
|
|
268
270
|
'cli.test.sampled': 'sampled {kept} of {total} {type} file(s)',
|
|
269
271
|
'cli.test.type.fail': '{type} — {failed} of {workers} shard(s) failed',
|
|
270
272
|
'cli.test.type.pass': '{type} — {files} test file(s) on {workers} worker(s) passed in {ms}ms',
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Spending one pass as a few `bun test --parallel=N` processes in sequence, not one. The width of
|
|
2
|
+
// a pass is `test-workers.ts`'s to decide; how many files one set of N workers is allowed to chew
|
|
3
|
+
// through before it is thrown away is this file's.
|
|
4
|
+
//
|
|
5
|
+
// WHY THERE IS MORE THAN ONE PROCESS, `As of 2026-09-27`. A `--parallel` worker is not freed file
|
|
6
|
+
// by file: it keeps its heap for the life of the `bun test` process and it GROWS with every file it
|
|
7
|
+
// is handed (JIT, module-resolution caches, PGlite heaps the collector has not yet returned). On
|
|
8
|
+
// notificado.co's unit corpus (768 files, PGlite per file, 12-core box, Bun 1.4.0), whole-tree RSS
|
|
9
|
+
// at 12 workers:
|
|
10
|
+
//
|
|
11
|
+
// | form | peak tree RSS | wall |
|
|
12
|
+
// |---------------------------------------------|---------------|--------|
|
|
13
|
+
// | 1x `--parallel=12`, 768 files (~64/worker) | 12.4-13.3 GB | 134-158s |
|
|
14
|
+
// | 3x `--parallel=12`, 256 files (~21/worker) | 10.8-11.4 GB | 133-147s |
|
|
15
|
+
// | 6x `--parallel=12`, 128 files (~11/worker) | 9.7 GB | 166s |
|
|
16
|
+
//
|
|
17
|
+
// (Wall is noisy — the box was shared, at load 30-40 — but batching at the same width never
|
|
18
|
+
// measured slower than not batching outside one outlier run.)
|
|
19
|
+
//
|
|
20
|
+
// One process's footprint scales with the files each worker sees, so an unbatched run's peak is a
|
|
21
|
+
// function of the CORPUS: every test file an app adds raises it, and nothing ever gives it back
|
|
22
|
+
// until the run ends. A batch boundary gives all of it back. At `BATCH_FILES_PER_WORKER` files per
|
|
23
|
+
// worker the peak is a function of the WIDTH alone — and the wall clock does not move, because a
|
|
24
|
+
// worker restart costs a second against a batch of ~40s. Halving the batch again bought 1.5 GB for
|
|
25
|
+
// 25% more wall, which is why the constant is not smaller.
|
|
26
|
+
//
|
|
27
|
+
// THE DEAL IS ROUND-ROBIN over the sorted list, so every batch gets a slice of every directory
|
|
28
|
+
// (a slow directory is not one batch's tail) and the split is a pure function of the file list and
|
|
29
|
+
// the width: `x test unit --workers N` reruns exactly the batches the failing run ran.
|
|
30
|
+
|
|
31
|
+
import type { ExecResult, Runner } from './exec';
|
|
32
|
+
import { execOutput } from './exec';
|
|
33
|
+
import { msg } from './messages';
|
|
34
|
+
import { BATCH_FILES_PER_WORKER } from './test-workers';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The file groups one pass runs as, in order. One group — the list itself — whenever the list fits
|
|
38
|
+
* in `workers x BATCH_FILES_PER_WORKER`, which is every small selection and every `--filter`.
|
|
39
|
+
* Otherwise the fewest groups that fit, dealt round-robin so their sizes differ by at most one.
|
|
40
|
+
*/
|
|
41
|
+
export function testBatches(files: readonly string[], workers: number): readonly string[][] {
|
|
42
|
+
const sorted = [...files].sort();
|
|
43
|
+
const cap = Math.max(1, Math.trunc(workers)) * BATCH_FILES_PER_WORKER;
|
|
44
|
+
const count = Math.max(1, Math.ceil(sorted.length / cap));
|
|
45
|
+
const batches: string[][] = Array.from({ length: count }, () => []);
|
|
46
|
+
sorted.forEach((file, index) => {
|
|
47
|
+
batches[index % count]?.push(file);
|
|
48
|
+
});
|
|
49
|
+
return batches;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Every batch's own result, and what they add up to. */
|
|
53
|
+
export interface BatchedRun {
|
|
54
|
+
/** One per batch, in order — `countsOf` reads each one's own Bun summary. */
|
|
55
|
+
readonly results: readonly ExecResult[];
|
|
56
|
+
readonly ok: boolean;
|
|
57
|
+
/** The first non-zero exit, or 0. */
|
|
58
|
+
readonly code: number;
|
|
59
|
+
/** Summed: the batches ran one after another. */
|
|
60
|
+
readonly durationMs: number;
|
|
61
|
+
/**
|
|
62
|
+
* Every batch's output, each under a line naming it when there was more than one. One batch is
|
|
63
|
+
* `execOutput` of it, byte for byte, so a small run reads exactly as it always has.
|
|
64
|
+
*/
|
|
65
|
+
readonly output: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Run `batches` one after another — never `Promise.all`, which would put every batch's workers on
|
|
70
|
+
* the machine at once and undo the bound — and keep going after a red one, because the caller
|
|
71
|
+
* asked for the whole selection and a report that stops at the first failure hides the rest. The
|
|
72
|
+
* one exception is a caller that asked to stop (`stopOnFailure`, i.e. `-- --bail`).
|
|
73
|
+
*/
|
|
74
|
+
export async function runBatches(input: {
|
|
75
|
+
readonly runner: Runner;
|
|
76
|
+
readonly batches: readonly (readonly string[])[];
|
|
77
|
+
readonly argsFor: (files: readonly string[]) => readonly string[];
|
|
78
|
+
readonly options: Parameters<Runner>[1];
|
|
79
|
+
/**
|
|
80
|
+
* Launch no further batch once one is red. Set for a caller-forwarded `--bail`: Bun stops a
|
|
81
|
+
* process at its threshold, and without this the next batch would start over from zero.
|
|
82
|
+
*/
|
|
83
|
+
readonly stopOnFailure?: boolean;
|
|
84
|
+
}): Promise<BatchedRun> {
|
|
85
|
+
const results: ExecResult[] = [];
|
|
86
|
+
for (const files of input.batches) {
|
|
87
|
+
const result = await input.runner(input.argsFor(files), input.options);
|
|
88
|
+
results.push(result);
|
|
89
|
+
if (!result.ok && input.stopOnFailure === true) break;
|
|
90
|
+
}
|
|
91
|
+
const first = results.find((result) => !result.ok);
|
|
92
|
+
const output =
|
|
93
|
+
results.length === 1
|
|
94
|
+
? execOutput(results[0] as ExecResult)
|
|
95
|
+
: results
|
|
96
|
+
.map((result, index) =>
|
|
97
|
+
[
|
|
98
|
+
msg('cli.test.batch', {
|
|
99
|
+
batch: index + 1,
|
|
100
|
+
batches: results.length,
|
|
101
|
+
files: input.batches[index]?.length ?? 0,
|
|
102
|
+
}),
|
|
103
|
+
execOutput(result),
|
|
104
|
+
].join('\n'),
|
|
105
|
+
)
|
|
106
|
+
.join('\n');
|
|
107
|
+
return {
|
|
108
|
+
results,
|
|
109
|
+
ok: first === undefined,
|
|
110
|
+
code: first?.code ?? 0,
|
|
111
|
+
durationMs: results.reduce((sum, result) => sum + result.durationMs, 0),
|
|
112
|
+
output,
|
|
113
|
+
};
|
|
114
|
+
}
|
package/src/test-shards.ts
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
// reproduces it exactly. Split out of cmd-test.ts because a printed reproduction is only true if
|
|
3
3
|
// it carries every input to the run — that rule is this file's, and argv parsing is that one's.
|
|
4
4
|
//
|
|
5
|
+
// ONE PROCESS PER BATCH, N WORKERS. Until 2026-09-27 it was one process for the whole selection;
|
|
6
|
+
// a worker's heap grows with every file it runs, so that process's peak grew with the corpus, and
|
|
7
|
+
// `test-batches.ts` now spends each pass as a few `--parallel=N` processes in sequence — the
|
|
8
|
+
// measurement is its header. Everything below still holds inside a batch.
|
|
9
|
+
//
|
|
5
10
|
// ONE PROCESS, N WORKERS, `As of 2026-08-27`. This file used to pack the files into N bins itself
|
|
6
11
|
// (largest-first greedy over file SIZE) and `Promise.all` one `bun test` per bin. Bun 1.4 runs the
|
|
7
12
|
// pool itself — `--parallel=N`, which starts each file on the next free worker — so the packer is
|
|
@@ -39,7 +44,6 @@
|
|
|
39
44
|
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
40
45
|
import type { AffectedSelection } from './affected';
|
|
41
46
|
import type { Runner } from './exec';
|
|
42
|
-
import { execOutput } from './exec';
|
|
43
47
|
import { msg } from './messages';
|
|
44
48
|
import type { CommandResult, Finding, JsonValue, StepResult } from './output';
|
|
45
49
|
import { quoteArg } from './shell-quote';
|
|
@@ -48,6 +52,7 @@ import { testEnvOverrides } from './test-dotenv';
|
|
|
48
52
|
/** `@ultimat3/testing`'s `ISOLATED_ENV`, restated: `cli → testing` is a runtime edge, kept to fixtures. */
|
|
49
53
|
export const ISOLATED_TEST_ENV = 'ULTIMATE_TEST_ISOLATED';
|
|
50
54
|
|
|
55
|
+
import { runBatches, testBatches } from './test-batches';
|
|
51
56
|
import { testPasses } from './test-passes';
|
|
52
57
|
import type { TestFile } from './test-select';
|
|
53
58
|
import type { TestType } from './verify-tests';
|
|
@@ -219,10 +224,10 @@ export const failureOf = (code: number, files: number, plan: ReproduceOptions):
|
|
|
219
224
|
});
|
|
220
225
|
|
|
221
226
|
/**
|
|
222
|
-
* ONE `bun test` PER
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
* header for what that measured.
|
|
227
|
+
* ONE `bun test` PER BATCH, a pass being as many batches as `test-batches.ts` cuts it into, and
|
|
228
|
+
* one pass unless the selection mixes serial files with the rest — `test-passes.ts` decides that,
|
|
229
|
+
* and this spends it. Bun owns the pool inside a batch and hands each free worker the next file, so
|
|
230
|
+
* nothing here decides which file runs where; see this file's header for what that measured.
|
|
226
231
|
*
|
|
227
232
|
* Sequential, never `Promise.all`: the whole point of a serial pass is that nothing runs beside
|
|
228
233
|
* it. And every pass runs even after one fails — the caller asked for a suite, and a report that
|
|
@@ -241,6 +246,9 @@ export async function runShards(options: RunShardsOptions): Promise<CommandResul
|
|
|
241
246
|
// Every run this spawns is isolated (`--parallel` or `--isolate`) unless the caller opted out.
|
|
242
247
|
...((options.passthrough ?? []).includes('--no-isolate') ? {} : { [ISOLATED_TEST_ENV]: '1' }),
|
|
243
248
|
};
|
|
249
|
+
const passthrough = options.passthrough ?? [];
|
|
250
|
+
const watching = passthrough.includes('--watch');
|
|
251
|
+
const bailing = passthrough.some((arg) => arg === '--bail' || arg.startsWith('--bail='));
|
|
244
252
|
const passes = testPasses({
|
|
245
253
|
files: options.files,
|
|
246
254
|
workers: options.workers,
|
|
@@ -250,18 +258,28 @@ export async function runShards(options: RunShardsOptions): Promise<CommandResul
|
|
|
250
258
|
const started = performance.now();
|
|
251
259
|
const steps: StepResult[] = [];
|
|
252
260
|
const spent: JsonValue[] = [];
|
|
261
|
+
let batchCount = 0;
|
|
253
262
|
let ok = true;
|
|
254
263
|
let exitCode = 0;
|
|
255
264
|
for (const pass of passes) {
|
|
256
265
|
const files = pass.files.map((file) => file.path);
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
266
|
+
// A `--worker` rerun is one process over one shard and is never split: shard i of the rerun
|
|
267
|
+
// must be the same files as shard i of the run it reproduces. Nor is `-- --watch`, which never
|
|
268
|
+
// exits, so a second batch would never start. Every other pass is spent in `test-batches.ts`'
|
|
269
|
+
// batches, one `bun test` after another.
|
|
270
|
+
const batches = only === undefined && !watching ? testBatches(files, pass.workers) : [files];
|
|
271
|
+
const result = await runBatches({
|
|
272
|
+
runner: options.runner,
|
|
273
|
+
batches,
|
|
274
|
+
stopOnFailure: bailing,
|
|
275
|
+
argsFor: (batch) =>
|
|
276
|
+
testArgs({
|
|
277
|
+
files: batch,
|
|
278
|
+
workers: Math.min(pass.workers, batch.length),
|
|
279
|
+
...(only === undefined ? {} : { shard: only }),
|
|
280
|
+
...(options.passthrough === undefined ? {} : { passthrough: options.passthrough }),
|
|
281
|
+
}),
|
|
282
|
+
options: {
|
|
265
283
|
cwd: options.root,
|
|
266
284
|
...(Object.keys(envOverrides).length === 0 && only === undefined
|
|
267
285
|
? {}
|
|
@@ -272,24 +290,28 @@ export async function runShards(options: RunShardsOptions): Promise<CommandResul
|
|
|
272
290
|
},
|
|
273
291
|
}),
|
|
274
292
|
},
|
|
275
|
-
);
|
|
293
|
+
});
|
|
294
|
+
batchCount += batches.length;
|
|
276
295
|
const plan = planOf(options, pass);
|
|
277
296
|
const label =
|
|
278
297
|
only === undefined ? `${pass.workers} worker(s)` : `shard ${only} of ${pass.workers}`;
|
|
279
298
|
steps.push({
|
|
280
|
-
name: `${pass.type === undefined ? label : `${pass.type} · ${label}`} · ${files.length} files
|
|
299
|
+
name: `${pass.type === undefined ? label : `${pass.type} · ${label}`} · ${files.length} files${
|
|
300
|
+
batches.length > 1 ? msg('cli.test.batches', { batches: batches.length }) : ''
|
|
301
|
+
}`,
|
|
281
302
|
ok: result.ok,
|
|
282
303
|
durationMs: result.durationMs,
|
|
283
304
|
// `output.ts` documents this field as absent for a NON-test step, so omitting it here made
|
|
284
305
|
// `renderJson` describe the test step as one — recoverable only by parsing `name`.
|
|
285
306
|
workers: pass.workers,
|
|
286
307
|
findings: result.ok ? [] : [failureOf(result.code, files.length, plan)],
|
|
287
|
-
output:
|
|
308
|
+
output: result.output,
|
|
288
309
|
});
|
|
289
310
|
spent.push({
|
|
290
311
|
...(pass.type === undefined ? {} : { type: pass.type }),
|
|
291
312
|
files: files.length,
|
|
292
313
|
workers: pass.workers,
|
|
314
|
+
...(batches.length > 1 ? { batches: batches.length } : {}),
|
|
293
315
|
ok: result.ok,
|
|
294
316
|
exitCode: result.code,
|
|
295
317
|
reproduce: reproduceFor(plan),
|
|
@@ -321,6 +343,8 @@ export async function runShards(options: RunShardsOptions): Promise<CommandResul
|
|
|
321
343
|
// Only when the split made more than one, so a single-pass run's JSON is byte-identical to
|
|
322
344
|
// what it has always been — and a mixed one can never be read as if it were a single run.
|
|
323
345
|
...(spent.length > 1 ? { passes: spent } : {}),
|
|
346
|
+
// Only when some pass was split, for the same reason: an unbatched run's JSON is unchanged.
|
|
347
|
+
...(batchCount > spent.length ? { batches: batchCount } : {}),
|
|
324
348
|
ok,
|
|
325
349
|
exitCode,
|
|
326
350
|
reproduce: reproduceFor(plan),
|
package/src/test-workers.ts
CHANGED
|
@@ -35,11 +35,11 @@ export function availableCpus(): number {
|
|
|
35
35
|
*
|
|
36
36
|
* The bound is memory, not cores. A worker is a whole Bun process with the framework's module
|
|
37
37
|
* graph loaded and — in the typed suites — its own cloned Postgres or an in-process PGlite. Until
|
|
38
|
-
* 22.3 that was a FIXED ceiling of 8, which held a 12-core box to 8 workers with 30 GB free
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
38
|
+
* 22.3 that was a FIXED ceiling of 8, which held a 12-core box to 8 workers with 30 GB free. The
|
|
39
|
+
* ceiling is now what the machine can actually hold — `MEMORY_SHARE` of `os.freemem()`
|
|
40
|
+
* (MemAvailable on Linux, so reclaimable page cache counts as free) divided by `WORKER_BYTES` —
|
|
41
|
+
* which binds on a small CI runner, on a loaded laptop and on a box shared with other sessions,
|
|
42
|
+
* the places an unbounded count swaps or, with no swap, is OOM-killed.
|
|
43
43
|
*
|
|
44
44
|
* The floor of 2 keeps a 1-core box sharding rather than silently reverting to serial.
|
|
45
45
|
*/
|
|
@@ -49,22 +49,44 @@ export const WORKER_OVERSUBSCRIBE = 1.5;
|
|
|
49
49
|
export const WORKER_FLOOR = 2;
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
|
-
* What one test worker is budgeted at, for the memory bound above. MEASURED —
|
|
53
|
-
* `x test unit`
|
|
54
|
-
*
|
|
52
|
+
* What one test worker is budgeted at, for the memory bound above. MEASURED — whole-tree RSS of
|
|
53
|
+
* `x test unit` on the notificado.co corpus (768 files, PGlite per file), 12-core box, Bun 1.4.0,
|
|
54
|
+
* `As of 2026-09-27`, batched by `BATCH_FILES_PER_WORKER`:
|
|
55
55
|
*
|
|
56
|
-
* | workers | peak tree RSS |
|
|
57
|
-
*
|
|
58
|
-
* |
|
|
59
|
-
* |
|
|
60
|
-
* |
|
|
56
|
+
* | workers | batches | peak tree RSS | largest single worker |
|
|
57
|
+
* |---------|---------|---------------|-----------------------|
|
|
58
|
+
* | 12 | 3 | 10.8-11.4 GB | 1.38-1.41 GB |
|
|
59
|
+
* | 14 | 3 | 12.2 GB | 1.39 GB |
|
|
60
|
+
* | 18 | 2 | 15.9 GB | 1.47 GB |
|
|
61
61
|
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
62
|
+
* Unbatched, the same box at 22.6.1's default of 18 workers peaked at 17.1-18.2 GB, and the same
|
|
63
|
+
* run's wall clock was 132-167s against 152-163s for this default (14 workers, 3 batches) — within
|
|
64
|
+
* the noise of a box at load 30-40.
|
|
65
|
+
*
|
|
66
|
+
* The constant is the LARGEST single worker rounded up, not the average (~0.88 GB), so the bound
|
|
67
|
+
* holds even on the run where every worker peaks at the same moment. The 2026-09-25 figure it
|
|
68
|
+
* replaces (1 GiB, from a "~0.45 GB slope over a ~17 GB intercept") budgeted the slope and waved
|
|
69
|
+
* the intercept away as "the corpus"; that intercept was per-worker growth over an unbatched run,
|
|
70
|
+
* and an 18-worker default on 35 GB free took a 45 GB box with no swap down (2026-09-27).
|
|
71
|
+
*/
|
|
72
|
+
export const WORKER_BYTES = 1.5 * 1024 * 1024 * 1024;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The share of available memory a default-width run may plan to use. Never all of it, because free
|
|
76
|
+
* memory is read ONCE, before a single worker has started, and a dev box is never only running the
|
|
77
|
+
* tests: a type-checker, an editor's language server, another agent's gate all grow after that
|
|
78
|
+
* read. A run that plans on every free byte is one neighbour away from the OOM killer. 0.6 and not
|
|
79
|
+
* less because the plan is made at the LARGEST worker's peak: measured, the whole tree peaks at
|
|
80
|
+
* ~0.9 GB a worker, so the real peak lands near 35-40% of what was available at the start.
|
|
81
|
+
*/
|
|
82
|
+
export const MEMORY_SHARE = 0.6;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Files each worker is handed before its `bun test` process is thrown away and a fresh one takes
|
|
86
|
+
* the next batch (`test-batches.ts`, which carries the measurement). What makes the peak a function
|
|
87
|
+
* of the width rather than of the corpus.
|
|
66
88
|
*/
|
|
67
|
-
export const
|
|
89
|
+
export const BATCH_FILES_PER_WORKER = 24;
|
|
68
90
|
|
|
69
91
|
/**
|
|
70
92
|
* The most `--workers` accepts, on either command. Not a default and not a memory rule — a sanity
|
|
@@ -77,7 +99,8 @@ export const WORKER_CEILING = 64;
|
|
|
77
99
|
export const availableMemory = (): number => freemem();
|
|
78
100
|
|
|
79
101
|
/**
|
|
80
|
-
* `ceil(cpus x 1.5)`, held to
|
|
102
|
+
* `ceil(cpus x 1.5)`, held to `MEMORY_SHARE` of free memory at `WORKER_BYTES` a worker, and never
|
|
103
|
+
* below the floor. The file-count
|
|
81
104
|
* clamp is `test-passes.ts`'s (every pass is clamped to its own file list), because only the
|
|
82
105
|
* caller knows the selection.
|
|
83
106
|
*/
|
|
@@ -86,7 +109,7 @@ export const defaultWorkers = (
|
|
|
86
109
|
freeBytes: number = availableMemory(),
|
|
87
110
|
): number => {
|
|
88
111
|
const byCpu = Math.ceil(available * WORKER_OVERSUBSCRIBE);
|
|
89
|
-
const byMemory = Math.floor(freeBytes / WORKER_BYTES);
|
|
112
|
+
const byMemory = Math.floor((freeBytes * MEMORY_SHARE) / WORKER_BYTES);
|
|
90
113
|
return Math.max(WORKER_FLOOR, Math.min(byCpu, byMemory, WORKER_CEILING));
|
|
91
114
|
};
|
|
92
115
|
|
package/src/verify-test-run.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// is never a race, and a race is never a selection bug.
|
|
4
4
|
|
|
5
5
|
import type { Runner } from './exec';
|
|
6
|
-
import { execOutput } from './exec';
|
|
7
6
|
import type { Finding } from './output';
|
|
7
|
+
import { runBatches, testBatches } from './test-batches';
|
|
8
8
|
import { countsOf } from './test-counts';
|
|
9
9
|
import { testEnvOverrides } from './test-dotenv';
|
|
10
10
|
import type { TestFile } from './test-select';
|
|
@@ -26,7 +26,8 @@ export interface ParallelRunOptions {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
|
-
*
|
|
29
|
+
* `bun test --parallel=N` per batch (`test-batches.ts`), not N processes this file spawns and
|
|
30
|
+
* packs itself.
|
|
30
31
|
*
|
|
31
32
|
* Two things make an arbitrary partition safe here and neither is optional. `--parallel` implies
|
|
32
33
|
* `--isolate`, so every FILE gets a fresh module registry — half a dozen registries in this
|
|
@@ -42,9 +43,17 @@ export async function runParallel(options: ParallelRunOptions): Promise<StepOutc
|
|
|
42
43
|
const files = options.files.map((file) => file.path);
|
|
43
44
|
const workers = Math.max(1, Math.min(Math.trunc(options.workers), files.length || 1));
|
|
44
45
|
const envOverrides = testEnvOverrides(options.root, options.env ?? Bun.env);
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
// Batched exactly as `x test` batches the same selection at the same width, so the gate's
|
|
47
|
+
// `fix:` line — `x test <type> --workers N` — reruns the very processes that failed.
|
|
48
|
+
const batches = testBatches(files, workers);
|
|
49
|
+
const result = await runBatches({
|
|
50
|
+
runner: options.runner,
|
|
51
|
+
batches,
|
|
52
|
+
argsFor: (batch) => testArgs({ files: batch, workers: Math.min(workers, batch.length) }),
|
|
53
|
+
options: {
|
|
54
|
+
cwd: options.root,
|
|
55
|
+
...(Object.keys(envOverrides).length === 0 ? {} : { env: envOverrides }),
|
|
56
|
+
},
|
|
48
57
|
});
|
|
49
58
|
// `failureOf` is `x test`'s own, imported rather than restated: the two paths report the SAME
|
|
50
59
|
// failed `bun test`, so a second literal here is two `cause:` strings and two `fix:` lines free
|
|
@@ -56,9 +65,11 @@ export async function runParallel(options: ParallelRunOptions): Promise<StepOutc
|
|
|
56
65
|
ok: findings.length === 0,
|
|
57
66
|
findings,
|
|
58
67
|
workers,
|
|
59
|
-
|
|
68
|
+
// Per batch: each process printed its own summary, and one parse of the joined text would
|
|
69
|
+
// read only the last one.
|
|
70
|
+
tests: countsOf(result.results),
|
|
60
71
|
// Only on failure: a green run's summary is already the step table's, and the reader of a red
|
|
61
72
|
// gate needs the assertion diff.
|
|
62
|
-
...(result.ok ? {} : { output:
|
|
73
|
+
...(result.ok ? {} : { output: result.output }),
|
|
63
74
|
};
|
|
64
75
|
}
|