@nyssance/tauri-hasgard 0.2.1 → 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/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;
@@ -309,6 +533,58 @@ function targetParams(target) {
309
533
  }
310
534
  return { x: target.x, y: target.y };
311
535
  }
536
+ function clickParams(options) {
537
+ const params = {};
538
+ if (options.modifiers !== void 0) params.modifiers = options.modifiers;
539
+ if (options.button !== void 0) params.button = options.button;
540
+ if (options.clickCount !== void 0) params.clickCount = options.clickCount;
541
+ if (options.position !== void 0) params.position = { ...options.position };
542
+ return params;
543
+ }
544
+ function routeParams(options) {
545
+ const params = { pattern: options.pattern };
546
+ if (options.method !== void 0) params.method = options.method;
547
+ if (options.times !== void 0) params.times = options.times;
548
+ if ("status" in options && options.status !== void 0) params.status = options.status;
549
+ if ("body" in options && options.body !== void 0) params.body = options.body;
550
+ if ("contentType" in options && options.contentType !== void 0) {
551
+ params.contentType = options.contentType;
552
+ }
553
+ return params;
554
+ }
555
+ function parseRouteAction(value, context) {
556
+ const action = expectString(value, context);
557
+ if (action !== "fulfill" && action !== "abort") {
558
+ throw new Error(`${context} must be "fulfill" or "abort", got ${action}`);
559
+ }
560
+ return action;
561
+ }
562
+ function parseRouteRule(value, index) {
563
+ const context = `route.list.routes[${index}]`;
564
+ const rule = expectRecord(value, context);
565
+ return {
566
+ id: expectNumber(rule.id, `${context}.id`),
567
+ pattern: expectString(rule.pattern, `${context}.pattern`),
568
+ method: expectNullableString(rule.method, `${context}.method`),
569
+ action: parseRouteAction(rule.action, `${context}.action`),
570
+ status: expectNumber(rule.status, `${context}.status`),
571
+ times: rule.times === null ? null : expectNumber(rule.times, `${context}.times`),
572
+ used: expectNumber(rule.used, `${context}.used`)
573
+ };
574
+ }
575
+ function parseInterceptedRequest(value, index) {
576
+ const context = `route.list.intercepted[${index}]`;
577
+ const entry = expectRecord(value, context);
578
+ return {
579
+ id: expectNumber(entry.id, `${context}.id`),
580
+ timestamp: expectNumber(entry.timestamp, `${context}.timestamp`),
581
+ route_id: expectNumber(entry.route_id, `${context}.route_id`),
582
+ method: expectString(entry.method, `${context}.method`),
583
+ url: expectString(entry.url, `${context}.url`),
584
+ action: parseRouteAction(entry.action, `${context}.action`),
585
+ status: expectNumber(entry.status, `${context}.status`)
586
+ };
587
+ }
312
588
  function absoluteIndex(index, total) {
313
589
  return index < 0 ? total + index : index;
314
590
  }
