@ultimat3/cli 16.0.0 → 18.0.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.
@@ -79,6 +79,17 @@ export const plural = (input: string): string => {
79
79
 
80
80
  export const titleKey = (input: string): string => `app.${kebab(input)}.title`;
81
81
 
82
+ /**
83
+ * A human title from a slug: `ledger-demo` -> `Ledger Demo`. Its one caller is the scaffolded
84
+ * `pwa.name`, which is what a browser shows a person in the install prompt — `app.name` is a slug
85
+ * by `NAME_RE` and `pascal` would offer to install `LedgerDemo`. Not a `NameSet` member: every
86
+ * other field there names a code identifier, and this one is prose.
87
+ */
88
+ export const titleCase = (input: string): string =>
89
+ words(input)
90
+ .map((word) => `${word[0]?.toUpperCase() ?? ''}${word.slice(1)}`)
91
+ .join(' ');
92
+
82
93
  export interface NameSet {
83
94
  readonly raw: string;
84
95
  readonly kebab: string;
@@ -8,6 +8,7 @@ import { ENV_EXAMPLE_PATH } from '@ultimat3/core';
8
8
  import { VERIFY_FLOOR_FILE } from '../verify-floor';
9
9
  import type { VerifyStepName } from '../verify-step';
10
10
  import type { GeneratedFile, NameSet } from './naming';
11
+ import { titleCase } from './naming';
11
12
  import { dbPackageFiles } from './scaffold-db-package';
12
13
  import { docsFiles } from './scaffold-docs';
13
14
  import { domainPackageFiles } from './scaffold-domain-package';
@@ -180,7 +181,19 @@ export const config = defineConfig({
180
181
  jobs: { queues: ['${app.kebab}-default'], concurrency: 4 },
181
182
  // In-process transport by default; set urlEnv and transport: 'nats' to scale past one node.
182
183
  realtime: { enabled: true, transport: 'memory' },
183
- pwa: { enabled: true, offline: 'runtime' },
184
+ // \`name\` and \`colors\` are what an install prompt shows and what a browser paints the splash
185
+ // with before any stylesheet has loaded — the four values the framework cannot derive, so
186
+ // \`defineConfig\` refuses \`pwa.enabled: true\` without them. Raw hex is legal here and nowhere
187
+ // else in an app.
188
+ pwa: {
189
+ enabled: true,
190
+ offline: 'runtime',
191
+ name: '${titleCase(app.raw)}',
192
+ colors: {
193
+ light: { themeColor: '#1b1f3b', backgroundColor: '#ffffff' },
194
+ dark: { themeColor: '#1b1f3b', backgroundColor: '#0b0d1a' },
195
+ },
196
+ },
184
197
  ai: { mcp: { expose: true, path: '/mcp' } },
185
198
  });
186
199
  `;
@@ -1,7 +1,37 @@
1
- // Spending the selected test files: the LPT plan that balances them across worker processes, the
2
- // argv each child gets, and the command that reproduces one shard exactly. Split out of
3
- // cmd-test.ts because a printed reproduction is only true if it carries every input to the split —
4
- // that rule is this file's, and argv parsing is that one's.
1
+ // Spending the selected test files: the argv one `bun test` run gets, and the command that
2
+ // reproduces it exactly. Split out of cmd-test.ts because a printed reproduction is only true if
3
+ // it carries every input to the run — that rule is this file's, and argv parsing is that one's.
4
+ //
5
+ // ONE PROCESS, N WORKERS, `As of 2026-08-27`. This file used to pack the files into N bins itself
6
+ // (largest-first greedy over file SIZE) and `Promise.all` one `bun test` per bin. Bun 1.4 runs the
7
+ // pool itself — `--parallel=N`, which starts each file on the next free worker — so the packer is
8
+ // deleted rather than improved.
9
+ //
10
+ // THE PACKER WAS NOT COSTING TIME, AND THAT IS THE FINDING. Four interleaved runs of each form,
11
+ // one machine, the same 1296-file unit corpus, 8 workers:
12
+ //
13
+ // 8x `bun test --isolate`, hand-packed 58.2s 60.0s 65.0s 66.5s
14
+ // 1x `bun test --parallel=8` 54.5s 57.8s 61.7s 64.5s
15
+ //
16
+ // Within noise of each other, because both are already work-bound. `--update-timings` measures the
17
+ // corpus at 436.7s of file time, so eight workers cannot beat 54.6s however the files are dealt,
18
+ // and the slowest single file is 20.5s — far under that floor, so no one file sets it either. A
19
+ // greedy pack of 1296 small items lands near-optimal by accident, which is why bytes being a poor
20
+ // proxy for time never showed up as a wall. The change buys the DELETION, not the seconds: this
21
+ // file's packer and its `Shard` type gone, one process instead of eight, and Bun's own summary
22
+ // instead of eight merged ones (issue #342).
23
+ //
24
+ // `--timings` IS REFUSED FOR THE SAME REASON. Bun will start the slowest files first from a
25
+ // recorded timings file, but there is at most the ~5s between the measured 59.8s median and the
26
+ // 54.6s floor in it — against a committed JSON that goes stale on every test edit and that nothing
27
+ // in the gate would notice had gone stale.
28
+ //
29
+ // TWO THINGS THE OLD SPLIT OWNED AND BUN NOW OWNS. `--parallel` implies `--isolate`, so the
30
+ // per-FILE module registry that made an arbitrary partition safe at all is unchanged. And the
31
+ // per-WORKER database survives untouched: `@ultimat3/testing`'s `workerId` already read
32
+ // `BUN_TEST_WORKER_ID` as its second key, which is exactly what Bun sets, 1..N, one per real
33
+ // process (probed on 1.4.0). `ULTIMATE_TEST_WORKER` stays the first key and is what `--worker`
34
+ // still sets, so a single-shard rerun keeps naming its own database.
5
35
 
6
36
  import { ERROR_DOCS_URL } from '@ultimat3/core';
7
37
  import type { AffectedSelection } from './affected';
@@ -11,82 +41,75 @@ import { msg } from './messages';
11
41
  import type { CommandResult, Finding, JsonValue, StepResult } from './output';
12
42
  import { quoteArg } from './shell-quote';
13
43
  import type { TestFile } from './test-select';
14
- import { bySizeThenPath } from './test-select';
15
44
  import type { TestType } from './verify-tests';
16
45
 
17
- export interface Shard {
18
- readonly index: number;
19
- readonly files: readonly string[];
20
- readonly bytes: number;
21
- }
22
-
23
46
  /**
24
- * Largest-first greedy bin packing (LPT). Deterministic — the total order is (size desc, path asc),
25
- * so the filesystem's scan order never reaches the assignment and a CI failure on worker 3 is the
26
- * same worker 3 locally. Balanced — every file lands in the currently emptiest bin, which bounds a
27
- * bin at average + largest file; round-robin or hashing can pile every slow file onto one worker.
28
- * The inner scan is O(files × workers), and workers is a core count, so a heap would only add
29
- * allocation.
47
+ * The argv for one run. An explicit file list, never a re-glob: discovery already decided which
48
+ * files belong to this type, and a child that globs again can pick up a file the selection removed.
49
+ *
50
+ * `--parallel=N` for the whole selection, `--shard=i+1/N` for one slice of it. The shard form is
51
+ * `--worker`'s, and it carries `--isolate` in its own right — only `--parallel` implies it, and a
52
+ * partition without a fresh module registry per file is the failure mode this whole design exists
53
+ * to remove: measured on this repo, an 8-way split turned 0 failures into 36, every one
54
+ * `X_PERMISSION_UNKNOWN` in `@ultimat3/query` because the `packages/cli` file that had been
55
+ * declaring `feed:read` for it landed elsewhere. Half a dozen registries here are process-global by
56
+ * design — the permission set, the roles, the entity/action/query tables, the error-code titles,
57
+ * the fixture bag — and a serial `bun test` only passes because glob order happens to put every
58
+ * declaring file before every file that reads what it left behind.
59
+ *
60
+ * Bun's shard partition is round-robin over the list it is given, so the sorted list this hands it
61
+ * makes `--shard=2/8` the same 1/8 on CI and on a laptop (probed on 1.4.0).
30
62
  */
31
- export function planShards(files: readonly TestFile[], workers: number): readonly Shard[] {
32
- const count = Math.max(1, Math.min(Math.trunc(workers), files.length));
33
- const loads = new Array<number>(count).fill(0);
34
- const buckets: string[][] = Array.from({ length: count }, () => []);
35
- for (const file of [...files].sort(bySizeThenPath)) {
36
- let target = 0;
37
- for (let i = 1; i < count; i += 1) if ((loads[i] ?? 0) < (loads[target] ?? 0)) target = i;
38
- buckets[target]?.push(file.path);
39
- loads[target] = (loads[target] ?? 0) + file.bytes;
40
- }
41
- return buckets.map((paths, index) => ({
42
- index,
43
- files: [...paths].sort(),
44
- bytes: loads[index] ?? 0,
45
- }));
63
+ export function testArgs(input: {
64
+ readonly files: readonly string[];
65
+ readonly workers: number;
66
+ /** 0-based, matching `--worker`. Absent runs the whole selection across `workers` processes. */
67
+ readonly shard?: number;
68
+ }): readonly string[] {
69
+ const files = [...input.files].sort();
70
+ return input.shard === undefined
71
+ ? ['bun', 'test', `--parallel=${String(input.workers)}`, ...files]
72
+ : [
73
+ 'bun',
74
+ 'test',
75
+ '--isolate',
76
+ `--shard=${String(input.shard + 1)}/${String(input.workers)}`,
77
+ ...files,
78
+ ];
46
79
  }
47
80
 
48
81
  /**
49
- * Explicit file list, so the child never re-globs and can never pick up another shard's files.
50
- *
51
- * `--isolate` is what makes an arbitrary partition safe, and it is not optional. Half a dozen
52
- * registries in this framework are process-global by design — the permission set, the roles, the
53
- * entity/action/query tables, the error-code titles, the fixture bag — and a serial `bun test`
54
- * only passes because glob order happens to put every declaring file before every file that reads
55
- * what it left behind. Re-partition the same files and that accident is gone: measured on this
56
- * repo, an 8-way split turned 0 failures into 36, all of them `X_PERMISSION_UNKNOWN` in
57
- * `@ultimat3/query` because the `packages/cli` file that had been declaring `feed:read` for it
58
- * landed in another shard. A fresh module registry per FILE removes the channel entirely, so the
59
- * split can be any split. The database is isolated per WORKER, not per file — one cloned template
60
- * per process, which is `ULTIMATE_TEST_WORKER` below.
82
+ * The files an argv selects, flags stripped. `bun test` takes its file list positionally, so this
83
+ * is the inverse of `testArgs` and the one thing a test asserting "what did the child get?" needs
84
+ * — the flag COUNT is not fixed (`--parallel=N` is one token, a shard run carries two more), and a
85
+ * test slicing a hardcoded prefix length reads a flag as a filename the day that changes.
61
86
  */
62
- export const SHARD_COMMAND_PREFIX = ['bun', 'test', '--isolate'] as const;
63
-
64
- export const shardArgs = (shard: Shard): readonly string[] => [
65
- ...SHARD_COMMAND_PREFIX,
66
- ...shard.files,
67
- ];
87
+ export const filesIn = (command: readonly string[]): readonly string[] =>
88
+ command.slice(2).filter((arg) => !arg.startsWith('--'));
68
89
 
69
90
  export interface ReproduceOptions {
70
- /** The *effective* worker count: `planShards` clamps to the file count, and the split follows. */
91
+ /** The *effective* worker count: the run clamps it to the file count, and the rerun follows. */
71
92
  readonly workers: number;
72
93
  readonly filter?: string;
73
94
  readonly type?: TestType;
74
95
  /** Files `--sample` kept, so the rerun samples the same corpus instead of the whole type. */
75
96
  readonly sample?: number;
76
97
  /**
77
- * The `--affected` narrowing, when there was one. The fourth input to the split and the one most
78
- * easily forgotten: `--affected` decides which files exist to shard at all, so a rerun without it
79
- * re-splits the WHOLE corpus and its shard 2 is a different shard 2.
98
+ * The `--affected` narrowing, when there was one. The input most easily forgotten: `--affected`
99
+ * decides which files exist to run at all, so a rerun without it selects the WHOLE corpus and
100
+ * its shard 2 is a different shard 2.
80
101
  */
81
102
  readonly affected?: AffectedSelection;
103
+ /** 0-based, when reproducing ONE shard. Absent reproduces the whole selection. */
104
+ readonly shard?: number;
82
105
  }
83
106
 
84
107
  /**
85
- * Every input to the split, printed back. The type, `--filter` and `--affected` decide which files
86
- * exist to shard, `--sample` decides how many of them survive, `--workers` decides the bins — drop
87
- * any one and the command still runs, over a different file set, which reproduces nothing.
108
+ * Every input to the run, printed back. The type, `--filter` and `--affected` decide which files
109
+ * exist, `--sample` decides how many of them survive, `--workers` decides the width — drop any one
110
+ * and the command still runs, over a different file set, which reproduces nothing.
88
111
  */
89
- export function reproduceFor(shard: Shard, options: ReproduceOptions): string {
112
+ export function reproduceFor(options: ReproduceOptions): string {
90
113
  return [
91
114
  'x test',
92
115
  ...(options.type === undefined ? [] : [quoteArg(options.type)]),
@@ -100,8 +123,7 @@ export function reproduceFor(shard: Shard, options: ReproduceOptions): string {
100
123
  ...(options.affected?.dirty === true ? ['--dirty'] : []),
101
124
  '--workers',
102
125
  String(options.workers),
103
- '--worker',
104
- String(shard.index),
126
+ ...(options.shard === undefined ? [] : ['--worker', String(options.shard)]),
105
127
  ].join(' ');
106
128
  }
107
129
 
@@ -110,104 +132,116 @@ export interface RunShardsOptions {
110
132
  readonly runner: Runner;
111
133
  readonly files: readonly TestFile[];
112
134
  readonly workers: number;
113
- /** Run exactly one shard of the same split, not a one-worker run of everything. */
135
+ /** Run exactly one shard of the same N-way split, not a one-worker run of everything. */
114
136
  readonly only?: number;
115
137
  readonly filter?: string;
116
138
  readonly type?: TestType;
117
139
  /**
118
140
  * Set when `--sample` narrowed `files`: `kept` is what survived, `total` what discovery found.
119
- * `kept` is carried rather than counted from the shards that ran, because `--worker N` runs one
120
- * shard of the sample and would otherwise report that shard's size as the corpus.
141
+ * `kept` is carried rather than counted from what ran, because `--worker N` runs one shard of the
142
+ * sample and would otherwise report that shard's size as the corpus.
121
143
  */
122
144
  readonly sample?: { readonly kept: number; readonly total: number };
123
145
  /** Passed straight to `reproduceFor`: see `ReproduceOptions.affected`. */
124
146
  readonly affected?: AffectedSelection;
125
147
  }
126
148
 
127
- /** The reproduction's inputs, resolved once: `workers` is the split's real width, not the ask. */
149
+ /** The reproduction's inputs, resolved once: `workers` is the run's real width, not the ask. */
128
150
  const planOf = (options: RunShardsOptions, workers: number): ReproduceOptions => ({
129
151
  workers,
130
152
  ...(options.filter === undefined ? {} : { filter: options.filter }),
131
153
  ...(options.type === undefined ? {} : { type: options.type }),
132
154
  ...(options.sample === undefined ? {} : { sample: options.sample.kept }),
133
155
  ...(options.affected === undefined ? {} : { affected: options.affected }),
156
+ ...(options.only === undefined ? {} : { shard: options.only }),
134
157
  });
135
158
 
136
- const failureOf = (shard: Shard, code: number, plan: ReproduceOptions): Finding => ({
137
- code: 'X_TEST_SHARD_FAILED',
138
- cause: `shard ${shard.index} of ${plan.workers} exited ${code} (${shard.files.length} file(s))`,
139
- fix: reproduceFor(shard, plan),
159
+ /**
160
+ * `X_TEST_SHARD_FAILED` for a `--worker` run and `X_TEST_FAILED` for a whole one, because the two
161
+ * name different reruns: a shard is reproduced by naming it, and a full run by rerunning it. Both
162
+ * codes already exist and both are already documented — a third would be a new name for a failed
163
+ * `bun test`.
164
+ */
165
+ export const failureOf = (code: number, files: number, plan: ReproduceOptions): Finding => ({
166
+ code: plan.shard === undefined ? 'X_TEST_FAILED' : 'X_TEST_SHARD_FAILED',
167
+ cause:
168
+ plan.shard === undefined
169
+ ? `${plan.type ?? 'test'} run exited ${code} across ${plan.workers} worker(s) (${files} file(s))`
170
+ : `shard ${plan.shard} of ${plan.workers} exited ${code} (${files} file(s))`,
171
+ fix: reproduceFor(plan),
140
172
  docs: ERROR_DOCS_URL,
141
173
  });
142
174
 
175
+ /**
176
+ * ONE `bun test`, not one per worker. Bun owns the pool and hands each free worker the next file,
177
+ * so nothing here decides which file runs where — see this file's header for what that measured.
178
+ *
179
+ * `ULTIMATE_TEST_WORKER` is still set for a `--worker` rerun and only then: that run is one
180
+ * process, so naming its database is this file's to do. A `--parallel` run has N of them and Bun
181
+ * numbers each with `BUN_TEST_WORKER_ID`, which `@ultimat3/testing`'s `workerId` already reads.
182
+ */
143
183
  export async function runShards(options: RunShardsOptions): Promise<CommandResult> {
144
- const shards = planShards(options.files, options.workers);
184
+ const files = options.files.map((file) => file.path);
185
+ const workers = Math.max(1, Math.min(Math.trunc(options.workers), files.length || 1));
145
186
  const only = options.only;
146
- const chosen = only === undefined ? shards : shards.filter((shard) => shard.index === only);
147
187
  const started = performance.now();
148
- // All shards at once: wall-clock is the whole point, and each one owns a separate database.
149
- const runs = await Promise.all(
150
- chosen.map(async (shard) => ({
151
- shard,
152
- result: await options.runner(shardArgs(shard), {
153
- cwd: options.root,
154
- env: { ULTIMATE_TEST_WORKER: String(shard.index) },
155
- }),
156
- })),
188
+ const result = await options.runner(
189
+ testArgs({ files, workers, ...(only === undefined ? {} : { shard: only }) }),
190
+ {
191
+ cwd: options.root,
192
+ ...(only === undefined ? {} : { env: { ULTIMATE_TEST_WORKER: String(only) } }),
193
+ },
157
194
  );
158
195
  const durationMs = Math.round(performance.now() - started);
159
- const plan = planOf(options, shards.length);
160
- const steps: readonly StepResult[] = runs.map(({ shard, result }) => ({
161
- name: `shard ${shard.index} · ${shard.files.length} files`,
162
- ok: result.ok,
163
- durationMs: result.durationMs,
164
- findings: result.ok ? [] : [failureOf(shard, result.code, plan)],
165
- output: execOutput(result),
166
- }));
167
- const failed = runs.filter((run) => !run.result.ok).map((run) => run.shard.index);
168
- const fileCount = chosen.reduce((total, shard) => total + shard.files.length, 0);
196
+ const plan = planOf({ ...options, workers }, workers);
169
197
  const type = options.type;
170
198
  const typeParam = type === undefined ? {} : { type };
171
199
  const sample = options.sample;
200
+ const label = only === undefined ? `${workers} worker(s)` : `shard ${only} of ${workers}`;
201
+ const steps: readonly StepResult[] = [
202
+ {
203
+ name: `${label} · ${files.length} files`,
204
+ ok: result.ok,
205
+ durationMs: result.durationMs,
206
+ // `output.ts` documents this field as absent for a NON-test step, so omitting it here made
207
+ // `renderJson` describe the test step as one — recoverable only by parsing `name`.
208
+ workers,
209
+ findings: result.ok ? [] : [failureOf(result.code, files.length, plan)],
210
+ output: execOutput(result),
211
+ },
212
+ ];
172
213
  const data: JsonValue = {
173
214
  ...typeParam,
174
- workers: shards.length,
175
- files: fileCount,
215
+ workers,
216
+ files: files.length,
176
217
  durationMs,
177
218
  ...(options.filter === undefined ? {} : { filter: options.filter }),
178
219
  ...(sample === undefined ? {} : { sample: { kept: sample.kept, total: sample.total } }),
179
- shards: runs.map(({ shard, result }) => ({
180
- index: shard.index,
181
- files: shard.files.length,
182
- bytes: shard.bytes,
183
- ok: result.ok,
184
- exitCode: result.code,
185
- durationMs: result.durationMs,
186
- reproduce: reproduceFor(shard, plan),
187
- })),
188
- failed,
220
+ ...(only === undefined ? {} : { shard: only }),
221
+ ok: result.ok,
222
+ exitCode: result.code,
223
+ reproduce: reproduceFor(plan),
189
224
  };
190
225
  return {
191
- ok: failed.length === 0,
226
+ ok: result.ok,
192
227
  command: 'test',
193
- summary:
194
- failed.length === 0
195
- ? msg(type === undefined ? 'cli.test.pass' : 'cli.test.type.pass', {
196
- ...typeParam,
197
- files: fileCount,
198
- workers: chosen.length,
199
- ms: durationMs,
200
- })
201
- : msg(type === undefined ? 'cli.test.fail' : 'cli.test.type.fail', {
202
- ...typeParam,
203
- failed: failed.length,
204
- workers: chosen.length,
205
- }),
228
+ summary: result.ok
229
+ ? msg(type === undefined ? 'cli.test.pass' : 'cli.test.type.pass', {
230
+ ...typeParam,
231
+ files: files.length,
232
+ workers,
233
+ ms: durationMs,
234
+ })
235
+ : msg(type === undefined ? 'cli.test.fail' : 'cli.test.type.fail', {
236
+ ...typeParam,
237
+ failed: 1,
238
+ workers,
239
+ }),
206
240
  steps,
207
241
  ...(sample === undefined
208
242
  ? {}
209
243
  : { lines: [msg('cli.test.sampled', { ...sample, type: type ?? 'all' })] }),
210
244
  data,
211
- exitCode: failed.length === 0 ? 0 : 1,
245
+ exitCode: result.ok ? 0 : 1,
212
246
  };
213
247
  }
package/src/ts-scan.ts CHANGED
@@ -106,7 +106,12 @@ function endOfRegex(text: string, from: number): number {
106
106
  * reading one as an opening quote desyncs every literal after it.
107
107
  */
108
108
  function blankRegions(text: string, strings: boolean): string {
109
- const out = [...text];
109
+ // `split('')` and NOT `[...text]`: the spread yields one element per CODE POINT while every
110
+ // index below runs over UTF-16 units (`text.length`, `text[i]`). One astral character — an emoji
111
+ // in a fixture, `piñata 🎉` — and `out` is shorter than `text`, so every write past it lands a
112
+ // slot early and the returned mask no longer aligns with the input. Measured: 22 files in this
113
+ // tree desynced, shipped source included, and eight rules read this mask.
114
+ const out = text.split('');
110
115
  const blank = (from: number, to: number): void => {
111
116
  for (let n = from; n < to; n += 1) if (out[n] !== '\n') out[n] = ' ';
112
117
  };
@@ -1,15 +1,13 @@
1
- // Running one test type across N worker processes and reporting it as one gate step. Split from
2
- // verify-tests.ts because that file owns which files belong to a type and this one owns what
3
- // happens to them once selected — a wrong file list is never a race, and a race is never a
4
- // selection bug.
1
+ // Running one test type as one gate step. Split from verify-tests.ts because that file owns which
2
+ // files belong to a type and this one owns what happens to them once selected — a wrong file list
3
+ // is never a race, and a race is never a selection bug.
5
4
 
6
- import { ERROR_DOCS_URL } from '@ultimat3/core';
7
5
  import type { Runner } from './exec';
8
6
  import { execOutput } from './exec';
9
7
  import type { Finding } from './output';
10
8
  import { countsOf } from './test-counts';
11
9
  import type { TestFile } from './test-select';
12
- import { planShards, reproduceFor, shardArgs } from './test-shards';
10
+ import { failureOf, testArgs } from './test-shards';
13
11
  import type { StepOutcome } from './verify-step';
14
12
  // Type-only, so nothing here evaluates verify-tests.ts and the two files cannot form a cycle.
15
13
  import type { TestType } from './verify-tests';
@@ -18,55 +16,42 @@ export interface ParallelRunOptions {
18
16
  readonly root: string;
19
17
  readonly runner: Runner;
20
18
  readonly files: readonly TestFile[];
21
- /** The ask. `planShards` clamps it to the file count, and the report carries what it became. */
19
+ /** The ask. Clamped to the file count, and the report carries what it became. */
22
20
  readonly workers: number;
23
- /** Carried into the `fix:` so a failed shard reproduces as `x test <type> --workers N …`. */
21
+ /** Carried into the `fix:` so a failure reproduces as `x test <type> --workers N`. */
24
22
  readonly type: TestType;
25
23
  }
26
24
 
27
25
  /**
28
- * The whole point is wall-clock, so every shard starts at once. Two things make that safe and
29
- * neither is optional: `shardArgs` gives each FILE its own module registry (`--isolate`), and
30
- * `ULTIMATE_TEST_WORKER` gives each PROCESS its own database — `@ultimat3/testing`'s
31
- * `acquireWorkerDatabase` reads exactly that variable first and clones the migrated template into
32
- * `…_w<index>`. Rails' numbered test databases, with Postgres doing the copy.
26
+ * ONE `bun test --parallel=N`, not N processes this file spawns and packs itself.
27
+ *
28
+ * Two things make an arbitrary partition safe here and neither is optional. `--parallel` implies
29
+ * `--isolate`, so every FILE gets a fresh module registry — half a dozen registries in this
30
+ * framework are process-global by design, and a serial run only passes because glob order happens
31
+ * to put every declaring file before every file that reads what it left behind (measured: a bare
32
+ * `bun test packages/` is 282 failures, the same corpus under `--isolate` is 0). And the database
33
+ * is per WORKER: `@ultimat3/testing`'s `workerId` reads `BUN_TEST_WORKER_ID`, which Bun sets 1..N,
34
+ * one per real process — so the numbered test databases keep working with nothing threaded here.
35
+ *
36
+ * `test-shards.ts`'s header carries what replacing the hand-written packer measured.
33
37
  */
34
38
  export async function runParallel(options: ParallelRunOptions): Promise<StepOutcome> {
35
- const shards = planShards(options.files, options.workers);
36
- const runs = await Promise.all(
37
- shards.map(async (shard) => ({
38
- shard,
39
- result: await options.runner(shardArgs(shard), {
40
- cwd: options.root,
41
- env: { ULTIMATE_TEST_WORKER: String(shard.index) },
42
- }),
43
- })),
44
- );
45
- const findings: Finding[] = [];
46
- for (const { shard, result } of runs) {
47
- if (result.ok) continue;
48
- findings.push({
49
- code: 'X_TEST_SHARD_FAILED',
50
- cause: `${options.type} shard ${shard.index} of ${shards.length} exited ${result.code} (${shard.files.length} file(s))`,
51
- // The reproduction has to name every input to the split, or it reruns a different file set:
52
- // `reproduceFor` is the one place that rule lives, shared with `x test`.
53
- fix: reproduceFor(shard, { workers: shards.length, type: options.type }),
54
- docs: ERROR_DOCS_URL,
55
- });
56
- }
57
- // Only the failing shards' output: a green 8-way split would otherwise print eight summaries,
58
- // and the reader of a red gate needs the assertion diff, not the seven runs that passed.
59
- const output = runs
60
- .filter((run) => !run.result.ok)
61
- .map((run) => `— shard ${run.shard.index}\n${execOutput(run.result)}`)
62
- .join('\n');
39
+ const files = options.files.map((file) => file.path);
40
+ const workers = Math.max(1, Math.min(Math.trunc(options.workers), files.length || 1));
41
+ const result = await options.runner(testArgs({ files, workers }), { cwd: options.root });
42
+ // `failureOf` is `x test`'s own, imported rather than restated: the two paths report the SAME
43
+ // failed `bun test`, so a second literal here is two `cause:` strings and two `fix:` lines free
44
+ // to drift — and the one that drifts is the gate's, which is the one an agent reads first.
45
+ const findings: readonly Finding[] = result.ok
46
+ ? []
47
+ : [failureOf(result.code, files.length, { workers, type: options.type })];
63
48
  return {
64
49
  ok: findings.length === 0,
65
50
  findings,
66
- workers: shards.length,
67
- // Every shard's summary, including the green ones whose output is dropped above: the counts
68
- // are how the ratchet tells a suite that passed from a suite that skipped itself to nothing.
69
- tests: countsOf(runs.map((run) => run.result)),
70
- ...(output === '' ? {} : { output }),
51
+ workers,
52
+ tests: countsOf([result]),
53
+ // Only on failure: a green run's summary is already the step table's, and the reader of a red
54
+ // gate needs the assertion diff.
55
+ ...(result.ok ? {} : { output: execOutput(result) }),
71
56
  };
72
57
  }