@nativedesktop/test 0.1.0 → 0.1.1
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 +1 -1
- package/src/launch.ts +57 -18
- package/src/query.ts +6 -3
- package/src/screenshot.ts +14 -1
package/package.json
CHANGED
package/src/launch.ts
CHANGED
|
@@ -36,6 +36,12 @@ export interface LaunchOptions {
|
|
|
36
36
|
entry: string;
|
|
37
37
|
cwd?: string;
|
|
38
38
|
backend?: Backend;
|
|
39
|
+
/** Pre-resolved host binary path, bypassing @nativedesktop/host's own
|
|
40
|
+
* resolution. For callers @nativedesktop/host can't place on its own: a
|
|
41
|
+
* consumer outside a NativeDesktop checkout (installed as a `file:`/`link:`
|
|
42
|
+
* dep, so the source-checkout fallback can't find it either), or the
|
|
43
|
+
* gtk-on-macOS dev path (no prebuilt ships there by design). */
|
|
44
|
+
hostBinary?: string;
|
|
39
45
|
env?: Record<string, string | undefined>;
|
|
40
46
|
/** ND_DEV=1. Default false. */
|
|
41
47
|
dev?: boolean;
|
|
@@ -57,6 +63,7 @@ const DEFAULT_READY_TIMEOUT_MS = 20_000;
|
|
|
57
63
|
const DEFAULT_RPC_TIMEOUT_MS = 8_000;
|
|
58
64
|
const DEFAULT_RETRIES = 2;
|
|
59
65
|
const MAX_STDERR_LINES = 5000;
|
|
66
|
+
const STDERR_DRAIN_GRACE_MS = 1000;
|
|
60
67
|
const SOCKET_PATH_RE = /ND_AUTOMATION_LISTENING path=(\S+)/;
|
|
61
68
|
|
|
62
69
|
function buildEnv(opts: LaunchOptions): Record<string, string> {
|
|
@@ -84,27 +91,43 @@ function buildEnv(opts: LaunchOptions): Record<string, string> {
|
|
|
84
91
|
return env;
|
|
85
92
|
}
|
|
86
93
|
|
|
94
|
+
interface StderrPump {
|
|
95
|
+
/** Resolves once the stream hits EOF, or once `cancel()` forces it to stop. */
|
|
96
|
+
done: Promise<void>;
|
|
97
|
+
/** Stops the pump without waiting for EOF -- the host's own child (spawned
|
|
98
|
+
* with `.stderr = .inherit`, see src/runtime.zig) can outlive the host and
|
|
99
|
+
* keep the pipe's write end open, so EOF is not guaranteed. */
|
|
100
|
+
cancel: () => void;
|
|
101
|
+
}
|
|
102
|
+
|
|
87
103
|
/** Splits a byte stream into lines, decoding across chunk boundaries. */
|
|
88
|
-
|
|
104
|
+
function pumpStderr(
|
|
89
105
|
stream: ReadableStream<Uint8Array>,
|
|
90
106
|
onLine: (line: string) => void,
|
|
91
107
|
onChunk?: (chunk: Uint8Array) => void,
|
|
92
|
-
):
|
|
108
|
+
): StderrPump {
|
|
93
109
|
const reader = stream.getReader();
|
|
94
110
|
const decoder = new TextDecoder();
|
|
95
111
|
let buf = "";
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
112
|
+
const done = (async () => {
|
|
113
|
+
try {
|
|
114
|
+
for (;;) {
|
|
115
|
+
const { value, done } = await reader.read();
|
|
116
|
+
if (done) break;
|
|
117
|
+
onChunk?.(value);
|
|
118
|
+
buf += decoder.decode(value, { stream: true });
|
|
119
|
+
let idx: number;
|
|
120
|
+
while ((idx = buf.indexOf("\n")) >= 0) {
|
|
121
|
+
onLine(buf.slice(0, idx));
|
|
122
|
+
buf = buf.slice(idx + 1);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
} catch {
|
|
126
|
+
// reader.cancel() below rejects the in-flight read; nothing left to drain.
|
|
105
127
|
}
|
|
106
|
-
|
|
107
|
-
|
|
128
|
+
if (buf.length) onLine(buf);
|
|
129
|
+
})();
|
|
130
|
+
return { done, cancel: () => void reader.cancel().catch(() => {}) };
|
|
108
131
|
}
|
|
109
132
|
|
|
110
133
|
interface ResolvedConfig {
|
|
@@ -134,6 +157,7 @@ interface LiveHost {
|
|
|
134
157
|
exited: boolean;
|
|
135
158
|
exitCode: number | null;
|
|
136
159
|
pumpDone: Promise<void>;
|
|
160
|
+
cancelStderr: () => void;
|
|
137
161
|
exitedPromise: Promise<number>;
|
|
138
162
|
}
|
|
139
163
|
|
|
@@ -183,6 +207,7 @@ export class AppHandle {
|
|
|
183
207
|
exited: false,
|
|
184
208
|
exitCode: null,
|
|
185
209
|
pumpDone: Promise.resolve(),
|
|
210
|
+
cancelStderr: () => {},
|
|
186
211
|
exitedPromise: Promise.resolve(0),
|
|
187
212
|
};
|
|
188
213
|
this.live = live;
|
|
@@ -198,9 +223,9 @@ export class AppHandle {
|
|
|
198
223
|
this.config.onStderr?.(line);
|
|
199
224
|
};
|
|
200
225
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
226
|
+
const pump = pumpStderr(proc.stderr, onLine, logSink ? (chunk) => void logSink.write(chunk) : undefined);
|
|
227
|
+
live.pumpDone = pump.done.finally(() => logSink?.end());
|
|
228
|
+
live.cancelStderr = pump.cancel;
|
|
204
229
|
live.exitedPromise = proc.exited.then((code) => {
|
|
205
230
|
live.exited = true;
|
|
206
231
|
live.exitCode = code;
|
|
@@ -264,7 +289,21 @@ export class AppHandle {
|
|
|
264
289
|
if (!exited && !live.exited) live.proc.kill("SIGKILL");
|
|
265
290
|
}
|
|
266
291
|
await live.exitedPromise.catch(() => {});
|
|
267
|
-
|
|
292
|
+
// The host process above is confirmed dead, but a crash (or a plain
|
|
293
|
+
// SIGTERM the host never handles) can leave its OWN bun child -- spawned
|
|
294
|
+
// with `.stderr = .inherit` (src/runtime.zig) -- orphaned and still
|
|
295
|
+
// holding the write end of this pipe, so `pumpDone` would never see EOF.
|
|
296
|
+
// Give it a short grace period, then force the pump to stop so close()
|
|
297
|
+
// always resolves rather than hanging on a process we don't own a
|
|
298
|
+
// handle to.
|
|
299
|
+
const drained = await Promise.race([
|
|
300
|
+
live.pumpDone.then(() => true).catch(() => true),
|
|
301
|
+
new Promise<boolean>((r) => setTimeout(() => r(false), STDERR_DRAIN_GRACE_MS)),
|
|
302
|
+
]);
|
|
303
|
+
if (!drained) {
|
|
304
|
+
live.cancelStderr();
|
|
305
|
+
await live.pumpDone.catch(() => {});
|
|
306
|
+
}
|
|
268
307
|
}
|
|
269
308
|
|
|
270
309
|
// --- identity ------------------------------------------------------------
|
|
@@ -469,7 +508,7 @@ function tail(lines: string[], n: number): string {
|
|
|
469
508
|
|
|
470
509
|
export async function launchApp(opts: LaunchOptions): Promise<AppHandle> {
|
|
471
510
|
const backend = resolveBackend({ backend: opts.backend });
|
|
472
|
-
const binary = await resolveHostBinary({ backend });
|
|
511
|
+
const binary = opts.hostBinary ?? (await resolveHostBinary({ backend }));
|
|
473
512
|
const config: ResolvedConfig = {
|
|
474
513
|
binary,
|
|
475
514
|
cwd: opts.cwd ?? process.cwd(),
|
package/src/query.ts
CHANGED
|
@@ -29,10 +29,13 @@ export function findMatchingNode(node: JsonNode, pred: (n: JsonNode) => boolean)
|
|
|
29
29
|
return null;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
/** A widget reference for the action RPCs: a bare testID, a bare ref, or an
|
|
33
|
-
|
|
32
|
+
/** A widget reference for the action RPCs: a bare testID, a bare ref, or an
|
|
33
|
+
* explicit descriptor. `action` applies to click only: a SourceTree row
|
|
34
|
+
* action id, dispatched on the row the testId names (or the selected row
|
|
35
|
+
* when targeting the widget itself). */
|
|
36
|
+
export type Target = string | number | { testId?: string; ref?: number; window?: number; action?: string };
|
|
34
37
|
|
|
35
|
-
export function resolveTarget(t: Target): { testId?: string; ref?: number; window?: number } {
|
|
38
|
+
export function resolveTarget(t: Target): { testId?: string; ref?: number; window?: number; action?: string } {
|
|
36
39
|
if (typeof t === "string") return { testId: t };
|
|
37
40
|
if (typeof t === "number") return { ref: t };
|
|
38
41
|
return { ...t };
|
package/src/screenshot.ts
CHANGED
|
@@ -60,7 +60,20 @@ export async function takeScreenshot(
|
|
|
60
60
|
async function captureViaNdshot(deps: ScreenshotDeps, path: string): Promise<ScreenshotResult> {
|
|
61
61
|
if (deps.backend !== "appkit") throw new Error(`via: "ndshot" is macOS/AppKit-only (backend=${deps.backend})`);
|
|
62
62
|
if (!existsSync(NDSHOT_BIN)) throw new Error(`ndshot binary missing at ${NDSHOT_BIN} — run tools/ndshot/build.sh`);
|
|
63
|
-
|
|
63
|
+
// The bare --pid path captures the FIRST window matching the pid, which is
|
|
64
|
+
// the app's menu-bar strip, not its content window. Resolve the real
|
|
65
|
+
// window (largest on-screen) via `ndshot list` and capture by --window-id.
|
|
66
|
+
const list = Bun.spawnSync([NDSHOT_BIN, "list"]);
|
|
67
|
+
if (list.exitCode !== 0) throw new Error(`ndshot list failed: ${list.stderr.toString().trim()}`);
|
|
68
|
+
const win = list.stdout
|
|
69
|
+
.toString()
|
|
70
|
+
.split("\n")
|
|
71
|
+
.filter((l) => l.trim().startsWith("{"))
|
|
72
|
+
.map((l) => JSON.parse(l) as { pid: number; windowID: number; width: number; height: number; onScreen: boolean })
|
|
73
|
+
.filter((w) => w.pid === deps.pid && w.onScreen && w.height > 100)
|
|
74
|
+
.sort((a, b) => b.width * b.height - a.width * a.height)[0];
|
|
75
|
+
if (!win) throw new Error(`ndshot list found no capturable window for pid ${deps.pid}`);
|
|
76
|
+
const proc = Bun.spawn([NDSHOT_BIN, "capture", "--out", path, "--window-id", String(win.windowID)], {
|
|
64
77
|
stdout: "ignore",
|
|
65
78
|
stderr: "pipe",
|
|
66
79
|
});
|