@nativedesktop/test 0.1.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 +26 -0
- package/src/client.ts +38 -0
- package/src/dialogs.ts +25 -0
- package/src/index.ts +27 -0
- package/src/launch.ts +488 -0
- package/src/png.ts +22 -0
- package/src/query.ts +39 -0
- package/src/registry.ts +41 -0
- package/src/screenshot.ts +74 -0
- package/src/socket.ts +100 -0
- package/src/wait.ts +32 -0
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nativedesktop/test",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://github.com/FormalSnake/NativeDesktop#readme",
|
|
7
|
+
"bugs": "https://github.com/FormalSnake/NativeDesktop/issues",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/FormalSnake/NativeDesktop.git",
|
|
11
|
+
"directory": "packages/test"
|
|
12
|
+
},
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"files": ["src"],
|
|
17
|
+
"main": "./src/index.ts",
|
|
18
|
+
"types": "./src/index.ts",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": "./src/index.ts"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@nativedesktop/host": "0.1.0",
|
|
24
|
+
"@nativedesktop/react": "0.1.0"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Wraps socket.ts's AutomationClient (reused, not forked — same framing, same
|
|
2
|
+
// generated RpcMethods map) so every call in the harness carries a timeout: a
|
|
3
|
+
// hung host (deadlock, crashed reader thread) fails a test in rpcTimeoutMs
|
|
4
|
+
// instead of the whole run hanging forever.
|
|
5
|
+
import { AutomationClient } from "./socket.ts";
|
|
6
|
+
import type { RpcMethodName, RpcParams, RpcResult } from "@nativedesktop/react/rpc";
|
|
7
|
+
|
|
8
|
+
export class TimedClient {
|
|
9
|
+
constructor(
|
|
10
|
+
private readonly inner: AutomationClient,
|
|
11
|
+
private timeoutMs: number,
|
|
12
|
+
private readonly stderrTail: (lines: number) => string,
|
|
13
|
+
) {}
|
|
14
|
+
|
|
15
|
+
setTimeoutMs(ms: number): void {
|
|
16
|
+
this.timeoutMs = ms;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
call<M extends RpcMethodName>(
|
|
20
|
+
method: M,
|
|
21
|
+
...params: RpcParams<M> extends undefined ? [] : [RpcParams<M>]
|
|
22
|
+
): Promise<RpcResult<M>> {
|
|
23
|
+
const ms = this.timeoutMs;
|
|
24
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
25
|
+
const timeout = new Promise<never>((_resolve, reject) => {
|
|
26
|
+
timer = setTimeout(() => {
|
|
27
|
+
reject(new Error(`${method} timed out after ${ms}ms\n--- last 40 stderr lines ---\n${this.stderrTail(40)}`));
|
|
28
|
+
}, ms);
|
|
29
|
+
});
|
|
30
|
+
return Promise.race([this.inner.call(method, ...params), timeout]).finally(() => clearTimeout(timer)) as Promise<
|
|
31
|
+
RpcResult<M>
|
|
32
|
+
>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
close(): void {
|
|
36
|
+
this.inner.close();
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/dialogs.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// §1.5's ND_AUTOMATION_DIALOG_SCRIPT: typed builder for the per-method FIFO
|
|
2
|
+
// consumed by src/automation_dialogs.zig. Entry shapes mirror the real
|
|
3
|
+
// systemRequest/widgetCommand results verbatim (verified against
|
|
4
|
+
// packages/react/src/system.ts's `dialog` object and
|
|
5
|
+
// packages/react/src/dialogs.ts's Window-scoped helpers) — NOT the
|
|
6
|
+
// {paths:[...]}/{button:0} shapes sketched in the design doc, which predate
|
|
7
|
+
// the landed result types:
|
|
8
|
+
// - dialog.openFile resolves to a raw string[] (system.ts: `Promise<string[]>`)
|
|
9
|
+
// - dialog.saveFile resolves to a raw string | null
|
|
10
|
+
// - dialog.showMessage resolves to a raw number (button index)
|
|
11
|
+
// - window.showAlert / .openFile / .saveFile settle dialogs.ts's
|
|
12
|
+
// AlertResult / OpenFileResult / SaveFileResult objects
|
|
13
|
+
export interface DialogScript {
|
|
14
|
+
"dialog.openFile"?: string[][];
|
|
15
|
+
"dialog.saveFile"?: (string | null)[];
|
|
16
|
+
"dialog.showMessage"?: number[];
|
|
17
|
+
"window.showAlert"?: { buttonId: string }[];
|
|
18
|
+
"window.openFile"?: { canceled: boolean; paths: string[] }[];
|
|
19
|
+
"window.saveFile"?: { canceled: boolean; path: string | null }[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Serializes a DialogScript to the inline-JSON form of ND_AUTOMATION_DIALOG_SCRIPT. */
|
|
23
|
+
export function dialogScriptEnv(script: DialogScript): string {
|
|
24
|
+
return JSON.stringify(script);
|
|
25
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// @nativedesktop/test — §1.4: launchApp/AppHandle over the automation socket.
|
|
2
|
+
// socket.ts's AutomationClient is the repo's ONE copy of the wire framing;
|
|
3
|
+
// packages/mcp and the drive scripts import it from here rather than forking.
|
|
4
|
+
export { AutomationClient } from "./socket.ts";
|
|
5
|
+
export { launchApp, AppHandle, killAll } from "./launch.ts";
|
|
6
|
+
export type { LaunchOptions } from "./launch.ts";
|
|
7
|
+
|
|
8
|
+
export { TimedClient } from "./client.ts";
|
|
9
|
+
|
|
10
|
+
export { resolveTarget, findNode, findAllNodes, findMatchingNode } from "./query.ts";
|
|
11
|
+
export type { Target } from "./query.ts";
|
|
12
|
+
|
|
13
|
+
export { poll, renderWaitValue } from "./wait.ts";
|
|
14
|
+
export type { WaitOpts } from "./wait.ts";
|
|
15
|
+
|
|
16
|
+
export { takeScreenshot } from "./screenshot.ts";
|
|
17
|
+
export type { ScreenshotOptions } from "./screenshot.ts";
|
|
18
|
+
|
|
19
|
+
export { dialogScriptEnv } from "./dialogs.ts";
|
|
20
|
+
export type { DialogScript } from "./dialogs.ts";
|
|
21
|
+
|
|
22
|
+
export { pngSize } from "./png.ts";
|
|
23
|
+
export type { PngSize } from "./png.ts";
|
|
24
|
+
|
|
25
|
+
// Tree types a caller needs to type find()/tree() results, re-exported so
|
|
26
|
+
// nothing outside this package has to import @nativedesktop/react/rpc itself.
|
|
27
|
+
export type { GetTreeResult, JsonNode } from "@nativedesktop/react/rpc";
|
package/src/launch.ts
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
// §1.4's launchApp/AppHandle: spawns a host binary with NATIVE_AUTOMATION=1,
|
|
2
|
+
// waits for the ready markers on stderr, and connects socket.ts's
|
|
3
|
+
// AutomationClient (wrapped in client.ts's TimedClient) over the parsed
|
|
4
|
+
// socket path. One AppHandle owns exactly one host process at a time;
|
|
5
|
+
// restart() tears it down and relaunches in place.
|
|
6
|
+
import { existsSync, mkdtempSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import type { Backend } from "@nativedesktop/host";
|
|
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,
|
|
26
|
+
} from "@nativedesktop/react/rpc";
|
|
27
|
+
import { TimedClient } from "./client.ts";
|
|
28
|
+
import { type DialogScript, dialogScriptEnv } from "./dialogs.ts";
|
|
29
|
+
import { findAllNodes, findMatchingNode, findNode, resolveTarget, type Target } from "./query.ts";
|
|
30
|
+
import { registerProcess, unregisterProcess } from "./registry.ts";
|
|
31
|
+
import { type ScreenshotOptions, takeScreenshot } from "./screenshot.ts";
|
|
32
|
+
import { poll, renderWaitValue, type WaitOpts } from "./wait.ts";
|
|
33
|
+
|
|
34
|
+
export interface LaunchOptions {
|
|
35
|
+
/** Entry file, e.g. "src/main.tsx" -> ND_SCRIPT. */
|
|
36
|
+
entry: string;
|
|
37
|
+
cwd?: string;
|
|
38
|
+
backend?: Backend;
|
|
39
|
+
env?: Record<string, string | undefined>;
|
|
40
|
+
/** ND_DEV=1. Default false. */
|
|
41
|
+
dev?: boolean;
|
|
42
|
+
/** -> ND_ACL_GRANTS JSON. */
|
|
43
|
+
acl?: Record<string, string[]>;
|
|
44
|
+
/** -> ND_AUTOMATION_DIALOG_SCRIPT (§1.5). */
|
|
45
|
+
dialogScript?: DialogScript;
|
|
46
|
+
readyMarkers?: string[];
|
|
47
|
+
readyTimeoutMs?: number;
|
|
48
|
+
rpcTimeoutMs?: number;
|
|
49
|
+
/** Relaunch attempts on ready failure / early exit, on top of the first try. Default 2. */
|
|
50
|
+
retries?: number;
|
|
51
|
+
logPath?: string;
|
|
52
|
+
onStderr?: (line: string) => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const DEFAULT_READY_MARKERS = ["ND_AUTOMATION_LISTENING", "ND_COMMIT_APPLIED"];
|
|
56
|
+
const DEFAULT_READY_TIMEOUT_MS = 20_000;
|
|
57
|
+
const DEFAULT_RPC_TIMEOUT_MS = 8_000;
|
|
58
|
+
const DEFAULT_RETRIES = 2;
|
|
59
|
+
const MAX_STDERR_LINES = 5000;
|
|
60
|
+
const SOCKET_PATH_RE = /ND_AUTOMATION_LISTENING path=(\S+)/;
|
|
61
|
+
|
|
62
|
+
function buildEnv(opts: LaunchOptions): Record<string, string> {
|
|
63
|
+
const merged: Record<string, string | undefined> = {
|
|
64
|
+
...process.env,
|
|
65
|
+
NATIVE_AUTOMATION: "1",
|
|
66
|
+
ND_SCRIPT: opts.entry,
|
|
67
|
+
};
|
|
68
|
+
if (opts.dev) merged.ND_DEV = "1";
|
|
69
|
+
if (opts.acl) merged.ND_ACL_GRANTS = JSON.stringify(opts.acl);
|
|
70
|
+
if (opts.dialogScript) merged.ND_AUTOMATION_DIALOG_SCRIPT = dialogScriptEnv(opts.dialogScript);
|
|
71
|
+
Object.assign(merged, opts.env);
|
|
72
|
+
const env: Record<string, string> = {};
|
|
73
|
+
for (const [key, value] of Object.entries(merged)) {
|
|
74
|
+
if (value !== undefined) env[key] = value;
|
|
75
|
+
}
|
|
76
|
+
// The GTK host (including GTK-via-Quartz on macOS) hard-fails during
|
|
77
|
+
// startup (ND_RUNTIME_ERROR nd_start_runtime failed) when XDG_RUNTIME_DIR
|
|
78
|
+
// is unset or points at a directory that doesn't exist. Every hand-written
|
|
79
|
+
// bash driver works around this with `mktemp -d`; do it here once so no
|
|
80
|
+
// caller has to remember the quirk.
|
|
81
|
+
if (!env.XDG_RUNTIME_DIR || !existsSync(env.XDG_RUNTIME_DIR)) {
|
|
82
|
+
env.XDG_RUNTIME_DIR = mkdtempSync(join(tmpdir(), "nd-test-xdg-"));
|
|
83
|
+
}
|
|
84
|
+
return env;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Splits a byte stream into lines, decoding across chunk boundaries. */
|
|
88
|
+
async function pumpStderr(
|
|
89
|
+
stream: ReadableStream<Uint8Array>,
|
|
90
|
+
onLine: (line: string) => void,
|
|
91
|
+
onChunk?: (chunk: Uint8Array) => void,
|
|
92
|
+
): Promise<void> {
|
|
93
|
+
const reader = stream.getReader();
|
|
94
|
+
const decoder = new TextDecoder();
|
|
95
|
+
let buf = "";
|
|
96
|
+
for (;;) {
|
|
97
|
+
const { value, done } = await reader.read();
|
|
98
|
+
if (done) break;
|
|
99
|
+
onChunk?.(value);
|
|
100
|
+
buf += decoder.decode(value, { stream: true });
|
|
101
|
+
let idx: number;
|
|
102
|
+
while ((idx = buf.indexOf("\n")) >= 0) {
|
|
103
|
+
onLine(buf.slice(0, idx));
|
|
104
|
+
buf = buf.slice(idx + 1);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (buf.length) onLine(buf);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface ResolvedConfig {
|
|
111
|
+
binary: string;
|
|
112
|
+
cwd: string;
|
|
113
|
+
env: Record<string, string>;
|
|
114
|
+
readyMarkers: string[];
|
|
115
|
+
readyTimeoutMs: number;
|
|
116
|
+
rpcTimeoutMs: number;
|
|
117
|
+
retries: number;
|
|
118
|
+
backend: Backend;
|
|
119
|
+
logPath?: string;
|
|
120
|
+
onStderr?: (line: string) => void;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface LineSink {
|
|
124
|
+
write(chunk: Uint8Array): number;
|
|
125
|
+
end(): void;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface LiveHost {
|
|
129
|
+
proc: import("bun").Subprocess<"ignore", "ignore", "pipe">;
|
|
130
|
+
pid: number;
|
|
131
|
+
stderrLines: string[];
|
|
132
|
+
seenMarkers: Set<string>;
|
|
133
|
+
socketPath: string | undefined;
|
|
134
|
+
exited: boolean;
|
|
135
|
+
exitCode: number | null;
|
|
136
|
+
pumpDone: Promise<void>;
|
|
137
|
+
exitedPromise: Promise<number>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export class AppHandle {
|
|
141
|
+
private live?: LiveHost;
|
|
142
|
+
private client?: TimedClient;
|
|
143
|
+
|
|
144
|
+
private constructor(private readonly config: ResolvedConfig) {}
|
|
145
|
+
|
|
146
|
+
static async launch(config: ResolvedConfig): Promise<AppHandle> {
|
|
147
|
+
const handle = new AppHandle(config);
|
|
148
|
+
await handle.launchWithRetries();
|
|
149
|
+
return handle;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private async launchWithRetries(): Promise<void> {
|
|
153
|
+
let lastErr: Error | undefined;
|
|
154
|
+
for (let attempt = 0; attempt <= this.config.retries; attempt++) {
|
|
155
|
+
try {
|
|
156
|
+
await this.spawnOnce();
|
|
157
|
+
await this.connectRpc();
|
|
158
|
+
return;
|
|
159
|
+
} catch (e) {
|
|
160
|
+
lastErr = e as Error;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
throw new Error(`launchApp: failed after ${this.config.retries + 1} attempt(s): ${lastErr?.message}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private async spawnOnce(): Promise<void> {
|
|
167
|
+
const proc = Bun.spawn([this.config.binary], {
|
|
168
|
+
cwd: this.config.cwd,
|
|
169
|
+
env: this.config.env,
|
|
170
|
+
stdin: "ignore",
|
|
171
|
+
stdout: "ignore",
|
|
172
|
+
stderr: "pipe",
|
|
173
|
+
});
|
|
174
|
+
registerProcess(proc);
|
|
175
|
+
|
|
176
|
+
const logSink: LineSink | undefined = this.config.logPath ? Bun.file(this.config.logPath).writer() : undefined;
|
|
177
|
+
const live: LiveHost = {
|
|
178
|
+
proc,
|
|
179
|
+
pid: proc.pid,
|
|
180
|
+
stderrLines: [],
|
|
181
|
+
seenMarkers: new Set(),
|
|
182
|
+
socketPath: undefined,
|
|
183
|
+
exited: false,
|
|
184
|
+
exitCode: null,
|
|
185
|
+
pumpDone: Promise.resolve(),
|
|
186
|
+
exitedPromise: Promise.resolve(0),
|
|
187
|
+
};
|
|
188
|
+
this.live = live;
|
|
189
|
+
|
|
190
|
+
const onLine = (line: string): void => {
|
|
191
|
+
live.stderrLines.push(line);
|
|
192
|
+
if (live.stderrLines.length > MAX_STDERR_LINES) live.stderrLines.shift();
|
|
193
|
+
for (const marker of this.config.readyMarkers) {
|
|
194
|
+
if (line.includes(marker)) live.seenMarkers.add(marker);
|
|
195
|
+
}
|
|
196
|
+
const match = SOCKET_PATH_RE.exec(line);
|
|
197
|
+
if (match?.[1]) live.socketPath = match[1];
|
|
198
|
+
this.config.onStderr?.(line);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
live.pumpDone = pumpStderr(proc.stderr, onLine, logSink ? (chunk) => void logSink.write(chunk) : undefined).finally(
|
|
202
|
+
() => logSink?.end(),
|
|
203
|
+
);
|
|
204
|
+
live.exitedPromise = proc.exited.then((code) => {
|
|
205
|
+
live.exited = true;
|
|
206
|
+
live.exitCode = code;
|
|
207
|
+
unregisterProcess(proc);
|
|
208
|
+
return code;
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
await this.waitUntilReady(live);
|
|
213
|
+
} catch (e) {
|
|
214
|
+
if (!live.exited) proc.kill("SIGKILL");
|
|
215
|
+
await live.exitedPromise.catch(() => {});
|
|
216
|
+
throw e;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private async waitUntilReady(live: LiveHost): Promise<void> {
|
|
221
|
+
const deadline = Date.now() + this.config.readyTimeoutMs;
|
|
222
|
+
for (;;) {
|
|
223
|
+
if (this.config.readyMarkers.every((m) => live.seenMarkers.has(m))) return;
|
|
224
|
+
if (live.exited) {
|
|
225
|
+
throw new Error(
|
|
226
|
+
`host exited before ready markers were seen (code ${live.exitCode})\n` +
|
|
227
|
+
`--- last 40 stderr lines ---\n${tail(live.stderrLines, 40)}`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
if (Date.now() >= deadline) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
`ready markers not seen within ${this.config.readyTimeoutMs}ms: ${this.config.readyMarkers.join(", ")}\n` +
|
|
233
|
+
`--- last 40 stderr lines ---\n${tail(live.stderrLines, 40)}`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private async connectRpc(): Promise<void> {
|
|
241
|
+
const live = this.live;
|
|
242
|
+
if (!live) throw new Error("connectRpc: no live host");
|
|
243
|
+
if (!live.socketPath) {
|
|
244
|
+
throw new Error(
|
|
245
|
+
`no automation socket path parsed from stderr (ND_AUTOMATION_LISTENING line missing)\n` +
|
|
246
|
+
`--- last 40 stderr lines ---\n${tail(live.stderrLines, 40)}`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
const inner = await AutomationClient.connect(live.socketPath);
|
|
250
|
+
this.client = new TimedClient(inner, this.config.rpcTimeoutMs, (n) => this.stderrTail(n));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private async teardown(): Promise<void> {
|
|
254
|
+
this.client?.close();
|
|
255
|
+
this.client = undefined;
|
|
256
|
+
const live = this.live;
|
|
257
|
+
if (!live) return;
|
|
258
|
+
if (!live.exited) {
|
|
259
|
+
live.proc.kill("SIGTERM");
|
|
260
|
+
const exited = await Promise.race([
|
|
261
|
+
live.exitedPromise.then(() => true),
|
|
262
|
+
new Promise<boolean>((r) => setTimeout(() => r(false), 3000)),
|
|
263
|
+
]);
|
|
264
|
+
if (!exited && !live.exited) live.proc.kill("SIGKILL");
|
|
265
|
+
}
|
|
266
|
+
await live.exitedPromise.catch(() => {});
|
|
267
|
+
await live.pumpDone.catch(() => {});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// --- identity ------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
get pid(): number {
|
|
273
|
+
if (!this.live) throw new Error("AppHandle: host not running");
|
|
274
|
+
return this.live.pid;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
get logPath(): string | undefined {
|
|
278
|
+
return this.config.logPath;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
get socketPath(): string {
|
|
282
|
+
if (!this.live?.socketPath) throw new Error("AppHandle: no automation socket connected");
|
|
283
|
+
return this.live.socketPath;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
get backend(): Backend {
|
|
287
|
+
return this.config.backend;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
get rpc(): TimedClient {
|
|
291
|
+
if (!this.client) throw new Error("AppHandle: not connected");
|
|
292
|
+
return this.client;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
stderr(): string {
|
|
296
|
+
return (this.live?.stderrLines ?? []).join("\n");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
stderrTail(n = 40): string {
|
|
300
|
+
return tail(this.live?.stderrLines ?? [], n);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// --- tree queries ----------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
async tree(window?: number): Promise<GetTreeResult> {
|
|
306
|
+
return this.rpc.call("getTree", { window });
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async find(testId: string, opts: { window?: number } = {}): Promise<JsonNode | null> {
|
|
310
|
+
const t = await this.tree(opts.window);
|
|
311
|
+
return findNode(t.root, testId);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async findAll(testId: string, opts: { window?: number } = {}): Promise<JsonNode[]> {
|
|
315
|
+
const t = await this.tree(opts.window);
|
|
316
|
+
return findAllNodes(t.root, testId);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async mustFind(testId: string, opts: { window?: number } = {}): Promise<JsonNode> {
|
|
320
|
+
const node = await this.find(testId, opts);
|
|
321
|
+
if (!node) throw new Error(`${testId} not found in tree`);
|
|
322
|
+
return node;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async findMatching(pred: (n: JsonNode) => boolean, opts: { window?: number } = {}): Promise<JsonNode | null> {
|
|
326
|
+
const t = await this.tree(opts.window);
|
|
327
|
+
return findMatchingNode(t.root, pred);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// --- actions (single RPC, host-side resolution, no retry loops) ------------
|
|
331
|
+
|
|
332
|
+
click(t: Target): Promise<ClickResult> {
|
|
333
|
+
return this.rpc.call("click", resolveTarget(t));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
setValue(t: Target, value: unknown): Promise<SetValueResult> {
|
|
337
|
+
return this.rpc.call("setValue", { ...resolveTarget(t), value });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
type(t: Target, text: string): Promise<TypeResult> {
|
|
341
|
+
return this.rpc.call("type", { ...resolveTarget(t), text });
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
scroll(t: Target, opts: { dx?: number; dy?: number } = {}): Promise<ScrollResult> {
|
|
345
|
+
return this.rpc.call("scroll", { ...resolveTarget(t), ...opts });
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
hover(t: Target): Promise<ClickResult> {
|
|
349
|
+
return this.rpc.call("hover", resolveTarget(t));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
doubleClick(t: Target): Promise<ClickResult> {
|
|
353
|
+
return this.rpc.call("doubleClick", resolveTarget(t));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
rightClick(t: Target): Promise<ClickResult> {
|
|
357
|
+
return this.rpc.call("rightClick", resolveTarget(t));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
keys(spec: string, opts: { window?: number } = {}): Promise<KeysResult> {
|
|
361
|
+
return this.rpc.call("keys", { keys: spec, ...opts });
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
drag(opts: DragParams): Promise<DragResult> {
|
|
365
|
+
return this.rpc.call("drag", opts);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// --- waitFor + sugar (thin pass-throughs; the host does the polling) -------
|
|
369
|
+
|
|
370
|
+
waitFor(condition: WaitCondition, opts: WaitOpts = {}): Promise<WaitForResult> {
|
|
371
|
+
return this.rpc.call("waitFor", { condition, timeoutMs: opts.timeoutMs, window: opts.window });
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
waitForText(text: string, opts?: WaitOpts): Promise<WaitForResult> {
|
|
375
|
+
return this.waitFor({ textContains: text }, opts);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
waitForPresent(testId: string, opts?: WaitOpts): Promise<WaitForResult> {
|
|
379
|
+
return this.waitFor({ testId, state: "present" }, opts);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
waitForGone(testId: string, opts?: WaitOpts): Promise<WaitForResult> {
|
|
383
|
+
return this.waitFor({ testId, state: "gone" }, opts);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
waitForEnabled(testId: string, opts?: WaitOpts): Promise<WaitForResult> {
|
|
387
|
+
return this.waitFor({ testId, state: "enabled" }, opts);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
waitForDisabled(testId: string, opts?: WaitOpts): Promise<WaitForResult> {
|
|
391
|
+
return this.waitFor({ testId, state: "disabled" }, opts);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
waitForFocused(testId: string, opts?: WaitOpts): Promise<WaitForResult> {
|
|
395
|
+
return this.waitFor({ testId, state: "focused" }, opts);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
waitForCount(testId: string, count: number, opts?: WaitOpts): Promise<WaitForResult> {
|
|
399
|
+
return this.waitFor({ testId, countAtLeast: count }, opts);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
waitForValue(testId: string, value: string | number | boolean, opts: WaitOpts & { contains?: boolean } = {}): Promise<WaitForResult> {
|
|
403
|
+
const { contains, ...rest } = opts;
|
|
404
|
+
const rendered = renderWaitValue(value);
|
|
405
|
+
return this.waitFor(contains ? { testId, valueContains: rendered } : { testId, valueEquals: rendered }, rest);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async waitForMarker(marker: string, timeoutMs = 5000): Promise<void> {
|
|
409
|
+
const deadline = Date.now() + timeoutMs;
|
|
410
|
+
for (;;) {
|
|
411
|
+
if (this.live?.stderrLines.some((l) => l.includes(marker))) return;
|
|
412
|
+
if (Date.now() >= deadline) {
|
|
413
|
+
throw new Error(`marker "${marker}" not seen within ${timeoutMs}ms\n--- last 40 stderr lines ---\n${this.stderrTail(40)}`);
|
|
414
|
+
}
|
|
415
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
windows(): Promise<WindowsResult> {
|
|
420
|
+
return this.rpc.call("windows");
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async waitForWindows(count: number, timeoutMs = 3000): Promise<number> {
|
|
424
|
+
const result = await poll(() => this.windows(), (r) => r.windows.length === count, { timeoutMs }).catch((e: Error) => {
|
|
425
|
+
throw new Error(`waitForWindows(${count}): ${e.message}`);
|
|
426
|
+
});
|
|
427
|
+
return result.windows.length;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// --- screenshot --------------------------------------------------------------
|
|
431
|
+
|
|
432
|
+
screenshot(path: string, opts: ScreenshotOptions = {}): Promise<ScreenshotResult> {
|
|
433
|
+
return takeScreenshot(
|
|
434
|
+
{
|
|
435
|
+
callScreenshot: (p, window) => this.rpc.call("screenshot", { path: p, window }),
|
|
436
|
+
pid: this.pid,
|
|
437
|
+
backend: this.backend,
|
|
438
|
+
},
|
|
439
|
+
path,
|
|
440
|
+
opts,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// --- lifecycle ---------------------------------------------------------------
|
|
445
|
+
|
|
446
|
+
async restart(): Promise<void> {
|
|
447
|
+
await this.teardown();
|
|
448
|
+
await this.launchWithRetries();
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async close(): Promise<void> {
|
|
452
|
+
await this.teardown();
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
kill(): void {
|
|
456
|
+
this.client?.close();
|
|
457
|
+
this.client = undefined;
|
|
458
|
+
if (this.live && !this.live.exited) this.live.proc.kill("SIGKILL");
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async [Symbol.asyncDispose](): Promise<void> {
|
|
462
|
+
await this.close();
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function tail(lines: string[], n: number): string {
|
|
467
|
+
return lines.slice(-n).join("\n");
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export async function launchApp(opts: LaunchOptions): Promise<AppHandle> {
|
|
471
|
+
const backend = resolveBackend({ backend: opts.backend });
|
|
472
|
+
const binary = await resolveHostBinary({ backend });
|
|
473
|
+
const config: ResolvedConfig = {
|
|
474
|
+
binary,
|
|
475
|
+
cwd: opts.cwd ?? process.cwd(),
|
|
476
|
+
env: buildEnv(opts),
|
|
477
|
+
readyMarkers: opts.readyMarkers ?? DEFAULT_READY_MARKERS,
|
|
478
|
+
readyTimeoutMs: opts.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS,
|
|
479
|
+
rpcTimeoutMs: opts.rpcTimeoutMs ?? DEFAULT_RPC_TIMEOUT_MS,
|
|
480
|
+
retries: opts.retries ?? DEFAULT_RETRIES,
|
|
481
|
+
backend,
|
|
482
|
+
logPath: opts.logPath,
|
|
483
|
+
onStderr: opts.onStderr,
|
|
484
|
+
};
|
|
485
|
+
return AppHandle.launch(config);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export { killAll } from "./registry.ts";
|
package/src/png.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// PNG dimension reader: parses the 8-byte signature + IHDR chunk (width/height,
|
|
2
|
+
// big-endian u32 at bytes 16/20) without pulling in an image-decoding
|
|
3
|
+
// dependency. Every drive script used to hand-roll this; this is the one copy.
|
|
4
|
+
|
|
5
|
+
export interface PngSize {
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
11
|
+
|
|
12
|
+
export async function pngSize(path: string): Promise<PngSize> {
|
|
13
|
+
const bytes = new Uint8Array(await Bun.file(path).slice(0, 24).arrayBuffer());
|
|
14
|
+
if (bytes.length < 24) throw new Error(`${path}: too short to be a PNG (${bytes.length} bytes)`);
|
|
15
|
+
for (let i = 0; i < SIGNATURE.length; i++) {
|
|
16
|
+
if (bytes[i] !== SIGNATURE[i]) throw new Error(`${path}: not a PNG (bad signature)`);
|
|
17
|
+
}
|
|
18
|
+
const chunkType = String.fromCharCode(bytes[12]!, bytes[13]!, bytes[14]!, bytes[15]!);
|
|
19
|
+
if (chunkType !== "IHDR") throw new Error(`${path}: first chunk is "${chunkType}", want "IHDR"`);
|
|
20
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
21
|
+
return { width: view.getUint32(16, false), height: view.getUint32(20, false) };
|
|
22
|
+
}
|
package/src/query.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Tree-walking helpers factored out of the four consumer drive scripts, which
|
|
2
|
+
// each hand-rolled the same find/mustFind pair. resolveTarget() is the
|
|
3
|
+
// client-side half of §1.2a's targeting: a caller passes a testID, a raw ref,
|
|
4
|
+
// or an explicit {testId?, ref?, window?}, and every action RPC accepts
|
|
5
|
+
// exactly one of ref/testId (validated host-side).
|
|
6
|
+
import type { JsonNode } from "@nativedesktop/react/rpc";
|
|
7
|
+
|
|
8
|
+
export function findNode(node: JsonNode, testId: string): JsonNode | null {
|
|
9
|
+
if (node.testID === testId) return node;
|
|
10
|
+
for (const child of node.children) {
|
|
11
|
+
const found = findNode(child, testId);
|
|
12
|
+
if (found) return found;
|
|
13
|
+
}
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function findAllNodes(node: JsonNode, testId: string, out: JsonNode[] = []): JsonNode[] {
|
|
18
|
+
if (node.testID === testId) out.push(node);
|
|
19
|
+
for (const child of node.children) findAllNodes(child, testId, out);
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function findMatchingNode(node: JsonNode, pred: (n: JsonNode) => boolean): JsonNode | null {
|
|
24
|
+
if (pred(node)) return node;
|
|
25
|
+
for (const child of node.children) {
|
|
26
|
+
const found = findMatchingNode(child, pred);
|
|
27
|
+
if (found) return found;
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A widget reference for the action RPCs: a bare testID, a bare ref, or an explicit descriptor. */
|
|
33
|
+
export type Target = string | number | { testId?: string; ref?: number; window?: number };
|
|
34
|
+
|
|
35
|
+
export function resolveTarget(t: Target): { testId?: string; ref?: number; window?: number } {
|
|
36
|
+
if (typeof t === "string") return { testId: t };
|
|
37
|
+
if (typeof t === "number") return { ref: t };
|
|
38
|
+
return { ...t };
|
|
39
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Process-lifetime registry of launched host binaries. A thrown assertion in
|
|
2
|
+
// a drive script (or a test runner's own SIGINT) must not orphan a native
|
|
3
|
+
// window — killAll() is wired to fire on every exit path exactly once.
|
|
4
|
+
|
|
5
|
+
const live = new Set<import("bun").Subprocess>();
|
|
6
|
+
let wired = false;
|
|
7
|
+
|
|
8
|
+
export function registerProcess(proc: import("bun").Subprocess): void {
|
|
9
|
+
live.add(proc);
|
|
10
|
+
wireSignalHandlers();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function unregisterProcess(proc: import("bun").Subprocess): void {
|
|
14
|
+
live.delete(proc);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Kills every still-registered host process. Safe to call more than once. */
|
|
18
|
+
export function killAll(): void {
|
|
19
|
+
for (const proc of live) {
|
|
20
|
+
try {
|
|
21
|
+
proc.kill("SIGKILL");
|
|
22
|
+
} catch {
|
|
23
|
+
// already dead
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
live.clear();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function wireSignalHandlers(): void {
|
|
30
|
+
if (wired) return;
|
|
31
|
+
wired = true;
|
|
32
|
+
process.on("exit", killAll);
|
|
33
|
+
process.on("SIGINT", () => {
|
|
34
|
+
killAll();
|
|
35
|
+
process.exit(130);
|
|
36
|
+
});
|
|
37
|
+
process.on("SIGTERM", () => {
|
|
38
|
+
killAll();
|
|
39
|
+
process.exit(143);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// screenshot() with retries + floor checks — subsumes every drive script's
|
|
2
|
+
// hand-rolled screenshotRetrying loop (notes-drive, tabs-drive, m6-drive: a
|
|
3
|
+
// screenshot right after a mutation/animation can race frame invalidation
|
|
4
|
+
// and answer -32603 or a blank frame; poll rather than treat one bad shot as
|
|
5
|
+
// final). `via: "ndshot"` shells out to tools/ndshot (macOS/AppKit only) for
|
|
6
|
+
// the ScreenCaptureKit capture path documented in automation-socket.md, for
|
|
7
|
+
// callers hitting the offscreen-render TextInput/TextArea blanking bug.
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { resolve as resolvePath } from "node:path";
|
|
10
|
+
import type { Backend } from "@nativedesktop/host";
|
|
11
|
+
import type { ScreenshotResult } from "@nativedesktop/react/rpc";
|
|
12
|
+
import { pngSize } from "./png.ts";
|
|
13
|
+
|
|
14
|
+
export interface ScreenshotOptions {
|
|
15
|
+
window?: number;
|
|
16
|
+
retries?: number;
|
|
17
|
+
minHeight?: number;
|
|
18
|
+
minBytes?: number;
|
|
19
|
+
via?: "rpc" | "ndshot";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ScreenshotDeps {
|
|
23
|
+
callScreenshot: (path: string, window?: number) => Promise<ScreenshotResult>;
|
|
24
|
+
pid: number;
|
|
25
|
+
backend: Backend;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const NDSHOT_BIN = resolvePath(import.meta.dir, "..", "..", "..", "tools", "ndshot", "bin", "ndshot");
|
|
29
|
+
|
|
30
|
+
export async function takeScreenshot(
|
|
31
|
+
deps: ScreenshotDeps,
|
|
32
|
+
path: string,
|
|
33
|
+
opts: ScreenshotOptions = {},
|
|
34
|
+
): Promise<ScreenshotResult> {
|
|
35
|
+
const retries = opts.retries ?? 5;
|
|
36
|
+
let lastErr: Error | undefined;
|
|
37
|
+
for (let attempt = 0; attempt < retries; attempt++) {
|
|
38
|
+
try {
|
|
39
|
+
const shot =
|
|
40
|
+
opts.via === "ndshot" ? await captureViaNdshot(deps, path) : await deps.callScreenshot(path, opts.window);
|
|
41
|
+
if (shot.width <= 0 || shot.height <= 0) {
|
|
42
|
+
throw new Error(`screenshot has no dimensions (${shot.width}x${shot.height})`);
|
|
43
|
+
}
|
|
44
|
+
if (opts.minHeight !== undefined && shot.height < opts.minHeight) {
|
|
45
|
+
throw new Error(`screenshot height ${shot.height} below floor ${opts.minHeight}`);
|
|
46
|
+
}
|
|
47
|
+
if (opts.minBytes !== undefined) {
|
|
48
|
+
const size = Bun.file(path).size;
|
|
49
|
+
if (size < opts.minBytes) throw new Error(`screenshot ${size} bytes below floor ${opts.minBytes}`);
|
|
50
|
+
}
|
|
51
|
+
return shot;
|
|
52
|
+
} catch (e) {
|
|
53
|
+
lastErr = e as Error;
|
|
54
|
+
if (attempt < retries - 1) await new Promise((r) => setTimeout(r, 150));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
throw new Error(`screenshot failed after ${retries} attempts: ${lastErr?.message}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function captureViaNdshot(deps: ScreenshotDeps, path: string): Promise<ScreenshotResult> {
|
|
61
|
+
if (deps.backend !== "appkit") throw new Error(`via: "ndshot" is macOS/AppKit-only (backend=${deps.backend})`);
|
|
62
|
+
if (!existsSync(NDSHOT_BIN)) throw new Error(`ndshot binary missing at ${NDSHOT_BIN} — run tools/ndshot/build.sh`);
|
|
63
|
+
const proc = Bun.spawn([NDSHOT_BIN, "capture", "--out", path, "--pid", String(deps.pid)], {
|
|
64
|
+
stdout: "ignore",
|
|
65
|
+
stderr: "pipe",
|
|
66
|
+
});
|
|
67
|
+
const status = await proc.exited;
|
|
68
|
+
if (status !== 0) {
|
|
69
|
+
const err = await new Response(proc.stderr).text();
|
|
70
|
+
throw new Error(`ndshot capture failed (exit ${status}): ${err.trim()}`);
|
|
71
|
+
}
|
|
72
|
+
const { width, height } = await pngSize(path);
|
|
73
|
+
return { path, width, height };
|
|
74
|
+
}
|
package/src/socket.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Framed JSON-RPC 2.0 client for the NativeDesktop automation socket.
|
|
2
|
+
// Wire format matches NDP: u32 LE length prefix + UTF-8 JSON (runtime/ndp.ts),
|
|
3
|
+
// but the payload here is a JSON-RPC 2.0 request/response, not an NDP message.
|
|
4
|
+
// Method names and params/result shapes are GENERATED from schema/rpc.json
|
|
5
|
+
// (the single source of truth shared with the Zig host, src/automation.zig via
|
|
6
|
+
// src/generated/rpc.zig) — `call` is constrained by the generated RpcMethods
|
|
7
|
+
// map, so a schema change is a compile error here, tRPC-style.
|
|
8
|
+
|
|
9
|
+
import type { RpcMethodName, RpcParams, RpcResult } from "@nativedesktop/react/rpc";
|
|
10
|
+
|
|
11
|
+
interface JsonRpcError {
|
|
12
|
+
code: number;
|
|
13
|
+
message: string;
|
|
14
|
+
data?: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface JsonRpcResponse {
|
|
18
|
+
jsonrpc: "2.0";
|
|
19
|
+
id: number;
|
|
20
|
+
result?: unknown;
|
|
21
|
+
error?: JsonRpcError;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class AutomationClient {
|
|
25
|
+
private socket!: import("bun").Socket;
|
|
26
|
+
private inbox = new Uint8Array(0);
|
|
27
|
+
private nextId = 1;
|
|
28
|
+
private pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
|
|
29
|
+
|
|
30
|
+
static async connect(path = process.env.ND_AUTOMATION_SOCKET): Promise<AutomationClient> {
|
|
31
|
+
if (!path) throw new Error("ND_AUTOMATION_SOCKET not set");
|
|
32
|
+
let self!: AutomationClient;
|
|
33
|
+
const socket = await Bun.connect({
|
|
34
|
+
unix: path,
|
|
35
|
+
socket: {
|
|
36
|
+
data(_sock, chunk) {
|
|
37
|
+
self.onData(chunk);
|
|
38
|
+
},
|
|
39
|
+
close() {
|
|
40
|
+
self.onClose();
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
self = new AutomationClient(socket);
|
|
45
|
+
return self;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private constructor(socket: import("bun").Socket) {
|
|
49
|
+
this.socket = socket;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private onClose(): void {
|
|
53
|
+
for (const p of this.pending.values()) p.reject(new Error("automation socket closed"));
|
|
54
|
+
this.pending.clear();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private onData(chunk: Uint8Array): void {
|
|
58
|
+
const merged = new Uint8Array(this.inbox.length + chunk.length);
|
|
59
|
+
merged.set(this.inbox, 0);
|
|
60
|
+
merged.set(chunk, this.inbox.length);
|
|
61
|
+
this.inbox = merged;
|
|
62
|
+
// Drain complete frames; a chunk may contain zero, one, or many frames,
|
|
63
|
+
// and the last frame may be split across the next read.
|
|
64
|
+
while (this.inbox.length >= 4) {
|
|
65
|
+
const view = new DataView(this.inbox.buffer, this.inbox.byteOffset, 4);
|
|
66
|
+
const len = view.getUint32(0, true);
|
|
67
|
+
if (this.inbox.length < 4 + len) break;
|
|
68
|
+
const json = new TextDecoder().decode(this.inbox.subarray(4, 4 + len));
|
|
69
|
+
this.inbox = this.inbox.subarray(4 + len);
|
|
70
|
+
this.dispatch(JSON.parse(json) as JsonRpcResponse);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private dispatch(msg: JsonRpcResponse): void {
|
|
75
|
+
const pending = this.pending.get(msg.id);
|
|
76
|
+
if (!pending) return; // unknown/stale id — drop
|
|
77
|
+
this.pending.delete(msg.id);
|
|
78
|
+
if (msg.error) pending.reject(new Error(`${msg.error.message} (${msg.error.code})`));
|
|
79
|
+
else pending.resolve(msg.result);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
call<M extends RpcMethodName>(
|
|
83
|
+
method: M,
|
|
84
|
+
...params: RpcParams<M> extends undefined ? [] : [RpcParams<M>]
|
|
85
|
+
): Promise<RpcResult<M>> {
|
|
86
|
+
const id = this.nextId++;
|
|
87
|
+
const json = new TextEncoder().encode(JSON.stringify({ jsonrpc: "2.0", id, method, params: params[0] }));
|
|
88
|
+
const frame = new Uint8Array(4 + json.length);
|
|
89
|
+
new DataView(frame.buffer).setUint32(0, json.length, true);
|
|
90
|
+
frame.set(json, 4);
|
|
91
|
+
return new Promise<RpcResult<M>>((resolve, reject) => {
|
|
92
|
+
this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject });
|
|
93
|
+
this.socket.write(frame);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
close(): void {
|
|
98
|
+
this.socket.end();
|
|
99
|
+
}
|
|
100
|
+
}
|
package/src/wait.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// §1.4's waitFor sugar is a thin pass-through to the `waitFor` RPC: the host
|
|
2
|
+
// polls its own retained tree at ~50ms (§1.1) and this client never
|
|
3
|
+
// re-implements that loop. poll() below is the one piece of genuine
|
|
4
|
+
// client-side polling in the harness, for conditions the waitFor vocabulary
|
|
5
|
+
// can't express (window count).
|
|
6
|
+
export interface WaitOpts {
|
|
7
|
+
timeoutMs?: number;
|
|
8
|
+
window?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Generic poll: calls `fn` until `pred` holds or `timeoutMs` elapses. */
|
|
12
|
+
export async function poll<T>(
|
|
13
|
+
fn: () => Promise<T>,
|
|
14
|
+
pred: (value: T) => boolean,
|
|
15
|
+
opts: { timeoutMs?: number; intervalMs?: number } = {},
|
|
16
|
+
): Promise<T> {
|
|
17
|
+
const timeoutMs = opts.timeoutMs ?? 3000;
|
|
18
|
+
const intervalMs = opts.intervalMs ?? 100;
|
|
19
|
+
const deadline = Date.now() + timeoutMs;
|
|
20
|
+
for (;;) {
|
|
21
|
+
const value = await fn();
|
|
22
|
+
if (pred(value)) return value;
|
|
23
|
+
if (Date.now() >= deadline) throw new Error(`poll: predicate never held within ${timeoutMs}ms`);
|
|
24
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Renders a value the same way the host stringifies `value` for
|
|
29
|
+
* valueEquals/valueContains: numbers stringified, bools "true"/"false". */
|
|
30
|
+
export function renderWaitValue(value: string | number | boolean): string {
|
|
31
|
+
return typeof value === "string" ? value : String(value);
|
|
32
|
+
}
|