@nativedesktop/test 0.4.14 → 0.4.16

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.4.14",
3
+ "version": "0.4.16",
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.4.14",
26
- "@nativedesktop/react": "0.4.14"
25
+ "@nativedesktop/host": "0.4.16",
26
+ "@nativedesktop/react": "0.4.16"
27
27
  }
28
28
  }
package/src/attach.ts CHANGED
@@ -15,6 +15,8 @@ import type {
15
15
  WindowsResult,
16
16
  } from "@nativedesktop/react/rpc";
17
17
  import { AutomationClient } from "./socket.ts";
18
+ import { resolveHostBinary } from "@nativedesktop/host";
19
+ import { Cursor } from "./cursor.ts";
18
20
  import { Locator, LocatorFactory, type LocatorClient, type RoleOptions, type TextOptions } from "./locator.ts";
19
21
  import type { Keyboard, Mouse } from "./keyboard.ts";
20
22
  import { renderWaitValue, type WaitOpts } from "./wait.ts";
@@ -106,6 +108,17 @@ export class AttachedApp implements LocatorClient {
106
108
  return this.factory.mouse;
107
109
  }
108
110
 
111
+ private cursorDriver?: Cursor;
112
+
113
+ /** The real system cursor (macOS). See cursor.ts. The helper comes from the
114
+ * locally resolved host binary, since an attached app's own is unknown. */
115
+ get cursor(): Cursor {
116
+ return (this.cursorDriver ??= new Cursor({
117
+ binary: () => resolveHostBinary({ backend: "appkit" }),
118
+ windows: () => this.windows(),
119
+ }));
120
+ }
121
+
109
122
  locator(selector: string): Locator {
110
123
  return this.factory.locator(selector);
111
124
  }
@@ -146,6 +159,7 @@ export class AttachedApp implements LocatorClient {
146
159
  }
147
160
 
148
161
  close(): Promise<void> {
162
+ this.cursorDriver?.close();
149
163
  this.rpc.close();
150
164
  return Promise.resolve();
151
165
  }
