@rightkit/qa 0.4.3 → 0.4.5
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 +21 -2
- package/dist/cdp.d.ts +5 -0
- package/dist/cdp.js +2 -0
- package/dist/mouse.d.ts +9 -0
- package/dist/mouse.js +12 -0
- package/dist/page.d.ts +114 -4
- package/dist/page.js +384 -41
- package/dist/process.d.ts +40 -0
- package/dist/process.js +100 -0
- package/package.json +2 -2
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,25 @@ 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
|
+
- `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.
|
|
105
|
+
|
|
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
|
+
|
|
89
108
|
## Keyboard keys
|
|
90
109
|
|
|
91
110
|
`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.
|
|
@@ -124,5 +143,5 @@ This package launches no native app and no WebView2 window; it never replaces th
|
|
|
124
143
|
|
|
125
144
|
## Known issues
|
|
126
145
|
|
|
127
|
-
- `@rightkit/qa-chrome` is pinned exactly to `0.1.
|
|
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).
|
|
128
147
|
- Three consumers pin three different versions of `@rightkit/qa-chrome` as dev dependencies (npm audit 2026-10-08).
|
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/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,67 @@ 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
|
+
}
|
|
24
|
+
/** Called once per poll, before the predicate. A throw rejects the wait with that error. */
|
|
25
|
+
export type PollHook = () => void | Promise<void>;
|
|
17
26
|
export interface WaitOptions {
|
|
18
27
|
timeout?: number;
|
|
19
28
|
polling?: 'raf' | number;
|
|
29
|
+
failureArtifacts?: FailureArtifactOptions;
|
|
30
|
+
onPoll?: PollHook;
|
|
20
31
|
}
|
|
21
32
|
export interface SelectorWaitOptions {
|
|
22
33
|
visible?: boolean;
|
|
23
34
|
hidden?: boolean;
|
|
24
35
|
timeout?: number;
|
|
36
|
+
failureArtifacts?: FailureArtifactOptions;
|
|
37
|
+
onPoll?: PollHook;
|
|
38
|
+
}
|
|
39
|
+
/** How the target is brought into view before the pointer moves. Vertical alignment is always centre. */
|
|
40
|
+
export interface ScrollOptions {
|
|
41
|
+
/** Default 'center', as before 0.4.5. 'nearest' scrolls the least needed to show the target at the edge. */
|
|
42
|
+
inline?: 'center' | 'nearest';
|
|
43
|
+
}
|
|
44
|
+
export interface HoverOptions {
|
|
45
|
+
scroll?: ScrollOptions;
|
|
25
46
|
}
|
|
26
47
|
export interface ClickOptions {
|
|
27
48
|
button?: 'left' | 'right' | 'middle';
|
|
28
49
|
clickCount?: number;
|
|
29
50
|
delay?: number;
|
|
51
|
+
scroll?: ScrollOptions;
|
|
52
|
+
/** 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. */
|
|
53
|
+
checkOccluded?: boolean;
|
|
54
|
+
}
|
|
55
|
+
export interface FillOptions {
|
|
56
|
+
/** 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. */
|
|
57
|
+
replace?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Default false: read the value back once after insertion and throw FillMismatchError on a difference.
|
|
60
|
+
* `{ timeoutMs }` polls the readback every 25 ms until it matches or the window ends, for fields that
|
|
61
|
+
* settle late (a framework rewrites the value after an input event). Requires replace.
|
|
62
|
+
*/
|
|
63
|
+
verify?: boolean | {
|
|
64
|
+
timeoutMs: number;
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** One console call or uncaught exception of a page. `line` is 1-based. */
|
|
68
|
+
export interface ConsoleEntry {
|
|
69
|
+
type: string;
|
|
70
|
+
text: string;
|
|
71
|
+
url?: string;
|
|
72
|
+
line?: number;
|
|
73
|
+
timestamp: number;
|
|
74
|
+
}
|
|
75
|
+
export interface UserAgentOptions {
|
|
76
|
+
platform?: string;
|
|
77
|
+
acceptLanguage?: string;
|
|
30
78
|
}
|
|
31
79
|
export interface TypeOptions {
|
|
32
80
|
delay?: number;
|
|
@@ -60,6 +108,25 @@ export interface QaAttachOptions extends QaConnectOptions {
|
|
|
60
108
|
allowRemoteHost?: boolean;
|
|
61
109
|
}
|
|
62
110
|
type PageFunction<Args extends unknown[], Result> = (...args: Args) => Result;
|
|
111
|
+
/** Thrown by click({ checkOccluded: true }) when another element is on top of the target at the click point. */
|
|
112
|
+
export declare class ElementOccludedError extends Error {
|
|
113
|
+
readonly point: {
|
|
114
|
+
x: number;
|
|
115
|
+
y: number;
|
|
116
|
+
};
|
|
117
|
+
/** Short description of the element on top, e.g. <div id="toast">. Text content is never included. */
|
|
118
|
+
readonly occluder: string;
|
|
119
|
+
constructor(occluder: string, point: {
|
|
120
|
+
x: number;
|
|
121
|
+
y: number;
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
/** Thrown by fill({ verify: true }) when the field does not hold the requested value. The values are not included. */
|
|
125
|
+
export declare class FillMismatchError extends Error {
|
|
126
|
+
readonly expectedLength: number;
|
|
127
|
+
readonly actualLength: number;
|
|
128
|
+
constructor(expectedLength: number, actualLength: number);
|
|
129
|
+
}
|
|
63
130
|
/** Owns only targets it creates; launcher owns browser process & profile cleanup. */
|
|
64
131
|
export declare class QaBrowserSession {
|
|
65
132
|
private client;
|
|
@@ -124,6 +191,9 @@ export declare class QaPage {
|
|
|
124
191
|
private lastNetworkActivity;
|
|
125
192
|
private generation;
|
|
126
193
|
private navigating;
|
|
194
|
+
private consoleBuffer;
|
|
195
|
+
private userAgentBaseline?;
|
|
196
|
+
private userAgentOverridden;
|
|
127
197
|
private get objectGroup();
|
|
128
198
|
/** @internal Use QaBrowserSession.newPage() or attachPage(). `owned` pages are closed on close(); attached ones only detach. */
|
|
129
199
|
constructor(client: CdpClient, targetId: string, sessionId: string, onClosed: () => void, owned?: boolean);
|
|
@@ -132,8 +202,17 @@ export declare class QaPage {
|
|
|
132
202
|
on(method: string, listener: (params: CdpParams) => void): () => void;
|
|
133
203
|
/** @internal */
|
|
134
204
|
initialize(): Promise<void>;
|
|
135
|
-
/**
|
|
205
|
+
/**
|
|
206
|
+
* UNSUPPORTED escape hatch: sends one page-scoped CDP command on this page's session and resolves
|
|
207
|
+
* with its raw result. Method names, params and results follow Chrome's protocol, not this package,
|
|
208
|
+
* and may change with the browser version. Prefer the typed QaPage methods where they exist.
|
|
209
|
+
*/
|
|
136
210
|
send(method: string, params?: CdpParams, timeout?: number): Promise<CdpParams>;
|
|
211
|
+
/**
|
|
212
|
+
* Emulation.setFocusEmulationEnabled: the page behaves as focused while its window is not
|
|
213
|
+
* (background or headless). Persists for the page until turned off.
|
|
214
|
+
*/
|
|
215
|
+
setFocusEmulation(enabled: boolean): Promise<void>;
|
|
137
216
|
private invalidateHandles;
|
|
138
217
|
/** @internal */
|
|
139
218
|
markClosed(error?: Error): void;
|
|
@@ -162,11 +241,42 @@ export declare class QaPage {
|
|
|
162
241
|
private requireElement;
|
|
163
242
|
private withElement;
|
|
164
243
|
private point;
|
|
244
|
+
/** Recent console calls and uncaught exceptions of this page, oldest first. Bounded to the newest 200. Returns copies. */
|
|
245
|
+
consoleEntries(): ConsoleEntry[];
|
|
246
|
+
/** Empties the console buffer. */
|
|
247
|
+
clearConsoleEntries(): void;
|
|
248
|
+
private recordConsole;
|
|
249
|
+
/**
|
|
250
|
+
* Overrides the user agent (Emulation.setUserAgentOverride) for this page, including later navigations.
|
|
251
|
+
* Each call replaces the whole override, so platform & acceptLanguage are sent only when given.
|
|
252
|
+
*/
|
|
253
|
+
setUserAgent(userAgent: string, options?: UserAgentOptions): Promise<void>;
|
|
254
|
+
/** Restores the navigator.userAgent captured before the first setUserAgent. A no-op when this page never overrode it. */
|
|
255
|
+
resetUserAgent(): Promise<void>;
|
|
256
|
+
/**
|
|
257
|
+
* Types a value into a field. Default: insert at the caret, exactly as type() does. With replace the
|
|
258
|
+
* whole content is selected first and replaced. With verify the value is read back and
|
|
259
|
+
* FillMismatchError is thrown on a difference: once by default, or polled until `timeoutMs` with
|
|
260
|
+
* `verify: { timeoutMs }`. Verify requires replace.
|
|
261
|
+
*/
|
|
262
|
+
fill(target: string | QaElement, value: string, options?: FillOptions): Promise<void>;
|
|
263
|
+
private selectAll;
|
|
264
|
+
/** Throws ElementOccludedError naming the element on top unless the target itself or a composed descendant is hit. */
|
|
265
|
+
private assertUnoccluded;
|
|
266
|
+
/** Writes a PNG and a JSON state dump for a timed-out wait into the caller directory. */
|
|
267
|
+
private captureFailure;
|
|
268
|
+
/** Attaches artifact paths to a timeout error. A write failure is appended to its message; the timeout still throws. */
|
|
269
|
+
/**
|
|
270
|
+
* Writes the failure artifacts for a timed-out wait or a failed onPoll hook. The paths are attached as
|
|
271
|
+
* `artifacts` only on a TimeoutError; for other errors the files are written and the paths are not attached.
|
|
272
|
+
* A write failure is appended to a TimeoutError's message; the original error still throws.
|
|
273
|
+
*/
|
|
274
|
+
private attachFailureArtifacts;
|
|
165
275
|
click(target: string | QaElement, options?: ClickOptions): Promise<void>;
|
|
166
276
|
type(selector: string, text: string, options?: TypeOptions): Promise<void>;
|
|
167
277
|
/** @internal */
|
|
168
278
|
typeElement(handle: QaElement, text: string, options?: TypeOptions): Promise<void>;
|
|
169
|
-
hover(target: string | QaElement): Promise<void>;
|
|
279
|
+
hover(target: string | QaElement, options?: HoverOptions): Promise<void>;
|
|
170
280
|
focus(target: string | QaElement): Promise<void>;
|
|
171
281
|
select(selector: string, ...values: string[]): Promise<string[]>;
|
|
172
282
|
screenshot(options?: ScreenshotOptions): Promise<Buffer>;
|
|
@@ -187,7 +297,7 @@ export declare class QaElement {
|
|
|
187
297
|
evaluate<Args extends unknown[], Result>(fn: (element: HTMLElement, ...args: Args) => Result, ...args: Args): Promise<Awaited<Result>>;
|
|
188
298
|
click(options?: ClickOptions): Promise<void>;
|
|
189
299
|
type(text: string, options?: TypeOptions): Promise<void>;
|
|
190
|
-
hover(): Promise<void>;
|
|
300
|
+
hover(options?: HoverOptions): Promise<void>;
|
|
191
301
|
focus(): Promise<void>;
|
|
192
302
|
boundingBox(): Promise<BoundingBox | null>;
|
|
193
303
|
}
|
package/dist/page.js
CHANGED
|
@@ -1,10 +1,117 @@
|
|
|
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
|
+
/** 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
|
+
}
|
|
59
|
+
/** Text of one console argument, from its CDP RemoteObject. */
|
|
60
|
+
function remoteText(object) {
|
|
61
|
+
if ('value' in object)
|
|
62
|
+
return typeof object.value === 'string' ? object.value : JSON.stringify(object.value) ?? String(object.value);
|
|
63
|
+
return String(object.unserializableValue ?? object.description ?? object.type ?? '');
|
|
64
|
+
}
|
|
65
|
+
/** url and 1-based line, present only when the event carries them. */
|
|
66
|
+
function locationOf(url, zeroBasedLine) {
|
|
67
|
+
const location = {};
|
|
68
|
+
if (typeof url === 'string' && url)
|
|
69
|
+
location.url = url;
|
|
70
|
+
if (typeof zeroBasedLine === 'number' && zeroBasedLine >= 0)
|
|
71
|
+
location.line = zeroBasedLine + 1;
|
|
72
|
+
return location;
|
|
73
|
+
}
|
|
74
|
+
function consoleEntry(event) {
|
|
75
|
+
const frame = event.stackTrace?.callFrames?.[0];
|
|
76
|
+
return {
|
|
77
|
+
type: String(event.type),
|
|
78
|
+
text: (event.args ?? []).map(remoteText).join(' '),
|
|
79
|
+
...locationOf(frame?.url, frame?.lineNumber),
|
|
80
|
+
timestamp: typeof event.timestamp === 'number' ? event.timestamp : Date.now(),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function exceptionEntry(event) {
|
|
84
|
+
const details = event.exceptionDetails ?? {};
|
|
85
|
+
return {
|
|
86
|
+
type: 'exception',
|
|
87
|
+
text: String(details.exception?.description ?? details.exception?.value ?? details.text ?? 'Uncaught exception'),
|
|
88
|
+
...locationOf(details.url, details.lineNumber),
|
|
89
|
+
timestamp: typeof event.timestamp === 'number' ? event.timestamp : Date.now(),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Validates a failureArtifacts option before any CDP work: absolute, and never inside
|
|
94
|
+
* RIGHTKIT_RUN_ROOT (the workspace home that disposeQaWorkspace deletes).
|
|
95
|
+
*/
|
|
96
|
+
function failureTarget(options) {
|
|
97
|
+
if (!options)
|
|
98
|
+
return undefined;
|
|
99
|
+
const { directory, label } = options;
|
|
100
|
+
if (typeof directory !== 'string' || !path.isAbsolute(directory))
|
|
101
|
+
throw new Error('failureArtifacts.directory must be an absolute path');
|
|
102
|
+
if (label !== undefined && typeof label !== 'string')
|
|
103
|
+
throw new Error('failureArtifacts.label must be a string');
|
|
104
|
+
const resolved = path.resolve(directory);
|
|
105
|
+
const runRoot = process.env.RIGHTKIT_RUN_ROOT;
|
|
106
|
+
if (runRoot) {
|
|
107
|
+
const relative = path.relative(path.resolve(runRoot), resolved);
|
|
108
|
+
const inside = relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`));
|
|
109
|
+
if (inside)
|
|
110
|
+
throw new Error('failureArtifacts.directory must be outside RIGHTKIT_RUN_ROOT: disposeQaWorkspace deletes that tree');
|
|
111
|
+
}
|
|
112
|
+
return { directory: resolved, label: (label ?? 'wait').replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 60) || 'wait' };
|
|
113
|
+
}
|
|
114
|
+
let failureSequence = 0;
|
|
8
115
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
9
116
|
function functionSource(fn) {
|
|
10
117
|
const source = fn.toString();
|
|
@@ -291,6 +398,9 @@ export class QaPage {
|
|
|
291
398
|
lastNetworkActivity = 0;
|
|
292
399
|
generation = 0;
|
|
293
400
|
navigating = false;
|
|
401
|
+
consoleBuffer = [];
|
|
402
|
+
userAgentBaseline;
|
|
403
|
+
userAgentOverridden = false;
|
|
294
404
|
get objectGroup() { return `qa-elements-${this.generation}`; }
|
|
295
405
|
/** @internal Use QaBrowserSession.newPage() or attachPage(). `owned` pages are closed on close(); attached ones only detach. */
|
|
296
406
|
constructor(client, targetId, sessionId, onClosed, owned = true) {
|
|
@@ -335,7 +445,7 @@ export class QaPage {
|
|
|
335
445
|
for (const [id, request] of this.requests)
|
|
336
446
|
if (request.frameId && removed.has(request.frameId))
|
|
337
447
|
this.requests.delete(id);
|
|
338
|
-
}), 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)
|
|
448
|
+
}), 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)
|
|
339
449
|
this.markClosed(); }), this.on('Inspector.detached', () => this.markClosed()));
|
|
340
450
|
}
|
|
341
451
|
chooseContext() {
|
|
@@ -360,12 +470,27 @@ export class QaPage {
|
|
|
360
470
|
await this.send('Network.enable');
|
|
361
471
|
await this.send('Page.setLifecycleEventsEnabled', { enabled: true });
|
|
362
472
|
}
|
|
363
|
-
/**
|
|
473
|
+
/**
|
|
474
|
+
* UNSUPPORTED escape hatch: sends one page-scoped CDP command on this page's session and resolves
|
|
475
|
+
* with its raw result. Method names, params and results follow Chrome's protocol, not this package,
|
|
476
|
+
* and may change with the browser version. Prefer the typed QaPage methods where they exist.
|
|
477
|
+
*/
|
|
364
478
|
send(method, params = {}, timeout) {
|
|
365
479
|
if (this.closed)
|
|
366
480
|
return Promise.reject(this.closed);
|
|
481
|
+
if (typeof method !== 'string' || !method)
|
|
482
|
+
return Promise.reject(new Error('CDP method must be a non-empty string'));
|
|
367
483
|
return this.client.send(method, params, this.sessionId, timeout);
|
|
368
484
|
}
|
|
485
|
+
/**
|
|
486
|
+
* Emulation.setFocusEmulationEnabled: the page behaves as focused while its window is not
|
|
487
|
+
* (background or headless). Persists for the page until turned off.
|
|
488
|
+
*/
|
|
489
|
+
async setFocusEmulation(enabled) {
|
|
490
|
+
if (typeof enabled !== 'boolean')
|
|
491
|
+
throw new Error('setFocusEmulation expects a boolean');
|
|
492
|
+
await this.send('Emulation.setFocusEmulationEnabled', { enabled });
|
|
493
|
+
}
|
|
369
494
|
invalidateHandles() {
|
|
370
495
|
const objectGroup = this.objectGroup;
|
|
371
496
|
this.generation++;
|
|
@@ -512,14 +637,36 @@ export class QaPage {
|
|
|
512
637
|
}
|
|
513
638
|
async run(fn, args, returnByValue, timeout = this.client.timeout) {
|
|
514
639
|
const deadline = Date.now() + timeout;
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
640
|
+
// A reload destroys the context an earlier call resolved. CDP refuses a call against a missing
|
|
641
|
+
// context without running it, so re-resolve the main context and retry until the caller's deadline.
|
|
642
|
+
let missing;
|
|
643
|
+
for (;;) {
|
|
644
|
+
const remaining = Math.max(1, deadline - Date.now());
|
|
645
|
+
let result;
|
|
646
|
+
try {
|
|
647
|
+
const contextId = await this.mainContext(remaining);
|
|
648
|
+
const common = { returnByValue, awaitPromise: true, objectGroup: this.objectGroup, timeout: remaining };
|
|
649
|
+
result = typeof fn === 'string'
|
|
650
|
+
? await this.send('Runtime.evaluate', { expression: fn, contextId, ...common }, remaining)
|
|
651
|
+
: await this.send('Runtime.callFunctionOn', { functionDeclaration: functionSource(fn), executionContextId: contextId, arguments: args.map(argument), ...common }, remaining);
|
|
652
|
+
}
|
|
653
|
+
catch (error) {
|
|
654
|
+
if (isMissingContext(error)) {
|
|
655
|
+
missing = error;
|
|
656
|
+
if (Date.now() < deadline) {
|
|
657
|
+
await sleep(10);
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
throw error;
|
|
661
|
+
}
|
|
662
|
+
// The budget ran out while a context was missing: report the cause, not a bare timeout.
|
|
663
|
+
if (error instanceof TimeoutError && missing)
|
|
664
|
+
throw missing;
|
|
665
|
+
throw error;
|
|
666
|
+
}
|
|
667
|
+
resultValue(result);
|
|
668
|
+
return result;
|
|
669
|
+
}
|
|
523
670
|
}
|
|
524
671
|
async evaluate(fn, ...args) {
|
|
525
672
|
return resultValue(await this.run(fn, args, true));
|
|
@@ -589,35 +736,53 @@ export class QaPage {
|
|
|
589
736
|
if (typeof polling === 'number' && (!(polling > 0) || !Number.isFinite(polling)))
|
|
590
737
|
throw new Error('Polling interval must be positive');
|
|
591
738
|
const source = typeof fn === 'string' ? `function() { return (${fn}); }` : functionSource(fn);
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
739
|
+
const artifacts = failureTarget(options.failureArtifacts);
|
|
740
|
+
const timeout = options.timeout ?? this.client.timeout;
|
|
741
|
+
const pollState = { failed: false, error: undefined };
|
|
742
|
+
const poll = pollHook(options.onPoll, pollState);
|
|
743
|
+
try {
|
|
744
|
+
await this.bounded(`Predicate ${source}`, timeout, async (remaining, stopped) => {
|
|
745
|
+
while (!stopped()) {
|
|
746
|
+
if (poll)
|
|
747
|
+
await poll();
|
|
748
|
+
try {
|
|
749
|
+
if (resultValue(await this.runSourceFunction(source, args, remaining())))
|
|
750
|
+
return;
|
|
751
|
+
if (polling === 'raf') {
|
|
752
|
+
// A browser animation frame, bounded fallback for background targets.
|
|
753
|
+
await this.runSourceFunction('function() { return new Promise(resolve => { const timer = setTimeout(resolve, 100); requestAnimationFrame(() => { clearTimeout(timer); resolve(); }); }); }', [], remaining());
|
|
754
|
+
}
|
|
755
|
+
else
|
|
756
|
+
await sleep(Math.min(polling, remaining()));
|
|
600
757
|
}
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
758
|
+
catch (error) {
|
|
759
|
+
if (error instanceof TimeoutError) {
|
|
760
|
+
await sleep(remaining());
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
if (!/context.*(destroyed|not found)|Cannot find context/i.test(String(error)))
|
|
764
|
+
throw error;
|
|
765
|
+
await sleep(Math.min(10, remaining()));
|
|
608
766
|
}
|
|
609
|
-
if (!/context.*(destroyed|not found)|Cannot find context/i.test(String(error)))
|
|
610
|
-
throw error;
|
|
611
|
-
await sleep(Math.min(10, remaining()));
|
|
612
767
|
}
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
catch (error) {
|
|
771
|
+
if (artifacts && (error instanceof TimeoutError || (pollState.failed && pollState.error === error))) {
|
|
772
|
+
await this.attachFailureArtifacts(error, artifacts, source, timeout);
|
|
613
773
|
}
|
|
614
|
-
|
|
774
|
+
throw error;
|
|
775
|
+
}
|
|
615
776
|
}
|
|
616
777
|
async waitForSelector(selector, options = {}) {
|
|
617
778
|
if (options.visible && options.hidden)
|
|
618
779
|
throw new Error('visible & hidden cannot both be true');
|
|
780
|
+
const artifacts = failureTarget(options.failureArtifacts);
|
|
781
|
+
const timeout = options.timeout ?? this.client.timeout;
|
|
782
|
+
const pollState = { failed: false, error: undefined };
|
|
783
|
+
const poll = pollHook(options.onPoll, pollState);
|
|
619
784
|
try {
|
|
620
|
-
return await this.bounded(`Selector ${selector}`,
|
|
785
|
+
return await this.bounded(`Selector ${selector}`, timeout, async (remaining) => {
|
|
621
786
|
await this.waitForFunction((selector, visible, hidden) => {
|
|
622
787
|
const element = document.querySelector(selector);
|
|
623
788
|
if (!element)
|
|
@@ -625,13 +790,20 @@ export class QaPage {
|
|
|
625
790
|
const style = getComputedStyle(element), box = element.getBoundingClientRect();
|
|
626
791
|
const shown = style.visibility !== 'hidden' && style.visibility !== 'collapse' && box.width > 0 && box.height > 0;
|
|
627
792
|
return hidden ? !shown : visible ? shown : true;
|
|
628
|
-
}, { timeout: remaining() }, selector, !!options.visible, !!options.hidden);
|
|
793
|
+
}, { timeout: remaining(), onPoll: poll }, selector, !!options.visible, !!options.hidden);
|
|
629
794
|
return options.hidden ? null : this.lookup(selector, remaining());
|
|
630
795
|
});
|
|
631
796
|
}
|
|
632
797
|
catch (error) {
|
|
633
|
-
if (error instanceof TimeoutError)
|
|
634
|
-
|
|
798
|
+
if (error instanceof TimeoutError) {
|
|
799
|
+
const named = new TimeoutError(`Selector ${selector} timed out after ${timeout}ms`);
|
|
800
|
+
if (artifacts)
|
|
801
|
+
await this.attachFailureArtifacts(named, artifacts, `waitForSelector ${JSON.stringify(selector)} visible=${!!options.visible} hidden=${!!options.hidden}`, timeout);
|
|
802
|
+
throw named;
|
|
803
|
+
}
|
|
804
|
+
if (artifacts && pollState.failed && pollState.error === error) {
|
|
805
|
+
await this.attachFailureArtifacts(error, artifacts, `waitForSelector ${JSON.stringify(selector)} onPoll`, timeout);
|
|
806
|
+
}
|
|
635
807
|
throw error;
|
|
636
808
|
}
|
|
637
809
|
}
|
|
@@ -652,20 +824,190 @@ export class QaPage {
|
|
|
652
824
|
await handle.dispose();
|
|
653
825
|
}
|
|
654
826
|
}
|
|
655
|
-
async point(handle) {
|
|
656
|
-
await handle.evaluate((element) => { if (!element.isConnected)
|
|
657
|
-
throw new Error('Element detached'); element.scrollIntoView({ block: 'center', inline:
|
|
827
|
+
async point(handle, inline = 'center') {
|
|
828
|
+
await handle.evaluate((element, inline) => { if (!element.isConnected)
|
|
829
|
+
throw new Error('Element detached'); element.scrollIntoView({ block: 'center', inline: inline, behavior: 'instant' }); }, inline);
|
|
658
830
|
const box = await handle.boundingBox();
|
|
659
831
|
if (!box || !box.width || !box.height)
|
|
660
832
|
throw new Error('Element has no clickable box');
|
|
661
833
|
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
|
662
834
|
}
|
|
835
|
+
/** Recent console calls and uncaught exceptions of this page, oldest first. Bounded to the newest 200. Returns copies. */
|
|
836
|
+
consoleEntries() { return this.consoleBuffer.map((entry) => ({ ...entry })); }
|
|
837
|
+
/** Empties the console buffer. */
|
|
838
|
+
clearConsoleEntries() { this.consoleBuffer.length = 0; }
|
|
839
|
+
recordConsole(entry) {
|
|
840
|
+
this.consoleBuffer.push(entry);
|
|
841
|
+
if (this.consoleBuffer.length > CONSOLE_LIMIT)
|
|
842
|
+
this.consoleBuffer.splice(0, this.consoleBuffer.length - CONSOLE_LIMIT);
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Overrides the user agent (Emulation.setUserAgentOverride) for this page, including later navigations.
|
|
846
|
+
* Each call replaces the whole override, so platform & acceptLanguage are sent only when given.
|
|
847
|
+
*/
|
|
848
|
+
async setUserAgent(userAgent, options = {}) {
|
|
849
|
+
if (typeof userAgent !== 'string' || !userAgent.trim())
|
|
850
|
+
throw new Error('User agent must be a non-empty string');
|
|
851
|
+
// The first override records the page's own navigator.userAgent so resetUserAgent can restore it.
|
|
852
|
+
if (!this.userAgentOverridden)
|
|
853
|
+
this.userAgentBaseline = await this.evaluate(() => navigator.userAgent);
|
|
854
|
+
await this.send('Emulation.setUserAgentOverride', {
|
|
855
|
+
userAgent,
|
|
856
|
+
...(options.platform !== undefined ? { platform: options.platform } : {}),
|
|
857
|
+
...(options.acceptLanguage !== undefined ? { acceptLanguage: options.acceptLanguage } : {}),
|
|
858
|
+
});
|
|
859
|
+
this.userAgentOverridden = true;
|
|
860
|
+
}
|
|
861
|
+
/** Restores the navigator.userAgent captured before the first setUserAgent. A no-op when this page never overrode it. */
|
|
862
|
+
async resetUserAgent() {
|
|
863
|
+
if (!this.userAgentOverridden || this.userAgentBaseline === undefined)
|
|
864
|
+
return;
|
|
865
|
+
await this.send('Emulation.setUserAgentOverride', { userAgent: this.userAgentBaseline });
|
|
866
|
+
this.userAgentOverridden = false;
|
|
867
|
+
this.userAgentBaseline = undefined;
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* Types a value into a field. Default: insert at the caret, exactly as type() does. With replace the
|
|
871
|
+
* whole content is selected first and replaced. With verify the value is read back and
|
|
872
|
+
* FillMismatchError is thrown on a difference: once by default, or polled until `timeoutMs` with
|
|
873
|
+
* `verify: { timeoutMs }`. Verify requires replace.
|
|
874
|
+
*/
|
|
875
|
+
async fill(target, value, options = {}) {
|
|
876
|
+
if (typeof value !== 'string')
|
|
877
|
+
throw new Error('fill value must be a string');
|
|
878
|
+
const verify = options.verify;
|
|
879
|
+
if (verify && !options.replace)
|
|
880
|
+
throw new Error('fill verify requires replace: true; an insert-only fill has no fixed expected value');
|
|
881
|
+
const windowMs = typeof verify === 'object' && verify !== null ? validateTimeout(verify.timeoutMs) : undefined;
|
|
882
|
+
await this.withElement(target, async (handle) => {
|
|
883
|
+
if (!options.replace) {
|
|
884
|
+
await this.typeElement(handle, value);
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
await handle.focus();
|
|
888
|
+
await this.selectAll(handle);
|
|
889
|
+
if (value === '')
|
|
890
|
+
await this.keyboard.press('Backspace');
|
|
891
|
+
else
|
|
892
|
+
await this.send('Input.insertText', { text: value });
|
|
893
|
+
if (!verify)
|
|
894
|
+
return;
|
|
895
|
+
const read = () => handle.evaluate((element) => (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement ? element.value : element.textContent) ?? '');
|
|
896
|
+
let actual = await read();
|
|
897
|
+
if (windowMs !== undefined) {
|
|
898
|
+
const deadline = Date.now() + windowMs;
|
|
899
|
+
while (actual !== value && Date.now() < deadline) {
|
|
900
|
+
await sleep(Math.min(25, Math.max(1, deadline - Date.now())));
|
|
901
|
+
actual = await read();
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
if (actual !== value)
|
|
905
|
+
throw new FillMismatchError(value.length, actual.length);
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
async selectAll(handle) {
|
|
909
|
+
const selected = await handle.evaluate((element) => {
|
|
910
|
+
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
|
|
911
|
+
element.select();
|
|
912
|
+
return true;
|
|
913
|
+
}
|
|
914
|
+
if (element.isContentEditable) {
|
|
915
|
+
const range = element.ownerDocument.createRange();
|
|
916
|
+
range.selectNodeContents(element);
|
|
917
|
+
const selection = element.ownerDocument.getSelection();
|
|
918
|
+
selection?.removeAllRanges();
|
|
919
|
+
selection?.addRange(range);
|
|
920
|
+
return true;
|
|
921
|
+
}
|
|
922
|
+
return false;
|
|
923
|
+
});
|
|
924
|
+
if (!selected)
|
|
925
|
+
throw new Error('fill target is not an editable input, textarea or contenteditable element');
|
|
926
|
+
}
|
|
927
|
+
/** Throws ElementOccludedError naming the element on top unless the target itself or a composed descendant is hit. */
|
|
928
|
+
async assertUnoccluded(handle, point) {
|
|
929
|
+
const occluder = await handle.evaluate((element, x, y) => {
|
|
930
|
+
let hit = element.ownerDocument.elementFromPoint(x, y);
|
|
931
|
+
// The document reports a shadow host, not the element inside its open shadow root: descend.
|
|
932
|
+
while (hit?.shadowRoot) {
|
|
933
|
+
const inner = hit.shadowRoot.elementFromPoint(x, y);
|
|
934
|
+
if (!inner || inner === hit)
|
|
935
|
+
break;
|
|
936
|
+
hit = inner;
|
|
937
|
+
}
|
|
938
|
+
// Composed ancestry: a shadow root's host counts as its parent, so shadow content inside the target passes.
|
|
939
|
+
for (let node = hit; node; node = node.parentNode ?? node.host ?? null) {
|
|
940
|
+
if (node === element)
|
|
941
|
+
return null;
|
|
942
|
+
}
|
|
943
|
+
if (!hit)
|
|
944
|
+
return '(no element at the click point)';
|
|
945
|
+
const id = hit.id ? ` id="${hit.id}"` : '';
|
|
946
|
+
const className = typeof hit.className === 'string' ? hit.className.trim().slice(0, 80) : '';
|
|
947
|
+
return `<${hit.tagName.toLowerCase()}${id}${className ? ` class="${className}"` : ''}>`;
|
|
948
|
+
}, point.x, point.y);
|
|
949
|
+
if (occluder !== null)
|
|
950
|
+
throw new ElementOccludedError(occluder, point);
|
|
951
|
+
}
|
|
952
|
+
/** 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`);
|
|
958
|
+
let screenshot;
|
|
959
|
+
let screenshotError;
|
|
960
|
+
try {
|
|
961
|
+
await writeFile(png, await this.screenshot());
|
|
962
|
+
screenshot = png;
|
|
963
|
+
}
|
|
964
|
+
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)) ?? {};
|
|
970
|
+
}
|
|
971
|
+
catch { /* page gone or stuck: the dump records no url or title */ }
|
|
972
|
+
const dump = {
|
|
973
|
+
label,
|
|
974
|
+
predicate,
|
|
975
|
+
timeoutMs: timeout,
|
|
976
|
+
timestamp: new Date().toISOString(),
|
|
977
|
+
url: where.url ?? null,
|
|
978
|
+
title: where.title ?? null,
|
|
979
|
+
screenshot: screenshot ? path.basename(screenshot) : null,
|
|
980
|
+
screenshotError: screenshotError ?? null,
|
|
981
|
+
console: this.consoleEntries(),
|
|
982
|
+
};
|
|
983
|
+
await writeFile(json, JSON.stringify(dump, null, 2));
|
|
984
|
+
return screenshot ? { json, screenshot } : { json };
|
|
985
|
+
}
|
|
986
|
+
/** Attaches artifact paths to a timeout error. A write failure is appended to its message; the timeout still throws. */
|
|
987
|
+
/**
|
|
988
|
+
* Writes the failure artifacts for a timed-out wait or a failed onPoll hook. The paths are attached as
|
|
989
|
+
* `artifacts` only on a TimeoutError; for other errors the files are written and the paths are not attached.
|
|
990
|
+
* A write failure is appended to a TimeoutError's message; the original error still throws.
|
|
991
|
+
*/
|
|
992
|
+
async attachFailureArtifacts(error, target, predicate, timeout) {
|
|
993
|
+
try {
|
|
994
|
+
const written = await this.captureFailure(target.directory, target.label, predicate, timeout);
|
|
995
|
+
if (error instanceof TimeoutError)
|
|
996
|
+
error.artifacts = written;
|
|
997
|
+
}
|
|
998
|
+
catch (captureError) {
|
|
999
|
+
if (error instanceof TimeoutError)
|
|
1000
|
+
error.message += ` (failure artifacts were not written: ${captureError instanceof Error ? captureError.message : String(captureError)})`;
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
663
1003
|
async click(target, options = {}) {
|
|
664
1004
|
this.mouse.assertReleased();
|
|
665
1005
|
await this.withElement(target, async (handle) => {
|
|
666
|
-
const point = await this.point(handle), button = options.button ?? 'left', count = options.clickCount ?? 1;
|
|
1006
|
+
const point = await this.point(handle, scrollInline(options.scroll)), button = options.button ?? 'left', count = options.clickCount ?? 1;
|
|
667
1007
|
await this.send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...point });
|
|
668
1008
|
this.mouse.track(point.x, point.y);
|
|
1009
|
+
if (options.checkOccluded)
|
|
1010
|
+
await this.assertUnoccluded(handle, point);
|
|
669
1011
|
for (let clickCount = 1; clickCount <= count; clickCount++) {
|
|
670
1012
|
await this.send('Input.dispatchMouseEvent', { type: 'mousePressed', ...point, button, buttons: button === 'left' ? 1 : button === 'right' ? 2 : 4, clickCount });
|
|
671
1013
|
if (options.delay)
|
|
@@ -689,10 +1031,11 @@ export class QaPage {
|
|
|
689
1031
|
await sleep(options.delay);
|
|
690
1032
|
}
|
|
691
1033
|
}
|
|
692
|
-
async hover(target) {
|
|
1034
|
+
async hover(target, options = {}) {
|
|
693
1035
|
this.mouse.assertReleased();
|
|
1036
|
+
const inline = scrollInline(options.scroll);
|
|
694
1037
|
await this.withElement(target, async (handle) => {
|
|
695
|
-
const point = await this.point(handle);
|
|
1038
|
+
const point = await this.point(handle, inline);
|
|
696
1039
|
await this.send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...point });
|
|
697
1040
|
this.mouse.track(point.x, point.y);
|
|
698
1041
|
});
|
|
@@ -758,7 +1101,7 @@ export class QaElement {
|
|
|
758
1101
|
}
|
|
759
1102
|
click(options = {}) { return this.page.click(this, options); }
|
|
760
1103
|
type(text, options = {}) { return this.page.typeElement(this, text, options); }
|
|
761
|
-
hover() { return this.page.hover(this); }
|
|
1104
|
+
hover(options = {}) { return this.page.hover(this, options); }
|
|
762
1105
|
async focus() { await this.evaluate((element) => element.focus()); }
|
|
763
1106
|
boundingBox() {
|
|
764
1107
|
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;
|
|
@@ -18,5 +19,44 @@ export declare class ProcessTimeoutError extends Error {
|
|
|
18
19
|
constructor(pid: number, timeoutMs: number, stdout: string, stderr: string);
|
|
19
20
|
}
|
|
20
21
|
export declare function runOwnedProcess(command: string, args?: string[], options?: RunOwnedProcessOptions): Promise<OwnedProcessResult>;
|
|
22
|
+
/**
|
|
23
|
+
* A free TCP port on 127.0.0.1, found by binding port 0 and releasing it. Another process can take
|
|
24
|
+
* the port before the caller binds it; bind promptly or retry on EADDRINUSE.
|
|
25
|
+
*/
|
|
26
|
+
export declare function freePort(): Promise<number>;
|
|
21
27
|
export declare function isProcessAlive(pid: number): boolean;
|
|
22
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
|
@@ -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;
|
|
@@ -112,3 +133,82 @@ async function waitForExit(pid, timeoutMs) {
|
|
|
112
133
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
113
134
|
}
|
|
114
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.5",
|
|
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.4"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"@types/node": "^24.0.0",
|