@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nativedesktop/test",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/FormalSnake/NativeDesktop#readme",
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
- async function pumpStderr(
104
+ function pumpStderr(
89
105
  stream: ReadableStream<Uint8Array>,
90
106
  onLine: (line: string) => void,
91
107
  onChunk?: (chunk: Uint8Array) => void,
92
- ): Promise<void> {
108
+ ): StderrPump {
93
109
  const reader = stream.getReader();
94
110
  const decoder = new TextDecoder();
95
111
  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);
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
- if (buf.length) onLine(buf);
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
- live.pumpDone = pumpStderr(proc.stderr, onLine, logSink ? (chunk) => void logSink.write(chunk) : undefined).finally(
202
- () => logSink?.end(),
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
- await live.pumpDone.catch(() => {});
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 explicit descriptor. */
33
- export type Target = string | number | { testId?: string; ref?: number; window?: number };
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
- const proc = Bun.spawn([NDSHOT_BIN, "capture", "--out", path, "--pid", String(deps.pid)], {
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
  });