@nyssance/tauri-hasgard 0.3.0 → 0.4.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/README.md CHANGED
@@ -29,6 +29,50 @@ export const { test, expect } = createHasgardTest({
29
29
 
30
30
  The fixture passes the selected socket to the application as `TAURI_HASGARD_SOCKET`, so each Playwright worker receives an isolated connection.
31
31
 
32
+ On macOS, a managed launch owns a detached process group. Teardown
33
+ waits for that group to exit, including children left behind by a launcher,
34
+ and escalates from SIGTERM to SIGKILL after five seconds. Startup failures also
35
+ clean up, and concurrent `stop()` calls share the same cleanup. A live endpoint
36
+ is rejected rather than unlinked; readiness requires a successful connection.
37
+ If cleanup fails, ownership is retained so `stop()` can be retried; another
38
+ launch is blocked until that cleanup succeeds.
39
+ Diagnostics retain the last 64 Ki characters per output stream with a truncation
40
+ marker. `await process.diagnostics()` exposes those tails and the actual exit
41
+ code or signal. Failed tests attach them; a failed screenshot does not replace
42
+ the original test error. Teardown removes the owned stale socket after its
43
+ process group exits, checking its identity and preserving live or replaced paths.
44
+
45
+ An independent supervisor owns the application and watches the worker's IPC
46
+ connection. If the worker is killed with SIGKILL, the supervisor still terminates
47
+ the application group and exits. Killing the supervisor itself or children that
48
+ deliberately create a new session or process group are outside this guarantee.
49
+ Attach-only fixtures do not terminate an application they did not launch.
50
+
51
+ `window.press(key)` uses native OS input and, by default, waits for a trusted
52
+ WebView keyup before the next press may switch windows. For OS shortcuts and
53
+ keys that navigate or close the document, explicitly use
54
+ `window.press(key, { completion: "native" })`; that mode confirms posting only.
55
+ Bare modifiers also use native posting completion. The active input method
56
+ remains in effect: use `fill` for exact text, and `Shift+m` for a shifted key.
57
+
58
+ To keep keyboard ownership until an application state is observable:
59
+
60
+ ```ts
61
+ await window.press("Tab", {
62
+ waitFor: "document.activeElement.id === 'save'",
63
+ timeoutMs: 1_000
64
+ })
65
+ ```
66
+
67
+ The postcondition timeout is capped at ten seconds. Failures retain their
68
+ `HasgardRpcError.data.phase` and `injected` status; never blindly retry an
69
+ already-injected or uncertain press. See [the wire contract](../../docs/protocol.md#native-keyboard-completion).
70
+
71
+ Requests are limited to 1,048,576 UTF-8 bytes including the trailing newline,
72
+ matching the plugin. Oversized requests fail before transmission. An explicit
73
+ null result is supported; malformed responses are rejected instead of returning
74
+ an invented value.
75
+
32
76
  ## Test
33
77
 
34
78
  ```ts
@@ -0,0 +1,31 @@
1
+ // src/process-group.js
2
+ import { setTimeout as delay } from "timers/promises";
3
+ function signalGroup(pid, signal) {
4
+ try {
5
+ process.kill(-pid, signal);
6
+ return true;
7
+ } catch (error) {
8
+ if (error.code === "ESRCH") return false;
9
+ if (signal === 0 && error.code === "EPERM") return true;
10
+ throw error;
11
+ }
12
+ }
13
+ async function waitForExit(pid, timeoutMs) {
14
+ const deadline = Date.now() + timeoutMs;
15
+ while (signalGroup(pid, 0)) {
16
+ if (Date.now() >= deadline) return false;
17
+ await delay(25);
18
+ }
19
+ return true;
20
+ }
21
+ async function stopProcessGroup(pid) {
22
+ signalGroup(pid, "SIGTERM");
23
+ if (!await waitForExit(pid, 5e3)) {
24
+ signalGroup(pid, "SIGKILL");
25
+ if (!await waitForExit(pid, 5e3)) throw new Error(`Hasgard process group ${pid} survived SIGKILL`);
26
+ }
27
+ }
28
+
29
+ export {
30
+ stopProcessGroup
31
+ };
package/dist/index.d.ts CHANGED
@@ -3,5 +3,5 @@ export { createHasgardExpect } from "./expect.js";
3
3
  export { createHasgardTest } from "./fixture.js";
4
4
  export { HasgardProcess } from "./process-manager.js";
5
5
  export { HasgardRpcClient, HasgardRpcError } from "./rpc-client.js";
6
- export type { BoundingBox, ClickOptions, ConsoleLogEntry, ConsoleLogOptions, DialogListing, DialogPolicy, DialogRecord, DialogType, DiffChange, DiffOptions, DragOffset, DropFile, ElementPosition, FileInput, FilterOptions, FormEntry, FormField, FormsDump, FulfillOptions, HasgardFixtures, HasgardLaunchConfig, HasgardTarget, HasgardTestConfig, HasgardWorkerFixtures, InterceptedRequest, JsonPrimitive, JsonValue, KeyModifier, MouseButton, NativeScreenshot, NativeScreenshotOptions, NetworkRequestEntry, NetworkRequestOptions, QueryDimension, RecorderEntry, RecorderResult, RecorderStatus, RoleQuery, RouteListing, RouteOptions, RouteRule, ScrollOptions, Snapshot, SnapshotDiff, SnapshotElement, SnapshotOptions, StorageEntry, StorageListing, StorageOptions, TextQuery, WaitForFunctionOptions, WaitOptions, WatchChanges, WatchNode, WatchOptions, WindowInfo, WindowState } from "./types.js";
6
+ export type { BoundingBox, ClickOptions, ConsoleLogEntry, ConsoleLogOptions, DialogListing, DialogPolicy, DialogRecord, DialogType, DiffChange, DiffOptions, DragOffset, DropFile, ElementPosition, FileInput, FilterOptions, FormEntry, FormField, FormsDump, FulfillOptions, HasgardFixtures, HasgardLaunchConfig, HasgardTarget, HasgardTestConfig, HasgardWorkerFixtures, InterceptedRequest, JsonPrimitive, JsonValue, KeyModifier, MouseButton, NativeScreenshot, NativeScreenshotOptions, NetworkRequestEntry, NetworkRequestOptions, PressOptions, QueryDimension, RecorderEntry, RecorderResult, RecorderStatus, RoleQuery, RouteListing, RouteOptions, RouteRule, ScrollOptions, Snapshot, SnapshotDiff, SnapshotElement, SnapshotOptions, StorageEntry, StorageListing, StorageOptions, TextQuery, WaitForFunctionOptions, WaitOptions, WatchChanges, WatchNode, WatchOptions, WindowInfo, WindowState } from "./types.js";
7
7
  export { byPoint, byRef, bySelector, HasgardApplication, HasgardFrameLocator, HasgardLocator, HasgardWindow } from "./window.js";
package/dist/index.js CHANGED
@@ -1,3 +1,7 @@
1
+ import {
2
+ stopProcessGroup
3
+ } from "./chunk-N7YDYAKI.js";
4
+
1
5
  // src/endpoint.ts
2
6
  import { tmpdir } from "os";
3
7
  import { join } from "path";
@@ -11,20 +15,18 @@ function hasgardEndpointPath(name) {
11
15
 
12
16
  // src/expect.ts
13
17
  async function poll(read, matches, timeoutMs) {
18
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0)
19
+ throw new RangeError("Assertion timeout must be a finite non-negative number");
14
20
  const deadline = Date.now() + timeoutMs;
15
- let value;
16
- let error;
17
- while (Date.now() <= deadline) {
21
+ for (; ; ) {
18
22
  try {
19
- value = await read();
20
- error = void 0;
21
- if (matches(value)) return { pass: true, value, error };
22
- } catch (caught) {
23
- error = caught;
23
+ const value = await read();
24
+ if (matches(value) || Date.now() >= deadline) return value;
25
+ } catch (error) {
26
+ if (Date.now() >= deadline) throw error;
24
27
  }
25
- await new Promise((resolve) => setTimeout(resolve, 50));
28
+ await new Promise((resolve) => setTimeout(resolve, Math.min(50, Math.max(0, deadline - Date.now()))));
26
29
  }
27
- return { pass: false, value, error };
28
30
  }
29
31
  function createHasgardExpect(playwrightExpect) {
30
32
  return playwrightExpect.extend({
@@ -36,8 +38,8 @@ function createHasgardExpect(playwrightExpect) {
36
38
  timeout
37
39
  );
38
40
  return {
39
- pass: result.value === true,
40
- message: () => this.isNot ? "expected Hasgard locator not to be visible" : `expected Hasgard locator to be visible within ${timeout}ms${result.error ? `: ${String(result.error)}` : ""}`
41
+ pass: result === true,
42
+ message: () => this.isNot ? "expected Hasgard locator not to be visible" : `expected Hasgard locator to be visible within ${timeout}ms`
41
43
  };
42
44
  },
43
45
  async toHaveText(locator, expected, options = {}) {
@@ -53,24 +55,55 @@ function createHasgardExpect(playwrightExpect) {
53
55
  timeout
54
56
  );
55
57
  return {
56
- pass: result.value === void 0 ? false : matches(result.value),
57
- message: () => this.isNot ? `expected Hasgard locator text not to match ${String(expected)}, received ${String(result.value)}` : `expected Hasgard locator text to match ${String(expected)}, received ${String(result.value)}`
58
+ pass: matches(result),
59
+ message: () => this.isNot ? `expected Hasgard locator text not to match ${String(expected)}, received ${String(result)}` : `expected Hasgard locator text to match ${String(expected)}, received ${String(result)}`
58
60
  };
59
61
  }
60
62
  });
61
63
  }
62
64
 
63
65
  // src/process-manager.ts
64
- import { spawn } from "child_process";
65
- import { access, rm } from "fs/promises";
66
+ import { fork, spawn } from "child_process";
67
+ import { lstat } from "fs/promises";
66
68
  import { createConnection } from "net";
69
+ import { setTimeout as delay } from "timers/promises";
67
70
  var HasgardProcess = class {
68
71
  child;
69
72
  stdout = "";
70
73
  stderr = "";
74
+ starting;
75
+ stopping;
76
+ applicationPid;
77
+ applicationExit;
78
+ /** Bounded log tails and the supervisor's actual child-close result. */
79
+ async diagnostics(waitForExitMs = 0) {
80
+ if (!Number.isFinite(waitForExitMs) || waitForExitMs < 0) throw new RangeError("Invalid diagnostics wait");
81
+ const deadline = Date.now() + waitForExitMs;
82
+ while (this.child && !this.applicationExit && Date.now() < deadline)
83
+ await delay(Math.min(25, deadline - Date.now()));
84
+ return { pid: this.applicationPid, exit: this.applicationExit, stdout: this.stdout, stderr: this.stderr };
85
+ }
71
86
  async start(config, socketPath) {
72
- if (this.child) throw new Error("Hasgard app process is already running");
73
- if (process.platform !== "win32") await rm(socketPath, { force: true });
87
+ if (this.child || this.stopping || this.starting) throw new Error("Hasgard app process is already running");
88
+ this.starting = this.launch(config, socketPath);
89
+ try {
90
+ await this.starting;
91
+ } finally {
92
+ this.starting = void 0;
93
+ }
94
+ }
95
+ async launch(config, socketPath) {
96
+ this.stdout = "";
97
+ this.stderr = "";
98
+ this.applicationPid = void 0;
99
+ this.applicationExit = void 0;
100
+ if (process.platform !== "win32" && await endpointAcceptsConnections(socketPath)) {
101
+ throw new Error(`Hasgard endpoint is already in use: ${socketPath}`);
102
+ }
103
+ if (process.platform !== "win32") {
104
+ await this.launchSupervised(config, socketPath);
105
+ return;
106
+ }
74
107
  const child = spawn(config.command, config.args, {
75
108
  cwd: config.cwd,
76
109
  env: { ...process.env, ...config.env, TAURI_HASGARD_SOCKET: socketPath },
@@ -79,68 +112,206 @@ var HasgardProcess = class {
79
112
  });
80
113
  this.child = child;
81
114
  child.stdout?.on("data", (data) => {
82
- this.stdout += data.toString();
115
+ this.stdout = appendOutput(this.stdout, data.toString());
83
116
  });
84
117
  child.stderr?.on("data", (data) => {
85
- this.stderr += data.toString();
118
+ this.stderr = appendOutput(this.stderr, data.toString());
86
119
  });
87
- await new Promise((resolve, reject) => {
88
- child.once("spawn", resolve);
89
- child.once("error", reject);
120
+ try {
121
+ await new Promise((resolve, reject) => {
122
+ child.once("spawn", resolve);
123
+ child.once("error", reject);
124
+ });
125
+ await this.waitForSocket(socketPath, config.timeoutMs);
126
+ } catch (error) {
127
+ try {
128
+ await this.stopChild();
129
+ } catch (cleanupError) {
130
+ throw new AggregateError([error, cleanupError], "Hasgard launch failed and process cleanup failed");
131
+ }
132
+ throw error;
133
+ }
134
+ }
135
+ async launchSupervised(config, socketPath) {
136
+ const child = fork(new URL("./process-supervisor.js", import.meta.url), [], {
137
+ detached: true,
138
+ execArgv: [],
139
+ stdio: ["ignore", "ignore", "pipe", "ipc"]
140
+ });
141
+ this.child = child;
142
+ child.stderr?.on("data", (data) => {
143
+ this.stderr = appendOutput(this.stderr, String(data));
90
144
  });
91
145
  try {
146
+ await new Promise((resolve, reject) => {
147
+ const timer = setTimeout(() => reject(new Error("Hasgard process supervisor did not start")), config.timeoutMs);
148
+ const failed = (error) => {
149
+ clearTimeout(timer);
150
+ reject(error);
151
+ };
152
+ child.once("error", failed);
153
+ child.once("exit", () => failed(new Error("Hasgard process supervisor exited")));
154
+ child.on(
155
+ "message",
156
+ (message) => {
157
+ if (message.type === "stdout") this.stdout = appendOutput(this.stdout, message.data);
158
+ else if (message.type === "stderr") this.stderr = appendOutput(this.stderr, message.data);
159
+ else if (message.type === "exit") this.applicationExit = { code: message.code, signal: message.signal };
160
+ else if (message.type === "error") failed(Object.assign(new Error(message.message), { code: message.code }));
161
+ else if (message.type === "started") {
162
+ if (!Number.isSafeInteger(message.pid) || message.pid <= 1) {
163
+ failed(new Error("Invalid supervised application PID"));
164
+ return;
165
+ }
166
+ this.applicationPid = message.pid;
167
+ clearTimeout(timer);
168
+ resolve();
169
+ }
170
+ }
171
+ );
172
+ child.send({ ...config, env: { ...process.env, ...config.env, TAURI_HASGARD_SOCKET: socketPath } }, (error) => {
173
+ if (error) failed(error);
174
+ });
175
+ });
92
176
  await this.waitForSocket(socketPath, config.timeoutMs);
177
+ await this.confirmEndpointOwnership(child, socketPath, config.timeoutMs);
93
178
  } catch (error) {
94
- await this.stop();
179
+ try {
180
+ await this.stopChild();
181
+ } catch (cleanupError) {
182
+ throw new AggregateError([error, cleanupError], "Hasgard launch failed and process cleanup failed");
183
+ }
95
184
  throw error;
96
185
  }
97
186
  }
98
187
  async waitForSocket(socketPath, timeoutMs) {
99
188
  const deadline = Date.now() + timeoutMs;
100
189
  while (Date.now() <= deadline) {
101
- if (this.child?.exitCode !== null && this.child?.exitCode !== void 0) {
190
+ const child = this.child;
191
+ if (this.applicationExit) {
192
+ const { code, signal } = this.applicationExit;
102
193
  throw new Error(
103
- `Tauri process exited with code ${this.child.exitCode}
194
+ `Tauri process exited with ${signal ? `signal ${signal}` : `code ${code}`}
104
195
  stdout:
105
196
  ${this.stdout}
106
197
  stderr:
107
198
  ${this.stderr}`
108
199
  );
109
200
  }
110
- if (process.platform === "win32") {
111
- if (await namedPipeAcceptsConnections(socketPath)) return;
112
- } else {
113
- try {
114
- await access(socketPath);
115
- return;
116
- } catch (error) {
117
- const code = error.code;
118
- if (code !== "ENOENT") throw error;
119
- }
201
+ if (child && (child.exitCode !== null || child.signalCode !== null)) {
202
+ throw new Error(
203
+ `Tauri process exited with ${child.signalCode ? `signal ${child.signalCode}` : `code ${child.exitCode}`}
204
+ stdout:
205
+ ${this.stdout}
206
+ stderr:
207
+ ${this.stderr}`
208
+ );
120
209
  }
121
- await new Promise((resolve) => setTimeout(resolve, 50));
210
+ if (await endpointAcceptsConnections(socketPath)) return;
211
+ await delay(50);
122
212
  }
123
213
  throw new Error(`Hasgard socket did not appear within ${timeoutMs}ms: ${socketPath}`);
124
214
  }
