@rightkit/qa 0.4.5 → 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,7 @@ 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
97
 
98
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
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.
@@ -143,5 +143,5 @@ This package launches no native app and no WebView2 window; it never replaces th
143
143
 
144
144
  ## Known issues
145
145
 
146
- - `@rightkit/qa-chrome` is pinned exactly to `0.1.4`. 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).
147
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,10 +16,24 @@ 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
  }
24
38
  /** Called once per poll, before the predicate. A throw rejects the wait with that error. */
25
39
  export type PollHook = () => void | Promise<void>;
@@ -263,9 +277,10 @@ export declare class QaPage {
263
277
  private selectAll;
264
278
  /** Throws ElementOccludedError naming the element on top unless the target itself or a composed descendant is hit. */
265
279
  private assertUnoccluded;
280
+ /** URL, title, bounded page text and the active element. Each part is null when unreadable; never throws. */
281
+ private failureState;
266
282
  /** Writes a PNG and a JSON state dump for a timed-out wait into the caller directory. */
267
283
  private captureFailure;
268
- /** Attaches artifact paths to a timeout error. A write failure is appended to its message; the timeout still throws. */
269
284
  /**
270
285
  * Writes the failure artifacts for a timed-out wait or a failed onPoll hook. The paths are attached as
271
286
  * `artifacts` only on a TimeoutError; for other errors the files are written and the paths are not attached.
package/dist/page.js CHANGED
@@ -96,22 +96,53 @@ function exceptionEntry(event) {
96
96
  function failureTarget(options) {
97
97
  if (!options)
98
98
  return undefined;
99
- const { directory, label } = options;
99
+ const { directory, label, extra, allowInsideRunRoot } = options;
100
100
  if (typeof directory !== 'string' || !path.isAbsolute(directory))
101
101
  throw new Error('failureArtifacts.directory must be an absolute path');
102
102
  if (label !== undefined && typeof label !== 'string')
103
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');
104
106
  const resolved = path.resolve(directory);
105
107
  const runRoot = process.env.RIGHTKIT_RUN_ROOT;
106
- if (runRoot) {
108
+ if (runRoot && !allowInsideRunRoot) {
107
109
  const relative = path.relative(path.resolve(runRoot), resolved);
108
110
  const inside = relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`));
109
111
  if (inside)
110
112
  throw new Error('failureArtifacts.directory must be outside RIGHTKIT_RUN_ROOT: disposeQaWorkspace deletes that tree');
111
113
  }
112
- 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 };
113
115
  }
114
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
+ }
115
146
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
116
147
  function functionSource(fn) {
117
148
  const source = fn.toString();
@@ -949,12 +980,38 @@ export class QaPage {
949
980
  if (occluder !== null)
950
981
  throw new ElementOccludedError(occluder, point);
951
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
+ }
952
1009
  /** Writes a PNG and a JSON state dump for a timed-out wait into the caller directory. */
953
- async captureFailure(directory, label, predicate, timeout) {
954
- await mkdir(directory, { recursive: true });
955
- const stem = `${label}-${Date.now()}-${++failureSequence}`;
956
- const json = path.join(directory, `${stem}.json`);
957
- 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`);
958
1015
  let screenshot;
959
1016
  let screenshotError;
960
1017
  try {
@@ -962,28 +1019,28 @@ export class QaPage {
962
1019
  screenshot = png;
963
1020
  }
964
1021
  catch (error) {
965
- screenshotError = error instanceof Error ? error.message : String(error);
966
- }
967
- let where = {};
968
- try {
969
- where = resultValue(await this.run(() => ({ url: location.href, title: document.title }), [], true, 2_000)) ?? {};
1022
+ screenshotError = errorText(error);
970
1023
  }
971
- 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);
972
1026
  const dump = {
973
- label,
1027
+ label: target.label,
974
1028
  predicate,
975
1029
  timeoutMs: timeout,
976
1030
  timestamp: new Date().toISOString(),
977
- url: where.url ?? null,
978
- title: where.title ?? null,
1031
+ url: state.url,
1032
+ title: state.title,
1033
+ pageText: state.pageText,
1034
+ activeElement: state.activeElement,
979
1035
  screenshot: screenshot ? path.basename(screenshot) : null,
980
1036
  screenshotError: screenshotError ?? null,
981
1037
  console: this.consoleEntries(),
1038
+ extra: extra.value,
1039
+ extraError: extra.error,
982
1040
  };
983
1041
  await writeFile(json, JSON.stringify(dump, null, 2));
984
1042
  return screenshot ? { json, screenshot } : { json };
985
1043
  }
986
- /** Attaches artifact paths to a timeout error. A write failure is appended to its message; the timeout still throws. */
987
1044
  /**
988
1045
  * Writes the failure artifacts for a timed-out wait or a failed onPoll hook. The paths are attached as
989
1046
  * `artifacts` only on a TimeoutError; for other errors the files are written and the paths are not attached.
@@ -991,7 +1048,7 @@ export class QaPage {
991
1048
  */
992
1049
  async attachFailureArtifacts(error, target, predicate, timeout) {
993
1050
  try {
994
- const written = await this.captureFailure(target.directory, target.label, predicate, timeout);
1051
+ const written = await this.captureFailure(target, predicate, timeout);
995
1052
  if (error instanceof TimeoutError)
996
1053
  error.artifacts = written;
997
1054
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/qa",
3
- "version": "0.4.5",
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.4"
27
+ "@rightkit/qa-chrome": "0.1.5"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@types/node": "^24.0.0",