@rightkit/qa 0.4.1 → 0.4.3
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 +82 -1
- package/dist/keyboard.d.ts +4 -1
- package/dist/keyboard.js +55 -11
- package/dist/mouse.d.ts +39 -0
- package/dist/mouse.js +82 -0
- package/dist/page.d.ts +53 -3
- package/dist/page.js +207 -9
- 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`, `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`. |
|
|
17
17
|
| `@rightkit/qa/chrome` | Re-export of `@rightkit/qa-chrome` |
|
|
18
18
|
|
|
19
19
|
Dependency: `@rightkit/qa-chrome` (exact `0.1.3`).
|
|
@@ -41,6 +41,87 @@ try {
|
|
|
41
41
|
|
|
42
42
|
The page driver uses Node's built-in `WebSocket`. It has no Puppeteer or Playwright dependency. Method names mirror Puppeteer's for the common calls (`goto`, `click`, `evaluate`, `waitForSelector`, `screenshot`).
|
|
43
43
|
|
|
44
|
+
## Attach to a running app and drive its mouse
|
|
45
|
+
|
|
46
|
+
`QaBrowserSession.attach` connects to an app that is already running with a debugging port, such as an installed WebView2 app started with `--remote-debugging-port`. It reads the browser socket from `http://<host>:<port>/json/version`. It creates no target, and nothing it attaches to is closed.
|
|
47
|
+
|
|
48
|
+
- `pages()` lists page targets (`targetId`, `url`, `title`).
|
|
49
|
+
- `attachPage(targetId)` or `attachPage(info => ...)` (the predicate must match exactly one page) returns a `QaPage` attached with `Target.attachToTarget`, flattened.
|
|
50
|
+
- `close()` on an attached page detaches it. Closing the session detaches every attached page. Neither sends `Target.closeTarget`.
|
|
51
|
+
- The host defaults to `127.0.0.1`. Any other host, and any browser socket on another host, is refused unless `allowRemoteHost: true`. A debugging port controls the whole app, so only allow a machine you control.
|
|
52
|
+
|
|
53
|
+
`page.mouse` holds the pointer position and pressed buttons. A move while a button is held sends the matching `buttons` mask. `page.click` and `page.hover` send their own events, so release any held mouse button before calling them.
|
|
54
|
+
|
|
55
|
+
Hover, pressed and after-click capture of a control in a running app:
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { QaBrowserSession } from "@rightkit/qa";
|
|
59
|
+
|
|
60
|
+
const settle = () => new Promise((resolve) => setTimeout(resolve, 120));
|
|
61
|
+
const browser = await QaBrowserSession.attach({ port: 9333 }); // host defaults to 127.0.0.1
|
|
62
|
+
try {
|
|
63
|
+
const [target] = await browser.pages();
|
|
64
|
+
if (!target) throw new Error("the app has no page target");
|
|
65
|
+
const page = await browser.attachPage(target.targetId);
|
|
66
|
+
const box = await (await page.$("#save"))?.boundingBox();
|
|
67
|
+
if (!box) throw new Error("#save is not visible");
|
|
68
|
+
const x = box.x + box.width / 2, y = box.y + box.height / 2;
|
|
69
|
+
|
|
70
|
+
await page.mouse.move(x, y, { steps: 4 }); // pointer rests on the control
|
|
71
|
+
await settle();
|
|
72
|
+
await page.screenshot({ path: "save-hover.png" });
|
|
73
|
+
|
|
74
|
+
await page.mouse.down(); // held press, not yet released
|
|
75
|
+
await settle();
|
|
76
|
+
await page.screenshot({ path: "save-pressed.png" });
|
|
77
|
+
|
|
78
|
+
await page.mouse.up(); // release on the control: this is the click
|
|
79
|
+
await settle();
|
|
80
|
+
await page.screenshot({ path: "save-after.png" });
|
|
81
|
+
await page.close(); // detaches; the app's page stays open
|
|
82
|
+
} finally {
|
|
83
|
+
await browser.close(); // detaches attached pages; never closes the app
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
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
|
+
|
|
89
|
+
## Keyboard keys
|
|
90
|
+
|
|
91
|
+
`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.
|
|
92
|
+
|
|
93
|
+
- 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.
|
|
94
|
+
- Chords join parts with `+`. A literal plus key goes last: `+` and `Control++`.
|
|
95
|
+
- `code` follows the US layout (`?` has code `Slash`). Other layouts are not modelled.
|
|
96
|
+
|
|
97
|
+
## Browser escape hatch (unsupported surface)
|
|
98
|
+
|
|
99
|
+
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.
|
|
100
|
+
|
|
101
|
+
- `browser.send(method, params?)` sends one browser-scoped command with no `sessionId` and resolves with its raw result.
|
|
102
|
+
- `browser.version()` returns `Browser.getVersion`.
|
|
103
|
+
- `browser.commandLine()` returns the browser argv from `Browser.getBrowserCommandLine` as `string[]`.
|
|
104
|
+
|
|
105
|
+
`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.
|
|
106
|
+
|
|
107
|
+
## Run root and workspace disposal
|
|
108
|
+
|
|
109
|
+
`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`.
|
|
110
|
+
|
|
111
|
+
## Ownership: this package versus the Rust `rightkit-qa` crate
|
|
112
|
+
|
|
113
|
+
Both runners stay. Pick by what the test drives.
|
|
114
|
+
|
|
115
|
+
| Surface | Owner | Evidence in code |
|
|
116
|
+
|---|---|---|
|
|
117
|
+
| Browser page (any web app in Chrome for Testing) | `@rightkit/qa` and `@rightkit/qa-chrome` | `src/page.ts` (`QaBrowserSession`, `QaPage`), `@rightkit/qa-chrome` `launchChromeForTesting` |
|
|
118
|
+
| Pane, frame and paint checks on a browser page | `@rightkit/qa` | `src/panes.ts`, `src/frames.ts`, `src/paint.ts` |
|
|
119
|
+
| macOS native app | Rust `rightkit-qa` (`control::launch`, in-app `rightkit-control` server) | `crates/rightkit-qa/src/control.rs` |
|
|
120
|
+
| Windows WebView2 (Tauri) app | Rust `rightkit-qa` (embedded control server on a named pipe) | `crates/rightkit-qa/src/control.rs`, `suite.rs` |
|
|
121
|
+
| Engine (CLI words, framed stdio sidecar) | Rust `rightkit-qa` (`engine::EngineTarget`) | `crates/rightkit-qa/src/engine.rs` |
|
|
122
|
+
|
|
123
|
+
This package launches no native app and no WebView2 window; it never replaces the Rust crate for them.
|
|
124
|
+
|
|
44
125
|
## Known issues
|
|
45
126
|
|
|
46
127
|
- `@rightkit/qa-chrome` is pinned exactly to `0.1.3`. A qa-chrome release therefore needs a `@rightkit/qa` republish to reach consumers (npm audit 2026-10-08, qa finding).
|
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
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { CdpParams } from './cdp.js';
|
|
2
|
+
export type MouseButton = 'left' | 'right' | 'middle';
|
|
3
|
+
export interface MouseMoveOptions {
|
|
4
|
+
steps?: number;
|
|
5
|
+
}
|
|
6
|
+
export interface MouseButtonOptions {
|
|
7
|
+
button?: MouseButton;
|
|
8
|
+
clickCount?: number;
|
|
9
|
+
}
|
|
10
|
+
export interface MousePosition {
|
|
11
|
+
x: number;
|
|
12
|
+
y: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Pointer state for one page: position and held buttons, so a move while a button is held
|
|
16
|
+
* sends the right `buttons` mask. Coordinates are CSS pixels of the viewport, as for `click`.
|
|
17
|
+
*/
|
|
18
|
+
export declare class QaMouse {
|
|
19
|
+
private send;
|
|
20
|
+
private x;
|
|
21
|
+
private y;
|
|
22
|
+
/** Held buttons in press order, with the clickCount each was pressed with. */
|
|
23
|
+
private held;
|
|
24
|
+
constructor(send: (method: string, params: CdpParams) => Promise<CdpParams>);
|
|
25
|
+
/** Last position this object sent or observed; starts at (0, 0). */
|
|
26
|
+
position(): MousePosition;
|
|
27
|
+
/** Move to (x, y), optionally in linear intermediate steps. Dispatches mouseMoved per step. */
|
|
28
|
+
move(x: number, y: number, options?: MouseMoveOptions): Promise<void>;
|
|
29
|
+
/** Press a button at the current position and keep it held until `up`. */
|
|
30
|
+
down(options?: MouseButtonOptions): Promise<void>;
|
|
31
|
+
/** Release a held button at the current position. */
|
|
32
|
+
up(options?: MouseButtonOptions): Promise<void>;
|
|
33
|
+
/** @internal Called by page.click & page.hover after they dispatch at a point. */
|
|
34
|
+
track(x: number, y: number): void;
|
|
35
|
+
/** @internal Page-level click & hover send their own events without a held mask, so refuse to run while held. */
|
|
36
|
+
assertReleased(): void;
|
|
37
|
+
private mask;
|
|
38
|
+
private pressedState;
|
|
39
|
+
}
|
package/dist/mouse.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/** CDP `buttons` bit per button. */
|
|
2
|
+
const buttonBits = { left: 1, right: 2, middle: 4 };
|
|
3
|
+
function checkButton(button) {
|
|
4
|
+
if (!(button in buttonBits))
|
|
5
|
+
throw new Error(`Unsupported button: ${button}`);
|
|
6
|
+
return button;
|
|
7
|
+
}
|
|
8
|
+
function checkCoordinates(x, y) {
|
|
9
|
+
if (!Number.isFinite(x) || !Number.isFinite(y))
|
|
10
|
+
throw new Error('Mouse coordinates must be finite numbers');
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Pointer state for one page: position and held buttons, so a move while a button is held
|
|
14
|
+
* sends the right `buttons` mask. Coordinates are CSS pixels of the viewport, as for `click`.
|
|
15
|
+
*/
|
|
16
|
+
export class QaMouse {
|
|
17
|
+
send;
|
|
18
|
+
x = 0;
|
|
19
|
+
y = 0;
|
|
20
|
+
/** Held buttons in press order, with the clickCount each was pressed with. */
|
|
21
|
+
held = new Map();
|
|
22
|
+
constructor(send) {
|
|
23
|
+
this.send = send;
|
|
24
|
+
}
|
|
25
|
+
/** Last position this object sent or observed; starts at (0, 0). */
|
|
26
|
+
position() { return { x: this.x, y: this.y }; }
|
|
27
|
+
/** Move to (x, y), optionally in linear intermediate steps. Dispatches mouseMoved per step. */
|
|
28
|
+
async move(x, y, options = {}) {
|
|
29
|
+
const steps = options.steps ?? 1;
|
|
30
|
+
if (!Number.isInteger(steps) || steps < 1)
|
|
31
|
+
throw new Error('Mouse move steps must be a positive integer');
|
|
32
|
+
checkCoordinates(x, y);
|
|
33
|
+
const [fromX, fromY] = [this.x, this.y];
|
|
34
|
+
const [button, buttons] = this.pressedState();
|
|
35
|
+
for (let step = 1; step <= steps; step++) {
|
|
36
|
+
const nextX = step === steps ? x : fromX + ((x - fromX) * step) / steps;
|
|
37
|
+
const nextY = step === steps ? y : fromY + ((y - fromY) * step) / steps;
|
|
38
|
+
await this.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: nextX, y: nextY, button, buttons });
|
|
39
|
+
this.x = nextX;
|
|
40
|
+
this.y = nextY;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Press a button at the current position and keep it held until `up`. */
|
|
44
|
+
async down(options = {}) {
|
|
45
|
+
const button = checkButton(options.button ?? 'left');
|
|
46
|
+
const clickCount = options.clickCount ?? 1;
|
|
47
|
+
if (!Number.isInteger(clickCount) || clickCount < 1)
|
|
48
|
+
throw new Error('Mouse clickCount must be a positive integer');
|
|
49
|
+
if (this.held.has(button))
|
|
50
|
+
throw new Error(`Mouse ${button} button already pressed`);
|
|
51
|
+
const buttons = this.mask() | buttonBits[button];
|
|
52
|
+
await this.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: this.x, y: this.y, button, buttons, clickCount });
|
|
53
|
+
this.held.set(button, clickCount);
|
|
54
|
+
}
|
|
55
|
+
/** Release a held button at the current position. */
|
|
56
|
+
async up(options = {}) {
|
|
57
|
+
const button = checkButton(options.button ?? 'left');
|
|
58
|
+
const clickCount = this.held.get(button);
|
|
59
|
+
if (clickCount === undefined)
|
|
60
|
+
throw new Error(`Mouse ${button} button not pressed`);
|
|
61
|
+
const buttons = this.mask() & ~buttonBits[button];
|
|
62
|
+
await this.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: this.x, y: this.y, button, buttons, clickCount });
|
|
63
|
+
this.held.delete(button);
|
|
64
|
+
}
|
|
65
|
+
/** @internal Called by page.click & page.hover after they dispatch at a point. */
|
|
66
|
+
track(x, y) { this.x = x; this.y = y; }
|
|
67
|
+
/** @internal Page-level click & hover send their own events without a held mask, so refuse to run while held. */
|
|
68
|
+
assertReleased() {
|
|
69
|
+
if (this.held.size)
|
|
70
|
+
throw new Error('Release page.mouse buttons before page click or hover');
|
|
71
|
+
}
|
|
72
|
+
mask() {
|
|
73
|
+
let buttons = 0;
|
|
74
|
+
for (const button of this.held.keys())
|
|
75
|
+
buttons |= buttonBits[button];
|
|
76
|
+
return buttons;
|
|
77
|
+
}
|
|
78
|
+
pressedState() {
|
|
79
|
+
const last = [...this.held.keys()].at(-1);
|
|
80
|
+
return [last ?? 'none', this.mask()];
|
|
81
|
+
}
|
|
82
|
+
}
|
package/dist/page.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { CdpClient, type CdpParams, type QaConnectOptions } from './cdp.js';
|
|
2
2
|
import { QaKeyboard } from './keyboard.js';
|
|
3
|
+
import { QaMouse } from './mouse.js';
|
|
3
4
|
export { TimeoutError, type QaConnectOptions } from './cdp.js';
|
|
4
5
|
export { QaKeyboard } from './keyboard.js';
|
|
6
|
+
export { QaMouse, type MouseButton, type MouseButtonOptions, type MouseMoveOptions, type MousePosition } from './mouse.js';
|
|
5
7
|
export interface NavigationOptions {
|
|
6
8
|
waitUntil?: 'load' | 'domcontentloaded' | 'networkidle0';
|
|
7
9
|
timeout?: number;
|
|
@@ -41,18 +43,63 @@ export interface BoundingBox {
|
|
|
41
43
|
width: number;
|
|
42
44
|
height: number;
|
|
43
45
|
}
|
|
46
|
+
/** One page target listed by the browser. */
|
|
47
|
+
export interface QaPageInfo {
|
|
48
|
+
targetId: string;
|
|
49
|
+
url: string;
|
|
50
|
+
title: string;
|
|
51
|
+
}
|
|
52
|
+
export interface QaAttachOptions extends QaConnectOptions {
|
|
53
|
+
/** Debugging port on `host`; the browser socket is read from http://<host>:<port>/json/version. Give this or httpEndpoint. */
|
|
54
|
+
port?: number;
|
|
55
|
+
/** Full http or https origin of the debugging endpoint, e.g. http://127.0.0.1:9333. Give this or port. */
|
|
56
|
+
httpEndpoint?: string;
|
|
57
|
+
/** Default 127.0.0.1. */
|
|
58
|
+
host?: string;
|
|
59
|
+
/** Default false. A debugging port is full control of the app; allow a non-loopback host only for a machine you control. */
|
|
60
|
+
allowRemoteHost?: boolean;
|
|
61
|
+
}
|
|
44
62
|
type PageFunction<Args extends unknown[], Result> = (...args: Args) => Result;
|
|
45
63
|
/** Owns only targets it creates; launcher owns browser process & profile cleanup. */
|
|
46
64
|
export declare class QaBrowserSession {
|
|
47
65
|
private client;
|
|
48
|
-
private
|
|
66
|
+
private openPages;
|
|
49
67
|
private closed;
|
|
50
68
|
private closing?;
|
|
51
69
|
private opening;
|
|
52
70
|
private constructor();
|
|
53
71
|
static connect(wsEndpoint: string, options?: QaConnectOptions): Promise<QaBrowserSession>;
|
|
72
|
+
/**
|
|
73
|
+
* Attach to a browser that is already running with a debugging port (for example an installed
|
|
74
|
+
* WebView2 app). Reads the browser socket from /json/version. Never creates or closes targets:
|
|
75
|
+
* use pages() and attachPage() to reach the app's own pages.
|
|
76
|
+
*/
|
|
77
|
+
static attach(options?: QaAttachOptions): Promise<QaBrowserSession>;
|
|
78
|
+
/** Page targets (type "page") the browser lists now. Read only. */
|
|
79
|
+
pages(): Promise<QaPageInfo[]>;
|
|
80
|
+
/**
|
|
81
|
+
* Attach to an existing page by target id, or by a predicate that must match exactly one page.
|
|
82
|
+
* The returned QaPage detaches on close; it never closes the app's target.
|
|
83
|
+
*/
|
|
84
|
+
attachPage(target: string | ((info: QaPageInfo) => boolean)): Promise<QaPage>;
|
|
85
|
+
private attachExisting;
|
|
54
86
|
newPage(): Promise<QaPage>;
|
|
55
87
|
private createPage;
|
|
88
|
+
/**
|
|
89
|
+
* UNSUPPORTED escape hatch: sends one browser-scoped CDP command (no sessionId) and resolves
|
|
90
|
+
* with its raw result. The protocol is Chrome's, not this package's: method names, params and
|
|
91
|
+
* results may change or disappear with a browser version. Prefer the typed QaPage API.
|
|
92
|
+
*/
|
|
93
|
+
send(method: string, params?: CdpParams): Promise<CdpParams>;
|
|
94
|
+
/** Browser.getVersion (unsupported surface, see send). Read only. */
|
|
95
|
+
version(): Promise<CdpParams>;
|
|
96
|
+
/**
|
|
97
|
+
* Browser argv via Browser.getBrowserCommandLine (unsupported surface, see send). The browser
|
|
98
|
+
* answers only when it was launched with --enable-automation; launchChromeForTesting passes no
|
|
99
|
+
* such flag by default, so pass { args: ['--enable-automation'] } to use this. A refusal rejects
|
|
100
|
+
* with an error that names the requirement; the browser is not changed.
|
|
101
|
+
*/
|
|
102
|
+
commandLine(): Promise<string[]>;
|
|
56
103
|
close(): Promise<void>;
|
|
57
104
|
}
|
|
58
105
|
export declare class QaPage {
|
|
@@ -60,7 +107,9 @@ export declare class QaPage {
|
|
|
60
107
|
readonly targetId: string;
|
|
61
108
|
private sessionId;
|
|
62
109
|
private onClosed;
|
|
110
|
+
private owned;
|
|
63
111
|
readonly keyboard: QaKeyboard;
|
|
112
|
+
readonly mouse: QaMouse;
|
|
64
113
|
private frameId?;
|
|
65
114
|
private contexts;
|
|
66
115
|
private contextId?;
|
|
@@ -76,8 +125,8 @@ export declare class QaPage {
|
|
|
76
125
|
private generation;
|
|
77
126
|
private navigating;
|
|
78
127
|
private get objectGroup();
|
|
79
|
-
/** @internal Use QaBrowserSession.newPage(). */
|
|
80
|
-
constructor(client: CdpClient, targetId: string, sessionId: string, onClosed: () => void);
|
|
128
|
+
/** @internal Use QaBrowserSession.newPage() or attachPage(). `owned` pages are closed on close(); attached ones only detach. */
|
|
129
|
+
constructor(client: CdpClient, targetId: string, sessionId: string, onClosed: () => void, owned?: boolean);
|
|
81
130
|
private chooseContext;
|
|
82
131
|
/** Subscribe to this page's raw CDP events; returned function unsubscribes. */
|
|
83
132
|
on(method: string, listener: (params: CdpParams) => void): () => void;
|
|
@@ -88,6 +137,7 @@ export declare class QaPage {
|
|
|
88
137
|
private invalidateHandles;
|
|
89
138
|
/** @internal */
|
|
90
139
|
markClosed(error?: Error): void;
|
|
140
|
+
/** Owned pages (newPage) close their target. Attached pages (attachPage) only detach; the app's page stays open. */
|
|
91
141
|
close(): Promise<void>;
|
|
92
142
|
private bounded;
|
|
93
143
|
private mainContext;
|
package/dist/page.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { writeFile } from 'node:fs/promises';
|
|
2
2
|
import { CdpClient, TimeoutError, validateTimeout } from './cdp.js';
|
|
3
3
|
import { QaKeyboard } from './keyboard.js';
|
|
4
|
+
import { QaMouse } from './mouse.js';
|
|
4
5
|
export { TimeoutError } from './cdp.js';
|
|
5
6
|
export { QaKeyboard } from './keyboard.js';
|
|
7
|
+
export { QaMouse } from './mouse.js';
|
|
6
8
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
7
9
|
function functionSource(fn) {
|
|
8
10
|
const source = fn.toString();
|
|
@@ -34,10 +36,71 @@ function resultValue(result) {
|
|
|
34
36
|
}
|
|
35
37
|
return result.result?.value;
|
|
36
38
|
}
|
|
39
|
+
const LOOPBACK_NAMES = new Set(['localhost', '::1']);
|
|
40
|
+
/** Bare host name (IPv6 without brackets) for a URL hostname or a host option. */
|
|
41
|
+
function bareHost(host) { return host.replace(/^\[(.*)\]$/, '$1'); }
|
|
42
|
+
function isLoopback(host) {
|
|
43
|
+
const bare = bareHost(host);
|
|
44
|
+
return LOOPBACK_NAMES.has(bare) || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(bare);
|
|
45
|
+
}
|
|
46
|
+
function assertDebugHost(host, allowRemoteHost, what) {
|
|
47
|
+
if (allowRemoteHost || isLoopback(host))
|
|
48
|
+
return;
|
|
49
|
+
throw new Error(`Refusing non-loopback ${what} ${host}: a debugging port controls the whole app. Pass allowRemoteHost: true only for a machine you control.`);
|
|
50
|
+
}
|
|
51
|
+
function debugOrigin(options) {
|
|
52
|
+
const given = [options.port !== undefined, options.httpEndpoint !== undefined].filter(Boolean).length;
|
|
53
|
+
if (given !== 1)
|
|
54
|
+
throw new Error('Pass exactly one of port or httpEndpoint');
|
|
55
|
+
if (options.httpEndpoint !== undefined) {
|
|
56
|
+
let url;
|
|
57
|
+
try {
|
|
58
|
+
url = new URL(options.httpEndpoint);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
throw new Error('httpEndpoint must be an http or https URL');
|
|
62
|
+
}
|
|
63
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
64
|
+
throw new Error('httpEndpoint must be an http or https URL');
|
|
65
|
+
return url;
|
|
66
|
+
}
|
|
67
|
+
const port = options.port;
|
|
68
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
69
|
+
throw new Error('Port must be an integer from 1 to 65535');
|
|
70
|
+
const host = options.host ?? '127.0.0.1';
|
|
71
|
+
const authority = host.includes(':') ? `[${bareHost(host)}]` : host;
|
|
72
|
+
try {
|
|
73
|
+
return new URL(`http://${authority}:${port}`);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
throw new Error('Debugging host is not a valid host name');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
async function readVersion(origin, timeout) {
|
|
80
|
+
const url = new URL('/json/version', origin);
|
|
81
|
+
let response;
|
|
82
|
+
try {
|
|
83
|
+
// Redirects are refused so the request cannot be bounced to another host.
|
|
84
|
+
response = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(timeout) });
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError'))
|
|
88
|
+
throw new TimeoutError(`Debugging endpoint ${origin.origin} timed out after ${timeout}ms`);
|
|
89
|
+
throw new Error(`Could not reach debugging endpoint ${origin.origin}`);
|
|
90
|
+
}
|
|
91
|
+
if (!response.ok)
|
|
92
|
+
throw new Error(`Debugging endpoint ${url.pathname} answered ${response.status}`);
|
|
93
|
+
try {
|
|
94
|
+
return await response.json();
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
throw new Error(`Debugging endpoint ${url.pathname} did not return JSON`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
37
100
|
/** Owns only targets it creates; launcher owns browser process & profile cleanup. */
|
|
38
101
|
export class QaBrowserSession {
|
|
39
102
|
client;
|
|
40
|
-
|
|
103
|
+
openPages = new Set();
|
|
41
104
|
closed = false;
|
|
42
105
|
closing;
|
|
43
106
|
opening = new Set();
|
|
@@ -47,6 +110,80 @@ export class QaBrowserSession {
|
|
|
47
110
|
static async connect(wsEndpoint, options = {}) {
|
|
48
111
|
return new QaBrowserSession(await CdpClient.connect(wsEndpoint, options));
|
|
49
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* Attach to a browser that is already running with a debugging port (for example an installed
|
|
115
|
+
* WebView2 app). Reads the browser socket from /json/version. Never creates or closes targets:
|
|
116
|
+
* use pages() and attachPage() to reach the app's own pages.
|
|
117
|
+
*/
|
|
118
|
+
static async attach(options = {}) {
|
|
119
|
+
const timeout = validateTimeout(options.timeout ?? 30_000);
|
|
120
|
+
const origin = debugOrigin(options);
|
|
121
|
+
assertDebugHost(origin.hostname, options.allowRemoteHost, 'debugging host');
|
|
122
|
+
const version = await readVersion(origin, timeout);
|
|
123
|
+
const socketUrl = version.webSocketDebuggerUrl;
|
|
124
|
+
if (typeof socketUrl !== 'string' || !socketUrl)
|
|
125
|
+
throw new Error('/json/version has no webSocketDebuggerUrl');
|
|
126
|
+
let socket;
|
|
127
|
+
try {
|
|
128
|
+
socket = new URL(socketUrl);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
throw new Error('webSocketDebuggerUrl is not a valid URL');
|
|
132
|
+
}
|
|
133
|
+
if (socket.protocol !== 'ws:' && socket.protocol !== 'wss:')
|
|
134
|
+
throw new Error('webSocketDebuggerUrl must be a ws or wss URL');
|
|
135
|
+
assertDebugHost(socket.hostname, options.allowRemoteHost, 'browser socket host');
|
|
136
|
+
return new QaBrowserSession(await CdpClient.connect(socketUrl, { timeout, debug: options.debug }));
|
|
137
|
+
}
|
|
138
|
+
/** Page targets (type "page") the browser lists now. Read only. */
|
|
139
|
+
async pages() {
|
|
140
|
+
if (this.closed)
|
|
141
|
+
throw new Error('Browser session closed');
|
|
142
|
+
const { targetInfos } = await this.client.send('Target.getTargets');
|
|
143
|
+
return targetInfos
|
|
144
|
+
.filter((info) => info.type === 'page')
|
|
145
|
+
.map((info) => ({ targetId: info.targetId, url: info.url, title: info.title }));
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Attach to an existing page by target id, or by a predicate that must match exactly one page.
|
|
149
|
+
* The returned QaPage detaches on close; it never closes the app's target.
|
|
150
|
+
*/
|
|
151
|
+
attachPage(target) {
|
|
152
|
+
if (this.closed)
|
|
153
|
+
return Promise.reject(new Error('Browser session closed'));
|
|
154
|
+
const pending = this.attachExisting(target);
|
|
155
|
+
this.opening.add(pending);
|
|
156
|
+
void pending.then(() => this.opening.delete(pending), () => this.opening.delete(pending));
|
|
157
|
+
return pending;
|
|
158
|
+
}
|
|
159
|
+
async attachExisting(target) {
|
|
160
|
+
const pages = await this.pages();
|
|
161
|
+
let info;
|
|
162
|
+
if (typeof target === 'string') {
|
|
163
|
+
info = pages.find((page) => page.targetId === target);
|
|
164
|
+
if (!info)
|
|
165
|
+
throw new Error(`No page target with id ${target}`);
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
const matches = pages.filter(target);
|
|
169
|
+
if (matches.length !== 1)
|
|
170
|
+
throw new Error(`Predicate matched ${matches.length} pages; it must match exactly one`);
|
|
171
|
+
info = matches[0];
|
|
172
|
+
}
|
|
173
|
+
const { sessionId } = await this.client.send('Target.attachToTarget', { targetId: info.targetId, flatten: true });
|
|
174
|
+
const page = new QaPage(this.client, info.targetId, sessionId, () => this.openPages.delete(page), false);
|
|
175
|
+
this.openPages.add(page);
|
|
176
|
+
try {
|
|
177
|
+
await page.initialize();
|
|
178
|
+
if (this.closed)
|
|
179
|
+
throw new Error('Browser session closed');
|
|
180
|
+
return page;
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
await page.close().catch(() => { }); // detach only
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
50
187
|
newPage() {
|
|
51
188
|
if (this.closed)
|
|
52
189
|
return Promise.reject(new Error('Browser session closed'));
|
|
@@ -59,8 +196,8 @@ export class QaBrowserSession {
|
|
|
59
196
|
const { targetId } = await this.client.send('Target.createTarget', { url: 'about:blank' });
|
|
60
197
|
try {
|
|
61
198
|
const { sessionId } = await this.client.send('Target.attachToTarget', { targetId, flatten: true });
|
|
62
|
-
const page = new QaPage(this.client, targetId, sessionId, () => this.
|
|
63
|
-
this.
|
|
199
|
+
const page = new QaPage(this.client, targetId, sessionId, () => this.openPages.delete(page));
|
|
200
|
+
this.openPages.add(page);
|
|
64
201
|
await page.initialize();
|
|
65
202
|
if (this.closed)
|
|
66
203
|
throw new Error('Browser session closed');
|
|
@@ -68,12 +205,51 @@ export class QaBrowserSession {
|
|
|
68
205
|
}
|
|
69
206
|
catch (error) {
|
|
70
207
|
await this.client.send('Target.closeTarget', { targetId }).catch(() => { });
|
|
71
|
-
for (const page of this.
|
|
208
|
+
for (const page of this.openPages)
|
|
72
209
|
if (page.targetId === targetId)
|
|
73
210
|
page.markClosed();
|
|
74
211
|
throw error;
|
|
75
212
|
}
|
|
76
213
|
}
|
|
214
|
+
/**
|
|
215
|
+
* UNSUPPORTED escape hatch: sends one browser-scoped CDP command (no sessionId) and resolves
|
|
216
|
+
* with its raw result. The protocol is Chrome's, not this package's: method names, params and
|
|
217
|
+
* results may change or disappear with a browser version. Prefer the typed QaPage API.
|
|
218
|
+
*/
|
|
219
|
+
send(method, params = {}) {
|
|
220
|
+
if (this.closed)
|
|
221
|
+
return Promise.reject(new Error('Browser session closed'));
|
|
222
|
+
if (typeof method !== 'string' || !method)
|
|
223
|
+
return Promise.reject(new Error('CDP method must be a non-empty string'));
|
|
224
|
+
return this.client.send(method, params);
|
|
225
|
+
}
|
|
226
|
+
/** Browser.getVersion (unsupported surface, see send). Read only. */
|
|
227
|
+
version() {
|
|
228
|
+
return this.send('Browser.getVersion');
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Browser argv via Browser.getBrowserCommandLine (unsupported surface, see send). The browser
|
|
232
|
+
* answers only when it was launched with --enable-automation; launchChromeForTesting passes no
|
|
233
|
+
* such flag by default, so pass { args: ['--enable-automation'] } to use this. A refusal rejects
|
|
234
|
+
* with an error that names the requirement; the browser is not changed.
|
|
235
|
+
*/
|
|
236
|
+
async commandLine() {
|
|
237
|
+
let reply;
|
|
238
|
+
try {
|
|
239
|
+
reply = await this.send('Browser.getBrowserCommandLine');
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
// CdpClient prefixes errors the browser itself returned with "CDP: "; timeouts and closed sockets pass through.
|
|
243
|
+
if (error instanceof Error && error.message.startsWith('CDP: ')) {
|
|
244
|
+
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 });
|
|
245
|
+
}
|
|
246
|
+
throw error;
|
|
247
|
+
}
|
|
248
|
+
const argv = reply.arguments;
|
|
249
|
+
if (!Array.isArray(argv) || !argv.every((arg) => typeof arg === 'string'))
|
|
250
|
+
throw new Error('Browser.getBrowserCommandLine did not return an argument list');
|
|
251
|
+
return argv;
|
|
252
|
+
}
|
|
77
253
|
close() {
|
|
78
254
|
if (this.closing)
|
|
79
255
|
return this.closing;
|
|
@@ -81,7 +257,7 @@ export class QaBrowserSession {
|
|
|
81
257
|
this.closing = (async () => {
|
|
82
258
|
try {
|
|
83
259
|
await Promise.allSettled([...this.opening]);
|
|
84
|
-
const results = await Promise.allSettled([...this.
|
|
260
|
+
const results = await Promise.allSettled([...this.openPages].map((page) => page.close()));
|
|
85
261
|
const failed = results.find((result) => result.status === 'rejected');
|
|
86
262
|
if (failed?.status === 'rejected')
|
|
87
263
|
throw failed.reason;
|
|
@@ -98,7 +274,9 @@ export class QaPage {
|
|
|
98
274
|
targetId;
|
|
99
275
|
sessionId;
|
|
100
276
|
onClosed;
|
|
277
|
+
owned;
|
|
101
278
|
keyboard;
|
|
279
|
+
mouse;
|
|
102
280
|
frameId;
|
|
103
281
|
contexts = new Map();
|
|
104
282
|
contextId;
|
|
@@ -114,13 +292,15 @@ export class QaPage {
|
|
|
114
292
|
generation = 0;
|
|
115
293
|
navigating = false;
|
|
116
294
|
get objectGroup() { return `qa-elements-${this.generation}`; }
|
|
117
|
-
/** @internal Use QaBrowserSession.newPage(). */
|
|
118
|
-
constructor(client, targetId, sessionId, onClosed) {
|
|
295
|
+
/** @internal Use QaBrowserSession.newPage() or attachPage(). `owned` pages are closed on close(); attached ones only detach. */
|
|
296
|
+
constructor(client, targetId, sessionId, onClosed, owned = true) {
|
|
119
297
|
this.client = client;
|
|
120
298
|
this.targetId = targetId;
|
|
121
299
|
this.sessionId = sessionId;
|
|
122
300
|
this.onClosed = onClosed;
|
|
301
|
+
this.owned = owned;
|
|
123
302
|
this.keyboard = new QaKeyboard((method, params) => this.send(method, params));
|
|
303
|
+
this.mouse = new QaMouse((method, params) => this.send(method, params));
|
|
124
304
|
this.unsubs.push(this.on('Runtime.executionContextCreated', ({ context }) => { this.contexts.set(context.id, context); this.chooseContext(); }), this.on('Runtime.executionContextDestroyed', ({ executionContextId }) => {
|
|
125
305
|
this.contexts.delete(executionContextId);
|
|
126
306
|
if (executionContextId === this.contextId) {
|
|
@@ -212,15 +392,26 @@ export class QaPage {
|
|
|
212
392
|
this.closedListeners.clear();
|
|
213
393
|
this.onClosed();
|
|
214
394
|
}
|
|
395
|
+
/** Owned pages (newPage) close their target. Attached pages (attachPage) only detach; the app's page stays open. */
|
|
215
396
|
close() {
|
|
216
397
|
if (this.closing)
|
|
217
398
|
return this.closing;
|
|
218
399
|
if (this.closed)
|
|
219
400
|
return Promise.resolve();
|
|
220
401
|
this.closing = (async () => {
|
|
402
|
+
if (this.owned) {
|
|
403
|
+
try {
|
|
404
|
+
await this.client.send('Target.closeTarget', { targetId: this.targetId });
|
|
405
|
+
}
|
|
406
|
+
finally {
|
|
407
|
+
this.markClosed();
|
|
408
|
+
}
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
221
411
|
try {
|
|
222
|
-
await this.client.send('Target.
|
|
412
|
+
await this.client.send('Target.detachFromTarget', { sessionId: this.sessionId });
|
|
223
413
|
}
|
|
414
|
+
catch { /* already detached: the local page is closed either way */ }
|
|
224
415
|
finally {
|
|
225
416
|
this.markClosed();
|
|
226
417
|
}
|
|
@@ -470,9 +661,11 @@ export class QaPage {
|
|
|
470
661
|
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
|
471
662
|
}
|
|
472
663
|
async click(target, options = {}) {
|
|
664
|
+
this.mouse.assertReleased();
|
|
473
665
|
await this.withElement(target, async (handle) => {
|
|
474
666
|
const point = await this.point(handle), button = options.button ?? 'left', count = options.clickCount ?? 1;
|
|
475
667
|
await this.send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...point });
|
|
668
|
+
this.mouse.track(point.x, point.y);
|
|
476
669
|
for (let clickCount = 1; clickCount <= count; clickCount++) {
|
|
477
670
|
await this.send('Input.dispatchMouseEvent', { type: 'mousePressed', ...point, button, buttons: button === 'left' ? 1 : button === 'right' ? 2 : 4, clickCount });
|
|
478
671
|
if (options.delay)
|
|
@@ -497,7 +690,12 @@ export class QaPage {
|
|
|
497
690
|
}
|
|
498
691
|
}
|
|
499
692
|
async hover(target) {
|
|
500
|
-
|
|
693
|
+
this.mouse.assertReleased();
|
|
694
|
+
await this.withElement(target, async (handle) => {
|
|
695
|
+
const point = await this.point(handle);
|
|
696
|
+
await this.send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...point });
|
|
697
|
+
this.mouse.track(point.x, point.y);
|
|
698
|
+
});
|
|
501
699
|
}
|
|
502
700
|
async focus(target) { await this.withElement(target, (handle) => handle.focus()); }
|
|
503
701
|
async select(selector, ...values) {
|
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