@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 CHANGED
@@ -1,73 +1,106 @@
1
- # `@rightkit/qa`
1
+ # @rightkit/qa
2
2
 
3
- > **Which QA tool?** To drive a Right Suite desktop app under test, use the Rust **`rightkit-qa`** crate with **`rightkit-control`**; that is the one supported engine. For a test browser only, use the zero-dependency **`@rightkit/qa-chrome`**. For an agent driving an installed app, use **`right-ctl`**. This package's WebdriverIO native lane & `right-qa` CLI were removed in 0.3.0.
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
- Shared QA primitives for Right Suite desktop apps. The package owns run locking, bearer-authenticated loopback control, deterministic evidence bundles, exact owned-process cleanup, and the guard that keeps development WebDriver access out of production builds.
6
-
7
- This package is intentionally private until the Windows and macOS phase gate passes. App repositories must never consume it through a Git, path, `file:`, `link:`, or `workspace:` dependency; adoption happens only after a registry version is published.
8
-
9
- ## Run storage
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
- ## Chrome for Testing (`@rightkit/qa/chrome`)
12
+ ## Entry points
18
13
 
19
- Web/CDP harnesses launch Chrome for Testing, never regular Google Chrome: on macOS each regular Chrome instance clones its app bundle under `<DARWIN_USER_TEMP_DIR>/../X/com.google.Chrome.code_sign_clone/`, and a killed instance leaks that clone.
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
- - `resolveChromeForTesting({ executablePath? })` resolves the explicit option, then `RIGHTKIT_CHROME_FOR_TESTING`, then `/Applications` and `~/Applications` app installs, then the newest Puppeteer (`~/.cache/puppeteer`, `PUPPETEER_CACHE_DIR`) and Playwright (`~/Library/Caches/ms-playwright`, `PLAYWRIGHT_BROWSERS_PATH`) builds. It throws `ChromeForTestingNotFoundError` with install instructions; it never falls back. `findChromeForTesting()` returns `{ path }` or `{ reason }` for test skips.
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
- ## Raw CDP page driver (Node 26)
21
+ Peer dependencies: none.
27
22
 
28
- `QaBrowserSession`, `QaPage`, `QaElement`, `QaKeyboard` & `TimeoutError` are exported from `@rightkit/qa`. Transport uses Node's built-in `WebSocket` directly; no Puppeteer, Playwright, or CDP wrapper dependency.
23
+ ## Example
29
24
 
30
25
  ```ts
31
- import { launchChromeForTesting, QaBrowserSession } from '@rightkit/qa';
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.evaluateOnNewDocument(() => { window.localStorage.setItem('qa', '1'); });
39
- await page.goto('http://127.0.0.1:3000', { waitUntil: 'networkidle0', timeout: 10_000 });
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 button?.dispose();
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
- Session owns targets it creates; launcher owns browser process & profile. `connect(endpoint, { timeout, debug })` defaults to 30,000 ms per connection/command, with logging off. Positive finite deadlines apply to navigation & waits; failures use typed `TimeoutError`. `page.on(cdpMethod, listener)` subscribes to page-session events & returns an unsubscribe function. Debug callbacks receive method names only. Throwing event subscribers are isolated from transport state.
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
- Evaluation uses main frame's default execution context, awaits promises & returns values by JSON. Functions run in Chrome, so pass external values as arguments rather than closing over Node variables. JavaScript exceptions become `Error` with Chrome's message. `evaluateOnNewDocument` accepts function + arguments or source text & runs before document scripts, including subsequent reloads.
44
+ ## Attach to a running app and drive its mouse
52
45
 
53
- `goto`/`reload` support `load`, `domcontentloaded` & `networkidle0` (zero outstanding requests for 500 ms). `waitForSelector` supports present/visible/hidden states; hidden waits return `null`. `waitForFunction` supports animation-frame or positive millisecond polling & resolves when predicate is truthy. Both name selector/predicate in timeout errors. `$`/`$$` return remote element handles, released by `dispose()`, navigation, or page closure.
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
- Click/hover scroll elements into view & use box centres. Mouse, keyboard & text input use CDP `Input` commands; `select` dispatches DOM `input`/`change` after selecting values. Keyboard supports required navigation/editing keys, letters/digits & modifier chords such as `Control+A`, `Meta+A`, or `Shift+ArrowLeft`. Screenshots return `Buffer`, optionally write `path`, & capture viewport PNG at configured DPR; `fullPage` & `captureBeyondViewport` support `false` only.
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
- | Puppeteer | QaPage equivalent |
58
- |---|---|
59
- | `puppeteer.connect({ browserWSEndpoint })` | `QaBrowserSession.connect(wsEndpoint)` |
60
- | `browser.newPage()` / `browser.close()` | Same calls; session closes owned targets |
61
- | `page.goto()` / `reload()` / `close()` | Same calls & navigation options |
62
- | `page.setViewport()` | Same call; CDP device metrics |
63
- | `page.evaluateOnNewDocument()` | Same call; function or source |
64
- | `page.evaluate()` / `$eval()` / `$$eval()` | Same calls; JSON results & awaited promises |
65
- | `page.waitForSelector()` | Same options; `QaElement` or `null` |
66
- | `page.waitForFunction()` | Same predicate/options/arguments; returns `void` |
67
- | `page.$()` / `page.$$()` | `QaElement` / `QaElement[]` |
68
- | `element.click/type/hover/focus/boundingBox/evaluate/dispose()` | Same calls on `QaElement` |
69
- | `page.click/type/hover/focus/select()` | Same calls; click accepts selector or handle |
70
- | `page.keyboard.press()` | Same call; named key or modifier chord |
71
- | `page.screenshot()` | Viewport PNG `Buffer`; optional `path` |
72
-
73
- `pnpm --dir packages/qa test` runs mock-WebSocket unit tests & real Chrome for Testing integration against local static fixture. Install Chrome through `@rightkit/qa-chrome` or set `RIGHTKIT_CHROME_FOR_TESTING`; integration requires Chrome & does not silently skip.
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).
@@ -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 pages;
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
- pages = new Set();
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.pages.delete(page));
63
- this.pages.add(page);
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.pages)
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.pages].map((page) => page.close()));
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.closeTarget', { targetId: this.targetId });
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
- await this.withElement(target, async (handle) => { await this.send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...await this.point(handle) }); });
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.0",
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/claude.git",
41
- "directory": "tools/rightkit/packages/qa"
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
  }