@rightkit/qa 0.4.2 → 0.4.4
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 +34 -1
- package/dist/cdp.d.ts +5 -0
- package/dist/cdp.js +2 -0
- package/dist/keyboard.d.ts +4 -1
- package/dist/keyboard.js +55 -11
- package/dist/mouse.d.ts +9 -0
- package/dist/mouse.js +12 -0
- package/dist/page.d.ts +92 -1
- package/dist/page.js +315 -25
- package/dist/process.d.ts +5 -0
- package/dist/process.js +21 -0
- package/dist/workspace.d.ts +10 -0
- package/dist/workspace.js +10 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ Shared browser and QA primitives for Right Suite apps: run storage and evidence
|
|
|
13
13
|
|
|
14
14
|
| Import | Contents |
|
|
15
15
|
|---|---|
|
|
16
|
-
| `@rightkit/qa` | `export *` from evidence, frames, guard, loopback, ownership, paint, panes, process, workspace, storage, page and chrome modules. Includes `QaBrowserSession`, `QaPage`, `QaElement`, `QaKeyboard`, `QaMouse`, `TimeoutError`. |
|
|
16
|
+
| `@rightkit/qa` | `export *` from evidence, frames, guard, loopback, ownership, paint, panes, process, workspace, storage, page and chrome modules. Includes `QaBrowserSession`, `QaPage`, `QaElement`, `QaKeyboard`, `QaMouse`, `TimeoutError`, `ElementOccludedError`, `FillMismatchError`, `freePort`. |
|
|
17
17
|
| `@rightkit/qa/chrome` | Re-export of `@rightkit/qa-chrome` |
|
|
18
18
|
|
|
19
19
|
Dependency: `@rightkit/qa-chrome` (exact `0.1.3`).
|
|
@@ -86,6 +86,39 @@ try {
|
|
|
86
86
|
|
|
87
87
|
`evaluate` throws the page's JavaScript exception (or a `TimeoutError`) rather than returning an error value. Wrap the call if you need the error as data.
|
|
88
88
|
|
|
89
|
+
## Console, occlusion, fill, wheel, user agent and failure artifacts
|
|
90
|
+
|
|
91
|
+
- `page.consoleEntries()` returns the newest 200 console calls and uncaught exceptions of the page, oldest first, as `{ type, text, url?, line?, timestamp }`. `type` is the console method (`log`, `error`, ...) or `exception`. Capture is always on; `clearConsoleEntries()` empties the buffer. Entries may contain page data, so do not write them to shared logs.
|
|
92
|
+
- `page.click(target, { checkOccluded: true })` checks, after the pointer moves and before it presses, that `elementFromPoint` at the click point is the target or one of its descendants. Otherwise it throws `ElementOccludedError`, whose `occluder` names the element on top (tag, id, class; never text). The check walks into open shadow roots to name the element on top; a cover inside a closed shadow root is named by its host. The default is `false`.
|
|
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
|
+
- `page.mouse.wheel({ deltaX?, deltaY? })` sends one `mouseWheel` event at the tracked pointer position. The pointer does not move.
|
|
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.
|
|
97
|
+
|
|
98
|
+
`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
|
+
|
|
100
|
+
## Keyboard keys
|
|
101
|
+
|
|
102
|
+
`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.
|
|
103
|
+
|
|
104
|
+
- Shifted symbols send Shift. `?` is Shift+`/`, `+` is Shift+`=`, `!` is Shift+`1`. Such a key carries `modifiers` 8 and its shifted `key` and `text`; no separate Shift key-down is sent.
|
|
105
|
+
- Chords join parts with `+`. A literal plus key goes last: `+` and `Control++`.
|
|
106
|
+
- `code` follows the US layout (`?` has code `Slash`). Other layouts are not modelled.
|
|
107
|
+
|
|
108
|
+
## Browser escape hatch (unsupported surface)
|
|
109
|
+
|
|
110
|
+
These methods expose Chrome's own DevTools protocol. Their names, params and results follow the browser version, not this package, and may break when Chrome changes. Use the typed `QaPage` API where it exists.
|
|
111
|
+
|
|
112
|
+
- `browser.send(method, params?)` sends one browser-scoped command with no `sessionId` and resolves with its raw result.
|
|
113
|
+
- `browser.version()` returns `Browser.getVersion`.
|
|
114
|
+
- `browser.commandLine()` returns the browser argv from `Browser.getBrowserCommandLine` as `string[]`.
|
|
115
|
+
|
|
116
|
+
`commandLine()` needs the browser launched with `--enable-automation`. `launchChromeForTesting` does not pass that flag, so launch with `launchChromeForTesting({ args: ["--enable-automation"] })`. Without it, the call rejects with an error that names the requirement; the browser is unchanged.
|
|
117
|
+
|
|
118
|
+
## Run root and workspace disposal
|
|
119
|
+
|
|
120
|
+
`createQaWorkspace` sets `RIGHTKIT_RUN_ROOT` to the workspace `home`. `disposeQaWorkspace` deletes that home, and deletes the workspace root too unless `keepEvidence` is set. A host must never map a directory that holds artifacts it wants to keep onto `RIGHTKIT_RUN_ROOT`, or onto the workspace root or home. Copy artifacts out before disposal, or use `keepEvidence: true` and keep anything written under `evidence` or `captures`.
|
|
121
|
+
|
|
89
122
|
## Ownership: this package versus the Rust `rightkit-qa` crate
|
|
90
123
|
|
|
91
124
|
Both runners stay. Pick by what the test drives.
|
package/dist/cdp.d.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
/** Minimal flattened-session CDP transport using Node's built-in WebSocket. */
|
|
2
2
|
export declare class TimeoutError extends Error {
|
|
3
|
+
/** Set when the wait was given failureArtifacts and the artifacts were written. */
|
|
4
|
+
artifacts?: {
|
|
5
|
+
json: string;
|
|
6
|
+
screenshot?: string;
|
|
7
|
+
};
|
|
3
8
|
constructor(message: string);
|
|
4
9
|
}
|
|
5
10
|
export interface QaConnectOptions {
|
package/dist/cdp.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** Minimal flattened-session CDP transport using Node's built-in WebSocket. */
|
|
2
2
|
export class TimeoutError extends Error {
|
|
3
|
+
/** Set when the wait was given failureArtifacts and the artifacts were written. */
|
|
4
|
+
artifacts;
|
|
3
5
|
constructor(message) { super(message); this.name = 'TimeoutError'; }
|
|
4
6
|
}
|
|
5
7
|
/** @internal All operations use finite deadlines. */
|
package/dist/keyboard.d.ts
CHANGED
|
@@ -4,6 +4,9 @@ export declare function keyDefinition(key: string, modifiers: number): CdpParams
|
|
|
4
4
|
export declare class QaKeyboard {
|
|
5
5
|
private send;
|
|
6
6
|
constructor(send: (method: string, params: CdpParams) => Promise<CdpParams>);
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* Single key or chord, e.g. Control+A, Meta+A, Shift+ArrowLeft, F3, ?, Control+/.
|
|
9
|
+
* Function keys F1-F24 and US-layout printable keys (shifted ones included) are supported.
|
|
10
|
+
*/
|
|
8
11
|
press(chord: string): Promise<void>;
|
|
9
12
|
}
|
package/dist/keyboard.js
CHANGED
|
@@ -6,45 +6,89 @@ const special = {
|
|
|
6
6
|
Shift: ['ShiftLeft', 16], Control: ['ControlLeft', 17], Alt: ['AltLeft', 18], Meta: ['MetaLeft', 91],
|
|
7
7
|
};
|
|
8
8
|
export const modifierBits = { Alt: 1, Control: 2, Meta: 4, Shift: 8 };
|
|
9
|
+
/** Function keys F1-F24: code "F<n>", Windows virtual key 111 + n (VK_F1 = 112 ... VK_F24 = 135). */
|
|
10
|
+
const functionKey = /^F(?:[1-9]|1\d|2[0-4])$/;
|
|
11
|
+
/** [base, shifted, code, Windows virtual key] for each US-layout punctuation key. */
|
|
12
|
+
const punctuation = [
|
|
13
|
+
['`', '~', 'Backquote', 192], ['-', '_', 'Minus', 189], ['=', '+', 'Equal', 187],
|
|
14
|
+
['[', '{', 'BracketLeft', 219], [']', '}', 'BracketRight', 221], ['\\', '|', 'Backslash', 220],
|
|
15
|
+
[';', ':', 'Semicolon', 186], ["'", '"', 'Quote', 222], [',', '<', 'Comma', 188],
|
|
16
|
+
['.', '>', 'Period', 190], ['/', '?', 'Slash', 191],
|
|
17
|
+
];
|
|
18
|
+
const shiftedDigits = ')!@#$%^&*(';
|
|
19
|
+
/** Keyed by the character the key prints, so "?" and "/" both resolve, the first with Shift forced. */
|
|
20
|
+
const printable = new Map();
|
|
21
|
+
for (const [base, shifted, code, keyCode] of punctuation) {
|
|
22
|
+
printable.set(base, { base, shifted, code, keyCode, requiresShift: false });
|
|
23
|
+
printable.set(shifted, { base, shifted, code, keyCode, requiresShift: true });
|
|
24
|
+
}
|
|
25
|
+
for (let digit = 0; digit <= 9; digit++) {
|
|
26
|
+
const base = String(digit), code = `Digit${digit}`, keyCode = 48 + digit;
|
|
27
|
+
printable.set(base, { base, shifted: shiftedDigits[digit], code, keyCode, requiresShift: false });
|
|
28
|
+
printable.set(shiftedDigits[digit], { base, shifted: shiftedDigits[digit], code, keyCode, requiresShift: true });
|
|
29
|
+
}
|
|
9
30
|
export function keyDefinition(key, modifiers) {
|
|
10
31
|
const named = special[key];
|
|
11
|
-
let code, windowsVirtualKeyCode, text;
|
|
12
|
-
|
|
32
|
+
let code, windowsVirtualKeyCode, text, unmodifiedText;
|
|
33
|
+
let effective = modifiers;
|
|
34
|
+
if (named) {
|
|
13
35
|
[code, windowsVirtualKeyCode, text] = named;
|
|
36
|
+
unmodifiedText = text;
|
|
37
|
+
}
|
|
38
|
+
else if (functionKey.test(key)) {
|
|
39
|
+
code = key;
|
|
40
|
+
windowsVirtualKeyCode = 111 + Number(key.slice(1));
|
|
41
|
+
}
|
|
14
42
|
else if (/^[a-z]$/i.test(key)) {
|
|
15
43
|
code = `Key${key.toUpperCase()}`;
|
|
16
44
|
windowsVirtualKeyCode = key.toUpperCase().charCodeAt(0);
|
|
17
45
|
key = modifiers & 8 ? key.toUpperCase() : modifiers & 7 ? key.toLowerCase() : key;
|
|
18
46
|
text = key;
|
|
47
|
+
unmodifiedText = code.slice(3).toLowerCase();
|
|
19
48
|
}
|
|
20
|
-
else if (
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
49
|
+
else if (printable.has(key)) {
|
|
50
|
+
const symbol = printable.get(key);
|
|
51
|
+
code = symbol.code;
|
|
52
|
+
windowsVirtualKeyCode = symbol.keyCode;
|
|
53
|
+
if (symbol.requiresShift)
|
|
54
|
+
effective |= 8;
|
|
55
|
+
key = effective & 8 ? symbol.shifted : symbol.base;
|
|
24
56
|
text = key;
|
|
57
|
+
unmodifiedText = symbol.base;
|
|
25
58
|
}
|
|
26
59
|
else if (key === ' ') {
|
|
27
60
|
code = 'Space';
|
|
28
61
|
windowsVirtualKeyCode = 32;
|
|
29
62
|
text = ' ';
|
|
63
|
+
unmodifiedText = ' ';
|
|
30
64
|
}
|
|
31
65
|
else
|
|
32
66
|
throw new Error(`Unsupported key: ${key}`);
|
|
33
67
|
if (key === 'Space')
|
|
34
68
|
key = ' ';
|
|
35
|
-
if (
|
|
69
|
+
if (effective & (1 | 2 | 4))
|
|
36
70
|
text = undefined;
|
|
37
|
-
|
|
38
|
-
|
|
71
|
+
return { key, code, windowsVirtualKeyCode, modifiers: effective, ...(text ? { text, unmodifiedText } : {}) };
|
|
72
|
+
}
|
|
73
|
+
/** Splits "Control+A" into parts. A literal plus key is the last part: "+" and "Control++". */
|
|
74
|
+
function splitChord(chord) {
|
|
75
|
+
if (chord === '+')
|
|
76
|
+
return ['+'];
|
|
77
|
+
if (chord.endsWith('++'))
|
|
78
|
+
return [...chord.slice(0, -2).split('+'), '+'];
|
|
79
|
+
return chord.split('+');
|
|
39
80
|
}
|
|
40
81
|
export class QaKeyboard {
|
|
41
82
|
send;
|
|
42
83
|
constructor(send) {
|
|
43
84
|
this.send = send;
|
|
44
85
|
}
|
|
45
|
-
/**
|
|
86
|
+
/**
|
|
87
|
+
* Single key or chord, e.g. Control+A, Meta+A, Shift+ArrowLeft, F3, ?, Control+/.
|
|
88
|
+
* Function keys F1-F24 and US-layout printable keys (shifted ones included) are supported.
|
|
89
|
+
*/
|
|
46
90
|
async press(chord) {
|
|
47
|
-
const parts = chord
|
|
91
|
+
const parts = splitChord(chord);
|
|
48
92
|
if (parts.some((part, i) => i < parts.length - 1 && !modifierBits[part]))
|
|
49
93
|
throw new Error(`Invalid key chord: ${chord}`);
|
|
50
94
|
keyDefinition(parts.at(-1), 0); // Validate before holding any modifier.
|
package/dist/mouse.d.ts
CHANGED
|
@@ -11,6 +11,10 @@ export interface MousePosition {
|
|
|
11
11
|
x: number;
|
|
12
12
|
y: number;
|
|
13
13
|
}
|
|
14
|
+
export interface MouseWheelOptions {
|
|
15
|
+
deltaX?: number; /** Vertical scroll delta in CSS pixels; positive scrolls down. Default 0. */
|
|
16
|
+
deltaY?: number;
|
|
17
|
+
}
|
|
14
18
|
/**
|
|
15
19
|
* Pointer state for one page: position and held buttons, so a move while a button is held
|
|
16
20
|
* sends the right `buttons` mask. Coordinates are CSS pixels of the viewport, as for `click`.
|
|
@@ -30,6 +34,11 @@ export declare class QaMouse {
|
|
|
30
34
|
down(options?: MouseButtonOptions): Promise<void>;
|
|
31
35
|
/** Release a held button at the current position. */
|
|
32
36
|
up(options?: MouseButtonOptions): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* Wheel (scroll) at the tracked position. Sends one mouseWheel event with the given deltas;
|
|
39
|
+
* the pointer does not move. At least one delta must be non-zero.
|
|
40
|
+
*/
|
|
41
|
+
wheel(options?: MouseWheelOptions): Promise<void>;
|
|
33
42
|
/** @internal Called by page.click & page.hover after they dispatch at a point. */
|
|
34
43
|
track(x: number, y: number): void;
|
|
35
44
|
/** @internal Page-level click & hover send their own events without a held mask, so refuse to run while held. */
|
package/dist/mouse.js
CHANGED
|
@@ -62,6 +62,18 @@ export class QaMouse {
|
|
|
62
62
|
await this.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: this.x, y: this.y, button, buttons, clickCount });
|
|
63
63
|
this.held.delete(button);
|
|
64
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Wheel (scroll) at the tracked position. Sends one mouseWheel event with the given deltas;
|
|
67
|
+
* the pointer does not move. At least one delta must be non-zero.
|
|
68
|
+
*/
|
|
69
|
+
async wheel(options = {}) {
|
|
70
|
+
const deltaX = options.deltaX ?? 0, deltaY = options.deltaY ?? 0;
|
|
71
|
+
if (!Number.isFinite(deltaX) || !Number.isFinite(deltaY))
|
|
72
|
+
throw new Error('Mouse wheel deltas must be finite numbers');
|
|
73
|
+
if (deltaX === 0 && deltaY === 0)
|
|
74
|
+
throw new Error('Mouse wheel needs a non-zero deltaX or deltaY');
|
|
75
|
+
await this.send('Input.dispatchMouseEvent', { type: 'mouseWheel', x: this.x, y: this.y, deltaX, deltaY });
|
|
76
|
+
}
|
|
65
77
|
/** @internal Called by page.click & page.hover after they dispatch at a point. */
|
|
66
78
|
track(x, y) { this.x = x; this.y = y; }
|
|
67
79
|
/** @internal Page-level click & hover send their own events without a held mask, so refuse to run while held. */
|
package/dist/page.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { QaKeyboard } from './keyboard.js';
|
|
|
3
3
|
import { QaMouse } from './mouse.js';
|
|
4
4
|
export { TimeoutError, type QaConnectOptions } from './cdp.js';
|
|
5
5
|
export { QaKeyboard } from './keyboard.js';
|
|
6
|
-
export { QaMouse, type MouseButton, type MouseButtonOptions, type MouseMoveOptions, type MousePosition } from './mouse.js';
|
|
6
|
+
export { QaMouse, type MouseButton, type MouseButtonOptions, type MouseMoveOptions, type MousePosition, type MouseWheelOptions } from './mouse.js';
|
|
7
7
|
export interface NavigationOptions {
|
|
8
8
|
waitUntil?: 'load' | 'domcontentloaded' | 'networkidle0';
|
|
9
9
|
timeout?: number;
|
|
@@ -14,19 +14,48 @@ export interface ViewportOptions {
|
|
|
14
14
|
deviceScaleFactor?: number;
|
|
15
15
|
mobile?: boolean;
|
|
16
16
|
}
|
|
17
|
+
/** Where a timed-out wait writes its screenshot and JSON state dump. */
|
|
18
|
+
export interface FailureArtifactOptions {
|
|
19
|
+
/** Absolute directory, created if missing. Must lie outside RIGHTKIT_RUN_ROOT, which disposeQaWorkspace deletes. */
|
|
20
|
+
directory: string;
|
|
21
|
+
/** Short name used in the file names and the dump. Default 'wait'. */
|
|
22
|
+
label?: string;
|
|
23
|
+
}
|
|
17
24
|
export interface WaitOptions {
|
|
18
25
|
timeout?: number;
|
|
19
26
|
polling?: 'raf' | number;
|
|
27
|
+
failureArtifacts?: FailureArtifactOptions;
|
|
20
28
|
}
|
|
21
29
|
export interface SelectorWaitOptions {
|
|
22
30
|
visible?: boolean;
|
|
23
31
|
hidden?: boolean;
|
|
24
32
|
timeout?: number;
|
|
33
|
+
failureArtifacts?: FailureArtifactOptions;
|
|
25
34
|
}
|
|
26
35
|
export interface ClickOptions {
|
|
27
36
|
button?: 'left' | 'right' | 'middle';
|
|
28
37
|
clickCount?: number;
|
|
29
38
|
delay?: number;
|
|
39
|
+
/** 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
|
+
checkOccluded?: boolean;
|
|
41
|
+
}
|
|
42
|
+
export interface FillOptions {
|
|
43
|
+
/** 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
|
+
replace?: boolean;
|
|
45
|
+
/** Default false. Reads the value back after insertion and throws FillMismatchError on a difference. Requires replace. */
|
|
46
|
+
verify?: boolean;
|
|
47
|
+
}
|
|
48
|
+
/** One console call or uncaught exception of a page. `line` is 1-based. */
|
|
49
|
+
export interface ConsoleEntry {
|
|
50
|
+
type: string;
|
|
51
|
+
text: string;
|
|
52
|
+
url?: string;
|
|
53
|
+
line?: number;
|
|
54
|
+
timestamp: number;
|
|
55
|
+
}
|
|
56
|
+
export interface UserAgentOptions {
|
|
57
|
+
platform?: string;
|
|
58
|
+
acceptLanguage?: string;
|
|
30
59
|
}
|
|
31
60
|
export interface TypeOptions {
|
|
32
61
|
delay?: number;
|
|
@@ -60,6 +89,25 @@ export interface QaAttachOptions extends QaConnectOptions {
|
|
|
60
89
|
allowRemoteHost?: boolean;
|
|
61
90
|
}
|
|
62
91
|
type PageFunction<Args extends unknown[], Result> = (...args: Args) => Result;
|
|
92
|
+
/** Thrown by click({ checkOccluded: true }) when another element is on top of the target at the click point. */
|
|
93
|
+
export declare class ElementOccludedError extends Error {
|
|
94
|
+
readonly point: {
|
|
95
|
+
x: number;
|
|
96
|
+
y: number;
|
|
97
|
+
};
|
|
98
|
+
/** Short description of the element on top, e.g. <div id="toast">. Text content is never included. */
|
|
99
|
+
readonly occluder: string;
|
|
100
|
+
constructor(occluder: string, point: {
|
|
101
|
+
x: number;
|
|
102
|
+
y: number;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/** Thrown by fill({ verify: true }) when the field does not hold the requested value. The values are not included. */
|
|
106
|
+
export declare class FillMismatchError extends Error {
|
|
107
|
+
readonly expectedLength: number;
|
|
108
|
+
readonly actualLength: number;
|
|
109
|
+
constructor(expectedLength: number, actualLength: number);
|
|
110
|
+
}
|
|
63
111
|
/** Owns only targets it creates; launcher owns browser process & profile cleanup. */
|
|
64
112
|
export declare class QaBrowserSession {
|
|
65
113
|
private client;
|
|
@@ -85,6 +133,21 @@ export declare class QaBrowserSession {
|
|
|
85
133
|
private attachExisting;
|
|
86
134
|
newPage(): Promise<QaPage>;
|
|
87
135
|
private createPage;
|
|
136
|
+
/**
|
|
137
|
+
* UNSUPPORTED escape hatch: sends one browser-scoped CDP command (no sessionId) and resolves
|
|
138
|
+
* with its raw result. The protocol is Chrome's, not this package's: method names, params and
|
|
139
|
+
* results may change or disappear with a browser version. Prefer the typed QaPage API.
|
|
140
|
+
*/
|
|
141
|
+
send(method: string, params?: CdpParams): Promise<CdpParams>;
|
|
142
|
+
/** Browser.getVersion (unsupported surface, see send). Read only. */
|
|
143
|
+
version(): Promise<CdpParams>;
|
|
144
|
+
/**
|
|
145
|
+
* Browser argv via Browser.getBrowserCommandLine (unsupported surface, see send). The browser
|
|
146
|
+
* answers only when it was launched with --enable-automation; launchChromeForTesting passes no
|
|
147
|
+
* such flag by default, so pass { args: ['--enable-automation'] } to use this. A refusal rejects
|
|
148
|
+
* with an error that names the requirement; the browser is not changed.
|
|
149
|
+
*/
|
|
150
|
+
commandLine(): Promise<string[]>;
|
|
88
151
|
close(): Promise<void>;
|
|
89
152
|
}
|
|
90
153
|
export declare class QaPage {
|
|
@@ -109,6 +172,9 @@ export declare class QaPage {
|
|
|
109
172
|
private lastNetworkActivity;
|
|
110
173
|
private generation;
|
|
111
174
|
private navigating;
|
|
175
|
+
private consoleBuffer;
|
|
176
|
+
private userAgentBaseline?;
|
|
177
|
+
private userAgentOverridden;
|
|
112
178
|
private get objectGroup();
|
|
113
179
|
/** @internal Use QaBrowserSession.newPage() or attachPage(). `owned` pages are closed on close(); attached ones only detach. */
|
|
114
180
|
constructor(client: CdpClient, targetId: string, sessionId: string, onClosed: () => void, owned?: boolean);
|
|
@@ -147,6 +213,31 @@ export declare class QaPage {
|
|
|
147
213
|
private requireElement;
|
|
148
214
|
private withElement;
|
|
149
215
|
private point;
|
|
216
|
+
/** Recent console calls and uncaught exceptions of this page, oldest first. Bounded to the newest 200. Returns copies. */
|
|
217
|
+
consoleEntries(): ConsoleEntry[];
|
|
218
|
+
/** Empties the console buffer. */
|
|
219
|
+
clearConsoleEntries(): void;
|
|
220
|
+
private recordConsole;
|
|
221
|
+
/**
|
|
222
|
+
* Overrides the user agent (Emulation.setUserAgentOverride) for this page, including later navigations.
|
|
223
|
+
* Each call replaces the whole override, so platform & acceptLanguage are sent only when given.
|
|
224
|
+
*/
|
|
225
|
+
setUserAgent(userAgent: string, options?: UserAgentOptions): Promise<void>;
|
|
226
|
+
/** Restores the navigator.userAgent captured before the first setUserAgent. A no-op when this page never overrode it. */
|
|
227
|
+
resetUserAgent(): Promise<void>;
|
|
228
|
+
/**
|
|
229
|
+
* 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 (requires replace) the value is read back
|
|
231
|
+
* once after insertion and FillMismatchError is thrown on a difference.
|
|
232
|
+
*/
|
|
233
|
+
fill(target: string | QaElement, value: string, options?: FillOptions): Promise<void>;
|
|
234
|
+
private selectAll;
|
|
235
|
+
/** Throws ElementOccludedError naming the element on top unless the target itself or a composed descendant is hit. */
|
|
236
|
+
private assertUnoccluded;
|
|
237
|
+
/** Writes a PNG and a JSON state dump for a timed-out wait into the caller directory. */
|
|
238
|
+
private captureFailure;
|
|
239
|
+
/** Attaches artifact paths to a timeout error. A write failure is appended to its message; the timeout still throws. */
|
|
240
|
+
private attachFailureArtifacts;
|
|
150
241
|
click(target: string | QaElement, options?: ClickOptions): Promise<void>;
|
|
151
242
|
type(selector: string, text: string, options?: TypeOptions): Promise<void>;
|
|
152
243
|
/** @internal */
|
package/dist/page.js
CHANGED
|
@@ -1,10 +1,92 @@
|
|
|
1
|
-
import { writeFile } from 'node:fs/promises';
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
import { CdpClient, TimeoutError, validateTimeout } from './cdp.js';
|
|
3
4
|
import { QaKeyboard } from './keyboard.js';
|
|
4
5
|
import { QaMouse } from './mouse.js';
|
|
5
6
|
export { TimeoutError } from './cdp.js';
|
|
6
7
|
export { QaKeyboard } from './keyboard.js';
|
|
7
8
|
export { QaMouse } from './mouse.js';
|
|
9
|
+
/** Newest console entries kept per page. */
|
|
10
|
+
const CONSOLE_LIMIT = 200;
|
|
11
|
+
/** Thrown by click({ checkOccluded: true }) when another element is on top of the target at the click point. */
|
|
12
|
+
export class ElementOccludedError extends Error {
|
|
13
|
+
point;
|
|
14
|
+
/** Short description of the element on top, e.g. <div id="toast">. Text content is never included. */
|
|
15
|
+
occluder;
|
|
16
|
+
constructor(occluder, point) {
|
|
17
|
+
super(`Click target is covered by ${occluder} at (${point.x}, ${point.y})`);
|
|
18
|
+
this.name = 'ElementOccludedError';
|
|
19
|
+
this.occluder = occluder;
|
|
20
|
+
this.point = point;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Thrown by fill({ verify: true }) when the field does not hold the requested value. The values are not included. */
|
|
24
|
+
export class FillMismatchError extends Error {
|
|
25
|
+
expectedLength;
|
|
26
|
+
actualLength;
|
|
27
|
+
constructor(expectedLength, actualLength) {
|
|
28
|
+
super(`fill verification failed: field holds ${actualLength} characters, expected ${expectedLength}`);
|
|
29
|
+
this.name = 'FillMismatchError';
|
|
30
|
+
this.expectedLength = expectedLength;
|
|
31
|
+
this.actualLength = actualLength;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Text of one console argument, from its CDP RemoteObject. */
|
|
35
|
+
function remoteText(object) {
|
|
36
|
+
if ('value' in object)
|
|
37
|
+
return typeof object.value === 'string' ? object.value : JSON.stringify(object.value) ?? String(object.value);
|
|
38
|
+
return String(object.unserializableValue ?? object.description ?? object.type ?? '');
|
|
39
|
+
}
|
|
40
|
+
/** url and 1-based line, present only when the event carries them. */
|
|
41
|
+
function locationOf(url, zeroBasedLine) {
|
|
42
|
+
const location = {};
|
|
43
|
+
if (typeof url === 'string' && url)
|
|
44
|
+
location.url = url;
|
|
45
|
+
if (typeof zeroBasedLine === 'number' && zeroBasedLine >= 0)
|
|
46
|
+
location.line = zeroBasedLine + 1;
|
|
47
|
+
return location;
|
|
48
|
+
}
|
|
49
|
+
function consoleEntry(event) {
|
|
50
|
+
const frame = event.stackTrace?.callFrames?.[0];
|
|
51
|
+
return {
|
|
52
|
+
type: String(event.type),
|
|
53
|
+
text: (event.args ?? []).map(remoteText).join(' '),
|
|
54
|
+
...locationOf(frame?.url, frame?.lineNumber),
|
|
55
|
+
timestamp: typeof event.timestamp === 'number' ? event.timestamp : Date.now(),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function exceptionEntry(event) {
|
|
59
|
+
const details = event.exceptionDetails ?? {};
|
|
60
|
+
return {
|
|
61
|
+
type: 'exception',
|
|
62
|
+
text: String(details.exception?.description ?? details.exception?.value ?? details.text ?? 'Uncaught exception'),
|
|
63
|
+
...locationOf(details.url, details.lineNumber),
|
|
64
|
+
timestamp: typeof event.timestamp === 'number' ? event.timestamp : Date.now(),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Validates a failureArtifacts option before any CDP work: absolute, and never inside
|
|
69
|
+
* RIGHTKIT_RUN_ROOT (the workspace home that disposeQaWorkspace deletes).
|
|
70
|
+
*/
|
|
71
|
+
function failureTarget(options) {
|
|
72
|
+
if (!options)
|
|
73
|
+
return undefined;
|
|
74
|
+
const { directory, label } = options;
|
|
75
|
+
if (typeof directory !== 'string' || !path.isAbsolute(directory))
|
|
76
|
+
throw new Error('failureArtifacts.directory must be an absolute path');
|
|
77
|
+
if (label !== undefined && typeof label !== 'string')
|
|
78
|
+
throw new Error('failureArtifacts.label must be a string');
|
|
79
|
+
const resolved = path.resolve(directory);
|
|
80
|
+
const runRoot = process.env.RIGHTKIT_RUN_ROOT;
|
|
81
|
+
if (runRoot) {
|
|
82
|
+
const relative = path.relative(path.resolve(runRoot), resolved);
|
|
83
|
+
const inside = relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`));
|
|
84
|
+
if (inside)
|
|
85
|
+
throw new Error('failureArtifacts.directory must be outside RIGHTKIT_RUN_ROOT: disposeQaWorkspace deletes that tree');
|
|
86
|
+
}
|
|
87
|
+
return { directory: resolved, label: (label ?? 'wait').replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 60) || 'wait' };
|
|
88
|
+
}
|
|
89
|
+
let failureSequence = 0;
|
|
8
90
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
9
91
|
function functionSource(fn) {
|
|
10
92
|
const source = fn.toString();
|
|
@@ -211,6 +293,45 @@ export class QaBrowserSession {
|
|
|
211
293
|
throw error;
|
|
212
294
|
}
|
|
213
295
|
}
|
|
296
|
+
/**
|
|
297
|
+
* UNSUPPORTED escape hatch: sends one browser-scoped CDP command (no sessionId) and resolves
|
|
298
|
+
* with its raw result. The protocol is Chrome's, not this package's: method names, params and
|
|
299
|
+
* results may change or disappear with a browser version. Prefer the typed QaPage API.
|
|
300
|
+
*/
|
|
301
|
+
send(method, params = {}) {
|
|
302
|
+
if (this.closed)
|
|
303
|
+
return Promise.reject(new Error('Browser session closed'));
|
|
304
|
+
if (typeof method !== 'string' || !method)
|
|
305
|
+
return Promise.reject(new Error('CDP method must be a non-empty string'));
|
|
306
|
+
return this.client.send(method, params);
|
|
307
|
+
}
|
|
308
|
+
/** Browser.getVersion (unsupported surface, see send). Read only. */
|
|
309
|
+
version() {
|
|
310
|
+
return this.send('Browser.getVersion');
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Browser argv via Browser.getBrowserCommandLine (unsupported surface, see send). The browser
|
|
314
|
+
* answers only when it was launched with --enable-automation; launchChromeForTesting passes no
|
|
315
|
+
* such flag by default, so pass { args: ['--enable-automation'] } to use this. A refusal rejects
|
|
316
|
+
* with an error that names the requirement; the browser is not changed.
|
|
317
|
+
*/
|
|
318
|
+
async commandLine() {
|
|
319
|
+
let reply;
|
|
320
|
+
try {
|
|
321
|
+
reply = await this.send('Browser.getBrowserCommandLine');
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
// CdpClient prefixes errors the browser itself returned with "CDP: "; timeouts and closed sockets pass through.
|
|
325
|
+
if (error instanceof Error && error.message.startsWith('CDP: ')) {
|
|
326
|
+
throw new Error(`Browser.getBrowserCommandLine was refused (${error.message.slice(5)}). It requires the browser to be launched with --enable-automation, e.g. launchChromeForTesting({ args: ['--enable-automation'] }).`, { cause: error });
|
|
327
|
+
}
|
|
328
|
+
throw error;
|
|
329
|
+
}
|
|
330
|
+
const argv = reply.arguments;
|
|
331
|
+
if (!Array.isArray(argv) || !argv.every((arg) => typeof arg === 'string'))
|
|
332
|
+
throw new Error('Browser.getBrowserCommandLine did not return an argument list');
|
|
333
|
+
return argv;
|
|
334
|
+
}
|
|
214
335
|
close() {
|
|
215
336
|
if (this.closing)
|
|
216
337
|
return this.closing;
|
|
@@ -252,6 +373,9 @@ export class QaPage {
|
|
|
252
373
|
lastNetworkActivity = 0;
|
|
253
374
|
generation = 0;
|
|
254
375
|
navigating = false;
|
|
376
|
+
consoleBuffer = [];
|
|
377
|
+
userAgentBaseline;
|
|
378
|
+
userAgentOverridden = false;
|
|
255
379
|
get objectGroup() { return `qa-elements-${this.generation}`; }
|
|
256
380
|
/** @internal Use QaBrowserSession.newPage() or attachPage(). `owned` pages are closed on close(); attached ones only detach. */
|
|
257
381
|
constructor(client, targetId, sessionId, onClosed, owned = true) {
|
|
@@ -296,7 +420,7 @@ export class QaPage {
|
|
|
296
420
|
for (const [id, request] of this.requests)
|
|
297
421
|
if (request.frameId && removed.has(request.frameId))
|
|
298
422
|
this.requests.delete(id);
|
|
299
|
-
}), this.on('Network.requestWillBeSent', ({ requestId, frameId, loaderId }) => { this.requests.set(requestId, { frameId, loaderId }); this.lastNetworkActivity = Date.now(); }), this.on('Network.loadingFinished', ({ requestId }) => { this.requests.delete(requestId); this.lastNetworkActivity = Date.now(); }), this.on('Network.loadingFailed', ({ requestId }) => { this.requests.delete(requestId); this.lastNetworkActivity = Date.now(); }), client.on('Disconnected', ({ error }) => this.markClosed(error)), client.on('Target.detachedFromTarget', ({ sessionId }) => { if (sessionId === this.sessionId)
|
|
423
|
+
}), this.on('Runtime.consoleAPICalled', (event) => this.recordConsole(consoleEntry(event))), this.on('Runtime.exceptionThrown', (event) => this.recordConsole(exceptionEntry(event))), this.on('Network.requestWillBeSent', ({ requestId, frameId, loaderId }) => { this.requests.set(requestId, { frameId, loaderId }); this.lastNetworkActivity = Date.now(); }), this.on('Network.loadingFinished', ({ requestId }) => { this.requests.delete(requestId); this.lastNetworkActivity = Date.now(); }), this.on('Network.loadingFailed', ({ requestId }) => { this.requests.delete(requestId); this.lastNetworkActivity = Date.now(); }), client.on('Disconnected', ({ error }) => this.markClosed(error)), client.on('Target.detachedFromTarget', ({ sessionId }) => { if (sessionId === this.sessionId)
|
|
300
424
|
this.markClosed(); }), this.on('Inspector.detached', () => this.markClosed()));
|
|
301
425
|
}
|
|
302
426
|
chooseContext() {
|
|
@@ -550,35 +674,46 @@ export class QaPage {
|
|
|
550
674
|
if (typeof polling === 'number' && (!(polling > 0) || !Number.isFinite(polling)))
|
|
551
675
|
throw new Error('Polling interval must be positive');
|
|
552
676
|
const source = typeof fn === 'string' ? `function() { return (${fn}); }` : functionSource(fn);
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
677
|
+
const artifacts = failureTarget(options.failureArtifacts);
|
|
678
|
+
const timeout = options.timeout ?? this.client.timeout;
|
|
679
|
+
try {
|
|
680
|
+
await this.bounded(`Predicate ${source}`, timeout, async (remaining, stopped) => {
|
|
681
|
+
while (!stopped()) {
|
|
682
|
+
try {
|
|
683
|
+
if (resultValue(await this.runSourceFunction(source, args, remaining())))
|
|
684
|
+
return;
|
|
685
|
+
if (polling === 'raf') {
|
|
686
|
+
// A browser animation frame, bounded fallback for background targets.
|
|
687
|
+
await this.runSourceFunction('function() { return new Promise(resolve => { const timer = setTimeout(resolve, 100); requestAnimationFrame(() => { clearTimeout(timer); resolve(); }); }); }', [], remaining());
|
|
688
|
+
}
|
|
689
|
+
else
|
|
690
|
+
await sleep(Math.min(polling, remaining()));
|
|
561
691
|
}
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
692
|
+
catch (error) {
|
|
693
|
+
if (error instanceof TimeoutError) {
|
|
694
|
+
await sleep(remaining());
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
if (!/context.*(destroyed|not found)|Cannot find context/i.test(String(error)))
|
|
698
|
+
throw error;
|
|
699
|
+
await sleep(Math.min(10, remaining()));
|
|
569
700
|
}
|
|
570
|
-
if (!/context.*(destroyed|not found)|Cannot find context/i.test(String(error)))
|
|
571
|
-
throw error;
|
|
572
|
-
await sleep(Math.min(10, remaining()));
|
|
573
701
|
}
|
|
574
|
-
}
|
|
575
|
-
}
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
catch (error) {
|
|
705
|
+
if (artifacts && error instanceof TimeoutError)
|
|
706
|
+
await this.attachFailureArtifacts(error, artifacts, source, timeout);
|
|
707
|
+
throw error;
|
|
708
|
+
}
|
|
576
709
|
}
|
|
577
710
|
async waitForSelector(selector, options = {}) {
|
|
578
711
|
if (options.visible && options.hidden)
|
|
579
712
|
throw new Error('visible & hidden cannot both be true');
|
|
713
|
+
const artifacts = failureTarget(options.failureArtifacts);
|
|
714
|
+
const timeout = options.timeout ?? this.client.timeout;
|
|
580
715
|
try {
|
|
581
|
-
return await this.bounded(`Selector ${selector}`,
|
|
716
|
+
return await this.bounded(`Selector ${selector}`, timeout, async (remaining) => {
|
|
582
717
|
await this.waitForFunction((selector, visible, hidden) => {
|
|
583
718
|
const element = document.querySelector(selector);
|
|
584
719
|
if (!element)
|
|
@@ -591,8 +726,12 @@ export class QaPage {
|
|
|
591
726
|
});
|
|
592
727
|
}
|
|
593
728
|
catch (error) {
|
|
594
|
-
if (error instanceof TimeoutError)
|
|
595
|
-
|
|
729
|
+
if (error instanceof TimeoutError) {
|
|
730
|
+
const named = new TimeoutError(`Selector ${selector} timed out after ${timeout}ms`);
|
|
731
|
+
if (artifacts)
|
|
732
|
+
await this.attachFailureArtifacts(named, artifacts, `waitForSelector ${JSON.stringify(selector)} visible=${!!options.visible} hidden=${!!options.hidden}`, timeout);
|
|
733
|
+
throw named;
|
|
734
|
+
}
|
|
596
735
|
throw error;
|
|
597
736
|
}
|
|
598
737
|
}
|
|
@@ -621,12 +760,163 @@ export class QaPage {
|
|
|
621
760
|
throw new Error('Element has no clickable box');
|
|
622
761
|
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
|
623
762
|
}
|
|
763
|
+
/** Recent console calls and uncaught exceptions of this page, oldest first. Bounded to the newest 200. Returns copies. */
|
|
764
|
+
consoleEntries() { return this.consoleBuffer.map((entry) => ({ ...entry })); }
|
|
765
|
+
/** Empties the console buffer. */
|
|
766
|
+
clearConsoleEntries() { this.consoleBuffer.length = 0; }
|
|
767
|
+
recordConsole(entry) {
|
|
768
|
+
this.consoleBuffer.push(entry);
|
|
769
|
+
if (this.consoleBuffer.length > CONSOLE_LIMIT)
|
|
770
|
+
this.consoleBuffer.splice(0, this.consoleBuffer.length - CONSOLE_LIMIT);
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Overrides the user agent (Emulation.setUserAgentOverride) for this page, including later navigations.
|
|
774
|
+
* Each call replaces the whole override, so platform & acceptLanguage are sent only when given.
|
|
775
|
+
*/
|
|
776
|
+
async setUserAgent(userAgent, options = {}) {
|
|
777
|
+
if (typeof userAgent !== 'string' || !userAgent.trim())
|
|
778
|
+
throw new Error('User agent must be a non-empty string');
|
|
779
|
+
// The first override records the page's own navigator.userAgent so resetUserAgent can restore it.
|
|
780
|
+
if (!this.userAgentOverridden)
|
|
781
|
+
this.userAgentBaseline = await this.evaluate(() => navigator.userAgent);
|
|
782
|
+
await this.send('Emulation.setUserAgentOverride', {
|
|
783
|
+
userAgent,
|
|
784
|
+
...(options.platform !== undefined ? { platform: options.platform } : {}),
|
|
785
|
+
...(options.acceptLanguage !== undefined ? { acceptLanguage: options.acceptLanguage } : {}),
|
|
786
|
+
});
|
|
787
|
+
this.userAgentOverridden = true;
|
|
788
|
+
}
|
|
789
|
+
/** Restores the navigator.userAgent captured before the first setUserAgent. A no-op when this page never overrode it. */
|
|
790
|
+
async resetUserAgent() {
|
|
791
|
+
if (!this.userAgentOverridden || this.userAgentBaseline === undefined)
|
|
792
|
+
return;
|
|
793
|
+
await this.send('Emulation.setUserAgentOverride', { userAgent: this.userAgentBaseline });
|
|
794
|
+
this.userAgentOverridden = false;
|
|
795
|
+
this.userAgentBaseline = undefined;
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* 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 (requires replace) the value is read back
|
|
800
|
+
* once after insertion and FillMismatchError is thrown on a difference.
|
|
801
|
+
*/
|
|
802
|
+
async fill(target, value, options = {}) {
|
|
803
|
+
if (typeof value !== 'string')
|
|
804
|
+
throw new Error('fill value must be a string');
|
|
805
|
+
if (options.verify && !options.replace)
|
|
806
|
+
throw new Error('fill verify requires replace: true; an insert-only fill has no fixed expected value');
|
|
807
|
+
await this.withElement(target, async (handle) => {
|
|
808
|
+
if (!options.replace) {
|
|
809
|
+
await this.typeElement(handle, value);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
await handle.focus();
|
|
813
|
+
await this.selectAll(handle);
|
|
814
|
+
if (value === '')
|
|
815
|
+
await this.keyboard.press('Backspace');
|
|
816
|
+
else
|
|
817
|
+
await this.send('Input.insertText', { text: value });
|
|
818
|
+
if (options.verify) {
|
|
819
|
+
const actual = await handle.evaluate((element) => (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement ? element.value : element.textContent) ?? '');
|
|
820
|
+
if (actual !== value)
|
|
821
|
+
throw new FillMismatchError(value.length, actual.length);
|
|
822
|
+
}
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
async selectAll(handle) {
|
|
826
|
+
const selected = await handle.evaluate((element) => {
|
|
827
|
+
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
|
|
828
|
+
element.select();
|
|
829
|
+
return true;
|
|
830
|
+
}
|
|
831
|
+
if (element.isContentEditable) {
|
|
832
|
+
const range = element.ownerDocument.createRange();
|
|
833
|
+
range.selectNodeContents(element);
|
|
834
|
+
const selection = element.ownerDocument.getSelection();
|
|
835
|
+
selection?.removeAllRanges();
|
|
836
|
+
selection?.addRange(range);
|
|
837
|
+
return true;
|
|
838
|
+
}
|
|
839
|
+
return false;
|
|
840
|
+
});
|
|
841
|
+
if (!selected)
|
|
842
|
+
throw new Error('fill target is not an editable input, textarea or contenteditable element');
|
|
843
|
+
}
|
|
844
|
+
/** Throws ElementOccludedError naming the element on top unless the target itself or a composed descendant is hit. */
|
|
845
|
+
async assertUnoccluded(handle, point) {
|
|
846
|
+
const occluder = await handle.evaluate((element, x, y) => {
|
|
847
|
+
let hit = element.ownerDocument.elementFromPoint(x, y);
|
|
848
|
+
// The document reports a shadow host, not the element inside its open shadow root: descend.
|
|
849
|
+
while (hit?.shadowRoot) {
|
|
850
|
+
const inner = hit.shadowRoot.elementFromPoint(x, y);
|
|
851
|
+
if (!inner || inner === hit)
|
|
852
|
+
break;
|
|
853
|
+
hit = inner;
|
|
854
|
+
}
|
|
855
|
+
// Composed ancestry: a shadow root's host counts as its parent, so shadow content inside the target passes.
|
|
856
|
+
for (let node = hit; node; node = node.parentNode ?? node.host ?? null) {
|
|
857
|
+
if (node === element)
|
|
858
|
+
return null;
|
|
859
|
+
}
|
|
860
|
+
if (!hit)
|
|
861
|
+
return '(no element at the click point)';
|
|
862
|
+
const id = hit.id ? ` id="${hit.id}"` : '';
|
|
863
|
+
const className = typeof hit.className === 'string' ? hit.className.trim().slice(0, 80) : '';
|
|
864
|
+
return `<${hit.tagName.toLowerCase()}${id}${className ? ` class="${className}"` : ''}>`;
|
|
865
|
+
}, point.x, point.y);
|
|
866
|
+
if (occluder !== null)
|
|
867
|
+
throw new ElementOccludedError(occluder, point);
|
|
868
|
+
}
|
|
869
|
+
/** Writes a PNG and a JSON state dump for a timed-out wait into the caller directory. */
|
|
870
|
+
async captureFailure(directory, label, predicate, timeout) {
|
|
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`);
|
|
875
|
+
let screenshot;
|
|
876
|
+
let screenshotError;
|
|
877
|
+
try {
|
|
878
|
+
await writeFile(png, await this.screenshot());
|
|
879
|
+
screenshot = png;
|
|
880
|
+
}
|
|
881
|
+
catch (error) {
|
|
882
|
+
screenshotError = error instanceof Error ? error.message : String(error);
|
|
883
|
+
}
|
|
884
|
+
let where = {};
|
|
885
|
+
try {
|
|
886
|
+
where = resultValue(await this.run(() => ({ url: location.href, title: document.title }), [], true, 2_000)) ?? {};
|
|
887
|
+
}
|
|
888
|
+
catch { /* page gone or stuck: the dump records no url or title */ }
|
|
889
|
+
const dump = {
|
|
890
|
+
label,
|
|
891
|
+
predicate,
|
|
892
|
+
timeoutMs: timeout,
|
|
893
|
+
timestamp: new Date().toISOString(),
|
|
894
|
+
url: where.url ?? null,
|
|
895
|
+
title: where.title ?? null,
|
|
896
|
+
screenshot: screenshot ? path.basename(screenshot) : null,
|
|
897
|
+
screenshotError: screenshotError ?? null,
|
|
898
|
+
console: this.consoleEntries(),
|
|
899
|
+
};
|
|
900
|
+
await writeFile(json, JSON.stringify(dump, null, 2));
|
|
901
|
+
return screenshot ? { json, screenshot } : { json };
|
|
902
|
+
}
|
|
903
|
+
/** Attaches artifact paths to a timeout error. A write failure is appended to its message; the timeout still throws. */
|
|
904
|
+
async attachFailureArtifacts(error, target, predicate, timeout) {
|
|
905
|
+
try {
|
|
906
|
+
error.artifacts = await this.captureFailure(target.directory, target.label, predicate, timeout);
|
|
907
|
+
}
|
|
908
|
+
catch (captureError) {
|
|
909
|
+
error.message += ` (failure artifacts were not written: ${captureError instanceof Error ? captureError.message : String(captureError)})`;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
624
912
|
async click(target, options = {}) {
|
|
625
913
|
this.mouse.assertReleased();
|
|
626
914
|
await this.withElement(target, async (handle) => {
|
|
627
915
|
const point = await this.point(handle), button = options.button ?? 'left', count = options.clickCount ?? 1;
|
|
628
916
|
await this.send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...point });
|
|
629
917
|
this.mouse.track(point.x, point.y);
|
|
918
|
+
if (options.checkOccluded)
|
|
919
|
+
await this.assertUnoccluded(handle, point);
|
|
630
920
|
for (let clickCount = 1; clickCount <= count; clickCount++) {
|
|
631
921
|
await this.send('Input.dispatchMouseEvent', { type: 'mousePressed', ...point, button, buttons: button === 'left' ? 1 : button === 'right' ? 2 : 4, clickCount });
|
|
632
922
|
if (options.delay)
|
package/dist/process.d.ts
CHANGED
|
@@ -18,5 +18,10 @@ export declare class ProcessTimeoutError extends Error {
|
|
|
18
18
|
constructor(pid: number, timeoutMs: number, stdout: string, stderr: string);
|
|
19
19
|
}
|
|
20
20
|
export declare function runOwnedProcess(command: string, args?: string[], options?: RunOwnedProcessOptions): Promise<OwnedProcessResult>;
|
|
21
|
+
/**
|
|
22
|
+
* A free TCP port on 127.0.0.1, found by binding port 0 and releasing it. Another process can take
|
|
23
|
+
* the port before the caller binds it; bind promptly or retry on EADDRINUSE.
|
|
24
|
+
*/
|
|
25
|
+
export declare function freePort(): Promise<number>;
|
|
21
26
|
export declare function isProcessAlive(pid: number): boolean;
|
|
22
27
|
export declare function killProcessTree(pid: number): Promise<void>;
|
package/dist/process.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createServer } from 'node:net';
|
|
2
3
|
export class ProcessTimeoutError extends Error {
|
|
3
4
|
pid;
|
|
4
5
|
stdout;
|
|
@@ -54,6 +55,26 @@ export function runOwnedProcess(command, args = [], options = {}) {
|
|
|
54
55
|
});
|
|
55
56
|
});
|
|
56
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* A free TCP port on 127.0.0.1, found by binding port 0 and releasing it. Another process can take
|
|
60
|
+
* the port before the caller binds it; bind promptly or retry on EADDRINUSE.
|
|
61
|
+
*/
|
|
62
|
+
export function freePort() {
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
const server = createServer();
|
|
65
|
+
server.once('error', reject);
|
|
66
|
+
server.listen(0, '127.0.0.1', () => {
|
|
67
|
+
const address = server.address();
|
|
68
|
+
if (!address || typeof address === 'string') {
|
|
69
|
+
server.close();
|
|
70
|
+
reject(new Error('freePort: no TCP address'));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const port = address.port;
|
|
74
|
+
server.close((error) => (error ? reject(error) : resolve(port)));
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
}
|
|
57
78
|
export function isProcessAlive(pid) {
|
|
58
79
|
if (!Number.isInteger(pid) || pid <= 0)
|
|
59
80
|
return false;
|
package/dist/workspace.d.ts
CHANGED
|
@@ -31,6 +31,11 @@ export interface CreateQaWorkspaceOptions {
|
|
|
31
31
|
leaseMs?: number;
|
|
32
32
|
retention?: import('./storage.js').RetentionPolicy;
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Create an isolated run home. `env.RIGHTKIT_RUN_ROOT` is set to `home`, which lives inside the
|
|
36
|
+
* workspace that {@link disposeQaWorkspace} deletes. A host must never map a directory holding
|
|
37
|
+
* artifacts it wants to keep onto RIGHTKIT_RUN_ROOT: disposal removes that tree.
|
|
38
|
+
*/
|
|
34
39
|
export declare function createQaWorkspace(options: CreateQaWorkspaceOptions): QaWorkspace;
|
|
35
40
|
export interface DisposeOptions {
|
|
36
41
|
/** Keep the recorded evidence and remove only the volatile profile. */
|
|
@@ -43,6 +48,11 @@ export interface DisposeOptions {
|
|
|
43
48
|
* Remove the workspace, retrying through the brief window in which an exiting
|
|
44
49
|
* app still holds its files open. Anything still present afterwards is returned,
|
|
45
50
|
* never silently ignored.
|
|
51
|
+
*
|
|
52
|
+
* Deletes `workspace.home`, which is what RIGHTKIT_RUN_ROOT points at, and also
|
|
53
|
+
* `workspace.root` unless `keepEvidence` is set. Anything a host maps under
|
|
54
|
+
* RIGHTKIT_RUN_ROOT is deleted: never map a directory holding artifacts you want
|
|
55
|
+
* to keep onto it.
|
|
46
56
|
*/
|
|
47
57
|
export declare function disposeQaWorkspace(workspace: QaWorkspace, options?: DisposeOptions): DisposeReport;
|
|
48
58
|
/**
|
package/dist/workspace.js
CHANGED
|
@@ -18,6 +18,11 @@ import crypto from 'node:crypto';
|
|
|
18
18
|
import fs from 'node:fs';
|
|
19
19
|
import path from 'node:path';
|
|
20
20
|
import { createManagedRun, createManagedAppRoot, finishManagedRun, pruneManagedRuns, resolveManagedRoot, } from './storage.js';
|
|
21
|
+
/**
|
|
22
|
+
* Create an isolated run home. `env.RIGHTKIT_RUN_ROOT` is set to `home`, which lives inside the
|
|
23
|
+
* workspace that {@link disposeQaWorkspace} deletes. A host must never map a directory holding
|
|
24
|
+
* artifacts it wants to keep onto RIGHTKIT_RUN_ROOT: disposal removes that tree.
|
|
25
|
+
*/
|
|
21
26
|
export function createQaWorkspace(options) {
|
|
22
27
|
const appKey = requireText(options.appKey, 'appKey');
|
|
23
28
|
const managed = createManagedRun({ managedRoot: options.root ? requireText(options.root, 'root') : resolveManagedRoot(), app: appKey, runId: options.runId ?? crypto.randomUUID(), leaseMs: options.leaseMs, retention: options.retention });
|
|
@@ -77,6 +82,11 @@ export function createQaWorkspace(options) {
|
|
|
77
82
|
* Remove the workspace, retrying through the brief window in which an exiting
|
|
78
83
|
* app still holds its files open. Anything still present afterwards is returned,
|
|
79
84
|
* never silently ignored.
|
|
85
|
+
*
|
|
86
|
+
* Deletes `workspace.home`, which is what RIGHTKIT_RUN_ROOT points at, and also
|
|
87
|
+
* `workspace.root` unless `keepEvidence` is set. Anything a host maps under
|
|
88
|
+
* RIGHTKIT_RUN_ROOT is deleted: never map a directory holding artifacts you want
|
|
89
|
+
* to keep onto it.
|
|
80
90
|
*/
|
|
81
91
|
export function disposeQaWorkspace(workspace, options = {}) {
|
|
82
92
|
const attempts = Math.max(1, options.attempts ?? 5);
|
package/package.json
CHANGED