package/src/cursor.ts ADDED
@@ -0,0 +1,169 @@
1
+ // app.cursor: the real system cursor, driven through the host binary's
2
+ // `--nd-input` helper (swift/Sources/NDShell/InputHelper.swift), which posts
3
+ // HID-level events. Unlike app.mouse, whose `pointer`/`drag` RPCs post
4
+ // NSEvents into the app's own queue, these are indistinguishable from a
5
+ // physical mouse: hover tracking, native context menus, drag sessions and
6
+ // window-server hit testing all see them. The price is the user's cursor: it
7
+ // moves, and the app is brought to the front first. macOS only, and the host
8
+ // binary needs Accessibility (`<host> --nd-grant` with SIP off, or System
9
+ // Settings).
10
+ import type { WindowsResult } from "@nativedesktop/react/rpc";
11
+ import type { Locator } from "./locator.ts";
12
+
13
+ export type CursorTarget = Locator | { x: number; y: number };
14
+
15
+ export interface CursorButtonOptions {
16
+ button?: "left" | "right" | "middle";
17
+ clickCount?: number;
18
+ }
19
+
20
+ export interface CursorMoveOptions {
21
+ /** Intermediate positions on the way, default 12, so hover and drag
22
+ * tracking see a path rather than a jump. */
23
+ steps?: number;
24
+ }
25
+
26
+ interface CursorDeps {
27
+ binary: () => Promise<string>;
28
+ /** The host's pid, to bring it forward before the first event. An attached
29
+ * app does not know it, and relies on the first click to focus instead. */
30
+ pid?: number;
31
+ windows: () => Promise<WindowsResult>;
32
+ /** Window node ref whose top-left `{x, y}` points and locator geometry are
33
+ * relative to; default the first window. */
34
+ window?: number;
35
+ }
36
+
37
+ type Reply = { ok: boolean; error?: string; [k: string]: unknown };
38
+
39
+ export class Cursor {
40
+ private proc?: ReturnType<typeof Bun.spawn>;
41
+ private lines?: AsyncIterator<string>;
42
+ private queue: Promise<unknown> = Promise.resolve();
43
+ private focused = false;
44
+
45
+ constructor(private readonly deps: CursorDeps) {}
46
+
47
+ /** Moves to the target's centre (or the window-relative point). */
48
+ async move(target: CursorTarget, opts: CursorMoveOptions = {}): Promise<void> {
49
+ const at = await this.point(target);
50
+ await this.send({ op: "move", ...at, steps: opts.steps ?? 12 });
51
+ }
52
+
53
+ hover(target: CursorTarget, opts: CursorMoveOptions = {}): Promise<void> {
54
+ return this.move(target, opts);
55
+ }
56
+
57
+ async down(opts: CursorButtonOptions = {}): Promise<void> {
58
+ await this.send({ op: "down", ...opts });
59
+ }
60
+
61
+ async up(opts: CursorButtonOptions = {}): Promise<void> {
62
+ await this.send({ op: "up", ...opts });
63
+ }
64
+
65
+ async click(target: CursorTarget, opts: CursorButtonOptions & CursorMoveOptions = {}): Promise<void> {
66
+ await this.move(target, opts);
67
+ const clicks = opts.clickCount ?? 1;
68
+ for (let n = 1; n <= clicks; n++) {
69
+ await this.down({ button: opts.button, clickCount: n });
70
+ await this.up({ button: opts.button, clickCount: n });
71
+ }
72
+ }
73
+
74
+ dblclick(target: CursorTarget, opts: CursorButtonOptions & CursorMoveOptions = {}): Promise<void> {
75
+ return this.click(target, { ...opts, clickCount: 2 });
76
+ }
77
+
78
+ rightClick(target: CursorTarget, opts: CursorMoveOptions = {}): Promise<void> {
79
+ return this.click(target, { ...opts, button: "right" });
80
+ }
81
+
82
+ /** Press on `from`, move to `to` through `steps` positions, release. */
83
+ async drag(from: CursorTarget, to: CursorTarget, opts: CursorMoveOptions & { button?: "left" | "right" } = {}): Promise<void> {
84
+ await this.move(from, { steps: opts.steps });
85
+ await this.down({ button: opts.button });
86
+ await this.move(to, { steps: opts.steps ?? 20 });
87
+ await this.up({ button: opts.button });
88
+ }
89
+
90
+ /** Scrolls by pixel deltas over the target; positive dy scrolls content up. */
91
+ async scroll(target: CursorTarget, delta: { dx?: number; dy?: number }): Promise<void> {
92
+ await this.move(target, { steps: 1 });
93
+ await this.send({ op: "scroll", dx: delta.dx ?? 0, dy: delta.dy ?? 0 });
94
+ }
95
+
96
+ /** The cursor's current position in global logical points. */
97
+ async position(): Promise<{ x: number; y: number }> {
98
+ const r = await this.send({ op: "position" });
99
+ return { x: r.x as number, y: r.y as number };
100
+ }
101
+
102
+ close(): void {
103
+ this.proc?.kill();
104
+ this.proc = undefined;
105
+ this.lines = undefined;
106
+ this.focused = false;
107
+ }
108
+
109
+ private async point(target: CursorTarget): Promise<{ x: number; y: number }> {
110
+ const { windows } = await this.deps.windows();
111
+ const win = (this.deps.window === undefined ? windows[0] : windows.find((w) => w.ref === this.deps.window)) ?? windows[0];
112
+ if (!win?.geometry) throw new Error("app.cursor: the window reports no geometry (AppKit only)");
113
+ let local: { x: number; y: number };
114
+ if ("boundingBox" in target) {
115
+ const box = await target.boundingBox();
116
+ if (!box) throw new Error("app.cursor: the target has no geometry");
117
+ local = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
118
+ } else {
119
+ local = target;
120
+ }
121
+ return { x: win.geometry.x + local.x, y: win.geometry.y + local.y };
122
+ }
123
+
124
+ private send(cmd: Record<string, unknown>): Promise<Reply> {
125
+ const run = this.queue.then(() => this.exchange(cmd));
126
+ this.queue = run.catch(() => {});
127
+ return run;
128
+ }
129
+
130
+ private async exchange(cmd: Record<string, unknown>): Promise<Reply> {
131
+ if (!this.proc) {
132
+ if (process.platform !== "darwin") throw new Error("app.cursor is macOS only");
133
+ this.proc = Bun.spawn([await this.deps.binary(), "--nd-input"], { stdin: "pipe", stdout: "pipe", stderr: "inherit" });
134
+ this.lines = readLines(this.proc.stdout as ReadableStream<Uint8Array>);
135
+ }
136
+ if (!this.focused && cmd.op !== "position" && this.deps.pid !== undefined) {
137
+ this.focused = true;
138
+ await this.exchangeRaw({ op: "focus", pid: this.deps.pid });
139
+ // Coming forward (or out of the Stage Manager strip) animates.
140
+ await Bun.sleep(300);
141
+ }
142
+ const reply = await this.exchangeRaw(cmd);
143
+ if (!reply.ok) throw new Error(`app.cursor ${cmd.op}: ${reply.error ?? "failed"}`);
144
+ return reply;
145
+ }
146
+
147
+ private async exchangeRaw(cmd: Record<string, unknown>): Promise<Reply> {
148
+ const stdin = this.proc!.stdin as import("bun").FileSink;
149
+ stdin.write(`${JSON.stringify(cmd)}\n`);
150
+ await stdin.flush();
151
+ const next = await this.lines!.next();
152
+ if (next.done) throw new Error("app.cursor: the input helper exited");
153
+ return JSON.parse(next.value) as Reply;
154
+ }
155
+ }
156
+
157
+ async function* readLines(stream: ReadableStream<Uint8Array>): AsyncGenerator<string> {
158
+ const decoder = new TextDecoder();
159
+ let buf = "";
160
+ for await (const chunk of stream) {
161
+ buf += decoder.decode(chunk, { stream: true });
162
+ let nl = buf.indexOf("\n");
163
+ while (nl >= 0) {
164
+ yield buf.slice(0, nl);
165
+ buf = buf.slice(nl + 1);
166
+ nl = buf.indexOf("\n");
167
+ }
168
+ }
169
+ }
package/src/index.ts CHANGED
@@ -31,6 +31,8 @@ export type { ActionOptions, BoundingBox, LocatorClient, RoleOptions, TextOption
31
31
  export { expect, LocatorAssertions, ValueAssertions } from "./expect.ts";
32
32
  export type { ExpectOptions } from "./expect.ts";
33
33
  export { Keyboard, Mouse, toChord } from "./keyboard.ts";
34
+ export { Cursor } from "./cursor.ts";
35
+ export type { CursorButtonOptions, CursorMoveOptions, CursorTarget } from "./cursor.ts";
34
36
  export { LocatorError, StrictModeError, TimeoutError, describeNode, rankCandidates } from "./errors.ts";
35
37
  export { formatSelector, parseSelector } from "./selectors.ts";
36
38
  export type { SelectorPart, TextSpec } from "./selectors.ts";
package/src/launch.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  type WindowsResult,
31
31
  } from "@nativedesktop/react/rpc";
