@rightkit/qa 0.4.4 → 0.4.6
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 +10 -2
- package/dist/page.d.ts +58 -9
- package/dist/page.js +194 -45
- package/dist/process.d.ts +35 -0
- package/dist/process.js +79 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -93,7 +93,15 @@ 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
|
|
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
|
+
|
|
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
|
+
- 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.
|
|
100
|
+
- `onPoll: () => void | Promise<void>` on `waitForFunction` and `waitForSelector` runs once per poll, before the predicate. A throw rejects the wait with that error.
|
|
101
|
+
- `click(target, { scroll: { inline: "nearest" } })` and `hover(target, { scroll })` choose horizontal alignment; the default is `center`, as before.
|
|
102
|
+
- `fill(target, value, { replace: true, verify: { timeoutMs } })` polls the readback until it matches or the window ends; `verify: true` reads once.
|
|
103
|
+
|
|
104
|
+
`spawnOwnedProcess(command, args, options)` starts a process in its own group (POSIX) and records it. `stopOwnedProcess(handle)` stops only a handle that `spawnOwnedProcess` returned, signalling the whole group. Bare pids and other objects are refused. `ownedProcessReceipt()` lists launched processes still running.
|
|
97
105
|
|
|
98
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.
|
|
99
107
|
|
|
@@ -135,5 +143,5 @@ This package launches no native app and no WebView2 window; it never replaces th
|
|
|
135
143
|
|
|
136
144
|
## Known issues
|
|
137
145
|
|
|
138
|
-
- `@rightkit/qa-chrome` is pinned exactly to `0.1.
|
|
146
|
+
- `@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).
|
|
139
147
|
- Three consumers pin three different versions of `@rightkit/qa-chrome` as dev dependencies (npm audit 2026-10-08).
|
package/dist/page.d.ts
CHANGED
|
@@ -16,34 +16,67 @@ 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
|
-
/**
|
|
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
|
}
|
|
38
|
+
/** Called once per poll, before the predicate. A throw rejects the wait with that error. */
|
|
39
|
+
export type PollHook = () => void | Promise<void>;
|
|
24
40
|
export interface WaitOptions {
|
|
25
41
|
timeout?: number;
|
|
26
42
|
polling?: 'raf' | number;
|
|
27
43
|
failureArtifacts?: FailureArtifactOptions;
|
|
44
|
+
onPoll?: PollHook;
|
|
28
45
|
}
|
|
29
46
|
export interface SelectorWaitOptions {
|
|
30
47
|
visible?: boolean;
|
|
31
48
|
hidden?: boolean;
|
|
32
49
|
timeout?: number;
|
|
33
50
|
failureArtifacts?: FailureArtifactOptions;
|
|
51
|
+
onPoll?: PollHook;
|
|
52
|
+
}
|
|
53
|
+
/** How the target is brought into view before the pointer moves. Vertical alignment is always centre. */
|
|
54
|
+
export interface ScrollOptions {
|
|
55
|
+
/** Default 'center', as before 0.4.5. 'nearest' scrolls the least needed to show the target at the edge. */
|
|
56
|
+
inline?: 'center' | 'nearest';
|
|
57
|
+
}
|
|
58
|
+
export interface HoverOptions {
|
|
59
|
+
scroll?: ScrollOptions;
|
|
34
60
|
}
|
|
35
61
|
export interface ClickOptions {
|
|
36
62
|
button?: 'left' | 'right' | 'middle';
|
|
37
63
|
clickCount?: number;
|
|
38
64
|
delay?: number;
|
|
65
|
+
scroll?: ScrollOptions;
|
|
39
66
|
/** Default false. When true, after the pointer moves and before pressing, checks that the element at the click point is the target or its descendant; otherwise throws ElementOccludedError. */
|
|
40
67
|
checkOccluded?: boolean;
|
|
41
68
|
}
|
|
42
69
|
export interface FillOptions {
|
|
43
70
|
/** Default false: insert at the caret, exactly as type() does. True selects the whole field first, so the value replaces its content; an empty value then deletes the selection. */
|
|
44
71
|
replace?: boolean;
|
|
45
|
-
/**
|
|
46
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Default false: read the value back once after insertion and throw FillMismatchError on a difference.
|
|
74
|
+
* `{ timeoutMs }` polls the readback every 25 ms until it matches or the window ends, for fields that
|
|
75
|
+
* settle late (a framework rewrites the value after an input event). Requires replace.
|
|
76
|
+
*/
|
|
77
|
+
verify?: boolean | {
|
|
78
|
+
timeoutMs: number;
|
|
79
|
+
};
|
|
47
80
|
}
|
|
48
81
|
/** One console call or uncaught exception of a page. `line` is 1-based. */
|
|
49
82
|
export interface ConsoleEntry {
|
|
@@ -183,8 +216,17 @@ export declare class QaPage {
|
|
|
183
216
|
on(method: string, listener: (params: CdpParams) => void): () => void;
|
|
184
217
|
/** @internal */
|
|
185
218
|
initialize(): Promise<void>;
|
|
186
|
-
/**
|
|
219
|
+
/**
|
|
220
|
+
* UNSUPPORTED escape hatch: sends one page-scoped CDP command on this page's session and resolves
|
|
221
|
+
* with its raw result. Method names, params and results follow Chrome's protocol, not this package,
|
|
222
|
+
* and may change with the browser version. Prefer the typed QaPage methods where they exist.
|
|
223
|
+
*/
|
|
187
224
|
send(method: string, params?: CdpParams, timeout?: number): Promise<CdpParams>;
|
|
225
|
+
/**
|
|
226
|
+
* Emulation.setFocusEmulationEnabled: the page behaves as focused while its window is not
|
|
227
|
+
* (background or headless). Persists for the page until turned off.
|
|
228
|
+
*/
|
|
229
|
+
setFocusEmulation(enabled: boolean): Promise<void>;
|
|
188
230
|
private invalidateHandles;
|
|
189
231
|
/** @internal */
|
|
190
232
|
markClosed(error?: Error): void;
|
|
@@ -227,22 +269,29 @@ export declare class QaPage {
|
|
|
227
269
|
resetUserAgent(): Promise<void>;
|
|
228
270
|
/**
|
|
229
271
|
* Types a value into a field. Default: insert at the caret, exactly as type() does. With replace the
|
|
230
|
-
* whole content is selected first and replaced. With verify
|
|
231
|
-
*
|
|
272
|
+
* whole content is selected first and replaced. With verify the value is read back and
|
|
273
|
+
* FillMismatchError is thrown on a difference: once by default, or polled until `timeoutMs` with
|
|
274
|
+
* `verify: { timeoutMs }`. Verify requires replace.
|
|
232
275
|
*/
|
|
233
276
|
fill(target: string | QaElement, value: string, options?: FillOptions): Promise<void>;
|
|
234
277
|
private selectAll;
|
|
235
278
|
/** Throws ElementOccludedError naming the element on top unless the target itself or a composed descendant is hit. */
|
|
236
279
|
private assertUnoccluded;
|
|
280
|
+
/** URL, title, bounded page text and the active element. Each part is null when unreadable; never throws. */
|
|
281
|
+
private failureState;
|
|
237
282
|
/** Writes a PNG and a JSON state dump for a timed-out wait into the caller directory. */
|
|
238
283
|
private captureFailure;
|
|
239
|
-
/**
|
|
284
|
+
/**
|
|
285
|
+
* Writes the failure artifacts for a timed-out wait or a failed onPoll hook. The paths are attached as
|
|
286
|
+
* `artifacts` only on a TimeoutError; for other errors the files are written and the paths are not attached.
|
|
287
|
+
* A write failure is appended to a TimeoutError's message; the original error still throws.
|
|
288
|
+
*/
|
|
240
289
|
private attachFailureArtifacts;
|
|
241
290
|
click(target: string | QaElement, options?: ClickOptions): Promise<void>;
|
|
242
291
|
type(selector: string, text: string, options?: TypeOptions): Promise<void>;
|
|
243
292
|
/** @internal */
|
|
244
293
|
typeElement(handle: QaElement, text: string, options?: TypeOptions): Promise<void>;
|
|
245
|
-
hover(target: string | QaElement): Promise<void>;
|
|
294
|
+
hover(target: string | QaElement, options?: HoverOptions): Promise<void>;
|
|
246
295
|
focus(target: string | QaElement): Promise<void>;
|
|
247
296
|
select(selector: string, ...values: string[]): Promise<string[]>;
|
|
248
297
|
screenshot(options?: ScreenshotOptions): Promise<Buffer>;
|
|
@@ -263,7 +312,7 @@ export declare class QaElement {
|
|
|
263
312
|
evaluate<Args extends unknown[], Result>(fn: (element: HTMLElement, ...args: Args) => Result, ...args: Args): Promise<Awaited<Result>>;
|
|
264
313
|
click(options?: ClickOptions): Promise<void>;
|
|
265
314
|
type(text: string, options?: TypeOptions): Promise<void>;
|
|
266
|
-
hover(): Promise<void>;
|
|
315
|
+
hover(options?: HoverOptions): Promise<void>;
|
|
267
316
|
focus(): Promise<void>;
|
|
268
317
|
boundingBox(): Promise<BoundingBox | null>;
|
|
269
318
|
}
|
package/dist/page.js
CHANGED
|
@@ -31,6 +31,31 @@ export class FillMismatchError extends Error {
|
|
|
31
31
|
this.actualLength = actualLength;
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
|
+
/** CDP's answer for a context id that no longer exists. The command did not run, so retrying is safe. */
|
|
35
|
+
function isMissingContext(error) {
|
|
36
|
+
return error instanceof Error && /Cannot find context with specified id/.test(error.message);
|
|
37
|
+
}
|
|
38
|
+
function scrollInline(scroll) {
|
|
39
|
+
const inline = scroll?.inline ?? 'center';
|
|
40
|
+
if (inline !== 'center' && inline !== 'nearest')
|
|
41
|
+
throw new Error('scroll.inline must be center or nearest');
|
|
42
|
+
return inline;
|
|
43
|
+
}
|
|
44
|
+
/** Wraps an onPoll hook so a throw is recorded on `state` (failure artifacts then apply to it). */
|
|
45
|
+
function pollHook(onPoll, state) {
|
|
46
|
+
if (!onPoll)
|
|
47
|
+
return undefined;
|
|
48
|
+
return async () => {
|
|
49
|
+
try {
|
|
50
|
+
await onPoll();
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
state.failed = true;
|
|
54
|
+
state.error = error;
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
34
59
|
/** Text of one console argument, from its CDP RemoteObject. */
|
|
35
60
|
function remoteText(object) {
|
|
36
61
|
if ('value' in object)
|
|
@@ -71,22 +96,53 @@ function exceptionEntry(event) {
|
|
|
71
96
|
function failureTarget(options) {
|
|
72
97
|
if (!options)
|
|
73
98
|
return undefined;
|
|
74
|
-
const { directory, label } = options;
|
|
99
|
+
const { directory, label, extra, allowInsideRunRoot } = options;
|
|
75
100
|
if (typeof directory !== 'string' || !path.isAbsolute(directory))
|
|
76
101
|
throw new Error('failureArtifacts.directory must be an absolute path');
|
|
77
102
|
if (label !== undefined && typeof label !== 'string')
|
|
78
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');
|
|
79
106
|
const resolved = path.resolve(directory);
|
|
80
107
|
const runRoot = process.env.RIGHTKIT_RUN_ROOT;
|
|
81
|
-
if (runRoot) {
|
|
108
|
+
if (runRoot && !allowInsideRunRoot) {
|
|
82
109
|
const relative = path.relative(path.resolve(runRoot), resolved);
|
|
83
110
|
const inside = relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`));
|
|
84
111
|
if (inside)
|
|
85
112
|
throw new Error('failureArtifacts.directory must be outside RIGHTKIT_RUN_ROOT: disposeQaWorkspace deletes that tree');
|
|
86
113
|
}
|
|
87
|
-
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 };
|
|
88
115
|
}
|
|
89
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
|
+
}
|
|
90
146
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
91
147
|
function functionSource(fn) {
|
|
92
148
|
const source = fn.toString();
|
|
@@ -445,12 +501,27 @@ export class QaPage {
|
|
|
445
501
|
await this.send('Network.enable');
|
|
446
502
|
await this.send('Page.setLifecycleEventsEnabled', { enabled: true });
|
|
447
503
|
}
|
|
448
|
-
/**
|
|
504
|
+
/**
|
|
505
|
+
* UNSUPPORTED escape hatch: sends one page-scoped CDP command on this page's session and resolves
|
|
506
|
+
* with its raw result. Method names, params and results follow Chrome's protocol, not this package,
|
|
507
|
+
* and may change with the browser version. Prefer the typed QaPage methods where they exist.
|
|
508
|
+
*/
|
|
449
509
|
send(method, params = {}, timeout) {
|
|
450
510
|
if (this.closed)
|
|
451
511
|
return Promise.reject(this.closed);
|
|
512
|
+
if (typeof method !== 'string' || !method)
|
|
513
|
+
return Promise.reject(new Error('CDP method must be a non-empty string'));
|
|
452
514
|
return this.client.send(method, params, this.sessionId, timeout);
|
|
453
515
|
}
|
|
516
|
+
/**
|
|
517
|
+
* Emulation.setFocusEmulationEnabled: the page behaves as focused while its window is not
|
|
518
|
+
* (background or headless). Persists for the page until turned off.
|
|
519
|
+
*/
|
|
520
|
+
async setFocusEmulation(enabled) {
|
|
521
|
+
if (typeof enabled !== 'boolean')
|
|
522
|
+
throw new Error('setFocusEmulation expects a boolean');
|
|
523
|
+
await this.send('Emulation.setFocusEmulationEnabled', { enabled });
|
|
524
|
+
}
|
|
454
525
|
invalidateHandles() {
|
|
455
526
|
const objectGroup = this.objectGroup;
|
|
456
527
|
this.generation++;
|
|
@@ -597,14 +668,36 @@ export class QaPage {
|
|
|
597
668
|
}
|
|
598
669
|
async run(fn, args, returnByValue, timeout = this.client.timeout) {
|
|
599
670
|
const deadline = Date.now() + timeout;
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
671
|
+
// A reload destroys the context an earlier call resolved. CDP refuses a call against a missing
|
|
672
|
+
// context without running it, so re-resolve the main context and retry until the caller's deadline.
|
|
673
|
+
let missing;
|
|
674
|
+
for (;;) {
|
|
675
|
+
const remaining = Math.max(1, deadline - Date.now());
|
|
676
|
+
let result;
|
|
677
|
+
try {
|
|
678
|
+
const contextId = await this.mainContext(remaining);
|
|
679
|
+
const common = { returnByValue, awaitPromise: true, objectGroup: this.objectGroup, timeout: remaining };
|
|
680
|
+
result = typeof fn === 'string'
|
|
681
|
+
? await this.send('Runtime.evaluate', { expression: fn, contextId, ...common }, remaining)
|
|
682
|
+
: await this.send('Runtime.callFunctionOn', { functionDeclaration: functionSource(fn), executionContextId: contextId, arguments: args.map(argument), ...common }, remaining);
|
|
683
|
+
}
|
|
684
|
+
catch (error) {
|
|
685
|
+
if (isMissingContext(error)) {
|
|
686
|
+
missing = error;
|
|
687
|
+
if (Date.now() < deadline) {
|
|
688
|
+
await sleep(10);
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
throw error;
|
|
692
|
+
}
|
|
693
|
+
// The budget ran out while a context was missing: report the cause, not a bare timeout.
|
|
694
|
+
if (error instanceof TimeoutError && missing)
|
|
695
|
+
throw missing;
|
|
696
|
+
throw error;
|
|
697
|
+
}
|
|
698
|
+
resultValue(result);
|
|
699
|
+
return result;
|
|
700
|
+
}
|
|
608
701
|
}
|
|
609
702
|
async evaluate(fn, ...args) {
|
|
610
703
|
return resultValue(await this.run(fn, args, true));
|
|
@@ -676,9 +769,13 @@ export class QaPage {
|
|
|
676
769
|
const source = typeof fn === 'string' ? `function() { return (${fn}); }` : functionSource(fn);
|
|
677
770
|
const artifacts = failureTarget(options.failureArtifacts);
|
|
678
771
|
const timeout = options.timeout ?? this.client.timeout;
|
|
772
|
+
const pollState = { failed: false, error: undefined };
|
|
773
|
+
const poll = pollHook(options.onPoll, pollState);
|
|
679
774
|
try {
|
|
680
775
|
await this.bounded(`Predicate ${source}`, timeout, async (remaining, stopped) => {
|
|
681
776
|
while (!stopped()) {
|
|
777
|
+
if (poll)
|
|
778
|
+
await poll();
|
|
682
779
|
try {
|
|
683
780
|
if (resultValue(await this.runSourceFunction(source, args, remaining())))
|
|
684
781
|
return;
|
|
@@ -702,8 +799,9 @@ export class QaPage {
|
|
|
702
799
|
});
|
|
703
800
|
}
|
|
704
801
|
catch (error) {
|
|
705
|
-
if (artifacts && error instanceof TimeoutError)
|
|
802
|
+
if (artifacts && (error instanceof TimeoutError || (pollState.failed && pollState.error === error))) {
|
|
706
803
|
await this.attachFailureArtifacts(error, artifacts, source, timeout);
|
|
804
|
+
}
|
|
707
805
|
throw error;
|
|
708
806
|
}
|
|
709
807
|
}
|
|
@@ -712,6 +810,8 @@ export class QaPage {
|
|
|
712
810
|
throw new Error('visible & hidden cannot both be true');
|
|
713
811
|
const artifacts = failureTarget(options.failureArtifacts);
|
|
714
812
|
const timeout = options.timeout ?? this.client.timeout;
|
|
813
|
+
const pollState = { failed: false, error: undefined };
|
|
814
|
+
const poll = pollHook(options.onPoll, pollState);
|
|
715
815
|
try {
|
|
716
816
|
return await this.bounded(`Selector ${selector}`, timeout, async (remaining) => {
|
|
717
817
|
await this.waitForFunction((selector, visible, hidden) => {
|
|
@@ -721,7 +821,7 @@ export class QaPage {
|
|
|
721
821
|
const style = getComputedStyle(element), box = element.getBoundingClientRect();
|
|
722
822
|
const shown = style.visibility !== 'hidden' && style.visibility !== 'collapse' && box.width > 0 && box.height > 0;
|
|
723
823
|
return hidden ? !shown : visible ? shown : true;
|
|
724
|
-
}, { timeout: remaining() }, selector, !!options.visible, !!options.hidden);
|
|
824
|
+
}, { timeout: remaining(), onPoll: poll }, selector, !!options.visible, !!options.hidden);
|
|
725
825
|
return options.hidden ? null : this.lookup(selector, remaining());
|
|
726
826
|
});
|
|
727
827
|
}
|
|
@@ -732,6 +832,9 @@ export class QaPage {
|
|
|
732
832
|
await this.attachFailureArtifacts(named, artifacts, `waitForSelector ${JSON.stringify(selector)} visible=${!!options.visible} hidden=${!!options.hidden}`, timeout);
|
|
733
833
|
throw named;
|
|
734
834
|
}
|
|
835
|
+
if (artifacts && pollState.failed && pollState.error === error) {
|
|
836
|
+
await this.attachFailureArtifacts(error, artifacts, `waitForSelector ${JSON.stringify(selector)} onPoll`, timeout);
|
|
837
|
+
}
|
|
735
838
|
throw error;
|
|
736
839
|
}
|
|
737
840
|
}
|
|
@@ -752,9 +855,9 @@ export class QaPage {
|
|
|
752
855
|
await handle.dispose();
|
|
753
856
|
}
|
|
754
857
|
}
|
|
755
|
-
async point(handle) {
|
|
756
|
-
await handle.evaluate((element) => { if (!element.isConnected)
|
|
757
|
-
throw new Error('Element detached'); element.scrollIntoView({ block: 'center', inline:
|
|
858
|
+
async point(handle, inline = 'center') {
|
|
859
|
+
await handle.evaluate((element, inline) => { if (!element.isConnected)
|
|
860
|
+
throw new Error('Element detached'); element.scrollIntoView({ block: 'center', inline: inline, behavior: 'instant' }); }, inline);
|
|
758
861
|
const box = await handle.boundingBox();
|
|
759
862
|
if (!box || !box.width || !box.height)
|
|
760
863
|
throw new Error('Element has no clickable box');
|
|
@@ -796,14 +899,17 @@ export class QaPage {
|
|
|
796
899
|
}
|
|
797
900
|
/**
|
|
798
901
|
* Types a value into a field. Default: insert at the caret, exactly as type() does. With replace the
|
|
799
|
-
* whole content is selected first and replaced. With verify
|
|
800
|
-
*
|
|
902
|
+
* whole content is selected first and replaced. With verify the value is read back and
|
|
903
|
+
* FillMismatchError is thrown on a difference: once by default, or polled until `timeoutMs` with
|
|
904
|
+
* `verify: { timeoutMs }`. Verify requires replace.
|
|
801
905
|
*/
|
|
802
906
|
async fill(target, value, options = {}) {
|
|
803
907
|
if (typeof value !== 'string')
|
|
804
908
|
throw new Error('fill value must be a string');
|
|
805
|
-
|
|
909
|
+
const verify = options.verify;
|
|
910
|
+
if (verify && !options.replace)
|
|
806
911
|
throw new Error('fill verify requires replace: true; an insert-only fill has no fixed expected value');
|
|
912
|
+
const windowMs = typeof verify === 'object' && verify !== null ? validateTimeout(verify.timeoutMs) : undefined;
|
|
807
913
|
await this.withElement(target, async (handle) => {
|
|
808
914
|
if (!options.replace) {
|
|
809
915
|
await this.typeElement(handle, value);
|
|
@@ -815,11 +921,19 @@ export class QaPage {
|
|
|
815
921
|
await this.keyboard.press('Backspace');
|
|
816
922
|
else
|
|
817
923
|
await this.send('Input.insertText', { text: value });
|
|
818
|
-
if (
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
924
|
+
if (!verify)
|
|
925
|
+
return;
|
|
926
|
+
const read = () => handle.evaluate((element) => (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement ? element.value : element.textContent) ?? '');
|
|
927
|
+
let actual = await read();
|
|
928
|
+
if (windowMs !== undefined) {
|
|
929
|
+
const deadline = Date.now() + windowMs;
|
|
930
|
+
while (actual !== value && Date.now() < deadline) {
|
|
931
|
+
await sleep(Math.min(25, Math.max(1, deadline - Date.now())));
|
|
932
|
+
actual = await read();
|
|
933
|
+
}
|
|
822
934
|
}
|
|
935
|
+
if (actual !== value)
|
|
936
|
+
throw new FillMismatchError(value.length, actual.length);
|
|
823
937
|
});
|
|
824
938
|
}
|
|
825
939
|
async selectAll(handle) {
|
|
@@ -866,12 +980,38 @@ export class QaPage {
|
|
|
866
980
|
if (occluder !== null)
|
|
867
981
|
throw new ElementOccludedError(occluder, point);
|
|
868
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
|
+
}
|
|
869
1009
|
/** Writes a PNG and a JSON state dump for a timed-out wait into the caller directory. */
|
|
870
|
-
async captureFailure(
|
|
871
|
-
await mkdir(directory, { recursive: true });
|
|
872
|
-
const stem = `${label}-${Date.now()}-${++failureSequence}`;
|
|
873
|
-
const json = path.join(directory, `${stem}.json`);
|
|
874
|
-
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`);
|
|
875
1015
|
let screenshot;
|
|
876
1016
|
let screenshotError;
|
|
877
1017
|
try {
|
|
@@ -879,40 +1019,48 @@ export class QaPage {
|
|
|
879
1019
|
screenshot = png;
|
|
880
1020
|
}
|
|
881
1021
|
catch (error) {
|
|
882
|
-
screenshotError =
|
|
883
|
-
}
|
|
884
|
-
let where = {};
|
|
885
|
-
try {
|
|
886
|
-
where = resultValue(await this.run(() => ({ url: location.href, title: document.title }), [], true, 2_000)) ?? {};
|
|
1022
|
+
screenshotError = errorText(error);
|
|
887
1023
|
}
|
|
888
|
-
|
|
1024
|
+
const state = await this.failureState();
|
|
1025
|
+
const extra = await runExtra(target.extra);
|
|
889
1026
|
const dump = {
|
|
890
|
-
label,
|
|
1027
|
+
label: target.label,
|
|
891
1028
|
predicate,
|
|
892
1029
|
timeoutMs: timeout,
|
|
893
1030
|
timestamp: new Date().toISOString(),
|
|
894
|
-
url:
|
|
895
|
-
title:
|
|
1031
|
+
url: state.url,
|
|
1032
|
+
title: state.title,
|
|
1033
|
+
pageText: state.pageText,
|
|
1034
|
+
activeElement: state.activeElement,
|
|
896
1035
|
screenshot: screenshot ? path.basename(screenshot) : null,
|
|
897
1036
|
screenshotError: screenshotError ?? null,
|
|
898
1037
|
console: this.consoleEntries(),
|
|
1038
|
+
extra: extra.value,
|
|
1039
|
+
extraError: extra.error,
|
|
899
1040
|
};
|
|
900
1041
|
await writeFile(json, JSON.stringify(dump, null, 2));
|
|
901
1042
|
return screenshot ? { json, screenshot } : { json };
|
|
902
1043
|
}
|
|
903
|
-
/**
|
|
1044
|
+
/**
|
|
1045
|
+
* Writes the failure artifacts for a timed-out wait or a failed onPoll hook. The paths are attached as
|
|
1046
|
+
* `artifacts` only on a TimeoutError; for other errors the files are written and the paths are not attached.
|
|
1047
|
+
* A write failure is appended to a TimeoutError's message; the original error still throws.
|
|
1048
|
+
*/
|
|
904
1049
|
async attachFailureArtifacts(error, target, predicate, timeout) {
|
|
905
1050
|
try {
|
|
906
|
-
|
|
1051
|
+
const written = await this.captureFailure(target, predicate, timeout);
|
|
1052
|
+
if (error instanceof TimeoutError)
|
|
1053
|
+
error.artifacts = written;
|
|
907
1054
|
}
|
|
908
1055
|
catch (captureError) {
|
|
909
|
-
|
|
1056
|
+
if (error instanceof TimeoutError)
|
|
1057
|
+
error.message += ` (failure artifacts were not written: ${captureError instanceof Error ? captureError.message : String(captureError)})`;
|
|
910
1058
|
}
|
|
911
1059
|
}
|
|
912
1060
|
async click(target, options = {}) {
|
|
913
1061
|
this.mouse.assertReleased();
|
|
914
1062
|
await this.withElement(target, async (handle) => {
|
|
915
|
-
const point = await this.point(handle), button = options.button ?? 'left', count = options.clickCount ?? 1;
|
|
1063
|
+
const point = await this.point(handle, scrollInline(options.scroll)), button = options.button ?? 'left', count = options.clickCount ?? 1;
|
|
916
1064
|
await this.send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...point });
|
|
917
1065
|
this.mouse.track(point.x, point.y);
|
|
918
1066
|
if (options.checkOccluded)
|
|
@@ -940,10 +1088,11 @@ export class QaPage {
|
|
|
940
1088
|
await sleep(options.delay);
|
|
941
1089
|
}
|
|
942
1090
|
}
|
|
943
|
-
async hover(target) {
|
|
1091
|
+
async hover(target, options = {}) {
|
|
944
1092
|
this.mouse.assertReleased();
|
|
1093
|
+
const inline = scrollInline(options.scroll);
|
|
945
1094
|
await this.withElement(target, async (handle) => {
|
|
946
|
-
const point = await this.point(handle);
|
|
1095
|
+
const point = await this.point(handle, inline);
|
|
947
1096
|
await this.send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...point });
|
|
948
1097
|
this.mouse.track(point.x, point.y);
|
|
949
1098
|
});
|
|
@@ -1009,7 +1158,7 @@ export class QaElement {
|
|
|
1009
1158
|
}
|
|
1010
1159
|
click(options = {}) { return this.page.click(this, options); }
|
|
1011
1160
|
type(text, options = {}) { return this.page.typeElement(this, text, options); }
|
|
1012
|
-
hover() { return this.page.hover(this); }
|
|
1161
|
+
hover(options = {}) { return this.page.hover(this, options); }
|
|
1013
1162
|
async focus() { await this.evaluate((element) => element.focus()); }
|
|
1014
1163
|
boundingBox() {
|
|
1015
1164
|
return this.evaluate((element) => {
|
package/dist/process.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ChildProcess } from 'node:child_process';
|
|
1
2
|
export interface OwnedProcessResult {
|
|
2
3
|
pid: number;
|
|
3
4
|
code: number | null;
|
|
@@ -25,3 +26,37 @@ export declare function runOwnedProcess(command: string, args?: string[], option
|
|
|
25
26
|
export declare function freePort(): Promise<number>;
|
|
26
27
|
export declare function isProcessAlive(pid: number): boolean;
|
|
27
28
|
export declare function killProcessTree(pid: number): Promise<void>;
|
|
29
|
+
export interface SpawnOwnedProcessOptions {
|
|
30
|
+
cwd?: string;
|
|
31
|
+
env?: NodeJS.ProcessEnv;
|
|
32
|
+
/** Default 'ignore'. 'pipe' exposes child.stdout and child.stderr. */
|
|
33
|
+
stdio?: 'ignore' | 'pipe';
|
|
34
|
+
windowsHide?: boolean;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* A process this package launched. Only the object returned by spawnOwnedProcess is accepted by
|
|
38
|
+
* stopOwnedProcess: a bare pid or a look-alike object is refused.
|
|
39
|
+
*/
|
|
40
|
+
export interface OwnedProcess {
|
|
41
|
+
readonly pid: number;
|
|
42
|
+
readonly child: ChildProcess;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Launches a long-running process in its own process group (POSIX) and records it. Stopping it
|
|
46
|
+
* with stopOwnedProcess signals the whole group, so grandchildren go too.
|
|
47
|
+
*/
|
|
48
|
+
export declare function spawnOwnedProcess(command: string, args?: string[], options?: SpawnOwnedProcessOptions): OwnedProcess;
|
|
49
|
+
/**
|
|
50
|
+
* Stops a process returned by spawnOwnedProcess: SIGTERM to its process group, SIGKILL after 1 s
|
|
51
|
+
* if members remain (POSIX); taskkill /T /F on Windows. Idempotent. Refuses anything else.
|
|
52
|
+
*/
|
|
53
|
+
export declare function stopOwnedProcess(handle: OwnedProcess): Promise<void>;
|
|
54
|
+
export interface OwnedProcessReceipt {
|
|
55
|
+
/**
|
|
56
|
+
* Pids launched by spawnOwnedProcess and not stopped whose process group (POSIX) or process
|
|
57
|
+
* (Windows) still exists. Empty when nothing this package launched is left running.
|
|
58
|
+
*/
|
|
59
|
+
leftoverPids: number[];
|
|
60
|
+
}
|
|
61
|
+
/** Receipt of what this package launched and still has running. Read only; signals nothing. */
|
|
62
|
+
export declare function ownedProcessReceipt(): OwnedProcessReceipt;
|
package/dist/process.js
CHANGED
|
@@ -133,3 +133,82 @@ async function waitForExit(pid, timeoutMs) {
|
|
|
133
133
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
|
+
/** Every handle this package has launched in this process, stopped or not. */
|
|
137
|
+
const launched = new Set();
|
|
138
|
+
const stopped = new WeakSet();
|
|
139
|
+
/**
|
|
140
|
+
* Launches a long-running process in its own process group (POSIX) and records it. Stopping it
|
|
141
|
+
* with stopOwnedProcess signals the whole group, so grandchildren go too.
|
|
142
|
+
*/
|
|
143
|
+
export function spawnOwnedProcess(command, args = [], options = {}) {
|
|
144
|
+
const child = spawn(command, args, {
|
|
145
|
+
cwd: options.cwd,
|
|
146
|
+
env: options.env,
|
|
147
|
+
stdio: options.stdio === 'pipe' ? ['ignore', 'pipe', 'pipe'] : 'ignore',
|
|
148
|
+
windowsHide: options.windowsHide ?? true,
|
|
149
|
+
detached: process.platform !== 'win32',
|
|
150
|
+
});
|
|
151
|
+
// Spawn failures arrive as 'error'; surface them through the missing pid, not as an unhandled event.
|
|
152
|
+
child.on('error', () => { });
|
|
153
|
+
if (!child.pid)
|
|
154
|
+
throw new Error(`failed to start owned process: ${command}`);
|
|
155
|
+
const handle = Object.freeze({ pid: child.pid, child });
|
|
156
|
+
launched.add(handle);
|
|
157
|
+
return handle;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Stops a process returned by spawnOwnedProcess: SIGTERM to its process group, SIGKILL after 1 s
|
|
161
|
+
* if members remain (POSIX); taskkill /T /F on Windows. Idempotent. Refuses anything else.
|
|
162
|
+
*/
|
|
163
|
+
export async function stopOwnedProcess(handle) {
|
|
164
|
+
if (!handle || typeof handle !== 'object' || !launched.has(handle)) {
|
|
165
|
+
const pid = handle && typeof handle === 'object' ? handle.pid : handle;
|
|
166
|
+
throw new Error(`refusing to stop pid ${String(pid)}: this package did not launch it`);
|
|
167
|
+
}
|
|
168
|
+
if (stopped.has(handle))
|
|
169
|
+
return;
|
|
170
|
+
const pid = handle.pid;
|
|
171
|
+
if (process.platform === 'win32') {
|
|
172
|
+
await runCleanupCommand('taskkill.exe', ['/PID', String(pid), '/T', '/F']);
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
signalGroup(pid, 'SIGTERM');
|
|
176
|
+
if (!(await waitGroupGone(pid, 1_000))) {
|
|
177
|
+
signalGroup(pid, 'SIGKILL');
|
|
178
|
+
await waitGroupGone(pid, 2_000);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
stopped.add(handle);
|
|
182
|
+
}
|
|
183
|
+
/** Receipt of what this package launched and still has running. Read only; signals nothing. */
|
|
184
|
+
export function ownedProcessReceipt() {
|
|
185
|
+
const leftoverPids = [...launched]
|
|
186
|
+
.filter((handle) => !stopped.has(handle))
|
|
187
|
+
.filter((handle) => (process.platform === 'win32' ? isProcessAlive(handle.pid) : groupAlive(handle.pid)))
|
|
188
|
+
.map((handle) => handle.pid);
|
|
189
|
+
return { leftoverPids };
|
|
190
|
+
}
|
|
191
|
+
function groupAlive(pid) {
|
|
192
|
+
try {
|
|
193
|
+
process.kill(-pid, 0);
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
return error.code === 'EPERM';
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function signalGroup(pid, signal) {
|
|
201
|
+
try {
|
|
202
|
+
process.kill(-pid, signal);
|
|
203
|
+
}
|
|
204
|
+
catch { /* group already gone */ }
|
|
205
|
+
}
|
|
206
|
+
async function waitGroupGone(pid, timeoutMs) {
|
|
207
|
+
const deadline = Date.now() + timeoutMs;
|
|
208
|
+
while (groupAlive(pid)) {
|
|
209
|
+
if (Date.now() >= deadline)
|
|
210
|
+
return false;
|
|
211
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
212
|
+
}
|
|
213
|
+
return true;
|
|
214
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rightkit/qa",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.6",
|
|
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.
|
|
27
|
+
"@rightkit/qa-chrome": "0.1.5"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"@types/node": "^24.0.0",
|