@rightkit/qa 0.4.6 → 0.4.7
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/devserver.d.ts +86 -0
- package/dist/devserver.js +205 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/process.d.ts +2 -0
- package/dist/process.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -105,6 +105,29 @@ try {
|
|
|
105
105
|
|
|
106
106
|
`freePort()` returns a free `127.0.0.1` port by binding port 0 and releasing it. Another process can take the port before use.
|
|
107
107
|
|
|
108
|
+
## Dev-server launcher
|
|
109
|
+
|
|
110
|
+
`startDevServer({ command, args?, cwd?, env?, shell?, windowsHide?, ports?, portFloor?, portEnv?, maxPortAttempts?, readiness })` starts a dev server hidden, gives it a port, and waits until it answers.
|
|
111
|
+
|
|
112
|
+
- The port goes to the process as the environment variable `portEnv` (default `PORT`), and every `{port}` in `args` is replaced with it.
|
|
113
|
+
- `readiness`: `urls` (an array, or a function of the port) that must each answer 2xx, or a `check(port)` predicate. `attemptTimeoutMs` bounds one request, `totalTimeoutMs` one launch's wait, `intervalMs` the delay between rounds.
|
|
114
|
+
- Port choice: `ports` in order (a held port is skipped before anything is spawned on it); otherwise the first free port at or above `portFloor`, probed by binding; otherwise `freePort()`. A collision (EADDRINUSE in the output) moves to the next candidate.
|
|
115
|
+
- Result: `{ pid, port, url, attempts, stop(), output() }`. `stop()` kills the whole process tree the launch started and reports `leftoverPids`.
|
|
116
|
+
- A failed launch stops its whole tree before it throws `DevServerError` (`reason`, `pid`, `port`, `attempts`, `leftoverPids`, and a bounded `output` tail). The message includes the last 2 KB of output.
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
const dev = await startDevServer({
|
|
120
|
+
command: process.execPath,
|
|
121
|
+
args: [viteBin, "--host", "127.0.0.1", "--port", "{port}", "--strictPort"],
|
|
122
|
+
portFloor: 5173,
|
|
123
|
+
cwd: root,
|
|
124
|
+
env: { VITE_MAILRIGHT_QA: "1" },
|
|
125
|
+
readiness: { urls: (port) => [`http://127.0.0.1:${port}/`], attemptTimeoutMs: 2500, totalTimeoutMs: 20_000, intervalMs: 300 },
|
|
126
|
+
});
|
|
127
|
+
// ... run the tests against dev.url ...
|
|
128
|
+
await dev.stop();
|
|
129
|
+
```
|
|
130
|
+
|
|
108
131
|
## Keyboard keys
|
|
109
132
|
|
|
110
133
|
`page.keyboard.press(chord)` accepts named keys (`Enter`, `Tab`, `Escape`, arrows, `Home`, `End`, `PageUp`, `PageDown`, `Backspace`, `Delete`, `Space`, `Shift`, `Control`, `Alt`, `Meta`), function keys `F1` through `F24`, letters, digits, and every printable US-layout symbol, including backslash.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why a dev server failed to become ready. `port-in-use`: every candidate port was taken.
|
|
3
|
+
* `exited`: the process ended first. `timeout`: the total budget passed. `check-failed`: the
|
|
4
|
+
* caller's check threw. `start-failed`: the command could not be started.
|
|
5
|
+
*/
|
|
6
|
+
export type DevServerFailure = 'exited' | 'timeout' | 'port-in-use' | 'check-failed' | 'start-failed';
|
|
7
|
+
export interface DevServerReadiness {
|
|
8
|
+
/**
|
|
9
|
+
* URLs that must each answer 2xx to a GET. Required unless `check` is given. A function receives
|
|
10
|
+
* the chosen port.
|
|
11
|
+
*/
|
|
12
|
+
urls?: string[] | ((port: number) => string[]);
|
|
13
|
+
/** Bound for one request (or one `check` call). */
|
|
14
|
+
attemptTimeoutMs: number;
|
|
15
|
+
/** Bound for the readiness wait of one launch attempt. */
|
|
16
|
+
totalTimeoutMs: number;
|
|
17
|
+
/** Delay between rounds of probes. */
|
|
18
|
+
intervalMs: number;
|
|
19
|
+
/** Caller predicate used instead of the URL probe. True means ready. A throw fails the launch. */
|
|
20
|
+
check?: (port: number) => boolean | Promise<boolean>;
|
|
21
|
+
}
|
|
22
|
+
export interface DevServerOptions {
|
|
23
|
+
/** Executable to start, hidden by default. */
|
|
24
|
+
command: string;
|
|
25
|
+
/** Arguments; every `{port}` is replaced with the chosen port. */
|
|
26
|
+
args?: string[];
|
|
27
|
+
cwd?: string;
|
|
28
|
+
/** Merged over process.env. The chosen port is set under `portEnv`. */
|
|
29
|
+
env?: NodeJS.ProcessEnv;
|
|
30
|
+
/** Run through the platform shell (for .cmd shims such as npm on Windows). Default false. */
|
|
31
|
+
shell?: boolean;
|
|
32
|
+
/** Default true: no visible console window on Windows. */
|
|
33
|
+
windowsHide?: boolean;
|
|
34
|
+
/** Candidate ports, tried in order. A collision moves to the next one. */
|
|
35
|
+
ports?: number[];
|
|
36
|
+
/** Without `ports`: the first free port at or above this one is chosen (probed by binding). */
|
|
37
|
+
portFloor?: number;
|
|
38
|
+
/** Environment variable that receives the port. Default `PORT`. */
|
|
39
|
+
portEnv?: string;
|
|
40
|
+
/** Launch attempts before giving up on port collisions. Default 5. With `ports`, at most the list length. */
|
|
41
|
+
maxPortAttempts?: number;
|
|
42
|
+
readiness: DevServerReadiness;
|
|
43
|
+
}
|
|
44
|
+
export interface DevServerStopReport {
|
|
45
|
+
/** Pids this launch started that are still running after the stop. Empty when the tree is gone. */
|
|
46
|
+
leftoverPids: number[];
|
|
47
|
+
}
|
|
48
|
+
/** A dev server that is ready. The process was launched by this package and is stopped by `stop()`. */
|
|
49
|
+
export interface DevServer {
|
|
50
|
+
pid: number;
|
|
51
|
+
port: number;
|
|
52
|
+
/** `http://127.0.0.1:<port>`. */
|
|
53
|
+
url: string;
|
|
54
|
+
/** Launch attempts it took, including the successful one. */
|
|
55
|
+
attempts: number;
|
|
56
|
+
/** Stops the whole process tree this launch started. Idempotent. */
|
|
57
|
+
stop(): Promise<DevServerStopReport>;
|
|
58
|
+
/** The bounded tail of stdout and stderr so far. */
|
|
59
|
+
output(): string;
|
|
60
|
+
}
|
|
61
|
+
/** A failed launch. Its process tree has already been stopped; `leftoverPids` lists what remained. */
|
|
62
|
+
export declare class DevServerError extends Error {
|
|
63
|
+
readonly reason: DevServerFailure;
|
|
64
|
+
readonly pid?: number;
|
|
65
|
+
readonly port?: number;
|
|
66
|
+
readonly attempts: number;
|
|
67
|
+
/** Bounded tail of the server's output. */
|
|
68
|
+
readonly output: string;
|
|
69
|
+
readonly leftoverPids: number[];
|
|
70
|
+
constructor(reason: DevServerFailure, detail: string, fields: {
|
|
71
|
+
pid?: number;
|
|
72
|
+
port?: number;
|
|
73
|
+
attempts: number;
|
|
74
|
+
output: string;
|
|
75
|
+
leftoverPids: number[];
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Starts a dev server hidden, chooses its port, and waits until it is ready. Failure, including a
|
|
80
|
+
* port collision that exhausts the candidates, stops the whole process tree this launch started and
|
|
81
|
+
* reports leftovers. A port collision (EADDRINUSE in the output) moves to the next candidate.
|
|
82
|
+
*
|
|
83
|
+
* A probe cannot take a floor, so `portFloor` probes upward from the floor with the same bind test
|
|
84
|
+
* that `freePort` uses; another process can still take the port before the server binds it.
|
|
85
|
+
*/
|
|
86
|
+
export declare function startDevServer(options: DevServerOptions): Promise<DevServer>;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { createServer } from 'node:net';
|
|
2
|
+
import { validateTimeout } from './cdp.js';
|
|
3
|
+
import { freePort, ownedProcessReceipt, spawnOwnedProcess, stopOwnedProcess } from './process.js';
|
|
4
|
+
/** Bounded tail of the server's stdout and stderr kept for error messages. */
|
|
5
|
+
const OUTPUT_LIMIT = 16 * 1024;
|
|
6
|
+
/** Output that means the server lost its port race. */
|
|
7
|
+
const COLLISION = /EADDRINUSE|address already in use/i;
|
|
8
|
+
/** A failed launch. Its process tree has already been stopped; `leftoverPids` lists what remained. */
|
|
9
|
+
export class DevServerError extends Error {
|
|
10
|
+
reason;
|
|
11
|
+
pid;
|
|
12
|
+
port;
|
|
13
|
+
attempts;
|
|
14
|
+
/** Bounded tail of the server's output. */
|
|
15
|
+
output;
|
|
16
|
+
leftoverPids;
|
|
17
|
+
constructor(reason, detail, fields) {
|
|
18
|
+
const tail = fields.output.trim().slice(-2_000);
|
|
19
|
+
super(`dev server ${reason}: ${detail}${tail ? `\n--- output tail ---\n${tail}` : ''}`);
|
|
20
|
+
this.name = 'DevServerError';
|
|
21
|
+
this.reason = reason;
|
|
22
|
+
this.pid = fields.pid;
|
|
23
|
+
this.port = fields.port;
|
|
24
|
+
this.attempts = fields.attempts;
|
|
25
|
+
this.output = fields.output;
|
|
26
|
+
this.leftoverPids = fields.leftoverPids;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function validPort(port) { return Number.isInteger(port) && port >= 1 && port <= 65535; }
|
|
30
|
+
function validateOptions(options) {
|
|
31
|
+
if (typeof options.command !== 'string' || !options.command)
|
|
32
|
+
throw new Error('command must be a non-empty string');
|
|
33
|
+
const { readiness } = options;
|
|
34
|
+
validateTimeout(readiness.attemptTimeoutMs);
|
|
35
|
+
validateTimeout(readiness.totalTimeoutMs);
|
|
36
|
+
if (!Number.isFinite(readiness.intervalMs) || readiness.intervalMs <= 0)
|
|
37
|
+
throw new Error('readiness.intervalMs must be a positive number');
|
|
38
|
+
if (readiness.urls === undefined && !readiness.check)
|
|
39
|
+
throw new Error('readiness needs urls or check');
|
|
40
|
+
if (options.ports !== undefined && (!Array.isArray(options.ports) || options.ports.length === 0 || !options.ports.every(validPort))) {
|
|
41
|
+
throw new Error('ports must be a non-empty list of ports from 1 to 65535');
|
|
42
|
+
}
|
|
43
|
+
if (options.portFloor !== undefined && !validPort(options.portFloor))
|
|
44
|
+
throw new Error('portFloor must be a port from 1 to 65535');
|
|
45
|
+
if (options.maxPortAttempts !== undefined && (!Number.isInteger(options.maxPortAttempts) || options.maxPortAttempts < 1))
|
|
46
|
+
throw new Error('maxPortAttempts must be a positive integer');
|
|
47
|
+
if (options.portEnv !== undefined && !options.portEnv)
|
|
48
|
+
throw new Error('portEnv must be a non-empty name');
|
|
49
|
+
}
|
|
50
|
+
/** Binds 127.0.0.1:<port> briefly; true when nothing else holds it. */
|
|
51
|
+
function portFreeOn(port) {
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
const server = createServer();
|
|
54
|
+
server.once('error', () => resolve(false));
|
|
55
|
+
server.listen(port, '127.0.0.1', () => server.close(() => resolve(true)));
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/** The first free port at or above `floor`, skipping `skip`. */
|
|
59
|
+
async function probeFrom(floor, skip) {
|
|
60
|
+
for (let port = floor; port <= 65_535; port++) {
|
|
61
|
+
if (!skip.has(port) && (await portFreeOn(port)))
|
|
62
|
+
return port;
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
async function probeUrl(url, timeoutMs) {
|
|
67
|
+
try {
|
|
68
|
+
const response = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(timeoutMs) });
|
|
69
|
+
await response.body?.cancel().catch(() => { });
|
|
70
|
+
return response.status >= 200 && response.status < 300;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** One round of readiness: every URL answers 2xx, or the check resolves true. A hung check counts as not ready. */
|
|
77
|
+
async function roundReady(readiness, port, attemptTimeoutMs) {
|
|
78
|
+
if (readiness.check) {
|
|
79
|
+
let timer;
|
|
80
|
+
try {
|
|
81
|
+
return await Promise.race([
|
|
82
|
+
Promise.resolve().then(() => readiness.check(port)),
|
|
83
|
+
new Promise((resolve) => { timer = setTimeout(() => resolve(false), attemptTimeoutMs); }),
|
|
84
|
+
]);
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const urls = typeof readiness.urls === 'function' ? readiness.urls(port) : readiness.urls ?? [];
|
|
91
|
+
for (const url of urls)
|
|
92
|
+
if (!(await probeUrl(url, attemptTimeoutMs)))
|
|
93
|
+
return false;
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
97
|
+
/**
|
|
98
|
+
* Starts a dev server hidden, chooses its port, and waits until it is ready. Failure, including a
|
|
99
|
+
* port collision that exhausts the candidates, stops the whole process tree this launch started and
|
|
100
|
+
* reports leftovers. A port collision (EADDRINUSE in the output) moves to the next candidate.
|
|
101
|
+
*
|
|
102
|
+
* A probe cannot take a floor, so `portFloor` probes upward from the floor with the same bind test
|
|
103
|
+
* that `freePort` uses; another process can still take the port before the server binds it.
|
|
104
|
+
*/
|
|
105
|
+
export async function startDevServer(options) {
|
|
106
|
+
validateOptions(options);
|
|
107
|
+
const portEnv = options.portEnv ?? 'PORT';
|
|
108
|
+
const maxAttempts = Math.min(options.maxPortAttempts ?? 5, options.ports?.length ?? Number.POSITIVE_INFINITY);
|
|
109
|
+
const candidates = options.ports ? [...options.ports] : undefined;
|
|
110
|
+
const tried = new Set();
|
|
111
|
+
let floor = options.portFloor;
|
|
112
|
+
let attempts = 0;
|
|
113
|
+
for (;;) {
|
|
114
|
+
if (attempts >= maxAttempts)
|
|
115
|
+
throw new DevServerError('port-in-use', `no free port after ${attempts} attempt(s)`, { attempts, output: '', leftoverPids: [] });
|
|
116
|
+
let port;
|
|
117
|
+
if (candidates) {
|
|
118
|
+
port = candidates.shift();
|
|
119
|
+
// A listed port that is already held is skipped before anything is spawned on it. Probing a
|
|
120
|
+
// foreign listener's port would count its answers as readiness, and would keep sockets to it open.
|
|
121
|
+
attempts++;
|
|
122
|
+
if (port !== undefined && !(await portFreeOn(port))) {
|
|
123
|
+
tried.add(port);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
else if (floor !== undefined)
|
|
128
|
+
port = await probeFrom(floor, tried);
|
|
129
|
+
else
|
|
130
|
+
port = await freePort();
|
|
131
|
+
if (port === undefined)
|
|
132
|
+
throw new DevServerError('port-in-use', candidates ? 'every listed port is in use' : `no free port at or above ${floor}`, { attempts, output: '', leftoverPids: [] });
|
|
133
|
+
tried.add(port);
|
|
134
|
+
floor = port + 1;
|
|
135
|
+
if (!candidates)
|
|
136
|
+
attempts++;
|
|
137
|
+
const launched = await launchOnce(options, port, portEnv, attempts);
|
|
138
|
+
if (launched)
|
|
139
|
+
return launched;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** One launch on one port. Returns the ready server, or undefined after a port collision (already stopped). */
|
|
143
|
+
async function launchOnce(options, port, portEnv, attempts) {
|
|
144
|
+
const { readiness } = options;
|
|
145
|
+
let handle;
|
|
146
|
+
try {
|
|
147
|
+
handle = spawnOwnedProcess(options.command, (options.args ?? []).map((arg) => arg.replaceAll('{port}', String(port))), {
|
|
148
|
+
cwd: options.cwd,
|
|
149
|
+
env: { ...process.env, ...options.env, [portEnv]: String(port) },
|
|
150
|
+
stdio: 'pipe',
|
|
151
|
+
windowsHide: options.windowsHide ?? true,
|
|
152
|
+
shell: options.shell ?? false,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
throw new DevServerError('start-failed', error instanceof Error ? error.message : String(error), { port, attempts, output: '', leftoverPids: [] });
|
|
157
|
+
}
|
|
158
|
+
let output = '';
|
|
159
|
+
const append = (chunk) => { output = (output + chunk).slice(-OUTPUT_LIMIT); };
|
|
160
|
+
handle.child.stdout?.setEncoding('utf8');
|
|
161
|
+
handle.child.stderr?.setEncoding('utf8');
|
|
162
|
+
handle.child.stdout?.on('data', append);
|
|
163
|
+
handle.child.stderr?.on('data', append);
|
|
164
|
+
let exit;
|
|
165
|
+
handle.child.once('exit', (code, signal) => { exit = { code, signal }; });
|
|
166
|
+
/** Stops this launch's tree, then fails, or reports a collision as a retry (undefined) when nothing leaked. */
|
|
167
|
+
const finish = async (reason, detail) => {
|
|
168
|
+
await stopOwnedProcess(handle).catch(() => { });
|
|
169
|
+
const leftoverPids = ownedProcessReceipt().leftoverPids.filter((pid) => pid === handle.pid);
|
|
170
|
+
if (reason === 'collision' && leftoverPids.length === 0)
|
|
171
|
+
return undefined;
|
|
172
|
+
throw new DevServerError(reason === 'collision' ? 'port-in-use' : reason, detail, { pid: handle.pid, port, attempts, output, leftoverPids });
|
|
173
|
+
};
|
|
174
|
+
const deadline = Date.now() + readiness.totalTimeoutMs;
|
|
175
|
+
for (;;) {
|
|
176
|
+
if (COLLISION.test(output))
|
|
177
|
+
return finish('collision', `port ${port} is in use`);
|
|
178
|
+
if (exit)
|
|
179
|
+
return finish('exited', `process exited before ready (code ${exit.code}, signal ${exit.signal})`);
|
|
180
|
+
let ready;
|
|
181
|
+
try {
|
|
182
|
+
ready = await roundReady(readiness, port, readiness.attemptTimeoutMs);
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
return finish('check-failed', error instanceof Error ? error.message : String(error));
|
|
186
|
+
}
|
|
187
|
+
if (ready) {
|
|
188
|
+
let stopping;
|
|
189
|
+
return {
|
|
190
|
+
pid: handle.pid,
|
|
191
|
+
port,
|
|
192
|
+
url: `http://127.0.0.1:${port}`,
|
|
193
|
+
attempts,
|
|
194
|
+
stop: () => (stopping ??= (async () => {
|
|
195
|
+
await stopOwnedProcess(handle);
|
|
196
|
+
return { leftoverPids: ownedProcessReceipt().leftoverPids.filter((pid) => pid === handle.pid) };
|
|
197
|
+
})()),
|
|
198
|
+
output: () => output,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
if (Date.now() >= deadline)
|
|
202
|
+
return finish('timeout', `not ready after ${readiness.totalTimeoutMs}ms on port ${port}`);
|
|
203
|
+
await sleep(Math.max(1, Math.min(readiness.intervalMs, deadline - Date.now())));
|
|
204
|
+
}
|
|
205
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/process.d.ts
CHANGED
|
@@ -32,6 +32,8 @@ export interface SpawnOwnedProcessOptions {
|
|
|
32
32
|
/** Default 'ignore'. 'pipe' exposes child.stdout and child.stderr. */
|
|
33
33
|
stdio?: 'ignore' | 'pipe';
|
|
34
34
|
windowsHide?: boolean;
|
|
35
|
+
/** Run through the platform shell (needed for .cmd shims such as npm on Windows). Default false. */
|
|
36
|
+
shell?: boolean;
|
|
35
37
|
}
|
|
36
38
|
/**
|
|
37
39
|
* A process this package launched. Only the object returned by spawnOwnedProcess is accepted by
|
package/dist/process.js
CHANGED
|
@@ -147,6 +147,7 @@ export function spawnOwnedProcess(command, args = [], options = {}) {
|
|
|
147
147
|
stdio: options.stdio === 'pipe' ? ['ignore', 'pipe', 'pipe'] : 'ignore',
|
|
148
148
|
windowsHide: options.windowsHide ?? true,
|
|
149
149
|
detached: process.platform !== 'win32',
|
|
150
|
+
shell: options.shell ?? false,
|
|
150
151
|
});
|
|
151
152
|
// Spawn failures arrive as 'error'; surface them through the missing pid, not as an unhandled event.
|
|
152
153
|
child.on('error', () => { });
|
package/package.json
CHANGED