32
32
  import { TimedClient } from "./client.ts";
33
+ import { Cursor } from "./cursor.ts";
33
34
  import { type DialogScript, dialogScriptEnv } from "./dialogs.ts";
34
35
  import type { Keyboard, Mouse } from "./keyboard.ts";
35
36
  import {
@@ -299,6 +300,8 @@ export class AppHandle implements LocatorClient {
299
300
  }
300
301
 
301
302
  private async teardown(): Promise<void> {
303
+ this.cursorDriver?.close();
304
+ this.cursorDriver = undefined;
302
305
  this.client?.close();
303
306
  this.client = undefined;
304
307
  const live = this.live;
@@ -387,6 +390,17 @@ export class AppHandle implements LocatorClient {
387
390
  return this.locators.mouse;
388
391
  }
389
392
 
393
+ private cursorDriver?: Cursor;
394
+
395
+ /** The real system cursor (macOS). See cursor.ts. */
396
+ get cursor(): Cursor {
397
+ return (this.cursorDriver ??= new Cursor({
398
+ binary: async () => this.config.binary,
399
+ pid: this.pid,
400
+ windows: () => this.windows(),
401
+ }));
402
+ }
403
+
390
404
  locator(selector: string): Locator {
391
405
  return this.locators.locator(selector);
392
406
  }