@desplega.ai/qa-use 2.14.0 → 2.15.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 +23 -0
- package/dist/lib/env/index.d.ts +13 -0
- package/dist/lib/env/index.d.ts.map +1 -1
- package/dist/lib/env/index.js +35 -0
- package/dist/lib/env/index.js.map +1 -1
- package/dist/lib/env/localhost.d.ts +22 -0
- package/dist/lib/env/localhost.d.ts.map +1 -0
- package/dist/lib/env/localhost.js +49 -0
- package/dist/lib/env/localhost.js.map +1 -0
- package/dist/lib/env/paths.d.ts +27 -0
- package/dist/lib/env/paths.d.ts.map +1 -0
- package/dist/lib/env/paths.js +42 -0
- package/dist/lib/env/paths.js.map +1 -0
- package/dist/lib/env/sessions.d.ts +55 -0
- package/dist/lib/env/sessions.d.ts.map +1 -0
- package/dist/lib/env/sessions.js +128 -0
- package/dist/lib/env/sessions.js.map +1 -0
- package/dist/lib/tunnel/errors.d.ts +61 -0
- package/dist/lib/tunnel/errors.d.ts.map +1 -0
- package/dist/lib/tunnel/errors.js +152 -0
- package/dist/lib/tunnel/errors.js.map +1 -0
- package/dist/lib/tunnel/index.d.ts.map +1 -1
- package/dist/lib/tunnel/index.js +26 -11
- package/dist/lib/tunnel/index.js.map +1 -1
- package/dist/lib/tunnel/registry.d.ts +182 -0
- package/dist/lib/tunnel/registry.d.ts.map +1 -0
- package/dist/lib/tunnel/registry.js +561 -0
- package/dist/lib/tunnel/registry.js.map +1 -0
- package/dist/package.json +1 -1
- package/dist/src/cli/commands/browser/_detached.d.ts +27 -0
- package/dist/src/cli/commands/browser/_detached.d.ts.map +1 -0
- package/dist/src/cli/commands/browser/_detached.js +422 -0
- package/dist/src/cli/commands/browser/_detached.js.map +1 -0
- package/dist/src/cli/commands/browser/close.d.ts +7 -0
- package/dist/src/cli/commands/browser/close.d.ts.map +1 -1
- package/dist/src/cli/commands/browser/close.js +101 -5
- package/dist/src/cli/commands/browser/close.js.map +1 -1
- package/dist/src/cli/commands/browser/create.d.ts +7 -0
- package/dist/src/cli/commands/browser/create.d.ts.map +1 -1
- package/dist/src/cli/commands/browser/create.js +233 -25
- package/dist/src/cli/commands/browser/create.js.map +1 -1
- package/dist/src/cli/commands/browser/index.d.ts.map +1 -1
- package/dist/src/cli/commands/browser/index.js +3 -0
- package/dist/src/cli/commands/browser/index.js.map +1 -1
- package/dist/src/cli/commands/browser/run.d.ts.map +1 -1
- package/dist/src/cli/commands/browser/run.js +13 -6
- package/dist/src/cli/commands/browser/run.js.map +1 -1
- package/dist/src/cli/commands/browser/status.d.ts +4 -0
- package/dist/src/cli/commands/browser/status.d.ts.map +1 -1
- package/dist/src/cli/commands/browser/status.js +85 -3
- package/dist/src/cli/commands/browser/status.js.map +1 -1
- package/dist/src/cli/commands/doctor.d.ts +45 -0
- package/dist/src/cli/commands/doctor.d.ts.map +1 -0
- package/dist/src/cli/commands/doctor.js +267 -0
- package/dist/src/cli/commands/doctor.js.map +1 -0
- package/dist/src/cli/commands/test/run.d.ts.map +1 -1
- package/dist/src/cli/commands/test/run.js +29 -18
- package/dist/src/cli/commands/test/run.js.map +1 -1
- package/dist/src/cli/commands/tunnel/close.d.ts +18 -0
- package/dist/src/cli/commands/tunnel/close.d.ts.map +1 -0
- package/dist/src/cli/commands/tunnel/close.js +154 -0
- package/dist/src/cli/commands/tunnel/close.js.map +1 -0
- package/dist/src/cli/commands/tunnel/index.d.ts +6 -0
- package/dist/src/cli/commands/tunnel/index.d.ts.map +1 -0
- package/dist/src/cli/commands/tunnel/index.js +17 -0
- package/dist/src/cli/commands/tunnel/index.js.map +1 -0
- package/dist/src/cli/commands/tunnel/ls.d.ts +10 -0
- package/dist/src/cli/commands/tunnel/ls.d.ts.map +1 -0
- package/dist/src/cli/commands/tunnel/ls.js +89 -0
- package/dist/src/cli/commands/tunnel/ls.js.map +1 -0
- package/dist/src/cli/commands/tunnel/start.d.ts +15 -0
- package/dist/src/cli/commands/tunnel/start.d.ts.map +1 -0
- package/dist/src/cli/commands/tunnel/start.js +65 -0
- package/dist/src/cli/commands/tunnel/start.js.map +1 -0
- package/dist/src/cli/commands/tunnel/status.d.ts +8 -0
- package/dist/src/cli/commands/tunnel/status.d.ts.map +1 -0
- package/dist/src/cli/commands/tunnel/status.js +58 -0
- package/dist/src/cli/commands/tunnel/status.js.map +1 -0
- package/dist/src/cli/generated/docs-content.d.ts +1 -1
- package/dist/src/cli/generated/docs-content.d.ts.map +1 -1
- package/dist/src/cli/generated/docs-content.js +157 -100
- package/dist/src/cli/generated/docs-content.js.map +1 -1
- package/dist/src/cli/index.js +8 -0
- package/dist/src/cli/index.js.map +1 -1
- package/dist/src/cli/lib/browser.d.ts +25 -9
- package/dist/src/cli/lib/browser.d.ts.map +1 -1
- package/dist/src/cli/lib/browser.js +73 -42
- package/dist/src/cli/lib/browser.js.map +1 -1
- package/dist/src/cli/lib/cli-entry.d.ts +40 -0
- package/dist/src/cli/lib/cli-entry.d.ts.map +1 -0
- package/dist/src/cli/lib/cli-entry.js +65 -0
- package/dist/src/cli/lib/cli-entry.js.map +1 -0
- package/dist/src/cli/lib/config.d.ts.map +1 -1
- package/dist/src/cli/lib/config.js +8 -4
- package/dist/src/cli/lib/config.js.map +1 -1
- package/dist/src/cli/lib/startup-sweep.d.ts +45 -0
- package/dist/src/cli/lib/startup-sweep.d.ts.map +1 -0
- package/dist/src/cli/lib/startup-sweep.js +246 -0
- package/dist/src/cli/lib/startup-sweep.js.map +1 -0
- package/dist/src/cli/lib/tunnel-banner.d.ts +33 -0
- package/dist/src/cli/lib/tunnel-banner.d.ts.map +1 -0
- package/dist/src/cli/lib/tunnel-banner.js +55 -0
- package/dist/src/cli/lib/tunnel-banner.js.map +1 -0
- package/dist/src/cli/lib/tunnel-error-hint.d.ts +20 -0
- package/dist/src/cli/lib/tunnel-error-hint.d.ts.map +1 -0
- package/dist/src/cli/lib/tunnel-error-hint.js +48 -0
- package/dist/src/cli/lib/tunnel-error-hint.js.map +1 -0
- package/dist/src/cli/lib/tunnel-option.d.ts +27 -0
- package/dist/src/cli/lib/tunnel-option.d.ts.map +1 -0
- package/dist/src/cli/lib/tunnel-option.js +77 -0
- package/dist/src/cli/lib/tunnel-option.js.map +1 -0
- package/dist/src/cli/lib/tunnel-resolve.d.ts +42 -0
- package/dist/src/cli/lib/tunnel-resolve.d.ts.map +1 -0
- package/dist/src/cli/lib/tunnel-resolve.js +72 -0
- package/dist/src/cli/lib/tunnel-resolve.js.map +1 -0
- package/lib/env/index.ts +51 -0
- package/lib/env/localhost.test.ts +63 -0
- package/lib/env/localhost.ts +51 -0
- package/lib/env/paths.ts +46 -0
- package/lib/env/sessions.test.ts +109 -0
- package/lib/env/sessions.ts +155 -0
- package/lib/tunnel/errors.test.ts +105 -0
- package/lib/tunnel/errors.ts +169 -0
- package/lib/tunnel/index.ts +26 -11
- package/lib/tunnel/registry.test.ts +420 -0
- package/lib/tunnel/registry.ts +646 -0
- package/package.json +1 -1
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { TunnelQuotaError } from './errors.js';
|
|
6
|
+
import type { TunnelManager, TunnelSession } from './index.js';
|
|
7
|
+
import {
|
|
8
|
+
canonicalTarget,
|
|
9
|
+
type TunnelManagerFactory,
|
|
10
|
+
TunnelRegistry,
|
|
11
|
+
targetHash,
|
|
12
|
+
} from './registry.js';
|
|
13
|
+
|
|
14
|
+
function writeRecord(
|
|
15
|
+
dir: string,
|
|
16
|
+
record: {
|
|
17
|
+
id: string;
|
|
18
|
+
target: string;
|
|
19
|
+
publicUrl: string;
|
|
20
|
+
pid: number;
|
|
21
|
+
refcount: number;
|
|
22
|
+
ttlExpiresAt: number | null;
|
|
23
|
+
startedAt: number;
|
|
24
|
+
}
|
|
25
|
+
): string {
|
|
26
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
27
|
+
const file = path.join(dir, `${record.id}.json`);
|
|
28
|
+
fs.writeFileSync(file, JSON.stringify(record));
|
|
29
|
+
return file;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Minimal in-memory fake of `TunnelManager` for registry tests. We
|
|
34
|
+
* intentionally avoid mocking the real `TunnelManager` module because
|
|
35
|
+
* the registry ONLY needs `startTunnel` and `stopTunnel` to function.
|
|
36
|
+
*/
|
|
37
|
+
function makeFakeManagerFactory(publicUrlPrefix = 'https://fake-tunnel'): {
|
|
38
|
+
factory: TunnelManagerFactory;
|
|
39
|
+
stopCalls: { count: number };
|
|
40
|
+
} {
|
|
41
|
+
const stopCalls = { count: 0 };
|
|
42
|
+
let seq = 0;
|
|
43
|
+
const factory: TunnelManagerFactory = () => {
|
|
44
|
+
const id = ++seq;
|
|
45
|
+
const fakeTunnel = {
|
|
46
|
+
url: `${publicUrlPrefix}-${id}.example.com`,
|
|
47
|
+
on: () => {},
|
|
48
|
+
close: async () => {},
|
|
49
|
+
} as unknown as TunnelSession['tunnel'];
|
|
50
|
+
|
|
51
|
+
const session: TunnelSession = {
|
|
52
|
+
tunnel: fakeTunnel,
|
|
53
|
+
publicUrl: `${publicUrlPrefix}-${id}.example.com`,
|
|
54
|
+
localPort: 0,
|
|
55
|
+
isActive: true,
|
|
56
|
+
host: 'fake',
|
|
57
|
+
region: 'auto',
|
|
58
|
+
};
|
|
59
|
+
const fake = {
|
|
60
|
+
startTunnel: async (port: number) => {
|
|
61
|
+
session.localPort = port;
|
|
62
|
+
return session;
|
|
63
|
+
},
|
|
64
|
+
stopTunnel: async () => {
|
|
65
|
+
stopCalls.count += 1;
|
|
66
|
+
},
|
|
67
|
+
getSession: () => session,
|
|
68
|
+
isActive: () => true,
|
|
69
|
+
checkHealth: async () => true,
|
|
70
|
+
getPublicUrl: () => session.publicUrl,
|
|
71
|
+
getWebSocketUrl: () => null,
|
|
72
|
+
getPublicIP: async () => '127.0.0.1',
|
|
73
|
+
};
|
|
74
|
+
return fake as unknown as TunnelManager;
|
|
75
|
+
};
|
|
76
|
+
return { factory, stopCalls };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe('canonicalTarget', () => {
|
|
80
|
+
it('lowercases host and strips path/query', () => {
|
|
81
|
+
expect(canonicalTarget('http://Localhost:3000/foo?bar=1')).toBe('http://localhost:3000');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('treats differing paths as the same target', () => {
|
|
85
|
+
expect(canonicalTarget('http://localhost:3000/a')).toBe(
|
|
86
|
+
canonicalTarget('http://localhost:3000/b')
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('returns raw string on invalid URL', () => {
|
|
91
|
+
expect(canonicalTarget('not-a-url')).toBe('not-a-url');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('targetHash', () => {
|
|
96
|
+
it('is stable across invocations', () => {
|
|
97
|
+
expect(targetHash('http://localhost:3000')).toBe(targetHash('http://LOCALHOST:3000/x'));
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('returns a 10-char hex prefix', () => {
|
|
101
|
+
expect(targetHash('http://localhost:3000')).toMatch(/^[a-f0-9]{10}$/);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('TunnelRegistry', () => {
|
|
106
|
+
let tmpHome: string;
|
|
107
|
+
let originalHome: string | undefined;
|
|
108
|
+
|
|
109
|
+
beforeEach(() => {
|
|
110
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'qa-use-registry-'));
|
|
111
|
+
originalHome = process.env.QA_USE_HOME;
|
|
112
|
+
process.env.QA_USE_HOME = tmpHome;
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
afterEach(() => {
|
|
116
|
+
if (originalHome === undefined) {
|
|
117
|
+
delete process.env.QA_USE_HOME;
|
|
118
|
+
} else {
|
|
119
|
+
process.env.QA_USE_HOME = originalHome;
|
|
120
|
+
}
|
|
121
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('acquire starts a tunnel and persists a registry file', async () => {
|
|
125
|
+
const { factory } = makeFakeManagerFactory();
|
|
126
|
+
const registry = new TunnelRegistry({ managerFactory: factory, graceMs: 50 });
|
|
127
|
+
|
|
128
|
+
const handle = await registry.acquire('http://localhost:3000');
|
|
129
|
+
expect(handle.publicUrl).toContain('fake-tunnel');
|
|
130
|
+
expect(handle.refcount).toBe(1);
|
|
131
|
+
|
|
132
|
+
const file = path.join(tmpHome, 'tunnels', `${handle.id}.json`);
|
|
133
|
+
expect(fs.existsSync(file)).toBe(true);
|
|
134
|
+
const stored = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
135
|
+
expect(stored.target).toBe('http://localhost:3000');
|
|
136
|
+
expect(stored.refcount).toBe(1);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('two acquires for the same target share one tunnel (refcount=2)', async () => {
|
|
140
|
+
const { factory } = makeFakeManagerFactory();
|
|
141
|
+
const registry = new TunnelRegistry({ managerFactory: factory, graceMs: 50 });
|
|
142
|
+
|
|
143
|
+
const a = await registry.acquire('http://localhost:3000');
|
|
144
|
+
const b = await registry.acquire('http://localhost:3000');
|
|
145
|
+
|
|
146
|
+
expect(a.publicUrl).toBe(b.publicUrl);
|
|
147
|
+
const list = registry.list();
|
|
148
|
+
expect(list).toHaveLength(1);
|
|
149
|
+
expect(list[0].refcount).toBe(2);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('release decrements refcount without tearing down when others hold', async () => {
|
|
153
|
+
const { factory, stopCalls } = makeFakeManagerFactory();
|
|
154
|
+
const registry = new TunnelRegistry({ managerFactory: factory, graceMs: 50 });
|
|
155
|
+
|
|
156
|
+
const a = await registry.acquire('http://localhost:3000');
|
|
157
|
+
const b = await registry.acquire('http://localhost:3000');
|
|
158
|
+
await registry.release(a);
|
|
159
|
+
|
|
160
|
+
const list = registry.list();
|
|
161
|
+
expect(list).toHaveLength(1);
|
|
162
|
+
expect(list[0].refcount).toBe(1);
|
|
163
|
+
expect(stopCalls.count).toBe(0);
|
|
164
|
+
|
|
165
|
+
await registry.release(b);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('last release tears down after the grace window', async () => {
|
|
169
|
+
const { factory, stopCalls } = makeFakeManagerFactory();
|
|
170
|
+
const GRACE = 30;
|
|
171
|
+
const registry = new TunnelRegistry({ managerFactory: factory, graceMs: GRACE });
|
|
172
|
+
|
|
173
|
+
const h = await registry.acquire('http://localhost:3000');
|
|
174
|
+
await registry.release(h);
|
|
175
|
+
|
|
176
|
+
// During grace, the entry is still there with refcount 0 + ttl set.
|
|
177
|
+
const mid = registry.list();
|
|
178
|
+
expect(mid).toHaveLength(1);
|
|
179
|
+
expect(mid[0].refcount).toBe(0);
|
|
180
|
+
expect(mid[0].ttlExpiresAt).toBeGreaterThan(Date.now());
|
|
181
|
+
|
|
182
|
+
// Wait for grace to elapse.
|
|
183
|
+
await new Promise((r) => setTimeout(r, GRACE + 30));
|
|
184
|
+
|
|
185
|
+
expect(stopCalls.count).toBe(1);
|
|
186
|
+
expect(registry.list()).toHaveLength(0);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('acquire within grace window cancels tear-down', async () => {
|
|
190
|
+
const { factory, stopCalls } = makeFakeManagerFactory();
|
|
191
|
+
const GRACE = 60;
|
|
192
|
+
const registry = new TunnelRegistry({ managerFactory: factory, graceMs: GRACE });
|
|
193
|
+
|
|
194
|
+
const a = await registry.acquire('http://localhost:3000');
|
|
195
|
+
await registry.release(a);
|
|
196
|
+
|
|
197
|
+
// Re-acquire before grace elapses.
|
|
198
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
199
|
+
const b = await registry.acquire('http://localhost:3000');
|
|
200
|
+
expect(b.publicUrl).toBe(a.publicUrl);
|
|
201
|
+
|
|
202
|
+
// Wait past the original grace window.
|
|
203
|
+
await new Promise((r) => setTimeout(r, GRACE + 30));
|
|
204
|
+
|
|
205
|
+
expect(stopCalls.count).toBe(0);
|
|
206
|
+
expect(registry.list()).toHaveLength(1);
|
|
207
|
+
|
|
208
|
+
await registry.release(b);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('list reconciles stale PID file (dead pid)', async () => {
|
|
212
|
+
// Seed a file with a bogus pid directly.
|
|
213
|
+
const dir = path.join(tmpHome, 'tunnels');
|
|
214
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
215
|
+
const record = {
|
|
216
|
+
id: 'abcdef1234',
|
|
217
|
+
target: 'http://localhost:9999',
|
|
218
|
+
publicUrl: 'https://stale.example.com',
|
|
219
|
+
pid: 99999999, // almost certainly not a real pid
|
|
220
|
+
refcount: 1,
|
|
221
|
+
ttlExpiresAt: null,
|
|
222
|
+
startedAt: Date.now(),
|
|
223
|
+
};
|
|
224
|
+
fs.writeFileSync(path.join(dir, `${record.id}.json`), JSON.stringify(record));
|
|
225
|
+
|
|
226
|
+
const { factory } = makeFakeManagerFactory();
|
|
227
|
+
const registry = new TunnelRegistry({ managerFactory: factory, graceMs: 30 });
|
|
228
|
+
|
|
229
|
+
const list = registry.list();
|
|
230
|
+
expect(list).toHaveLength(0);
|
|
231
|
+
expect(fs.existsSync(path.join(dir, `${record.id}.json`))).toBe(false);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('acquire beyond the concurrency cap throws TunnelQuotaError', async () => {
|
|
235
|
+
// Seed 10 fake-but-alive records (owned by this process pid).
|
|
236
|
+
const dir = path.join(tmpHome, 'tunnels');
|
|
237
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
238
|
+
for (let i = 0; i < 10; i++) {
|
|
239
|
+
const id = `cap${i.toString().padStart(7, '0')}`;
|
|
240
|
+
const record = {
|
|
241
|
+
id,
|
|
242
|
+
target: `http://localhost:${4000 + i}`,
|
|
243
|
+
publicUrl: `https://cap-${i}.example.com`,
|
|
244
|
+
pid: process.pid,
|
|
245
|
+
refcount: 1,
|
|
246
|
+
ttlExpiresAt: null,
|
|
247
|
+
startedAt: Date.now(),
|
|
248
|
+
};
|
|
249
|
+
fs.writeFileSync(path.join(dir, `${id}.json`), JSON.stringify(record));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const { factory } = makeFakeManagerFactory();
|
|
253
|
+
const registry = new TunnelRegistry({ managerFactory: factory, graceMs: 30 });
|
|
254
|
+
|
|
255
|
+
await expect(registry.acquire('http://localhost:5999')).rejects.toBeInstanceOf(
|
|
256
|
+
TunnelQuotaError
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('forceClose tears down regardless of refcount', async () => {
|
|
261
|
+
const { factory, stopCalls } = makeFakeManagerFactory();
|
|
262
|
+
const registry = new TunnelRegistry({ managerFactory: factory, graceMs: 30 });
|
|
263
|
+
|
|
264
|
+
const h = await registry.acquire('http://localhost:3000');
|
|
265
|
+
expect(registry.list()).toHaveLength(1);
|
|
266
|
+
|
|
267
|
+
await registry.forceClose(h.target);
|
|
268
|
+
expect(stopCalls.count).toBe(1);
|
|
269
|
+
expect(registry.list()).toHaveLength(0);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// -------------------------------------------------------------------
|
|
273
|
+
// Cross-process coordination (foreign owner simulated via file seed)
|
|
274
|
+
// -------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
it('attaches to a foreign-owner record instead of starting a new tunnel', async () => {
|
|
277
|
+
const dir = path.join(tmpHome, 'tunnels');
|
|
278
|
+
const target = 'http://localhost:3000';
|
|
279
|
+
const hash = targetHash(target);
|
|
280
|
+
const canon = canonicalTarget(target);
|
|
281
|
+
// Seed a record owned by the parent process (ppid) — guaranteed
|
|
282
|
+
// alive for the duration of this test process, and distinct from
|
|
283
|
+
// our own pid so the registry treats it as a foreign owner.
|
|
284
|
+
const foreignPid = process.ppid;
|
|
285
|
+
expect(foreignPid).not.toBe(process.pid);
|
|
286
|
+
writeRecord(dir, {
|
|
287
|
+
id: hash,
|
|
288
|
+
target: canon,
|
|
289
|
+
publicUrl: 'https://attached-tunnel.example.com',
|
|
290
|
+
pid: foreignPid,
|
|
291
|
+
refcount: 1,
|
|
292
|
+
ttlExpiresAt: null,
|
|
293
|
+
startedAt: Date.now(),
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const { factory, stopCalls } = makeFakeManagerFactory('https://new-tunnel');
|
|
297
|
+
let started = 0;
|
|
298
|
+
const countingFactory: TunnelManagerFactory = () => {
|
|
299
|
+
started += 1;
|
|
300
|
+
return factory();
|
|
301
|
+
};
|
|
302
|
+
const registry = new TunnelRegistry({ managerFactory: countingFactory, graceMs: 30 });
|
|
303
|
+
|
|
304
|
+
const handle = await registry.acquire(target);
|
|
305
|
+
expect(handle.isCrossProcessAttach).toBe(true);
|
|
306
|
+
expect(handle.publicUrl).toBe('https://attached-tunnel.example.com');
|
|
307
|
+
expect(handle.refcount).toBe(2);
|
|
308
|
+
expect(started).toBe(0); // No new tunnel was started.
|
|
309
|
+
expect(stopCalls.count).toBe(0);
|
|
310
|
+
|
|
311
|
+
// Release should decrement the on-disk refcount but NOT tear down
|
|
312
|
+
// (we are not the owner of the in-memory manager).
|
|
313
|
+
await registry.release(handle);
|
|
314
|
+
const raw = JSON.parse(fs.readFileSync(path.join(dir, `${hash}.json`), 'utf8'));
|
|
315
|
+
expect(raw.refcount).toBe(1);
|
|
316
|
+
// Attach release with final refcount > 0 MUST NOT set a grace TTL
|
|
317
|
+
// (there is still a holder).
|
|
318
|
+
expect(raw.ttlExpiresAt).toBeNull();
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('reaps stale record with dead owner pid and starts a fresh tunnel', async () => {
|
|
322
|
+
const dir = path.join(tmpHome, 'tunnels');
|
|
323
|
+
const target = 'http://localhost:3000';
|
|
324
|
+
const hash = targetHash(target);
|
|
325
|
+
const canon = canonicalTarget(target);
|
|
326
|
+
writeRecord(dir, {
|
|
327
|
+
id: hash,
|
|
328
|
+
target: canon,
|
|
329
|
+
publicUrl: 'https://stale.example.com',
|
|
330
|
+
pid: 99999999, // almost certainly not a real pid
|
|
331
|
+
refcount: 1,
|
|
332
|
+
ttlExpiresAt: null,
|
|
333
|
+
startedAt: Date.now() - 60_000,
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
const { factory } = makeFakeManagerFactory('https://fresh');
|
|
337
|
+
const registry = new TunnelRegistry({ managerFactory: factory, graceMs: 30 });
|
|
338
|
+
|
|
339
|
+
const handle = await registry.acquire(target);
|
|
340
|
+
expect(handle.isCrossProcessAttach).toBe(false);
|
|
341
|
+
expect(handle.publicUrl).toContain('fresh');
|
|
342
|
+
expect(handle.refcount).toBe(1);
|
|
343
|
+
// The record on disk should be rewritten with the current pid.
|
|
344
|
+
const raw = JSON.parse(fs.readFileSync(path.join(dir, `${hash}.json`), 'utf8'));
|
|
345
|
+
expect(raw.pid).toBe(process.pid);
|
|
346
|
+
expect(raw.publicUrl).toContain('fresh');
|
|
347
|
+
|
|
348
|
+
await registry.release(handle);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('owner release sets ttlExpiresAt on disk during the grace window', async () => {
|
|
352
|
+
const { factory } = makeFakeManagerFactory();
|
|
353
|
+
const GRACE = 120;
|
|
354
|
+
const registry = new TunnelRegistry({ managerFactory: factory, graceMs: GRACE });
|
|
355
|
+
|
|
356
|
+
const handle = await registry.acquire('http://localhost:3000');
|
|
357
|
+
await registry.release(handle);
|
|
358
|
+
|
|
359
|
+
const file = path.join(tmpHome, 'tunnels', `${handle.id}.json`);
|
|
360
|
+
const during = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
361
|
+
expect(during.refcount).toBe(0);
|
|
362
|
+
expect(during.ttlExpiresAt).not.toBeNull();
|
|
363
|
+
expect(during.ttlExpiresAt).toBeGreaterThan(Date.now());
|
|
364
|
+
expect(during.ttlExpiresAt).toBeLessThanOrEqual(Date.now() + GRACE + 20);
|
|
365
|
+
|
|
366
|
+
// Wait out the grace window.
|
|
367
|
+
await new Promise((r) => setTimeout(r, GRACE + 60));
|
|
368
|
+
expect(fs.existsSync(file)).toBe(false);
|
|
369
|
+
expect(registry.list()).toHaveLength(0);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('honours QA_USE_TUNNEL_GRACE_MS env override', async () => {
|
|
373
|
+
const prev = process.env.QA_USE_TUNNEL_GRACE_MS;
|
|
374
|
+
process.env.QA_USE_TUNNEL_GRACE_MS = '75';
|
|
375
|
+
try {
|
|
376
|
+
const { factory, stopCalls } = makeFakeManagerFactory();
|
|
377
|
+
// Pass a HUGE graceMs but expect the env var to override.
|
|
378
|
+
const registry = new TunnelRegistry({ managerFactory: factory, graceMs: 10_000 });
|
|
379
|
+
const handle = await registry.acquire('http://localhost:3000');
|
|
380
|
+
await registry.release(handle);
|
|
381
|
+
// Should tear down in ~75ms.
|
|
382
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
383
|
+
expect(stopCalls.count).toBe(1);
|
|
384
|
+
expect(registry.list()).toHaveLength(0);
|
|
385
|
+
} finally {
|
|
386
|
+
if (prev === undefined) delete process.env.QA_USE_TUNNEL_GRACE_MS;
|
|
387
|
+
else process.env.QA_USE_TUNNEL_GRACE_MS = prev;
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('race resolution on concurrent acquires: at most one tunnel survives', async () => {
|
|
392
|
+
// Two parallel acquires for the SAME target. Because `startTunnel`
|
|
393
|
+
// cannot be held under the lockfile (it can take seconds in real
|
|
394
|
+
// use), both may start provider tunnels; the race-resolution inside
|
|
395
|
+
// the post-start lock keeps exactly one and `stopTunnel`s the other.
|
|
396
|
+
// Observable contract: one record on disk, refcount=2, both handles
|
|
397
|
+
// share the same publicUrl.
|
|
398
|
+
const { factory, stopCalls } = makeFakeManagerFactory();
|
|
399
|
+
let started = 0;
|
|
400
|
+
const countingFactory: TunnelManagerFactory = () => {
|
|
401
|
+
started += 1;
|
|
402
|
+
return factory();
|
|
403
|
+
};
|
|
404
|
+
const registry = new TunnelRegistry({ managerFactory: countingFactory, graceMs: 50 });
|
|
405
|
+
|
|
406
|
+
const [a, b] = await Promise.all([
|
|
407
|
+
registry.acquire('http://localhost:3000'),
|
|
408
|
+
registry.acquire('http://localhost:3000'),
|
|
409
|
+
]);
|
|
410
|
+
|
|
411
|
+
expect(started).toBeLessThanOrEqual(2);
|
|
412
|
+
expect(a.publicUrl).toBe(b.publicUrl);
|
|
413
|
+
const list = registry.list();
|
|
414
|
+
expect(list).toHaveLength(1);
|
|
415
|
+
expect(list[0].refcount).toBe(2);
|
|
416
|
+
// If two were started, exactly one must have been stopped as the
|
|
417
|
+
// race loser.
|
|
418
|
+
if (started === 2) expect(stopCalls.count).toBe(1);
|
|
419
|
+
});
|
|
420
|
+
});
|