@@ -550,6 +826,17 @@ var HasgardWindow = class {
550
826
  getByTestId(testId) {
551
827
  return new HasgardLocator(this, { kind: "query", by: "testid", value: testId, query: {} });
552
828
  }
829
+ /**
830
+ * Scope locators to a same-origin `<iframe>`.
831
+ *
832
+ * Cross-origin frames are out of reach: the same-origin policy binds injected
833
+ * script exactly as it binds the page's own, so no bridge can see into a
834
+ * third-party payment or OAuth frame. Those raise an error naming the origin
835
+ * problem rather than reporting a missing element.
836
+ */
837
+ frameLocator(selector) {
838
+ return new HasgardFrameLocator(this, [selector]);
839
+ }
553
840
  async snapshot(options = {}) {
554
841
  const raw = expectRecord(
555
842
  await this.rpc.call("snapshot", withWindow(this.label, options)),
@@ -597,8 +884,14 @@ var HasgardWindow = class {
597
884
  async scrollBy(options) {
598
885
  await this.rpc.call("scroll", withWindow(this.label, options));
599
886
  }
600
- async press(key) {
601
- 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));
602
895
  }
603
896
  async diff(options = {}) {
604
897
  const { reference, ...snapshotOptions } = options;
@@ -787,6 +1080,53 @@ var HasgardWindow = class {
787
1080
  await this.call("dialog.clear");
788
1081
  }
789
1082
  };
1083
+ /**
1084
+ * Shape what the page's own requests receive, without a real backend.
1085
+ *
1086
+ * Rules are declarative rather than Playwright's per-request callback: the
1087
+ * bridge only ever answers requests, so it cannot call back into this process
1088
+ * and await a handler while the page sits inside `fetch`. The same constraint
1089
+ * shaped `dialogs`.
1090
+ *
1091
+ * Rules are checked in registration order and the first match wins, so
1092
+ * register the narrow ones before a catch-all. Both `fetch` and
1093
+ * `XMLHttpRequest` are covered; resources the webview loads itself -- the
1094
+ * document, `<img src>`, stylesheets -- are not, since no page script sees
1095
+ * them.
1096
+ */
1097
+ routes = {
1098
+ /** Answer matching requests with this response instead of the network. */
1099
+ fulfill: async (options) => {
1100
+ const value = expectRecord(
1101
+ await this.call("route.add", { ...routeParams(options), action: "fulfill" }),
1102
+ "route.add result"
1103
+ );
1104
+ return { id: expectNumber(value.id, "route.add.id") };
1105
+ },
1106
+ /** Fail matching requests the way a dropped connection does. */
1107
+ abort: async (options) => {
1108
+ const value = expectRecord(
1109
+ await this.call("route.add", { ...routeParams(options), action: "abort" }),
1110
+ "route.add result"
1111
+ );
1112
+ return { id: expectNumber(value.id, "route.add.id") };
1113
+ },
1114
+ /** The active rules, their use counts, and every request they answered. */
1115
+ list: async () => {
1116
+ const value = expectRecord(await this.call("route.list"), "route.list result");
1117
+ return {
1118
+ routes: expectArray(value.routes, "route.list.routes").map((entry, index) => parseRouteRule(entry, index)),
1119
+ intercepted: expectArray(value.intercepted, "route.list.intercepted").map(
1120
+ (entry, index) => parseInterceptedRequest(entry, index)
1121
+ )
1122
+ };
1123
+ },
1124
+ /** Remove every rule and its interception log. */
1125
+ clear: async () => {
1126
+ const value = expectRecord(await this.call("route.clear"), "route.clear result");
1127
+ return { removed: expectNumber(value.removed, "route.clear.removed") };
1128
+ }
1129
+ };
790
1130
  /** This window's operating-system window id, where the platform exposes one. */
791
1131
  async nativeId() {
792
1132
  const listing = await new HasgardApplication(this.rpc).windows();
@@ -816,6 +1156,49 @@ var HasgardWindow = class {
816
1156
  }
817
1157
  }
818
1158
  };
1159
+ var HasgardFrameLocator = class _HasgardFrameLocator {
1160
+ windowRef;
1161
+ frame;
1162
+ constructor(window, frame) {
1163
+ this.windowRef = window;
1164
+ this.frame = frame;
1165
+ }
1166
+ /** Descend into a frame nested inside this one. */
1167
+ frameLocator(selector) {
1168
+ return new _HasgardFrameLocator(this.windowRef, [...this.frame, selector]);
1169
+ }
1170
+ locator(selector) {
1171
+ return new HasgardLocator(this.windowRef, { kind: "selector", selector, frame: [...this.frame] });
1172
+ }
1173
+ getByRole(role, query = {}) {
1174
+ return new HasgardLocator(this.windowRef, { kind: "role", role, query, frame: [...this.frame] });
1175
+ }
1176
+ getByText(text, query = {}) {
1177
+ return this.byDimension("text", text, query);
1178
+ }
1179
+ getByLabel(text, query = {}) {
1180
+ return this.byDimension("label", text, query);
1181
+ }
1182
+ getByPlaceholder(text, query = {}) {
1183
+ return this.byDimension("placeholder", text, query);
1184
+ }
1185
+ getByAltText(text, query = {}) {
1186
+ return this.byDimension("alt", text, query);
1187
+ }
1188
+ getByTitle(text, query = {}) {
1189
+ return this.byDimension("title", text, query);
1190
+ }
1191
+ getByTestId(testId) {
1192
+ return this.byDimension("testid", testId, {});
1193
+ }
1194
+ /** Snapshot the frame's own document rather than the top one. */
1195
+ async snapshot(options = {}) {
1196
+ return this.windowRef.snapshot({ ...options, frame: [...this.frame] });
1197
+ }
1198
+ byDimension(by, value, query) {
1199
+ return new HasgardLocator(this.windowRef, { kind: "query", by, value, query, frame: [...this.frame] });
1200
+ }
1201
+ };
819
1202
  var HasgardLocator = class _HasgardLocator {
820
1203
  window;
821
1204
  query;
@@ -823,14 +1206,22 @@ var HasgardLocator = class _HasgardLocator {
823
1206
  this.window = window;
824
1207
  this.query = query;
825
1208
  }
826
- async click() {
827
- await this.withTarget((target) => this.window.call("click", targetParams(target)));
1209
+ /**
1210
+ * Click this element.
1211
+ *
1212
+ * A right or middle click raises `contextmenu`/`auxclick` and no `click`, and
1213
+ * `clickCount: 2` escalates `detail` across the presses before a single
1214
+ * `dblclick` -- both matching what the platform does, so a listener bound to
1215
+ * the wrong event fails here rather than in front of a user.
1216
+ */
1217
+ async click(options = {}) {
1218
+ await this.withTarget((target) => this.window.call("click", { ...this.scoped(target), ...clickParams(options) }));
828
1219
  }
829
1220
  async fill(value) {
830
- await this.withTarget((target) => this.window.call("fill", { ...targetParams(target), value }));
1221
+ await this.withTarget((target) => this.window.call("fill", { ...this.scoped(target), value }));
831
1222
  }
832
1223
  async type(text) {
833
- await this.withTarget((target) => this.window.call("type", { ...targetParams(target), text }));
1224
+ await this.withTarget((target) => this.window.call("type", { ...this.scoped(target), text }));
834
1225
  }
835
1226
  /** Empty the control, firing `input` and `change` as `fill` does. */
836
1227
  async clear() {
@@ -847,7 +1238,7 @@ var HasgardLocator = class _HasgardLocator {
847
1238
  const payloads = await readFileInputs(files);
848
1239
  await this.withTarget(
849
1240
  (target) => this.window.call("setInputFiles", {
850
- ...targetParams(target),
1241
+ ...this.scoped(target),
851
1242
  files: payloads
852
1243
  })
853
1244
  );
@@ -862,48 +1253,49 @@ var HasgardLocator = class _HasgardLocator {
862
1253
  async wheel(deltaX, deltaY) {
863
1254
  return this.withTarget(async (target) => {
864
1255
  const value = expectRecord(
865
- await this.window.call("wheel", { ...targetParams(target), deltaX, deltaY }),
1256
+ await this.window.call("wheel", { ...this.scoped(target), deltaX, deltaY }),
866
1257
  "wheel result"
867
1258
  );
868
1259
  return expectBoolean(value.defaultPrevented, "wheel.defaultPrevented");
869
1260
  });
870
1261
  }
871
1262
  async selectOption(value) {
872
- await this.withTarget((target) => this.window.call("select", { ...targetParams(target), value }));
1263
+ await this.withTarget((target) => this.window.call("select", { ...this.scoped(target), value }));
873
1264
  }
874
1265
  /** Drive the checkbox to checked. A no-op when it is already checked. */
875
1266
  async check() {
876
- await this.withTarget((target) => this.window.call("check", { ...targetParams(target), checked: true }));
1267
+ await this.withTarget((target) => this.window.call("check", { ...this.scoped(target), checked: true }));
877
1268
  }
878
1269
  /** Drive the checkbox to unchecked. A no-op when it is already unchecked. */
879
1270
  async uncheck() {
880
- await this.withTarget((target) => this.window.call("check", { ...targetParams(target), checked: false }));
1271
+ await this.withTarget((target) => this.window.call("check", { ...this.scoped(target), checked: false }));
881
1272
  }
882
1273
  /** Flip the checkbox, whatever its current state. */
883
1274
  async toggle() {
884
- await this.withTarget((target) => this.window.call("check", targetParams(target)));
1275
+ await this.withTarget((target) => this.window.call("check", this.scoped(target)));
885
1276
  }
886
1277
  async hover() {
887
- await this.withTarget((target) => this.window.call("hover", targetParams(target)));
1278
+ await this.withTarget((target) => this.window.call("hover", this.scoped(target)));
888
1279
  }
889
- async dblclick() {
890
- await this.withTarget((target) => this.window.call("dblclick", targetParams(target)));
1280
+ /** Shorthand for `click({ clickCount: 2 })`; other options still apply. */
1281
+ async dblclick(options = {}) {
1282
+ await this.click({ ...options, clickCount: 2 });
891
1283
  }
892
1284
  async focus() {
893
- await this.withTarget((target) => this.window.call("focus", targetParams(target)));
1285
+ await this.withTarget((target) => this.window.call("focus", this.scoped(target)));
894
1286
  }
895
1287
  async blur() {
896
- await this.withTarget((target) => this.window.call("blur", targetParams(target)));
1288
+ await this.withTarget((target) => this.window.call("blur", this.scoped(target)));
897
1289
  }
898
1290
  /** Scroll this element's own scrollport. */
899
1291
  async scrollBy(options) {
900
1292
  await this.withTarget(
901
- (target) => this.window.call("scroll", { ...targetParams(target), ...options })
1293
+ (target) => this.window.call("scroll", { ...this.scoped(target), ...options })
902
1294
  );
903
1295
  }
904
1296
  async boundingBox() {
905
1297
  return this.withTarget(async (target) => {
906
- const value = expectRecord(await this.window.call("boundingBox", targetParams(target)), "boundingBox result");
1298
+ const value = expectRecord(await this.window.call("boundingBox", this.scoped(target)), "boundingBox result");
907
1299
  return {
908
1300
  x: expectNumber(value.x, "boundingBox.x"),
909
1301
  y: expectNumber(value.y, "boundingBox.y"),
@@ -914,7 +1306,7 @@ var HasgardLocator = class _HasgardLocator {
914
1306
  }
915
1307
  async isDisabled() {
916
1308
  return this.withTarget(async (target) => {
917
- const value = expectRecord(await this.window.call("disabled", targetParams(target)), "disabled result");
1309
+ const value = expectRecord(await this.window.call("disabled", this.scoped(target)), "disabled result");
918
1310
  return expectBoolean(value.disabled, "disabled.disabled");
919
1311
  });
920
1312
  }
@@ -923,17 +1315,17 @@ var HasgardLocator = class _HasgardLocator {
923
1315
  }
924
1316
  async textContent() {
925
1317
  return this.withTarget(
926
- async (target) => expectString(await this.window.call("text", targetParams(target)), "text result")
1318
+ async (target) => expectString(await this.window.call("text", this.scoped(target)), "text result")
927
1319
  );
928
1320
  }
929
1321
  async inputValue() {
930
1322
  return this.withTarget(
931
- async (target) => expectString(await this.window.call("value", targetParams(target)), "value result")
1323
+ async (target) => expectString(await this.window.call("value", this.scoped(target)), "value result")
932
1324
  );
933
1325
  }
934
1326
  async innerHTML() {
935
1327
  return this.withTarget(
936
- async (target) => expectString(await this.window.call("html", targetParams(target)), "html result")
1328
+ async (target) => expectString(await this.window.call("html", this.scoped(target)), "html result")
937
1329
  );
938
1330
  }
939
1331
  async getAttribute(name) {
@@ -957,41 +1349,46 @@ var HasgardLocator = class _HasgardLocator {
957
1349
  /** Drag this element by a pixel offset from its centre. */
958
1350
  async dragBy(offset) {
959
1351
  await this.withTarget(
960
- (target) => this.window.call("drag", { source: targetParams(target), offset: { x: offset.x, y: offset.y } })
1352
+ (target) => this.window.call("drag", { source: this.scoped(target), offset: { x: offset.x, y: offset.y } })
961
1353
  );
962
1354
  }
963
1355
  /** Dispatch a file drop onto this element. `data` is base64-encoded. */
964
1356
  async dropFiles(files) {
965
1357
  await this.withTarget(
966
1358
  (target) => this.window.call("drop", {
967
- ...targetParams(target),
1359
+ ...this.scoped(target),
968
1360
  files
969
1361
  })
970
1362
  );
971
1363
  }
972
1364
  async attributes() {
973
1365
  return this.withTarget(async (target) => {
974
- const value = expectRecord(await this.window.call("attrs", targetParams(target)), "attrs result");
1366
+ const value = expectRecord(await this.window.call("attrs", this.scoped(target)), "attrs result");
975
1367
  return Object.fromEntries(
976
1368
  Object.entries(value).map(([name, attr]) => [name, expectString(attr, `attribute ${name}`)])
977
1369
  );
978
1370
  });
979
1371
  }
980
1372
  async isVisible() {
981
- return this.withTarget(async (target) => {
982
- const value = expectRecord(await this.window.call("visible", targetParams(target)), "visible result");
1373
+ return this.window.runExclusive(async () => {
1374
+ const target = await this.resolveUnique(true);
1375
+ if (target === null) return false;
1376
+ const value = expectRecord(await this.window.call("visible", this.scoped(target)), "visible result");
983
1377
  return expectBoolean(value.visible, "visible.visible");
984
1378
  });
985
1379
  }
986
1380
  async isChecked() {
987
1381
  return this.withTarget(async (target) => {
988
- const value = expectRecord(await this.window.call("checked", targetParams(target)), "checked result");
1382
+ const value = expectRecord(await this.window.call("checked", this.scoped(target)), "checked result");
989
1383
  return expectBoolean(value.checked, "checked.checked");
990
1384
  });
991
1385
  }
992
1386
  async count() {
993
1387
  const total = this.query.kind === "selector" && !this.query.filters?.length ? expectNumber(
994
- expectRecord(await this.window.call("count", { selector: this.query.selector }), "count result").count,
1388
+ expectRecord(
1389
+ await this.window.call("count", { selector: this.query.selector, ...this.frameParams() }),
1390
+ "count result"
1391
+ ).count,
995
1392
  "count.count"
996
1393
  ) : (await this.resolveAll()).length;
997
1394
  if (this.query.index === void 0) return total;
@@ -1003,7 +1400,8 @@ var HasgardLocator = class _HasgardLocator {
1003
1400
  await this.window.call("wait", {
1004
1401
  selector: this.query.selector,
1005
1402
  gone: options.state === "detached",
1006
- timeout: options.timeoutMs
1403
+ timeout: options.timeoutMs,
1404
+ ...this.frameParams()
1007
1405
  });
1008
1406
  return;
1009
1407
  }
@@ -1046,7 +1444,7 @@ var HasgardLocator = class _HasgardLocator {
1046
1444
  last() {
1047
1445
  return this.nth(-1);
1048
1446
  }
1049
- async resolveUnique() {
1447
+ async resolveUnique(allowMissing = false) {
1050
1448
  if (this.query.kind === "selector" && !this.query.filters?.length) {
1051
1449
  return this.query.index === void 0 ? bySelector(this.query.selector) : { selector: this.query.selector, index: this.query.index };
1052
1450
  }
@@ -1054,15 +1452,30 @@ var HasgardLocator = class _HasgardLocator {
1054
1452
  if (this.query.index !== void 0) {
1055
1453
  const element = elements[absoluteIndex(this.query.index, elements.length)];
1056
1454
  if (!element) {
1455
+ if (allowMissing) return null;
1057
1456
  throw new Error(`Locator has no element at index ${this.query.index} (${elements.length} matched)`);
1058
1457
  }
1059
1458
  return byRef(element.ref);
1060
1459
  }
1460
+ if (elements.length === 0 && allowMissing) return null;
1061
1461
  if (elements.length !== 1) {
1062
1462
  throw new Error(`Locator resolved to ${elements.length} elements; expected exactly one`);
1063
1463
  }
1064
1464
  return byRef(elements[0].ref);
1065
1465
  }
1466
+ /**
1467
+ * Serialize a target together with the frame chain this locator lives in.
1468
+ *
1469
+ * A ref carries its own document, so the bridge ignores `frame` for one; the
1470
+ * chain still travels so that a selector or coordinate target -- which the
1471
+ * bridge must resolve itself -- lands in the right document.
1472
+ */
1473
+ scoped(target) {
1474
+ return { ...targetParams(target), ...this.frameParams() };
1475
+ }
1476
+ frameParams() {
1477
+ return this.query.frame ? { frame: [...this.query.frame] } : {};
1478
+ }
1066
1479
  async withTarget(operation) {
1067
1480
  return this.window.runExclusive(async () => operation(await this.resolveUnique()));
1068
1481
  }
@@ -1079,7 +1492,7 @@ var HasgardLocator = class _HasgardLocator {
1079
1492
  return this.runQuery(params);
1080
1493
  }
1081
1494
  const query = this.query;
1082
- const snapshot = await this.window.snapshot();
1495
+ const snapshot = await this.window.snapshot(query.frame ? { frame: [...query.frame] } : {});
1083
1496
  return snapshot.elements.filter((element) => {
1084
1497
  if (element.role !== query.role) return false;
1085
1498
  const expected = query.query.name;
@@ -1110,7 +1523,7 @@ var HasgardLocator = class _HasgardLocator {
1110
1523
  return current;
1111
1524
  }
1112
1525
  async runQuery(params) {
1113
- return this.parseElements(await this.window.call("query", params), "query");
1526
+ return this.parseElements(await this.window.call("query", { ...params, ...this.frameParams() }), "query");
1114
1527
  }
1115
1528
  parseElements(raw, source) {
1116
1529
  const value = expectRecord(raw, `${source} result`);
@@ -1169,6 +1582,7 @@ function createHasgardTest(config) {
1169
1582
  const expect = createHasgardExpect(config.expect);
1170
1583
  const readinessTimeoutMs = config.launch ? config.launch.timeoutMs : 3e4;
1171
1584
  const fixtureTimeoutMs = config.launch ? config.launch.timeoutMs + 3e4 : 3e4;
1585
+ const processes = /* @__PURE__ */ new WeakMap();
1172
1586
  const test = base.extend({
1173
1587
  hasgard: [
1174
1588
  async ({}, use, workerInfo) => {
@@ -1180,6 +1594,7 @@ function createHasgardTest(config) {
1180
1594
  else await process2.waitForSocket(socketPath, workerInfo.project.timeout);
1181
1595
  await rpc.connect(workerInfo.project.timeout);
1182
1596
  const hasgard = new HasgardApplication(rpc);
1597
+ processes.set(hasgard, process2);
1183
1598
  const ping = await hasgard.ping();
1184
1599
  if (ping.status !== "ok") throw new Error(`Unexpected Hasgard ping status: ${ping.status}`);
1185
1600
  await hasgard.waitForWindowReady(config.windowLabel, config.readySelector, readinessTimeoutMs);
@@ -1194,11 +1609,21 @@ function createHasgardTest(config) {
1194
1609
  window: async ({ hasgard }, use, testInfo) => {
1195
1610
  await use(hasgard.window(config.windowLabel));
1196
1611
  if (testInfo.status !== testInfo.expectedStatus) {
1197
- const screenshot = await hasgard.window(config.windowLabel).screenshot();
1198
- await testInfo.attach("hasgard-screenshot", {
1199
- body: screenshot,
1200
- contentType: "image/png"
1201
- });
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
+ }
1202
1627
  }
1203
1628
  }
1204
1629
  });
@@ -1206,6 +1631,7 @@ function createHasgardTest(config) {
1206
1631
  }
1207
1632
  export {
1208
1633
  HasgardApplication,
1634
+ HasgardFrameLocator,
1209
1635
  HasgardLocator,
1210
1636
  HasgardProcess,
1211
1637
  HasgardRpcClient,