@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 +3 -3
- package/src/attach.ts +14 -0
- package/src/cursor.ts +169 -0
- package/src/index.ts +2 -0
- package/src/launch.ts +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nativedesktop/test",
|
|
3
|
-
"version": "0.4.
|
|
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.
|
|
26
|
-
"@nativedesktop/react": "0.4.
|
|
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
|
}
|