@nyssance/tauri-hasgard 0.3.0 → 0.5.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/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;
@@ -515,9 +739,11 @@ var HasgardApplication = class {
515
739
  while (Date.now() <= deadline) {
516
740
  if ((await this.windows()).some((window) => window.label === label)) {
517
741
  const window = this.window(label);
518
- const state = await window.state();
519
- if (state.url !== "about:blank" && state.readyState !== "loading") {
520
- return window;
742
+ try {
743
+ const state = await window.state();
744
+ if (state.url !== "about:blank" && state.readyState !== "loading") return window;
745
+ } catch (error) {
746
+ if (!(error instanceof HasgardRpcError) || error.code !== -32002) throw error;
521
747
  }
522
748
  }
523
749
  await new Promise((resolve) => setTimeout(resolve, 50));
@@ -660,8 +886,14 @@ var HasgardWindow = class {
660
886
  async scrollBy(options) {
661
887
  await this.rpc.call("scroll", withWindow(this.label, options));
662
888
  }
663
- async press(key) {
664
- await this.rpc.call("press", withWindow(this.label, { key }));
889
+ async press(key, options) {
890
+ const params = { key };
891
+ if (options) {
892
+ if (options.completion !== void 0) params.completion = options.completion;
893
+ if (options.waitFor !== void 0) params.waitFor = options.waitFor;
894
+ if (options.timeoutMs !== void 0) params.timeout = options.timeoutMs;
895
+ }
896
+ await this.rpc.call("press", withWindow(this.label, params));
665
897
  }
666
898
  async diff(options = {}) {
667
899
  const { reference, ...snapshotOptions } = options;
@@ -776,6 +1008,32 @@ var HasgardWindow = class {
776
1008
  if (!dataUrl.startsWith(prefix)) throw new Error("screenshot result is not a PNG data URL");
777
1009
  return Buffer.from(dataUrl.slice(prefix.length), "base64");
778
1010
  }
1011
+ /** Start a bounded recording of this native window. macOS only; requires ffmpeg. */
1012
+ async startVideo(options) {
1013
+ await this.rpc.call("video.start", {
1014
+ window: this.label,
1015
+ output_path: options.outputPath,
1016
+ ...options.fps === void 0 ? {} : { fps: options.fps },
1017
+ ...options.maxDurationMs === void 0 ? {} : { max_duration_ms: options.maxDurationMs }
1018
+ });
1019
+ }
1020
+ /** Stop recording; capture and encoder failures reject rather than returning an empty artifact. */
1021
+ async stopVideo() {
1022
+ const value = expectRecord(await this.rpc.call("video.stop", { window: this.label }), "video.stop result");
1023
+ return {
1024
+ outputPath: expectString(value.output_path, "video.output_path"),
1025
+ frames: expectNumber(value.frames, "video.frames"),
1026
+ durationMs: expectNumber(value.duration_ms, "video.duration_ms"),
1027
+ byteSize: expectNumber(value.byte_size, "video.byte_size")
1028
+ };
1029
+ }
1030
+ async videoStatus() {
1031
+ const value = expectRecord(await this.rpc.call("video.status", { window: this.label }), "video.status result");
1032
+ return {
1033
+ active: expectBoolean(value.active, "video.active"),
1034
+ pendingResult: expectBoolean(value.pending_result, "video.pending_result")
1035
+ };
1036
+ }
779
1037
  /**
780
1038
  * Capture this window through the native compositor, writing a PNG to
781
1039
  * `outputPath`. macOS only.
@@ -1140,7 +1398,9 @@ var HasgardLocator = class _HasgardLocator {
1140
1398
  });
1141
1399
  }
1142
1400
  async isVisible() {
1143
- return this.withTarget(async (target) => {
1401
+ return this.window.runExclusive(async () => {
1402
+ const target = await this.resolveUnique(true);
1403
+ if (target === null) return false;
1144
1404
  const value = expectRecord(await this.window.call("visible", this.scoped(target)), "visible result");
1145
1405
  return expectBoolean(value.visible, "visible.visible");
1146
1406
  });
@@ -1212,7 +1472,7 @@ var HasgardLocator = class _HasgardLocator {
1212
1472
  last() {
1213
1473
  return this.nth(-1);
1214
1474
  }
1215
- async resolveUnique() {
1475
+ async resolveUnique(allowMissing = false) {
1216
1476
  if (this.query.kind === "selector" && !this.query.filters?.length) {
1217
1477
  return this.query.index === void 0 ? bySelector(this.query.selector) : { selector: this.query.selector, index: this.query.index };
1218
1478
  }
@@ -1220,10 +1480,12 @@ var HasgardLocator = class _HasgardLocator {
1220
1480
  if (this.query.index !== void 0) {
1221
1481
  const element = elements[absoluteIndex(this.query.index, elements.length)];
1222
1482
  if (!element) {
1483
+ if (allowMissing) return null;
1223
1484
  throw new Error(`Locator has no element at index ${this.query.index} (${elements.length} matched)`);
1224
1485
  }
1225
1486
  return byRef(element.ref);
1226
1487
  }
1488
+ if (elements.length === 0 && allowMissing) return null;
1227
1489
  if (elements.length !== 1) {
1228
1490
  throw new Error(`Locator resolved to ${elements.length} elements; expected exactly one`);
1229
1491
  }
@@ -1348,6 +1610,7 @@ function createHasgardTest(config) {
1348
1610
  const expect = createHasgardExpect(config.expect);
1349
1611
  const readinessTimeoutMs = config.launch ? config.launch.timeoutMs : 3e4;
1350
1612
  const fixtureTimeoutMs = config.launch ? config.launch.timeoutMs + 3e4 : 3e4;
1613
+ const processes = /* @__PURE__ */ new WeakMap();
1351
1614
  const test = base.extend({
1352
1615
  hasgard: [
1353
1616
  async ({}, use, workerInfo) => {
@@ -1359,10 +1622,20 @@ function createHasgardTest(config) {
1359
1622
  else await process2.waitForSocket(socketPath, workerInfo.project.timeout);
1360
1623
  await rpc.connect(workerInfo.project.timeout);
1361
1624
  const hasgard = new HasgardApplication(rpc);
1625
+ processes.set(hasgard, process2);
1362
1626
  const ping = await hasgard.ping();
1363
1627
  if (ping.status !== "ok") throw new Error(`Unexpected Hasgard ping status: ${ping.status}`);
1364
1628
  await hasgard.waitForWindowReady(config.windowLabel, config.readySelector, readinessTimeoutMs);
1365
1629
  await use(hasgard);
1630
+ } catch (error) {
1631
+ if (config.launch) {
1632
+ try {
1633
+ console.error(`Hasgard fixture failed: ${JSON.stringify(await process2.diagnostics(250))}`);
1634
+ } catch (diagnosticError) {
1635
+ console.error("Hasgard diagnostics failed:", diagnosticError);
1636
+ }
1637
+ }
1638
+ throw error;
1366
1639
  } finally {
1367
1640
  rpc.disconnect();
1368
1641
  await process2.stop();
@@ -1373,11 +1646,21 @@ function createHasgardTest(config) {
1373
1646
  window: async ({ hasgard }, use, testInfo) => {
1374
1647
  await use(hasgard.window(config.windowLabel));
1375
1648
  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
- });
1649
+ const managed = processes.get(hasgard);
1650
+ if (managed && config.launch) {
1651
+ const diagnostics = await managed.diagnostics(250);
1652
+ await testInfo.attach("hasgard-process", {
1653
+ body: JSON.stringify(diagnostics, null, 2),
1654
+ contentType: "application/json"
1655
+ });
1656
+ if (diagnostics.exit) console.error(`Hasgard application exited: ${JSON.stringify(diagnostics)}`);
1657
+ }
1658
+ try {
1659
+ const screenshot = await hasgard.window(config.windowLabel).screenshot();
1660
+ await testInfo.attach("hasgard-screenshot", { body: screenshot, contentType: "image/png" });
1661
+ } catch (error) {
1662
+ await testInfo.attach("hasgard-screenshot-error", { body: String(error), contentType: "text/plain" });
1663
+ }
1381
1664
  }
1382
1665
  }
1383
1666
  });
@@ -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
@@ -91,6 +91,23 @@ export declare class HasgardWindow {
91
91
  evaluate<T>(script: string): Promise<T>;
92
92
  invoke<T>(command: string, args: Record<string, JsonValue>): Promise<T>;
93
93
  screenshot(selector?: string): Promise<Buffer>;
94
+ /** Start a bounded recording of this native window. macOS only; requires ffmpeg. */
95
+ startVideo(options: {
96
+ outputPath: string;
97
+ fps?: number;
98
+ maxDurationMs?: number;
99
+ }): Promise<void>;
100
+ /** Stop recording; capture and encoder failures reject rather than returning an empty artifact. */
101
+ stopVideo(): Promise<{
102
+ outputPath: string;
103
+ frames: number;
104
+ durationMs: number;
105
+ byteSize: number;
106
+ }>;
107
+ videoStatus(): Promise<{
108
+ active: boolean;
109
+ pendingResult: boolean;
110
+ }>;
94
111
  /**
95
112
  * Capture this window through the native compositor, writing a PNG to
96
113
  * `outputPath`. macOS only.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nyssance/tauri-hasgard",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
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"