@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
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured tunnel error classes.
|
|
3
|
+
*
|
|
4
|
+
* The CLI uses these to render triage-hint error messages on tunnel
|
|
5
|
+
* failure. `classifyTunnelFailure` inspects a thrown error from the
|
|
6
|
+
* underlying `@desplega.ai/localtunnel` provider and returns the most
|
|
7
|
+
* specific subclass we can identify.
|
|
8
|
+
*
|
|
9
|
+
* Zero retries live in this layer — classification is strictly about
|
|
10
|
+
* picking the right error shape to hand back up the stack.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface TunnelErrorContext {
|
|
14
|
+
/** Human-readable target the user was trying to tunnel (URL or host:port). */
|
|
15
|
+
target?: string;
|
|
16
|
+
/** Identifier of the tunnel provider (kept open for future providers). */
|
|
17
|
+
provider?: string;
|
|
18
|
+
/** Original underlying error, preserved for logs / debugging. */
|
|
19
|
+
cause?: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Base class for all tunnel-layer failures surfaced to the CLI.
|
|
24
|
+
*
|
|
25
|
+
* Do not throw `TunnelError` directly — throw one of the subclasses
|
|
26
|
+
* below so the CLI can pick the right hint.
|
|
27
|
+
*/
|
|
28
|
+
export class TunnelError extends Error {
|
|
29
|
+
readonly target?: string;
|
|
30
|
+
readonly provider?: string;
|
|
31
|
+
readonly cause?: unknown;
|
|
32
|
+
|
|
33
|
+
constructor(message: string, context: TunnelErrorContext = {}) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = 'TunnelError';
|
|
36
|
+
this.target = context.target;
|
|
37
|
+
this.provider = context.provider ?? 'localtunnel';
|
|
38
|
+
this.cause = context.cause;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Network / connectivity failure (DNS, timeout, ECONNREFUSED, etc.). */
|
|
43
|
+
export class TunnelNetworkError extends TunnelError {
|
|
44
|
+
constructor(message: string, context: TunnelErrorContext = {}) {
|
|
45
|
+
super(message, context);
|
|
46
|
+
this.name = 'TunnelNetworkError';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Auth failure (bad/expired API key, 401/403 from provider). */
|
|
51
|
+
export class TunnelAuthError extends TunnelError {
|
|
52
|
+
constructor(message: string, context: TunnelErrorContext = {}) {
|
|
53
|
+
super(message, context);
|
|
54
|
+
this.name = 'TunnelAuthError';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Quota / rate-limit / subdomain-clash failure. */
|
|
59
|
+
export class TunnelQuotaError extends TunnelError {
|
|
60
|
+
constructor(message: string, context: TunnelErrorContext = {}) {
|
|
61
|
+
super(message, context);
|
|
62
|
+
this.name = 'TunnelQuotaError';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Fallback when no classification matched. */
|
|
67
|
+
export class TunnelUnknownError extends TunnelError {
|
|
68
|
+
constructor(message: string, context: TunnelErrorContext = {}) {
|
|
69
|
+
super(message, context);
|
|
70
|
+
this.name = 'TunnelUnknownError';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function extractStatusCode(err: unknown): number | undefined {
|
|
75
|
+
if (!err || typeof err !== 'object') return undefined;
|
|
76
|
+
const obj = err as Record<string, unknown>;
|
|
77
|
+
if (typeof obj.statusCode === 'number') return obj.statusCode;
|
|
78
|
+
if (typeof obj.status === 'number') return obj.status;
|
|
79
|
+
// Some providers nest the status in a `response` object.
|
|
80
|
+
const response = obj.response;
|
|
81
|
+
if (response && typeof response === 'object') {
|
|
82
|
+
const respObj = response as Record<string, unknown>;
|
|
83
|
+
if (typeof respObj.status === 'number') return respObj.status;
|
|
84
|
+
if (typeof respObj.statusCode === 'number') return respObj.statusCode;
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function extractErrorCode(err: unknown): string | undefined {
|
|
90
|
+
if (!err || typeof err !== 'object') return undefined;
|
|
91
|
+
const obj = err as Record<string, unknown>;
|
|
92
|
+
if (typeof obj.code === 'string') return obj.code;
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function extractMessage(err: unknown): string {
|
|
97
|
+
if (err instanceof Error) return err.message;
|
|
98
|
+
if (typeof err === 'string') return err;
|
|
99
|
+
if (err && typeof err === 'object') {
|
|
100
|
+
const obj = err as Record<string, unknown>;
|
|
101
|
+
if (typeof obj.message === 'string') return obj.message;
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
return String(err);
|
|
105
|
+
} catch {
|
|
106
|
+
return 'unknown tunnel error';
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const NETWORK_CODES = new Set([
|
|
111
|
+
'ECONNREFUSED',
|
|
112
|
+
'ECONNRESET',
|
|
113
|
+
'ETIMEDOUT',
|
|
114
|
+
'ENOTFOUND',
|
|
115
|
+
'EAI_AGAIN',
|
|
116
|
+
'EHOSTUNREACH',
|
|
117
|
+
'ENETUNREACH',
|
|
118
|
+
'EPIPE',
|
|
119
|
+
'UND_ERR_CONNECT_TIMEOUT',
|
|
120
|
+
'UND_ERR_SOCKET',
|
|
121
|
+
]);
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Inspect a thrown tunnel error and return the most specific
|
|
125
|
+
* `TunnelError` subclass we can identify.
|
|
126
|
+
*
|
|
127
|
+
* Heuristics:
|
|
128
|
+
* - Node / undici error codes (`ECONNREFUSED`, `ETIMEDOUT`, …) →
|
|
129
|
+
* `TunnelNetworkError`
|
|
130
|
+
* - HTTP 401/403 → `TunnelAuthError`
|
|
131
|
+
* - HTTP 429 or messages about quota / rate-limit / subdomain in use →
|
|
132
|
+
* `TunnelQuotaError`
|
|
133
|
+
* - Everything else → `TunnelUnknownError`
|
|
134
|
+
*/
|
|
135
|
+
export function classifyTunnelFailure(err: unknown, context: TunnelErrorContext = {}): TunnelError {
|
|
136
|
+
const ctx: TunnelErrorContext = {
|
|
137
|
+
...context,
|
|
138
|
+
cause: context.cause ?? err,
|
|
139
|
+
};
|
|
140
|
+
const message = extractMessage(err);
|
|
141
|
+
const code = extractErrorCode(err);
|
|
142
|
+
const status = extractStatusCode(err);
|
|
143
|
+
const lowered = message.toLowerCase();
|
|
144
|
+
|
|
145
|
+
if (code && NETWORK_CODES.has(code)) {
|
|
146
|
+
return new TunnelNetworkError(message, ctx);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (/timeout|timed out|econnrefused|econnreset|enotfound|network/i.test(lowered)) {
|
|
150
|
+
return new TunnelNetworkError(message, ctx);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (
|
|
154
|
+
status === 401 ||
|
|
155
|
+
status === 403 ||
|
|
156
|
+
/\b(401|403|unauthori[sz]ed|forbidden)\b/i.test(lowered)
|
|
157
|
+
) {
|
|
158
|
+
return new TunnelAuthError(message, ctx);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (
|
|
162
|
+
status === 429 ||
|
|
163
|
+
/quota|rate.?limit|too many|subdomain.*(in use|taken|already)/i.test(lowered)
|
|
164
|
+
) {
|
|
165
|
+
return new TunnelQuotaError(message, ctx);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return new TunnelUnknownError(message || 'tunnel failed', ctx);
|
|
169
|
+
}
|
package/lib/tunnel/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import https from 'node:https';
|
|
|
3
3
|
import { URL } from 'node:url';
|
|
4
4
|
import localtunnel from '@desplega.ai/localtunnel';
|
|
5
5
|
import { getEnv } from '../env/index.js';
|
|
6
|
+
import { classifyTunnelFailure, TunnelError } from './errors.js';
|
|
6
7
|
|
|
7
8
|
export interface TunnelSession {
|
|
8
9
|
tunnel: localtunnel.Tunnel;
|
|
@@ -69,24 +70,38 @@ export class TunnelManager {
|
|
|
69
70
|
options.apiKey,
|
|
70
71
|
options.sessionIndex
|
|
71
72
|
);
|
|
72
|
-
console.
|
|
73
|
+
console.error(`Using deterministic subdomain: ${subdomain}`);
|
|
73
74
|
} else if (!subdomain) {
|
|
74
75
|
// Fallback to timestamp-based random subdomain
|
|
75
76
|
subdomain = `qa-use-${Date.now().toString().slice(-6)}`;
|
|
76
|
-
console.
|
|
77
|
+
console.error(`Using random subdomain: ${subdomain}`);
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
console.
|
|
80
|
+
console.error(`Starting tunnel on port ${port} with host ${host} in region ${region}`);
|
|
80
81
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
82
|
+
let tunnel: localtunnel.Tunnel;
|
|
83
|
+
try {
|
|
84
|
+
tunnel = await localtunnel({
|
|
85
|
+
port,
|
|
86
|
+
host,
|
|
87
|
+
subdomain,
|
|
88
|
+
local_host: options.localHost || 'localhost',
|
|
89
|
+
auth: true,
|
|
90
|
+
});
|
|
91
|
+
} catch (err) {
|
|
92
|
+
// Classify provider errors into structured TunnelError subclasses so
|
|
93
|
+
// the CLI can render a triage-hint message. Zero retries here —
|
|
94
|
+
// surface the failure immediately.
|
|
95
|
+
const classified =
|
|
96
|
+
err instanceof TunnelError
|
|
97
|
+
? err
|
|
98
|
+
: classifyTunnelFailure(err, {
|
|
99
|
+
target: `${options.localHost || 'localhost'}:${port}`,
|
|
100
|
+
});
|
|
101
|
+
throw classified;
|
|
102
|
+
}
|
|
88
103
|
|
|
89
|
-
console.
|
|
104
|
+
console.error(`Tunnel started at ${tunnel.url}`);
|
|
90
105
|
|
|
91
106
|
this.session = {
|
|
92
107
|
tunnel,
|
|
@@ -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
|
+
});
|