@rightkit/qa 0.3.2 → 0.4.1

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,24 +1,47 @@
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.
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 |
6
11
 
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.
12
+ ## Entry points
8
13
 
9
- ## Run storage
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`, `TimeoutError`. |
17
+ | `@rightkit/qa/chrome` | Re-export of `@rightkit/qa-chrome` |
10
18
 
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`.
19
+ Dependency: `@rightkit/qa-chrome` (exact `0.1.3`).
12
20
 
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.
21
+ Peer dependencies: none.
14
22
 
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`.
23
+ ## Example
16
24
 
17
- ## Chrome for Testing (`@rightkit/qa/chrome`)
25
+ ```ts
26
+ import { launchChromeForTesting, QaBrowserSession } from "@rightkit/qa";
18
27
 
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.
28
+ const chrome = await launchChromeForTesting();
29
+ const browser = await QaBrowserSession.connect(chrome.wsEndpoint);
30
+ try {
31
+ const page = await browser.newPage();
32
+ await page.setViewport({ width: 1280, height: 720, deviceScaleFactor: 1 });
33
+ await page.goto("http://127.0.0.1:3000", { waitUntil: "networkidle0", timeout: 10_000 });
34
+ const button = await page.waitForSelector("button", { visible: true });
35
+ await button?.click();
36
+ await page.screenshot({ path: "viewport.png" });
37
+ } finally {
38
+ try { await browser.close(); } finally { await chrome.close(); }
39
+ }
40
+ ```
20
41
 
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.
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
+
44
+ ## Known issues
45
+
46
+ - `@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).
47
+ - Three consumers pin three different versions of `@rightkit/qa-chrome` as dev dependencies (npm audit 2026-10-08).
package/dist/cdp.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ /** Minimal flattened-session CDP transport using Node's built-in WebSocket. */
2
+ export declare class TimeoutError extends Error {
3
+ constructor(message: string);
4
+ }
5
+ export interface QaConnectOptions {
6
+ /** Default 30 seconds; applies to connection & each CDP command. */
7
+ timeout?: number;
8
+ /** Off by default. Receives method names only, never evaluated source or input text. */
9
+ debug?: (message: string) => void;
10
+ }
11
+ export type CdpParams = Record<string, any>;
12
+ type Listener = (params: CdpParams) => void;
13
+ /** @internal All operations use finite deadlines. */
14
+ export declare function validateTimeout(timeout: number): number;
15
+ export declare class CdpClient {
16
+ private socket;
17
+ readonly timeout: number;
18
+ private debug?;
19
+ private nextId;
20
+ private pending;
21
+ private listeners;
22
+ private closed?;
23
+ private constructor();
24
+ static connect(endpoint: string, options?: QaConnectOptions): Promise<CdpClient>;
25
+ send(method: string, params?: CdpParams, session?: string, timeout?: number): Promise<CdpParams>;
26
+ on(method: string, listener: Listener, session?: string): () => void;
27
+ private emit;
28
+ rejectSession(session: string, error: Error): void;
29
+ private fail;
30
+ close(): void;
31
+ }
32
+ export {};
package/dist/cdp.js ADDED
@@ -0,0 +1,141 @@
1
+ /** Minimal flattened-session CDP transport using Node's built-in WebSocket. */
2
+ export class TimeoutError extends Error {
3
+ constructor(message) { super(message); this.name = 'TimeoutError'; }
4
+ }
5
+ /** @internal All operations use finite deadlines. */
6
+ export function validateTimeout(timeout) {
7
+ if (!Number.isFinite(timeout) || timeout <= 0 || timeout > 2_147_483_647)
8
+ throw new Error('Timeout must be a positive finite number below 2147483648ms');
9
+ return timeout;
10
+ }
11
+ export class CdpClient {
12
+ socket;
13
+ timeout;
14
+ debug;
15
+ nextId = 0;
16
+ pending = new Map();
17
+ listeners = new Map();
18
+ closed;
19
+ constructor(socket, timeout, debug) {
20
+ this.socket = socket;
21
+ this.timeout = timeout;
22
+ this.debug = debug;
23
+ socket.addEventListener('message', (event) => {
24
+ let message;
25
+ try {
26
+ message = JSON.parse(String(event.data));
27
+ }
28
+ catch (error) {
29
+ this.fail(new Error(`Invalid CDP message: ${String(error)}`));
30
+ this.socket.close();
31
+ return;
32
+ }
33
+ if (message.id !== undefined) {
34
+ const pending = this.pending.get(message.id);
35
+ if (!pending)
36
+ return;
37
+ this.pending.delete(message.id);
38
+ clearTimeout(pending.timer);
39
+ if (message.error)
40
+ pending.reject(new Error(`CDP: ${message.error.message}`));
41
+ else
42
+ pending.resolve(message.result ?? {});
43
+ }
44
+ else if (message.method) {
45
+ this.emit(message.method, message.params ?? {}, message.sessionId);
46
+ }
47
+ });
48
+ socket.addEventListener('close', () => this.fail(new Error('CDP socket closed')));
49
+ socket.addEventListener('error', () => this.fail(new Error('CDP socket error')));
50
+ }
51
+ static async connect(endpoint, options = {}) {
52
+ const timeout = validateTimeout(options.timeout ?? 30_000);
53
+ const socket = new WebSocket(endpoint);
54
+ const client = new CdpClient(socket, timeout, options.debug);
55
+ await new Promise((resolve, reject) => {
56
+ const done = (error) => {
57
+ clearTimeout(timer);
58
+ socket.removeEventListener('open', open);
59
+ socket.removeEventListener('error', errorHandler);
60
+ socket.removeEventListener('close', close);
61
+ if (error) {
62
+ client.close();
63
+ reject(error);
64
+ }
65
+ else
66
+ resolve();
67
+ };
68
+ const open = () => done();
69
+ const errorHandler = () => done(new Error('CDP connection failed'));
70
+ const close = () => done(new Error('CDP socket closed while connecting'));
71
+ const timer = setTimeout(() => done(new TimeoutError(`CDP connect timed out after ${timeout}ms`)), timeout);
72
+ socket.addEventListener('open', open);
73
+ socket.addEventListener('error', errorHandler);
74
+ socket.addEventListener('close', close);
75
+ });
76
+ return client;
77
+ }
78
+ send(method, params = {}, session, timeout = this.timeout) {
79
+ if (this.closed)
80
+ return Promise.reject(this.closed);
81
+ validateTimeout(timeout);
82
+ this.debug?.(`CDP ${method}`);
83
+ const id = ++this.nextId;
84
+ return new Promise((resolve, reject) => {
85
+ const timer = setTimeout(() => {
86
+ this.pending.delete(id);
87
+ reject(new TimeoutError(`${method} timed out after ${timeout}ms`));
88
+ }, timeout);
89
+ this.pending.set(id, { resolve, reject, timer, session });
90
+ try {
91
+ this.socket.send(JSON.stringify({ id, method, params, ...(session ? { sessionId: session } : {}) }));
92
+ }
93
+ catch (error) {
94
+ clearTimeout(timer);
95
+ this.pending.delete(id);
96
+ reject(error);
97
+ }
98
+ });
99
+ }
100
+ on(method, listener, session) {
101
+ const key = `${session ?? ''}:${method}`;
102
+ let listeners = this.listeners.get(key);
103
+ if (!listeners)
104
+ this.listeners.set(key, listeners = new Set());
105
+ listeners.add(listener);
106
+ return () => { listeners.delete(listener); if (!listeners.size)
107
+ this.listeners.delete(key); };
108
+ }
109
+ emit(method, params, session) {
110
+ for (const listener of [...(this.listeners.get(`${session ?? ''}:${method}`) ?? [])]) {
111
+ // A user subscription cannot corrupt transport state or suppress internal listeners.
112
+ try {
113
+ listener(params);
114
+ }
115
+ catch {
116
+ this.debug?.(`CDP listener failed: ${method}`);
117
+ }
118
+ }
119
+ }
120
+ rejectSession(session, error) {
121
+ for (const [id, pending] of this.pending)
122
+ if (pending.session === session) {
123
+ clearTimeout(pending.timer);
124
+ this.pending.delete(id);
125
+ pending.reject(error);
126
+ }
127
+ }
128
+ fail(error) {
129
+ if (this.closed)
130
+ return;
131
+ this.closed = error;
132
+ for (const pending of this.pending.values()) {
133
+ clearTimeout(pending.timer);
134
+ pending.reject(error);
135
+ }
136
+ this.pending.clear();
137
+ this.emit('Disconnected', { error });
138
+ this.listeners.clear();
139
+ }
140
+ close() { this.fail(new Error('CDP socket closed')); this.socket.close(); }
141
+ }
package/dist/index.d.ts CHANGED
@@ -9,3 +9,4 @@ export * from './panes.js';
9
9
  export * from './process.js';
