@celilo/e2e 0.11.0 → 0.11.1

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.
Files changed (45) hide show
  1. package/npm-registry-server/src/packument.test.ts +2 -5
  2. package/npm-registry-server/src/packument.ts +3 -3
  3. package/npm-registry-server/src/server.ts +1 -1
  4. package/package.json +3 -3
  5. package/registry-server/src/bootstrap.ts +1 -1
  6. package/registry-server/src/introspection.test.ts +8 -12
  7. package/registry-server/src/server.test.ts +2 -2
  8. package/registry-server/src/server.ts +1 -1
  9. package/registry-server/src/storage.test.ts +8 -4
  10. package/registry-server/src/storage.ts +1 -1
  11. package/registry-server/src/validation.ts +2 -4
  12. package/scripts/pack-celilo-packages.ts +1 -4
  13. package/scripts/stage-apt-repo.ts +1 -1
  14. package/scripts/stage-libsignal.ts +1 -3
  15. package/simulators/greenwave/server.ts +3 -1
  16. package/simulators/greenwave/state.ts +17 -10
  17. package/simulators/isitup/server.ts +1 -5
  18. package/simulators/namecheap-ddns/server.ts +2 -4
  19. package/simulators/namecheap-ddns/zone-updater.test.ts +3 -1
  20. package/simulators/namecheap-ddns/zone-updater.ts +6 -10
  21. package/simulators/signal-cli/server.ts +3 -1
  22. package/src/browser.ts +3 -3
  23. package/src/bus-events.ts +2 -2
  24. package/src/cli/command-tree-parser.ts +8 -2
  25. package/src/cli/completion.ts +5 -8
  26. package/src/cli/index.ts +9 -2
  27. package/src/cli/module-discovery.ts +3 -1
  28. package/src/cli-version-contract.ts +5 -6
  29. package/src/extract-failure.ts +92 -0
  30. package/src/fixtures.ts +4 -11
  31. package/src/network-builder.ts +12 -7
  32. package/src/observer.test.ts +42 -11
  33. package/src/observer.ts +12 -5
  34. package/src/registry-bundle.ts +1 -3
  35. package/src/run-lock.test.ts +2 -2
  36. package/src/run-lock.ts +19 -5
  37. package/src/runner.ts +9 -67
  38. package/src/shared-infra.ts +50 -22
  39. package/src/socks-proxy.ts +14 -17
  40. package/src/vantage-helpers.ts +5 -1
  41. package/src/vantage.test.ts +78 -24
  42. package/src/vantage.ts +24 -17
  43. package/src/wait-for-run.ts +1 -1
  44. package/src/zone-classifier.test.ts +7 -1
  45. package/src/zone-classifier.ts +2 -2
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Turns a failed test file's raw output into the one line a human reads first.
3
+ *
4
+ * Pure (Rule 10.1), and split out of runner.ts so it can be tested — runner.ts
5
+ * calls main() at import time, so nothing inside it is reachable from a test.
6
+ * Sibling of parse-line.ts / parse-line.test.ts.
7
+ */
8
+
9
+ // Strips ANSI escape sequences so pattern matching works on bun's coloured output
10
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
11
+ export function stripAnsi(s: string): string {
12
+ return s.replace(ANSI_RE, '');
13
+ }
14
+
15
+ export function extractFailureMessage(lines: string[], exitCode: number): string {
16
+ const plain = lines.map(stripAnsi);
17
+
18
+ // The FIRST failing test, in bun's "(fail) name [Xms]" format, wins over
19
+ // everything below. In a staged suite every later stage fails too — on
20
+ // purpose, via requireStage — so any rule that picks by content rather than
21
+ // by order reports a downstream symptom as the cause.
22
+ //
23
+ // That is not hypothetical. technitium-pipeline's stage 0 timed out; the
24
+ // reported failure was "Module 'namecheap' not found", from a stage that had
25
+ // only ever been reached because stage 0 died. Three people read that as a
26
+ // module-import bug. The line that actually mattered — "this test timed out
27
+ // after 120000ms" — was 60 lines up in output.log and in no summary at all.
28
+ //
29
+ // The "✗ name" branch below never fired there: bun prints "✗" only in its
30
+ // per-file summary, and prints "(fail)" as each test completes.
31
+ const failIdx = plain.findIndex((l) => /^\(fail\)\s+\S/.test(l));
32
+ if (failIdx >= 0) {
33
+ const testName = plain[failIdx]
34
+ .replace(/^\(fail\)\s+/, '')
35
+ .replace(/\s*\[[\d.]+ms\].*$/, '')
36
+ .trim();
37
+ // bun puts the reason on the next line, indented and marked with "^".
38
+ const reason = (plain[failIdx + 1] ?? '').trim().replace(/^\^\s*/, '');
39
+ return reason ? `${testName}: ${reason}` : testName;
40
+ }
41
+
42
+ // bun's own test failure: "✗ test name [Xms]" — extract the failing test name
43
+ const bunFailIdx = plain.findIndex((l) => /^\s*✗\s+\S/.test(l) && !l.includes('failed ('));
44
+ if (bunFailIdx >= 0) {
45
+ const testName = plain[bunFailIdx]
46
+ .replace(/^\s*✗\s+/, '')
47
+ .replace(/\s*\[\d+ms\].*$/, '')
48
+ .trim();
49
+ // Grab the error detail on the next few lines (bun indents it under the test)
50
+ const detail = plain
51
+ .slice(bunFailIdx + 1, bunFailIdx + 6)
52
+ .map((l) => l.trim())
53
+ .filter((l) => l.length > 0 && !l.startsWith('at ') && !l.startsWith('│') && l !== '└─')
54
+ .slice(0, 2)
55
+ .join(' — ');
56
+ return detail ? `${testName}: ${detail}` : testName;
57
+ }
58
+
59
+ // "error: ..." lines (may be indented in bun output)
60
+ const errorIdx = plain.findIndex(
61
+ (l) => l.trimStart().startsWith('error:') && !l.includes('bun test'),
62
+ );
63
+ if (errorIdx >= 0) {
64
+ const context = plain
65
+ .slice(errorIdx, errorIdx + 5)
66
+ .map((l) => l.trim())
67
+ .filter((l) => l.length > 0 && !l.startsWith('at '));
68
+ return context.join('\n ');
69
+ }
70
+
71
+ const celiloError = plain.find(
72
+ (l) => l.includes('CeliloCommandError') || (l.includes('celilo') && l.includes('failed')),
73
+ );
74
+ if (celiloError) return celiloError.slice(0, 120);
75
+
76
+ const timeout = plain.find((l) => l.includes('Timeout') || l.includes('timed out'));
77
+ if (timeout) return timeout.slice(0, 120);
78
+
79
+ // Last resort: print the last few non-empty, non-progress lines from the output
80
+ const tail = plain
81
+ .filter(
82
+ (l) =>
83
+ l.length > 0 &&
84
+ !l.startsWith('[progress') &&
85
+ !l.startsWith('[e2e:') &&
86
+ !l.startsWith('[ansible:'),
87
+ )
88
+ .slice(-3)
89
+ .map((l) => l.trim())
90
+ .join(' | ');
91
+ return tail || `exit code ${exitCode}`;
92
+ }
package/src/fixtures.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { NetworkBuilder, network } from './network-builder';
1
+ import { type NetworkBuilder, network } from './network-builder';
2
2
 
