@rightkit/qa 0.4.4 → 0.4.6

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
@@ -93,7 +93,15 @@ try {
93
93
  - `page.fill(target, value, { replace?, verify? })`. The default inserts at the caret, as `type()` does. `replace: true` selects the whole field first; an empty value then deletes it. `verify: true` (requires `replace`) reads the value back once and throws `FillMismatchError` on a difference. The error gives lengths, not values.
94
94
  - `page.mouse.wheel({ deltaX?, deltaY? })` sends one `mouseWheel` event at the tracked pointer position. The pointer does not move.
95
95
  - `page.setUserAgent(ua, { platform?, acceptLanguage? })` overrides the user agent for the page and its later navigations. Each call replaces the whole override. `page.resetUserAgent()` restores the `navigator.userAgent` captured before the first override.
96
- - `waitForFunction(fn, { failureArtifacts: { directory, label? } })` and `waitForSelector(...)` write a screenshot and a JSON state dump (url, title, predicate text, timeout, console entries) when they time out. Paths are on `error.artifacts` (`json`, `screenshot?`). `directory` must be absolute and must not lie inside `RIGHTKIT_RUN_ROOT`: `disposeQaWorkspace` deletes that tree. A failed write is appended to the error message; the timeout still throws.
96
+ - `waitForFunction(fn, { failureArtifacts: { directory, label?, extra?, allowInsideRunRoot? } })` and `waitForSelector(...)` write a screenshot and a JSON state dump on timeout: url, title, bounded page text (`pageText`), the focused element's descriptor without its value (`activeElement`), console entries, and `extra()` if given (`extra`, or `extraError` when it throws, never settles or cannot serialise). Paths are on `error.artifacts` (`json`, `screenshot?`). `directory` must be absolute and must not lie inside `RIGHTKIT_RUN_ROOT`, which `disposeQaWorkspace` deletes; `allowInsideRunRoot: true` opts in, and those files then go with the workspace, so copy them out before dispose. A failed write is appended to the error message; the timeout still throws.
97
+
98
+ - `page.send(method, params?, timeout?)` is a page-scoped raw CDP escape hatch: unsupported surface, with Chrome's names, params and results. Prefer the typed methods. `page.setFocusEmulation(enabled)` sends `Emulation.setFocusEmulationEnabled`.
99
+ - Navigation and reloads: `evaluate`, `waitForFunction` and `waitForSelector` re-resolve the main-frame context when Chrome reports it missing, and retry within the caller's timeout.
100
+ - `onPoll: () => void | Promise<void>` on `waitForFunction` and `waitForSelector` runs once per poll, before the predicate. A throw rejects the wait with that error.
101
+ - `click(target, { scroll: { inline: "nearest" } })` and `hover(target, { scroll })` choose horizontal alignment; the default is `center`, as before.
102
+ - `fill(target, value, { replace: true, verify: { timeoutMs } })` polls the readback until it matches or the window ends; `verify: true` reads once.
103
+
104
+ `spawnOwnedProcess(command, args, options)` starts a process in its own group (POSIX) and records it. `stopOwnedProcess(handle)` stops only a handle that `spawnOwnedProcess` returned, signalling the whole group. Bare pids and other objects are refused. `ownedProcessReceipt()` lists launched processes still running.
97
105
 
98
106
  `freePort()` returns a free `127.0.0.1` port by binding port 0 and releasing it. Another process can take the port before use.
99
107
 
@@ -135,5 +143,5 @@ This package launches no native app and no WebView2 window; it never replaces th
135
143
 
136
144
  ## Known issues
137
145
 
138
- - `@rightkit/qa-chrome` is pinned exactly to `0.1.3`. A qa-chrome release therefore needs a `@rightkit/qa` republish to reach consumers (npm audit 2026-10-08, qa finding).
146
+ - `@rightkit/qa-chrome` is pinned exactly to `0.1.5`. A qa-chrome release therefore needs a `@rightkit/qa` republish to reach consumers (npm audit 2026-10-08, qa finding).
139
147
  - Three consumers pin three different versions of `@rightkit/qa-chrome` as dev dependencies (npm audit 2026-10-08).
package/dist/page.d.ts CHANGED
@@ -16,34 +16,67 @@ export interface ViewportOptions {
16
16
  }
17
17
  /** Where a timed-out wait writes its screenshot and JSON state dump. */
18
18
  export interface FailureArtifactOptions {
19
- /** Absolute directory, created if missing. Must lie outside RIGHTKIT_RUN_ROOT, which disposeQaWorkspace deletes. */
19
+ /**
20
+ * Absolute directory, created if missing. Must lie outside RIGHTKIT_RUN_ROOT, which
21
+ * disposeQaWorkspace deletes, unless allowInsideRunRoot is set.
22
+ */
20
23
  directory: string;
21
24
  /** Short name used in the file names and the dump. Default 'wait'. */
22
25
  label?: string;
26
+ /**
27
+ * Opt-in: the directory may lie inside RIGHTKIT_RUN_ROOT. These files are deleted with the
28
+ * workspace by disposeQaWorkspace; copy them out before dispose. Default false.
29
+ */
30
+ allowInsideRunRoot?: boolean;
31
+ /**
32
+ * Host state to embed in the JSON dump under `extra` (bounded to 64 KiB, cut off after 1 s). A
33
+ * throw, a timeout or an unserialisable result is recorded under `extraError` and never masks the
34
+ * timeout.
35
+ */
36
+ extra?: () => unknown | Promise<unknown>;
23
37
  }
38
+ /** Called once per poll, before the predicate. A throw rejects the wait with that error. */
39
+ export type PollHook = () => void | Promise<void>;
24
40
  export interface WaitOptions {
25
41
  timeout?: number;
26
42
  polling?: 'raf' | number;
27
43
  failureArtifacts?: FailureArtifactOptions;
44
+ onPoll?: PollHook;
28
45
  }
29
46
  export interface SelectorWaitOptions {
30
47
  visible?: boolean;
31
48
  hidden?: boolean;
32
49
  timeout?: number;
33
50
  failureArtifacts?: FailureArtifactOptions;
51
+ onPoll?: PollHook;
52
+ }
53
+ /** How the target is brought into view before the pointer moves. Vertical alignment is always centre. */
54
+ export interface ScrollOptions {
55
+ /** Default 'center', as before 0.4.5. 'nearest' scrolls the least needed to show the target at the edge. */
56
+ inline?: 'center' | 'nearest';
57
+ }
58
+ export interface HoverOptions {
59
+ scroll?: ScrollOptions;
34
60
  }
35
61
  export interface ClickOptions {
36
62
  button?: 'left' | 'right' | 'middle';
37
63
  clickCount?: number;
38
64
  delay?: number;
65
+ scroll?: ScrollOptions;
39
66
  /** Default false. When true, after the pointer moves and before pressing, checks that the element at the click point is the target or its descendant; otherwise throws ElementOccludedError. */
40
67
  checkOccluded?: boolean;
41
68
  }
42
69
  export interface FillOptions {
43
70
  /** Default false: insert at the caret, exactly as type() does. True selects the whole field first, so the value replaces its content; an empty value then deletes the selection. */
44
71
  replace?: boolean;
45
- /** Default false. Reads the value back after insertion and throws FillMismatchError on a difference. Requires replace. */
46
- verify?: boolean;
72
+ /**
73
+ * Default false: read the value back once after insertion and throw FillMismatchError on a difference.
74
+ * `{ timeoutMs }` polls the readback every 25 ms until it matches or the window ends, for fields that
75
+ * settle late (a framework rewrites the value after an input event). Requires replace.
76
+ */
77
+ verify?: boolean | {
78
+ timeoutMs: number;
79
+ };
47
80
  }
48
81
  /** One console call or uncaught exception of a page. `line` is 1-based. */
49
82
  export interface ConsoleEntry {
@@ -183,8 +216,17 @@ export declare class QaPage {
183
216
  on(method: string, listener: (params: CdpParams) => void): () => void;
184
217
  /** @internal */
185
218
  initialize(): Promise<void>;
186
- /** @internal */
219
+ /**
220
+ * UNSUPPORTED escape hatch: sends one page-scoped CDP command on this page's session and resolves
221
+ * with its raw result. Method names, params and results follow Chrome's protocol, not this package,
222
+ * and may change with the browser version. Prefer the typed QaPage methods where they exist.
223
+ */
187
224
  send(method: string, params?: CdpParams, timeout?: number): Promise<CdpParams>;
225
+ /**
226
+ * Emulation.setFocusEmulationEnabled: the page behaves as focused while its window is not
227
+ * (background or headless). Persists for the page until turned off.
228
+ */
229
+ setFocusEmulation(enabled: boolean): Promise<void>;
188
230
  private invalidateHandles;
189
231
  /** @internal */
190
232
  markClosed(error?: Error): void;
@@ -227,22 +269,29 @@ export declare class QaPage {
227
269
  resetUserAgent(): Promise<void>;
228
270
  /**
229
271
  * Types a value into a field. Default: insert at the caret, exactly as type() does. With replace the
230
- * whole content is selected first and replaced. With verify (requires replace) the value is read back
231
- * once after insertion and FillMismatchError is thrown on a difference.
272
+ * whole content is selected first and replaced. With verify the value is read back and
273
+ * FillMismatchError is thrown on a difference: once by default, or polled until `timeoutMs` with
274
+ * `verify: { timeoutMs }`. Verify requires replace.
232
275
  */
233
276
  fill(target: string | QaElement, value: string, options?: FillOptions): Promise<void>;
234
277
  private selectAll;
235
278
  /** Throws ElementOccludedError naming the element on top unless the target itself or a composed descendant is hit. */
236
279
  private assertUnoccluded;
280
+ /** URL, title, bounded page text and the active element. Each part is null when unreadable; never throws. */
281
+ private failureState;
237
282
  /** Writes a PNG and a JSON state dump for a timed-out wait into the caller directory. */
238
283
  private captureFailure;
239
- /** Attaches artifact paths to a timeout error. A write failure is appended to its message; the timeout still throws. */
284
+ /**
285
+ * Writes the failure artifacts for a timed-out wait or a failed onPoll hook. The paths are attached as
286
+ * `artifacts` only on a TimeoutError; for other errors the files are written and the paths are not attached.
287
+ * A write failure is appended to a TimeoutError's message; the original error still throws.
288
+ */
240
289
  private attachFailureArtifacts;
241
290
  click(target: string | QaElement, options?: ClickOptions): Promise<void>;
242
291
  type(selector: string, text: string, options?: TypeOptions): Promise<void>;
243
292
  /** @internal */
244
293
  typeElement(handle: QaElement, text: string, options?: TypeOptions): Promise<void>;
245
- hover(target: string | QaElement): Promise<void>;
294
+ hover(target: string | QaElement, options?: HoverOptions): Promise<void>;
246
295
  focus(target: string | QaElement): Promise<void>;
247
296
  select(selector: string, ...values: string[]): Promise<string[]>;
248
297
  screenshot(options?: ScreenshotOptions): Promise<Buffer>;
@@ -263,7 +312,7 @@ export declare class QaElement {
263
312
  evaluate<Args extends unknown[], Result>(fn: (element: HTMLElement, ...args: Args) => Result, ...args: Args): Promise<Awaited<Result>>;
264
313
  click(options?: ClickOptions): Promise<void>;
265
314
  type(text: string, options?: TypeOptions): Promise<void>;
266
- hover(): Promise<void>;
315
+ hover(options?: HoverOptions): Promise<void>;
267
316
  focus(): Promise<void>;
268
317
  boundingBox(): Promise<BoundingBox | null>;
269
318
  }
package/dist/page.js CHANGED
@@ -31,6 +31,31 @@ export class FillMismatchError extends Error {
31
31
  this.actualLength = actualLength;
32
32
  }
33
33
  }
34
+ /** CDP's answer for a context id that no longer exists. The command did not run, so retrying is safe. */
35
+ function isMissingContext(error) {
36
+ return error instanceof Error && /Cannot find context with specified id/.test(error.message);
37
+ }
38
+ function scrollInline(scroll) {
39
+ const inline = scroll?.inline ?? 'center';
40
+ if (inline !== 'center' && inline !== 'nearest')
41
+ throw new Error('scroll.inline must be center or nearest');
42
+ return inline;
43
+ }
44
+ /** Wraps an onPoll hook so a throw is recorded on `state` (failure artifacts then apply to it). */
45
+ function pollHook(onPoll, state) {
46
+ if (!onPoll)
47
+ return undefined;
48
+ return async () => {
49
+ try {
50
+ await onPoll();
51
+ }
52
+ catch (error) {
53
+ state.failed = true;
54
+ state.error = error;
55
+ throw error;
56
+ }
57
+ };
58
+ }
34
59
  /** Text of one console argument, from its CDP RemoteObject. */
35
60
  function remoteText(object) {
36
61
  if ('value' in object)
@@ -71,22 +96,53 @@ function exceptionEntry(event) {
71
96
  function failureTarget(options) {
72
97
  if (!options)
73
98
  return undefined;
74
- const { directory, label } = options;
99
+ const { directory, label, extra, allowInsideRunRoot } = options;
75
100
  if (typeof directory !== 'string' || !path.isAbsolute(directory))
76
101
  throw new Error('failureArtifacts.directory must be an absolute path');
77
102
  if (label !== undefined && typeof label !== 'string')
78
103
  throw new Error('failureArtifacts.label must be a string');
104
+ if (extra !== undefined && typeof extra !== 'function')
105
+ throw new Error('failureArtifacts.extra must be a function');
79
106
  const resolved = path.resolve(directory);
80
107
  const runRoot = process.env.RIGHTKIT_RUN_ROOT;
81
- if (runRoot) {
108
+ if (runRoot && !allowInsideRunRoot) {
82
109
  const relative = path.relative(path.resolve(runRoot), resolved);
83
110
  const inside = relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`));
84
111
  if (inside)
85
112
  throw new Error('failureArtifacts.directory must be outside RIGHTKIT_RUN_ROOT: disposeQaWorkspace deletes that tree');
86
113
  }
87
- return { directory: resolved, label: (label ?? 'wait').replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 60) || 'wait' };
114
+ return { directory: resolved, label: (label ?? 'wait').replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 60) || 'wait', extra };
88
115
  }
89
116
  let failureSequence = 0;
117
+ /** Bounds for the failure dump: extra state, page text, and the time a host callback may take. */
118
+ const EXTRA_LIMIT = 64 * 1024;
119
+ const EXTRA_TIMEOUT_MS = 1_000;
120
+ const PAGE_TEXT_LIMIT = 20_000;
121
+ function errorText(error) { return error instanceof Error ? error.message : String(error); }
122
+ /** Runs the host's extra callback under a bound, and keeps its JSON within EXTRA_LIMIT. Never throws. */
123
+ async function runExtra(extra) {
124
+ if (!extra)
125
+ return { value: null, error: null };
126
+ let timer;
127
+ try {
128
+ const value = await Promise.race([
129
+ Promise.resolve().then(() => extra()),
130
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`extra timed out after ${EXTRA_TIMEOUT_MS}ms`)), EXTRA_TIMEOUT_MS); }),
131
+ ]);
132
+ const json = JSON.stringify(value);
133
+ if (json === undefined)
134
+ return { value: null, error: null };
135
+ if (json.length > EXTRA_LIMIT)
136
+ return { value: { truncated: true, length: json.length, preview: json.slice(0, EXTRA_LIMIT) }, error: null };
137
+ return { value: JSON.parse(json), error: null };
138
+ }
139
+ catch (error) {
140
+ return { value: null, error: errorText(error) };
141
+ }
142
+ finally {
143
+ clearTimeout(timer);
144
+ }
145
+ }
90
146
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
91
147
  function functionSource(fn) {
92
148
  const source = fn.toString();
@@ -445,12 +501,27 @@ export class QaPage {
445
501
  await this.send('Network.enable');
446
502
  await this.send('Page.setLifecycleEventsEnabled', { enabled: true });
447
503
  }
448
- /** @internal */
504
+ /**
505
+ * UNSUPPORTED escape hatch: sends one page-scoped CDP command on this page's session and resolves
506
+ * with its raw result. Method names, params and results follow Chrome's protocol, not this package,
507
+ * and may change with the browser version. Prefer the typed QaPage methods where they exist.
508
+ */
449
509
  send(method, params = {}, timeout) {
450
510
  if (this.closed)
451
511
  return Promise.reject(this.closed);
512
+ if (typeof method !== 'string' || !method)
513
+ return Promise.reject(new Error('CDP method must be a non-empty string'));
452
514
  return this.client.send(method, params, this.sessionId, timeout);
453
515
  }
516
+ /**
517
+ * Emulation.setFocusEmulationEnabled: the page behaves as focused while its window is not
518
+ * (background or headless). Persists for the page until turned off.
519
+ */
520
+ async setFocusEmulation(enabled) {
521
+ if (typeof enabled !== 'boolean')
522
+ throw new Error('setFocusEmulation expects a boolean');
523
+ await this.send('Emulation.setFocusEmulationEnabled', { enabled });
524
+ }
454
525
  invalidateHandles() {
455
526
  const objectGroup = this.objectGroup;
456
527
  this.generation++;
@@ -597,14 +668,36 @@ export class QaPage {
597
668
  }
598
669
  async run(fn, args, returnByValue, timeout = this.client.timeout) {
599
670
  const deadline = Date.now() + timeout;
600
- const contextId = await this.mainContext(timeout);
601
- timeout = Math.max(1, deadline - Date.now());
602
- const common = { returnByValue, awaitPromise: true, objectGroup: this.objectGroup, timeout };
603
- const result = typeof fn === 'string'
604
- ? await this.send('Runtime.evaluate', { expression: fn, contextId, ...common }, timeout)
605
- : await this.send('Runtime.callFunctionOn', { functionDeclaration: functionSource(fn), executionContextId: contextId, arguments: args.map(argument), ...common }, timeout);
606
- resultValue(result);
607
- return result;
671
+ // A reload destroys the context an earlier call resolved. CDP refuses a call against a missing
672
+ // context without running it, so re-resolve the main context and retry until the caller's deadline.
673
+ let missing;
674
+ for (;;) {
675
+ const remaining = Math.max(1, deadline - Date.now());
676
+ let result;
677
+ try {
678
+ const contextId = await this.mainContext(remaining);
679
+ const common = { returnByValue, awaitPromise: true, objectGroup: this.objectGroup, timeout: remaining };
680
+ result = typeof fn === 'string'
681
+ ? await this.send('Runtime.evaluate', { expression: fn, contextId, ...common }, remaining)
682
+ : await this.send('Runtime.callFunctionOn', { functionDeclaration: functionSource(fn), executionContextId: contextId, arguments: args.map(argument), ...common }, remaining);
683
+ }
684
+ catch (error) {
685
+ if (isMissingContext(error)) {
686
+ missing = error;
687
+ if (Date.now() < deadline) {
688
+ await sleep(10);
689
+ continue;
690
+ }
691
+ throw error;
692
+ }
693
+ // The budget ran out while a context was missing: report the cause, not a bare timeout.
694
+ if (error instanceof TimeoutError && missing)
695
+ throw missing;
696
+ throw error;
697
+ }
698
+ resultValue(result);
699
+ return result;
700
+ }
608
701
  }
609
702
  async evaluate(fn, ...args) {
610
703
  return resultValue(await this.run(fn, args, true));
@@ -676,9 +769,13 @@ export class QaPage {
676
769
  const source = typeof fn === 'string' ? `function() { return (${fn}); }` : functionSource(fn);
677
770
  const artifacts = failureTarget(options.failureArtifacts);
678
771
  const timeout = options.timeout ?? this.client.timeout;
772
+ const pollState = { failed: false, error: undefined };
773
+ const poll = pollHook(options.onPoll, pollState);
679
774
  try {
680
775
  await this.bounded(`Predicate ${source}`, timeout, async (remaining, stopped) => {
681
776
  while (!stopped()) {
777
+ if (poll)
778
+ await poll();
682
779
  try {
683
780
  if (resultValue(await this.runSourceFunction(source, args, remaining())))
684
781
  return;
@@ -702,8 +799,9 @@ export class QaPage {
702
799
  });
703
800
  }
704
801
  catch (error) {
705
- if (artifacts && error instanceof TimeoutError)
802
+ if (artifacts && (error instanceof TimeoutError || (pollState.failed && pollState.error === error))) {
706
803
  await this.attachFailureArtifacts(error, artifacts, source, timeout);
804
+ }
707
805
  throw error;
708
806
  }
709
807
  }
@@ -712,6 +810,8 @@ export class QaPage {
712
810
  throw new Error('visible & hidden cannot both be true');
713
811
  const artifacts = failureTarget(options.failureArtifacts);
714
812
  const timeout = options.timeout ?? this.client.timeout;
813
+ const pollState = { failed: false, error: undefined };
814
+ const poll = pollHook(options.onPoll, pollState);
715
815
  try {
716
816
  return await this.bounded(`Selector ${selector}`, timeout, async (remaining) => {
717
817
  await this.waitForFunction((selector, visible, hidden) => {
@@ -721,7 +821,7 @@ export class QaPage {
721
821
  const style = getComputedStyle(element), box = element.getBoundingClientRect();
722
822
  const shown = style.visibility !== 'hidden' && style.visibility !== 'collapse' && box.width > 0 && box.height > 0;
723
823
  return hidden ? !shown : visible ? shown : true;
724
- }, { timeout: remaining() }, selector, !!options.visible, !!options.hidden);
824
+ }, { timeout: remaining(), onPoll: poll }, selector, !!options.visible, !!options.hidden);
725
825
  return options.hidden ? null : this.lookup(selector, remaining());
726
826
  });
727
827
  }
@@ -732,6 +832,9 @@ export class QaPage {
732
832
  await this.attachFailureArtifacts(named, artifacts, `waitForSelector ${JSON.stringify(selector)} visible=${!!options.visible} hidden=${!!options.hidden}`, timeout);
733
833
  throw named;
734
834
  }
835
+ if (artifacts && pollState.failed && pollState.error === error) {
836
+ await this.attachFailureArtifacts(error, artifacts, `waitForSelector ${JSON.stringify(selector)} onPoll`, timeout);
837
+ }
735
838
  throw error;
736
839
  }
737
840
  }
@@ -752,9 +855,9 @@ export class QaPage {
752
855
  await handle.dispose();
753
856
  }
754
857
  }
755
- async point(handle) {
756
- await handle.evaluate((element) => { if (!element.isConnected)
757
- throw new Error('Element detached'); element.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' }); });
858
+ async point(handle, inline = 'center') {
859
+ await handle.evaluate((element, inline) => { if (!element.isConnected)
860
+ throw new Error('Element detached'); element.scrollIntoView({ block: 'center', inline: inline, behavior: 'instant' }); }, inline);
758
861
  const box = await handle.boundingBox();
759
862
  if (!box || !box.width || !box.height)
760
863
  throw new Error('Element has no clickable box');
@@ -796,14 +899,17 @@ export class QaPage {
796
899
  }
797
900
  /**
798
901
  * Types a value into a field. Default: insert at the caret, exactly as type() does. With replace the
799
- * whole content is selected first and replaced. With verify (requires replace) the value is read back
800
- * once after insertion and FillMismatchError is thrown on a difference.
902
+ * whole content is selected first and replaced. With verify the value is read back and
903
+ * FillMismatchError is thrown on a difference: once by default, or polled until `timeoutMs` with
904
+ * `verify: { timeoutMs }`. Verify requires replace.
801
905
  */
802
906
  async fill(target, value, options = {}) {
803
907
  if (typeof value !== 'string')
804
908
  throw new Error('fill value must be a string');
805
- if (options.verify && !options.replace)
909
+ const verify = options.verify;
910
+ if (verify && !options.replace)
806
911
  throw new Error('fill verify requires replace: true; an insert-only fill has no fixed expected value');
912
+ const windowMs = typeof verify === 'object' && verify !== null ? validateTimeout(verify.timeoutMs) : undefined;
807
913
  await this.withElement(target, async (handle) => {
808
914
  if (!options.replace) {
809
915
  await this.typeElement(handle, value);
@@ -815,11 +921,19 @@ export class QaPage {
815
921
  await this.keyboard.press('Backspace');
816
922
  else
817
923
  await this.send('Input.insertText', { text: value });
818
- if (options.verify) {
819
- const actual = await handle.evaluate((element) => (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement ? element.value : element.textContent) ?? '');
820
- if (actual !== value)
821
- throw new FillMismatchError(value.length, actual.length);
924
+ if (!verify)
925
+ return;
926
+ const read = () => handle.evaluate((element) => (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement ? element.value : element.textContent) ?? '');
927
+ let actual = await read();
928
+ if (windowMs !== undefined) {
929
+ const deadline = Date.now() + windowMs;
930
+ while (actual !== value && Date.now() < deadline) {
931
+ await sleep(Math.min(25, Math.max(1, deadline - Date.now())));
932
+ actual = await read();
933
+ }
822
934
  }
935
+ if (actual !== value)
936
+ throw new FillMismatchError(value.length, actual.length);
823
937
  });
824
938
  }
825
939
  async selectAll(handle) {
@@ -866,12 +980,38 @@ export class QaPage {
866
980
  if (occluder !== null)
867
981
  throw new ElementOccludedError(occluder, point);
868
982
  }
983
+ /** URL, title, bounded page text and the active element. Each part is null when unreadable; never throws. */
984
+ async failureState() {
985
+ try {
986
+ const raw = resultValue(await this.run(() => {
987
+ const el = document.activeElement;
988
+ const attr = (name) => el?.getAttribute(name) || undefined;
989
+ return {
990
+ url: location.href,
991
+ title: document.title,
992
+ text: document.body ? document.body.innerText : '',
993
+ // Descriptor only: never the value, which may be a secret.
994
+ active: el ? { tag: el.tagName.toLowerCase(), id: el.id || undefined, name: attr('name'), type: attr('type'), role: attr('role'), ariaLabel: attr('aria-label') } : null,
995
+ };
996
+ }, [], true, 2_000)) ?? {};
997
+ const text = typeof raw.text === 'string' ? raw.text : '';
998
+ return {
999
+ url: typeof raw.url === 'string' ? raw.url : null,
1000
+ title: typeof raw.title === 'string' ? raw.title : null,
1001
+ pageText: { text: text.slice(0, PAGE_TEXT_LIMIT), truncated: text.length > PAGE_TEXT_LIMIT, length: text.length },
1002
+ activeElement: raw.active ?? null,
1003
+ };
1004
+ }
1005
+ catch {
1006
+ return { url: null, title: null, pageText: null, activeElement: null };
1007
+ }
1008
+ }
869
1009
  /** Writes a PNG and a JSON state dump for a timed-out wait into the caller directory. */
870
- async captureFailure(directory, label, predicate, timeout) {
871
- await mkdir(directory, { recursive: true });
872
- const stem = `${label}-${Date.now()}-${++failureSequence}`;
873
- const json = path.join(directory, `${stem}.json`);
874
- const png = path.join(directory, `${stem}.png`);
1010
+ async captureFailure(target, predicate, timeout) {
1011
+ await mkdir(target.directory, { recursive: true });
1012
+ const stem = `${target.label}-${Date.now()}-${++failureSequence}`;
1013
+ const json = path.join(target.directory, `${stem}.json`);
1014
+ const png = path.join(target.directory, `${stem}.png`);
875
1015
  let screenshot;
876
1016
  let screenshotError;
877
1017
  try {
@@ -879,40 +1019,48 @@ export class QaPage {
879
1019
  screenshot = png;
880
1020
  }
881
1021
  catch (error) {
882
- screenshotError = error instanceof Error ? error.message : String(error);
883
- }
884
- let where = {};
885
- try {
886
- where = resultValue(await this.run(() => ({ url: location.href, title: document.title }), [], true, 2_000)) ?? {};
1022
+ screenshotError = errorText(error);
887
1023
  }
888
- catch { /* page gone or stuck: the dump records no url or title */ }
1024
+ const state = await this.failureState();
1025
+ const extra = await runExtra(target.extra);
889
1026
  const dump = {
890
- label,
1027
+ label: target.label,
891
1028
  predicate,
892
1029
  timeoutMs: timeout,
893
1030
  timestamp: new Date().toISOString(),
894
- url: where.url ?? null,
895
- title: where.title ?? null,
1031
+ url: state.url,
1032
+ title: state.title,
1033
+ pageText: state.pageText,
1034
+ activeElement: state.activeElement,
896
1035
  screenshot: screenshot ? path.basename(screenshot) : null,
897
1036
  screenshotError: screenshotError ?? null,
898
1037
  console: this.consoleEntries(),
1038
+ extra: extra.value,
1039
+ extraError: extra.error,
899
1040
  };
900
1041
  await writeFile(json, JSON.stringify(dump, null, 2));
901
1042
  return screenshot ? { json, screenshot } : { json };
902
1043
  }
903
- /** Attaches artifact paths to a timeout error. A write failure is appended to its message; the timeout still throws. */
1044
+ /**
1045
+ * Writes the failure artifacts for a timed-out wait or a failed onPoll hook. The paths are attached as
1046
+ * `artifacts` only on a TimeoutError; for other errors the files are written and the paths are not attached.
1047
+ * A write failure is appended to a TimeoutError's message; the original error still throws.
1048
+ */
904
1049
  async attachFailureArtifacts(error, target, predicate, timeout) {
905
1050
  try {
906
- error.artifacts = await this.captureFailure(target.directory, target.label, predicate, timeout);
1051
+ const written = await this.captureFailure(target, predicate, timeout);
1052
+ if (error instanceof TimeoutError)
1053
+ error.artifacts = written;
907
1054
  }
908
1055
  catch (captureError) {
909
- error.message += ` (failure artifacts were not written: ${captureError instanceof Error ? captureError.message : String(captureError)})`;
1056
+ if (error instanceof TimeoutError)
1057
+ error.message += ` (failure artifacts were not written: ${captureError instanceof Error ? captureError.message : String(captureError)})`;
910
1058
  }
911
1059
  }
912
1060
  async click(target, options = {}) {
913
1061
  this.mouse.assertReleased();
914
1062
  await this.withElement(target, async (handle) => {
915
- const point = await this.point(handle), button = options.button ?? 'left', count = options.clickCount ?? 1;
1063
+ const point = await this.point(handle, scrollInline(options.scroll)), button = options.button ?? 'left', count = options.clickCount ?? 1;
916
1064
  await this.send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...point });
917
1065
  this.mouse.track(point.x, point.y);
918
1066
  if (options.checkOccluded)
@@ -940,10 +1088,11 @@ export class QaPage {
940
1088
  await sleep(options.delay);
941
1089
  }
942
1090
  }
943
- async hover(target) {
1091
+ async hover(target, options = {}) {
944
1092
  this.mouse.assertReleased();
1093
+ const inline = scrollInline(options.scroll);
945
1094
  await this.withElement(target, async (handle) => {
946
- const point = await this.point(handle);
1095
+ const point = await this.point(handle, inline);
947
1096
  await this.send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...point });
948
1097
  this.mouse.track(point.x, point.y);
949
1098
  });
@@ -1009,7 +1158,7 @@ export class QaElement {
1009
1158
  }
1010
1159
  click(options = {}) { return this.page.click(this, options); }
1011
1160
  type(text, options = {}) { return this.page.typeElement(this, text, options); }
1012
- hover() { return this.page.hover(this); }
1161
+ hover(options = {}) { return this.page.hover(this, options); }
1013
1162
  async focus() { await this.evaluate((element) => element.focus()); }
1014
1163
  boundingBox() {
1015
1164
  return this.evaluate((element) => {
package/dist/process.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type ChildProcess } from 'node:child_process';
1
2
  export interface OwnedProcessResult {
2
3
  pid: number;
3
4
  code: number | null;
@@ -25,3 +26,37 @@ export declare function runOwnedProcess(command: string, args?: string[], option
25
26
  export declare function freePort(): Promise<number>;
26
27
  export declare function isProcessAlive(pid: number): boolean;
27
28
  export declare function killProcessTree(pid: number): Promise<void>;
29
+ export interface SpawnOwnedProcessOptions {
30
+ cwd?: string;
31
+ env?: NodeJS.ProcessEnv;
32
+ /** Default 'ignore'. 'pipe' exposes child.stdout and child.stderr. */
33
+ stdio?: 'ignore' | 'pipe';
34
+ windowsHide?: boolean;
35
+ }
36
+ /**
37
+ * A process this package launched. Only the object returned by spawnOwnedProcess is accepted by
38
+ * stopOwnedProcess: a bare pid or a look-alike object is refused.
39
+ */
40
+ export interface OwnedProcess {
41
+ readonly pid: number;
42
+ readonly child: ChildProcess;
43
+ }
44
+ /**
45
+ * Launches a long-running process in its own process group (POSIX) and records it. Stopping it
46
+ * with stopOwnedProcess signals the whole group, so grandchildren go too.
47
+ */
48
+ export declare function spawnOwnedProcess(command: string, args?: string[], options?: SpawnOwnedProcessOptions): OwnedProcess;
49
+ /**
50
+ * Stops a process returned by spawnOwnedProcess: SIGTERM to its process group, SIGKILL after 1 s
51
+ * if members remain (POSIX); taskkill /T /F on Windows. Idempotent. Refuses anything else.
52
+ */
53
+ export declare function stopOwnedProcess(handle: OwnedProcess): Promise<void>;
54
+ export interface OwnedProcessReceipt {
55
+ /**
56
+ * Pids launched by spawnOwnedProcess and not stopped whose process group (POSIX) or process
57
+ * (Windows) still exists. Empty when nothing this package launched is left running.
58
+ */
59
+ leftoverPids: number[];
60
+ }
61
+ /** Receipt of what this package launched and still has running. Read only; signals nothing. */
62
+ export declare function ownedProcessReceipt(): OwnedProcessReceipt;
package/dist/process.js CHANGED
@@ -133,3 +133,82 @@ async function waitForExit(pid, timeoutMs) {
133
133
  await new Promise((resolve) => setTimeout(resolve, 25));
134
134
  }
135
135
  }
136
+ /** Every handle this package has launched in this process, stopped or not. */
137
+ const launched = new Set();
138
+ const stopped = new WeakSet();
139
+ /**
140
+ * Launches a long-running process in its own process group (POSIX) and records it. Stopping it
141
+ * with stopOwnedProcess signals the whole group, so grandchildren go too.
142
+ */
143
+ export function spawnOwnedProcess(command, args = [], options = {}) {
144
+ const child = spawn(command, args, {
145
+ cwd: options.cwd,
146
+ env: options.env,
147
+ stdio: options.stdio === 'pipe' ? ['ignore', 'pipe', 'pipe'] : 'ignore',
148
+ windowsHide: options.windowsHide ?? true,
149
+ detached: process.platform !== 'win32',
150
+ });
151
+ // Spawn failures arrive as 'error'; surface them through the missing pid, not as an unhandled event.
152
+ child.on('error', () => { });
153
+ if (!child.pid)
154
+ throw new Error(`failed to start owned process: ${command}`);
155
+ const handle = Object.freeze({ pid: child.pid, child });
156
+ launched.add(handle);
157
+ return handle;
158
+ }
159
+ /**
160
+ * Stops a process returned by spawnOwnedProcess: SIGTERM to its process group, SIGKILL after 1 s
161
+ * if members remain (POSIX); taskkill /T /F on Windows. Idempotent. Refuses anything else.
162
+ */
163
+ export async function stopOwnedProcess(handle) {
164
+ if (!handle || typeof handle !== 'object' || !launched.has(handle)) {
165
+ const pid = handle && typeof handle === 'object' ? handle.pid : handle;
166
+ throw new Error(`refusing to stop pid ${String(pid)}: this package did not launch it`);
167
+ }
168
+ if (stopped.has(handle))
169
+ return;
170
+ const pid = handle.pid;
171
+ if (process.platform === 'win32') {
172
+ await runCleanupCommand('taskkill.exe', ['/PID', String(pid), '/T', '/F']);
173
+ }
174
+ else {
175
+ signalGroup(pid, 'SIGTERM');
176
+ if (!(await waitGroupGone(pid, 1_000))) {
177
+ signalGroup(pid, 'SIGKILL');
178
+ await waitGroupGone(pid, 2_000);
179
+ }
180
+ }
181
+ stopped.add(handle);
182
+ }
183
+ /** Receipt of what this package launched and still has running. Read only; signals nothing. */
184
+ export function ownedProcessReceipt() {
185
+ const leftoverPids = [...launched]
186
+ .filter((handle) => !stopped.has(handle))
187
+ .filter((handle) => (process.platform === 'win32' ? isProcessAlive(handle.pid) : groupAlive(handle.pid)))
188
+ .map((handle) => handle.pid);
189
+ return { leftoverPids };
190
+ }
191
+ function groupAlive(pid) {
192
+ try {
193
+ process.kill(-pid, 0);
194
+ return true;
195
+ }
196
+ catch (error) {
197
+ return error.code === 'EPERM';
198
+ }
199
+ }
200
+ function signalGroup(pid, signal) {
201
+ try {
202
+ process.kill(-pid, signal);
203
+ }
204
+ catch { /* group already gone */ }
205
+ }
206
+ async function waitGroupGone(pid, timeoutMs) {
207
+ const deadline = Date.now() + timeoutMs;
208
+ while (groupAlive(pid)) {
209
+ if (Date.now() >= deadline)
210
+ return false;
211
+ await new Promise((resolve) => setTimeout(resolve, 25));
212
+ }
213
+ return true;
214
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/qa",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
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",
@@ -24,7 +24,7 @@
24
24
  ],
25
25
  "sideEffects": false,
26
26
  "dependencies": {
27
- "@rightkit/qa-chrome": "0.1.3"
27
+ "@rightkit/qa-chrome": "0.1.5"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@types/node": "^24.0.0",