@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nativedesktop/test",
3
- "version": "0.1.2",
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.1.2",
26
- "@nativedesktop/react": "0.1.2"
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
- const ms = this.timeoutMs;
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 type {
13
- DragParams,
14
- DragResult,
15
- ClickResult,
16
- GetTreeResult,
17
- JsonNode,
18
- KeysResult,
19
- ScreenshotResult,
20
- ScrollResult,
21
- SetValueResult,
22
- TypeResult,
23
- WaitCondition,
24
- WaitForResult,
25
- WindowsResult,
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
- return findNode(t.root, testId);
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
- return findAllNodes(t.root, testId);
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
- return findMatchingNode(t.root, pred);
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 { ScreenshotResult } from "@nativedesktop/react/rpc";
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 Error(`${msg.error.message} (${msg.error.code})`));
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