@celilo/e2e 0.19.3 → 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,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-`test()`-block durations, recorded where something can read them later.
|
|
3
|
+
*
|
|
4
|
+
* The 300s budget in `apps/celilo/CLAUDE.md` is PER TEST BLOCK, not per suite.
|
|
5
|
+
* `.e2e-timing.json` records suite totals only, so the one number the policy is
|
|
6
|
+
* written against was recorded nowhere. bun prints it — `(pass) suite > stage 1
|
|
7
|
+
* [155030.65ms]` — into `output.log`, which lives in `e2e/results/<ts>/` and is
|
|
8
|
+
* deleted with it. In practice only FAILED suites ever get read, so the
|
|
9
|
+
* population of blocks approaching the cap was unmeasured (celilo#1268).
|
|
10
|
+
*
|
|
11
|
+
* That gap is what let a table comparing SUITE totals against a PER-TEST cap
|
|
12
|
+
* stand as a finding for twenty minutes: there was no per-block number to check
|
|
13
|
+
* it against. Two suites falsified it by passing at durations the theory called
|
|
14
|
+
* impossible (`module-pause` 303s, `aspect-fanout-new-systems` 401s) — both
|
|
15
|
+
* spread over many blocks, neither anywhere near the cap.
|
|
16
|
+
*
|
|
17
|
+
* Pure parser + a merge, in its own module so it is testable: runner.ts calls
|
|
18
|
+
* main() at import time, so nothing declared there is reachable from a test.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { stripAnsi } from './extract-failure';
|
|
22
|
+
|
|
23
|
+
/** suite name → test-block name → last measured duration in ms. */
|
|
24
|
+
export type BlockTiming = Record<string, Record<string, number>>;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One suite's per-block durations: what ran, and how long each block took.
|
|
28
|
+
*
|
|
29
|
+
* Durations come from bun's JUnit report when there is one, because that is the
|
|
30
|
+
* only source covering blocks that PASSED. Console output names a block only
|
|
31
|
+
* when it fails. A failed block that did no work is dropped by the duration
|
|
32
|
+
* guard below; there is no message-based skip filter, for the reason recorded
|
|
33
|
+
* there.
|
|
34
|
+
*/
|
|
35
|
+
/**
|
|
36
|
+
* A failed block below this did no work, so its duration measures nothing.
|
|
37
|
+
*
|
|
38
|
+
* Nothing that stands up a container, deploys a module or waits on DNS returns
|
|
39
|
+
* in 50ms. A `requireStage` skip returns in a fraction of one.
|
|
40
|
+
*/
|
|
41
|
+
const NOT_A_MEASUREMENT_MS = 50;
|
|
42
|
+
|
|
43
|
+
export function parseBlockDurations(lines: string[], junitXml?: string): Record<string, number> {
|
|
44
|
+
const failed = failedBlocks(lines);
|
|
45
|
+
const measured = junitXml ? parseJunitDurations(junitXml) : parseStdoutDurations(lines);
|
|
46
|
+
const out: Record<string, number> = {};
|
|
47
|
+
for (const [block, ms] of Object.entries(measured)) {
|
|
48
|
+
// A failed block that did no work is a cascade-skip, and recording it would
|
|
49
|
+
// enter a stage that never ran as a healthy fast one. The duration is the
|
|
50
|
+
// sound signal — NOT any "Skipped:" marker in the console text. bun prints
|
|
51
|
+
// a block's error message in two REAL and OPPOSITE layouts: below its own
|
|
52
|
+
// (fail) line (wireguard-manager-private 2026-09-05T09-30-56, where the
|
|
53
|
+
// message names its own stage) and ABOVE it, under the PREVIOUS block's
|
|
54
|
+
// line (alerting, wave-0 census e2e/results/2026-09-06T04-21-57, where the
|
|
55
|
+
// at-frames point into the NEXT stage's body). Same bun, days apart. Any
|
|
56
|
+
// window reading text next to a (fail) line attributes a sibling's message
|
|
57
|
+
// to this block in one layout or the other — and the forward version did
|
|
58
|
+
// exactly that on the census: stage 1 failed for real at 1021397ms, the
|
|
59
|
+
// filter read stage 2's skip message under its line, and deleted the one
|
|
60
|
+
// measurement this file exists to keep (celilo#1291). Worst case now is a
|
|
61
|
+
// skip that measured over 50ms entering the record as a fast healthy block
|
|
62
|
+
// — cosmetic noise nowhere near any cap — instead of a real measurement
|
|
63
|
+
// being deleted.
|
|
64
|
+
if (failed.has(block) && ms < NOT_A_MEASUREMENT_MS) continue;
|
|
65
|
+
out[block] = ms;
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Every block bun reported as failing, by name. */
|
|
71
|
+
function failedBlocks(lines: string[]): Set<string> {
|
|
72
|
+
const out = new Set<string>();
|
|
73
|
+
for (const line of lines.map(stripAnsi)) {
|
|
74
|
+
if (isResultLine(line) && line.startsWith('(fail)')) out.add(blockNameOf(line));
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Per-testcase durations from bun's JUnit reporter.
|
|
81
|
+
*
|
|
82
|
+
* The reporter is the ONLY source that covers passing blocks. bun's console
|
|
83
|
+
* output prints `(fail) name [Nms]` as each test fails and nothing at all for a
|
|
84
|
+
* test that passes — measured across 108 recorded `output.log` files, of which
|
|
85
|
+
* ZERO contain a `(pass)` line. A record built from stdout therefore holds only
|
|
86
|
+
* the blocks that already broke, which is the exact opposite of the question
|
|
87
|
+
* ("which blocks are running out of room?").
|
|
88
|
+
*
|
|
89
|
+
* Found by shipping the stdout version and reading what it wrote: one entry, for
|
|
90
|
+
* the one stage that failed, out of seven that ran.
|
|
91
|
+
*
|
|
92
|
+
* `time` is seconds with microsecond precision.
|
|
93
|
+
*/
|
|
94
|
+
export function parseJunitDurations(xml: string): Record<string, number> {
|
|
95
|
+
const out: Record<string, number> = {};
|
|
96
|
+
for (const m of xml.matchAll(/<testcase\b([^>]*)\/?>/g)) {
|
|
97
|
+
const attrs = m[1];
|
|
98
|
+
const name = attrs.match(/\bname="([^"]*)"/)?.[1];
|
|
99
|
+
const time = attrs.match(/\btime="([\d.]+)"/)?.[1];
|
|
100
|
+
if (!name || !time) continue;
|
|
101
|
+
out[unescapeXml(name)] = Math.round(Number(time) * 1000);
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function unescapeXml(s: string): string {
|
|
107
|
+
return s
|
|
108
|
+
.replace(/</g, '<')
|
|
109
|
+
.replace(/>/g, '>')
|
|
110
|
+
.replace(/"/g, '"')
|
|
111
|
+
.replace(/'/g, "'")
|
|
112
|
+
.replace(/&/g, '&');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Fallback for a run that produced no JUnit file — a crash, or an older bun.
|
|
117
|
+
* Covers failures only, for the reason above.
|
|
118
|
+
*/
|
|
119
|
+
function parseStdoutDurations(lines: string[]): Record<string, number> {
|
|
120
|
+
const plain = lines.map(stripAnsi);
|
|
121
|
+
const out: Record<string, number> = {};
|
|
122
|
+
for (const line of plain) {
|
|
123
|
+
if (!isResultLine(line)) continue;
|
|
124
|
+
const ms = line.match(/\[([\d.]+)ms\]\s*$/);
|
|
125
|
+
if (!ms) continue;
|
|
126
|
+
const block = blockNameOf(line);
|
|
127
|
+
if (!block) continue;
|
|
128
|
+
out[block] = Math.max(out[block] ?? 0, Math.round(Number(ms[1])));
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function isResultLine(l: string): boolean {
|
|
134
|
+
return /^\((?:pass|fail)\)\s+\S/.test(l);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** bun renders nesting as "describe > block"; a cap is declared against the block. */
|
|
138
|
+
function blockNameOf(line: string): string {
|
|
139
|
+
const full = line.replace(/^\((?:pass|fail)\)\s+/, '').replace(/\s*\[[\d.]+ms\]\s*$/, '');
|
|
140
|
+
return (full.includes(' > ') ? full.slice(full.lastIndexOf(' > ') + 3) : full).trim();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Fold one suite's blocks into the stored record, replacing that suite's entry.
|
|
145
|
+
*
|
|
146
|
+
* The suite is replaced rather than merged so a split shows up on the next run:
|
|
147
|
+
* a block that no longer exists must stop being reported, or the record keeps
|
|
148
|
+
* a fixed suite looking broken forever.
|
|
149
|
+
*/
|
|
150
|
+
export function mergeBlockTiming(
|
|
151
|
+
stored: BlockTiming,
|
|
152
|
+
suite: string,
|
|
153
|
+
blocks: Record<string, number>,
|
|
154
|
+
): BlockTiming {
|
|
155
|
+
if (Object.keys(blocks).length === 0) return stored;
|
|
156
|
+
return { ...stored, [suite]: blocks };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Stable key order so the committed file diffs by value, not by iteration order. */
|
|
160
|
+
export function serializeBlockTiming(timing: BlockTiming): string {
|
|
161
|
+
const suites = Object.keys(timing).sort();
|
|
162
|
+
const ordered: BlockTiming = {};
|
|
163
|
+
for (const s of suites) {
|
|
164
|
+
const blocks = timing[s];
|
|
165
|
+
ordered[s] = Object.fromEntries(
|
|
166
|
+
Object.keys(blocks)
|
|
167
|
+
.sort()
|
|
168
|
+
.map((b) => [b, blocks[b]]),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
return `${JSON.stringify(ordered, null, 2)}\n`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ─── Declared caps, and blocks that are running out of room ──────────
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The per-block budget each block (test() or stage()) declares as its third
|
|
178
|
+
* argument.
|
|
179
|
+
*
|
|
180
|
+
* There is no other place to read it from. The runner spawns
|
|
181
|
+
* `bun test --timeout 3600000`, so the CLI value is a backstop for debug
|
|
182
|
+
* sessions; the number that governs a block is the literal in the source, and
|
|
183
|
+
* a block with no literal silently inherits the hour.
|
|
184
|
+
*
|
|
185
|
+
* Measured, not assumed: an inline third argument DOES override the CLI value
|
|
186
|
+
* (bun 1.3.3), including when the body swallows its own errors the way a staged
|
|
187
|
+
* e2e suite does.
|
|
188
|
+
*
|
|
189
|
+
* Handles both formats biome produces: the one-line open/close
|
|
190
|
+
* (`test('name', async () => {` ... `}, 300_000);`) and the wrapped one
|
|
191
|
+
* (`stage(\n 'name',\n async () => {` ... `},\n 300_000,\n);`).
|
|
192
|
+
*/
|
|
193
|
+
export function declaredBlockCaps(source: string): Record<string, number> {
|
|
194
|
+
const caps: Record<string, number> = {};
|
|
195
|
+
for (const entry of declaredCapEntries(source)) {
|
|
196
|
+
if (entry.name !== null) caps[entry.name] = entry.capMs;
|
|
197
|
+
}
|
|
198
|
+
return caps;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface DeclaredCapEntry {
|
|
202
|
+
/**
|
|
203
|
+
* The block name as declared, or null when the closer could not be
|
|
204
|
+
* attributed to a keyed opener (a template-literal name interpolates at
|
|
205
|
+
* runtime, so the source text can never equal the name bun prints).
|
|
206
|
+
* The timeout-cap gate fails loudly on null-named over-bar entries rather
|
|
207
|
+
* than letting a real cap escape the manifest.
|
|
208
|
+
*/
|
|
209
|
+
name: string | null;
|
|
210
|
+
capMs: number;
|
|
211
|
+
/** 1-indexed source line of the closer that declared the cap. */
|
|
212
|
+
line: number;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* `declaredBlockCaps` with provenance: which line declared each cap, and
|
|
217
|
+
* which closers went unattributed. The e2e-timeout-cap gate consumes this
|
|
218
|
+
* so the manifest cites source lines and an over-bar closer the parser
|
|
219
|
+
* cannot key fails the gate instead of escaping silently.
|
|
220
|
+
*/
|
|
221
|
+
export function declaredCapEntries(source: string): DeclaredCapEntry[] {
|
|
222
|
+
const lines = source.split('\n');
|
|
223
|
+
const entries: DeclaredCapEntry[] = [];
|
|
224
|
+
let pending: string | null = null; // the open block's name, if keyable
|
|
225
|
+
let awaitingName = false; // multi-line open: the name is on the next line
|
|
226
|
+
let closeIndent = -1; // the indent the block's closing `},` must sit at
|
|
227
|
+
|
|
228
|
+
// Biome's wrapped form indents the body close one level past the opener:
|
|
229
|
+
// stage( <- indent 2
|
|
230
|
+
// 'name',
|
|
231
|
+
// async () => { <- indent 4
|
|
232
|
+
// ... <- indent 6
|
|
233
|
+
// }, <- indent 4 = opener + 2
|
|
234
|
+
// 900_000,
|
|
235
|
+
// );
|
|
236
|
+
// The inline form closes at the opener's own indent:
|
|
237
|
+
// test('name', async () => { ... }, 300_000);
|
|
238
|
+
// A nested close (an object literal, a waitFor callback) is always DEEPER
|
|
239
|
+
// than the block's close indent, so matching on indent is what keeps a
|
|
240
|
+
// nested `},` from stealing the pending block's name — the failure that
|
|
241
|
+
// made stage 1 of aspect-fanout-new-systems read as unattributed.
|
|
242
|
+
const indentOf = (line: string) => line.length - line.trimStart().length;
|
|
243
|
+
|
|
244
|
+
for (const [index, line] of lines.entries()) {
|
|
245
|
+
// Any block-open clears `pending`, including one this parser cannot key on.
|
|
246
|
+
// Skipping the line instead would leave the PREVIOUS block's name armed, so
|
|
247
|
+
// the next `}, N)` would overwrite that block's cap with this one's.
|
|
248
|
+
// stage() is the shared wrapper from @celilo/e2e for staged suites (ce-3ei3);
|
|
249
|
+
// it takes the same (name, body, timeout) shape as test().
|
|
250
|
+
if (/^\s*(?:test|it|stage)(?:\.\w+)?\(/.test(line)) {
|
|
251
|
+
// A template-literal name interpolates at runtime, so the source text can
|
|
252
|
+
// never equal the name bun prints. Left uncapped rather than keyed on a
|
|
253
|
+
// string nothing will ever match.
|
|
254
|
+
pending = line.match(/^\s*(?:test|it|stage)(?:\.\w+)?\(\s*['"](.+?)['"]\s*,/)?.[1] ?? null;
|
|
255
|
+
awaitingName = /\(\s*$/.test(line);
|
|
256
|
+
closeIndent = indentOf(line) + (awaitingName ? 2 : 0);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (awaitingName) {
|
|
260
|
+
const name = line.match(/^\s*['"](.+?)['"]\s*,?\s*$/);
|
|
261
|
+
pending = name?.[1] ?? null;
|
|
262
|
+
awaitingName = false;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (closeIndent === -1 || indentOf(line) !== closeIndent) continue;
|
|
266
|
+
const bare = line.trimStart();
|
|
267
|
+
|
|
268
|
+
const close = bare.match(/^\},\s*([0-9_]+)\s*\)\s*;?\s*$/);
|
|
269
|
+
if (close) {
|
|
270
|
+
entries.push({ name: pending, capMs: Number(close[1].replace(/_/g, '')), line: index + 1 });
|
|
271
|
+
pending = null;
|
|
272
|
+
closeIndent = -1;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (/^\},\s*$/.test(bare)) {
|
|
276
|
+
// Wrapped form: the timeout is the next line, at the same indent. Any
|
|
277
|
+
// other follower means this `},` closes the block with no cap at all.
|
|
278
|
+
const next = lines[index + 1];
|
|
279
|
+
const timeout = next?.match(/^\s*([0-9_]+)\s*,\s*$/);
|
|
280
|
+
if (timeout) {
|
|
281
|
+
entries.push({
|
|
282
|
+
name: pending,
|
|
283
|
+
capMs: Number(timeout[1].replace(/_/g, '')),
|
|
284
|
+
line: index + 2,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
pending = null;
|
|
288
|
+
closeIndent = -1;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return entries;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export interface TightBlock {
|
|
295
|
+
block: string;
|
|
296
|
+
ms: number;
|
|
297
|
+
capMs: number;
|
|
298
|
+
fraction: number;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Blocks that finished inside their budget but had little of it left.
|
|
303
|
+
*
|
|
304
|
+
* This is the signal the census could not see. A block that BLOWS its cap says
|
|
305
|
+
* "this test timed out after 300000ms" and reads as a slow host; a block that
|
|
306
|
+
* lands at 95% says nothing at all, and is one ordinary night's load away from
|
|
307
|
+
* the first case. Median inflation on the grind host is 1.14x and p90 is 1.58x,
|
|
308
|
+
* so 80% of a cap is roughly a coin flip at p90.
|
|
309
|
+
*
|
|
310
|
+
* Blocks with no measurement, and blocks with no declared cap, are absent from
|
|
311
|
+
* the result rather than assumed healthy.
|
|
312
|
+
*/
|
|
313
|
+
export function tightBlocks(
|
|
314
|
+
measured: Record<string, number>,
|
|
315
|
+
caps: Record<string, number>,
|
|
316
|
+
fraction = 0.8,
|
|
317
|
+
): TightBlock[] {
|
|
318
|
+
const out: TightBlock[] = [];
|
|
319
|
+
for (const [block, ms] of Object.entries(measured)) {
|
|
320
|
+
const capMs = caps[block];
|
|
321
|
+
if (!capMs) continue;
|
|
322
|
+
if (ms < capMs * fraction) continue;
|
|
323
|
+
out.push({ block, ms, capMs, fraction: ms / capMs });
|
|
324
|
+
}
|
|
325
|
+
return out.sort((a, b) => b.fraction - a.fraction);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export interface OverBudgetBlock {
|
|
329
|
+
block: string;
|
|
330
|
+
ms: number;
|
|
331
|
+
capMs: number;
|
|
332
|
+
fraction: number;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Blocks whose measured duration EXCEEDS the cap they declare, and the run must
|
|
337
|
+
* fail on them.
|
|
338
|
+
*
|
|
339
|
+
* This reads the JUnit durations directly and deliberately bypasses
|
|
340
|
+
* `parseBlockDurations`, whose skip filter exists to keep stages that never ran
|
|
341
|
+
* out of the RECORD. A budget check has the opposite obligation: every block
|
|
342
|
+
* that ran must be measured against its declaration, and the census proved the
|
|
343
|
+
* cost of routing it through the record first. alerting stage 1 ran 1021397ms
|
|
344
|
+
* against its own 300000ms declaration (celilo#1291) and every downstream
|
|
345
|
+
* consumer saw nothing, because the skip filter misattributed a sibling's
|
|
346
|
+
* message and dropped the block before anything could compare it to a cap.
|
|
347
|
+
*
|
|
348
|
+
* bun's per-test timeout is NOT the enforcer of this number. It is an
|
|
349
|
+
* EventLoopTimer that can fail to fire while other timers in the same process
|
|
350
|
+
* fire on schedule (the census: the deploy's own 180s budget fired at exactly
|
|
351
|
+
* 180s into a block whose 300s bun timer never fired; oven-sh/bun#32056
|
|
352
|
+
* family). The declaration binds HERE, after the fact: the block still holds
|
|
353
|
+
* the rig while it overruns, but a run that let it happen does not pass.
|
|
354
|
+
*/
|
|
355
|
+
export function overBudgetBlocks(junitXml: string, source: string): OverBudgetBlock[] {
|
|
356
|
+
const measured = parseJunitDurations(junitXml);
|
|
357
|
+
const caps = declaredBlockCaps(source);
|
|
358
|
+
const out: OverBudgetBlock[] = [];
|
|
359
|
+
for (const [block, ms] of Object.entries(measured)) {
|
|
360
|
+
const capMs = caps[block];
|
|
361
|
+
if (!capMs) continue;
|
|
362
|
+
if (ms <= capMs) continue;
|
|
363
|
+
out.push({ block, ms, capMs, fraction: ms / capMs });
|
|
364
|
+
}
|
|
365
|
+
return out.sort((a, b) => b.fraction - a.fraction);
|
|
366
|
+
}
|
package/src/cli/build.test.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { afterEach, beforeEach, expect, test } from 'bun:test';
|
|
2
|
-
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
-
import {
|
|
5
|
+
import { gzipSync } from 'node:zlib';
|
|
6
|
+
import {
|
|
7
|
+
assertGzipValid,
|
|
8
|
+
stageNetappsFromRegistry,
|
|
9
|
+
verifyNetapp,
|
|
10
|
+
verifyStagedNetapps,
|
|
11
|
+
} from './build';
|
|
6
12
|
|
|
7
13
|
let dir: string;
|
|
8
14
|
const realFetch = globalThis.fetch;
|
|
@@ -20,6 +26,8 @@ afterEach(() => {
|
|
|
20
26
|
|
|
21
27
|
test('fetches each module latest version to <name>.netapp via the download endpoint', async () => {
|
|
22
28
|
const seen: string[] = [];
|
|
29
|
+
// Valid gzip payload — the staging step verifies every downloaded .netapp.
|
|
30
|
+
const netappBytes = gzipSync(Buffer.from('tar-payload'));
|
|
23
31
|
// @ts-expect-error — minimal fetch stub for the two shapes we call.
|
|
24
32
|
globalThis.fetch = async (url: string) => {
|
|
25
33
|
seen.push(url);
|
|
@@ -33,15 +41,57 @@ test('fetches each module latest version to <name>.netapp via the download endpo
|
|
|
33
41
|
}),
|
|
34
42
|
);
|
|
35
43
|
}
|
|
36
|
-
return new Response(new Uint8Array(
|
|
44
|
+
return new Response(new Uint8Array(netappBytes));
|
|
37
45
|
};
|
|
38
46
|
|
|
39
47
|
await stageNetappsFromRegistry(dir);
|
|
40
48
|
|
|
41
49
|
expect(readdirSync(dir).sort()).toEqual(['caddy.netapp', 'namecheap.netapp']);
|
|
42
50
|
expect(existsSync(join(dir, 'caddy.netapp'))).toBe(true);
|
|
43
|
-
expect([...readFileSync(join(dir, 'caddy.netapp'))]).toEqual([
|
|
51
|
+
expect([...readFileSync(join(dir, 'caddy.netapp'))]).toEqual([...netappBytes]);
|
|
44
52
|
// Trailing slash stripped; download hits the module's max_version.
|
|
45
53
|
expect(seen).toContain('https://example.test/registry/api/v1/modules/caddy/1.2.3/download');
|
|
46
54
|
expect(seen).toContain('https://example.test/registry/api/v1/modules/namecheap/0.4.0/download');
|
|
47
55
|
});
|
|
56
|
+
|
|
57
|
+
test('refuses a downloaded .netapp that is not a complete gzip stream (celilo#1257)', async () => {
|
|
58
|
+
const realExit = process.exit;
|
|
59
|
+
const exitCalls: unknown[] = [];
|
|
60
|
+
// Stub exit so the test survives the loud failure the corrupt download gets.
|
|
61
|
+
(process as unknown as { exit: (code?: number) => never }).exit = ((code?: number) => {
|
|
62
|
+
exitCalls.push(code);
|
|
63
|
+
throw new Error(`process.exit(${code})`);
|
|
64
|
+
}) as typeof process.exit;
|
|
65
|
+
// @ts-expect-error — minimal fetch stub for the two shapes we call.
|
|
66
|
+
globalThis.fetch = async (url: string) => {
|
|
67
|
+
if (url.includes('/api/v1/modules?')) {
|
|
68
|
+
return new Response(
|
|
69
|
+
JSON.stringify({ modules: [{ name: 'technitium', max_version: '9.9.9' }] }),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
// A payload truncated mid-stream, the shape a wedged packaging write leaves.
|
|
73
|
+
return new Response(new Uint8Array([0x1f, 0x8b, 0x08, 0x00, 0x99]));
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
await expect(stageNetappsFromRegistry(dir)).rejects.toThrow('process.exit(1)');
|
|
78
|
+
} finally {
|
|
79
|
+
process.exit = realExit;
|
|
80
|
+
}
|
|
81
|
+
expect(exitCalls).toEqual([1]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('verifyStagedNetapps accepts a valid .netapp and refuses a truncated one', () => {
|
|
85
|
+
const valid = gzipSync(Buffer.from('x'.repeat(2048)));
|
|
86
|
+
writeFileSync(join(dir, 'good.netapp'), valid);
|
|
87
|
+
verifyStagedNetapps(dir);
|
|
88
|
+
verifyNetapp(join(dir, 'good.netapp'));
|
|
89
|
+
assertGzipValid(join(dir, 'good.netapp'));
|
|
90
|
+
|
|
91
|
+
// Plant a truncated .netapp: valid gzip bytes cut at 60 percent, missing the
|
|
92
|
+
// trailer. The recurrence gate for celilo#1257.
|
|
93
|
+
writeFileSync(join(dir, 'technitium.netapp'), valid.subarray(0, Math.floor(valid.length * 0.6)));
|
|
94
|
+
expect(() => verifyStagedNetapps(dir)).toThrow(/technitium\.netapp.*gzip/s);
|
|
95
|
+
expect(() => verifyNetapp(join(dir, 'technitium.netapp'))).toThrow(/technitium\.netapp/s);
|
|
96
|
+
expect(() => assertGzipValid(join(dir, 'technitium.netapp'))).toThrow(/unexpected end of file/s);
|
|
97
|
+
});
|