215
+ async confirmEndpointOwnership(child, path, timeoutMs) {
216
+ const stat = await lstat(path);
217
+ if (!stat.isSocket()) throw new Error(`Hasgard endpoint is not a socket: ${path}`);
218
+ await new Promise((resolve, reject) => {
219
+ const done = (error) => {
220
+ clearTimeout(timer);
221
+ child.off("message", message);
222
+ child.off("exit", exited);
223
+ if (error) reject(error);
224
+ else resolve();
225
+ };
226
+ const message = (value) => {
227
+ if (value.type === "endpoint-owned") done();
228
+ else if (value.type === "error") done(new Error(value.message));
229
+ };
230
+ const exited = () => done(new Error("Hasgard supervisor exited before acknowledging endpoint ownership"));
231
+ const timer = setTimeout(
232
+ () => done(new Error("Hasgard supervisor did not acknowledge endpoint ownership")),
233
+ timeoutMs
234
+ );
235
+ child.on("message", message);
236
+ child.once("exit", exited);
237
+ child.send({ type: "endpoint", identity: { dev: stat.dev, ino: stat.ino } }, (error) => {
238
+ if (error) done(error);
239
+ });
240
+ });
241
+ }
125
242
  async stop() {
243
+ if (this.starting) await this.starting;
244
+ if (this.stopping) return this.stopping;
245
+ this.stopping = this.stopChild();
246
+ try {
247
+ await this.stopping;
248
+ } finally {
249
+ this.stopping = void 0;
250
+ }
251
+ }
252
+ async stopChild() {
126
253
  const child = this.child;
127
- if (!child || child.exitCode !== null) return;
254
+ if (!child) return;
255
+ if (process.platform !== "win32") {
256
+ if (this.applicationPid !== void 0) {
257
+ await stopProcessGroup(this.applicationPid);
258
+ }
259
+ const exited2 = child.exitCode !== null || child.signalCode !== null ? Promise.resolve() : new Promise((resolve) => child.once("exit", () => resolve()));
260
+ if (child.connected) child.disconnect();
261
+ const abort2 = new AbortController();
262
+ try {
263
+ if (await Promise.race([exited2, delay(11e3, "timeout", { signal: abort2.signal })]) === "timeout") {
264
+ throw new Error(`Hasgard process supervisor ${child.pid} did not finish cleanup`);
265
+ }
266
+ } finally {
267
+ abort2.abort();
268
+ }
269
+ this.child = void 0;
270
+ this.applicationPid = void 0;
271
+ if (child.exitCode !== 0 || child.signalCode !== null) {
272
+ throw new Error(`Hasgard supervisor cleanup failed (${child.signalCode ?? child.exitCode}):
273
+ ${this.stderr}`);
274
+ }
275
+ return;
276
+ }
277
+ if (child.pid === void 0 || child.exitCode !== null || child.signalCode !== null) {
278
+ this.child = void 0;
279
+ return;
280
+ }
128
281
  const exited = new Promise((resolve) => child.once("exit", () => resolve()));
129
282
  if (process.platform === "win32" && child.pid !== void 0) {
130
283
  await terminateWindowsProcessTree(child.pid);
131
284
  } else if (child.pid !== void 0) {
132
285
  process.kill(-child.pid, "SIGTERM");
133
286
  }
134
- const timeout = new Promise((resolve) => setTimeout(() => resolve("timeout"), 5e3));
135
- if (await Promise.race([exited, timeout]) === "timeout") {
136
- if (process.platform === "win32" && child.pid !== void 0) await terminateWindowsProcessTree(child.pid);
137
- else if (child.pid !== void 0) process.kill(-child.pid, "SIGKILL");
138
- await exited;
287
+ const abort = new AbortController();
288
+ const timeout = delay(5e3, "timeout", { signal: abort.signal });
289
+ try {
290
+ if (await Promise.race([exited, timeout]) === "timeout") {
291
+ if (process.platform === "win32" && child.pid !== void 0) await terminateWindowsProcessTree(child.pid);
292
+ else if (child.pid !== void 0) process.kill(-child.pid, "SIGKILL");
293
+ await exited;
294
+ }
295
+ } finally {
296
+ abort.abort();
139
297
  }
140
298
  this.child = void 0;
141
299
  }
142
300
  };
