@volter/twin-world 0.1.0 → 0.1.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.
@@ -0,0 +1,208 @@
1
+ // Shared setup for the runtime test files. Extracted so runtime.test.ts could be split by
2
+ // concern: 25 sequential tests in one process floored the gate at ~24s, and the gate shards
3
+ // per FILE. Every helper lives here — including the five that used to sit BETWEEN tests,
4
+ // whose loss broke the first attempt at this split.
5
+ import { afterEach, expect, spyOn } from 'bun:test';
6
+ import { existsSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import net from 'node:net';
10
+ import { spawnSync } from 'node:child_process';
11
+ import { activateScript, doctorWorld, downWorld, ensureWorldProxy, runWithWorldEnv, runWorld, shareWorld, shareWorldServices, statusWorld, unshareWorld, upWorld, urlsWorld, worldShellEnv } from './runtime.ts';
12
+ import { opensslAvailable } from './redirect-proxy.ts';
13
+ import { stateDirName } from '@volter/twin';
14
+
15
+ /** Test-only seam (see runtime.ts `proxyDaemonDeadlineMs`): force the daemon-poll deadline to `ms`
16
+ * for the duration of `fn`, then restore whatever was there before — deterministic, no sleeps. */
17
+ export async function withProxyDeadline<T>(ms: number, fn: () => Promise<T> | T): Promise<T> {
18
+ const prev = process.env.VOLTER_PROXY_DEADLINE_MS;
19
+ process.env.VOLTER_PROXY_DEADLINE_MS = String(ms);
20
+ try {
21
+ return await fn();
22
+ } finally {
23
+ if (prev === undefined) delete process.env.VOLTER_PROXY_DEADLINE_MS;
24
+ else process.env.VOLTER_PROXY_DEADLINE_MS = prev;
25
+ }
26
+ }
27
+
28
+ /** Best-effort cleanup for the forced-timeout tests below: with the deadline forced to 0, the
29
+ * detached proxy-daemon child is still spawned and may go on to write `proxy.json` a little later
30
+ * (asynchronously, after our synchronous poll already gave up) — kill it so it doesn't linger as an
31
+ * orphan process once the test's tmp root is discarded. Never gates test pass/fail. */
32
+ export async function reapLeakedProxyDaemon(root: string, name: string): Promise<void> {
33
+ const proxyJsonPath = join(root, stateDirName(), 'worlds', name, 'proxy.json');
34
+ const deadline = Date.now() + 3000;
35
+ while (Date.now() < deadline) {
36
+ if (existsSync(proxyJsonPath)) {
37
+ try {
38
+ const state = JSON.parse(readFileSync(proxyJsonPath, 'utf8')) as { pid?: number };
39
+ if (state.pid) { try { process.kill(state.pid, 'SIGTERM'); } catch { /* already gone */ } }
40
+ } catch { /* ignore parse races */ }
41
+ return;
42
+ }
43
+ await new Promise((r) => setTimeout(r, 100));
44
+ }
45
+ }
46
+
47
+ /** A hostname under the IANA-reserved `.invalid` TLD (RFC 2606) — guaranteed to NEVER resolve, on
48
+ * any network, forever. Used as the "untwinned public host" below: it is unambiguously non-local
49
+ * (so `isLocalOrPrivateHost` never exempts it) and unambiguously not a vendor (so it is never
50
+ * redirected to a twin), and a real (non-strict-egress) attempt to reach it fails FAST on DNS
51
+ * resolution — offline and deterministic, no dependency on real internet access. */
52
+ export const UNTWINNED_HOST = 'definitely-untwinned.invalid';
53
+
54
+ /** Drive a raw CONNECT through the ambient redirect proxy and collect whatever bytes come back
55
+ * before the socket closes (or `timeoutMs` elapses, as a safety net — a blind-tunnel attempt to an
56
+ * unresolvable host closes with NO data at all, so this never actually waits the full timeout). */
57
+ export function connectThroughProxy(port: number, host: string, targetHost: string, targetPort = 443, timeoutMs = 3000): Promise<string> {
58
+ return new Promise((resolvePromise, reject) => {
59
+ const sock = net.connect(port, host, () => {
60
+ sock.write(`CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n\r\n`);
61
+ });
62
+ let data = '';
63
+ const timer = setTimeout(() => { sock.destroy(); resolvePromise(data); }, timeoutMs);
64
+ sock.on('data', (chunk: Buffer) => { data += chunk.toString('utf8'); });
65
+ sock.on('close', () => { clearTimeout(timer); resolvePromise(data); });
66
+ sock.on('error', (err) => { clearTimeout(timer); reject(err); });
67
+ });
68
+ }
69
+
70
+ /** Force `opensslAvailable()` to false via its TEST-ONLY seam (see `redirect-proxy.ts`). A
71
+ * PATH-shim (fake `openssl` binary prepended to PATH) was tried first and does NOT reliably work
72
+ * here: `opensslAvailable()`'s `spawnSync('openssl', …)` call passes no explicit `env`, and this
73
+ * runtime resolves the executable against a PATH snapshotted at process start rather than a
74
+ * same-process `process.env.PATH` mutation made mid-test (verified: a fresh child process DOES
75
+ * see a shimmed PATH, but mutating PATH in the already-running test process does not change what
76
+ * THIS process's own `spawnSync` resolves) — so the deterministic, seam-based override is used. */
77
+ export function withNoOpenssl(): { restore: () => void } {
78
+ const previous = process.env.VOLTER_TEST_NO_OPENSSL;
79
+ process.env.VOLTER_TEST_NO_OPENSSL = '1';
80
+ return {
81
+ restore: () => {
82
+ if (previous === undefined) delete process.env.VOLTER_TEST_NO_OPENSSL;
83
+ else process.env.VOLTER_TEST_NO_OPENSSL = previous;
84
+ },
85
+ };
86
+ }
87
+
88
+ export const worlds: Array<{ root: string; name: string }> = [];
89
+
90
+ /**
91
+ * Register the teardown hook for the CALLING test file. Every file that pushes to `worlds` must
92
+ * call this at top level.
93
+ *
94
+ * It has to be a function call rather than a module-scope `afterEach`, and that distinction was an
95
+ * eight-file process leak. A hook registered while this module's body runs belongs to whichever
96
+ * file happened to import it FIRST; module caching means the body never runs again, so every other
97
+ * file got no hook at all. Each file individually looked clean — it was the first importer — and
98
+ * only a whole-directory run leaked, which is exactly the shape that reads as flakiness. The
99
+ * stray proxy daemons and tunnel stubs then held ports until unrelated HTTP tests started failing.
100
+ *
101
+ * Teardown also settles ALL worlds before reporting: the list is spliced empty up front, so a bare
102
+ * `for … await` loop that rejected on the first world abandoned the rest with nothing left holding
103
+ * a reference to them. Nothing about a world that failed to stop makes the next one less important
104
+ * to stop.
105
+ */
106
+ export function useWorldCleanup(): void {
107
+ afterEach(async () => {
108
+ const pending = worlds.splice(0);
109
+ const failures = await Promise.allSettled(pending.map((w) => downWorld(w.name, w.root)));
110
+ const errors = failures.filter((f): f is PromiseRejectedResult => f.status === 'rejected');
111
+ // Reported, never swallowed: a world that cannot be torn down is a real finding about the
112
+ // runtime, and silently ignoring it is how this stayed invisible for so long.
113
+ if (errors.length) {
114
+ throw new Error(`world teardown failed for ${errors.length}/${pending.length} world(s): ${errors.map((e) => String(e.reason)).join('; ')}`);
115
+ }
116
+ });
117
+ }
118
+
119
+
120
+ export function writeRaceConfig(root: string, id: string): void {
121
+ mkdirSync(join(root, 'worlds', 'configs'), { recursive: true });
122
+ writeFileSync(join(root, 'worlds', 'configs', `${id}.json`), JSON.stringify({
123
+ id,
124
+ services: [
125
+ {
126
+ id: 'app',
127
+ type: 'process',
128
+ command: 'node',
129
+ args: ['-e', "require('node:http').createServer((_req,res)=>res.end('ok')).listen(Number(process.env.PORT),'127.0.0.1')"],
130
+ env: { NODE_OPTIONS: '' },
131
+ portArg: false,
132
+ rootArg: false,
133
+ },
134
+ ],
135
+ }));
136
+ }
137
+
138
+ /** Bounded wait (no fixed sleeps) for SIGTERM'd services to actually exit after downWorld. */
139
+ export async function waitForWorldDown(name: string, root: string): Promise<boolean> {
140
+ const deadline = Date.now() + 5_000;
141
+ while (Date.now() < deadline) {
142
+ if (!statusWorld(name, root).running) return true;
143
+ await new Promise((r) => setTimeout(r, 50));
144
+ }
145
+ return !statusWorld(name, root).running;
146
+ }
147
+
148
+ export function pidAlive(pid: number): boolean {
149
+ try {
150
+ process.kill(pid, 0);
151
+ return true;
152
+ } catch {
153
+ return false;
154
+ }
155
+ }
156
+
157
+ // --- TWIN-60: `--mode sealed` must actually ENFORCE strict egress (VOLTER_TWIN_STRICT_EGRESS),
158
+ // not just record intent (VOLTER_WORLD_SEALED) that nothing consumes. One vendor twin (s3) is
159
+ // enough to exercise both enforcement points: the Node injector (control-plane/inject.cjs, HTTP
160
+ // path) and the ambient TLS redirect proxy (redirect-proxy.ts, CONNECT path).
161
+ export function writeSingleTwinConfig(root: string, id: string): void {
162
+ mkdirSync(join(root, 'worlds', 'configs'), { recursive: true });
163
+ writeFileSync(join(root, 'worlds', 'configs', `${id}.json`), JSON.stringify({
164
+ id,
165
+ services: [
166
+ {
167
+ id: 's3',
168
+ command: 'node',
169
+ args: ['-e', "require('node:http').createServer((_req,res)=>res.end('s3')).listen(Number(process.env.PORT),'127.0.0.1')"],
170
+ env: { NODE_OPTIONS: '' },
171
+ injectEnv: 'S3_TWIN_URL',
172
+ portArg: false,
173
+ rootArg: false,
174
+ },
175
+ ],
176
+ }));
177
+ }
178
+
179
+ /** Twin + a second (non-twin) service — the proxy-attempt loop tries the ambient proxy again
180
+ * before starting each subsequent service, so this reaches the ambient proxy regardless of
181
+ * whether the single/last-service re-evaluation (TWIN-64) has landed yet — kept independent of
182
+ * that fix so this CONNECT-level test exercises TWIN-60's wiring in isolation. */
183
+ export function writeTwinPlusAppConfig(root: string, id: string): void {
184
+ mkdirSync(join(root, 'worlds', 'configs'), { recursive: true });
185
+ writeFileSync(join(root, 'worlds', 'configs', `${id}.json`), JSON.stringify({
186
+ id,
187
+ services: [
188
+ {
189
+ id: 's3',
190
+ command: 'node',
191
+ args: ['-e', "require('node:http').createServer((_req,res)=>res.end('s3')).listen(Number(process.env.PORT),'127.0.0.1')"],
192
+ env: { NODE_OPTIONS: '' },
193
+ injectEnv: 'S3_TWIN_URL',
194
+ portArg: false,
195
+ rootArg: false,
196
+ },
197
+ {
198
+ id: 'app',
199
+ type: 'process',
200
+ command: 'node',
201
+ args: ['-e', "require('node:http').createServer((_req,res)=>res.end('ok')).listen(Number(process.env.PORT),'127.0.0.1')"],
202
+ env: { NODE_OPTIONS: '' },
203
+ portArg: false,
204
+ rootArg: false,
205
+ },
206
+ ],
207
+ }));
208
+ }