@rightkit/qa 0.3.1 → 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
@@ -6,6 +6,14 @@ Shared QA primitives for Right Suite desktop apps. The package owns run locking,
6
6
 
7
7
  This package is intentionally private until the Windows and macOS phase gate passes. App repositories must never consume it through a Git, path, `file:`, `link:`, or `workspace:` dependency; adoption happens only after a registry version is published.
8
8
 
9
+ ## Run storage
10
+
11
+ Run roots, harness evidence, captures & summaries remain under `<managed>/runs/<app>/<run-id>`. App homes, profiles, service/port files, live logs & app-provided output paths use an owned internal system-temp companion recorded as `appRoot` in `.rightkit-run.json`. On macOS, companions always use `/private/tmp/rightkit-qa/<app-prefix>-<random>` with short names so launchd can open app files & control socket paths fit `sockaddr_un`.
12
+
13
+ Finish copies app logs to managed `evidence/`, app-written evidence to `evidence/app/` & app captures to `captures/` before releasing ownership. Disposal removes temp companions; failed copies or cleanup preserve ownership metadata for recovery. Retention & orphan pruning validate companion bindings & protect live runs.
14
+
15
+ Workspace env sets `RIGHTKIT_RUN_ROOT`, `HOME` & `USERPROFILE` to `home`; `RIGHTKIT_SUITE_ROOT` to `home/suite`; data/profile keys to `home/data`; `XDG_CACHE_HOME` to `home/cache`; `TMPDIR`, `TMP` & `TEMP` to `home/tmp`. `RIGHTKIT_QA_EVIDENCE_DIR` & `RIGHTKIT_QA_CAPTURE_DIR` use `home/evidence` & `home/captures`. The JS package supplies workspace env; native UI bundle staging & unique per-launch suite/service identity belong to Rust `control::launch`.
16
+
9
17
  ## Chrome for Testing (`@rightkit/qa/chrome`)
10
18
 
11
19
  Web/CDP harnesses launch Chrome for Testing, never regular Google Chrome: on macOS each regular Chrome instance clones its app bundle under `<DARWIN_USER_TEMP_DIR>/../X/com.google.Chrome.code_sign_clone/`, and a killed instance leaks that clone.
@@ -14,3 +22,52 @@ Web/CDP harnesses launch Chrome for Testing, never regular Google Chrome: on mac
14
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.
15
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.
16
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/dist/storage.d.ts CHANGED
@@ -10,6 +10,8 @@ export interface RunMarker {
10
10
  ownerPid: number;
11
11
  leaseUntil: number;
12
12
  completedAt?: number;
13
+ /** Owned internal-temp companion; validated against its binding before cleanup. */
14
+ appRoot?: string;
13
15
  }
