@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.
- package/known-external-services.json +1192 -0
- package/package.json +12 -5
- package/src/app-url.ts +175 -0
- package/src/attach.ts +113 -0
- package/src/browser-proxy-cli.ts +2 -1
- package/src/changeset.ts +416 -0
- package/src/cli.ts +589 -10
- package/src/covers.ts +724 -0
- package/src/fixture-env.ts +207 -0
- package/src/host-cli.ts +2 -1
- package/src/host-worker.ts +4 -3
- package/src/host.ts +26 -4
- package/src/index.ts +97 -1
- package/src/init.ts +1142 -0
- package/src/inject-map.ts +70 -0
- package/src/managed-infra-cli.ts +208 -0
- package/src/pack-facts.ts +136 -0
- package/src/pglite-backing.ts +125 -0
- package/src/pglite-host.mjs +147 -0
- package/src/prerequisites.ts +15 -58
- package/src/project-inspect.ts +683 -0
- package/src/redirect-proxy.ts +109 -85
- package/src/reflect.ts +443 -0
- package/src/resource-holder.ts +11 -0
- package/src/resources.ts +160 -0
- package/src/runtime-test-support.ts +208 -0
- package/src/runtime.ts +667 -106
- package/src/schema.ts +67 -0
- package/src/serve.ts +102 -0
- package/src/tail.ts +203 -0
|
@@ -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
|
+
}
|