3
3
  /** Single caddy machine in the DMZ — basic web server deployment */
4
4
  export const CADDY_DEPLOYMENT = (): NetworkBuilder => network().dmz({ caddy: '10.226.10.10' });
@@ -15,9 +15,7 @@ export const INFRASTRUCTURE_ONLY = (): NetworkBuilder => network();
15
15
 
16
16
  /** Caddy + Authentik: web server in DMZ, identity provider in app zone */
17
17
  export const AUTHENTIK_DEPLOYMENT = (): NetworkBuilder =>
18
- network()
19
- .dmz({ caddy: '10.226.10.10' })
20
- .app({ authentik: '10.226.20.30' });
18
+ network().dmz({ caddy: '10.226.10.10' }).app({ authentik: '10.226.20.30' });
21
19
 
22
20
  /** Multiple DMZ machines for testing multi-service deployments */
23
21
  export const MULTI_DMZ = (): NetworkBuilder =>
@@ -28,13 +26,8 @@ export const MULTI_DMZ = (): NetworkBuilder =>
28
26
 
29
27
  /** Technitium DNS + Caddy: internal DNS resolver + full web stack */
30
28
  export const TECHNITIUM_STACK = (): NetworkBuilder =>
31
- network()
32
- .internal({ 'dns-int': '10.226.1.53' })
33
- .dmz({ caddy: '10.226.10.10' });
29
+ network().internal({ 'dns-int': '10.226.1.53' }).dmz({ caddy: '10.226.10.10' });
34
30
 