143
- async function namedPipeAcceptsConnections(path) {
301
+ function appendOutput(previous, chunk) {
302
+ const output = previous + chunk;
303
+ return output.length > 65536 ? `[earlier output truncated]
304
+ ${output.slice(-65536)}` : output;
305
+ }
306
+ async function endpointAcceptsConnections(path) {
307
+ if (process.platform !== "win32") {
308
+ try {
309
+ if (!(await lstat(path)).isSocket()) throw new Error(`Hasgard endpoint exists but is not a socket: ${path}`);
310
+ } catch (error) {
311
+ if (error.code === "ENOENT") return false;
312
+ throw error;
313
+ }
314
+ }
144
315
  return new Promise((resolve, reject) => {
145
316
  const socket = createConnection({ path });
146
317
  socket.once("connect", () => {
@@ -168,6 +339,32 @@ async function terminateWindowsProcessTree(pid) {
168
339
  // src/rpc-client.ts
169
340
  import { createConnection as createConnection2 } from "net";
170
341
  import { createInterface } from "readline";
342
+
343
+ // src/protocol.ts
344
+ function isObject(value) {
345
+ return typeof value === "object" && value !== null && !Array.isArray(value);
346
+ }
347
+ function parseRpcResponse(line) {
348
+ const value = JSON.parse(line);
349
+ const invalid = () => new Error(`Invalid Hasgard JSON-RPC envelope: ${line}`);
350
+ if (!isObject(value) || value.jsonrpc !== "2.0" || !Object.hasOwn(value, "id")) throw invalid();
351
+ if (value.id !== null && (!Number.isSafeInteger(value.id) || value.id < 0)) throw invalid();
352
+ const hasResult = Object.hasOwn(value, "result");
353
+ const hasError = Object.hasOwn(value, "error");
354
+ if (hasResult === hasError) throw invalid();
355
+ if (hasResult && value.id === null) throw invalid();
356
+ if (hasError) {
357
+ const error = value.error;
358
+ if (!isObject(error) || !Number.isInteger(error.code) || error.code < -2147483648 || error.code > 2147483647 || typeof error.message !== "string")
359
+ throw invalid();
360
+ }
361
+ return value;
362
+ }
363
+
364
+ // src/protocol-limits.ts
365
+ var MAX_REQUEST_BYTES = 1048576;
366
+
367
+ // src/rpc-client.ts
171
368
  var HasgardRpcError = class extends Error {
172
369
  code;
173
370
  data;
@@ -191,39 +388,66 @@ var HasgardRpcClient = class {
191
388
  if (this.socket) throw new Error("Hasgard RPC client is already connected");
192
389
  const socket = createConnection2({ path: this.socketPath });
193
390
  this.socket = socket;
194
- await new Promise((resolve, reject) => {
195
- const timeout = setTimeout(() => {
196
- socket.destroy();
197
- reject(new Error(`Hasgard socket connection timed out after ${timeoutMs}ms: ${this.socketPath}`));
198
- }, timeoutMs);
199
- socket.once("connect", () => {
200
- clearTimeout(timeout);
201
- resolve();
202
- });
203
- socket.once("error", (error) => {
204
- clearTimeout(timeout);
205
- reject(error);
391
+ try {
392
+ await new Promise((resolve, reject) => {
393
+ const cleanup = () => {
394
+ clearTimeout(timeout);
395
+ socket.off("connect", connected);
396
+ socket.off("error", failed);
397
+ socket.off("close", closed);
398
+ };
399
+ const connected = () => {
400
+ cleanup();
401
+ resolve();
402
+ };
403
+ const failed = (error) => {
404
+ cleanup();
405
+ reject(error);
406
+ };
407
+ const closed = () => failed(new Error("Hasgard socket closed while connecting"));
408
+ const timeout = setTimeout(() => {
409
+ failed(new Error(`Hasgard socket connection timed out after ${timeoutMs}ms: ${this.socketPath}`));
410
+ socket.destroy();
411
+ }, timeoutMs);
412
+ socket.once("connect", connected);
413
+ socket.once("error", failed);
414
+ socket.once("close", closed);
206
415
  });
207
- });
416
+ } catch (error) {
417
+ if (this.socket === socket) this.socket = void 0;
418
+ socket.destroy();
419
+ throw error;
420
+ }
208
421
  this.lines = createInterface({ input: socket });
209
422
  this.lines.on("line", (line) => this.handleLine(line));
210
- socket.on("close", () => this.rejectPending(new Error("Hasgard socket closed")));
211
- socket.on("error", (error) => this.rejectPending(error));
423
+ socket.on("close", () => {
424
+ if (this.socket !== socket) return;
425
+ this.rejectPending(new Error("Hasgard socket closed"));
426
+ this.disconnect();
427
+ });
428
+ socket.on("error", (error) => {
429
+ if (this.socket === socket) this.rejectPending(error);
430
+ });
212
431
  }
213
432
  async call(method, params) {
214
433
  const socket = this.socket;
215
- if (!socket) throw new Error("Hasgard RPC client is not connected");
434
+ if (!socket || socket.destroyed || !socket.writable) throw new Error("Hasgard RPC client is not connected");
216
435
  const id = this.nextId;
217
436
  this.nextId += 1;
218
437
  const request = params ? { jsonrpc: "2.0", id, method, params } : { jsonrpc: "2.0", id, method };
438
+ const line = `${JSON.stringify(request)}
439
+ `;
440
+ const bytes = Buffer.byteLength(line, "utf8");
441
+ if (bytes > MAX_REQUEST_BYTES) {
442
+ throw new Error(`Hasgard request is ${bytes} bytes; maximum is ${MAX_REQUEST_BYTES} bytes including newline`);
443
+ }
219
444
  const result = new Promise((resolve, reject) => {
220
445
  this.pending.set(id, {
221
446
  resolve: (value) => resolve(value),
222
447
  reject
223
448
  });
224
449
  });
225
- socket.write(`${JSON.stringify(request)}
226
- `, (error) => {
450
+ socket.write(line, (error) => {
227
451
  if (!error) return;
228
452
  const pending = this.pending.get(id);
229
453
  if (!pending) return;
@@ -242,16 +466,16 @@ var HasgardRpcClient = class {
242
466
  handleLine(line) {
243
467
  let response;
244
468
  try {
245
- response = JSON.parse(line);
246
- } catch {
247
- this.rejectPending(new Error(`Invalid Hasgard JSON-RPC response: ${line}`));
469
+ response = parseRpcResponse(line);
470
+ } catch (error) {
471
+ this.rejectPending(error instanceof Error ? error : new Error(String(error)));
248
472
  return;
249
473
  }
250
- if (response.jsonrpc !== "2.0" || !Number.isInteger(response.id)) {
251
- this.rejectPending(new Error(`Invalid Hasgard JSON-RPC envelope: ${line}`));
474
+ if ("error" in response && response.id === null) {
475
+ this.rejectPending(new HasgardRpcError(response.error.code, response.error.message, response.error.data));
252
476
  return;
253
477
  }
254
- const pending = this.pending.get(response.id);
478
+ const pending = response.id === null ? void 0 : this.pending.get(response.id);
255
479
  if (!pending) {
256
480
  this.rejectPending(new Error(`Unexpected Hasgard JSON-RPC response id: ${response.id}`));
257
481
  return;
@@ -660,8 +884,14 @@ var HasgardWindow = class {
660
884
  async scrollBy(options) {
661
885
  await this.rpc.call("scroll", withWindow(this.label, options));
662
886
  }
663
- async press(key) {
664
- await this.rpc.call("press", withWindow(this.label, { key }));
887
+ async press(key, options) {
888
+ const params = { key };
889
+ if (options) {
890
+ if (options.completion !== void 0) params.completion = options.completion;
891
+ if (options.waitFor !== void 0) params.waitFor = options.waitFor;
892
+ if (options.timeoutMs !== void 0) params.timeout = options.timeoutMs;
893
+ }
894
+ await this.rpc.call("press", withWindow(this.label, params));
665
895
  }
666
896
  async diff(options = {}) {
667
897
  const { reference, ...snapshotOptions } = options;
@@ -1140,7 +1370,9 @@ var HasgardLocator = class _HasgardLocator {
1140
1370
  });
1141
1371
  }
1142
1372
  async isVisible() {
1143
- return this.withTarget(async (target) => {
1373
+ return this.window.runExclusive(async () => {
1374
+ const target = await this.resolveUnique(true);
1375
+ if (target === null) return false;
1144
1376
  const value = expectRecord(await this.window.call("visible", this.scoped(target)), "visible result");
1145
1377
  return expectBoolean(value.visible, "visible.visible");
1146
1378
  });
@@ -1212,7 +1444,7 @@ var HasgardLocator = class _HasgardLocator {
1212
1444
  last() {
1213
1445
  return this.nth(-1);
1214
1446
  }
1215
- async resolveUnique() {
1447
+ async resolveUnique(allowMissing = false) {
1216
1448
  if (this.query.kind === "selector" && !this.query.filters?.length) {
1217
1449
  return this.query.index === void 0 ? bySelector(this.query.selector) : { selector: this.query.selector, index: this.query.index };
1218
1450
  }
@@ -1220,10 +1452,12 @@ var HasgardLocator = class _HasgardLocator {
1220
1452
  if (this.query.index !== void 0) {
1221
1453
  const element = elements[absoluteIndex(this.query.index, elements.length)];
1222
1454
  if (!element) {
1455
+ if (allowMissing) return null;
1223
1456
  throw new Error(`Locator has no element at index ${this.query.index} (${elements.length} matched)`);
1224
1457
  }
1225
1458
  return byRef(element.ref);
1226
1459
  }
1460
+ if (elements.length === 0 && allowMissing) return null;
1227
1461
  if (elements.length !== 1) {
1228
1462
  throw new Error(`Locator resolved to ${elements.length} elements; expected exactly one`);
1229
1463
  }
@@ -1348,6 +1582,7 @@ function createHasgardTest(config) {
1348
1582
  const expect = createHasgardExpect(config.expect);
1349
1583
  const readinessTimeoutMs = config.launch ? config.launch.timeoutMs : 3e4;
1350
1584
  const fixtureTimeoutMs = config.launch ? config.launch.timeoutMs + 3e4 : 3e4;
1585
+ const processes = /* @__PURE__ */ new WeakMap();
1351
1586
  const test = base.extend({
1352
1587
  hasgard: [
1353
1588
  async ({}, use, workerInfo) => {
@@ -1359,6 +1594,7 @@ function createHasgardTest(config) {
1359
1594
  else await process2.waitForSocket(socketPath, workerInfo.project.timeout);
1360
1595
  await rpc.connect(workerInfo.project.timeout);
1361
1596
  const hasgard = new HasgardApplication(rpc);
1597
+ processes.set(hasgard, process2);
1362
1598
  const ping = await hasgard.ping();
1363
1599
  if (ping.status !== "ok") throw new Error(`Unexpected Hasgard ping status: ${ping.status}`);
1364
1600
  await hasgard.waitForWindowReady(config.windowLabel, config.readySelector, readinessTimeoutMs);
@@ -1373,11 +1609,21 @@ function createHasgardTest(config) {
1373
1609
  window: async ({ hasgard }, use, testInfo) => {
1374
1610
  await use(hasgard.window(config.windowLabel));
1375
1611
  if (testInfo.status !== testInfo.expectedStatus) {
1376
- const screenshot = await hasgard.window(config.windowLabel).screenshot();
1377
- await testInfo.attach("hasgard-screenshot", {
1378
- body: screenshot,
1379
- contentType: "image/png"
1380
- });
1612
+ const managed = processes.get(hasgard);
1613
+ if (managed && config.launch) {
1614
+ const diagnostics = await managed.diagnostics(250);
1615
+ await testInfo.attach("hasgard-process", {
1616
+ body: JSON.stringify(diagnostics, null, 2),
1617
+ contentType: "application/json"
1618
+ });
1619
+ if (diagnostics.exit) console.error(`Hasgard application exited: ${JSON.stringify(diagnostics)}`);
1620
+ }
1621
+ try {
1622
+ const screenshot = await hasgard.window(config.windowLabel).screenshot();
1623
+ await testInfo.attach("hasgard-screenshot", { body: screenshot, contentType: "image/png" });
1624
+ } catch (error) {
1625
+ await testInfo.attach("hasgard-screenshot-error", { body: String(error), contentType: "text/plain" });
1626
+ }
1381
1627
  }
1382
1628
  }
1383
1629
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -3,7 +3,25 @@ export declare class HasgardProcess {
3
3
  private child;
4
4
  private stdout;
5
5
  private stderr;
6
+ private starting;
7
+ private stopping;
8
+ private applicationPid;
9
+ private applicationExit;
10
+ /** Bounded log tails and the supervisor's actual child-close result. */
11
+ diagnostics(waitForExitMs?: number): Promise<{
12
+ pid: number | undefined;
13
+ exit: {
14
+ code: number | null;
15
+ signal: string | null;
16
+ } | undefined;
17
+ stdout: string;
18
+ stderr: string;
19
+ }>;
6
20
  start(config: HasgardLaunchConfig, socketPath: string): Promise<void>;
21
+ private launch;
22
+ private launchSupervised;
7
23
  waitForSocket(socketPath: string, timeoutMs: number): Promise<void>;
24
+ private confirmEndpointOwnership;
8
25
  stop(): Promise<void>;
26
+ private stopChild;
9
27
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,154 @@
1
+ import {
2
+ stopProcessGroup
3
+ } from "./chunk-N7YDYAKI.js";
4
+
5
+ // src/process-supervisor.js
6
+ import { spawn } from "child_process";
7
+ import { lstatSync, unlinkSync } from "fs";
8
+ import { createConnection } from "net";
9
+ import { setTimeout as delay } from "timers/promises";
10
+ var application;
11
+ var starting = false;
12
+ var stopping;
13
+ var endpoint;
14
+ var initialEndpoint;
15
+ var ownedEndpoint;
16
+ var endpointWatch;
17
+ function identity(path) {
18
+ try {
19
+ const stat = lstatSync(path);
20
+ return { dev: stat.dev, ino: stat.ino, socket: stat.isSocket() };
21
+ } catch (error) {
22
+ if (error.code === "ENOENT") return void 0;
23
+ throw error;
24
+ }
25
+ }
26
+ function sameIdentity(first, second) {
27
+ return first && second && first.dev === second.dev && first.ino === second.ino;
28
+ }
29
+ function captureEndpoint() {
30
+ if (ownedEndpoint || !endpoint) return;
31
+ const current = identity(endpoint);
32
+ if (current?.socket && !sameIdentity(current, initialEndpoint)) {
33
+ ownedEndpoint = current;
34
+ clearInterval(endpointWatch);
35
+ }
36
+ }
37
+ async function removeOwnedEndpoint() {
38
+ if (!ownedEndpoint || !sameIdentity(identity(endpoint), ownedEndpoint)) return;
39
+ const accepting = await new Promise((resolve, reject) => {
40
+ const socket = createConnection({ path: endpoint });
41
+ socket.once("connect", () => {
42
+ socket.destroy();
43
+ resolve(true);
44
+ });
45
+ socket.once("error", (error) => {
46
+ if (error.code === "ECONNREFUSED" || error.code === "ENOENT") resolve(false);
47
+ else reject(error);
48
+ });
49
+ socket.setTimeout(1e3, () => {
50
+ socket.destroy();
51
+ reject(new Error("Endpoint cleanup probe timed out"));
52
+ });
53
+ });
54
+ if (!accepting && sameIdentity(identity(endpoint), ownedEndpoint)) {
55
+ try {
56
+ unlinkSync(endpoint);
57
+ } catch (error) {
58
+ if (error.code !== "ENOENT") throw error;
59
+ }
60
+ }
61
+ }
62
+ process.stderr.on("error", (error) => {
63
+ if (error.code !== "EPIPE") shutdown(1);
64
+ });
65
+ function send(message, stream) {
66
+ if (!process.connected) return;
67
+ const writable = process.send(message, (error) => {
68
+ if (error) {
69
+ if (error.code !== "ERR_IPC_CHANNEL_CLOSED" && error.code !== "EPIPE")
70
+ process.stderr.write(`Hasgard supervisor IPC: ${error.message}
71
+ `);
72
+ if (process.connected) process.disconnect();
73
+ } else stream?.resume();
74
+ });
75
+ if (!writable) stream?.pause();
76
+ }
77
+ process.once("message", (config) => {
78
+ if (!process.connected || stopping) return;
79
+ starting = true;
80
+ try {
81
+ endpoint = config.env.TAURI_HASGARD_SOCKET;
82
+ if (typeof endpoint !== "string" || !endpoint) throw new Error("Supervisor requires TAURI_HASGARD_SOCKET");
83
+ initialEndpoint = identity(endpoint);
84
+ endpointWatch = setInterval(captureEndpoint, 25);
85
+ process.on("message", (message) => {
86
+ if (message.type !== "endpoint") return;
87
+ const current = identity(endpoint);
88
+ if (!current?.socket || !sameIdentity(current, message.identity)) {
89
+ send({ type: "error", message: "Application endpoint changed during readiness" });
90
+ return;
91
+ }
92
+ ownedEndpoint = current;
93
+ clearInterval(endpointWatch);
94
+ send({ type: "endpoint-owned" });
95
+ });
96
+ application = spawn(config.command, config.args, {
97
+ cwd: config.cwd,
98
+ env: config.env,
99
+ detached: true,
100
+ stdio: ["ignore", "pipe", "pipe"]
101
+ });
102
+ application.stdout.on("data", (chunk) => send({ type: "stdout", data: chunk.toString() }, application.stdout));
103
+ application.stderr.on("data", (chunk) => send({ type: "stderr", data: chunk.toString() }, application.stderr));
104
+ application.once("spawn", () => send({ type: "started", pid: application.pid }));
105
+ application.once("error", (error) => send({ type: "error", message: error.message, code: error.code }));
106
+ application.once("close", (code, signal) => send({ type: "exit", code, signal }));
107
+ } catch (error) {
108
+ send({ type: "error", message: error.message, code: error.code });
109
+ }
110
+ });
111
+ process.once("disconnect", () => shutdown(0));
112
+ for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"]) process.on(signal, () => shutdown(0));
113
+ process.on("uncaughtException", (error) => {
114
+ process.stderr.write(`Hasgard supervisor error: ${error.stack}
115
+ `);
116
+ shutdown(1);
117
+ });
118
+ process.on("unhandledRejection", (error) => {
119
+ process.stderr.write(`Hasgard supervisor rejection: ${String(error)}
120
+ `);
121
+ shutdown(1);
122
+ });
123
+ function shutdown(exitCode) {
124
+ if (!stopping) stopping = cleanup(exitCode);
125
+ }
126
+ async function cleanup(exitCode) {
127
+ clearInterval(endpointWatch);
128
+ const pid = application?.pid;
129
+ if (!starting || pid === void 0) process.exit(exitCode);
130
+ let groupStopped = false;
131
+ let endpointFailures = 0;
132
+ for (; ; ) {
133
+ try {
134
+ let endpointError;
135
+ try {
136
+ captureEndpoint();
137
+ } catch (error) {
138
+ endpointError = error;
139
+ }
140
+ if (!groupStopped) {
141
+ await stopProcessGroup(pid);
142
+ groupStopped = true;
143
+ }
144
+ if (endpointError) throw endpointError;
145
+ await removeOwnedEndpoint();
146
+ process.exit(exitCode);
147
+ } catch (error) {
148
+ process.stderr.write(`Hasgard orphan cleanup: ${error.message}
149
+ `);
150
+ if (groupStopped && ++endpointFailures >= 3) process.exit(1);
151
+ await delay(1e3);
152
+ }
153
+ }
154
+ }
@@ -0,0 +1 @@
1
+ export declare const MAX_REQUEST_BYTES = 1048576;
@@ -0,0 +1,16 @@
1
+ import type { JsonValue } from "./types.js";
2
+ export type RpcResponse = {
3
+ jsonrpc: "2.0";
4
+ id: number;
5
+ result: JsonValue;
6
+ } | {
7
+ jsonrpc: "2.0";
8
+ id: number | null;
9
+ error: {
10
+ code: number;
11
+ message: string;
12
+ data?: JsonValue;
13
+ };
14
+ };
15
+ /** Validate the shared Hasgard envelope without inventing missing results. */
16
+ export declare function parseRpcResponse(line: string): RpcResponse;
@@ -0,0 +1 @@
1
+ export {};
package/dist/types.d.ts CHANGED
@@ -78,6 +78,12 @@ export interface DragOffset {
78
78
  }
79
79
  /** A held keyboard modifier. Named as the platform reports them on the event. */
80
80
  export type KeyModifier = "Alt" | "Control" | "Meta" | "Shift";
81
+ /** An application postcondition to await before another native press may focus a window. */
82
+ export interface PressOptions {
83
+ completion?: "webview" | "native";
84
+ waitFor?: string;
85
+ timeoutMs?: number;
86
+ }
81
87
  export type MouseButton = "left" | "middle" | "right";
82
88
  /** A point inside the element, measured from its top-left corner. */
83
89
  export interface ElementPosition {
package/dist/window.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { HasgardRpcClient } from "./rpc-client.js";
2
- import type { BoundingBox, ClickOptions, ConsoleLogEntry, ConsoleLogOptions, DialogListing, DialogPolicy, DiffOptions, DragOffset, DropFile, FileInput, FilterOptions, FormsDump, FulfillOptions, HasgardTarget, JsonValue, NativeScreenshot, NativeScreenshotOptions, NetworkRequestEntry, NetworkRequestOptions, QueryDimension, RecorderEntry, RecorderResult, RecorderStatus, RoleQuery, RouteListing, RouteOptions, ScrollOptions, Snapshot, SnapshotDiff, SnapshotOptions, StorageListing, StorageOptions, TextQuery, WaitForFunctionOptions, WaitOptions, WatchChanges, WatchOptions, WindowInfo, WindowState } from "./types.js";
2
+ import type { BoundingBox, ClickOptions, ConsoleLogEntry, ConsoleLogOptions, DialogListing, DialogPolicy, DiffOptions, DragOffset, DropFile, FileInput, FilterOptions, FormsDump, FulfillOptions, HasgardTarget, JsonValue, NativeScreenshot, NativeScreenshotOptions, NetworkRequestEntry, NetworkRequestOptions, PressOptions, QueryDimension, RecorderEntry, RecorderResult, RecorderStatus, RoleQuery, RouteListing, RouteOptions, ScrollOptions, Snapshot, SnapshotDiff, SnapshotOptions, StorageListing, StorageOptions, TextQuery, WaitForFunctionOptions, WaitOptions, WatchChanges, WatchOptions, WindowInfo, WindowState } from "./types.js";
3
3
  export declare const byRef: (ref: string) => HasgardTarget;
4
4
  export declare const bySelector: (selector: string) => HasgardTarget;
5
5
  export declare const byPoint: (x: number, y: number) => HasgardTarget;
@@ -65,7 +65,7 @@ export declare class HasgardWindow {
65
65
  navigate(url: string): Promise<void>;
66
66
  /** Scroll the document by a relative amount, or to `top`/`bottom`. */
67
67
  scrollBy(options: ScrollOptions): Promise<void>;
68
- press(key: string): Promise<void>;
68
+ press(key: string, options?: PressOptions): Promise<void>;
69
69
  diff(options?: DiffOptions): Promise<SnapshotDiff>;
70
70
  /**
71
71
  * Poll a JavaScript expression in the webview until it returns a truthy
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nyssance/tauri-hasgard",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Playwright Test fixtures for native Tauri Hasgard automation",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -18,12 +18,14 @@
18
18
  "NOTICE.md"
19
19
  ],
20
20
  "scripts": {
21
- "build": "bun node_modules/tsup/dist/cli-default.js src/index.ts --format esm --outDir dist --clean && bun node_modules/typescript/bin/tsc -p tsconfig.json --noEmit false --emitDeclarationOnly --declaration true",
22
- "dev": "tsup src/index.ts --format esm --dts --outDir dist --watch",
21
+ "build": "bun node_modules/tsup/dist/cli-default.js src/index.ts src/process-supervisor.js --format esm --outDir dist --clean && bun node_modules/typescript/bin/tsc -p tsconfig.json --noEmit false --emitDeclarationOnly --declaration true",
22
+ "dev": "bun run --parallel dev:js dev:types",
23
23
  "test": "vitest run",
24
24
  "test:watch": "vitest",
25
25
  "typecheck": "tsc --noEmit",
26
- "clean": "rm -rf dist"
26
+ "clean": "rm -rf dist",
27
+ "dev:js": "tsup src/index.ts src/process-supervisor.js --format esm --outDir dist --watch",
28
+ "dev:types": "tsc -p tsconfig.json --noEmit false --emitDeclarationOnly --declaration true --watch"
27
29
  },
28
30
  "peerDependencies": {
29
31
  "@playwright/test": ">=1.61.1 <2"