@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
|
@@ -4,23 +4,26 @@
|
|
|
4
4
|
* Provides automatic localhost tunneling when tests target localhost URLs,
|
|
5
5
|
* and browser WebSocket connection for remote test execution.
|
|
6
6
|
*/
|
|
7
|
-
import { URL } from 'node:url';
|
|
8
7
|
import { BrowserManager } from '../../../lib/browser/index.js';
|
|
9
|
-
import {
|
|
8
|
+
import { classifyTunnelFailure, TunnelError } from '../../../lib/tunnel/errors.js';
|
|
9
|
+
import { tunnelRegistry } from '../../../lib/tunnel/registry.js';
|
|
10
10
|
import { error } from './output.js';
|
|
11
|
+
import { printTunnelReuseBanner, printTunnelStartBanner } from './tunnel-banner.js';
|
|
12
|
+
import { formatTunnelFailure } from './tunnel-error-hint.js';
|
|
13
|
+
import { resolveTunnelMode } from './tunnel-resolve.js';
|
|
11
14
|
/**
|
|
12
|
-
*
|
|
15
|
+
* Mirror of `TunnelManager.getWebSocketUrl` for the cross-process
|
|
16
|
+
* attach case where we don't have a live `TunnelManager` to call.
|
|
17
|
+
* Converts the tunnel's public HTTPS URL + the local browser WS path
|
|
18
|
+
* into a `wss://.../devtools/browser/...` URL.
|
|
13
19
|
*/
|
|
14
|
-
|
|
20
|
+
function deriveCrossProcessWsUrl(publicHttpUrl, localWsEndpoint) {
|
|
15
21
|
try {
|
|
16
|
-
const
|
|
17
|
-
return (
|
|
18
|
-
parsed.hostname === '127.0.0.1' ||
|
|
19
|
-
parsed.hostname === '::1' ||
|
|
20
|
-
parsed.hostname.endsWith('.localhost'));
|
|
22
|
+
const wsPath = new URL(localWsEndpoint).pathname;
|
|
23
|
+
return publicHttpUrl.replace('https://', 'wss://').replace('http://', 'ws://') + wsPath;
|
|
21
24
|
}
|
|
22
25
|
catch {
|
|
23
|
-
return
|
|
26
|
+
return null;
|
|
24
27
|
}
|
|
25
28
|
}
|
|
26
29
|
/**
|
|
@@ -39,22 +42,6 @@ export function ensureBrowsersInstalled() {
|
|
|
39
42
|
process.exit(1);
|
|
40
43
|
}
|
|
41
44
|
}
|
|
42
|
-
/**
|
|
43
|
-
* Get the port from a URL
|
|
44
|
-
*/
|
|
45
|
-
export function getPortFromUrl(url) {
|
|
46
|
-
try {
|
|
47
|
-
const parsed = new URL(url);
|
|
48
|
-
if (parsed.port) {
|
|
49
|
-
return parseInt(parsed.port, 10);
|
|
50
|
-
}
|
|
51
|
-
// Default ports
|
|
52
|
-
return parsed.protocol === 'https:' ? 443 : 80;
|
|
53
|
-
}
|
|
54
|
-
catch {
|
|
55
|
-
return 80;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
45
|
/**
|
|
59
46
|
* Start browser and tunnel (if needed for localhost testing)
|
|
60
47
|
*
|
|
@@ -67,6 +54,7 @@ export async function startBrowserWithTunnel(testUrl, options = {}) {
|
|
|
67
54
|
ensureBrowsersInstalled();
|
|
68
55
|
const browser = new BrowserManager();
|
|
69
56
|
let tunnel = null;
|
|
57
|
+
let tunnelHandle = null;
|
|
70
58
|
let publicWsUrl = null;
|
|
71
59
|
// Start browser
|
|
72
60
|
console.error('Starting browser...');
|
|
@@ -74,24 +62,65 @@ export async function startBrowserWithTunnel(testUrl, options = {}) {
|
|
|
74
62
|
headless: options.headless ?? true,
|
|
75
63
|
});
|
|
76
64
|
const wsUrl = browserSession.wsEndpoint;
|
|
77
|
-
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
65
|
+
// Resolve the on/off decision. Default mode is 'auto' — that way
|
|
66
|
+
// callers that don't pass `tunnelMode` still get Phase-2 behaviour.
|
|
67
|
+
const mode = options.tunnelMode ?? 'auto';
|
|
68
|
+
const decision = resolveTunnelMode(mode, testUrl, options.apiUrl);
|
|
69
|
+
const isLocalhost = decision === 'on';
|
|
70
|
+
if (decision === 'on') {
|
|
71
|
+
// The tunnel target is the browser WebSocket URL — that's what the
|
|
72
|
+
// remote backend needs to reach. `testUrl` is retained only for the
|
|
73
|
+
// banner copy so users see the localhost *app* URL they typed.
|
|
74
|
+
const bannerTarget = testUrl ?? wsUrl;
|
|
75
|
+
try {
|
|
76
|
+
tunnelHandle = await tunnelRegistry.acquire(wsUrl, {
|
|
77
|
+
apiKey: options.apiKey,
|
|
78
|
+
sessionIndex: options.sessionIndex,
|
|
79
|
+
});
|
|
80
|
+
if (tunnelHandle.isCrossProcessAttach) {
|
|
81
|
+
// Another process owns the TunnelManager. We can't construct a
|
|
82
|
+
// new one (it would race for the same subdomain). Derive the
|
|
83
|
+
// public WS URL from the recorded public HTTP URL + our local
|
|
84
|
+
// ws path — mirrors `TunnelManager.getWebSocketUrl` without
|
|
85
|
+
// needing an in-process manager.
|
|
86
|
+
tunnel = null;
|
|
87
|
+
publicWsUrl = deriveCrossProcessWsUrl(tunnelHandle.publicUrl, wsUrl);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
// `tunnel` field is a back-compat shim for existing callers
|
|
91
|
+
// that check `session.tunnel` and reach into
|
|
92
|
+
// `stopTunnel`/`checkHealth`. The registry owns the lifecycle
|
|
93
|
+
// now — release goes through `registry.release(handle)` in
|
|
94
|
+
// `stopBrowserWithTunnel`.
|
|
95
|
+
tunnel = tunnelRegistry.getLiveManager(wsUrl);
|
|
96
|
+
publicWsUrl = tunnel ? tunnel.getWebSocketUrl(wsUrl) : null;
|
|
97
|
+
}
|
|
98
|
+
// Branch banner: reuse banner when this acquire landed on an
|
|
99
|
+
// already-running tunnel (refcount > 1 after increment OR we
|
|
100
|
+
// attached to a sibling process's tunnel), else the fresh-start
|
|
101
|
+
// banner.
|
|
102
|
+
const bannerOpts = {
|
|
103
|
+
target: bannerTarget,
|
|
104
|
+
publicUrl: tunnelHandle.publicUrl,
|
|
105
|
+
quiet: options.quiet,
|
|
106
|
+
};
|
|
107
|
+
if (tunnelHandle.refcount > 1 || tunnelHandle.isCrossProcessAttach) {
|
|
108
|
+
printTunnelReuseBanner(bannerOpts);
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
printTunnelStartBanner(bannerOpts);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
const classified = err instanceof TunnelError ? err : classifyTunnelFailure(err, { target: bannerTarget });
|
|
116
|
+
console.error(formatTunnelFailure(classified));
|
|
117
|
+
throw classified;
|
|
118
|
+
}
|
|
91
119
|
}
|
|
92
120
|
return {
|
|
93
121
|
browser,
|
|
94
122
|
tunnel,
|
|
123
|
+
tunnelHandle,
|
|
95
124
|
wsUrl,
|
|
96
125
|
publicWsUrl,
|
|
97
126
|
isLocalhost,
|
|
@@ -101,8 +130,10 @@ export async function startBrowserWithTunnel(testUrl, options = {}) {
|
|
|
101
130
|
* Stop browser and tunnel
|
|
102
131
|
*/
|
|
103
132
|
export async function stopBrowserWithTunnel(session) {
|
|
104
|
-
if (session.
|
|
105
|
-
await session.
|
|
133
|
+
if (session.tunnelHandle) {
|
|
134
|
+
await tunnelRegistry.release(session.tunnelHandle);
|
|
135
|
+
session.tunnelHandle = null;
|
|
136
|
+
session.tunnel = null;
|
|
106
137
|
}
|
|
107
138
|
await session.browser.stopBrowser();
|
|
108
139
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"browser.js","sourceRoot":"","sources":["../../../../src/cli/lib/browser.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"browser.js","sourceRoot":"","sources":["../../../../src/cli/lib/browser.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI re-exec resolver.
|
|
3
|
+
*
|
|
4
|
+
* The detached `browser create` path spawns the CLI binary as a child
|
|
5
|
+
* process and runs it with the hidden `__browser-detach` subcommand. The
|
|
6
|
+
* parent process needs to deterministically figure out the right
|
|
7
|
+
* `(command, args)` to invoke, regardless of HOW the parent was invoked:
|
|
8
|
+
*
|
|
9
|
+
* 1. Installed binary — e.g. `qa-use` on PATH (maps to node or bun
|
|
10
|
+
* executing the compiled entry under `dist/` or a shim script).
|
|
11
|
+
* 2. `bun run cli ...` — `process.argv[1]` is a `.ts` file under the
|
|
12
|
+
* repo and `process.execPath` is the `bun` binary, which handles
|
|
13
|
+
* `.ts` natively.
|
|
14
|
+
* 3. Symlinked binary — `process.argv[1]` is a symlink; `fs.realpathSync`
|
|
15
|
+
* resolves it to the underlying file, so we don't re-exec through a
|
|
16
|
+
* broken symlink.
|
|
17
|
+
*
|
|
18
|
+
* In every case we return `{ command: process.execPath, args: [realPath, ...] }`
|
|
19
|
+
* so the child inherits the same runtime (node OR bun) as the parent.
|
|
20
|
+
*/
|
|
21
|
+
export interface CliEntry {
|
|
22
|
+
/** Executable to spawn (typically `process.execPath`). */
|
|
23
|
+
command: string;
|
|
24
|
+
/** Argv to pass (first element is the real script path). */
|
|
25
|
+
args: string[];
|
|
26
|
+
}
|
|
27
|
+
export interface ResolveCliEntryDeps {
|
|
28
|
+
argv?: string[];
|
|
29
|
+
execPath?: string;
|
|
30
|
+
realpathSync?: (p: string) => string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the `(command, args)` needed to re-exec the CLI binary.
|
|
34
|
+
*
|
|
35
|
+
* When `extraArgs` is provided, they are appended after the resolved
|
|
36
|
+
* script path — callers use this to inject `__browser-detach <session-id>`
|
|
37
|
+
* and related flags.
|
|
38
|
+
*/
|
|
39
|
+
export declare function resolveCliEntry(extraArgs?: string[], deps?: ResolveCliEntryDeps): CliEntry;
|
|
40
|
+
//# sourceMappingURL=cli-entry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli-entry.d.ts","sourceRoot":"","sources":["../../../../src/cli/lib/cli-entry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAIH,MAAM,WAAW,QAAQ;IACvB,0DAA0D;IAC1D,OAAO,EAAE,MAAM,CAAC;IAChB,4DAA4D;IAC5D,IAAI,EAAE,MAAM,EAAE,CAAC;CAChB;AAED,MAAM,WAAW,mBAAmB;IAClC,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;CACtC;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAC7B,SAAS,GAAE,MAAM,EAAO,EACxB,IAAI,GAAE,mBAAwB,GAC7B,QAAQ,CAsCV"}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI re-exec resolver.
|
|
3
|
+
*
|
|
4
|
+
* The detached `browser create` path spawns the CLI binary as a child
|
|
5
|
+
* process and runs it with the hidden `__browser-detach` subcommand. The
|
|
6
|
+
* parent process needs to deterministically figure out the right
|
|
7
|
+
* `(command, args)` to invoke, regardless of HOW the parent was invoked:
|
|
8
|
+
*
|
|
9
|
+
* 1. Installed binary — e.g. `qa-use` on PATH (maps to node or bun
|
|
10
|
+
* executing the compiled entry under `dist/` or a shim script).
|
|
11
|
+
* 2. `bun run cli ...` — `process.argv[1]` is a `.ts` file under the
|
|
12
|
+
* repo and `process.execPath` is the `bun` binary, which handles
|
|
13
|
+
* `.ts` natively.
|
|
14
|
+
* 3. Symlinked binary — `process.argv[1]` is a symlink; `fs.realpathSync`
|
|
15
|
+
* resolves it to the underlying file, so we don't re-exec through a
|
|
16
|
+
* broken symlink.
|
|
17
|
+
*
|
|
18
|
+
* In every case we return `{ command: process.execPath, args: [realPath, ...] }`
|
|
19
|
+
* so the child inherits the same runtime (node OR bun) as the parent.
|
|
20
|
+
*/
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
/**
|
|
23
|
+
* Resolve the `(command, args)` needed to re-exec the CLI binary.
|
|
24
|
+
*
|
|
25
|
+
* When `extraArgs` is provided, they are appended after the resolved
|
|
26
|
+
* script path — callers use this to inject `__browser-detach <session-id>`
|
|
27
|
+
* and related flags.
|
|
28
|
+
*/
|
|
29
|
+
export function resolveCliEntry(extraArgs = [], deps = {}) {
|
|
30
|
+
const argv = deps.argv ?? process.argv;
|
|
31
|
+
const execPath = deps.execPath ?? process.execPath;
|
|
32
|
+
const realpathSync = deps.realpathSync ?? fs.realpathSync;
|
|
33
|
+
const rawEntry = argv[1];
|
|
34
|
+
if (!rawEntry) {
|
|
35
|
+
throw new Error('resolveCliEntry: process.argv[1] is empty — cannot re-exec the CLI');
|
|
36
|
+
}
|
|
37
|
+
let resolvedEntry;
|
|
38
|
+
try {
|
|
39
|
+
resolvedEntry = realpathSync(rawEntry);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// Fallback to the raw path if realpath fails (e.g. rare sandboxed
|
|
43
|
+
// environments). We still want the spawn to attempt with what we have
|
|
44
|
+
// rather than bubble an unrelated error.
|
|
45
|
+
resolvedEntry = rawEntry;
|
|
46
|
+
}
|
|
47
|
+
// If the entry is a TypeScript source file and the runtime is Node.js
|
|
48
|
+
// (not Bun), raw `node` can't execute it — we need the tsx loader. This
|
|
49
|
+
// happens in dev (`bun run cli` / `npm run cli` → `tsx src/cli/index.ts`).
|
|
50
|
+
// Under Bun, .ts is native. In production (installed binary), the entry
|
|
51
|
+
// is a compiled .js under dist/.
|
|
52
|
+
const isTsEntry = /\.(ts|tsx|mts|cts)$/.test(resolvedEntry);
|
|
53
|
+
const isBun = typeof globalThis.Bun !== 'undefined';
|
|
54
|
+
if (isTsEntry && !isBun) {
|
|
55
|
+
return {
|
|
56
|
+
command: execPath,
|
|
57
|
+
args: ['--import', 'tsx', resolvedEntry, ...extraArgs],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
command: execPath,
|
|
62
|
+
args: [resolvedEntry, ...extraArgs],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=cli-entry.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli-entry.js","sourceRoot":"","sources":["../../../../src/cli/lib/cli-entry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAAE,MAAM,SAAS,CAAC;AAezB;;;;;;GAMG;AACH,MAAM,UAAU,eAAe,CAC7B,YAAsB,EAAE,EACxB,OAA4B,EAAE;IAE9B,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IACvC,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,IAAI,OAAO,CAAC,QAAQ,CAAC;IACnD,MAAM,YAAY,GAAG,IAAI,CAAC,YAAY,IAAI,EAAE,CAAC,YAAY,CAAC;IAE1D,MAAM,QAAQ,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC;IACzB,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,MAAM,IAAI,KAAK,CAAC,oEAAoE,CAAC,CAAC;IACxF,CAAC;IAED,IAAI,aAAqB,CAAC;IAC1B,IAAI,CAAC;QACH,aAAa,GAAG,YAAY,CAAC,QAAQ,CAAC,CAAC;IACzC,CAAC;IAAC,MAAM,CAAC;QACP,kEAAkE;QAClE,sEAAsE;QACtE,yCAAyC;QACzC,aAAa,GAAG,QAAQ,CAAC;IAC3B,CAAC;IAED,sEAAsE;IACtE,wEAAwE;IACxE,2EAA2E;IAC3E,wEAAwE;IACxE,iCAAiC;IACjC,MAAM,SAAS,GAAG,qBAAqB,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC;IAC5D,MAAM,KAAK,GAAG,OAAQ,UAAgC,CAAC,GAAG,KAAK,WAAW,CAAC;IAC3E,IAAI,SAAS,IAAI,CAAC,KAAK,EAAE,CAAC;QACxB,OAAO;YACL,OAAO,EAAE,QAAQ;YACjB,IAAI,EAAE,CAAC,UAAU,EAAE,KAAK,EAAE,aAAa,EAAE,GAAG,SAAS,CAAC;SACvD,CAAC;IACJ,CAAC;IAED,OAAO;QACL,OAAO,EAAE,QAAQ;QACjB,IAAI,EAAE,CAAC,aAAa,EAAE,GAAG,SAAS,CAAC;KACpC,CAAC;AACJ,CAAC"}
|
|
@@ -15,6 +15,12 @@ export interface RunTestOptions {
|
|
|
15
15
|
testId?: string;
|
|
16
16
|
/** Run ID for organizing downloads (set automatically) */
|
|
17
17
|
runId?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Idle timeout in seconds. If no SSE events arrive for this long, the
|
|
20
|
+
* underlying fetch is aborted and `runCliTest` rejects with a `timed out`
|
|
21
|
+
* error. `0` (or undefined) disables the watchdog.
|
|
22
|
+
*/
|
|
23
|
+
idleTimeoutSec?: number;
|
|
18
24
|
}
|
|
19
25
|
/**
|
|
20
26
|
* Run a test with real-time progress output
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"runner.d.ts","sourceRoot":"","sources":["../../../../src/cli/lib/runner.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAChG,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AAGxD,MAAM,WAAW,cAAc;IAC7B,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,8CAA8C;IAC9C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,yCAAyC;IACzC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,mCAAmC;IACnC,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,uCAAuC;IACvC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,0DAA0D;IAC1D,KAAK,CAAC,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"runner.d.ts","sourceRoot":"","sources":["../../../../src/cli/lib/runner.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAChG,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AAGxD,MAAM,WAAW,cAAc;IAC7B,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,8CAA8C;IAC9C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,yCAAyC;IACzC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,mCAAmC;IACnC,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,uCAAuC;IACvC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,0DAA0D;IAC1D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;;;GAQG;AACH,wBAAsB,OAAO,CAC3B,MAAM,EAAE,SAAS,EACjB,OAAO,EAAE,iBAAiB,EAC1B,UAAU,GAAE,cAAmB,EAC/B,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,QAAQ,KAAK,IAAI,GAClC,OAAO,CAAC,gBAAgB,CAAC,CA4B3B;AAED;;;;;;GAMG;AACH,wBAAsB,QAAQ,CAC5B,MAAM,EAAE,SAAS,EACjB,KAAK,EAAE,iBAAiB,EAAE,GACzB,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAU7B"}
|
|
@@ -12,7 +12,7 @@ import { printSSEProgress } from './output.js';
|
|
|
12
12
|
* @returns Test result
|
|
13
13
|
*/
|
|
14
14
|
export async function runTest(client, options, runOptions = {}, onEvent) {
|
|
15
|
-
const { verbose = false, sourceFile, download, downloadBaseDir, testId, runId } = runOptions;
|
|
15
|
+
const { verbose = false, sourceFile, download, downloadBaseDir, testId, runId, idleTimeoutSec, } = runOptions;
|
|
16
16
|
// Build context for SSE progress handler
|
|
17
17
|
const context = sourceFile || download ? { sourceFile, download, downloadBaseDir, testId, runId } : undefined;
|
|
18
18
|
return await client.runCliTest(options, (event) => {
|
|
@@ -22,7 +22,7 @@ export async function runTest(client, options, runOptions = {}, onEvent) {
|
|
|
22
22
|
if (onEvent) {
|
|
23
23
|
onEvent(event);
|
|
24
24
|
}
|
|
25
|
-
});
|
|
25
|
+
}, { idleTimeoutSec });
|
|
26
26
|
}
|
|
27
27
|
/**
|
|
28
28
|
* Run multiple tests sequentially
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"runner.js","sourceRoot":"","sources":["../../../../src/cli/lib/runner.ts"],"names":[],"mappings":"AAAA;;GAEG;AAIH,OAAO,EAAE,gBAAgB,EAA2B,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"runner.js","sourceRoot":"","sources":["../../../../src/cli/lib/runner.ts"],"names":[],"mappings":"AAAA;;GAEG;AAIH,OAAO,EAAE,gBAAgB,EAA2B,MAAM,aAAa,CAAC;AAsBxE;;;;;;;;GAQG;AACH,MAAM,CAAC,KAAK,UAAU,OAAO,CAC3B,MAAiB,EACjB,OAA0B,EAC1B,aAA6B,EAAE,EAC/B,OAAmC;IAEnC,MAAM,EACJ,OAAO,GAAG,KAAK,EACf,UAAU,EACV,QAAQ,EACR,eAAe,EACf,MAAM,EACN,KAAK,EACL,cAAc,GACf,GAAG,UAAU,CAAC;IAEf,yCAAyC;IACzC,MAAM,OAAO,GACX,UAAU,IAAI,QAAQ,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;IAEhG,OAAO,MAAM,MAAM,CAAC,UAAU,CAC5B,OAAO,EACP,CAAC,KAAK,EAAE,EAAE;QACR,4BAA4B;QAC5B,gBAAgB,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;QAE1C,uCAAuC;QACvC,IAAI,OAAO,EAAE,CAAC;YACZ,OAAO,CAAC,KAAK,CAAC,CAAC;QACjB,CAAC;IACH,CAAC,EACD,EAAE,cAAc,EAAE,CACnB,CAAC;AACJ,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,QAAQ,CAC5B,MAAiB,EACjB,KAA0B;IAE1B,MAAM,OAAO,GAAuB,EAAE,CAAC;IAEvC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACtC,OAAO,CAAC,GAAG,CAAC,kBAAkB,CAAC,GAAG,CAAC,IAAI,KAAK,CAAC,MAAM,OAAO,CAAC,CAAC;QAC5D,MAAM,MAAM,GAAG,MAAM,OAAO,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;QAC/C,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IACvB,CAAC;IAED,OAAO,OAAO,CAAC;AACjB,CAAC"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded startup sweep — runs on every CLI invocation except `doctor`
|
|
3
|
+
* itself and `__browser-detach`. Cheap, silent-on-success cleanup of
|
|
4
|
+
* orphaned PID files whose owning process is no longer alive.
|
|
5
|
+
*
|
|
6
|
+
* Design constraints:
|
|
7
|
+
* - **Budget**: 250 ms hard cap. We stop iterating early if the budget
|
|
8
|
+
* is exceeded, even if more stale entries remain. `doctor` picks up
|
|
9
|
+
* the rest on the next explicit run.
|
|
10
|
+
* - **Zero net/API calls**. We only remove PID files and force-close
|
|
11
|
+
* tunnel registry entries (in-process). Backend session-end calls
|
|
12
|
+
* belong to `doctor`, not to the sweep.
|
|
13
|
+
* - **Silent on success**. A single-line stderr notice only when we
|
|
14
|
+
* actually reaped something: `qa-use: cleaned up N stale session(s)`.
|
|
15
|
+
* - **Safe on empty state**. If `~/.qa-use/sessions/` or
|
|
16
|
+
* `~/.qa-use/tunnels/` doesn't exist, return immediately.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Determine whether to run the sweep for this CLI invocation. Looks at
|
|
20
|
+
* `process.argv` positionally — we don't have the parsed Commander tree
|
|
21
|
+
* available at this stage.
|
|
22
|
+
*/
|
|
23
|
+
export declare function shouldSweep(argv?: string[]): boolean;
|
|
24
|
+
interface SweepResult {
|
|
25
|
+
reapedSessions: number;
|
|
26
|
+
reapedTunnels: number;
|
|
27
|
+
budgetExceeded: boolean;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Run the bounded sweep. Returns a summary object; callers may use it for
|
|
31
|
+
* tests. The function never throws — failures are swallowed so startup
|
|
32
|
+
* is never blocked.
|
|
33
|
+
*/
|
|
34
|
+
export declare function runStartupSweep(): Promise<SweepResult>;
|
|
35
|
+
/**
|
|
36
|
+
* Entry point for `src/cli/index.ts`. Fire-and-forget: we kick off the
|
|
37
|
+
* sweep but don't await it — subsequent CLI work can run in parallel.
|
|
38
|
+
* The sweep's own budget guarantees it won't hold the process open for
|
|
39
|
+
* long.
|
|
40
|
+
*
|
|
41
|
+
* If the sweep reaped anything, we emit a single-line stderr notice.
|
|
42
|
+
*/
|
|
43
|
+
export declare function kickoffStartupSweep(argv?: string[]): void;
|
|
44
|
+
export {};
|
|
45
|
+
//# sourceMappingURL=startup-sweep.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"startup-sweep.d.ts","sourceRoot":"","sources":["../../../../src/cli/lib/startup-sweep.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAqBH;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,IAAI,GAAE,MAAM,EAAiB,GAAG,OAAO,CA+BlE;AAED,UAAU,WAAW;IACnB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,CAAC;IACtB,cAAc,EAAE,OAAO,CAAC;CACzB;AAwGD;;;;GAIG;AACH,wBAAsB,eAAe,IAAI,OAAO,CAAC,WAAW,CAAC,CAmB5D;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,GAAE,MAAM,EAAiB,GAAG,IAAI,CAuBvE"}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded startup sweep — runs on every CLI invocation except `doctor`
|
|
3
|
+
* itself and `__browser-detach`. Cheap, silent-on-success cleanup of
|
|
4
|
+
* orphaned PID files whose owning process is no longer alive.
|
|
5
|
+
*
|
|
6
|
+
* Design constraints:
|
|
7
|
+
* - **Budget**: 250 ms hard cap. We stop iterating early if the budget
|
|
8
|
+
* is exceeded, even if more stale entries remain. `doctor` picks up
|
|
9
|
+
* the rest on the next explicit run.
|
|
10
|
+
* - **Zero net/API calls**. We only remove PID files and force-close
|
|
11
|
+
* tunnel registry entries (in-process). Backend session-end calls
|
|
12
|
+
* belong to `doctor`, not to the sweep.
|
|
13
|
+
* - **Silent on success**. A single-line stderr notice only when we
|
|
14
|
+
* actually reaped something: `qa-use: cleaned up N stale session(s)`.
|
|
15
|
+
* - **Safe on empty state**. If `~/.qa-use/sessions/` or
|
|
16
|
+
* `~/.qa-use/tunnels/` doesn't exist, return immediately.
|
|
17
|
+
*/
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { sessionsDir, tunnelsDir } from '../../../lib/env/paths.js';
|
|
21
|
+
import { isPidAlive } from '../../../lib/env/sessions.js';
|
|
22
|
+
import { tunnelRegistry } from '../../../lib/tunnel/registry.js';
|
|
23
|
+
const BUDGET_MS = 250;
|
|
24
|
+
/**
|
|
25
|
+
* Commands that MUST NOT trigger a sweep:
|
|
26
|
+
* - `doctor` — runs its own fuller reap; double-reap is a no-op but
|
|
27
|
+
* noisy when it prints "cleaned up N" from the sweep, immediately
|
|
28
|
+
* followed by doctor's own report of zero.
|
|
29
|
+
* - `__browser-detach` — the detached child. It is itself a freshly
|
|
30
|
+
* spawned process whose PID file we may not have written yet; the
|
|
31
|
+
* sweep must not race with that write.
|
|
32
|
+
*/
|
|
33
|
+
const SKIP_COMMANDS = new Set(['doctor', '__browser-detach']);
|
|
34
|
+
/**
|
|
35
|
+
* Determine whether to run the sweep for this CLI invocation. Looks at
|
|
36
|
+
* `process.argv` positionally — we don't have the parsed Commander tree
|
|
37
|
+
* available at this stage.
|
|
38
|
+
*/
|
|
39
|
+
export function shouldSweep(argv = process.argv) {
|
|
40
|
+
// argv[0] = node, argv[1] = cli entry. Command is argv[2] unless it's
|
|
41
|
+
// a flag (e.g. `--version`, `--help` alone). Additionally, many nested
|
|
42
|
+
// commands (e.g. `browser __browser-detach`) have the sentinel in
|
|
43
|
+
// argv[3]; scan the first few entries.
|
|
44
|
+
for (let i = 2; i < Math.min(argv.length, 5); i++) {
|
|
45
|
+
const token = argv[i];
|
|
46
|
+
if (!token || token.startsWith('-'))
|
|
47
|
+
continue;
|
|
48
|
+
if (SKIP_COMMANDS.has(token))
|
|
49
|
+
return false;
|
|
50
|
+
// First non-flag token found; it's the top-level command. Return true
|
|
51
|
+
// if it isn't a skip command (already handled).
|
|
52
|
+
// We still check nested tokens because `browser __browser-detach` has
|
|
53
|
+
// the sentinel in position i+1.
|
|
54
|
+
}
|
|
55
|
+
// Also explicitly scan entire argv for __browser-detach (can be nested).
|
|
56
|
+
if (argv.includes('__browser-detach'))
|
|
57
|
+
return false;
|
|
58
|
+
// `browser status` (with or without --list / session id) must not sweep:
|
|
59
|
+
// the sweep would race with rendering and silently reap the very
|
|
60
|
+
// stale entries the user asked to see. `browser` followed (somewhere)
|
|
61
|
+
// by `status` is the canonical shape we skip. Other `browser`
|
|
62
|
+
// subcommands (create/close/snapshot/...) still sweep normally.
|
|
63
|
+
const browserIdx = argv.indexOf('browser');
|
|
64
|
+
if (browserIdx !== -1) {
|
|
65
|
+
for (let i = browserIdx + 1; i < argv.length; i++) {
|
|
66
|
+
const token = argv[i];
|
|
67
|
+
if (!token || token.startsWith('-'))
|
|
68
|
+
continue;
|
|
69
|
+
if (token === 'status')
|
|
70
|
+
return false;
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
async function sweepSessions(deadline) {
|
|
77
|
+
const dir = sessionsDir();
|
|
78
|
+
let files;
|
|
79
|
+
try {
|
|
80
|
+
files = fs.readdirSync(dir);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
let reaped = 0;
|
|
86
|
+
for (const name of files) {
|
|
87
|
+
if (Date.now() >= deadline)
|
|
88
|
+
break;
|
|
89
|
+
if (!name.endsWith('.json') || name.endsWith('.tmp'))
|
|
90
|
+
continue;
|
|
91
|
+
const file = path.join(dir, name);
|
|
92
|
+
let parsed = null;
|
|
93
|
+
try {
|
|
94
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
95
|
+
parsed = JSON.parse(raw);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// Unreadable file — remove it.
|
|
99
|
+
try {
|
|
100
|
+
fs.unlinkSync(file);
|
|
101
|
+
reaped += 1;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
/* ignore */
|
|
105
|
+
}
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (!parsed || typeof parsed.pid !== 'number') {
|
|
109
|
+
try {
|
|
110
|
+
fs.unlinkSync(file);
|
|
111
|
+
reaped += 1;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
/* ignore */
|
|
115
|
+
}
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (!isPidAlive(parsed.pid)) {
|
|
119
|
+
try {
|
|
120
|
+
fs.unlinkSync(file);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
/* already gone */
|
|
124
|
+
}
|
|
125
|
+
// Best-effort: if the session referenced a tunnel target, ensure
|
|
126
|
+
// any registry handle is released in-process. No net calls.
|
|
127
|
+
if (parsed.target) {
|
|
128
|
+
try {
|
|
129
|
+
await tunnelRegistry.forceClose(parsed.target);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
/* best-effort */
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
reaped += 1;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return reaped;
|
|
139
|
+
}
|
|
140
|
+
async function sweepTunnels(deadline) {
|
|
141
|
+
const dir = tunnelsDir();
|
|
142
|
+
let files;
|
|
143
|
+
try {
|
|
144
|
+
files = fs.readdirSync(dir);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
let reaped = 0;
|
|
150
|
+
for (const name of files) {
|
|
151
|
+
if (Date.now() >= deadline)
|
|
152
|
+
break;
|
|
153
|
+
if (!name.endsWith('.json') || name.endsWith('.tmp'))
|
|
154
|
+
continue;
|
|
155
|
+
const file = path.join(dir, name);
|
|
156
|
+
let parsed = null;
|
|
157
|
+
try {
|
|
158
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
159
|
+
parsed = JSON.parse(raw);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
try {
|
|
163
|
+
fs.unlinkSync(file);
|
|
164
|
+
reaped += 1;
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
/* ignore */
|
|
168
|
+
}
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (!parsed || typeof parsed.pid !== 'number' || !isPidAlive(parsed.pid)) {
|
|
172
|
+
if (parsed?.target) {
|
|
173
|
+
try {
|
|
174
|
+
await tunnelRegistry.forceClose(parsed.target);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
/* best-effort; fall through to manual unlink */
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
fs.unlinkSync(file);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
/* already gone */
|
|
185
|
+
}
|
|
186
|
+
reaped += 1;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return reaped;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Run the bounded sweep. Returns a summary object; callers may use it for
|
|
193
|
+
* tests. The function never throws — failures are swallowed so startup
|
|
194
|
+
* is never blocked.
|
|
195
|
+
*/
|
|
196
|
+
export async function runStartupSweep() {
|
|
197
|
+
const deadline = Date.now() + BUDGET_MS;
|
|
198
|
+
const result = {
|
|
199
|
+
reapedSessions: 0,
|
|
200
|
+
reapedTunnels: 0,
|
|
201
|
+
budgetExceeded: false,
|
|
202
|
+
};
|
|
203
|
+
try {
|
|
204
|
+
result.reapedSessions = await sweepSessions(deadline);
|
|
205
|
+
if (Date.now() < deadline) {
|
|
206
|
+
result.reapedTunnels = await sweepTunnels(deadline);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
/* never surface sweep failures */
|
|
211
|
+
}
|
|
212
|
+
if (Date.now() >= deadline) {
|
|
213
|
+
result.budgetExceeded = true;
|
|
214
|
+
}
|
|
215
|
+
return result;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Entry point for `src/cli/index.ts`. Fire-and-forget: we kick off the
|
|
219
|
+
* sweep but don't await it — subsequent CLI work can run in parallel.
|
|
220
|
+
* The sweep's own budget guarantees it won't hold the process open for
|
|
221
|
+
* long.
|
|
222
|
+
*
|
|
223
|
+
* If the sweep reaped anything, we emit a single-line stderr notice.
|
|
224
|
+
*/
|
|
225
|
+
export function kickoffStartupSweep(argv = process.argv) {
|
|
226
|
+
if (!shouldSweep(argv))
|
|
227
|
+
return;
|
|
228
|
+
void runStartupSweep()
|
|
229
|
+
.then((result) => {
|
|
230
|
+
const total = result.reapedSessions + result.reapedTunnels;
|
|
231
|
+
if (total > 0) {
|
|
232
|
+
const parts = [];
|
|
233
|
+
if (result.reapedSessions > 0) {
|
|
234
|
+
parts.push(`${result.reapedSessions} stale session${result.reapedSessions === 1 ? '' : 's'}`);
|
|
235
|
+
}
|
|
236
|
+
if (result.reapedTunnels > 0) {
|
|
237
|
+
parts.push(`${result.reapedTunnels} stale tunnel${result.reapedTunnels === 1 ? '' : 's'}`);
|
|
238
|
+
}
|
|
239
|
+
console.error(`qa-use: cleaned up ${parts.join(' + ')}`);
|
|
240
|
+
}
|
|
241
|
+
})
|
|
242
|
+
.catch(() => {
|
|
243
|
+
/* never surface sweep failures */
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
//# sourceMappingURL=startup-sweep.js.map
|