35
31
  /** Technitium DNS with DHCP client: tests DHCP DNS propagation */
36
32
  export const TECHNITIUM_DHCP_STACK = (): NetworkBuilder =>
37
- network()
38
- .internal({ 'dns-int': '10.226.1.53' })
39
- .dmz({ caddy: '10.226.10.10' })
40
- .withDhcpClient();
33
+ network().internal({ 'dns-int': '10.226.1.53' }).dmz({ caddy: '10.226.10.10' }).withDhcpClient();
@@ -2,7 +2,14 @@ import { execSync } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { startNetwork } from './container-manager';
5
- import type { MachineSpec, NetworkConfig, NetworkHandle, TopologyPreset, Vantage, Zone } from './types';
5
+ import type {
6
+ MachineSpec,
7
+ NetworkConfig,
8
+ NetworkHandle,
9
+ TopologyPreset,
10
+ Vantage,
11
+ Zone,
12
+ } from './types';
6
13
 
7
14
  /**
8
15
  * If a mounted path looks like a celilo module (has scripts/package.json),
@@ -10,10 +17,7 @@ import type { MachineSpec, NetworkConfig, NetworkHandle, TopologyPreset, Vantage
10
17
  */
11
18
  async function ensureModuleDeps(hostPath: string): Promise<void> {
12
19
  // Check common locations for a module's scripts dir
13
- for (const scriptsDir of [
14
- join(hostPath, 'scripts'),
15
- join(hostPath, 'celilo', 'scripts'),
16
- ]) {
20
+ for (const scriptsDir of [join(hostPath, 'scripts'), join(hostPath, 'celilo', 'scripts')]) {
17
21
  const pkgJson = join(scriptsDir, 'package.json');
18
22
  const nodeModules = join(scriptsDir, 'node_modules');
19
23
  if (existsSync(pkgJson) && !existsSync(nodeModules)) {
@@ -24,7 +28,7 @@ async function ensureModuleDeps(hostPath: string): Promise<void> {
24
28
  const msg = err instanceof Error ? err.message : String(err);
25
29
  throw new Error(
26
30
  `Failed to install module dependencies in ${scriptsDir}:\n${msg}\n\n` +
27
- `Fix: cd ${scriptsDir} && bun install`,
31
+ `Fix: cd ${scriptsDir} && bun install`,
28
32
  );
29
33
  }
30
34
  }
@@ -173,8 +177,9 @@ export class NetworkBuilder {
173
177
  * network().dmz({ caddy: '10.226.10.10' }).observe('internalDevice', 'publicInternet')
174
178
  */
175
179
  observe(...vantages: Vantage[]): this {
180
+ this.config.observers ??= [];
176
181
  for (const vantage of vantages) {
177
- (this.config.observers ??= []).push({ vantage });
182
+ this.config.observers.push({ vantage });
178
183
  }
179
184
  return this;
180
185
  }
@@ -1,8 +1,8 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
2
  import {
3
+ OBSERVER_PLACEMENTS,
3
4
  createObserverTransport,
4
5
  normalizeObservers,
5
- OBSERVER_PLACEMENTS,
6
6
  observerEnv,
7
7
  vantageContainer,
8
8
  } from './observer';
@@ -38,7 +38,9 @@ describe('placement faithfulness (the load-bearing invariants)', () => {
38
38
  test('segmented-zone observer IPs derive from their zone gateway (no pinned subnets)', () => {
39
39
  expect(OBSERVER_PLACEMENTS.dmzSystem.gateway).toBe(ZONE_GATEWAYS.dmz);
40
40
  expect(OBSERVER_PLACEMENTS.dmzSystem.ip).toBe(ZONE_GATEWAYS.dmz.replace(/\.\d+$/, '.240'));
41
- expect(OBSERVER_PLACEMENTS.secureSystem.ip).toBe(ZONE_GATEWAYS.secure.replace(/\.\d+$/, '.240'));
41
+ expect(OBSERVER_PLACEMENTS.secureSystem.ip).toBe(
42
+ ZONE_GATEWAYS.secure.replace(/\.\d+$/, '.240'),
43
+ );
42
44
  });
43
45
 
44
46
  test('publicInternet uses only the public resolver (never the internal split-horizon view)', () => {
@@ -55,7 +57,9 @@ describe('placement faithfulness (the load-bearing invariants)', () => {
55
57
 
56
58
  describe('normalizeObservers + vantageContainer', () => {
57
59
  test('dedupes and rejects management as an injectable observer', () => {
58
- expect(normalizeObservers([{ vantage: 'internalDevice' }, { vantage: 'internalDevice' }])).toHaveLength(1);
60
+ expect(
61
+ normalizeObservers([{ vantage: 'internalDevice' }, { vantage: 'internalDevice' }]),
62
+ ).toHaveLength(1);
59
63
  expect(() => normalizeObservers([{ vantage: 'management' }])).toThrow(/management/);
60
64
  });
61
65
  test('management vantage maps to the real management container; others to observers', () => {
@@ -70,15 +74,26 @@ describe('observer transport — parsing + container targeting', () => {
70
74
  cmd.startsWith('getent') ? { stdout: '10.226.1.253 www.iamtheinternet.org' } : {},
71
75
  );
72
76
  const t = createObserverTransport(handle);
73
- const r = await t.resolve({ from: 'internalDevice', name: 'www.iamtheinternet.org', method: 'system' });
77
+ const r = await t.resolve({
78
+ from: 'internalDevice',
79
+ name: 'www.iamtheinternet.org',
80
+ method: 'system',
81
+ });
74
82
  expect(r.ips).toEqual(['10.226.1.253']);
75
83
  expect(calls[0].container).toBe('observer-internal');
76
84
  });
77
85
 
78
86
  test('resolve(dns) with explicit server', async () => {
79
- const { handle, calls } = fakeHandle((_c, cmd) => (cmd.includes('dig') ? { stdout: '10.226.10.10\n' } : {}));
87
+ const { handle, calls } = fakeHandle((_c, cmd) =>
88
+ cmd.includes('dig') ? { stdout: '10.226.10.10\n' } : {},
89
+ );
80
90
  const t = createObserverTransport(handle);
81
- const r = await t.resolve({ from: 'dmzSystem', name: 'auth.x', method: 'dns', server: '10.226.1.10' });
91
+ const r = await t.resolve({
92
+ from: 'dmzSystem',
93
+ name: 'auth.x',
94
+ method: 'dns',
95
+ server: '10.226.1.10',
96
+ });
82
97
  expect(r.ips).toEqual(['10.226.10.10']);
83
98
  expect(calls[0].cmd).toContain('@10.226.1.10');
84
99
  });
@@ -88,15 +103,23 @@ describe('observer transport — parsing + container targeting', () => {
88
103
  cmd.startsWith('grep -F') ? { stdout: '10.226.10.10 auth.iamtheinternet.org' } : {},
89
104
  );
90
105
  const t = createObserverTransport(handle);
91
- const r = await t.resolve({ from: 'dmzSystem', name: 'auth.iamtheinternet.org', method: 'hostfiles' });
106
+ const r = await t.resolve({
107
+ from: 'dmzSystem',
108
+ name: 'auth.iamtheinternet.org',
109
+ method: 'hostfiles',
110
+ });
92
111
  expect(r.ips).toEqual(['10.226.10.10']);
93
112
  });
94
113
 
95
114
  test('tcpConnect maps the container exit code', async () => {
96
115
  const { handle } = fakeHandle((_c, cmd) => ({ exitCode: cmd.includes('443') ? 0 : 1 }));
97
116
  const t = createObserverTransport(handle);
98
- expect((await t.tcpConnect({ from: 'internalDevice', host: '10.226.1.253', port: 443 })).connected).toBe(true);
99
- expect((await t.tcpConnect({ from: 'internalDevice', host: '10.226.10.10', port: 22 })).connected).toBe(false);
117
+ expect(
118
+ (await t.tcpConnect({ from: 'internalDevice', host: '10.226.1.253', port: 443 })).connected,
119
+ ).toBe(true);
120
+ expect(
121
+ (await t.tcpConnect({ from: 'internalDevice', host: '10.226.10.10', port: 22 })).connected,
122
+ ).toBe(false);
100
123
  });
101
124
 
102
125
  test('httpsRequest parses cert (CN/issuer/SAN/selfSigned) + status', async () => {
@@ -113,7 +136,10 @@ describe('observer transport — parsing + container targeting', () => {
113
136
  return { stdout: 'Caddy reverse proxy is running' };
114
137
  });
115
138
  const t = createObserverTransport(handle);
116
- const r = await t.httpsRequest({ from: 'publicInternet', url: 'https://deathstar.iamtheinternet.org' });
139
+ const r = await t.httpsRequest({
140
+ from: 'publicInternet',
141
+ url: 'https://deathstar.iamtheinternet.org',
142
+ });
117
143
  expect(r.status).toBe(200);
118
144
  expect(r.cert.cn).toBe('deathstar.iamtheinternet.org');
119
145
  expect(r.cert.issuer).toContain('Pebble');
@@ -140,7 +166,12 @@ describe('host-shell footgun guard', () => {
140
166
  const { handle, calls } = fakeHandle(() => ({ stdout: '200', exitCode: 0 }));
141
167
  const t = createObserverTransport(handle);
142
168
  await t.resolve({ from: 'internalDevice', name: 'www.x', method: 'system' });
143
- await t.resolve({ from: 'internalDevice', name: 'www.x', method: 'dns', server: '10.226.1.10' });
169
+ await t.resolve({
170
+ from: 'internalDevice',
171
+ name: 'www.x',
172
+ method: 'dns',
173
+ server: '10.226.1.10',
174
+ });
144
175
  await t.resolve({ from: 'internalDevice', name: 'www.x', method: 'hostfiles' });
145
176
  await t.tcpConnect({ from: 'internalDevice', host: '10.226.10.10', port: 443 });
146
177
  await t.ping({ from: 'internalDevice', target: '10.226.1.253' });
package/src/observer.ts CHANGED
@@ -109,7 +109,9 @@ export const OBSERVER_PLACEMENTS: Record<Exclude<Vantage, 'management'>, Observe
109
109
  /** The placement for an injectable vantage; throws for `management` (not injectable). */
110
110
  export function observerPlacement(vantage: Vantage): ObserverPlacement {
111
111
  if (vantage === 'management') {
112
- throw new Error('management has no observer placement — it reuses the real management container.');
112
+ throw new Error(
113
+ 'management has no observer placement — it reuses the real management container.',
114
+ );
113
115
  }
114
116
  return OBSERVER_PLACEMENTS[vantage];
115
117
  }
@@ -209,7 +211,10 @@ export function createObserverTransport(handle: NetworkHandle): ProbeTransport {
209
211
  `timeout 4 bash -c 'exec 3<>/dev/tcp/${opts.host}/${opts.port}'`,
210
212
  8_000,
211
213
  );
212
- return { connected: res.exitCode === 0, detail: res.exitCode === 0 ? undefined : 'connect failed/timed out' };
214
+ return {
215
+ connected: res.exitCode === 0,
216
+ detail: res.exitCode === 0 ? undefined : 'connect failed/timed out',
217
+ };
213
218
  },
214
219
 
215
220
  async ping(opts) {
@@ -223,8 +228,7 @@ export function createObserverTransport(handle: NetworkHandle): ProbeTransport {
223
228
  const { host, port } = hostPort(opts.url);
224
229
  const certRes = await handle.exec(
225
230
  c,
226
- `echo | openssl s_client -connect ${host}:${port} -servername ${host} 2>/dev/null | ` +
227
- 'openssl x509 -noout -subject -issuer -ext subjectAltName',
231
+ `echo | openssl s_client -connect ${host}:${port} -servername ${host} 2>/dev/null | openssl x509 -noout -subject -issuer -ext subjectAltName`,
228
232
  10_000,
229
233
  );
230
234
  // -k gathers the status even for an untrusted chain; the cert is reported truthfully
@@ -249,7 +253,10 @@ export function createObserverTransport(handle: NetworkHandle): ProbeTransport {
249
253
  const hosts = await handle.exec(c, `grep -F ${opts.name} /etc/hosts`, 5_000);
250
254
  return {
251
255
  resolvConf: resolv.stdout.trim(),
252
- hostsLines: hosts.stdout.split('\n').map((l) => l.trim()).filter(Boolean),
256
+ hostsLines: hosts.stdout
257
+ .split('\n')
258
+ .map((l) => l.trim())
259
+ .filter(Boolean),
253
260
  };
254
261
  },
255
262
  };
@@ -39,9 +39,7 @@ export function ensureRegistryServerBundle(pkgDir: string): void {
39
39
  if (!existsSync(upstream)) {
40
40
  if (!existsSync(bundle)) {
41
41
  throw new Error(
42
- `cele2e: ${bundle} is missing and ../registry-server doesn't exist either.\n` +
43
- 'The @celilo/e2e package was published without its registry-server bundle. ' +
44
- 'Re-publish from infra/scripts/publish.ts so the bundle is recreated.',
42
+ `cele2e: ${bundle} is missing and ../registry-server doesn't exist either.\nThe @celilo/e2e package was published without its registry-server bundle. Re-publish from infra/scripts/publish.ts so the bundle is recreated.`,
45
43
  );
46
44
  }
47
45
  return; // npm-installed; bundle ships in tarball
@@ -3,10 +3,10 @@ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { hostname, tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import {
6
- acquireRunLock,
7
- clearLock,
8
6
  E2eBusyError,
9
7
  type LockHolder,
8
+ acquireRunLock,
9
+ clearLock,
10
10
  lockStatus,
11
11
  markKept,
12
12
  readHolder,
package/src/run-lock.ts CHANGED
@@ -24,7 +24,7 @@
24
24
  */
25
25
 
26
26
  import { execFileSync } from 'node:child_process';
27
- import { mkdirSync, openSync, closeSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
27
+ import { closeSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
28
28
  import { homedir, hostname } from 'node:os';
29
29
  import { basename, dirname, join } from 'node:path';
30
30
 
@@ -71,7 +71,10 @@ export class E2eBusyError extends Error {
71
71
  export function formatBusy(h: LockHolder): string {
72
72
  const age = ageString(Date.parse(h.startedAt));
73
73
  const verb = h.state === 'kept' ? 'holds a kept stack from' : 'running';
74
- const what = h.state === 'kept' ? `(run \`cele2e release\` to free it)` : `${h.test}, started ${age} ago (pid ${h.pid})`;
74
+ const what =
75
+ h.state === 'kept'
76
+ ? '(run `cele2e release` to free it)'
77
+ : `${h.test}, started ${age} ago (pid ${h.pid})`;
75
78
  return `e2e busy: ${h.session} ${verb} ${what}`;
76
79
  }
77
80
 
@@ -88,7 +91,11 @@ function deriveSession(): string {
88
91
  const cwd = process.cwd();
89
92
  const git = (args: string[]): string => {
90
93
  try {
91
- return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
94
+ return execFileSync('git', args, {
95
+ cwd,
96
+ encoding: 'utf-8',
97
+ stdio: ['ignore', 'pipe', 'ignore'],
98
+ }).trim();
92
99
  } catch {
93
100
  return '';
94
101
  }
@@ -164,7 +171,9 @@ export function acquireRunLock(opts: { test: string; runId: string; allowKept?:
164
171
  // Reclaim when: corrupt/unreadable, the holder is stale, OR this is a
165
172
  // `--reuse` run taking over a `kept` stack it's deliberately reusing.
166
173
  if (!existing || isStale(existing) || (opts.allowKept && existing.state === 'kept')) {
167
- try { unlinkSync(lockPath()); } catch {}
174
+ try {
175
+ unlinkSync(lockPath());
176
+ } catch {}
168
177
  continue;
169
178
  }
170
179
  throw new E2eBusyError(existing);
@@ -224,7 +233,12 @@ export function releaseRunLock(): void {
224
233
  if (held.keepOnRelease) {
225
234
  const cur = readHolder();
226
235
  if (cur && cur.pid === process.pid) {
227
- try { writeFileSync(lockPath(), JSON.stringify({ ...cur, state: 'kept', beatAt: Date.now() }, null, 2)); } catch {}
236
+ try {
237
+ writeFileSync(
238
+ lockPath(),
239
+ JSON.stringify({ ...cur, state: 'kept', beatAt: Date.now() }, null, 2),
240
+ );
241
+ } catch {}
228
242
  }
229
243
  return;
230
244
  }
package/src/runner.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  emitTestCompleted,
30
30
  emitTestStarted,
31
31
  } from './bus-events';
32
+ import { extractFailureMessage, stripAnsi } from './extract-failure';
32
33
  import { parseLine } from './parse-line';
33
34
  import { E2eBusyError, acquireRunLock, markKept } from './run-lock';
34
35
  import { SIMULATOR_IPS } from './simulator-ips';
@@ -176,7 +177,7 @@ function loadTiming(): TimingHistory {
176
177
  }
177
178
 
178
179
  function saveTiming(history: TimingHistory): void {
179
- writeFileSync(timingFile, JSON.stringify(history, Object.keys(history).sort(), 2) + '\n');
180
+ writeFileSync(timingFile, `${JSON.stringify(history, Object.keys(history).sort(), 2)}\n`);
180
181
  }
181
182
 
182
183
  function formatDuration(secs: number): string {
@@ -185,67 +186,8 @@ function formatDuration(secs: number): string {
185
186
  }
186
187
 
187
188
  // ─── Error Extraction ────────────────────────────────────────────────
188
-
189
- // Strips ANSI escape sequences so pattern matching works on bun's coloured output
190
- const ANSI_RE = /\x1b\[[0-9;]*m/g;
191
- function stripAnsi(s: string): string {
192
- return s.replace(ANSI_RE, '');
193
- }
194
-
195
- function extractFailureMessage(lines: string[], exitCode: number): string {
196
- const plain = lines.map(stripAnsi);
197
-
198
- // bun's own test failure: "✗ test name [Xms]" — extract the failing test name
199
- const bunFailIdx = plain.findIndex((l) => /^\s*✗\s+\S/.test(l) && !l.includes('failed ('));
200
- if (bunFailIdx >= 0) {
201
- const testName = plain[bunFailIdx]
202
- .replace(/^\s*✗\s+/, '')
203
- .replace(/\s*\[\d+ms\].*$/, '')
204
- .trim();
205
- // Grab the error detail on the next few lines (bun indents it under the test)
206
- const detail = plain
207
- .slice(bunFailIdx + 1, bunFailIdx + 6)
208
- .map((l) => l.trim())
209
- .filter((l) => l.length > 0 && !l.startsWith('at ') && !l.startsWith('│') && l !== '└─')
210
- .slice(0, 2)
211
- .join(' — ');
212
- return detail ? `${testName}: ${detail}` : testName;
213
- }
214
-
215
- // "error: ..." lines (may be indented in bun output)
216
- const errorIdx = plain.findIndex(
217
- (l) => l.trimStart().startsWith('error:') && !l.includes('bun test'),
218
- );
219
- if (errorIdx >= 0) {
220
- const context = plain
221
- .slice(errorIdx, errorIdx + 5)
222
- .map((l) => l.trim())
223
- .filter((l) => l.length > 0 && !l.startsWith('at '));
224
- return context.join('\n ');
225
- }
226
-
227
- const celiloError = plain.find(
228
- (l) => l.includes('CeliloCommandError') || (l.includes('celilo') && l.includes('failed')),
229
- );
230
- if (celiloError) return celiloError.slice(0, 120);
231
-
232
- const timeout = plain.find((l) => l.includes('Timeout') || l.includes('timed out'));
233
- if (timeout) return timeout.slice(0, 120);
234
-
235
- // Last resort: print the last few non-empty, non-progress lines from the output
236
- const tail = plain
237
- .filter(
238
- (l) =>
239
- l.length > 0 &&
240
- !l.startsWith('[progress') &&
241
- !l.startsWith('[e2e:') &&
242
- !l.startsWith('[ansible:'),
243
- )
244
- .slice(-3)
245
- .map((l) => l.trim())
246
- .join(' | ');
247
- return tail || `exit code ${exitCode}`;
248
- }
189
+ // extractFailureMessage / stripAnsi live in extract-failure.ts: this file
190
+ // calls main() at import time, so nothing declared here is testable.
249
191
 
250
192
  // ─── Test Execution ──────────────────────────────────────────────────
251
193
 
@@ -409,7 +351,7 @@ async function runTest(
409
351
  display.abandonFailed();
410
352
  }
411
353
 
412
- writeFileSync(logFile, stdout + '\n---STDERR---\n' + stderr);
354
+ writeFileSync(logFile, `${stdout}\n---STDERR---\n${stderr}`);
413
355
 
414
356
  const expectMatch = stdout.match(/(\d+) expect\(\) calls/);
415
357
  const expectCount = expectMatch ? Number.parseInt(expectMatch[1], 10) : undefined;
@@ -816,7 +758,7 @@ async function main() {
816
758
  if (flagKeep && result.projectName) {
817
759
  writeFileSync(
818
760
  persistentFile,
819
- JSON.stringify(
761
+ `${JSON.stringify(
820
762
  {
821
763
  projectName: result.projectName,
822
764
  testFile: file,
@@ -824,7 +766,7 @@ async function main() {
824
766
  },
825
767
  null,
826
768
  2,
827
- ) + '\n',
769
+ )}\n`,
828
770
  );
829
771
  console.log(`${dim} Persistent network: ${result.projectName}${reset}`);
830
772
  console.log(`${dim} Reuse: ${basename(process.argv[1])} --reuse${reset}`);
@@ -886,7 +828,7 @@ async function main() {
886
828
  const icon = r.status === 'pass' ? '✓' : '✗';
887
829
  const color = r.status === 'pass' ? green : red;
888
830
  console.log(
889
- `${bold}║${reset} ${color}${icon}${reset} ${r.name.padEnd(28)} ${dim}${(r.duration + 's').padStart(6)}${reset}`,
831
+ `${bold}║${reset} ${color}${icon}${reset} ${r.name.padEnd(28)} ${dim}${(`${r.duration}s`).padStart(6)}${reset}`,
890
832
  );
891
833
  if (r.error) {
892
834
  console.log(`${bold}║${reset} ${red}└─ ${r.error}${reset}`);
@@ -911,7 +853,7 @@ async function main() {
911
853
  const adds: string[] = [];
912
854
  if (!content.includes('results/')) adds.push('results/');
913
855
  if (!content.includes('.e2e-timing.json')) adds.push('.e2e-timing.json');
914
- if (adds.length) writeFileSync(gitignore, content.trimEnd() + '\n' + adds.join('\n') + '\n');
856
+ if (adds.length) writeFileSync(gitignore, `${content.trimEnd()}\n${adds.join('\n')}\n`);
915
857
 
916
858
  emitRunCompleted({
917
859
  runId,