@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.
- package/CLAUDE.md +90 -7
- package/package.json +30 -30
- package/src/app-root.ts +13 -1
- package/src/cmd-dev.ts +16 -2
- package/src/cmd-doctor.ts +1 -1
- package/src/cmd-test.ts +20 -9
- package/src/db-accept-created.ts +207 -0
- package/src/db-generate.ts +18 -1
- package/src/db-subscribes.ts +81 -0
- package/src/db-ungeneratable.ts +14 -2
- package/src/dev-assets.ts +19 -54
- package/src/dev-notify-retention.ts +69 -0
- package/src/dev-purge.ts +47 -2
- package/src/dev-render.ts +20 -4
- package/src/dev-replicator.ts +19 -1
- package/src/dev-roles.ts +19 -1
- package/src/dev-runtime.ts +12 -3
- package/src/dev-services.ts +8 -0
- package/src/dev-traces.ts +5 -1
- package/src/e2e-page.ts +16 -2
- package/src/error-codes.ts +9 -0
- package/src/icon-assets.ts +74 -0
- package/src/index.ts +19 -4
- package/src/island-harness-script.ts +8 -1
- package/src/island-shot.ts +45 -6
- package/src/island-verdict.ts +16 -4
- package/src/mcp-errors.ts +6 -0
- package/src/messages.ts +1 -1
- package/src/metrics-endpoint.ts +6 -1
- package/src/prerender.ts +16 -0
- package/src/pwa-artifacts.ts +188 -0
- package/src/serve.ts +13 -1
- package/src/sync-authenticator.ts +8 -2
- package/src/templates/naming.ts +11 -0
- package/src/templates/scaffold-repo.ts +14 -1
- package/src/test-shards.ts +150 -116
- package/src/ts-scan.ts +6 -1
- package/src/verify-test-run.ts +31 -46
package/src/templates/naming.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
`;
|
package/src/test-shards.ts
CHANGED
|
@@ -1,7 +1,37 @@
|
|
|
1
|
-
// Spending the selected test files: the
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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
|
|
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:
|
|
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
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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
|
|
86
|
-
* exist
|
|
87
|
-
*
|
|
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(
|
|
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
|
|
120
|
-
*
|
|
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
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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,
|
|
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
|
|
175
|
-
files:
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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:
|
|
226
|
+
ok: result.ok,
|
|
192
227
|
command: 'test',
|
|
193
|
-
summary:
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
};
|
package/src/verify-test-run.ts
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
|
-
// Running one test type
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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 {
|
|
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.
|
|
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
|
|
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
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
67
|
-
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
...(
|
|
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
|
}
|