@celilo/e2e 0.19.2 → 0.20.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/README.md +30 -13
- package/bin/e2e-bake-management +171 -12
- package/bin/e2e-infra +0 -1
- package/bin/e2e-up +14 -3
- package/docker/Dockerfile.observer +12 -1
- package/docker/Dockerfile.target-machine +22 -1
- package/npm-registry-server/package.json +1 -1
- package/package.json +3 -3
- package/registry-server/package.json +1 -1
- package/scripts/pack-celilo-packages.ts +15 -0
- package/src/block-timing.test.ts +559 -0
- package/src/block-timing.ts +366 -0
- package/src/cli/build.test.ts +54 -4
- package/src/cli/build.ts +204 -88
- package/src/cli/command-registry.ts +21 -0
- package/src/cli/command-tree-parser.ts +11 -2
- package/src/cli/completion.ts +9 -0
- package/src/cli/host.ts +252 -0
- package/src/cli/index.ts +78 -51
- package/src/cli/module-discovery.ts +108 -13
- package/src/cli/scaffold.ts +18 -26
- package/src/container-manager.cleanup.test.ts +284 -0
- package/src/container-manager.runner.test.ts +351 -0
- package/src/container-manager.test.ts +84 -0
- package/src/container-manager.ts +721 -185
- package/src/docker-compose-generator.ts +135 -61
- package/src/doctor.test.ts +259 -4
- package/src/doctor.ts +276 -3
- package/src/exit-cleanup.test.ts +83 -1
- package/src/fleet-nameserver-gate.test.ts +45 -0
- package/src/host-vm.test.ts +156 -0
- package/src/host-vm.ts +230 -0
- package/src/index.ts +11 -0
- package/src/live-stack.test.ts +184 -0
- package/src/live-stack.ts +145 -0
- package/src/no-unjustified-sleep.test.ts +90 -0
- package/src/proxmox-provisioner.test.ts +18 -2
- package/src/proxmox-provisioner.ts +22 -0
- package/src/public-sim-routes.test.ts +9 -2
- package/src/repo-root.ts +33 -0
- package/src/run-args.test.ts +76 -0
- package/src/run-args.ts +89 -0
- package/src/runner.ts +213 -8
- package/src/shared-infra.ts +83 -32
- package/src/socks-proxy.ts +2 -0
- package/src/source-fingerprint.test.ts +213 -0
- package/src/source-fingerprint.ts +201 -0
- package/src/stage-simulator-inputs.ts +93 -0
- package/src/stages.ts +133 -0
- package/src/wait-for-run.ts +1 -0
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import {
|
|
5
|
+
declaredBlockCaps,
|
|
6
|
+
declaredCapEntries,
|
|
7
|
+
mergeBlockTiming,
|
|
8
|
+
overBudgetBlocks,
|
|
9
|
+
parseBlockDurations,
|
|
10
|
+
parseJunitDurations,
|
|
11
|
+
serializeBlockTiming,
|
|
12
|
+
tightBlocks,
|
|
13
|
+
} from './block-timing';
|
|
14
|
+
|
|
15
|
+
const REPO_ROOT = join(import.meta.dir, '..', '..', '..');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Fixtures are copied verbatim out of real `e2e/results/*/output.log` files —
|
|
19
|
+
* a hand-written mirror would give confident coverage of a format bun does not
|
|
20
|
+
* emit, which is the same bug one level up.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// modules/wireguard-manager/e2e, run 2026-09-05T04-35-43. Stage 1 ran 155s and
|
|
24
|
+
// blew an assertion; stages 2-8 are requireStage cascade-skips.
|
|
25
|
+
const CASCADE = `
|
|
26
|
+
(fail) wireguard-manager runs behind the idp on the private ingress > stage 1: firewall, registrar and public ingress [155030.65ms]
|
|
27
|
+
8 | function requireStage(_label: string) {
|
|
28
|
+
(fail) wireguard-manager runs behind the idp on the private ingress > stage 2: authentik provides idp [39.19ms]
|
|
29
|
+
^ error: Skipped: deploy stage failed — timeout after 120000ms
|
|
30
|
+
(fail) wireguard-manager runs behind the idp on the private ingress > stage 3: the control-plane tunnel [1.29ms]
|
|
31
|
+
^ error: Skipped: deploy stage failed — timeout after 120000ms
|
|
32
|
+
`
|
|
33
|
+
.trim()
|
|
34
|
+
.split('\n');
|
|
35
|
+
|
|
36
|
+
// Modeled on the wave-0 census alerting log (e2e/results/2026-09-06T04-21-57,
|
|
37
|
+
// celilo#1291), where the layout was read verbatim: stage 1 failed for REAL at
|
|
38
|
+
// 1021397ms, and the text under its (fail) line is stage 2's skip message,
|
|
39
|
+
// because bun prints a block's own error ABOVE its (fail) line. The forward
|
|
40
|
+
// window the skip filter used before read that message and deleted stage 1's
|
|
41
|
+
// measurement — the exact block this file exists to keep.
|
|
42
|
+
const CENSUS_SHAPE = `
|
|
43
|
+
CeliloCommandError: module deploy caddy failed
|
|
44
|
+
stderr: "\\ntimeout after 180000ms",
|
|
45
|
+
exitCode: 124,
|
|
46
|
+
killed 1 dangling process
|
|
47
|
+
(fail) alerting reflects real module health > stage 1: a healthy deployed module raises no alerts [1021397.57ms]
|
|
48
|
+
35 | describe('alerting reflects real module health', () => {
|
|
49
|
+
error: Skipped — prior stage failed (stage 1)
|
|
50
|
+
at requireStage (alerting.test.ts:40:31)
|
|
51
|
+
(fail) alerting reflects real module health > stage 2: a broken service produces an alert naming the failing check [6.91ms]
|
|
52
|
+
35 | describe('alerting reflects real module health', () => {
|
|
53
|
+
error: Skipped — prior stage failed (stage 1)
|
|
54
|
+
at requireStage (alerting.test.ts:40:31)
|
|
55
|
+
(fail) alerting reflects real module health > stage 3: a degrading system accumulates alerts [3.47ms]
|
|
56
|
+
`
|
|
57
|
+
.trim()
|
|
58
|
+
.split('\n');
|
|
59
|
+
|
|
60
|
+
const CENSUS_JUNIT = `<testsuite name="alerting" tests="3">
|
|
61
|
+
<testcase name="stage 1: a healthy deployed module raises no alerts" classname="alerting reflects real module health" time="1021.398" />
|
|
62
|
+
<testcase name="stage 2: a broken service produces an alert naming the failing check" classname="alerting reflects real module health" time="0.007" />
|
|
63
|
+
</testsuite>`;
|
|
64
|
+
|
|
65
|
+
describe('parseBlockDurations', () => {
|
|
66
|
+
test('records the block that ran and drops the cascade-skips behind it', () => {
|
|
67
|
+
expect(parseBlockDurations(CASCADE)).toEqual({
|
|
68
|
+
'stage 1: firewall, registrar and public ingress': 155031,
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('a sub-millisecond skip is never recorded as a fast healthy block', () => {
|
|
73
|
+
// The whole point: 39.19ms for "stage 2" is the absence of a measurement.
|
|
74
|
+
// Recorded, it reads as a block with 300s of headroom.
|
|
75
|
+
const blocks = parseBlockDurations(CASCADE);
|
|
76
|
+
expect(Object.keys(blocks)).not.toContain('stage 2: authentik provides idp');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('a real failure that is not a cascade-skip IS recorded', () => {
|
|
80
|
+
const lines = [
|
|
81
|
+
'(fail) split-horizon views > deploy resolver (in dmz) + caddy [300001.00ms]',
|
|
82
|
+
' ^ this test timed out after 300000ms.',
|
|
83
|
+
];
|
|
84
|
+
expect(parseBlockDurations(lines)).toEqual({
|
|
85
|
+
'deploy resolver (in dmz) + caddy': 300001,
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('bun printing its result list twice yields one entry', () => {
|
|
90
|
+
const once = '(pass) suite > stage 1 [1234.50ms]';
|
|
91
|
+
expect(parseBlockDurations([once, once])).toEqual({ 'stage 1': 1235 });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('strips colour, since bun renders the list with ANSI', () => {
|
|
95
|
+
const lines = ['\x1b[32m(pass)\x1b[0m suite \x1b[2m>\x1b[0m stage 1 \x1b[2m[900.00ms]\x1b[0m'];
|
|
96
|
+
expect(parseBlockDurations(lines)).toEqual({ 'stage 1': 900 });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('an unnested test keeps its own name', () => {
|
|
100
|
+
expect(parseBlockDurations(['(pass) deploys caddy directly [400.00ms]'])).toEqual({
|
|
101
|
+
'deploys caddy directly': 400,
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('ignores lines that are not bun results', () => {
|
|
106
|
+
expect(parseBlockDurations(['Ran 3 tests across 1 file. [1892.67s]', ' 0 pass'])).toEqual({});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('a real failure is not deleted by the sibling skip messages printed under its line', () => {
|
|
110
|
+
// The celilo#1291 regression: stage 1 ran 1021s and failed on its own
|
|
111
|
+
// error, and the marker filter read stage 2's "Skipped — prior stage
|
|
112
|
+
// failed" from beneath its (fail) line, classified it as a cascade-skip,
|
|
113
|
+
// and returned {} for the whole suite. The duration guard carries the
|
|
114
|
+
// record now: stage 1 failed and did real work, so it stays.
|
|
115
|
+
expect(parseBlockDurations(CENSUS_SHAPE, CENSUS_JUNIT)).toEqual({
|
|
116
|
+
'stage 1: a healthy deployed module raises no alerts': 1021398,
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('census-shaped skip siblings are still dropped', () => {
|
|
121
|
+
// Stages 2 and 3 never ran: they failed, and their measured durations are
|
|
122
|
+
// a fraction of the did-no-work threshold.
|
|
123
|
+
const blocks = parseBlockDurations(CENSUS_SHAPE, CENSUS_JUNIT);
|
|
124
|
+
expect(Object.keys(blocks)).toEqual(['stage 1: a healthy deployed module raises no alerts']);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('mergeBlockTiming', () => {
|
|
129
|
+
test('replaces a suite wholesale so a split stops reporting the old block', () => {
|
|
130
|
+
const before = { alerting: { 'stage 1': 100, 'stage 2': 200 } };
|
|
131
|
+
expect(mergeBlockTiming(before, 'alerting', { 'stage 1a': 50, 'stage 1b': 60 })).toEqual({
|
|
132
|
+
alerting: { 'stage 1a': 50, 'stage 1b': 60 },
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('a run that parsed no blocks leaves the record alone', () => {
|
|
137
|
+
const before = { alerting: { 'stage 1': 100 } };
|
|
138
|
+
expect(mergeBlockTiming(before, 'alerting', {})).toBe(before);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('other suites are untouched', () => {
|
|
142
|
+
const before = { alerting: { a: 1 }, smoke: { b: 2 } };
|
|
143
|
+
expect(mergeBlockTiming(before, 'alerting', { a: 9 })).toEqual({
|
|
144
|
+
alerting: { a: 9 },
|
|
145
|
+
smoke: { b: 2 },
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('serializeBlockTiming sorts suites and blocks so the file diffs by value', () => {
|
|
151
|
+
const out = serializeBlockTiming({ zed: { b: 2, a: 1 }, alpha: { c: 3 } });
|
|
152
|
+
expect(out).toBe(
|
|
153
|
+
'{\n "alpha": {\n "c": 3\n },\n "zed": {\n "a": 1,\n "b": 2\n }\n}\n',
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('declaredBlockCaps', () => {
|
|
158
|
+
test('reads the third argument of each test block', () => {
|
|
159
|
+
const src = [
|
|
160
|
+
" test('stage 1: deploy', async () => {",
|
|
161
|
+
' await thing();',
|
|
162
|
+
' }, 300_000);',
|
|
163
|
+
'',
|
|
164
|
+
" test('stage 2: verify', async () => {",
|
|
165
|
+
' }, 120_000);',
|
|
166
|
+
].join('\n');
|
|
167
|
+
expect(declaredBlockCaps(src)).toEqual({
|
|
168
|
+
'stage 1: deploy': 300_000,
|
|
169
|
+
'stage 2: verify': 120_000,
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('a block with no third argument gets no cap, not a default', () => {
|
|
174
|
+
// It silently inherits the runner's `--timeout 3600000`. Reporting a
|
|
175
|
+
// fabricated 300s here would hide exactly that.
|
|
176
|
+
const src = [" test('uncapped', async () => {", ' });'].join('\n');
|
|
177
|
+
expect(declaredBlockCaps(src)).toEqual({});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('a template-literal name is skipped rather than mis-keyed', () => {
|
|
181
|
+
const src = [' test(`stage 2: ${host} resolves`, async () => {', ' }, 60_000);'].join('\n');
|
|
182
|
+
// Interpolated names cannot be matched against a runtime block name, so the
|
|
183
|
+
// parser must not invent a key for them.
|
|
184
|
+
expect(Object.keys(declaredBlockCaps(src))).toHaveLength(0);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('reads the wrapped format biome emits for stage()', () => {
|
|
188
|
+
const src = [
|
|
189
|
+
' stage(',
|
|
190
|
+
" 'stage 1: stand up the topology',",
|
|
191
|
+
' async () => {',
|
|
192
|
+
' await thing();',
|
|
193
|
+
' },',
|
|
194
|
+
' 300_000,',
|
|
195
|
+
' );',
|
|
196
|
+
].join('\n');
|
|
197
|
+
expect(declaredBlockCaps(src)).toEqual({ 'stage 1: stand up the topology': 300_000 });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('a wrapped block with no timeout inherits the hour, silently', () => {
|
|
201
|
+
const src = [
|
|
202
|
+
' stage(',
|
|
203
|
+
" 'stage 2: verify',",
|
|
204
|
+
' async () => {',
|
|
205
|
+
' await thing();',
|
|
206
|
+
' },',
|
|
207
|
+
' );',
|
|
208
|
+
].join('\n');
|
|
209
|
+
expect(declaredBlockCaps(src)).toEqual({});
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe('declaredCapEntries', () => {
|
|
214
|
+
test('reports the line of the closer that declared the cap', () => {
|
|
215
|
+
const src = [
|
|
216
|
+
' stage(',
|
|
217
|
+
" 'stage 1: deploy',",
|
|
218
|
+
' async () => {',
|
|
219
|
+
' await thing();',
|
|
220
|
+
' },',
|
|
221
|
+
' 300_000,',
|
|
222
|
+
' );',
|
|
223
|
+
].join('\n');
|
|
224
|
+
expect(declaredCapEntries(src)).toEqual([{ name: 'stage 1: deploy', capMs: 300_000, line: 6 }]);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('a nested object close followed by `});` does not steal the block name', () => {
|
|
228
|
+
// The literal shape that bit aspect-fanout-new-systems stage 1: a nested
|
|
229
|
+
// `},` line arms the timeout expectation, the `});` line consumed it, and
|
|
230
|
+
// the real block closer recorded as unattributed.
|
|
231
|
+
const src = [
|
|
232
|
+
' stage(',
|
|
233
|
+
" 'stage 1: deploy',",
|
|
234
|
+
' async () => {',
|
|
235
|
+
' await net.respondWith({',
|
|
236
|
+
" interview: { 'a.b': 'c' },",
|
|
237
|
+
' });',
|
|
238
|
+
' });',
|
|
239
|
+
' await thing();',
|
|
240
|
+
' },',
|
|
241
|
+
' 900_000,',
|
|
242
|
+
' );',
|
|
243
|
+
].join('\n');
|
|
244
|
+
expect(declaredBlockCaps(src)).toEqual({ 'stage 1: deploy': 900_000 });
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('a nested waitFor closer does not steal the block name or its cap', () => {
|
|
248
|
+
const src = [
|
|
249
|
+
' stage(',
|
|
250
|
+
" 'stage 1: deploy',",
|
|
251
|
+
' async () => {',
|
|
252
|
+
' await net.waitFor(',
|
|
253
|
+
' async () => {',
|
|
254
|
+
' return (await probe()) === true;',
|
|
255
|
+
' },',
|
|
256
|
+
' 30_000,',
|
|
257
|
+
" 'probe to pass',",
|
|
258
|
+
' );',
|
|
259
|
+
' },',
|
|
260
|
+
' 900_000,',
|
|
261
|
+
' );',
|
|
262
|
+
].join('\n');
|
|
263
|
+
expect(declaredBlockCaps(src)).toEqual({ 'stage 1: deploy': 900_000 });
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('a template-literal name yields an unattributed entry, not silence', () => {
|
|
267
|
+
const src = [' test(`stage ${n}: x`, async () => {', ' }, 60_000);'].join('\n');
|
|
268
|
+
expect(declaredCapEntries(src)).toEqual([{ name: null, capMs: 60_000, line: 2 }]);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe('tightBlocks', () => {
|
|
273
|
+
const caps = { a: 300_000, b: 300_000, c: 120_000 };
|
|
274
|
+
|
|
275
|
+
test('flags a block at or above the fraction, ordered worst first', () => {
|
|
276
|
+
const got = tightBlocks({ a: 240_000, b: 291_000, c: 10_000 }, caps);
|
|
277
|
+
expect(got.map((t) => t.block)).toEqual(['b', 'a']);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('a block with no declared cap is absent, not assumed healthy', () => {
|
|
281
|
+
expect(tightBlocks({ unknown: 3_000_000 }, caps)).toEqual([]);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test('a block with no measurement is absent', () => {
|
|
285
|
+
expect(tightBlocks({}, caps)).toEqual([]);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('a block that blew its cap is still reported', () => {
|
|
289
|
+
expect(tightBlocks({ a: 300_001 }, caps)[0].fraction).toBeGreaterThan(1);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe('overBudgetBlocks', () => {
|
|
294
|
+
// The celilo#1291 recurrence gate: a check over a finished run's junit.xml
|
|
295
|
+
// that FAILS when any block exceeds its declared cap. The census's alerting
|
|
296
|
+
// run produced exactly this junit against exactly these caps and every
|
|
297
|
+
// downstream consumer reported nothing, because the skip filter dropped the
|
|
298
|
+
// block before anything compared it to a cap. This function reads the junit
|
|
299
|
+
// directly, so the measurement cannot be filtered away first.
|
|
300
|
+
const source = [
|
|
301
|
+
" test('stage 1: a healthy deployed module raises no alerts', async () => {",
|
|
302
|
+
' }, 300_000);',
|
|
303
|
+
" test('stage 2: a broken service produces an alert', async () => {",
|
|
304
|
+
' }, 180_000);',
|
|
305
|
+
].join('\n');
|
|
306
|
+
|
|
307
|
+
test('flags the census overrun: 1021398ms against a 300000ms declaration', () => {
|
|
308
|
+
const overruns = overBudgetBlocks(CENSUS_JUNIT, source);
|
|
309
|
+
expect(overruns).toEqual([
|
|
310
|
+
{
|
|
311
|
+
block: 'stage 1: a healthy deployed module raises no alerts',
|
|
312
|
+
ms: 1021398,
|
|
313
|
+
capMs: 300000,
|
|
314
|
+
fraction: 1021398 / 300000,
|
|
315
|
+
},
|
|
316
|
+
]);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('a run within its caps passes the gate', () => {
|
|
320
|
+
const within = CENSUS_JUNIT.replace('time="1021.398"', 'time="102.398"');
|
|
321
|
+
expect(overBudgetBlocks(within, source)).toEqual([]);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test('a block with no declared cap is not enforceable and stays out', () => {
|
|
325
|
+
const uncapped = CENSUS_JUNIT.replace(
|
|
326
|
+
'stage 1: a healthy deployed module raises no alerts',
|
|
327
|
+
'stage 1: an uncapped block',
|
|
328
|
+
);
|
|
329
|
+
expect(overBudgetBlocks(uncapped, source)).toEqual([]);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test('the census junit against the real alerting caps reports the real overrun', () => {
|
|
333
|
+
// Reach, not reasoning: the actual fixture from the wave-0 census run,
|
|
334
|
+
// against the caps the actual suite file declares.
|
|
335
|
+
const src = readFileSync(join(REPO_ROOT, 'e2e', 'tests', 'alerting.test.ts'), 'utf-8');
|
|
336
|
+
const overruns = overBudgetBlocks(CENSUS_JUNIT, src);
|
|
337
|
+
expect(overruns.length).toBe(1);
|
|
338
|
+
expect(overruns[0].fraction).toBeGreaterThan(3);
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
describe('the cap parser reaches the real suites', () => {
|
|
343
|
+
// Measuring reach, not reasoning about it. A style change that stops the
|
|
344
|
+
// parser matching would otherwise turn every suite into "no cap declared" —
|
|
345
|
+
// a confident, well-formed report about nothing (celilo#1268).
|
|
346
|
+
const files: string[] = [];
|
|
347
|
+
const roots = [join(REPO_ROOT, 'e2e', 'tests')];
|
|
348
|
+
for (const m of readdirSync(join(REPO_ROOT, 'modules'))) {
|
|
349
|
+
const d = join(REPO_ROOT, 'modules', m, 'e2e');
|
|
350
|
+
if (existsSync(d)) roots.push(d);
|
|
351
|
+
}
|
|
352
|
+
for (const r of roots) {
|
|
353
|
+
if (!existsSync(r)) continue;
|
|
354
|
+
for (const f of readdirSync(r)) if (f.endsWith('.test.ts')) files.push(join(r, f));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
test('finds test files to read at all', () => {
|
|
358
|
+
expect(files.length).toBeGreaterThan(30);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test('most e2e blocks declare a cap the parser can read', () => {
|
|
362
|
+
let withCap = 0;
|
|
363
|
+
let blocks = 0;
|
|
364
|
+
for (const f of files) {
|
|
365
|
+
const src = readFileSync(f, 'utf-8');
|
|
366
|
+
blocks += (src.match(/^\s*(?:test|it|stage)(?:\.\w+)?\(/gm) ?? []).length;
|
|
367
|
+
withCap += Object.keys(declaredBlockCaps(src)).length;
|
|
368
|
+
}
|
|
369
|
+
// 227 of ~260 at the time this was written. The floor is deliberately well
|
|
370
|
+
// under that: it must fail on a parser that stops working, not on a suite
|
|
371
|
+
// being added.
|
|
372
|
+
expect(blocks).toBeGreaterThan(150);
|
|
373
|
+
expect(withCap / blocks).toBeGreaterThan(0.6);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test('reads split-horizon-views, the suite this gate was written for', () => {
|
|
377
|
+
const f = join(
|
|
378
|
+
REPO_ROOT,
|
|
379
|
+
'modules',
|
|
380
|
+
'knot-unbound-internal',
|
|
381
|
+
'e2e',
|
|
382
|
+
'split-horizon-views.test.ts',
|
|
383
|
+
);
|
|
384
|
+
const caps = declaredBlockCaps(readFileSync(f, 'utf-8'));
|
|
385
|
+
expect(Object.keys(caps).length).toBeGreaterThan(0);
|
|
386
|
+
for (const cap of Object.values(caps)) expect(cap).toBeLessThanOrEqual(300_000);
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
describe('no e2e suite declares the same block name twice', () => {
|
|
391
|
+
// The record is keyed on the block name, so two blocks sharing one inside a
|
|
392
|
+
// suite collapse into a single entry and the slower one hides the other. Hit
|
|
393
|
+
// for real while splitting celilo-apt-deploy: cutting stage 1 into six left a
|
|
394
|
+
// second "stage 3" and a second "stage 4" further down the file.
|
|
395
|
+
const files: string[] = [];
|
|
396
|
+
const roots = [join(REPO_ROOT, 'e2e', 'tests')];
|
|
397
|
+
for (const m of readdirSync(join(REPO_ROOT, 'modules'))) {
|
|
398
|
+
const d = join(REPO_ROOT, 'modules', m, 'e2e');
|
|
399
|
+
if (existsSync(d)) roots.push(d);
|
|
400
|
+
}
|
|
401
|
+
for (const r of roots) {
|
|
402
|
+
if (!existsSync(r)) continue;
|
|
403
|
+
for (const f of readdirSync(r)) if (f.endsWith('.test.ts')) files.push(join(r, f));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
test('every declared block name is unique within its file', () => {
|
|
407
|
+
const dupes: string[] = [];
|
|
408
|
+
for (const f of files) {
|
|
409
|
+
const seen = new Set<string>();
|
|
410
|
+
for (const m of readFileSync(f, 'utf-8').matchAll(
|
|
411
|
+
/^\s*(?:test|it)(?:\.\w+)?\(\s*['"](.+?)['"]\s*,/gm,
|
|
412
|
+
)) {
|
|
413
|
+
const name = m[1];
|
|
414
|
+
if (seen.has(name)) dupes.push(`${f.slice(REPO_ROOT.length + 1)}: "${name}"`);
|
|
415
|
+
seen.add(name);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
expect(dupes).toEqual([]);
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
// Emitted by `bun test --reporter=junit` for the cascade shape above: one real
|
|
423
|
+
// failure, one requireStage skip, one PASS. Copied from a real bun 1.3.3 run.
|
|
424
|
+
const CASCADE_JUNIT = `<?xml version="1.0" encoding="UTF-8"?>
|
|
425
|
+
<testsuites name="bun test" tests="3" assertions="2" failures="2" skipped="0" time="0.340297">
|
|
426
|
+
<testsuite name="cascade.test.ts" file="cascade.test.ts" tests="3" failures="2" time="0.324">
|
|
427
|
+
<testcase name="stage 1: the real work" classname="" time="0.123538" file="cascade.test.ts" line="4" assertions="1">
|
|
428
|
+
<failure type="AssertionError" />
|
|
429
|
+
</testcase>
|
|
430
|
+
<testcase name="stage 2: cascade" classname="" time="0.00026" file="cascade.test.ts" line="8" assertions="0">
|
|
431
|
+
<failure type="AssertionError" />
|
|
432
|
+
</testcase>
|
|
433
|
+
<testcase name="stage 3: passes" classname="" time="0.201089" file="cascade.test.ts" line="9" assertions="1" />
|
|
434
|
+
</testsuite>
|
|
435
|
+
</testsuites>`;
|
|
436
|
+
|
|
437
|
+
const CASCADE_STDOUT = [
|
|
438
|
+
'(fail) stage 1: the real work [123.54ms]',
|
|
439
|
+
' ^ error: expect(received).toBe(expected)',
|
|
440
|
+
'(fail) stage 2: cascade [0.26ms]',
|
|
441
|
+
' ^ error: Skipped: deploy stage failed — deploy: boom',
|
|
442
|
+
]
|
|
443
|
+
.join('\n')
|
|
444
|
+
.split('\n');
|
|
445
|
+
|
|
446
|
+
describe('parseJunitDurations', () => {
|
|
447
|
+
test('reads seconds with microsecond precision into ms', () => {
|
|
448
|
+
expect(parseJunitDurations(CASCADE_JUNIT)).toEqual({
|
|
449
|
+
'stage 1: the real work': 124,
|
|
450
|
+
'stage 2: cascade': 0,
|
|
451
|
+
'stage 3: passes': 201,
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
test('covers a block that PASSED — the whole reason this source exists', () => {
|
|
456
|
+
// bun's console prints nothing for a passing test. Measured across 108
|
|
457
|
+
// recorded output.log files: zero contain a "(pass)" line.
|
|
458
|
+
expect(parseJunitDurations(CASCADE_JUNIT)['stage 3: passes']).toBe(201);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test('unescapes an XML-escaped block name', () => {
|
|
462
|
+
const xml = '<testcase name="a & b <c>" time="1.5" />';
|
|
463
|
+
expect(parseJunitDurations(xml)).toEqual({ 'a & b <c>': 1500 });
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test('ignores a testcase with no time', () => {
|
|
467
|
+
expect(parseJunitDurations('<testcase name="x" />')).toEqual({});
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
describe('parseBlockDurations with a JUnit report', () => {
|
|
472
|
+
test('records the pass and the real failure, and drops the cascade-skip', () => {
|
|
473
|
+
expect(parseBlockDurations(CASCADE_STDOUT, CASCADE_JUNIT)).toEqual({
|
|
474
|
+
'stage 1: the real work': 124,
|
|
475
|
+
'stage 3: passes': 201,
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test('falls back to stdout when a crash left no JUnit file', () => {
|
|
480
|
+
// Every bun JUnit failure is a bare <failure type="AssertionError" />, so
|
|
481
|
+
// nothing in the XML distinguishes a cascade-skip from a real failure, and
|
|
482
|
+
// the stdout fallback has only durations and markers. The record keys on
|
|
483
|
+
// the duration: stage 2's "Skipped:" marker is visible here and changes
|
|
484
|
+
// nothing.
|
|
485
|
+
expect(parseBlockDurations(CASCADE_STDOUT, undefined)).toEqual({
|
|
486
|
+
'stage 1: the real work': 124,
|
|
487
|
+
});
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// wireguard-manager-private, run 2026-09-05T09-30-56. bun prints a six-line
|
|
492
|
+
// source excerpt before the message when the throw carries a stack, so
|
|
493
|
+
// `error: Skipped:` lands well past where a short window would look. The
|
|
494
|
+
// four-line window this replaced recorded all six of these skips at 0.0s.
|
|
495
|
+
const SKIP_WITH_SOURCE_EXCERPT =
|
|
496
|
+
`(fail) wireguard-manager > stage 3: the control-plane tunnel [10.62ms]
|
|
497
|
+
67 | let net: NetworkHandle;
|
|
498
|
+
68 | let stageError: string | null = null;
|
|
499
|
+
69 | let alphaKey = '';
|
|
500
|
+
70 |
|
|
501
|
+
71 | function requireStage(stage: string): void {
|
|
502
|
+
72 | if (stageError) throw new Error(\`Skipped: \${stage} stage failed — \${stageError}\`);
|
|
503
|
+
^
|
|
504
|
+
error: Skipped: tunnel stage failed — idp: celilo module import failed (exit 1)
|
|
505
|
+
|
|
506
|
+
at requireStage (/repo/modules/wireguard-manager/e2e/x.test.ts:72:29)
|
|
507
|
+
(fail) wireguard-manager > stage 4: internal resolver [0.85ms]
|
|
508
|
+
67 | let net: NetworkHandle;
|
|
509
|
+
^
|
|
510
|
+
error: Skipped: resolver stage failed — idp: celilo module import failed (exit 1)
|
|
511
|
+
`.split('\n');
|
|
512
|
+
|
|
513
|
+
describe('a cascade-skip behind a source excerpt', () => {
|
|
514
|
+
test('is dropped from the record by its did-no-work duration, marker or no marker', () => {
|
|
515
|
+
// This log's layout is the OPPOSITE of the census alerting log's: the skip
|
|
516
|
+
// message sits below its own (fail) line here, above it there. No window
|
|
517
|
+
// over the text attributes soundly across both; the duration does.
|
|
518
|
+
const junit = `<testcase name="stage 3: the control-plane tunnel" time="0.010616" />
|
|
519
|
+
<testcase name="stage 4: internal resolver" time="0.000852" />
|
|
520
|
+
<testcase name="stage 1: firewall, registrar and public ingress" time="300.001024" />`;
|
|
521
|
+
expect(parseBlockDurations(SKIP_WITH_SOURCE_EXCERPT, junit)).toEqual({
|
|
522
|
+
'stage 1: firewall, registrar and public ingress': 300001,
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
describe('a failed block that did no work is not a measurement', () => {
|
|
528
|
+
// wireguard-manager-private 2026-09-05T09-42-54. bun prints the message AFTER
|
|
529
|
+
// the result line as each test completes and BEFORE it in the end-of-file
|
|
530
|
+
// listing, so the last failing block's "Skipped:" is above its own line in
|
|
531
|
+
// both. It leaked through the marker filter at 0.25ms.
|
|
532
|
+
const lines = [
|
|
533
|
+
'(fail) wireguard-manager > stage 8: pausing the manager does not disturb the tunnel [0.25ms]',
|
|
534
|
+
'[progress:start] stopping network | network stopped',
|
|
535
|
+
' 0 pass',
|
|
536
|
+
' 8 fail',
|
|
537
|
+
];
|
|
538
|
+
const junit =
|
|
539
|
+
'<testcase name="stage 8: pausing the manager does not disturb the tunnel" time="0.00025" />';
|
|
540
|
+
|
|
541
|
+
test('is dropped even when the marker is nowhere the filter can see it', () => {
|
|
542
|
+
expect(parseBlockDurations(lines, junit)).toEqual({});
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
test('a fast block that PASSED is kept — that is a real measurement', () => {
|
|
546
|
+
const passing = ['(fail) other > stage 1 [500.00ms]'];
|
|
547
|
+
const j = '<testcase name="quick assertion" time="0.002" />';
|
|
548
|
+
expect(parseBlockDurations(passing, j)).toEqual({ 'quick assertion': 2 });
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test('a failed block that did real work is kept', () => {
|
|
552
|
+
const j = '<testcase name="stage 1: deploy" time="300.001" />';
|
|
553
|
+
const l = [
|
|
554
|
+
'(fail) suite > stage 1: deploy [300001.00ms]',
|
|
555
|
+
' ^ this test timed out after 300000ms.',
|
|
556
|
+
];
|
|
557
|
+
expect(parseBlockDurations(l, j)).toEqual({ 'stage 1: deploy': 300001 });
|
|
558
|
+
});
|
|
559
|
+
});
|