@rightkit/qa 0.4.5 → 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 CHANGED
@@ -93,7 +93,7 @@ try {
93
93
  - `page.fill(target, value, { replace?, verify? })`. The default inserts at the caret, as `type()` does. `replace: true` selects the whole field first; an empty value then deletes it. `verify: true` (requires `replace`) reads the value back once and throws `FillMismatchError` on a difference. The error gives lengths, not values.
94
94
  - `page.mouse.wheel({ deltaX?, deltaY? })` sends one `mouseWheel` event at the tracked pointer position. The pointer does not move.
95
95
  - `page.setUserAgent(ua, { platform?, acceptLanguage? })` overrides the user agent for the page and its later navigations. Each call replaces the whole override. `page.resetUserAgent()` restores the `navigator.userAgent` captured before the first override.
96
- - `waitForFunction(fn, { failureArtifacts: { directory, label? } })` and `waitForSelector(...)` write a screenshot and a JSON state dump (url, title, predicate text, timeout, console entries) when they time out. Paths are on `error.artifacts` (`json`, `screenshot?`). `directory` must be absolute and must not lie inside `RIGHTKIT_RUN_ROOT`: `disposeQaWorkspace` deletes that tree. A failed write is appended to the error message; the timeout still throws.
96
+ - `waitForFunction(fn, { failureArtifacts: { directory, label?, extra?, allowInsideRunRoot? } })` and `waitForSelector(...)` write a screenshot and a JSON state dump on timeout: url, title, bounded page text (`pageText`), the focused element's descriptor without its value (`activeElement`), console entries, and `extra()` if given (`extra`, or `extraError` when it throws, never settles or cannot serialise). Paths are on `error.artifacts` (`json`, `screenshot?`). `directory` must be absolute and must not lie inside `RIGHTKIT_RUN_ROOT`, which `disposeQaWorkspace` deletes; `allowInsideRunRoot: true` opts in, and those files then go with the workspace, so copy them out before dispose. A failed write is appended to the error message; the timeout still throws.
97
97
 
98
98
  - `page.send(method, params?, timeout?)` is a page-scoped raw CDP escape hatch: unsupported surface, with Chrome's names, params and results. Prefer the typed methods. `page.setFocusEmulation(enabled)` sends `Emulation.setFocusEmulationEnabled`.
99
99
  - Navigation and reloads: `evaluate`, `waitForFunction` and `waitForSelector` re-resolve the main-frame context when Chrome reports it missing, and retry within the caller's timeout.
@@ -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.
@@ -143,5 +166,5 @@ This package launches no native app and no WebView2 window; it never replaces th
143
166
 
144
167
  ## Known issues
145
168
 
146
- - `@rightkit/qa-chrome` is pinned exactly to `0.1.4`. A qa-chrome release therefore needs a `@rightkit/qa` republish to reach consumers (npm audit 2026-10-08, qa finding).
169
+ - `@rightkit/qa-chrome` is pinned exactly to `0.1.5`. A qa-chrome release therefore needs a `@rightkit/qa` republish to reach consumers (npm audit 2026-10-08, qa finding).
147
170
  - Three consumers pin three different versions of `@rightkit/qa-chrome` as dev dependencies (npm audit 2026-10-08).
@@ -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
@@ -1,5 +1,6 @@
1
1
  export * from './chrome.js';
2
2
  export * from './evidence.js';
3
+ export * from './devserver.js';
3
4
  export * from './frames.js';
4
5
  export * from './guard.js';
5
6
  export * from './loopback.js';
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from './chrome.js';
2
2
  export * from './evidence.js';
3
+ export * from './devserver.js';
3
4
  export * from './frames.js';
4
5
  export * from './guard.js';
5
6
  export * from './loopback.js';
package/dist/page.d.ts CHANGED
@@ -16,10 +16,24 @@ export interface ViewportOptions {
16
16
  }
17
17
  /** Where a timed-out wait writes its screenshot and JSON state dump. */
18
18
  export interface FailureArtifactOptions {
19
- /** Absolute directory, created if missing. Must lie outside RIGHTKIT_RUN_ROOT, which disposeQaWorkspace deletes. */
19
+ /**
20
+ * Absolute directory, created if missing. Must lie outside RIGHTKIT_RUN_ROOT, which
21
+ * disposeQaWorkspace deletes, unless allowInsideRunRoot is set.
22
+ */
20
23
  directory: string;
21
24
  /** Short name used in the file names and the dump. Default 'wait'. */
22
25
  label?: string;
26
+ /**
27
+ * Opt-in: the directory may lie inside RIGHTKIT_RUN_ROOT. These files are deleted with the
28
+ * workspace by disposeQaWorkspace; copy them out before dispose. Default false.
29
+ */
30
+ allowInsideRunRoot?: boolean;
31
+ /**
32
+ * Host state to embed in the JSON dump under `extra` (bounded to 64 KiB, cut off after 1 s). A
33
+ * throw, a timeout or an unserialisable result is recorded under `extraError` and never masks the
34
+ * timeout.
35
+ */
36
+ extra?: () => unknown | Promise<unknown>;
23
37
  }
24
38
  /** Called once per poll, before the predicate. A throw rejects the wait with that error. */
25
39
  export type PollHook = () => void | Promise<void>;
@@ -263,9 +277,10 @@ export declare class QaPage {
263
277
  private selectAll;
264
278
  /** Throws ElementOccludedError naming the element on top unless the target itself or a composed descendant is hit. */
265
279
  private assertUnoccluded;
280
+ /** URL, title, bounded page text and the active element. Each part is null when unreadable; never throws. */
281
+ private failureState;
266
282
  /** Writes a PNG and a JSON state dump for a timed-out wait into the caller directory. */
267
283
  private captureFailure;
268
- /** Attaches artifact paths to a timeout error. A write failure is appended to its message; the timeout still throws. */
269
284
  /**
270
285
  * Writes the failure artifacts for a timed-out wait or a failed onPoll hook. The paths are attached as
271
286
  * `artifacts` only on a TimeoutError; for other errors the files are written and the paths are not attached.
package/dist/page.js CHANGED
@@ -96,22 +96,53 @@ function exceptionEntry(event) {
96
96
  function failureTarget(options) {
97
97
  if (!options)
98
98
  return undefined;
99
- const { directory, label } = options;
99
+ const { directory, label, extra, allowInsideRunRoot } = options;
100
100
  if (typeof directory !== 'string' || !path.isAbsolute(directory))
101
101
  throw new Error('failureArtifacts.directory must be an absolute path');
102
102
  if (label !== undefined && typeof label !== 'string')
103
103
  throw new Error('failureArtifacts.label must be a string');
104
+ if (extra !== undefined && typeof extra !== 'function')
105
+ throw new Error('failureArtifacts.extra must be a function');
104
106
  const resolved = path.resolve(directory);
105
107
  const runRoot = process.env.RIGHTKIT_RUN_ROOT;
106
- if (runRoot) {
108
+ if (runRoot && !allowInsideRunRoot) {
107
109
  const relative = path.relative(path.resolve(runRoot), resolved);
108
110
  const inside = relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`));
109
111
  if (inside)
110
112
  throw new Error('failureArtifacts.directory must be outside RIGHTKIT_RUN_ROOT: disposeQaWorkspace deletes that tree');
111
113
  }
112
- return { directory: resolved, label: (label ?? 'wait').replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 60) || 'wait' };
114
+ return { directory: resolved, label: (label ?? 'wait').replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 60) || 'wait', extra };
113
115
  }
114
116
  let failureSequence = 0;
117
+ /** Bounds for the failure dump: extra state, page text, and the time a host callback may take. */
118
+ const EXTRA_LIMIT = 64 * 1024;
119
+ const EXTRA_TIMEOUT_MS = 1_000;
120
+ const PAGE_TEXT_LIMIT = 20_000;
121
+ function errorText(error) { return error instanceof Error ? error.message : String(error); }
122
+ /** Runs the host's extra callback under a bound, and keeps its JSON within EXTRA_LIMIT. Never throws. */
123
+ async function runExtra(extra) {
124
+ if (!extra)
125
+ return { value: null, error: null };
126
+ let timer;
127
+ try {
128
+ const value = await Promise.race([
129
+ Promise.resolve().then(() => extra()),
130
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`extra timed out after ${EXTRA_TIMEOUT_MS}ms`)), EXTRA_TIMEOUT_MS); }),
131
+ ]);
132
+ const json = JSON.stringify(value);
133
+ if (json === undefined)
134
+ return { value: null, error: null };
135
+ if (json.length > EXTRA_LIMIT)
136
+ return { value: { truncated: true, length: json.length, preview: json.slice(0, EXTRA_LIMIT) }, error: null };
137
+ return { value: JSON.parse(json), error: null };
138
+ }
139
+ catch (error) {
140
+ return { value: null, error: errorText(error) };
141
+ }
142
+ finally {
143
+ clearTimeout(timer);
144
+ }
145
+ }
115
146
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
116
147
  function functionSource(fn) {
117
148
  const source = fn.toString();
@@ -949,12 +980,38 @@ export class QaPage {
949
980
  if (occluder !== null)
950
981
  throw new ElementOccludedError(occluder, point);
951
982
  }
983
+ /** URL, title, bounded page text and the active element. Each part is null when unreadable; never throws. */
984
+ async failureState() {
985
+ try {
986
+ const raw = resultValue(await this.run(() => {
987
+ const el = document.activeElement;
988
+ const attr = (name) => el?.getAttribute(name) || undefined;
989
+ return {
990
+ url: location.href,
991
+ title: document.title,
992
+ text: document.body ? document.body.innerText : '',
993
+ // Descriptor only: never the value, which may be a secret.
994
+ active: el ? { tag: el.tagName.toLowerCase(), id: el.id || undefined, name: attr('name'), type: attr('type'), role: attr('role'), ariaLabel: attr('aria-label') } : null,
995
+ };
996
+ }, [], true, 2_000)) ?? {};
997
+ const text = typeof raw.text === 'string' ? raw.text : '';
998
+ return {
999
+ url: typeof raw.url === 'string' ? raw.url : null,
1000
+ title: typeof raw.title === 'string' ? raw.title : null,
1001
+ pageText: { text: text.slice(0, PAGE_TEXT_LIMIT), truncated: text.length > PAGE_TEXT_LIMIT, length: text.length },
1002
+ activeElement: raw.active ?? null,
1003
+ };
1004
+ }
1005
+ catch {
1006
+ return { url: null, title: null, pageText: null, activeElement: null };
1007
+ }
1008
+ }
952
1009
  /** Writes a PNG and a JSON state dump for a timed-out wait into the caller directory. */
953
- async captureFailure(directory, label, predicate, timeout) {
954
- await mkdir(directory, { recursive: true });
955
- const stem = `${label}-${Date.now()}-${++failureSequence}`;
956
- const json = path.join(directory, `${stem}.json`);
957
- const png = path.join(directory, `${stem}.png`);
1010
+ async captureFailure(target, predicate, timeout) {
1011
+ await mkdir(target.directory, { recursive: true });
1012
+ const stem = `${target.label}-${Date.now()}-${++failureSequence}`;
1013
+ const json = path.join(target.directory, `${stem}.json`);
1014
+ const png = path.join(target.directory, `${stem}.png`);
958
1015
  let screenshot;
959
1016
  let screenshotError;
960
1017
  try {
@@ -962,28 +1019,28 @@ export class QaPage {
962
1019
  screenshot = png;
963
1020
  }
964
1021
  catch (error) {
965
- screenshotError = error instanceof Error ? error.message : String(error);
966
- }
967
- let where = {};
968
- try {
969
- where = resultValue(await this.run(() => ({ url: location.href, title: document.title }), [], true, 2_000)) ?? {};
1022
+ screenshotError = errorText(error);
970
1023
  }
971
- catch { /* page gone or stuck: the dump records no url or title */ }
1024
+ const state = await this.failureState();
1025
+ const extra = await runExtra(target.extra);
972
1026
  const dump = {
973
- label,
1027
+ label: target.label,
974
1028
  predicate,
975
1029
  timeoutMs: timeout,
976
1030
  timestamp: new Date().toISOString(),
977
- url: where.url ?? null,
978
- title: where.title ?? null,
1031
+ url: state.url,
1032
+ title: state.title,
1033
+ pageText: state.pageText,
1034
+ activeElement: state.activeElement,
979
1035
  screenshot: screenshot ? path.basename(screenshot) : null,
980
1036
  screenshotError: screenshotError ?? null,
981
1037
  console: this.consoleEntries(),
1038
+ extra: extra.value,
1039
+ extraError: extra.error,
982
1040
  };
983
1041
  await writeFile(json, JSON.stringify(dump, null, 2));
984
1042
  return screenshot ? { json, screenshot } : { json };
985
1043
  }
986
- /** Attaches artifact paths to a timeout error. A write failure is appended to its message; the timeout still throws. */
987
1044
  /**
988
1045
  * Writes the failure artifacts for a timed-out wait or a failed onPoll hook. The paths are attached as
989
1046
  * `artifacts` only on a TimeoutError; for other errors the files are written and the paths are not attached.
@@ -991,7 +1048,7 @@ export class QaPage {
991
1048
  */
992
1049
  async attachFailureArtifacts(error, target, predicate, timeout) {
993
1050
  try {
994
- const written = await this.captureFailure(target.directory, target.label, predicate, timeout);
1051
+ const written = await this.captureFailure(target, predicate, timeout);
995
1052
  if (error instanceof TimeoutError)
996
1053
  error.artifacts = written;
997
1054
  }
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/qa",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
4
4
  "description": "Shared QA evidence, ownership, browser helpers, and release-backdoor guards for Right Suite apps.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "type": "module",
@@ -24,7 +24,7 @@
24
24
  ],
25
25
  "sideEffects": false,
26
26
  "dependencies": {
27
- "@rightkit/qa-chrome": "0.1.4"
27
+ "@rightkit/qa-chrome": "0.1.5"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@types/node": "^24.0.0",