14
16
  export interface ManagedRun {
15
17
  root: string;
@@ -48,3 +50,7 @@ export declare function createManagedRun(options: {
48
50
  }): ManagedRun;
49
51
  export declare function finishManagedRun(run: ManagedRun | string): RunMarker | undefined;
50
52
  export declare function pruneManagedRuns(managedRoot: string, app: string, policy?: RetentionPolicy): RetentionReport;
53
+ /** Short internal paths keep launchd files accessible & service sockets within sockaddr_un. */
54
+ export declare function appTempDir(): string;
55
+ export declare function createManagedAppRoot(run: ManagedRun): string;
56
+ export declare function removeManagedAppRoot(root: string, marker: RunMarker): void;
package/dist/storage.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs';
2
+ import os from 'node:os';
2
3
  import path from 'node:path';
3
4
  export const RUN_MARKER = '.rightkit-run.json';
4
5
  export const DEFAULT_RETENTION_BYTES = 20_000_000_000;
@@ -10,9 +11,12 @@ export function resolveManagedRoot(explicit) {
10
11
  return path.resolve(explicit);
11
12
  if (process.env.RIGHTKIT_MANAGED_ROOT?.trim())
12
13
  return path.resolve(process.env.RIGHTKIT_MANAGED_ROOT);
13
- if (process.platform === 'win32')
14
- return path.resolve('D:\\.rightkit-managed');
15
- return '/Volumes/D/.rightkit-managed';
14
+ const [workstation, volume] = process.platform === 'win32' ? ['D:\\.rightkit-managed', 'D:\\'] : ['/Volumes/D/.rightkit-managed', '/Volumes/D'];
15
+ if (fs.existsSync(volume))
16
+ return path.resolve(workstation);
17
+ // Hosted CI and other machines: runner temp, then the system temp dir.
18
+ const runnerTemp = process.env.RUNNER_TEMP?.trim();
19
+ return path.join(runnerTemp && fs.existsSync(runnerTemp) ? runnerTemp : os.tmpdir(), 'rightkit-managed');
16
20
  }
17
21
  export function safeKey(value, name) {
18
22
  const v = String(value ?? '').trim();
@@ -69,6 +73,14 @@ export function finishManagedRun(run) {
69
73
  const marker = readMarker(markerPath);
70
74
  if (!marker)
71
75
  return undefined;
76
+ const home = ownedAppRoot(root, marker);
77
+ if (home) {
78
+ copyAppTree(path.join(home, 'evidence'), path.join(root, 'evidence', 'app'));
79
+ copyAppTree(path.join(home, 'captures'), path.join(root, 'captures'));
80
+ for (const name of fs.readdirSync(home))
81
+ if (name.endsWith('.log'))
82
+ copyAppTree(path.join(home, name), path.join(root, 'evidence', name));
83
+ }
72
84
  const finished = { ...marker, ownerPid: 0, leaseUntil: 0, completedAt: Date.now() };
73
85
  writeMarker(root, finished);
74
86
  return finished;
@@ -149,6 +161,7 @@ function pruneUnlocked(appRoot, app, policy, report) {
149
161
  if (!safeDirectory(item.root))
150
162
  continue;
151
163
  try {
164
+ removeManagedAppRoot(item.root, item.marker);
152
165
  fs.rmSync(item.root, { recursive: true, force: false });
153
166
  report.removed.push(item.root);
154
167
  removed.add(item.marker.runId);
@@ -226,6 +239,8 @@ function readMarker(file) {
226
239
  return undefined;
227
240
  if (x.completedAt !== undefined && (typeof x.completedAt !== 'number' || !Number.isFinite(x.completedAt) || x.completedAt < 0))
228
241
  return undefined;
242
+ if (x.appRoot !== undefined && typeof x.appRoot !== 'string')
243
+ return undefined;
229
244
  return x;
230
245
  }
231
246
  catch {
@@ -313,3 +328,53 @@ function describeError(error) {
313
328
  }
314
329
  function sleepSync(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
315
330
  function cryptoRandomId() { return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`; }
331
+ /** Short internal paths keep launchd files accessible & service sockets within sockaddr_un. */
332
+ export function appTempDir() {
333
+ return process.platform === 'darwin' ? '/private/tmp' : fs.realpathSync(os.tmpdir());
334
+ }
335
+ export function createManagedAppRoot(run) {
336
+ const parent = path.join(appTempDir(), 'rightkit-qa');
337
+ ensureSafeDirectory(parent);
338
+ const home = fs.mkdtempSync(path.join(parent, `${run.app.slice(0, 16)}-`));
339
+ fs.writeFileSync(path.join(home, '.rightkit-app.json'), JSON.stringify({ runRoot: fs.realpathSync(run.root), app: run.app, runId: run.runId }), { flag: 'wx' });
340
+ run.marker.appRoot = home;
341
+ writeMarker(run.root, run.marker);
342
+ return home;
343
+ }
344
+ function ownedAppRoot(root, marker) {
345
+ const home = marker.appRoot;
346
+ if (!home || !fs.existsSync(home))
347
+ return undefined;
348
+ const parent = path.dirname(home);
349
+ const temp = path.dirname(parent);
350
+ const allowedTemp = temp === appTempDir() || process.platform === 'darwin' && (temp === '/private/tmp' || isInside('/private/var/folders', temp) && path.basename(temp) === 'T');
351
+ if (path.basename(parent) !== 'rightkit-qa' || !allowedTemp || fs.realpathSync(home) !== home || !safeDirectory(home))
352
+ throw new Error('invalid QA app temp root');
353
+ const binding = JSON.parse(fs.readFileSync(path.join(home, '.rightkit-app.json'), 'utf8'));
354
+ if (binding.runRoot !== fs.realpathSync(root) || binding.app !== marker.app || binding.runId !== marker.runId)
355
+ throw new Error('QA app temp ownership mismatch');
356
+ return home;
357
+ }
358
+ export function removeManagedAppRoot(root, marker) {
359
+ const home = ownedAppRoot(root, marker);
360
+ if (home)
361
+ fs.rmSync(home, { recursive: true, force: false });
362
+ }
363
+ function copyAppTree(source, target) {
364
+ const meta = fs.lstatSync(source);
365
+ if (meta.isSymbolicLink())
366
+ throw new Error('symlink in QA app evidence');
367
+ if (fs.existsSync(target) && fs.lstatSync(target).isSymbolicLink())
368
+ throw new Error('symlink in managed QA evidence');
369
+ if (meta.isDirectory()) {
370
+ ensureSafeDirectory(target);
371
+ for (const name of fs.readdirSync(source))
372
+ copyAppTree(path.join(source, name), path.join(target, name));
373
+ }
374
+ else if (meta.isFile()) {
375
+ ensureSafeDirectory(path.dirname(target));
376
+ fs.copyFileSync(source, target);
377
+ }
378
+ else
379
+ throw new Error('unsupported QA app evidence file');
380
+ }
@@ -4,6 +4,8 @@ export interface QaWorkspace {
4
4
  appKey: string;
5
5
  managedRoot: string;
6
6
  root: string;
7
+ /** Owned app home on internal system temp storage. */
8
+ home: string;
7
9
  /** Isolated user-data directory; pass to the app so it takes its own lock. */
8
10
  dataDir: string;
9
11
  /** Somewhere for screenshots, logs, and other recorded evidence. */
package/dist/workspace.js CHANGED
@@ -17,35 +17,49 @@
17
17
  import crypto from 'node:crypto';
18
18
  import fs from 'node:fs';
19
19
  import path from 'node:path';
20
- import { createManagedRun, finishManagedRun, pruneManagedRuns, resolveManagedRoot, } from './storage.js';
20
+ import { createManagedRun, createManagedAppRoot, finishManagedRun, pruneManagedRuns, resolveManagedRoot, } from './storage.js';
21
21
  export function createQaWorkspace(options) {
22
22
  const appKey = requireText(options.appKey, 'appKey');
23
23
  const managed = createManagedRun({ managedRoot: options.root ? requireText(options.root, 'root') : resolveManagedRoot(), app: appKey, runId: options.runId ?? crypto.randomUUID(), leaseMs: options.leaseMs, retention: options.retention });
24
24
  const runId = managed.runId;
25
25
  const root = managed.root;
26
- const dataDir = path.join(root, 'data');
26
+ const home = createManagedAppRoot(managed);
27
+ const dataDir = path.join(home, 'data');
27
28
  const evidenceDir = path.join(root, 'evidence');
28
29
  const captureDir = path.join(root, 'captures');
29
- const tmpDir = path.join(root, 'tmp');
30
+ const tmpDir = path.join(home, 'tmp');
31
+ const cacheDir = path.join(home, 'cache');
32
+ const appEvidenceDir = path.join(home, 'evidence');
33
+ const appCaptureDir = path.join(home, 'captures');
34
+ fs.mkdirSync(appEvidenceDir, { recursive: true });
35
+ fs.mkdirSync(appCaptureDir, { recursive: true });
30
36
  fs.mkdirSync(dataDir, { recursive: true });
31
37
  fs.mkdirSync(evidenceDir, { recursive: true });
32
38
  fs.mkdirSync(captureDir, { recursive: true });
33
39
  fs.mkdirSync(tmpDir, { recursive: true });
40
+ fs.mkdirSync(cacheDir, { recursive: true });
34
41
  return {
35
42
  runId,
36
43
  appKey,
37
44
  managedRoot: path.resolve(options.root ? requireText(options.root, 'root') : resolveManagedRoot()),
38
45
  root,
46
+ home,
39
47
  dataDir,
40
48
  evidenceDir,
41
49
  captureDir,
42
50
  env: {
43
51
  RIGHTKIT_QA_NATIVE: '1',
44
52
  RIGHTKIT_QA_RUN_ID: runId,
45
- RIGHTKIT_RUN_ROOT: root,
53
+ RIGHTKIT_RUN_ROOT: home,
54
+ RIGHTKIT_SUITE_ROOT: path.join(home, 'suite'),
55
+ HOME: home,
56
+ USERPROFILE: home,
57
+ APPDATA: dataDir,
58
+ LOCALAPPDATA: dataDir,
59
+ XDG_CACHE_HOME: cacheDir,
46
60
  RIGHTKIT_QA_DATA_DIR: dataDir,
47
- RIGHTKIT_QA_EVIDENCE_DIR: evidenceDir,
48
- RIGHTKIT_QA_CAPTURE_DIR: captureDir,
61
+ RIGHTKIT_QA_EVIDENCE_DIR: appEvidenceDir,
62
+ RIGHTKIT_QA_CAPTURE_DIR: appCaptureDir,
49
63
  TMPDIR: tmpDir,
50
64
  TMP: tmpDir,
51
65
  TEMP: tmpDir,
@@ -68,13 +82,19 @@ export function disposeQaWorkspace(workspace, options = {}) {
68
82
  const attempts = Math.max(1, options.attempts ?? 5);
69
83
  const retryDelayMs = options.retryDelayMs ?? 200;
70
84
  const sleep = options.sleep ?? sleepSync;
71
- const targets = options.keepEvidence ? [workspace.dataDir] : [workspace.root];
85
+ const targets = options.keepEvidence ? [workspace.home] : [workspace.home, workspace.root];
72
86
  const report = { removed: [], retained: [] };
73
- finishManagedRun(workspace.root);
87
+ try {
88
+ finishManagedRun(workspace.root);
89
+ }
90
+ catch (error) {
91
+ report.retained.push({ path: workspace.root, reason: describeError(error) });
92
+ return report;
93
+ }
74
94
  for (const target of targets) {
75
- // Only ever remove inside the workspace root we created.
95
+ // Only remove the managed run or its registered temp companion.
76
96
  const resolved = path.resolve(target);
77
- if (resolved !== path.resolve(workspace.root) && !isInside(workspace.root, resolved)) {
97
+ if (resolved !== path.resolve(workspace.root) && resolved !== path.resolve(workspace.home)) {
78
98
  report.retained.push({ path: resolved, reason: 'outside the QA workspace root' });
79
99
  continue;
80
100
  }
@@ -97,8 +117,14 @@ export function disposeQaWorkspace(workspace, options = {}) {
97
117
  }
98
118
  if (lastError === undefined && !fs.existsSync(resolved))
99
119
  report.removed.push(resolved);
100
- else
120
+ else {
101
121
  report.retained.push({ path: resolved, reason: describeError(lastError) });
122
+ if (resolved === path.resolve(workspace.home)) {
123
+ if (!options.keepEvidence)
124
+ report.retained.push({ path: workspace.root, reason: 'app temp cleanup pending' });
125
+ break;
126
+ }
127
+ }
102
128
  }
103
129
  return report;
104
130
  }
@@ -130,10 +156,6 @@ export function pruneAbandonedWorkspaces(root, { olderThanMs, now = Date.now(),
130
156
  }
131
157
  return report;
132
158
  }
133
- function isInside(root, candidate) {
134
- const relative = path.relative(path.resolve(root), path.resolve(candidate));
135
- return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
136
- }
137
159
  function describeError(error) {
138
160
  if (error === undefined)
139
161
  return 'still present after removal reported success';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/qa",
3
- "version": "0.3.1",
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",