@rightkit/qa 0.4.4 → 0.4.5
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 +9 -1
- package/dist/page.d.ts +41 -7
- package/dist/page.js +119 -27
- package/dist/process.d.ts +35 -0
- package/dist/process.js +79 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -95,6 +95,14 @@ try {
|
|
|
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
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.
|
|
97
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.
|
|
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
|
|
|
100
108
|
## Keyboard keys
|
|
@@ -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.
|
|
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).
|
|
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
|
@@ -21,29 +21,48 @@ export interface FailureArtifactOptions {
|
|
|
21
21
|
/** Short name used in the file names and the dump. Default 'wait'. */
|
|
22
22
|
label?: string;
|
|
23
23
|
}
|
|
24
|
+
/** Called once per poll, before the predicate. A throw rejects the wait with that error. */
|
|
25
|
+
export type PollHook = () => void | Promise<void>;
|
|
24
26
|
export interface WaitOptions {
|
|
25
27
|
timeout?: number;
|
|
26
28
|
polling?: 'raf' | number;
|
|
27
29
|
failureArtifacts?: FailureArtifactOptions;
|
|
30
|
+
onPoll?: PollHook;
|
|
28
31
|
}
|
|
29
32
|
export interface SelectorWaitOptions {
|
|
30
33
|
visible?: boolean;
|
|
31
34
|
hidden?: boolean;
|
|
32
35
|
timeout?: number;
|
|
33
36
|
failureArtifacts?: FailureArtifactOptions;
|
|
37
|
+
onPoll?: PollHook;
|
|
38
|
+
}
|
|
39
|
+
/** How the target is brought into view before the pointer moves. Vertical alignment is always centre. */
|
|
40
|
+
export interface ScrollOptions {
|
|
41
|
+
/** Default 'center', as before 0.4.5. 'nearest' scrolls the least needed to show the target at the edge. */
|
|
42
|
+
inline?: 'center' | 'nearest';
|
|
43
|
+
}
|
|
44
|
+
export interface HoverOptions {
|
|
45
|
+
scroll?: ScrollOptions;
|
|
34
46
|
}
|
|
35
47
|
export interface ClickOptions {
|
|
36
48
|
button?: 'left' | 'right' | 'middle';
|
|
37
49
|
clickCount?: number;
|
|
38
50
|
delay?: number;
|
|
51
|
+
scroll?: ScrollOptions;
|
|
39
52
|
/** 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
53
|
checkOccluded?: boolean;
|
|
41
54
|
}
|
|
42
55
|
export interface FillOptions {
|
|
43
56
|
/** 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
57
|
replace?: boolean;
|
|
45
|
-
/**
|
|
46
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Default false: read the value back once after insertion and throw FillMismatchError on a difference.
|
|
60
|
+
* `{ timeoutMs }` polls the readback every 25 ms until it matches or the window ends, for fields that
|
|
61
|
+
* settle late (a framework rewrites the value after an input event). Requires replace.
|
|
62
|
+
*/
|
|
63
|
+
verify?: boolean | {
|
|
64
|
+
timeoutMs: number;
|
|
65
|
+
};
|
|
47
66
|
}
|
|
48
67
|
/** One console call or uncaught exception of a page. `line` is 1-based. */
|
|
49
68
|
export interface ConsoleEntry {
|
|
@@ -183,8 +202,17 @@ export declare class QaPage {
|
|
|
183
202
|
on(method: string, listener: (params: CdpParams) => void): () => void;
|
|
184
203
|
/** @internal */
|
|
185
204
|
initialize(): Promise<void>;
|
|
186
|
-
/**
|
|
205
|
+
/**
|
|
206
|
+
* UNSUPPORTED escape hatch: sends one page-scoped CDP command on this page's session and resolves
|
|
207
|
+
* with its raw result. Method names, params and results follow Chrome's protocol, not this package,
|
|
208
|
+
* and may change with the browser version. Prefer the typed QaPage methods where they exist.
|
|
209
|
+
*/
|
|
187
210
|
send(method: string, params?: CdpParams, timeout?: number): Promise<CdpParams>;
|
|
211
|
+
/**
|
|
212
|
+
* Emulation.setFocusEmulationEnabled: the page behaves as focused while its window is not
|
|
213
|
+
* (background or headless). Persists for the page until turned off.
|
|
214
|
+
*/
|
|
215
|
+
setFocusEmulation(enabled: boolean): Promise<void>;
|
|
188
216
|
private invalidateHandles;
|
|
189
217
|
/** @internal */
|
|
190
218
|
markClosed(error?: Error): void;
|
|
@@ -227,8 +255,9 @@ export declare class QaPage {
|
|
|
227
255
|
resetUserAgent(): Promise<void>;
|
|
228
256
|
/**
|
|
229
257
|
* 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
|
|
231
|
-
*
|
|
258
|
+
* whole content is selected first and replaced. With verify the value is read back and
|
|
259
|
+
* FillMismatchError is thrown on a difference: once by default, or polled until `timeoutMs` with
|
|
260
|
+
* `verify: { timeoutMs }`. Verify requires replace.
|
|
232
261
|
*/
|
|
233
262
|
fill(target: string | QaElement, value: string, options?: FillOptions): Promise<void>;
|
|
234
263
|
private selectAll;
|
|
@@ -237,12 +266,17 @@ export declare class QaPage {
|
|
|
237
266
|
/** Writes a PNG and a JSON state dump for a timed-out wait into the caller directory. */
|
|
238
267
|
private captureFailure;
|
|
239
268
|
/** Attaches artifact paths to a timeout error. A write failure is appended to its message; the timeout still throws. */
|
|
269
|
+
/**
|
|
270
|
+
* Writes the failure artifacts for a timed-out wait or a failed onPoll hook. The paths are attached as
|
|
271
|
+
* `artifacts` only on a TimeoutError; for other errors the files are written and the paths are not attached.
|
|
272
|
+
* A write failure is appended to a TimeoutError's message; the original error still throws.
|
|
273
|
+
*/
|
|
240
274
|
private attachFailureArtifacts;
|
|
241
275
|
click(target: string | QaElement, options?: ClickOptions): Promise<void>;
|
|
242
276
|
type(selector: string, text: string, options?: TypeOptions): Promise<void>;
|
|
243
277
|
/** @internal */
|
|
244
278
|
typeElement(handle: QaElement, text: string, options?: TypeOptions): Promise<void>;
|
|
245
|
-
hover(target: string | QaElement): Promise<void>;
|
|
279
|
+
hover(target: string | QaElement, options?: HoverOptions): Promise<void>;
|
|
246
280
|
focus(target: string | QaElement): Promise<void>;
|
|
247
281
|
select(selector: string, ...values: string[]): Promise<string[]>;
|
|
248
282
|
screenshot(options?: ScreenshotOptions): Promise<Buffer>;
|
|
@@ -263,7 +297,7 @@ export declare class QaElement {
|
|
|
263
297
|
evaluate<Args extends unknown[], Result>(fn: (element: HTMLElement, ...args: Args) => Result, ...args: Args): Promise<Awaited<Result>>;
|
|
264
298
|
click(options?: ClickOptions): Promise<void>;
|
|
265
299
|
type(text: string, options?: TypeOptions): Promise<void>;
|
|
266
|
-
hover(): Promise<void>;
|
|
300
|
+
hover(options?: HoverOptions): Promise<void>;
|
|
267
301
|
focus(): Promise<void>;
|
|
268
302
|
boundingBox(): Promise<BoundingBox | null>;
|
|
269
303
|
}
|
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)
|
|
@@ -445,12 +470,27 @@ export class QaPage {
|
|
|
445
470
|
await this.send('Network.enable');
|
|
446
471
|
await this.send('Page.setLifecycleEventsEnabled', { enabled: true });
|
|
447
472
|
}
|
|
448
|
-
/**
|
|
473
|
+
/**
|
|
474
|
+
* UNSUPPORTED escape hatch: sends one page-scoped CDP command on this page's session and resolves
|
|
475
|
+
* with its raw result. Method names, params and results follow Chrome's protocol, not this package,
|
|
476
|
+
* and may change with the browser version. Prefer the typed QaPage methods where they exist.
|
|
477
|
+
*/
|
|
449
478
|
send(method, params = {}, timeout) {
|
|
450
479
|
if (this.closed)
|
|
451
480
|
return Promise.reject(this.closed);
|
|
481
|
+
if (typeof method !== 'string' || !method)
|
|
482
|
+
return Promise.reject(new Error('CDP method must be a non-empty string'));
|
|
452
483
|
return this.client.send(method, params, this.sessionId, timeout);
|
|
453
484
|
}
|
|
485
|
+
/**
|
|
486
|
+
* Emulation.setFocusEmulationEnabled: the page behaves as focused while its window is not
|
|
487
|
+
* (background or headless). Persists for the page until turned off.
|
|
488
|
+
*/
|
|
489
|
+
async setFocusEmulation(enabled) {
|
|
490
|
+
if (typeof enabled !== 'boolean')
|
|
491
|
+
throw new Error('setFocusEmulation expects a boolean');
|
|
492
|
+
await this.send('Emulation.setFocusEmulationEnabled', { enabled });
|
|
493
|
+
}
|
|
454
494
|
invalidateHandles() {
|
|
455
495
|
const objectGroup = this.objectGroup;
|
|
456
496
|
this.generation++;
|
|
@@ -597,14 +637,36 @@ export class QaPage {
|
|
|
597
637
|
}
|
|
598
638
|
async run(fn, args, returnByValue, timeout = this.client.timeout) {
|
|
599
639
|
const deadline = Date.now() + timeout;
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
640
|
+
// A reload destroys the context an earlier call resolved. CDP refuses a call against a missing
|
|
641
|
+
// context without running it, so re-resolve the main context and retry until the caller's deadline.
|
|
642
|
+
let missing;
|
|
643
|
+
for (;;) {
|
|
644
|
+
const remaining = Math.max(1, deadline - Date.now());
|
|
645
|
+
let result;
|
|
646
|
+
try {
|
|
647
|
+
const contextId = await this.mainContext(remaining);
|
|
648
|
+
const common = { returnByValue, awaitPromise: true, objectGroup: this.objectGroup, timeout: remaining };
|
|
649
|
+
result = typeof fn === 'string'
|
|
650
|
+
? await this.send('Runtime.evaluate', { expression: fn, contextId, ...common }, remaining)
|
|
651
|
+
: await this.send('Runtime.callFunctionOn', { functionDeclaration: functionSource(fn), executionContextId: contextId, arguments: args.map(argument), ...common }, remaining);
|
|
652
|
+
}
|
|
653
|
+
catch (error) {
|
|
654
|
+
if (isMissingContext(error)) {
|
|
655
|
+
missing = error;
|
|
656
|
+
if (Date.now() < deadline) {
|
|
657
|
+
await sleep(10);
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
throw error;
|
|
661
|
+
}
|
|
662
|
+
// The budget ran out while a context was missing: report the cause, not a bare timeout.
|
|
663
|
+
if (error instanceof TimeoutError && missing)
|
|
664
|
+
throw missing;
|
|
665
|
+
throw error;
|
|
666
|
+
}
|
|
667
|
+
resultValue(result);
|
|
668
|
+
return result;
|
|
669
|
+
}
|
|
608
670
|
}
|
|
609
671
|
async evaluate(fn, ...args) {
|
|
610
672
|
return resultValue(await this.run(fn, args, true));
|
|
@@ -676,9 +738,13 @@ export class QaPage {
|
|
|
676
738
|
const source = typeof fn === 'string' ? `function() { return (${fn}); }` : functionSource(fn);
|
|
677
739
|
const artifacts = failureTarget(options.failureArtifacts);
|
|
678
740
|
const timeout = options.timeout ?? this.client.timeout;
|
|
741
|
+
const pollState = { failed: false, error: undefined };
|
|
742
|
+
const poll = pollHook(options.onPoll, pollState);
|
|
679
743
|
try {
|
|
680
744
|
await this.bounded(`Predicate ${source}`, timeout, async (remaining, stopped) => {
|
|
681
745
|
while (!stopped()) {
|
|
746
|
+
if (poll)
|
|
747
|
+
await poll();
|
|
682
748
|
try {
|
|
683
749
|
if (resultValue(await this.runSourceFunction(source, args, remaining())))
|
|
684
750
|
return;
|
|
@@ -702,8 +768,9 @@ export class QaPage {
|
|
|
702
768
|
});
|
|
703
769
|
}
|
|
704
770
|
catch (error) {
|
|
705
|
-
if (artifacts && error instanceof TimeoutError)
|
|
771
|
+
if (artifacts && (error instanceof TimeoutError || (pollState.failed && pollState.error === error))) {
|
|
706
772
|
await this.attachFailureArtifacts(error, artifacts, source, timeout);
|
|
773
|
+
}
|
|
707
774
|
throw error;
|
|
708
775
|
}
|
|
709
776
|
}
|
|
@@ -712,6 +779,8 @@ export class QaPage {
|
|
|
712
779
|
throw new Error('visible & hidden cannot both be true');
|
|
713
780
|
const artifacts = failureTarget(options.failureArtifacts);
|
|
714
781
|
const timeout = options.timeout ?? this.client.timeout;
|
|
782
|
+
const pollState = { failed: false, error: undefined };
|
|
783
|
+
const poll = pollHook(options.onPoll, pollState);
|
|
715
784
|
try {
|
|
716
785
|
return await this.bounded(`Selector ${selector}`, timeout, async (remaining) => {
|
|
717
786
|
await this.waitForFunction((selector, visible, hidden) => {
|
|
@@ -721,7 +790,7 @@ export class QaPage {
|
|
|
721
790
|
const style = getComputedStyle(element), box = element.getBoundingClientRect();
|
|
722
791
|
const shown = style.visibility !== 'hidden' && style.visibility !== 'collapse' && box.width > 0 && box.height > 0;
|
|
723
792
|
return hidden ? !shown : visible ? shown : true;
|
|
724
|
-
}, { timeout: remaining() }, selector, !!options.visible, !!options.hidden);
|
|
793
|
+
}, { timeout: remaining(), onPoll: poll }, selector, !!options.visible, !!options.hidden);
|
|
725
794
|
return options.hidden ? null : this.lookup(selector, remaining());
|
|
726
795
|
});
|
|
727
796
|
}
|
|
@@ -732,6 +801,9 @@ export class QaPage {
|
|
|
732
801
|
await this.attachFailureArtifacts(named, artifacts, `waitForSelector ${JSON.stringify(selector)} visible=${!!options.visible} hidden=${!!options.hidden}`, timeout);
|
|
733
802
|
throw named;
|
|
734
803
|
}
|
|
804
|
+
if (artifacts && pollState.failed && pollState.error === error) {
|
|
805
|
+
await this.attachFailureArtifacts(error, artifacts, `waitForSelector ${JSON.stringify(selector)} onPoll`, timeout);
|
|
806
|
+
}
|
|
735
807
|
throw error;
|
|
736
808
|
}
|
|
737
809
|
}
|
|
@@ -752,9 +824,9 @@ export class QaPage {
|
|
|
752
824
|
await handle.dispose();
|
|
753
825
|
}
|
|
754
826
|
}
|
|
755
|
-
async point(handle) {
|
|
756
|
-
await handle.evaluate((element) => { if (!element.isConnected)
|
|
757
|
-
throw new Error('Element detached'); element.scrollIntoView({ block: 'center', inline:
|
|
827
|
+
async point(handle, inline = 'center') {
|
|
828
|
+
await handle.evaluate((element, inline) => { if (!element.isConnected)
|
|
829
|
+
throw new Error('Element detached'); element.scrollIntoView({ block: 'center', inline: inline, behavior: 'instant' }); }, inline);
|
|
758
830
|
const box = await handle.boundingBox();
|
|
759
831
|
if (!box || !box.width || !box.height)
|
|
760
832
|
throw new Error('Element has no clickable box');
|
|
@@ -796,14 +868,17 @@ export class QaPage {
|
|
|
796
868
|
}
|
|
797
869
|
/**
|
|
798
870
|
* 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
|
|
800
|
-
*
|
|
871
|
+
* whole content is selected first and replaced. With verify the value is read back and
|
|
872
|
+
* FillMismatchError is thrown on a difference: once by default, or polled until `timeoutMs` with
|
|
873
|
+
* `verify: { timeoutMs }`. Verify requires replace.
|
|
801
874
|
*/
|
|
802
875
|
async fill(target, value, options = {}) {
|
|
803
876
|
if (typeof value !== 'string')
|
|
804
877
|
throw new Error('fill value must be a string');
|
|
805
|
-
|
|
878
|
+
const verify = options.verify;
|
|
879
|
+
if (verify && !options.replace)
|
|
806
880
|
throw new Error('fill verify requires replace: true; an insert-only fill has no fixed expected value');
|
|
881
|
+
const windowMs = typeof verify === 'object' && verify !== null ? validateTimeout(verify.timeoutMs) : undefined;
|
|
807
882
|
await this.withElement(target, async (handle) => {
|
|
808
883
|
if (!options.replace) {
|
|
809
884
|
await this.typeElement(handle, value);
|
|
@@ -815,11 +890,19 @@ export class QaPage {
|
|
|
815
890
|
await this.keyboard.press('Backspace');
|
|
816
891
|
else
|
|
817
892
|
await this.send('Input.insertText', { text: value });
|
|
818
|
-
if (
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
893
|
+
if (!verify)
|
|
894
|
+
return;
|
|
895
|
+
const read = () => handle.evaluate((element) => (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement ? element.value : element.textContent) ?? '');
|
|
896
|
+
let actual = await read();
|
|
897
|
+
if (windowMs !== undefined) {
|
|
898
|
+
const deadline = Date.now() + windowMs;
|
|
899
|
+
while (actual !== value && Date.now() < deadline) {
|
|
900
|
+
await sleep(Math.min(25, Math.max(1, deadline - Date.now())));
|
|
901
|
+
actual = await read();
|
|
902
|
+
}
|
|
822
903
|
}
|
|
904
|
+
if (actual !== value)
|
|
905
|
+
throw new FillMismatchError(value.length, actual.length);
|
|
823
906
|
});
|
|
824
907
|
}
|
|
825
908
|
async selectAll(handle) {
|
|
@@ -901,18 +984,26 @@ export class QaPage {
|
|
|
901
984
|
return screenshot ? { json, screenshot } : { json };
|
|
902
985
|
}
|
|
903
986
|
/** Attaches artifact paths to a timeout error. A write failure is appended to its message; the timeout still throws. */
|
|
987
|
+
/**
|
|
988
|
+
* Writes the failure artifacts for a timed-out wait or a failed onPoll hook. The paths are attached as
|
|
989
|
+
* `artifacts` only on a TimeoutError; for other errors the files are written and the paths are not attached.
|
|
990
|
+
* A write failure is appended to a TimeoutError's message; the original error still throws.
|
|
991
|
+
*/
|
|
904
992
|
async attachFailureArtifacts(error, target, predicate, timeout) {
|
|
905
993
|
try {
|
|
906
|
-
|
|
994
|
+
const written = await this.captureFailure(target.directory, target.label, predicate, timeout);
|
|
995
|
+
if (error instanceof TimeoutError)
|
|
996
|
+
error.artifacts = written;
|
|
907
997
|
}
|
|
908
998
|
catch (captureError) {
|
|
909
|
-
|
|
999
|
+
if (error instanceof TimeoutError)
|
|
1000
|
+
error.message += ` (failure artifacts were not written: ${captureError instanceof Error ? captureError.message : String(captureError)})`;
|
|
910
1001
|
}
|
|
911
1002
|
}
|
|
912
1003
|
async click(target, options = {}) {
|
|
913
1004
|
this.mouse.assertReleased();
|
|
914
1005
|
await this.withElement(target, async (handle) => {
|
|
915
|
-
const point = await this.point(handle), button = options.button ?? 'left', count = options.clickCount ?? 1;
|
|
1006
|
+
const point = await this.point(handle, scrollInline(options.scroll)), button = options.button ?? 'left', count = options.clickCount ?? 1;
|
|
916
1007
|
await this.send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...point });
|
|
917
1008
|
this.mouse.track(point.x, point.y);
|
|
918
1009
|
if (options.checkOccluded)
|
|
@@ -940,10 +1031,11 @@ export class QaPage {
|
|
|
940
1031
|
await sleep(options.delay);
|
|
941
1032
|
}
|
|
942
1033
|
}
|
|
943
|
-
async hover(target) {
|
|
1034
|
+
async hover(target, options = {}) {
|
|
944
1035
|
this.mouse.assertReleased();
|
|
1036
|
+
const inline = scrollInline(options.scroll);
|
|
945
1037
|
await this.withElement(target, async (handle) => {
|
|
946
|
-
const point = await this.point(handle);
|
|
1038
|
+
const point = await this.point(handle, inline);
|
|
947
1039
|
await this.send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...point });
|
|
948
1040
|
this.mouse.track(point.x, point.y);
|
|
949
1041
|
});
|
|
@@ -1009,7 +1101,7 @@ export class QaElement {
|
|
|
1009
1101
|
}
|
|
1010
1102
|
click(options = {}) { return this.page.click(this, options); }
|
|
1011
1103
|
type(text, options = {}) { return this.page.typeElement(this, text, options); }
|
|
1012
|
-
hover() { return this.page.hover(this); }
|
|
1104
|
+
hover(options = {}) { return this.page.hover(this, options); }
|
|
1013
1105
|
async focus() { await this.evaluate((element) => element.focus()); }
|
|
1014
1106
|
boundingBox() {
|
|
1015
1107
|
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.
|
|
3
|
+
"version": "0.4.5",
|
|
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.
|
|
27
|
+
"@rightkit/qa-chrome": "0.1.4"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"@types/node": "^24.0.0",
|