@rightkit/qa 0.3.2 → 0.4.0

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
@@ -22,3 +22,52 @@ Web/CDP harnesses launch Chrome for Testing, never regular Google Chrome: on mac
22
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
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
24
  - `codeSignCloneEntries()` lists the macOS clone entries so tests can assert that none appear.
25
+
26
+ ## Raw CDP page driver (Node 26)
27
+
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.
29
+
30
+ ```ts
31
+ import { launchChromeForTesting, QaBrowserSession } from '@rightkit/qa';
32
+
33
+ const chrome = await launchChromeForTesting();
34
+ const browser = await QaBrowserSession.connect(chrome.wsEndpoint);
35
+ try {
36
+ const page = await browser.newPage();
37
+ 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 });
41
+ await button?.click();
42
+ await button?.dispose();
43
+ await page.screenshot({ path: 'viewport.png', type: 'png', captureBeyondViewport: false });
44
+ } finally {
45
+ try { await browser.close(); } finally { await chrome.close(); }
46
+ }
47
+ ```
48
+
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.
50
+
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.
52
+
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.
54
+
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.
56
+
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.
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.0",
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",