@rightkit/qa 0.4.0 → 0.4.2
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 +81 -48
- package/dist/mouse.d.ts +39 -0
- package/dist/mouse.js +82 -0
- package/dist/page.d.ts +38 -3
- package/dist/page.js +168 -9
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -1,73 +1,106 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @rightkit/qa
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Shared browser and QA primitives for Right Suite apps: run storage and evidence bundles, bearer-authenticated loopback control, owned-process cleanup, pane and frame checks, paint checks, workspace environment, a raw Chrome DevTools Protocol page driver, and the Chrome for Testing re-export.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
Run roots, harness evidence, captures & summaries remain under `<managed>/runs/<app>/<run-id>`. App homes, profiles, service/port files, live logs & app-provided output paths use an owned internal system-temp companion recorded as `appRoot` in `.rightkit-run.json`. On macOS, companions always use `/private/tmp/rightkit-qa/<app-prefix>-<random>` with short names so launchd can open app files & control socket paths fit `sockaddr_un`.
|
|
12
|
-
|
|
13
|
-
Finish copies app logs to managed `evidence/`, app-written evidence to `evidence/app/` & app captures to `captures/` before releasing ownership. Disposal removes temp companions; failed copies or cleanup preserve ownership metadata for recovery. Retention & orphan pruning validate companion bindings & protect live runs.
|
|
14
|
-
|
|
15
|
-
Workspace env sets `RIGHTKIT_RUN_ROOT`, `HOME` & `USERPROFILE` to `home`; `RIGHTKIT_SUITE_ROOT` to `home/suite`; data/profile keys to `home/data`; `XDG_CACHE_HOME` to `home/cache`; `TMPDIR`, `TMP` & `TEMP` to `home/tmp`. `RIGHTKIT_QA_EVIDENCE_DIR` & `RIGHTKIT_QA_CAPTURE_DIR` use `home/evidence` & `home/captures`. The JS package supplies workspace env; native UI bundle staging & unique per-launch suite/service identity belong to Rust `control::launch`.
|
|
5
|
+
| Item | Value |
|
|
6
|
+
|---|---|
|
|
7
|
+
| Where it runs | Dev and test tooling (Node, and browser-driven test harnesses) |
|
|
8
|
+
| Rust pair | None declared. For native desktop QA use the Rust `rightkit-qa` crate with `rightkit-control`. |
|
|
9
|
+
| Version | see INDEX.md |
|
|
10
|
+
| License | MIT OR Apache-2.0 |
|
|
16
11
|
|
|
17
|
-
##
|
|
12
|
+
## Entry points
|
|
18
13
|
|
|
19
|
-
|
|
14
|
+
| Import | Contents |
|
|
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`. |
|
|
17
|
+
| `@rightkit/qa/chrome` | Re-export of `@rightkit/qa-chrome` |
|
|
20
18
|
|
|
21
|
-
|
|
22
|
-
- `launchChromeForTesting(options)` starts it in its own process group with a temp profile and returns `{ wsEndpoint, port, pid, profileDir, close() }`. Launch one browser per suite and open targets per test.
|
|
23
|
-
- `close()` / `closeChromeGracefully()` sends CDP `Browser.close`, waits up to 5 s, sends SIGTERM to the process group (`taskkill /T` on Windows), waits again, sends SIGKILL only last, then removes the profile. It reports the step that ended the tree.
|
|
24
|
-
- `codeSignCloneEntries()` lists the macOS clone entries so tests can assert that none appear.
|
|
19
|
+
Dependency: `@rightkit/qa-chrome` (exact `0.1.3`).
|
|
25
20
|
|
|
26
|
-
|
|
21
|
+
Peer dependencies: none.
|
|
27
22
|
|
|
28
|
-
|
|
23
|
+
## Example
|
|
29
24
|
|
|
30
25
|
```ts
|
|
31
|
-
import { launchChromeForTesting, QaBrowserSession } from
|
|
26
|
+
import { launchChromeForTesting, QaBrowserSession } from "@rightkit/qa";
|
|
32
27
|
|
|
33
28
|
const chrome = await launchChromeForTesting();
|
|
34
29
|
const browser = await QaBrowserSession.connect(chrome.wsEndpoint);
|
|
35
30
|
try {
|
|
36
31
|
const page = await browser.newPage();
|
|
37
32
|
await page.setViewport({ width: 1280, height: 720, deviceScaleFactor: 1 });
|
|
38
|
-
await page.
|
|
39
|
-
await page.
|
|
40
|
-
const button = await page.waitForSelector('button', { visible: true });
|
|
33
|
+
await page.goto("http://127.0.0.1:3000", { waitUntil: "networkidle0", timeout: 10_000 });
|
|
34
|
+
const button = await page.waitForSelector("button", { visible: true });
|
|
41
35
|
await button?.click();
|
|
42
|
-
await
|
|
43
|
-
await page.screenshot({ path: 'viewport.png', type: 'png', captureBeyondViewport: false });
|
|
36
|
+
await page.screenshot({ path: "viewport.png" });
|
|
44
37
|
} finally {
|
|
45
38
|
try { await browser.close(); } finally { await chrome.close(); }
|
|
46
39
|
}
|
|
47
40
|
```
|
|
48
41
|
|
|
49
|
-
|
|
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`).
|
|
50
43
|
|
|
51
|
-
|
|
44
|
+
## Attach to a running app and drive its mouse
|
|
52
45
|
|
|
53
|
-
`
|
|
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.
|
|
54
47
|
|
|
55
|
-
|
|
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.
|
|
56
52
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
+
## Ownership: this package versus the Rust `rightkit-qa` crate
|
|
90
|
+
|
|
91
|
+
Both runners stay. Pick by what the test drives.
|
|
92
|
+
|
|
93
|
+
| Surface | Owner | Evidence in code |
|
|
94
|
+
|---|---|---|
|
|
95
|
+
| Browser page (any web app in Chrome for Testing) | `@rightkit/qa` and `@rightkit/qa-chrome` | `src/page.ts` (`QaBrowserSession`, `QaPage`), `@rightkit/qa-chrome` `launchChromeForTesting` |
|
|
96
|
+
| Pane, frame and paint checks on a browser page | `@rightkit/qa` | `src/panes.ts`, `src/frames.ts`, `src/paint.ts` |
|
|
97
|
+
| macOS native app | Rust `rightkit-qa` (`control::launch`, in-app `rightkit-control` server) | `crates/rightkit-qa/src/control.rs` |
|
|
98
|
+
| Windows WebView2 (Tauri) app | Rust `rightkit-qa` (embedded control server on a named pipe) | `crates/rightkit-qa/src/control.rs`, `suite.rs` |
|
|
99
|
+
| Engine (CLI words, framed stdio sidecar) | Rust `rightkit-qa` (`engine::EngineTarget`) | `crates/rightkit-qa/src/engine.rs` |
|
|
100
|
+
|
|
101
|
+
This package launches no native app and no WebView2 window; it never replaces the Rust crate for them.
|
|
102
|
+
|
|
103
|
+
## Known issues
|
|
104
|
+
|
|
105
|
+
- `@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).
|
|
106
|
+
- Three consumers pin three different versions of `@rightkit/qa-chrome` as dev dependencies (npm audit 2026-10-08).
|
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,16 +43,46 @@ 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;
|
|
56
88
|
close(): Promise<void>;
|
|
@@ -60,7 +92,9 @@ export declare class QaPage {
|
|
|
60
92
|
readonly targetId: string;
|
|
61
93
|
private sessionId;
|
|
62
94
|
private onClosed;
|
|
95
|
+
private owned;
|
|
63
96
|
readonly keyboard: QaKeyboard;
|
|
97
|
+
readonly mouse: QaMouse;
|
|
64
98
|
private frameId?;
|
|
65
99
|
private contexts;
|
|
66
100
|
private contextId?;
|
|
@@ -76,8 +110,8 @@ export declare class QaPage {
|
|
|
76
110
|
private generation;
|
|
77
111
|
private navigating;
|
|
78
112
|
private get objectGroup();
|
|
79
|
-
/** @internal Use QaBrowserSession.newPage(). */
|
|
80
|
-
constructor(client: CdpClient, targetId: string, sessionId: string, onClosed: () => void);
|
|
113
|
+
/** @internal Use QaBrowserSession.newPage() or attachPage(). `owned` pages are closed on close(); attached ones only detach. */
|
|
114
|
+
constructor(client: CdpClient, targetId: string, sessionId: string, onClosed: () => void, owned?: boolean);
|
|
81
115
|
private chooseContext;
|
|
82
116
|
/** Subscribe to this page's raw CDP events; returned function unsubscribes. */
|
|
83
117
|
on(method: string, listener: (params: CdpParams) => void): () => void;
|
|
@@ -88,6 +122,7 @@ export declare class QaPage {
|
|
|
88
122
|
private invalidateHandles;
|
|
89
123
|
/** @internal */
|
|
90
124
|
markClosed(error?: Error): void;
|
|
125
|
+
/** Owned pages (newPage) close their target. Attached pages (attachPage) only detach; the app's page stays open. */
|
|
91
126
|
close(): Promise<void>;
|
|
92
127
|
private bounded;
|
|
93
128
|
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,7 +205,7 @@ 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;
|
|
@@ -81,7 +218,7 @@ export class QaBrowserSession {
|
|
|
81
218
|
this.closing = (async () => {
|
|
82
219
|
try {
|
|
83
220
|
await Promise.allSettled([...this.opening]);
|
|
84
|
-
const results = await Promise.allSettled([...this.
|
|
221
|
+
const results = await Promise.allSettled([...this.openPages].map((page) => page.close()));
|
|
85
222
|
const failed = results.find((result) => result.status === 'rejected');
|
|
86
223
|
if (failed?.status === 'rejected')
|
|
87
224
|
throw failed.reason;
|
|
@@ -98,7 +235,9 @@ export class QaPage {
|
|
|
98
235
|
targetId;
|
|
99
236
|
sessionId;
|
|
100
237
|
onClosed;
|
|
238
|
+
owned;
|
|
101
239
|
keyboard;
|
|
240
|
+
mouse;
|
|
102
241
|
frameId;
|
|
103
242
|
contexts = new Map();
|
|
104
243
|
contextId;
|
|
@@ -114,13 +253,15 @@ export class QaPage {
|
|
|
114
253
|
generation = 0;
|
|
115
254
|
navigating = false;
|
|
116
255
|
get objectGroup() { return `qa-elements-${this.generation}`; }
|
|
117
|
-
/** @internal Use QaBrowserSession.newPage(). */
|
|
118
|
-
constructor(client, targetId, sessionId, onClosed) {
|
|
256
|
+
/** @internal Use QaBrowserSession.newPage() or attachPage(). `owned` pages are closed on close(); attached ones only detach. */
|
|
257
|
+
constructor(client, targetId, sessionId, onClosed, owned = true) {
|
|
119
258
|
this.client = client;
|
|
120
259
|
this.targetId = targetId;
|
|
121
260
|
this.sessionId = sessionId;
|
|
122
261
|
this.onClosed = onClosed;
|
|
262
|
+
this.owned = owned;
|
|
123
263
|
this.keyboard = new QaKeyboard((method, params) => this.send(method, params));
|
|
264
|
+
this.mouse = new QaMouse((method, params) => this.send(method, params));
|
|
124
265
|
this.unsubs.push(this.on('Runtime.executionContextCreated', ({ context }) => { this.contexts.set(context.id, context); this.chooseContext(); }), this.on('Runtime.executionContextDestroyed', ({ executionContextId }) => {
|
|
125
266
|
this.contexts.delete(executionContextId);
|
|
126
267
|
if (executionContextId === this.contextId) {
|
|
@@ -212,15 +353,26 @@ export class QaPage {
|
|
|
212
353
|
this.closedListeners.clear();
|
|
213
354
|
this.onClosed();
|
|
214
355
|
}
|
|
356
|
+
/** Owned pages (newPage) close their target. Attached pages (attachPage) only detach; the app's page stays open. */
|
|
215
357
|
close() {
|
|
216
358
|
if (this.closing)
|
|
217
359
|
return this.closing;
|
|
218
360
|
if (this.closed)
|
|
219
361
|
return Promise.resolve();
|
|
220
362
|
this.closing = (async () => {
|
|
363
|
+
if (this.owned) {
|
|
364
|
+
try {
|
|
365
|
+
await this.client.send('Target.closeTarget', { targetId: this.targetId });
|
|
366
|
+
}
|
|
367
|
+
finally {
|
|
368
|
+
this.markClosed();
|
|
369
|
+
}
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
221
372
|
try {
|
|
222
|
-
await this.client.send('Target.
|
|
373
|
+
await this.client.send('Target.detachFromTarget', { sessionId: this.sessionId });
|
|
223
374
|
}
|
|
375
|
+
catch { /* already detached: the local page is closed either way */ }
|
|
224
376
|
finally {
|
|
225
377
|
this.markClosed();
|
|
226
378
|
}
|
|
@@ -470,9 +622,11 @@ export class QaPage {
|
|
|
470
622
|
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
|
471
623
|
}
|
|
472
624
|
async click(target, options = {}) {
|
|
625
|
+
this.mouse.assertReleased();
|
|
473
626
|
await this.withElement(target, async (handle) => {
|
|
474
627
|
const point = await this.point(handle), button = options.button ?? 'left', count = options.clickCount ?? 1;
|
|
475
628
|
await this.send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...point });
|
|
629
|
+
this.mouse.track(point.x, point.y);
|
|
476
630
|
for (let clickCount = 1; clickCount <= count; clickCount++) {
|
|
477
631
|
await this.send('Input.dispatchMouseEvent', { type: 'mousePressed', ...point, button, buttons: button === 'left' ? 1 : button === 'right' ? 2 : 4, clickCount });
|
|
478
632
|
if (options.delay)
|
|
@@ -497,7 +651,12 @@ export class QaPage {
|
|
|
497
651
|
}
|
|
498
652
|
}
|
|
499
653
|
async hover(target) {
|
|
500
|
-
|
|
654
|
+
this.mouse.assertReleased();
|
|
655
|
+
await this.withElement(target, async (handle) => {
|
|
656
|
+
const point = await this.point(handle);
|
|
657
|
+
await this.send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...point });
|
|
658
|
+
this.mouse.track(point.x, point.y);
|
|
659
|
+
});
|
|
501
660
|
}
|
|
502
661
|
async focus(target) { await this.withElement(target, (handle) => handle.focus()); }
|
|
503
662
|
async select(selector, ...values) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rightkit/qa",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
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",
|
|
@@ -37,12 +37,12 @@
|
|
|
37
37
|
},
|
|
38
38
|
"repository": {
|
|
39
39
|
"type": "git",
|
|
40
|
-
"url": "git+https://github.com/bogusyogi/
|
|
41
|
-
"directory": "
|
|
40
|
+
"url": "git+https://github.com/bogusyogi/rightkit.git",
|
|
41
|
+
"directory": "packages/qa"
|
|
42
42
|
},
|
|
43
43
|
"scripts": {
|
|
44
44
|
"build": "tsc -p tsconfig.json",
|
|
45
45
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
46
|
-
"test": "vitest run && node --test public-storage.test.mjs"
|
|
46
|
+
"test": "pnpm --filter @rightkit/qa-chrome build && pnpm run build && vitest run && node --test public-storage.test.mjs"
|
|
47
47
|
}
|
|
48
48
|
}
|