10
10
  export * from './workspace.js';
11
11
  export * from './storage.js';
12
+ export * from './page.js';
package/dist/index.js CHANGED
@@ -9,3 +9,4 @@ export * from './panes.js';
9
9
  export * from './process.js';
10
10
  export * from './workspace.js';
11
11
  export * from './storage.js';
12
+ export * from './page.js';
@@ -0,0 +1,9 @@
1
+ import type { CdpParams } from './cdp.js';
2
+ export declare const modifierBits: Record<string, number>;
3
+ export declare function keyDefinition(key: string, modifiers: number): CdpParams;
4
+ export declare class QaKeyboard {
5
+ private send;
6
+ constructor(send: (method: string, params: CdpParams) => Promise<CdpParams>);
7
+ /** Single key or chord, e.g. Control+A, Meta+A, Shift+ArrowLeft. */
8
+ press(chord: string): Promise<void>;
9
+ }
@@ -0,0 +1,79 @@
1
+ const special = {
2
+ Escape: ['Escape', 27], Enter: ['Enter', 13, '\r'], Tab: ['Tab', 9],
3
+ ArrowLeft: ['ArrowLeft', 37], ArrowUp: ['ArrowUp', 38], ArrowRight: ['ArrowRight', 39], ArrowDown: ['ArrowDown', 40],
4
+ Backspace: ['Backspace', 8], Delete: ['Delete', 46], Home: ['Home', 36], End: ['End', 35],
5
+ PageUp: ['PageUp', 33], PageDown: ['PageDown', 34], Space: ['Space', 32, ' '],
6
+ Shift: ['ShiftLeft', 16], Control: ['ControlLeft', 17], Alt: ['AltLeft', 18], Meta: ['MetaLeft', 91],
7
+ };
8
+ export const modifierBits = { Alt: 1, Control: 2, Meta: 4, Shift: 8 };
9
+ export function keyDefinition(key, modifiers) {
10
+ const named = special[key];
11
+ let code, windowsVirtualKeyCode, text;
12
+ if (named)
13
+ [code, windowsVirtualKeyCode, text] = named;
14
+ else if (/^[a-z]$/i.test(key)) {
15
+ code = `Key${key.toUpperCase()}`;
16
+ windowsVirtualKeyCode = key.toUpperCase().charCodeAt(0);
17
+ key = modifiers & 8 ? key.toUpperCase() : modifiers & 7 ? key.toLowerCase() : key;
18
+ text = key;
19
+ }
20
+ else if (/^[0-9]$/.test(key)) {
21
+ code = `Digit${key}`;
22
+ windowsVirtualKeyCode = key.charCodeAt(0);
23
+ key = modifiers & 8 ? ')!@#$%^&*('[Number(key)] : key;
24
+ text = key;
25
+ }
26
+ else if (key === ' ') {
27
+ code = 'Space';
28
+ windowsVirtualKeyCode = 32;
29
+ text = ' ';
30
+ }
31
+ else
32
+ throw new Error(`Unsupported key: ${key}`);
33
+ if (key === 'Space')
34
+ key = ' ';
35
+ if (modifiers & (1 | 2 | 4))
36
+ text = undefined;
37
+ const unmodifiedText = code.startsWith('Key') ? code.slice(3).toLowerCase() : code.startsWith('Digit') ? code.slice(5) : text;
38
+ return { key, code, windowsVirtualKeyCode, modifiers, ...(text ? { text, unmodifiedText } : {}) };
39
+ }
40
+ export class QaKeyboard {
41
+ send;
42
+ constructor(send) {
43
+ this.send = send;
44
+ }
45
+ /** Single key or chord, e.g. Control+A, Meta+A, Shift+ArrowLeft. */
46
+ async press(chord) {
47
+ const parts = chord.split('+');
48
+ if (parts.some((part, i) => i < parts.length - 1 && !modifierBits[part]))
49
+ throw new Error(`Invalid key chord: ${chord}`);
50
+ keyDefinition(parts.at(-1), 0); // Validate before holding any modifier.
51
+ let modifiers = 0;
52
+ const held = [];
53
+ let failure;
54
+ try {
55
+ for (const part of parts) {
56
+ modifiers |= modifierBits[part] ?? 0;
57
+ const definition = keyDefinition(part, modifiers);
58
+ await this.send('Input.dispatchKeyEvent', { type: definition.text ? 'keyDown' : 'rawKeyDown', ...definition });
59
+ held.push(part);
60
+ }
61
+ }
62
+ catch (error) {
63
+ failure = error;
64
+ }
65
+ finally {
66
+ for (const part of held.reverse()) {
67
+ modifiers &= ~(modifierBits[part] ?? 0);
68
+ try {
69
+ await this.send('Input.dispatchKeyEvent', { type: 'keyUp', ...keyDefinition(part, modifiers), text: undefined, unmodifiedText: undefined });
70
+ }
71
+ catch (error) {
72
+ failure ??= error;
73
+ }
74
+ }
75
+ }
76
+ if (failure)
77
+ throw failure;
78
+ }
79
+ }
package/dist/page.d.ts ADDED
@@ -0,0 +1,143 @@
1
+ import { CdpClient, type CdpParams, type QaConnectOptions } from './cdp.js';
2
+ import { QaKeyboard } from './keyboard.js';
3
+ export { TimeoutError, type QaConnectOptions } from './cdp.js';
4
+ export { QaKeyboard } from './keyboard.js';
5
+ export interface NavigationOptions {
6
+ waitUntil?: 'load' | 'domcontentloaded' | 'networkidle0';
7
+ timeout?: number;
8
+ }
9
+ export interface ViewportOptions {
10
+ width: number;
11
+ height: number;
12
+ deviceScaleFactor?: number;
13
+ mobile?: boolean;
14
+ }
15
+ export interface WaitOptions {
16
+ timeout?: number;
17
+ polling?: 'raf' | number;
18
+ }
19
+ export interface SelectorWaitOptions {
20
+ visible?: boolean;
21
+ hidden?: boolean;
22
+ timeout?: number;
23
+ }
24
+ export interface ClickOptions {
25
+ button?: 'left' | 'right' | 'middle';
26
+ clickCount?: number;
27
+ delay?: number;
28
+ }
29
+ export interface TypeOptions {
30
+ delay?: number;
31
+ }
32
+ export interface ScreenshotOptions {
33
+ path?: string;
34
+ type?: 'png';
35
+ captureBeyondViewport?: false;
36
+ fullPage?: false;
37
+ }
38
+ export interface BoundingBox {
39
+ x: number;
40
+ y: number;
41
+ width: number;
42
+ height: number;
43
+ }
44
+ type PageFunction<Args extends unknown[], Result> = (...args: Args) => Result;
45
+ /** Owns only targets it creates; launcher owns browser process & profile cleanup. */
46
+ export declare class QaBrowserSession {
47
+ private client;
48
+ private pages;
49
+ private closed;
50
+ private closing?;
51
+ private opening;
52
+ private constructor();
53
+ static connect(wsEndpoint: string, options?: QaConnectOptions): Promise<QaBrowserSession>;
54
+ newPage(): Promise<QaPage>;
55
+ private createPage;
56
+ close(): Promise<void>;
57
+ }
58
+ export declare class QaPage {
59
+ private client;
60
+ readonly targetId: string;
61
+ private sessionId;
62
+ private onClosed;
63
+ readonly keyboard: QaKeyboard;
64
+ private frameId?;
65
+ private contexts;
66
+ private contextId?;
67
+ private handles;
68
+ private unsubs;
69
+ private subscriptions;
70
+ private closed?;
71
+ private closing?;
72
+ private closedListeners;
73
+ private requests;
74
+ private frameParents;
75
+ private lastNetworkActivity;
76
+ private generation;
77
+ private navigating;
78
+ private get objectGroup();
79
+ /** @internal Use QaBrowserSession.newPage(). */
80
+ constructor(client: CdpClient, targetId: string, sessionId: string, onClosed: () => void);
81
+ private chooseContext;
82
+ /** Subscribe to this page's raw CDP events; returned function unsubscribes. */
83
+ on(method: string, listener: (params: CdpParams) => void): () => void;
84
+ /** @internal */
85
+ initialize(): Promise<void>;
86
+ /** @internal */
87
+ send(method: string, params?: CdpParams, timeout?: number): Promise<CdpParams>;
88
+ private invalidateHandles;
89
+ /** @internal */
90
+ markClosed(error?: Error): void;
91
+ close(): Promise<void>;
92
+ private bounded;
93
+ private mainContext;
94
+ private navigate;
95
+ goto(url: string, options?: NavigationOptions): Promise<void>;
96
+ reload(options?: NavigationOptions): Promise<void>;
97
+ setViewport(options: ViewportOptions): Promise<void>;
98
+ evaluateOnNewDocument<Args extends unknown[]>(fn: PageFunction<Args, unknown> | string, ...args: Args): Promise<void>;
99
+ private run;
100
+ evaluate<Args extends unknown[], Result>(fn: PageFunction<Args, Result> | string, ...args: Args): Promise<Awaited<Result>>;
101
+ /** @internal */
102
+ callElement<Args extends unknown[], Result>(handle: QaElement, fn: (element: HTMLElement, ...args: Args) => Result, args: Args): Promise<Awaited<Result>>;
103
+ private handle;
104
+ $(selector: string): Promise<QaElement | null>;
105
+ private lookup;
106
+ $$(selector: string): Promise<QaElement[]>;
107
+ $eval<Args extends unknown[], Result>(selector: string, fn: (element: HTMLElement, ...args: Args) => Result, ...args: Args): Promise<Awaited<Result>>;
108
+ $$eval<Args extends unknown[], Result>(selector: string, fn: (elements: HTMLElement[], ...args: Args) => Result, ...args: Args): Promise<Awaited<Result>>;
109
+ private runSourceFunction;
110
+ waitForFunction<Args extends unknown[]>(fn: PageFunction<Args, unknown> | string, options?: WaitOptions, ...args: Args): Promise<void>;
111
+ waitForSelector(selector: string, options?: SelectorWaitOptions): Promise<QaElement | null>;
112
+ private requireElement;
113
+ private withElement;
114
+ private point;
115
+ click(target: string | QaElement, options?: ClickOptions): Promise<void>;
116
+ type(selector: string, text: string, options?: TypeOptions): Promise<void>;
117
+ /** @internal */
118
+ typeElement(handle: QaElement, text: string, options?: TypeOptions): Promise<void>;
119
+ hover(target: string | QaElement): Promise<void>;
120
+ focus(target: string | QaElement): Promise<void>;
121
+ select(selector: string, ...values: string[]): Promise<string[]>;
122
+ screenshot(options?: ScreenshotOptions): Promise<Buffer>;
123
+ }
124
+ /** Remote DOM reference; dispose explicitly or let navigation release its object group. */
125
+ export declare class QaElement {
126
+ private page;
127
+ readonly objectId: string;
128
+ private onDisposed;
129
+ private disposed;
130
+ /** @internal */
131
+ constructor(page: QaPage, objectId: string, onDisposed: () => void);
132
+ /** @internal */
133
+ assertUsable(page: QaPage): void;
134
+ /** @internal */
135
+ invalidate(): void;
136
+ dispose(): Promise<void>;
137
+ evaluate<Args extends unknown[], Result>(fn: (element: HTMLElement, ...args: Args) => Result, ...args: Args): Promise<Awaited<Result>>;
138
+ click(options?: ClickOptions): Promise<void>;
139
+ type(text: string, options?: TypeOptions): Promise<void>;
140
+ hover(): Promise<void>;
141
+ focus(): Promise<void>;
142
+ boundingBox(): Promise<BoundingBox | null>;
143
+ }
package/dist/page.js ADDED
@@ -0,0 +1,573 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+ import { CdpClient, TimeoutError, validateTimeout } from './cdp.js';
3
+ import { QaKeyboard } from './keyboard.js';
4
+ export { TimeoutError } from './cdp.js';
5
+ export { QaKeyboard } from './keyboard.js';
6
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
7
+ function functionSource(fn) {
8
+ const source = fn.toString();
9
+ if (source.includes('[native code]'))
10
+ throw new Error('Cannot evaluate native function');
11
+ // Parse only (never execute) to distinguish methods from async arrow functions.
12
+ try {
13
+ new Function(`return (${source}\n)`);
14
+ }
15
+ catch {
16
+ const declaration = source.startsWith('async ') ? source.replace(/^async\s+/, 'async function ') : `function ${source}`;
17
+ new Function(`return (${declaration}\n)`);
18
+ return declaration;
19
+ }
20
+ return source;
21
+ }
22
+ function argument(value) {
23
+ if (value === undefined)
24
+ return {};
25
+ if (typeof value === 'number' && (!Number.isFinite(value) || Object.is(value, -0)))
26
+ return { unserializableValue: Object.is(value, -0) ? '-0' : String(value) };
27
+ if (typeof value === 'bigint')
28
+ return { unserializableValue: `${value}n` };
29
+ return { value };
30
+ }
31
+ function resultValue(result) {
32
+ if (result.exceptionDetails) {
33
+ throw new Error(result.exceptionDetails.exception?.description ?? result.exceptionDetails.exception?.value ?? result.exceptionDetails.text ?? 'JavaScript evaluation failed');
34
+ }
35
+ return result.result?.value;
36
+ }
37
+ /** Owns only targets it creates; launcher owns browser process & profile cleanup. */
38
+ export class QaBrowserSession {
39
+ client;
40
+ pages = new Set();
41
+ closed = false;
42
+ closing;
43
+ opening = new Set();
44
+ constructor(client) {
45
+ this.client = client;
46
+ }
47
+ static async connect(wsEndpoint, options = {}) {
48
+ return new QaBrowserSession(await CdpClient.connect(wsEndpoint, options));
49
+ }
50
+ newPage() {
51
+ if (this.closed)
52
+ return Promise.reject(new Error('Browser session closed'));
53
+ const pending = this.createPage();
54
+ this.opening.add(pending);
55
+ void pending.then(() => this.opening.delete(pending), () => this.opening.delete(pending));
56
+ return pending;
57
+ }
58
+ async createPage() {
59
+ const { targetId } = await this.client.send('Target.createTarget', { url: 'about:blank' });
60
+ try {
61
+ 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);
64
+ await page.initialize();
65
+ if (this.closed)
66
+ throw new Error('Browser session closed');
67
+ return page;
68
+ }
69
+ catch (error) {
70
+ await this.client.send('Target.closeTarget', { targetId }).catch(() => { });
71
+ for (const page of this.pages)
72
+ if (page.targetId === targetId)
73
+ page.markClosed();
74
+ throw error;
75
+ }
76
+ }
77
+ close() {
78
+ if (this.closing)
79
+ return this.closing;
80
+ this.closed = true;
81
+ this.closing = (async () => {
82
+ try {
83
+ await Promise.allSettled([...this.opening]);
84
+ const results = await Promise.allSettled([...this.pages].map((page) => page.close()));
85
+ const failed = results.find((result) => result.status === 'rejected');
86
+ if (failed?.status === 'rejected')
87
+ throw failed.reason;
88
+ }
89
+ finally {
90
+ this.client.close();
91
+ }
92
+ })();
93
+ return this.closing;
94
+ }
95
+ }
96
+ export class QaPage {
97
+ client;
98
+ targetId;
99
+ sessionId;
100
+ onClosed;
101
+ keyboard;
102
+ frameId;
103
+ contexts = new Map();
104
+ contextId;
105
+ handles = new Set();
106
+ unsubs = [];
107
+ subscriptions = new Set();
108
+ closed;
109
+ closing;
110
+ closedListeners = new Set();
111
+ requests = new Map();
112
+ frameParents = new Map();
113
+ lastNetworkActivity = 0;
114
+ generation = 0;
115
+ navigating = false;
116
+ get objectGroup() { return `qa-elements-${this.generation}`; }
117
+ /** @internal Use QaBrowserSession.newPage(). */
118
+ constructor(client, targetId, sessionId, onClosed) {
119
+ this.client = client;
120
+ this.targetId = targetId;
121
+ this.sessionId = sessionId;
122
+ this.onClosed = onClosed;
123
+ this.keyboard = new QaKeyboard((method, params) => this.send(method, params));
124
+ this.unsubs.push(this.on('Runtime.executionContextCreated', ({ context }) => { this.contexts.set(context.id, context); this.chooseContext(); }), this.on('Runtime.executionContextDestroyed', ({ executionContextId }) => {
125
+ this.contexts.delete(executionContextId);
126
+ if (executionContextId === this.contextId) {
127
+ this.invalidateHandles();
128
+ this.chooseContext();
129
+ }
130
+ }), this.on('Runtime.executionContextsCleared', () => { this.contexts.clear(); this.contextId = undefined; this.invalidateHandles(); }), this.on('Page.frameNavigated', ({ frame }) => {
131
+ if (frame.parentId)
132
+ this.frameParents.set(frame.id, frame.parentId);
133
+ else {
134
+ this.frameId = frame.id;
135
+ this.frameParents.clear();
136
+ this.invalidateHandles();
137
+ this.chooseContext();
138
+ }
139
+ // Chrome can abandon old-document favicon/fetch requests without a terminal event.
140
+ // New document requests have already started by frameNavigated; preserve that loader.
141
+ for (const [id, request] of this.requests) {
142
+ if ((!frame.parentId || request.frameId === frame.id) && request.loaderId !== frame.loaderId)
143
+ this.requests.delete(id);
144
+ }
145
+ }), this.on('Page.frameAttached', ({ frameId, parentFrameId }) => this.frameParents.set(frameId, parentFrameId)), this.on('Page.frameDetached', ({ frameId, reason }) => {
146
+ if (reason === 'swap')
147
+ return;
148
+ const removed = new Set([frameId]);
149
+ for (const id of removed)
150
+ for (const [child, parent] of this.frameParents)
151
+ if (parent === id)
152
+ removed.add(child);
153
+ for (const id of removed)
154
+ this.frameParents.delete(id);
155
+ for (const [id, request] of this.requests)
156
+ if (request.frameId && removed.has(request.frameId))
157
+ this.requests.delete(id);
158
+ }), this.on('Network.requestWillBeSent', ({ requestId, frameId, loaderId }) => { this.requests.set(requestId, { frameId, loaderId }); this.lastNetworkActivity = Date.now(); }), this.on('Network.loadingFinished', ({ requestId }) => { this.requests.delete(requestId); this.lastNetworkActivity = Date.now(); }), this.on('Network.loadingFailed', ({ requestId }) => { this.requests.delete(requestId); this.lastNetworkActivity = Date.now(); }), client.on('Disconnected', ({ error }) => this.markClosed(error)), client.on('Target.detachedFromTarget', ({ sessionId }) => { if (sessionId === this.sessionId)
159
+ this.markClosed(); }), this.on('Inspector.detached', () => this.markClosed()));
160
+ }
161
+ chooseContext() {
162
+ this.contextId = [...this.contexts.values()].reverse().find((context) => context.auxData?.isDefault && context.auxData.frameId === this.frameId)?.id;
163
+ }
164
+ /** Subscribe to this page's raw CDP events; returned function unsubscribes. */
165
+ on(method, listener) {
166
+ if (this.closed)
167
+ throw this.closed;
168
+ const off = this.client.on(method, listener, this.sessionId);
169
+ const unsubscribe = () => { off(); this.subscriptions.delete(unsubscribe); };
170
+ this.subscriptions.add(unsubscribe);
171
+ return unsubscribe;
172
+ }
173
+ /** @internal */
174
+ async initialize() {
175
+ await this.send('Page.enable');
176
+ const { frameTree } = await this.send('Page.getFrameTree');
177
+ this.frameId = frameTree.frame.id;
178
+ this.chooseContext();
179
+ await this.send('Runtime.enable');
180
+ await this.send('Network.enable');
181
+ await this.send('Page.setLifecycleEventsEnabled', { enabled: true });
182
+ }
183
+ /** @internal */
184
+ send(method, params = {}, timeout) {
185
+ if (this.closed)
186
+ return Promise.reject(this.closed);
187
+ return this.client.send(method, params, this.sessionId, timeout);
188
+ }
189
+ invalidateHandles() {
190
+ const objectGroup = this.objectGroup;
191
+ this.generation++;
192
+ for (const handle of this.handles)
193
+ handle.invalidate();
194
+ this.handles.clear();
195
+ // Navigation destroys the context; explicitly release any surviving remote references.
196
+ if (!this.closed)
197
+ void this.send('Runtime.releaseObjectGroup', { objectGroup }).catch(() => { });
198
+ }
199
+ /** @internal */
200
+ markClosed(error = new Error('Page closed')) {
201
+ if (this.closed)
202
+ return;
203
+ this.closed = error;
204
+ this.invalidateHandles();
205
+ this.client.rejectSession(this.sessionId, error);
206
+ for (const unsubscribe of this.unsubs.splice(0))
207
+ unsubscribe();
208
+ for (const unsubscribe of this.subscriptions)
209
+ unsubscribe();
210
+ for (const listener of this.closedListeners)
211
+ listener(error);
212
+ this.closedListeners.clear();
213
+ this.onClosed();
214
+ }
215
+ close() {
216
+ if (this.closing)
217
+ return this.closing;
218
+ if (this.closed)
219
+ return Promise.resolve();
220
+ this.closing = (async () => {
221
+ try {
222
+ await this.client.send('Target.closeTarget', { targetId: this.targetId });
223
+ }
224
+ finally {
225
+ this.markClosed();
226
+ }
227
+ })();
228
+ return this.closing;
229
+ }
230
+ bounded(label, timeout, work) {
231
+ if (this.closed)
232
+ return Promise.reject(this.closed);
233
+ validateTimeout(timeout);
234
+ const deadline = Date.now() + timeout;
235
+ let stopped = false;
236
+ return new Promise((resolve, reject) => {
237
+ const finish = (error, value) => {
238
+ if (stopped)
239
+ return;
240
+ stopped = true;
241
+ clearTimeout(timer);
242
+ this.closedListeners.delete(onClose);
243
+ if (error)
244
+ reject(error);
245
+ else
246
+ resolve(value);
247
+ };
248
+ const onClose = (error) => finish(error);
249
+ this.closedListeners.add(onClose);
250
+ const timer = setTimeout(() => finish(new TimeoutError(`${label} timed out after ${timeout}ms`)), timeout);
251
+ work(() => Math.max(1, deadline - Date.now()), () => stopped).then((value) => finish(undefined, value), (error) => finish(error));
252
+ });
253
+ }
254
+ async mainContext(timeout = this.client.timeout) {
255
+ return this.bounded('Main execution context', timeout, async (_remaining, stopped) => {
256
+ while (this.contextId === undefined) {
257
+ if (stopped())
258
+ throw new Error('Context wait cancelled');
259
+ await sleep(10);
260
+ }
261
+ return this.contextId;
262
+ });
263
+ }
264
+ async navigate(method, params, options) {
265
+ if (this.navigating)
266
+ throw new Error('Navigation already in progress');
267
+ const waitUntil = options.waitUntil ?? 'load';
268
+ if (!['load', 'domcontentloaded', 'networkidle0'].includes(waitUntil))
269
+ throw new Error(`Unsupported waitUntil: ${waitUntil}`);
270
+ const timeout = validateTimeout(options.timeout ?? this.client.timeout);
271
+ this.navigating = true;
272
+ const events = [];
273
+ let sameDocument = false;
274
+ let loaderId;
275
+ const off = this.on('Page.lifecycleEvent', (event) => events.push(event));
276
+ const offSame = this.on('Page.navigatedWithinDocument', ({ frameId }) => { if (frameId === this.frameId)
277
+ sameDocument = true; });
278
+ try {
279
+ await this.bounded(`Navigation (${waitUntil})`, timeout, async (remaining, stopped) => {
280
+ const response = await this.send(method, params, remaining());
281
+ if (response.errorText)
282
+ throw new Error(`Navigation failed: ${response.errorText}`);
283
+ loaderId = response.loaderId;
284
+ if (method === 'Page.navigate' && !loaderId)
285
+ sameDocument = true;
286
+ let idleSince;
287
+ while (!stopped()) {
288
+ const name = waitUntil === 'domcontentloaded' ? 'DOMContentLoaded' : 'load';
289
+ // Reload's loader is learned from init, never a previous document's load event.
290
+ if (!loaderId && !sameDocument)
291
+ loaderId = events.find((event) => event.frameId === this.frameId && event.name === 'init')?.loaderId;
292
+ const ready = sameDocument || events.some((event) => event.frameId === this.frameId && event.loaderId === loaderId && event.name === name);
293
+ if (ready && waitUntil !== 'networkidle0')
294
+ return;
295
+ const committed = sameDocument || !!loaderId;
296
+ if (waitUntil === 'networkidle0' && committed && !this.requests.size) {
297
+ idleSince = Math.max(idleSince ?? Date.now(), this.lastNetworkActivity);
298
+ if (Date.now() - idleSince >= 500)
299
+ return;
300
+ }
301
+ else
302
+ idleSince = undefined;
303
+ await sleep(Math.min(20, remaining()));
304
+ }
305
+ });
306
+ }
307
+ finally {
308
+ off();
309
+ offSame();
310
+ this.navigating = false;
311
+ }
312
+ }
313
+ goto(url, options = {}) { return this.navigate('Page.navigate', { url }, options); }
314
+ reload(options = {}) { return this.navigate('Page.reload', {}, options); }
315
+ async setViewport(options) {
316
+ await this.send('Emulation.setDeviceMetricsOverride', { ...options, deviceScaleFactor: options.deviceScaleFactor ?? 1, mobile: options.mobile ?? false });
317
+ }
318
+ async evaluateOnNewDocument(fn, ...args) {
319
+ const source = typeof fn === 'string' ? fn : `(${functionSource(fn)})(...${JSON.stringify(args)})`;
320
+ await this.send('Page.addScriptToEvaluateOnNewDocument', { source });
321
+ }
322
+ async run(fn, args, returnByValue, timeout = this.client.timeout) {
323
+ const deadline = Date.now() + timeout;
324
+ const contextId = await this.mainContext(timeout);
325
+ timeout = Math.max(1, deadline - Date.now());
326
+ const common = { returnByValue, awaitPromise: true, objectGroup: this.objectGroup, timeout };
327
+ const result = typeof fn === 'string'
328
+ ? await this.send('Runtime.evaluate', { expression: fn, contextId, ...common }, timeout)
329
+ : await this.send('Runtime.callFunctionOn', { functionDeclaration: functionSource(fn), executionContextId: contextId, arguments: args.map(argument), ...common }, timeout);
330
+ resultValue(result);
331
+ return result;
332
+ }
333
+ async evaluate(fn, ...args) {
334
+ return resultValue(await this.run(fn, args, true));
335
+ }
336
+ /** @internal */
337
+ async callElement(handle, fn, args) {
338
+ handle.assertUsable(this);
339
+ const result = await this.send('Runtime.callFunctionOn', {
340
+ objectId: handle.objectId, functionDeclaration: `function(...args) { return (${functionSource(fn)})(this, ...args); }`,
341
+ arguments: args.map(argument), returnByValue: true, awaitPromise: true,
342
+ });
343
+ return resultValue(result);
344
+ }
345
+ handle(objectId) {
346
+ const handle = new QaElement(this, objectId, () => this.handles.delete(handle));
347
+ this.handles.add(handle);
348
+ return handle;
349
+ }
350
+ async $(selector) {
351
+ return this.lookup(selector);
352
+ }
353
+ async lookup(selector, timeout = this.client.timeout) {
354
+ const generation = this.generation;
355
+ const { result } = await this.run((selector) => document.querySelector(selector), [selector], false, timeout);
356
+ if (generation !== this.generation)
357
+ throw new Error('Element lookup interrupted by navigation');
358
+ return result.objectId ? this.handle(result.objectId) : null;
359
+ }
360
+ async $$(selector) {
361
+ const generation = this.generation;
362
+ const { result } = await this.run((selector) => Array.from(document.querySelectorAll(selector)), [selector], false);
363
+ if (!result.objectId)
364
+ return [];
365
+ try {
366
+ const { result: properties } = await this.send('Runtime.getProperties', { objectId: result.objectId, ownProperties: true });
367
+ if (generation !== this.generation)
368
+ throw new Error('Element lookup interrupted by navigation');
369
+ return properties.filter((property) => /^\d+$/.test(property.name) && property.value?.objectId).map((property) => this.handle(property.value.objectId));
370
+ }
371
+ finally {
372
+ await this.send('Runtime.releaseObject', { objectId: result.objectId }).catch(() => { });
373
+ }
374
+ }
375
+ async $eval(selector, fn, ...args) {
376
+ const handle = await this.requireElement(selector);
377
+ try {
378
+ return await handle.evaluate(fn, ...args);
379
+ }
380
+ finally {
381
+ await handle.dispose();
382
+ }
383
+ }
384
+ async $$eval(selector, fn, ...args) {
385
+ const source = `function(selector, ...args) { return (${functionSource(fn)})(Array.from(document.querySelectorAll(selector)), ...args); }`;
386
+ return resultValue(await this.runSourceFunction(source, [selector, ...args]));
387
+ }
388
+ async runSourceFunction(source, args, timeout = this.client.timeout) {
389
+ const deadline = Date.now() + timeout;
390
+ const executionContextId = await this.mainContext(timeout);
391
+ timeout = Math.max(1, deadline - Date.now());
392
+ const result = await this.send('Runtime.callFunctionOn', { functionDeclaration: source, executionContextId, arguments: args.map(argument), returnByValue: true, awaitPromise: true, timeout }, timeout);
393
+ resultValue(result);
394
+ return result;
395
+ }
396
+ async waitForFunction(fn, options = {}, ...args) {
397
+ const polling = options.polling ?? 'raf';
398
+ if (typeof polling === 'number' && (!(polling > 0) || !Number.isFinite(polling)))
399
+ throw new Error('Polling interval must be positive');
400
+ const source = typeof fn === 'string' ? `function() { return (${fn}); }` : functionSource(fn);
401
+ await this.bounded(`Predicate ${source}`, options.timeout ?? this.client.timeout, async (remaining, stopped) => {
402
+ while (!stopped()) {
403
+ try {
404
+ if (resultValue(await this.runSourceFunction(source, args, remaining())))
405
+ return;
406
+ if (polling === 'raf') {
407
+ // A browser animation frame, bounded fallback for background targets.
408
+ await this.runSourceFunction('function() { return new Promise(resolve => { const timer = setTimeout(resolve, 100); requestAnimationFrame(() => { clearTimeout(timer); resolve(); }); }); }', [], remaining());
409
+ }
410
+ else
411
+ await sleep(Math.min(polling, remaining()));
412
+ }
413
+ catch (error) {
414
+ if (error instanceof TimeoutError) {
415
+ await sleep(remaining());
416
+ continue;
417
+ }
418
+ if (!/context.*(destroyed|not found)|Cannot find context/i.test(String(error)))
419
+ throw error;
420
+ await sleep(Math.min(10, remaining()));
421
+ }
422
+ }
423
+ });
424
+ }
425
+ async waitForSelector(selector, options = {}) {
426
+ if (options.visible && options.hidden)
427
+ throw new Error('visible & hidden cannot both be true');
428
+ try {
429
+ return await this.bounded(`Selector ${selector}`, options.timeout ?? this.client.timeout, async (remaining) => {
430
+ await this.waitForFunction((selector, visible, hidden) => {
431
+ const element = document.querySelector(selector);
432
+ if (!element)
433
+ return hidden;
434
+ const style = getComputedStyle(element), box = element.getBoundingClientRect();
435
+ const shown = style.visibility !== 'hidden' && style.visibility !== 'collapse' && box.width > 0 && box.height > 0;
436
+ return hidden ? !shown : visible ? shown : true;
437
+ }, { timeout: remaining() }, selector, !!options.visible, !!options.hidden);
438
+ return options.hidden ? null : this.lookup(selector, remaining());
439
+ });
440
+ }
441
+ catch (error) {
442
+ if (error instanceof TimeoutError)
443
+ throw new TimeoutError(`Selector ${selector} timed out after ${options.timeout ?? this.client.timeout}ms`);
444
+ throw error;
445
+ }
446
+ }
447
+ async requireElement(selector) {
448
+ const handle = await this.$(selector);
449
+ if (!handle)
450
+ throw new Error(`No element matches selector: ${selector}`);
451
+ return handle;
452
+ }
453
+ async withElement(target, work) {
454
+ const handle = typeof target === 'string' ? await this.requireElement(target) : target;
455
+ handle.assertUsable(this);
456
+ try {
457
+ return await work(handle);
458
+ }
459
+ finally {
460
+ if (typeof target === 'string')
461
+ await handle.dispose();
462
+ }
463
+ }
464
+ async point(handle) {
465
+ await handle.evaluate((element) => { if (!element.isConnected)
466
+ throw new Error('Element detached'); element.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' }); });
467
+ const box = await handle.boundingBox();
468
+ if (!box || !box.width || !box.height)
469
+ throw new Error('Element has no clickable box');
470
+ return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
471
+ }
472
+ async click(target, options = {}) {
473
+ await this.withElement(target, async (handle) => {
474
+ const point = await this.point(handle), button = options.button ?? 'left', count = options.clickCount ?? 1;
475
+ await this.send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...point });
476
+ for (let clickCount = 1; clickCount <= count; clickCount++) {
477
+ await this.send('Input.dispatchMouseEvent', { type: 'mousePressed', ...point, button, buttons: button === 'left' ? 1 : button === 'right' ? 2 : 4, clickCount });
478
+ if (options.delay)
479
+ await sleep(options.delay);
480
+ await this.send('Input.dispatchMouseEvent', { type: 'mouseReleased', ...point, button, buttons: 0, clickCount });
481
+ }
482
+ });
483
+ }
484
+ async type(selector, text, options = {}) {
485
+ await this.withElement(selector, (handle) => this.typeElement(handle, text, options));
486
+ }
487
+ /** @internal */
488
+ async typeElement(handle, text, options = {}) {
489
+ await handle.focus();
490
+ if (!options.delay) {
491
+ await this.send('Input.insertText', { text });
492
+ return;
493
+ }
494
+ for (const char of text) {
495
+ await this.send('Input.insertText', { text: char });
496
+ await sleep(options.delay);
497
+ }
498
+ }
499
+ async hover(target) {
500
+ await this.withElement(target, async (handle) => { await this.send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...await this.point(handle) }); });
501
+ }
502
+ async focus(target) { await this.withElement(target, (handle) => handle.focus()); }
503
+ async select(selector, ...values) {
504
+ return this.$eval(selector, (element, values) => {
505
+ if (!(element instanceof HTMLSelectElement))
506
+ throw new Error('Element is not a select');
507
+ element.selectedIndex = -1;
508
+ const selected = [];
509
+ for (const option of Array.from(element.options)) {
510
+ if (!element.multiple && selected.length)
511
+ break;
512
+ option.selected = values.includes(option.value);
513
+ if (option.selected)
514
+ selected.push(option.value);
515
+ }
516
+ element.dispatchEvent(new Event('input', { bubbles: true }));
517
+ element.dispatchEvent(new Event('change', { bubbles: true }));
518
+ return selected;
519
+ }, values);
520
+ }
521
+ async screenshot(options = {}) {
522
+ if (options.type && options.type !== 'png' || options.fullPage || options.captureBeyondViewport)
523
+ throw new Error('Only viewport PNG screenshots are supported');
524
+ const { data } = await this.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false, fromSurface: true });
525
+ const buffer = Buffer.from(data, 'base64');
526
+ if (options.path)
527
+ await writeFile(options.path, buffer);
528
+ return buffer;
529
+ }
530
+ }
531
+ /** Remote DOM reference; dispose explicitly or let navigation release its object group. */
532
+ export class QaElement {
533
+ page;
534
+ objectId;
535
+ onDisposed;
536
+ disposed = false;
537
+ /** @internal */
538
+ constructor(page, objectId, onDisposed) {
539
+ this.page = page;
540
+ this.objectId = objectId;
541
+ this.onDisposed = onDisposed;
542
+ }
543
+ /** @internal */
544
+ assertUsable(page) {
545
+ if (this.disposed)
546
+ throw new Error('Element handle disposed or released by navigation');
547
+ if (page !== this.page)
548
+ throw new Error('Element belongs to another page');
549
+ }
550
+ /** @internal */
551
+ invalidate() { this.disposed = true; this.onDisposed(); }
552
+ async dispose() {
553
+ if (this.disposed)
554
+ return;
555
+ this.invalidate();
556
+ await this.page.send('Runtime.releaseObject', { objectId: this.objectId });
557
+ }
558
+ evaluate(fn, ...args) {
559
+ return this.page.callElement(this, fn, args);
560
+ }
561
+ click(options = {}) { return this.page.click(this, options); }
562
+ type(text, options = {}) { return this.page.typeElement(this, text, options); }
563
+ hover() { return this.page.hover(this); }
564
+ async focus() { await this.evaluate((element) => element.focus()); }
565
+ boundingBox() {
566
+ return this.evaluate((element) => {
567
+ if (!element.isConnected || !element.getClientRects().length || getComputedStyle(element).visibility === 'hidden')
568
+ return null;
569
+ const { x, y, width, height } = element.getBoundingClientRect();
570
+ return { x, y, width, height };
571
+ });
572
+ }
573
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/qa",
3
- "version": "0.3.2",
3
+ "version": "0.4.1",
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
  }