@nativedesktop/test 0.1.2 → 0.3.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/package.json +3 -3
- package/src/client.ts +11 -1
- package/src/launch.ts +103 -22
- package/src/screenshot.ts +7 -1
- package/src/socket.ts +20 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nativedesktop/test",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/FormalSnake/NativeDesktop#readme",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
".": "./src/index.ts"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@nativedesktop/host": "0.
|
|
26
|
-
"@nativedesktop/react": "0.
|
|
25
|
+
"@nativedesktop/host": "0.3.0",
|
|
26
|
+
"@nativedesktop/react": "0.3.0"
|
|
27
27
|
}
|
|
28
28
|
}
|
package/src/client.ts
CHANGED
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
import { AutomationClient } from "./socket.ts";
|
|
6
6
|
import type { RpcMethodName, RpcParams, RpcResult } from "@nativedesktop/react/rpc";
|
|
7
7
|
|
|
8
|
+
/// Room for the host to answer after its own deadline expires, for calls that
|
|
9
|
+
/// declare one (waitFor, webviewEval). Small: the host answers a timeout in
|
|
10
|
+
/// one poll interval.
|
|
11
|
+
const HOST_ANSWER_SLACK_MS = 2000;
|
|
12
|
+
|
|
8
13
|
export class TimedClient {
|
|
9
14
|
constructor(
|
|
10
15
|
private readonly inner: AutomationClient,
|
|
@@ -20,7 +25,12 @@ export class TimedClient {
|
|
|
20
25
|
method: M,
|
|
21
26
|
...params: RpcParams<M> extends undefined ? [] : [RpcParams<M>]
|
|
22
27
|
): Promise<RpcResult<M>> {
|
|
23
|
-
|
|
28
|
+
// A call that carries its OWN deadline must not be cut short by the
|
|
29
|
+
// blanket one: a `waitFor` asked to wait 15s failed here at the 8s default,
|
|
30
|
+
// which reads as a product bug rather than a harness setting.
|
|
31
|
+
const declared = (params[0] as { timeoutMs?: number } | undefined)?.timeoutMs;
|
|
32
|
+
const ms =
|
|
33
|
+
typeof declared === "number" ? Math.max(this.timeoutMs, declared + HOST_ANSWER_SLACK_MS) : this.timeoutMs;
|
|
24
34
|
let timer: ReturnType<typeof setTimeout>;
|
|
25
35
|
const timeout = new Promise<never>((_resolve, reject) => {
|
|
26
36
|
timer = setTimeout(() => {
|
package/src/launch.ts
CHANGED
|
@@ -8,21 +8,24 @@ import { tmpdir } from "node:os";
|
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import type { Backend } from "@nativedesktop/host";
|
|
10
10
|
import { resolveBackend, resolveHostBinary } from "@nativedesktop/host";
|
|
11
|
-
import { AutomationClient } from "./socket.ts";
|
|
12
|
-
import
|
|
13
|
-
DragParams,
|
|
14
|
-
DragResult,
|
|
15
|
-
ClickResult,
|
|
16
|
-
GetTreeResult,
|
|
17
|
-
JsonNode,
|
|
18
|
-
KeysResult,
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
11
|
+
import { AutomationClient, AutomationRpcError } from "./socket.ts";
|
|
12
|
+
import {
|
|
13
|
+
type DragParams,
|
|
14
|
+
type DragResult,
|
|
15
|
+
type ClickResult,
|
|
16
|
+
type GetTreeResult,
|
|
17
|
+
type JsonNode,
|
|
18
|
+
type KeysResult,
|
|
19
|
+
RPC_ERRORS,
|
|
20
|
+
type ScreenshotResult,
|
|
21
|
+
type ScrollResult,
|
|
22
|
+
type SetValueResult,
|
|
23
|
+
type TypeResult,
|
|
24
|
+
type WaitCondition,
|
|
25
|
+
type WaitForResult,
|
|
26
|
+
type WebViewEvalResult,
|
|
27
|
+
type WebViewInfo,
|
|
28
|
+
type WindowsResult,
|
|
26
29
|
} from "@nativedesktop/react/rpc";
|
|
27
30
|
import { TimedClient } from "./client.ts";
|
|
28
31
|
import { type DialogScript, dialogScriptEnv } from "./dialogs.ts";
|
|
@@ -143,8 +146,12 @@ interface ResolvedConfig {
|
|
|
143
146
|
onStderr?: (line: string) => void;
|
|
144
147
|
}
|
|
145
148
|
|
|
149
|
+
/// The slice of Bun's FileSink the log pump uses. `write` is typed
|
|
150
|
+
/// `number | Promise<number>` upstream (it buffers, and only awaits on flush),
|
|
151
|
+
/// so narrowing it to `number` here made `bunx tsc --noEmit` fail for every
|
|
152
|
+
/// consumer of this package.
|
|
146
153
|
interface LineSink {
|
|
147
|
-
write(chunk: Uint8Array): number
|
|
154
|
+
write(chunk: Uint8Array): number | Promise<number>;
|
|
148
155
|
end(): void;
|
|
149
156
|
}
|
|
150
157
|
|
|
@@ -345,14 +352,32 @@ export class AppHandle {
|
|
|
345
352
|
return this.rpc.call("getTree", { window });
|
|
346
353
|
}
|
|
347
354
|
|
|
355
|
+
// A window closing between windows() (or an earlier query) and this call
|
|
356
|
+
// races the window ref out from under `opts.window`; getTree answers the
|
|
357
|
+
// distinguishable windowGone error rather than invalidParams (see
|
|
358
|
+
// schema/rpc.json), and every find* helper below treats that exactly like
|
|
359
|
+
// an unmatched testId: null, an empty list, or a "not found" throw. tree()
|
|
360
|
+
// itself does NOT swallow it: a raw tree() caller needs to know the window
|
|
361
|
+
// is gone rather than get a fake empty snapshot that reads the same as
|
|
362
|
+
// "this window really has no children".
|
|
363
|
+
private isWindowGone(e: unknown): boolean {
|
|
364
|
+
return e instanceof AutomationRpcError && e.code === RPC_ERRORS.windowGone.code;
|
|
365
|
+
}
|
|
366
|
+
|
|
348
367
|
async find(testId: string, opts: { window?: number } = {}): Promise<JsonNode | null> {
|
|
349
|
-
const t = await this.tree(opts.window)
|
|
350
|
-
|
|
368
|
+
const t = await this.tree(opts.window).catch((e) => {
|
|
369
|
+
if (this.isWindowGone(e)) return null;
|
|
370
|
+
throw e;
|
|
371
|
+
});
|
|
372
|
+
return t ? findNode(t.root, testId) : null;
|
|
351
373
|
}
|
|
352
374
|
|
|
353
375
|
async findAll(testId: string, opts: { window?: number } = {}): Promise<JsonNode[]> {
|
|
354
|
-
const t = await this.tree(opts.window)
|
|
355
|
-
|
|
376
|
+
const t = await this.tree(opts.window).catch((e) => {
|
|
377
|
+
if (this.isWindowGone(e)) return null;
|
|
378
|
+
throw e;
|
|
379
|
+
});
|
|
380
|
+
return t ? findAllNodes(t.root, testId) : [];
|
|
356
381
|
}
|
|
357
382
|
|
|
358
383
|
async mustFind(testId: string, opts: { window?: number } = {}): Promise<JsonNode> {
|
|
@@ -362,8 +387,11 @@ export class AppHandle {
|
|
|
362
387
|
}
|
|
363
388
|
|
|
364
389
|
async findMatching(pred: (n: JsonNode) => boolean, opts: { window?: number } = {}): Promise<JsonNode | null> {
|
|
365
|
-
const t = await this.tree(opts.window)
|
|
366
|
-
|
|
390
|
+
const t = await this.tree(opts.window).catch((e) => {
|
|
391
|
+
if (this.isWindowGone(e)) return null;
|
|
392
|
+
throw e;
|
|
393
|
+
});
|
|
394
|
+
return t ? findMatchingNode(t.root, pred) : null;
|
|
367
395
|
}
|
|
368
396
|
|
|
369
397
|
// --- actions (single RPC, host-side resolution, no retry loops) ------------
|
|
@@ -444,6 +472,59 @@ export class AppHandle {
|
|
|
444
472
|
return this.waitFor(contains ? { testId, valueContains: rendered } : { testId, valueEquals: rendered }, rest);
|
|
445
473
|
}
|
|
446
474
|
|
|
475
|
+
// --- webview / page ------------------------------------------------------
|
|
476
|
+
// The page half of the vocabulary. None of it needs the app to forward
|
|
477
|
+
// events: url and title come off the engine, page text and eval run in the
|
|
478
|
+
// page itself.
|
|
479
|
+
|
|
480
|
+
waitForUrl(testId: string, urlContains: string, opts?: WaitOpts): Promise<WaitForResult> {
|
|
481
|
+
return this.waitFor({ testId, urlContains }, opts);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
waitForPageTitle(testId: string, pageTitleContains: string, opts?: WaitOpts): Promise<WaitForResult> {
|
|
485
|
+
return this.waitFor({ testId, pageTitleContains }, opts);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Injects `document.body.innerText` into the page, re-probed at most once
|
|
489
|
+
* per 250ms — so a match can lag the page by one probe. */
|
|
490
|
+
waitForPageText(testId: string, pageTextContains: string, opts?: WaitOpts): Promise<WaitForResult> {
|
|
491
|
+
return this.waitFor({ testId, pageTextContains }, opts);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
webviewInfo(target: Target, opts: { window?: number } = {}): Promise<WebViewInfo> {
|
|
495
|
+
return this.rpc.call("webviewInfo", { ...resolveTarget(target), ...opts });
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Evaluates `code` in the page (optionally in a named isolated world) and
|
|
499
|
+
* answers the result's string rendering. A thrown exception comes back as
|
|
500
|
+
* `{ok: false, error}`, not a rejection — the code ran. */
|
|
501
|
+
evalInPage(
|
|
502
|
+
target: Target,
|
|
503
|
+
code: string,
|
|
504
|
+
opts: { world?: string; window?: number; timeoutMs?: number } = {},
|
|
505
|
+
): Promise<WebViewEvalResult> {
|
|
506
|
+
return this.rpc.call("webviewEval", { ...resolveTarget(target), code, ...opts });
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** Navigates a webview and waits for the page to commit that URL. The
|
|
510
|
+
* navigate rides the widget's own `url` prop on the app side, so this drives
|
|
511
|
+
* the engine directly instead: `location.href = ...` in the page, then the
|
|
512
|
+
* host-side url predicate. */
|
|
513
|
+
async openAndAwaitLoad(testId: string, url: string, opts: WaitOpts = {}): Promise<WaitForResult> {
|
|
514
|
+
await this.evalInPage({ testId }, `location.href = ${JSON.stringify(url)}`, { window: opts.window });
|
|
515
|
+
// Match on the URL minus its scheme: engines normalise (trailing slash,
|
|
516
|
+
// percent-encoding, http/https upgrades), so the full string rarely
|
|
517
|
+
// survives verbatim.
|
|
518
|
+
return this.waitForUrl(testId, url.replace(/^[a-z]+:\/\//i, ""), opts);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** Screenshot of the window holding a webview. On GTK the in-process
|
|
522
|
+
* snapshot already rasterizes live WebKit content, so this is the ordinary
|
|
523
|
+
* capture with a floor that rejects a blank frame. */
|
|
524
|
+
screenshotPage(path: string, opts: ScreenshotOptions = {}): Promise<ScreenshotResult> {
|
|
525
|
+
return this.screenshot(path, { minBytes: 2048, ...opts });
|
|
526
|
+
}
|
|
527
|
+
|
|
447
528
|
async waitForMarker(marker: string, timeoutMs = 5000): Promise<void> {
|
|
448
529
|
const deadline = Date.now() + timeoutMs;
|
|
449
530
|
for (;;) {
|
package/src/screenshot.ts
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
import { existsSync } from "node:fs";
|
|
9
9
|
import { resolve as resolvePath } from "node:path";
|
|
10
10
|
import type { Backend } from "@nativedesktop/host";
|
|
11
|
-
import type
|
|
11
|
+
import { RPC_ERRORS, type ScreenshotResult } from "@nativedesktop/react/rpc";
|
|
12
12
|
import { pngSize } from "./png.ts";
|
|
13
|
+
import { AutomationRpcError } from "./socket.ts";
|
|
13
14
|
|
|
14
15
|
export interface ScreenshotOptions {
|
|
15
16
|
window?: number;
|
|
@@ -50,6 +51,11 @@ export async function takeScreenshot(
|
|
|
50
51
|
}
|
|
51
52
|
return shot;
|
|
52
53
|
} catch (e) {
|
|
54
|
+
// A closed window is never coming back mid-run, so retrying just
|
|
55
|
+
// burns the retry budget on a guaranteed repeat failure; fail fast
|
|
56
|
+
// with the distinguishable error instead of the generic "N attempts"
|
|
57
|
+
// wrapper the frame-invalidation races below are meant for.
|
|
58
|
+
if (e instanceof AutomationRpcError && e.code === RPC_ERRORS.windowGone.code) throw e;
|
|
53
59
|
lastErr = e as Error;
|
|
54
60
|
if (attempt < retries - 1) await new Promise((r) => setTimeout(r, 150));
|
|
55
61
|
}
|
package/src/socket.ts
CHANGED
|
@@ -21,6 +21,25 @@ interface JsonRpcResponse {
|
|
|
21
21
|
error?: JsonRpcError;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* A rejected call's `code`/`data` survive on the error object (matching
|
|
26
|
+
* schema/rpc.json's `errors` table, e.g. RPC_ERRORS.windowGone.code), so a
|
|
27
|
+
* caller can branch on the error kind without parsing `.message` text.
|
|
28
|
+
* `.message` keeps the existing "<msg> (<code>)" shape so callers that
|
|
29
|
+
* already substring-match a code keep working.
|
|
30
|
+
*/
|
|
31
|
+
export class AutomationRpcError extends Error {
|
|
32
|
+
readonly code: number;
|
|
33
|
+
readonly data: unknown;
|
|
34
|
+
|
|
35
|
+
constructor(code: number, message: string, data?: unknown) {
|
|
36
|
+
super(`${message} (${code})`);
|
|
37
|
+
this.name = "AutomationRpcError";
|
|
38
|
+
this.code = code;
|
|
39
|
+
this.data = data;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
24
43
|
export class AutomationClient {
|
|
25
44
|
private socket!: import("bun").Socket;
|
|
26
45
|
private inbox = new Uint8Array(0);
|
|
@@ -75,7 +94,7 @@ export class AutomationClient {
|
|
|
75
94
|
const pending = this.pending.get(msg.id);
|
|
76
95
|
if (!pending) return; // unknown/stale id — drop
|
|
77
96
|
this.pending.delete(msg.id);
|
|
78
|
-
if (msg.error) pending.reject(new
|
|
97
|
+
if (msg.error) pending.reject(new AutomationRpcError(msg.error.code, msg.error.message, msg.error.data));
|
|
79
98
|
else pending.resolve(msg.result);
|
|
80
99
|
}
|
|
81
100
|
|