@celilo/e2e 0.11.0 → 0.11.2
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/npm-registry-server/src/packument.test.ts +2 -5
- package/npm-registry-server/src/packument.ts +3 -3
- package/npm-registry-server/src/server.ts +1 -1
- package/package.json +3 -3
- package/registry-server/src/bootstrap.ts +1 -1
- package/registry-server/src/introspection.test.ts +8 -12
- package/registry-server/src/server.test.ts +2 -2
- package/registry-server/src/server.ts +1 -1
- package/registry-server/src/storage.test.ts +8 -4
- package/registry-server/src/storage.ts +1 -1
- package/registry-server/src/validation.ts +2 -4
- package/scripts/consumer-paths-changed.ts +83 -0
- package/scripts/pack-celilo-packages.ts +1 -4
- package/scripts/stage-apt-repo.ts +1 -1
- package/scripts/stage-libsignal.ts +1 -3
- package/simulators/greenwave/server.ts +3 -1
- package/simulators/greenwave/state.ts +17 -10
- package/simulators/isitup/server.ts +1 -5
- package/simulators/namecheap-ddns/server.ts +2 -4
- package/simulators/namecheap-ddns/zone-updater.test.ts +3 -1
- package/simulators/namecheap-ddns/zone-updater.ts +6 -10
- package/simulators/signal-cli/server.ts +3 -1
- package/src/browser.ts +3 -3
- package/src/bus-events.ts +2 -2
- package/src/cli/command-tree-parser.ts +8 -2
- package/src/cli/completion.ts +5 -8
- package/src/cli/index.ts +9 -2
- package/src/cli/module-discovery.ts +3 -1
- package/src/cli-version-contract.ts +5 -6
- package/src/extract-failure.ts +92 -0
- package/src/fixtures.ts +4 -11
- package/src/network-builder.ts +12 -7
- package/src/observer.test.ts +42 -11
- package/src/observer.ts +12 -5
- package/src/registry-bundle.ts +1 -3
- package/src/run-lock.test.ts +2 -2
- package/src/run-lock.ts +19 -5
- package/src/runner.ts +9 -67
- package/src/shared-infra.ts +50 -22
- package/src/socks-proxy.ts +14 -17
- package/src/vantage-helpers.ts +5 -1
- package/src/vantage.test.ts +78 -24
- package/src/vantage.ts +24 -17
- package/src/wait-for-run.ts +1 -1
- package/src/zone-classifier.test.ts +7 -1
- package/src/zone-classifier.ts +2 -2
|
@@ -17,7 +17,10 @@ export const MIN_CLI_VERSION = '0.9.0';
|
|
|
17
17
|
* it doesn't, or `null` when OK. No I/O, so it's unit-testable without a
|
|
18
18
|
* running management container.
|
|
19
19
|
*/
|
|
20
|
-
export function checkCliVersion(
|
|
20
|
+
export function checkCliVersion(
|
|
21
|
+
versionStdout: string,
|
|
22
|
+
minVersion = MIN_CLI_VERSION,
|
|
23
|
+
): string | null {
|
|
21
24
|
// Output is `celilo <version>` (apps/celilo displayVersion). Grab the token.
|
|
22
25
|
const version = versionStdout.match(/celilo\s+(\S+)/)?.[1];
|
|
23
26
|
if (!version) {
|
|
@@ -27,11 +30,7 @@ export function checkCliVersion(versionStdout: string, minVersion = MIN_CLI_VERS
|
|
|
27
30
|
);
|
|
28
31
|
}
|
|
29
32
|
if (Bun.semver.order(version, minVersion) < 0) {
|
|
30
|
-
return (
|
|
31
|
-
`Management image has celilo ${version}, harness requires >=${minVersion}. ` +
|
|
32
|
-
`Rebuild the management image against a newer @celilo/cli ` +
|
|
33
|
-
`(e.g. \`cele2e build-infra\`, or \`--published\` after publishing).`
|
|
34
|
-
);
|
|
33
|
+
return `Management image has celilo ${version}, harness requires >=${minVersion}. Rebuild the management image against a newer @celilo/cli (e.g. \`cele2e build-infra\`, or \`--published\` after publishing).`;
|
|
35
34
|
}
|
|
36
35
|
return null;
|
|
37
36
|
}
|
|
@@ -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();
|
package/src/network-builder.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
182
|
+
this.config.observers.push({ vantage });
|
|
178
183
|
}
|
|
179
184
|
return this;
|
|
180
185
|
}
|
package/src/observer.test.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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({
|
|
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) =>
|
|
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({
|
|
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({
|
|
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(
|
|
99
|
-
|
|
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({
|
|
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({
|
|
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(
|
|
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 {
|
|
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
|
|
256
|
+
hostsLines: hosts.stdout
|
|
257
|
+
.split('\n')
|
|
258
|
+
.map((l) => l.trim())
|
|
259
|
+
.filter(Boolean),
|
|
253
260
|
};
|
|
254
261
|
},
|
|
255
262
|
};
|
package/src/registry-bundle.ts
CHANGED
|
@@ -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.\
|
|
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
|
package/src/run-lock.test.ts
CHANGED
|
@@ -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,
|
|
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 =
|
|
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, {
|
|
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 {
|
|
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 {
|
|
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)
|
|
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
|
-
//
|
|
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
|
|
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
|
-
)
|
|
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
|
|
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()
|
|
856
|
+
if (adds.length) writeFileSync(gitignore, `${content.trimEnd()}\n${adds.join('\n')}\n`);
|
|
915
857
|
|
|
916
858
|
emitRunCompleted({
|
|
917
859
|
runId,
|