@desplega.ai/qa-use 2.14.1 → 2.15.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.
- package/README.md +23 -0
- package/dist/lib/api/index.d.ts +5 -1
- package/dist/lib/api/index.d.ts.map +1 -1
- package/dist/lib/api/index.js +112 -5
- package/dist/lib/api/index.js.map +1 -1
- package/dist/lib/api/sse.d.ts +22 -2
- package/dist/lib/api/sse.d.ts.map +1 -1
- package/dist/lib/api/sse.js +77 -5
- package/dist/lib/api/sse.js.map +1 -1
- 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 +33 -19
- 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/runner.d.ts +6 -0
- package/dist/src/cli/lib/runner.d.ts.map +1 -1
- package/dist/src/cli/lib/runner.js +2 -2
- package/dist/src/cli/lib/runner.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/api/index.ts +136 -6
- package/lib/api/sse.test.ts +530 -0
- package/lib/api/sse.ts +105 -5
- 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
package/lib/env/index.ts
CHANGED
|
@@ -11,11 +11,24 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
11
11
|
import { homedir } from 'node:os';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
13
|
|
|
14
|
+
export { getPortFromUrl, isLocalhostUrl } from './localhost.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Tunnel mode stored in ~/.qa-use.json.
|
|
18
|
+
* Kept as a string literal here (instead of importing `TunnelMode` from
|
|
19
|
+
* `src/cli/lib/tunnel-option.ts`) to avoid pulling CLI code into the
|
|
20
|
+
* shared env loader.
|
|
21
|
+
*/
|
|
22
|
+
export type QaUseTunnelMode = 'auto' | 'on' | 'off';
|
|
23
|
+
|
|
24
|
+
const VALID_TUNNEL_MODES: readonly QaUseTunnelMode[] = ['auto', 'on', 'off'] as const;
|
|
25
|
+
|
|
14
26
|
interface QaUseConfig {
|
|
15
27
|
api_key?: string;
|
|
16
28
|
api_url?: string;
|
|
17
29
|
app_url?: string;
|
|
18
30
|
region?: string;
|
|
31
|
+
tunnel?: QaUseTunnelMode;
|
|
19
32
|
env?: Record<string, string>;
|
|
20
33
|
}
|
|
21
34
|
|
|
@@ -215,6 +228,44 @@ export function getCustomHeaders(): Record<string, string> | null {
|
|
|
215
228
|
return Object.keys(headers).length > 0 ? headers : null;
|
|
216
229
|
}
|
|
217
230
|
|
|
231
|
+
/**
|
|
232
|
+
* Read the `tunnel` key from `~/.qa-use.json`.
|
|
233
|
+
*
|
|
234
|
+
* Returns one of `'auto' | 'on' | 'off'`, or `undefined` if unset.
|
|
235
|
+
* On an invalid value, logs a one-line stderr warning and returns `undefined`
|
|
236
|
+
* (caller will fall back to the default, typically `'auto'`).
|
|
237
|
+
*
|
|
238
|
+
* Phase 1: config-only (no env-var override layer for tunnel mode).
|
|
239
|
+
*/
|
|
240
|
+
let tunnelWarningLogged = false;
|
|
241
|
+
export function getTunnelModeFromConfig(): QaUseTunnelMode | undefined {
|
|
242
|
+
const config = loadConfig();
|
|
243
|
+
if (!config) return undefined;
|
|
244
|
+
|
|
245
|
+
const raw = config.tunnel;
|
|
246
|
+
if (raw === undefined || raw === null) return undefined;
|
|
247
|
+
|
|
248
|
+
if (typeof raw === 'string' && VALID_TUNNEL_MODES.includes(raw as QaUseTunnelMode)) {
|
|
249
|
+
return raw as QaUseTunnelMode;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (!tunnelWarningLogged) {
|
|
253
|
+
console.error(
|
|
254
|
+
`qa-use: invalid "tunnel" value in ~/.qa-use.json: ${JSON.stringify(raw)}. ` +
|
|
255
|
+
`Expected one of: ${VALID_TUNNEL_MODES.join(', ')}. Falling back to "auto".`
|
|
256
|
+
);
|
|
257
|
+
tunnelWarningLogged = true;
|
|
258
|
+
}
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Reset the tunnel-warning latch (for tests).
|
|
264
|
+
*/
|
|
265
|
+
export function clearTunnelWarningLatch(): void {
|
|
266
|
+
tunnelWarningLogged = false;
|
|
267
|
+
}
|
|
268
|
+
|
|
218
269
|
/**
|
|
219
270
|
* Get agent session ID from environment if available.
|
|
220
271
|
* Used for auto-linking browser sessions and test runs to agent sessions.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { getPortFromUrl, isLocalhostUrl } from './localhost.js';
|
|
3
|
+
|
|
4
|
+
describe('isLocalhostUrl', () => {
|
|
5
|
+
test('returns true for localhost', () => {
|
|
6
|
+
expect(isLocalhostUrl('http://localhost:3000')).toBe(true);
|
|
7
|
+
expect(isLocalhostUrl('http://localhost')).toBe(true);
|
|
8
|
+
expect(isLocalhostUrl('https://localhost:8080/path')).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('returns true for 127.0.0.1', () => {
|
|
12
|
+
expect(isLocalhostUrl('http://127.0.0.1:5000')).toBe(true);
|
|
13
|
+
expect(isLocalhostUrl('http://127.0.0.1')).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('returns true for ::1 (IPv6)', () => {
|
|
17
|
+
expect(isLocalhostUrl('http://[::1]:3000')).toBe(true);
|
|
18
|
+
expect(isLocalhostUrl('http://[::1]')).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('returns true for *.localhost', () => {
|
|
22
|
+
expect(isLocalhostUrl('http://foo.localhost:3000')).toBe(true);
|
|
23
|
+
expect(isLocalhostUrl('http://api.localhost')).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('returns true for 0.0.0.0', () => {
|
|
27
|
+
expect(isLocalhostUrl('http://0.0.0.0:3000')).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('returns false for example.com', () => {
|
|
31
|
+
expect(isLocalhostUrl('https://example.com')).toBe(false);
|
|
32
|
+
expect(isLocalhostUrl('http://example.com:8080')).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('returns false for private IPs (not actually localhost)', () => {
|
|
36
|
+
expect(isLocalhostUrl('http://192.168.1.1')).toBe(false);
|
|
37
|
+
expect(isLocalhostUrl('http://10.0.0.1')).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('returns false for invalid URLs', () => {
|
|
41
|
+
expect(isLocalhostUrl('not-a-url')).toBe(false);
|
|
42
|
+
expect(isLocalhostUrl('')).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('getPortFromUrl', () => {
|
|
47
|
+
test('returns explicit port', () => {
|
|
48
|
+
expect(getPortFromUrl('http://localhost:3000')).toBe(3000);
|
|
49
|
+
expect(getPortFromUrl('https://example.com:8443')).toBe(8443);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('defaults to 443 for https without port', () => {
|
|
53
|
+
expect(getPortFromUrl('https://example.com')).toBe(443);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('defaults to 80 for http without port', () => {
|
|
57
|
+
expect(getPortFromUrl('http://example.com')).toBe(80);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('returns 80 for invalid URL', () => {
|
|
61
|
+
expect(getPortFromUrl('not-a-url')).toBe(80);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical localhost URL helpers.
|
|
3
|
+
*
|
|
4
|
+
* These live in `lib/env/` so they are usable from both the CLI layer and
|
|
5
|
+
* library code (tunnel, browser) without forcing a dependency on `src/cli`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { URL } from 'node:url';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Check if a URL points to localhost.
|
|
12
|
+
*
|
|
13
|
+
* Matches:
|
|
14
|
+
* - `localhost`
|
|
15
|
+
* - `127.0.0.1`
|
|
16
|
+
* - `::1`
|
|
17
|
+
* - `*.localhost` (e.g., `foo.localhost`)
|
|
18
|
+
* - `0.0.0.0`
|
|
19
|
+
*/
|
|
20
|
+
export function isLocalhostUrl(url: string): boolean {
|
|
21
|
+
try {
|
|
22
|
+
const parsed = new URL(url);
|
|
23
|
+
// Node/Bun's URL parser returns IPv6 hosts wrapped in brackets
|
|
24
|
+
// (e.g. "[::1]"). Strip them for comparison.
|
|
25
|
+
const host = parsed.hostname.replace(/^\[|\]$/g, '');
|
|
26
|
+
return (
|
|
27
|
+
host === 'localhost' ||
|
|
28
|
+
host === '127.0.0.1' ||
|
|
29
|
+
host === '::1' ||
|
|
30
|
+
host === '0.0.0.0' ||
|
|
31
|
+
host.endsWith('.localhost')
|
|
32
|
+
);
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Get the port from a URL, defaulting to 443 for https and 80 otherwise.
|
|
40
|
+
*/
|
|
41
|
+
export function getPortFromUrl(url: string): number {
|
|
42
|
+
try {
|
|
43
|
+
const parsed = new URL(url);
|
|
44
|
+
if (parsed.port) {
|
|
45
|
+
return parseInt(parsed.port, 10);
|
|
46
|
+
}
|
|
47
|
+
return parsed.protocol === 'https:' ? 443 : 80;
|
|
48
|
+
} catch {
|
|
49
|
+
return 80;
|
|
50
|
+
}
|
|
51
|
+
}
|
package/lib/env/paths.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem paths used by qa-use for CLI-side state.
|
|
3
|
+
*
|
|
4
|
+
* All paths are relative to `os.homedir()` and created lazily on first
|
|
5
|
+
* write. Reading these directories does not create them — that's important
|
|
6
|
+
* for commands like `tunnel ls` that report an empty state cleanly when
|
|
7
|
+
* nothing has ever been written.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Base dir for all qa-use CLI-side state. Defaults to `~/.qa-use` but may
|
|
16
|
+
* be overridden via `QA_USE_HOME` (test-friendly).
|
|
17
|
+
*/
|
|
18
|
+
export function qaUseDir(): string {
|
|
19
|
+
const override = process.env.QA_USE_HOME;
|
|
20
|
+
if (override && override.length > 0) {
|
|
21
|
+
return override;
|
|
22
|
+
}
|
|
23
|
+
return path.join(os.homedir(), '.qa-use');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Where persisted tunnel registry entries live. Each active tunnel is a
|
|
28
|
+
* single JSON file named `<sha256(target)[0..10]>.json`.
|
|
29
|
+
*/
|
|
30
|
+
export function tunnelsDir(): string {
|
|
31
|
+
return path.join(qaUseDir(), 'tunnels');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Where detached browser-session PID files live (used by Phase 4 onward).
|
|
36
|
+
*/
|
|
37
|
+
export function sessionsDir(): string {
|
|
38
|
+
return path.join(qaUseDir(), 'sessions');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Ensure a directory exists (recursively). No-op if it already does.
|
|
43
|
+
*/
|
|
44
|
+
export function ensureDir(dir: string): void {
|
|
45
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
46
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
type DetachedSessionRecord,
|
|
7
|
+
listSessionRecords,
|
|
8
|
+
removeSessionRecord,
|
|
9
|
+
sessionFilePath,
|
|
10
|
+
writeSessionRecord,
|
|
11
|
+
} from './sessions.js';
|
|
12
|
+
|
|
13
|
+
// Redirect QA_USE_HOME so `paths.ts` resolves to a throwaway dir per test.
|
|
14
|
+
let originalQaUseHome: string | undefined;
|
|
15
|
+
let tmpHome: string;
|
|
16
|
+
|
|
17
|
+
function makeRecord(overrides: Partial<DetachedSessionRecord> = {}): DetachedSessionRecord {
|
|
18
|
+
return {
|
|
19
|
+
id: 'qa-test-1',
|
|
20
|
+
pid: 99999999,
|
|
21
|
+
target: 'http://localhost:3000',
|
|
22
|
+
publicUrl: 'https://example.tunnel',
|
|
23
|
+
startedAt: '2026-04-21T00:00:00.000Z',
|
|
24
|
+
ttlExpiresAt: Date.now() + 60_000,
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('sessions.ts', () => {
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
originalQaUseHome = process.env.QA_USE_HOME;
|
|
32
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'qa-use-sessions-'));
|
|
33
|
+
process.env.QA_USE_HOME = tmpHome;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
if (originalQaUseHome !== undefined) {
|
|
38
|
+
process.env.QA_USE_HOME = originalQaUseHome;
|
|
39
|
+
} else {
|
|
40
|
+
delete process.env.QA_USE_HOME;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
44
|
+
} catch {
|
|
45
|
+
/* best-effort */
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('removeSessionRecord', () => {
|
|
50
|
+
test('fast path: removes <id>.json when filename matches id', () => {
|
|
51
|
+
const record = makeRecord({ id: 'qa-fast-1' });
|
|
52
|
+
writeSessionRecord(record);
|
|
53
|
+
const p = sessionFilePath('qa-fast-1');
|
|
54
|
+
expect(fs.existsSync(p)).toBe(true);
|
|
55
|
+
|
|
56
|
+
removeSessionRecord('qa-fast-1');
|
|
57
|
+
expect(fs.existsSync(p)).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('fallback: removes file when filename drifts from internal id', () => {
|
|
61
|
+
// Simulate corrupted/drifted state: filename qa-stale-2.json, internal id qa-stale-1.
|
|
62
|
+
const dir = path.join(tmpHome, 'sessions');
|
|
63
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
64
|
+
const driftedPath = path.join(dir, 'qa-stale-2.json');
|
|
65
|
+
const content = JSON.stringify(makeRecord({ id: 'qa-stale-1' }));
|
|
66
|
+
fs.writeFileSync(driftedPath, content);
|
|
67
|
+
|
|
68
|
+
// No file at sessionFilePath('qa-stale-1'), but a file with id=qa-stale-1 exists as qa-stale-2.json
|
|
69
|
+
expect(fs.existsSync(sessionFilePath('qa-stale-1'))).toBe(false);
|
|
70
|
+
expect(fs.existsSync(driftedPath)).toBe(true);
|
|
71
|
+
|
|
72
|
+
removeSessionRecord('qa-stale-1');
|
|
73
|
+
|
|
74
|
+
expect(fs.existsSync(driftedPath)).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('no-op when no matching record exists', () => {
|
|
78
|
+
// Create an unrelated record — scan should not touch it.
|
|
79
|
+
const dir = path.join(tmpHome, 'sessions');
|
|
80
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
81
|
+
const unrelatedPath = path.join(dir, 'other.json');
|
|
82
|
+
fs.writeFileSync(unrelatedPath, JSON.stringify(makeRecord({ id: 'other' })));
|
|
83
|
+
|
|
84
|
+
removeSessionRecord('not-present');
|
|
85
|
+
|
|
86
|
+
expect(fs.existsSync(unrelatedPath)).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('no-op when sessions dir is missing', () => {
|
|
90
|
+
// Should not throw even when the dir doesn't exist.
|
|
91
|
+
expect(() => removeSessionRecord('missing')).not.toThrow();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('listSessionRecords', () => {
|
|
96
|
+
test('returns parsed records regardless of filename drift', () => {
|
|
97
|
+
const dir = path.join(tmpHome, 'sessions');
|
|
98
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
99
|
+
fs.writeFileSync(
|
|
100
|
+
path.join(dir, 'drifted.json'),
|
|
101
|
+
JSON.stringify(makeRecord({ id: 'actual-id' }))
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const records = listSessionRecords();
|
|
105
|
+
expect(records).toHaveLength(1);
|
|
106
|
+
expect(records[0]!.id).toBe('actual-id');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detached browser-session PID files.
|
|
3
|
+
*
|
|
4
|
+
* Each detached `browser create` writes a JSON file under
|
|
5
|
+
* `~/.qa-use/sessions/<session-id>.json` describing the running child
|
|
6
|
+
* process. The file schema is documented in `DetachedSessionRecord`.
|
|
7
|
+
*
|
|
8
|
+
* Readers (tunnel close, browser status, doctor) cross-reference the
|
|
9
|
+
* `pid` field via `process.kill(pid, 0)` to detect stale entries.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { ensureDir, sessionsDir } from './paths.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* On-disk schema for a detached browser session.
|
|
18
|
+
*
|
|
19
|
+
* The file is created by the detached child (`__browser-detach`) shortly
|
|
20
|
+
* after it starts, and removed on clean exit. Partial writes are
|
|
21
|
+
* avoided by atomic rename (write `.tmp` + `rename`).
|
|
22
|
+
*/
|
|
23
|
+
export interface DetachedSessionRecord {
|
|
24
|
+
/** Backend session id (also the filename base). */
|
|
25
|
+
id: string;
|
|
26
|
+
/** PID of the detached child process. */
|
|
27
|
+
pid: number;
|
|
28
|
+
/** Tunnel target (canonical origin of the browser WS URL). */
|
|
29
|
+
target: string;
|
|
30
|
+
/** Public URL for the tunnel (from registry handle). `null` when no tunnel. */
|
|
31
|
+
publicUrl: string | null;
|
|
32
|
+
/** ISO timestamp of child startup. */
|
|
33
|
+
startedAt: string;
|
|
34
|
+
/** Epoch ms at which the backend session TTL expires. */
|
|
35
|
+
ttlExpiresAt: number;
|
|
36
|
+
/**
|
|
37
|
+
* True when the registry handle was an attach (another process owns
|
|
38
|
+
* the in-process `TunnelManager`). Informational — `browser close`
|
|
39
|
+
* prints this in diagnostics.
|
|
40
|
+
*/
|
|
41
|
+
crossProcessTunnel?: boolean;
|
|
42
|
+
/** Optional: subdomain used for the tunnel. */
|
|
43
|
+
subdomain?: string;
|
|
44
|
+
/** Optional: viewport for display/debug. */
|
|
45
|
+
viewport?: string;
|
|
46
|
+
/** Optional: headless flag for display/debug. */
|
|
47
|
+
headless?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function sessionFilePath(sessionId: string): string {
|
|
51
|
+
return path.join(sessionsDir(), `${sessionId}.json`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function writeSessionRecord(record: DetachedSessionRecord): void {
|
|
55
|
+
const dir = sessionsDir();
|
|
56
|
+
ensureDir(dir);
|
|
57
|
+
const finalPath = sessionFilePath(record.id);
|
|
58
|
+
const tmp = `${finalPath}.tmp-${process.pid}-${Date.now()}`;
|
|
59
|
+
fs.writeFileSync(tmp, JSON.stringify(record, null, 2));
|
|
60
|
+
fs.renameSync(tmp, finalPath);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function readSessionRecord(sessionId: string): DetachedSessionRecord | null {
|
|
64
|
+
try {
|
|
65
|
+
const raw = fs.readFileSync(sessionFilePath(sessionId), 'utf8');
|
|
66
|
+
const parsed = JSON.parse(raw) as DetachedSessionRecord;
|
|
67
|
+
if (typeof parsed.id !== 'string' || typeof parsed.pid !== 'number') return null;
|
|
68
|
+
return parsed;
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function removeSessionRecord(sessionId: string): void {
|
|
75
|
+
try {
|
|
76
|
+
fs.unlinkSync(sessionFilePath(sessionId));
|
|
77
|
+
return;
|
|
78
|
+
} catch (err) {
|
|
79
|
+
// Fast path missed — fall through to dir-scan fallback only on ENOENT.
|
|
80
|
+
// Any other error (EPERM, EACCES, EBUSY, etc.) is propagated by
|
|
81
|
+
// simply returning: callers treat removal as best-effort and surfacing
|
|
82
|
+
// an error here would change the public contract.
|
|
83
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Fallback: filename may have drifted from the internal `id` (corrupted
|
|
89
|
+
// state, manual edits, legacy data). Scan the dir and unlink the file
|
|
90
|
+
// whose parsed content has a matching `id`.
|
|
91
|
+
const dir = sessionsDir();
|
|
92
|
+
let files: string[];
|
|
93
|
+
try {
|
|
94
|
+
files = fs.readdirSync(dir);
|
|
95
|
+
} catch {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
for (const name of files) {
|
|
99
|
+
if (!name.endsWith('.json') || name.endsWith('.tmp')) continue;
|
|
100
|
+
const fullPath = path.join(dir, name);
|
|
101
|
+
try {
|
|
102
|
+
const raw = fs.readFileSync(fullPath, 'utf8');
|
|
103
|
+
const parsed = JSON.parse(raw) as DetachedSessionRecord;
|
|
104
|
+
if (parsed.id === sessionId) {
|
|
105
|
+
try {
|
|
106
|
+
fs.unlinkSync(fullPath);
|
|
107
|
+
} catch {
|
|
108
|
+
/* already gone */
|
|
109
|
+
}
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
/* skip unreadable */
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* List all persisted detached-session records. Does NOT reconcile
|
|
120
|
+
* against PIDs — callers decide how to handle stale entries.
|
|
121
|
+
*/
|
|
122
|
+
export function listSessionRecords(): DetachedSessionRecord[] {
|
|
123
|
+
const dir = sessionsDir();
|
|
124
|
+
let files: string[];
|
|
125
|
+
try {
|
|
126
|
+
files = fs.readdirSync(dir);
|
|
127
|
+
} catch {
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
const out: DetachedSessionRecord[] = [];
|
|
131
|
+
for (const name of files) {
|
|
132
|
+
if (!name.endsWith('.json') || name.endsWith('.tmp')) continue;
|
|
133
|
+
try {
|
|
134
|
+
const raw = fs.readFileSync(path.join(dir, name), 'utf8');
|
|
135
|
+
const parsed = JSON.parse(raw) as DetachedSessionRecord;
|
|
136
|
+
if (typeof parsed.id !== 'string' || typeof parsed.pid !== 'number') continue;
|
|
137
|
+
out.push(parsed);
|
|
138
|
+
} catch {
|
|
139
|
+
/* skip unreadable */
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Cheap liveness check — `kill(pid, 0)` returns true if the pid exists. */
|
|
146
|
+
export function isPidAlive(pid: number): boolean {
|
|
147
|
+
if (!pid || pid <= 0) return false;
|
|
148
|
+
try {
|
|
149
|
+
process.kill(pid, 0);
|
|
150
|
+
return true;
|
|
151
|
+
} catch (err) {
|
|
152
|
+
if ((err as NodeJS.ErrnoException).code === 'EPERM') return true;
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
classifyTunnelFailure,
|
|
4
|
+
TunnelAuthError,
|
|
5
|
+
TunnelError,
|
|
6
|
+
TunnelNetworkError,
|
|
7
|
+
TunnelQuotaError,
|
|
8
|
+
TunnelUnknownError,
|
|
9
|
+
} from './errors.js';
|
|
10
|
+
|
|
11
|
+
describe('TunnelError', () => {
|
|
12
|
+
test('base class carries target + provider + cause', () => {
|
|
13
|
+
const cause = new Error('boom');
|
|
14
|
+
const err = new TunnelError('msg', {
|
|
15
|
+
target: 'http://localhost:3000',
|
|
16
|
+
provider: 'localtunnel',
|
|
17
|
+
cause,
|
|
18
|
+
});
|
|
19
|
+
expect(err).toBeInstanceOf(Error);
|
|
20
|
+
expect(err.target).toBe('http://localhost:3000');
|
|
21
|
+
expect(err.provider).toBe('localtunnel');
|
|
22
|
+
expect(err.cause).toBe(cause);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('provider defaults to "localtunnel"', () => {
|
|
26
|
+
const err = new TunnelError('msg', { target: 'x' });
|
|
27
|
+
expect(err.provider).toBe('localtunnel');
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('classifyTunnelFailure', () => {
|
|
32
|
+
test('classifies Node errno codes as network errors', () => {
|
|
33
|
+
const nodeErr = Object.assign(new Error('ECONNREFUSED'), { code: 'ECONNREFUSED' });
|
|
34
|
+
const out = classifyTunnelFailure(nodeErr);
|
|
35
|
+
expect(out).toBeInstanceOf(TunnelNetworkError);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('classifies ETIMEDOUT as network', () => {
|
|
39
|
+
const err = Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' });
|
|
40
|
+
expect(classifyTunnelFailure(err)).toBeInstanceOf(TunnelNetworkError);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('classifies ENOTFOUND as network', () => {
|
|
44
|
+
const err = Object.assign(new Error('getaddrinfo ENOTFOUND'), { code: 'ENOTFOUND' });
|
|
45
|
+
expect(classifyTunnelFailure(err)).toBeInstanceOf(TunnelNetworkError);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('classifies message-based timeout text as network', () => {
|
|
49
|
+
const err = new Error('Connection timed out after 5s');
|
|
50
|
+
expect(classifyTunnelFailure(err)).toBeInstanceOf(TunnelNetworkError);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('classifies HTTP 401 as auth error', () => {
|
|
54
|
+
const err = Object.assign(new Error('Unauthorized'), { statusCode: 401 });
|
|
55
|
+
expect(classifyTunnelFailure(err)).toBeInstanceOf(TunnelAuthError);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('classifies HTTP 403 as auth error', () => {
|
|
59
|
+
const err = Object.assign(new Error('Forbidden'), { statusCode: 403 });
|
|
60
|
+
expect(classifyTunnelFailure(err)).toBeInstanceOf(TunnelAuthError);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('classifies "unauthorized" message as auth error', () => {
|
|
64
|
+
const err = new Error('Request rejected: unauthorized');
|
|
65
|
+
expect(classifyTunnelFailure(err)).toBeInstanceOf(TunnelAuthError);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('classifies HTTP 429 as quota error', () => {
|
|
69
|
+
const err = Object.assign(new Error('Too Many Requests'), { statusCode: 429 });
|
|
70
|
+
expect(classifyTunnelFailure(err)).toBeInstanceOf(TunnelQuotaError);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('classifies "rate limit" message as quota error', () => {
|
|
74
|
+
const err = new Error('rate limit exceeded');
|
|
75
|
+
expect(classifyTunnelFailure(err)).toBeInstanceOf(TunnelQuotaError);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('classifies "subdomain already in use" as quota error', () => {
|
|
79
|
+
const err = new Error('subdomain qa-use-foo is already in use');
|
|
80
|
+
expect(classifyTunnelFailure(err)).toBeInstanceOf(TunnelQuotaError);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('classifies nested response.status', () => {
|
|
84
|
+
const err = { message: 'boom', response: { status: 401 } };
|
|
85
|
+
expect(classifyTunnelFailure(err)).toBeInstanceOf(TunnelAuthError);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('falls back to TunnelUnknownError on unrecognised shape', () => {
|
|
89
|
+
const err = new Error('something exploded');
|
|
90
|
+
expect(classifyTunnelFailure(err)).toBeInstanceOf(TunnelUnknownError);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('preserves target and cause in the returned error', () => {
|
|
94
|
+
const cause = new Error('root');
|
|
95
|
+
const out = classifyTunnelFailure(cause, { target: 'http://localhost:3000' });
|
|
96
|
+
expect(out.target).toBe('http://localhost:3000');
|
|
97
|
+
expect(out.cause).toBe(cause);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('handles non-Error values', () => {
|
|
101
|
+
expect(classifyTunnelFailure('bare string')).toBeInstanceOf(TunnelUnknownError);
|
|
102
|
+
expect(classifyTunnelFailure(undefined)).toBeInstanceOf(TunnelUnknownError);
|
|
103
|
+
expect(classifyTunnelFailure(null)).toBeInstanceOf(TunnelUnknownError);
|
|
104
|
+
});
|
|
105
|
+
});
|