@nyssance/tauri-hasgard 0.6.0-alpha.1 → 0.6.0-alpha.4
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 +1 -1
- package/dist/index.js +377 -461
- package/dist/process-supervisor.js +78 -29
- package/dist/window.d.ts +1 -1
- package/package.json +7 -8
- package/dist/chunk-N7YDYAKI.js +0 -31
package/dist/index.js
CHANGED
|
@@ -1,29 +1,27 @@
|
|
|
1
|
-
import {
|
|
2
|
-
stopProcessGroup
|
|
3
|
-
} from "./chunk-N7YDYAKI.js";
|
|
4
|
-
|
|
5
1
|
// src/endpoint.ts
|
|
6
|
-
import { tmpdir } from "os";
|
|
7
|
-
import { join } from "path";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
8
4
|
function endpointPathForPlatform(name, platform, temporaryDirectory) {
|
|
9
|
-
if (platform === "win32")
|
|
5
|
+
if (platform === "win32")
|
|
6
|
+
return `\\\\.\\pipe\\${name}`;
|
|
10
7
|
return join(temporaryDirectory, `${name}.sock`);
|
|
11
8
|
}
|
|
12
9
|
function hasgardEndpointPath(name) {
|
|
13
10
|
return endpointPathForPlatform(name, process.platform, tmpdir());
|
|
14
11
|
}
|
|
15
|
-
|
|
16
12
|
// src/expect.ts
|
|
17
13
|
async function poll(read, matches, timeoutMs) {
|
|
18
14
|
if (!Number.isFinite(timeoutMs) || timeoutMs < 0)
|
|
19
15
|
throw new RangeError("Assertion timeout must be a finite non-negative number");
|
|
20
16
|
const deadline = Date.now() + timeoutMs;
|
|
21
|
-
for (
|
|
17
|
+
for (;; ) {
|
|
22
18
|
try {
|
|
23
19
|
const value = await read();
|
|
24
|
-
if (matches(value) || Date.now() >= deadline)
|
|
20
|
+
if (matches(value) || Date.now() >= deadline)
|
|
21
|
+
return value;
|
|
25
22
|
} catch (error) {
|
|
26
|
-
if (Date.now() >= deadline)
|
|
23
|
+
if (Date.now() >= deadline)
|
|
24
|
+
throw error;
|
|
27
25
|
}
|
|
28
26
|
await new Promise((resolve) => setTimeout(resolve, Math.min(50, Math.max(0, deadline - Date.now()))));
|
|
29
27
|
}
|
|
@@ -31,29 +29,22 @@ async function poll(read, matches, timeoutMs) {
|
|
|
31
29
|
function createHasgardExpect(playwrightExpect) {
|
|
32
30
|
return playwrightExpect.extend({
|
|
33
31
|
async toBeVisible(locator, options = {}) {
|
|
34
|
-
const timeout =
|
|
35
|
-
const result = await poll(
|
|
36
|
-
() => locator.isVisible(),
|
|
37
|
-
(visible) => this.isNot ? !visible : visible,
|
|
38
|
-
timeout
|
|
39
|
-
);
|
|
32
|
+
const { timeout = 5000 } = options;
|
|
33
|
+
const result = await poll(() => locator.isVisible(), (visible) => this.isNot ? !visible : visible, timeout);
|
|
40
34
|
return {
|
|
41
|
-
pass: result
|
|
35
|
+
pass: result,
|
|
42
36
|
message: () => this.isNot ? "expected Hasgard locator not to be visible" : `expected Hasgard locator to be visible within ${timeout}ms`
|
|
43
37
|
};
|
|
44
38
|
},
|
|
45
39
|
async toHaveText(locator, expected, options = {}) {
|
|
46
|
-
const timeout =
|
|
40
|
+
const { timeout = 5000 } = options;
|
|
47
41
|
const matches = (text) => {
|
|
48
|
-
if (typeof expected === "string")
|
|
42
|
+
if (typeof expected === "string")
|
|
43
|
+
return text === expected;
|
|
49
44
|
expected.lastIndex = 0;
|
|
50
45
|
return expected.test(text);
|
|
51
46
|
};
|
|
52
|
-
const result = await poll(
|
|
53
|
-
() => locator.textContent(),
|
|
54
|
-
(text) => this.isNot ? !matches(text) : matches(text),
|
|
55
|
-
timeout
|
|
56
|
-
);
|
|
47
|
+
const result = await poll(() => locator.textContent(), (text) => this.isNot ? !matches(text) : matches(text), timeout);
|
|
57
48
|
return {
|
|
58
49
|
pass: matches(result),
|
|
59
50
|
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)}`
|
|
@@ -61,13 +52,46 @@ function createHasgardExpect(playwrightExpect) {
|
|
|
61
52
|
}
|
|
62
53
|
});
|
|
63
54
|
}
|
|
55
|
+
// src/process-manager.ts
|
|
56
|
+
import { fork, spawn } from "node:child_process";
|
|
57
|
+
import { lstat } from "node:fs/promises";
|
|
58
|
+
import { createConnection } from "node:net";
|
|
59
|
+
import { setTimeout as delay2 } from "node:timers/promises";
|
|
60
|
+
|
|
61
|
+
// src/process-group.js
|
|
62
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
63
|
+
function signalGroup(pid, signal) {
|
|
64
|
+
try {
|
|
65
|
+
process.kill(-pid, signal);
|
|
66
|
+
return true;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (error.code === "ESRCH")
|
|
69
|
+
return false;
|
|
70
|
+
if (signal === 0 && error.code === "EPERM")
|
|
71
|
+
return true;
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async function waitForExit(pid, timeoutMs) {
|
|
76
|
+
const deadline = Date.now() + timeoutMs;
|
|
77
|
+
while (signalGroup(pid, 0)) {
|
|
78
|
+
if (Date.now() >= deadline)
|
|
79
|
+
return false;
|
|
80
|
+
await delay(25);
|
|
81
|
+
}
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
async function stopProcessGroup(pid) {
|
|
85
|
+
signalGroup(pid, "SIGTERM");
|
|
86
|
+
if (!await waitForExit(pid, 5000)) {
|
|
87
|
+
signalGroup(pid, "SIGKILL");
|
|
88
|
+
if (!await waitForExit(pid, 5000))
|
|
89
|
+
throw new Error(`Hasgard process group ${pid} survived SIGKILL`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
64
92
|
|
|
65
93
|
// src/process-manager.ts
|
|
66
|
-
|
|
67
|
-
import { lstat } from "fs/promises";
|
|
68
|
-
import { createConnection } from "net";
|
|
69
|
-
import { setTimeout as delay } from "timers/promises";
|
|
70
|
-
var HasgardProcess = class {
|
|
94
|
+
class HasgardProcess {
|
|
71
95
|
child;
|
|
72
96
|
stdout = "";
|
|
73
97
|
stderr = "";
|
|
@@ -75,28 +99,29 @@ var HasgardProcess = class {
|
|
|
75
99
|
stopping;
|
|
76
100
|
applicationPid;
|
|
77
101
|
applicationExit;
|
|
78
|
-
/** Bounded log tails and the supervisor's actual child-close result. */
|
|
79
102
|
async diagnostics(waitForExitMs = 0) {
|
|
80
|
-
if (!Number.isFinite(waitForExitMs) || waitForExitMs < 0)
|
|
103
|
+
if (!Number.isFinite(waitForExitMs) || waitForExitMs < 0)
|
|
104
|
+
throw new RangeError("Invalid diagnostics wait");
|
|
81
105
|
const deadline = Date.now() + waitForExitMs;
|
|
82
106
|
while (this.child && !this.applicationExit && Date.now() < deadline)
|
|
83
|
-
await
|
|
107
|
+
await delay2(Math.min(25, deadline - Date.now()));
|
|
84
108
|
return { pid: this.applicationPid, exit: this.applicationExit, stdout: this.stdout, stderr: this.stderr };
|
|
85
109
|
}
|
|
86
110
|
async start(config, socketPath) {
|
|
87
|
-
if (this.child || this.stopping || this.starting)
|
|
111
|
+
if (this.child || this.stopping || this.starting)
|
|
112
|
+
throw new Error("Hasgard app process is already running");
|
|
88
113
|
this.starting = this.launch(config, socketPath);
|
|
89
114
|
try {
|
|
90
115
|
await this.starting;
|
|
91
116
|
} finally {
|
|
92
|
-
this.starting =
|
|
117
|
+
this.starting = undefined;
|
|
93
118
|
}
|
|
94
119
|
}
|
|
95
120
|
async launch(config, socketPath) {
|
|
96
121
|
this.stdout = "";
|
|
97
122
|
this.stderr = "";
|
|
98
|
-
this.applicationPid =
|
|
99
|
-
this.applicationExit =
|
|
123
|
+
this.applicationPid = undefined;
|
|
124
|
+
this.applicationExit = undefined;
|
|
100
125
|
if (process.platform !== "win32" && await endpointAcceptsConnections(socketPath)) {
|
|
101
126
|
throw new Error(`Hasgard endpoint is already in use: ${socketPath}`);
|
|
102
127
|
}
|
|
@@ -151,26 +176,28 @@ var HasgardProcess = class {
|
|
|
151
176
|
};
|
|
152
177
|
child.once("error", failed);
|
|
153
178
|
child.once("exit", () => failed(new Error("Hasgard process supervisor exited")));
|
|
154
|
-
child.on(
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
clearTimeout(timer);
|
|
168
|
-
resolve();
|
|
179
|
+
child.on("message", (message) => {
|
|
180
|
+
if (message.type === "stdout")
|
|
181
|
+
this.stdout = appendOutput(this.stdout, message.data);
|
|
182
|
+
else if (message.type === "stderr")
|
|
183
|
+
this.stderr = appendOutput(this.stderr, message.data);
|
|
184
|
+
else if (message.type === "exit")
|
|
185
|
+
this.applicationExit = { code: message.code, signal: message.signal };
|
|
186
|
+
else if (message.type === "error")
|
|
187
|
+
failed(Object.assign(new Error(message.message), { code: message.code }));
|
|
188
|
+
else if (message.type === "started") {
|
|
189
|
+
if (!Number.isSafeInteger(message.pid) || message.pid <= 1) {
|
|
190
|
+
failed(new Error("Invalid supervised application PID"));
|
|
191
|
+
return;
|
|
169
192
|
}
|
|
193
|
+
this.applicationPid = message.pid;
|
|
194
|
+
clearTimeout(timer);
|
|
195
|
+
resolve();
|
|
170
196
|
}
|
|
171
|
-
);
|
|
197
|
+
});
|
|
172
198
|
child.send({ ...config, env: { ...process.env, ...config.env, TAURI_HASGARD_SOCKET: socketPath } }, (error) => {
|
|
173
|
-
if (error)
|
|
199
|
+
if (error)
|
|
200
|
+
failed(error);
|
|
174
201
|
});
|
|
175
202
|
});
|
|
176
203
|
await this.waitForSocket(socketPath, config.timeoutMs);
|
|
@@ -190,114 +217,120 @@ var HasgardProcess = class {
|
|
|
190
217
|
const child = this.child;
|
|
191
218
|
if (this.applicationExit) {
|
|
192
219
|
const { code, signal } = this.applicationExit;
|
|
193
|
-
throw new Error(
|
|
194
|
-
`Tauri process exited with ${signal ? `signal ${signal}` : `code ${code}`}
|
|
220
|
+
throw new Error(`Tauri process exited with ${signal ? `signal ${signal}` : `code ${code}`}
|
|
195
221
|
stdout:
|
|
196
222
|
${this.stdout}
|
|
197
223
|
stderr:
|
|
198
|
-
${this.stderr}`
|
|
199
|
-
);
|
|
224
|
+
${this.stderr}`);
|
|
200
225
|
}
|
|
201
226
|
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}`}
|
|
227
|
+
throw new Error(`Tauri process exited with ${child.signalCode ? `signal ${child.signalCode}` : `code ${child.exitCode}`}
|
|
204
228
|
stdout:
|
|
205
229
|
${this.stdout}
|
|
206
230
|
stderr:
|
|
207
|
-
${this.stderr}`
|
|
208
|
-
);
|
|
231
|
+
${this.stderr}`);
|
|
209
232
|
}
|
|
210
|
-
if (await endpointAcceptsConnections(socketPath))
|
|
211
|
-
|
|
233
|
+
if (await endpointAcceptsConnections(socketPath))
|
|
234
|
+
return;
|
|
235
|
+
await delay2(50);
|
|
212
236
|
}
|
|
213
237
|
throw new Error(`Hasgard socket did not appear within ${timeoutMs}ms: ${socketPath}`);
|
|
214
238
|
}
|
|
215
239
|
async confirmEndpointOwnership(child, path, timeoutMs) {
|
|
216
240
|
const stat = await lstat(path);
|
|
217
|
-
if (!stat.isSocket())
|
|
241
|
+
if (!stat.isSocket())
|
|
242
|
+
throw new Error(`Hasgard endpoint is not a socket: ${path}`);
|
|
218
243
|
await new Promise((resolve, reject) => {
|
|
219
244
|
const done = (error) => {
|
|
220
245
|
clearTimeout(timer);
|
|
221
246
|
child.off("message", message);
|
|
222
247
|
child.off("exit", exited);
|
|
223
|
-
if (error)
|
|
224
|
-
|
|
248
|
+
if (error)
|
|
249
|
+
reject(error);
|
|
250
|
+
else
|
|
251
|
+
resolve();
|
|
225
252
|
};
|
|
226
253
|
const message = (value) => {
|
|
227
|
-
if (value.type === "endpoint-owned")
|
|
228
|
-
|
|
254
|
+
if (value.type === "endpoint-owned")
|
|
255
|
+
done();
|
|
256
|
+
else if (value.type === "error")
|
|
257
|
+
done(new Error(value.message));
|
|
229
258
|
};
|
|
230
259
|
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
|
-
);
|
|
260
|
+
const timer = setTimeout(() => done(new Error("Hasgard supervisor did not acknowledge endpoint ownership")), timeoutMs);
|
|
235
261
|
child.on("message", message);
|
|
236
262
|
child.once("exit", exited);
|
|
237
263
|
child.send({ type: "endpoint", identity: { dev: stat.dev, ino: stat.ino } }, (error) => {
|
|
238
|
-
if (error)
|
|
264
|
+
if (error)
|
|
265
|
+
done(error);
|
|
239
266
|
});
|
|
240
267
|
});
|
|
241
268
|
}
|
|
242
269
|
async stop() {
|
|
243
|
-
if (this.starting)
|
|
244
|
-
|
|
270
|
+
if (this.starting)
|
|
271
|
+
await this.starting;
|
|
272
|
+
if (this.stopping)
|
|
273
|
+
return this.stopping;
|
|
245
274
|
this.stopping = this.stopChild();
|
|
246
275
|
try {
|
|
247
276
|
await this.stopping;
|
|
248
277
|
} finally {
|
|
249
|
-
this.stopping =
|
|
278
|
+
this.stopping = undefined;
|
|
250
279
|
}
|
|
251
280
|
}
|
|
252
281
|
async stopChild() {
|
|
253
282
|
const child = this.child;
|
|
254
|
-
if (!child)
|
|
283
|
+
if (!child)
|
|
284
|
+
return;
|
|
255
285
|
if (process.platform !== "win32") {
|
|
256
|
-
if (this.applicationPid !==
|
|
286
|
+
if (this.applicationPid !== undefined) {
|
|
257
287
|
await stopProcessGroup(this.applicationPid);
|
|
258
288
|
}
|
|
259
|
-
const
|
|
260
|
-
if (child.connected)
|
|
261
|
-
|
|
289
|
+
const exited = child.exitCode !== null || child.signalCode !== null ? Promise.resolve() : new Promise((resolve) => child.once("exit", () => resolve()));
|
|
290
|
+
if (child.connected)
|
|
291
|
+
child.disconnect();
|
|
292
|
+
const abort = new AbortController;
|
|
262
293
|
try {
|
|
263
|
-
if (await Promise.race([
|
|
294
|
+
if (await Promise.race([exited, delay2(11000, "timeout", { signal: abort.signal })]) === "timeout") {
|
|
264
295
|
throw new Error(`Hasgard process supervisor ${child.pid} did not finish cleanup`);
|
|
265
296
|
}
|
|
266
297
|
} finally {
|
|
267
|
-
|
|
298
|
+
abort.abort();
|
|
268
299
|
}
|
|
269
|
-
this.child =
|
|
270
|
-
this.applicationPid =
|
|
300
|
+
this.child = undefined;
|
|
301
|
+
this.applicationPid = undefined;
|
|
271
302
|
if (child.exitCode !== 0 || child.signalCode !== null) {
|
|
272
303
|
throw new Error(`Hasgard supervisor cleanup failed (${child.signalCode ?? child.exitCode}):
|
|
273
304
|
${this.stderr}`);
|
|
274
305
|
}
|
|
275
306
|
return;
|
|
276
307
|
}
|
|
277
|
-
if (child.pid ===
|
|
278
|
-
this.child =
|
|
308
|
+
if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) {
|
|
309
|
+
this.child = undefined;
|
|
279
310
|
return;
|
|
280
311
|
}
|
|
281
312
|
const exited = new Promise((resolve) => child.once("exit", () => resolve()));
|
|
282
|
-
if (process.platform === "win32" && child.pid !==
|
|
313
|
+
if (process.platform === "win32" && child.pid !== undefined) {
|
|
283
314
|
await terminateWindowsProcessTree(child.pid);
|
|
284
|
-
} else if (child.pid !==
|
|
315
|
+
} else if (child.pid !== undefined) {
|
|
285
316
|
process.kill(-child.pid, "SIGTERM");
|
|
286
317
|
}
|
|
287
|
-
const abort = new AbortController
|
|
288
|
-
const timeout =
|
|
318
|
+
const abort = new AbortController;
|
|
319
|
+
const timeout = delay2(5000, "timeout", { signal: abort.signal });
|
|
289
320
|
try {
|
|
290
321
|
if (await Promise.race([exited, timeout]) === "timeout") {
|
|
291
|
-
if (process.platform === "win32" && child.pid !==
|
|
292
|
-
|
|
322
|
+
if (process.platform === "win32" && child.pid !== undefined)
|
|
323
|
+
await terminateWindowsProcessTree(child.pid);
|
|
324
|
+
else if (child.pid !== undefined)
|
|
325
|
+
process.kill(-child.pid, "SIGKILL");
|
|
293
326
|
await exited;
|
|
294
327
|
}
|
|
295
328
|
} finally {
|
|
296
329
|
abort.abort();
|
|
297
330
|
}
|
|
298
|
-
this.child =
|
|
331
|
+
this.child = undefined;
|
|
299
332
|
}
|
|
300
|
-
}
|
|
333
|
+
}
|
|
301
334
|
function appendOutput(previous, chunk) {
|
|
302
335
|
const output = previous + chunk;
|
|
303
336
|
return output.length > 65536 ? `[earlier output truncated]
|
|
@@ -306,9 +339,11 @@ ${output.slice(-65536)}` : output;
|
|
|
306
339
|
async function endpointAcceptsConnections(path) {
|
|
307
340
|
if (process.platform !== "win32") {
|
|
308
341
|
try {
|
|
309
|
-
if (!(await lstat(path)).isSocket())
|
|
342
|
+
if (!(await lstat(path)).isSocket())
|
|
343
|
+
throw new Error(`Hasgard endpoint exists but is not a socket: ${path}`);
|
|
310
344
|
} catch (error) {
|
|
311
|
-
if (error.code === "ENOENT")
|
|
345
|
+
if (error.code === "ENOENT")
|
|
346
|
+
return false;
|
|
312
347
|
throw error;
|
|
313
348
|
}
|
|
314
349
|
}
|
|
@@ -337,8 +372,11 @@ async function terminateWindowsProcessTree(pid) {
|
|
|
337
372
|
}
|
|
338
373
|
|
|
339
374
|
// src/rpc-client.ts
|
|
340
|
-
import { createConnection as createConnection2 } from "net";
|
|
341
|
-
import { createInterface } from "readline";
|
|
375
|
+
import { createConnection as createConnection2 } from "node:net";
|
|
376
|
+
import { createInterface } from "node:readline";
|
|
377
|
+
|
|
378
|
+
// src/protocol-limits.ts
|
|
379
|
+
var MAX_REQUEST_BYTES = 1048576;
|
|
342
380
|
|
|
343
381
|
// src/protocol.ts
|
|
344
382
|
function isObject(value) {
|
|
@@ -347,12 +385,16 @@ function isObject(value) {
|
|
|
347
385
|
function parseRpcResponse(line) {
|
|
348
386
|
const value = JSON.parse(line);
|
|
349
387
|
const invalid = () => new Error(`Invalid Hasgard JSON-RPC envelope: ${line}`);
|
|
350
|
-
if (!isObject(value) || value.jsonrpc !== "2.0" || !Object.hasOwn(value, "id"))
|
|
351
|
-
|
|
388
|
+
if (!isObject(value) || value.jsonrpc !== "2.0" || !Object.hasOwn(value, "id"))
|
|
389
|
+
throw invalid();
|
|
390
|
+
if (value.id !== null && (!Number.isSafeInteger(value.id) || value.id < 0))
|
|
391
|
+
throw invalid();
|
|
352
392
|
const hasResult = Object.hasOwn(value, "result");
|
|
353
393
|
const hasError = Object.hasOwn(value, "error");
|
|
354
|
-
if (hasResult === hasError)
|
|
355
|
-
|
|
394
|
+
if (hasResult === hasError)
|
|
395
|
+
throw invalid();
|
|
396
|
+
if (hasResult && value.id === null)
|
|
397
|
+
throw invalid();
|
|
356
398
|
if (hasError) {
|
|
357
399
|
const error = value.error;
|
|
358
400
|
if (!isObject(error) || !Number.isInteger(error.code) || error.code < -2147483648 || error.code > 2147483647 || typeof error.message !== "string")
|
|
@@ -361,11 +403,8 @@ function parseRpcResponse(line) {
|
|
|
361
403
|
return value;
|
|
362
404
|
}
|
|
363
405
|
|
|
364
|
-
// src/protocol-limits.ts
|
|
365
|
-
var MAX_REQUEST_BYTES = 1048576;
|
|
366
|
-
|
|
367
406
|
// src/rpc-client.ts
|
|
368
|
-
|
|
407
|
+
class HasgardRpcError extends Error {
|
|
369
408
|
code;
|
|
370
409
|
data;
|
|
371
410
|
constructor(code, message, data) {
|
|
@@ -374,18 +413,20 @@ var HasgardRpcError = class extends Error {
|
|
|
374
413
|
this.code = code;
|
|
375
414
|
this.data = data;
|
|
376
415
|
}
|
|
377
|
-
}
|
|
378
|
-
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
class HasgardRpcClient {
|
|
379
419
|
socketPath;
|
|
380
420
|
socket;
|
|
381
421
|
lines;
|
|
382
422
|
nextId = 1;
|
|
383
|
-
pending =
|
|
423
|
+
pending = new Map;
|
|
384
424
|
constructor(socketPath) {
|
|
385
425
|
this.socketPath = socketPath;
|
|
386
426
|
}
|
|
387
427
|
async connect(timeoutMs) {
|
|
388
|
-
if (this.socket)
|
|
428
|
+
if (this.socket)
|
|
429
|
+
throw new Error("Hasgard RPC client is already connected");
|
|
389
430
|
const socket = createConnection2({ path: this.socketPath });
|
|
390
431
|
this.socket = socket;
|
|
391
432
|
try {
|
|
@@ -414,24 +455,28 @@ var HasgardRpcClient = class {
|
|
|
414
455
|
socket.once("close", closed);
|
|
415
456
|
});
|
|
416
457
|
} catch (error) {
|
|
417
|
-
if (this.socket === socket)
|
|
458
|
+
if (this.socket === socket)
|
|
459
|
+
this.socket = undefined;
|
|
418
460
|
socket.destroy();
|
|
419
461
|
throw error;
|
|
420
462
|
}
|
|
421
463
|
this.lines = createInterface({ input: socket });
|
|
422
464
|
this.lines.on("line", (line) => this.handleLine(line));
|
|
423
465
|
socket.on("close", () => {
|
|
424
|
-
if (this.socket !== socket)
|
|
466
|
+
if (this.socket !== socket)
|
|
467
|
+
return;
|
|
425
468
|
this.rejectPending(new Error("Hasgard socket closed"));
|
|
426
469
|
this.disconnect();
|
|
427
470
|
});
|
|
428
471
|
socket.on("error", (error) => {
|
|
429
|
-
if (this.socket === socket)
|
|
472
|
+
if (this.socket === socket)
|
|
473
|
+
this.rejectPending(error);
|
|
430
474
|
});
|
|
431
475
|
}
|
|
432
476
|
async call(method, params) {
|
|
433
477
|
const socket = this.socket;
|
|
434
|
-
if (!socket || socket.destroyed || !socket.writable)
|
|
478
|
+
if (!socket || socket.destroyed || !socket.writable)
|
|
479
|
+
throw new Error("Hasgard RPC client is not connected");
|
|
435
480
|
const id = this.nextId;
|
|
436
481
|
this.nextId += 1;
|
|
437
482
|
const request = params ? { jsonrpc: "2.0", id, method, params } : { jsonrpc: "2.0", id, method };
|
|
@@ -448,9 +493,11 @@ var HasgardRpcClient = class {
|
|
|
448
493
|
});
|
|
449
494
|
});
|
|
450
495
|
socket.write(line, (error) => {
|
|
451
|
-
if (!error)
|
|
496
|
+
if (!error)
|
|
497
|
+
return;
|
|
452
498
|
const pending = this.pending.get(id);
|
|
453
|
-
if (!pending)
|
|
499
|
+
if (!pending)
|
|
500
|
+
return;
|
|
454
501
|
this.pending.delete(id);
|
|
455
502
|
pending.reject(error);
|
|
456
503
|
});
|
|
@@ -458,9 +505,9 @@ var HasgardRpcClient = class {
|
|
|
458
505
|
}
|
|
459
506
|
disconnect() {
|
|
460
507
|
this.lines?.close();
|
|
461
|
-
this.lines =
|
|
508
|
+
this.lines = undefined;
|
|
462
509
|
this.socket?.destroy();
|
|
463
|
-
this.socket =
|
|
510
|
+
this.socket = undefined;
|
|
464
511
|
this.rejectPending(new Error("Hasgard RPC client disconnected"));
|
|
465
512
|
}
|
|
466
513
|
handleLine(line) {
|
|
@@ -475,7 +522,7 @@ var HasgardRpcClient = class {
|
|
|
475
522
|
this.rejectPending(new HasgardRpcError(response.error.code, response.error.message, response.error.data));
|
|
476
523
|
return;
|
|
477
524
|
}
|
|
478
|
-
const pending = response.id === null ?
|
|
525
|
+
const pending = response.id === null ? undefined : this.pending.get(response.id);
|
|
479
526
|
if (!pending) {
|
|
480
527
|
this.rejectPending(new Error(`Unexpected Hasgard JSON-RPC response id: ${response.id}`));
|
|
481
528
|
return;
|
|
@@ -488,19 +535,20 @@ var HasgardRpcClient = class {
|
|
|
488
535
|
pending.resolve(response.result);
|
|
489
536
|
}
|
|
490
537
|
rejectPending(error) {
|
|
491
|
-
for (const pending of this.pending.values())
|
|
538
|
+
for (const pending of this.pending.values())
|
|
539
|
+
pending.reject(error);
|
|
492
540
|
this.pending.clear();
|
|
493
541
|
}
|
|
494
|
-
}
|
|
542
|
+
}
|
|
495
543
|
|
|
496
544
|
// src/window.ts
|
|
497
|
-
import { readFile } from "fs/promises";
|
|
498
|
-
import { basename } from "path";
|
|
499
|
-
var locks =
|
|
545
|
+
import { readFile } from "node:fs/promises";
|
|
546
|
+
import { basename } from "node:path";
|
|
547
|
+
var locks = new WeakMap;
|
|
500
548
|
function lockFor(rpc, label) {
|
|
501
549
|
let clientLocks = locks.get(rpc);
|
|
502
550
|
if (!clientLocks) {
|
|
503
|
-
clientLocks =
|
|
551
|
+
clientLocks = new Map;
|
|
504
552
|
locks.set(rpc, clientLocks);
|
|
505
553
|
}
|
|
506
554
|
let lock = clientLocks.get(label);
|
|
@@ -518,36 +566,44 @@ function withWindow(window, params) {
|
|
|
518
566
|
}
|
|
519
567
|
async function readFileInputs(files) {
|
|
520
568
|
const list = Array.isArray(files) ? files : [files];
|
|
521
|
-
return Promise.all(
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
);
|
|
569
|
+
return Promise.all(list.map(async (entry) => {
|
|
570
|
+
if (typeof entry !== "string")
|
|
571
|
+
return entry;
|
|
572
|
+
const contents = await readFile(entry);
|
|
573
|
+
return { name: basename(entry), data: contents.toString("base64") };
|
|
574
|
+
}));
|
|
528
575
|
}
|
|
529
576
|
function targetParams(target) {
|
|
530
|
-
if ("ref" in target)
|
|
577
|
+
if ("ref" in target)
|
|
578
|
+
return { ref: target.ref };
|
|
531
579
|
if ("selector" in target) {
|
|
532
|
-
return target.index ===
|
|
580
|
+
return target.index === undefined ? { selector: target.selector } : { selector: target.selector, index: target.index };
|
|
533
581
|
}
|
|
534
582
|
return { x: target.x, y: target.y };
|
|
535
583
|
}
|
|
536
584
|
function clickParams(options) {
|
|
537
585
|
const params = {};
|
|
538
|
-
if (options.modifiers !==
|
|
539
|
-
|
|
540
|
-
if (options.
|
|
541
|
-
|
|
586
|
+
if (options.modifiers !== undefined)
|
|
587
|
+
params.modifiers = options.modifiers;
|
|
588
|
+
if (options.button !== undefined)
|
|
589
|
+
params.button = options.button;
|
|
590
|
+
if (options.clickCount !== undefined)
|
|
591
|
+
params.clickCount = options.clickCount;
|
|
592
|
+
if (options.position !== undefined)
|
|
593
|
+
params.position = { ...options.position };
|
|
542
594
|
return params;
|
|
543
595
|
}
|
|
544
596
|
function routeParams(options) {
|
|
545
597
|
const params = { pattern: options.pattern };
|
|
546
|
-
if (options.method !==
|
|
547
|
-
|
|
548
|
-
if (
|
|
549
|
-
|
|
550
|
-
if ("
|
|
598
|
+
if (options.method !== undefined)
|
|
599
|
+
params.method = options.method;
|
|
600
|
+
if (options.times !== undefined)
|
|
601
|
+
params.times = options.times;
|
|
602
|
+
if ("status" in options && options.status !== undefined)
|
|
603
|
+
params.status = options.status;
|
|
604
|
+
if ("body" in options && options.body !== undefined)
|
|
605
|
+
params.body = options.body;
|
|
606
|
+
if ("contentType" in options && options.contentType !== undefined) {
|
|
551
607
|
params.contentType = options.contentType;
|
|
552
608
|
}
|
|
553
609
|
return params;
|
|
@@ -595,27 +651,32 @@ function expectRecord(value, context) {
|
|
|
595
651
|
return value;
|
|
596
652
|
}
|
|
597
653
|
function expectString(value, context) {
|
|
598
|
-
if (typeof value !== "string")
|
|
654
|
+
if (typeof value !== "string")
|
|
655
|
+
throw new Error(`${context} must be a string`);
|
|
599
656
|
return value;
|
|
600
657
|
}
|
|
601
658
|
function expectBoolean(value, context) {
|
|
602
|
-
if (typeof value !== "boolean")
|
|
659
|
+
if (typeof value !== "boolean")
|
|
660
|
+
throw new Error(`${context} must be a boolean`);
|
|
603
661
|
return value;
|
|
604
662
|
}
|
|
605
663
|
function expectNumber(value, context) {
|
|
606
|
-
if (typeof value !== "number")
|
|
664
|
+
if (typeof value !== "number")
|
|
665
|
+
throw new Error(`${context} must be a number`);
|
|
607
666
|
return value;
|
|
608
667
|
}
|
|
609
668
|
function expectArray(value, context) {
|
|
610
|
-
if (!Array.isArray(value))
|
|
669
|
+
if (!Array.isArray(value))
|
|
670
|
+
throw new Error(`${context} must be an array`);
|
|
611
671
|
return value;
|
|
612
672
|
}
|
|
613
673
|
function expectNullableString(value, context) {
|
|
614
|
-
if (value === null)
|
|
674
|
+
if (value === null)
|
|
675
|
+
return null;
|
|
615
676
|
return expectString(value, context);
|
|
616
677
|
}
|
|
617
678
|
function optionalString(value, context) {
|
|
618
|
-
return value ===
|
|
679
|
+
return value === undefined ? undefined : expectString(value, context);
|
|
619
680
|
}
|
|
620
681
|
function parseConsoleLogEntry(value, index) {
|
|
621
682
|
const entry = expectRecord(value, `console.getLogs[${index}]`);
|
|
@@ -650,7 +711,8 @@ function parseFormField(value, context) {
|
|
|
650
711
|
name: expectString(field.name, `${context}.name`),
|
|
651
712
|
value: Array.isArray(raw) ? raw.map((option, index) => expectString(option, `${context}.value[${index}]`)) : expectString(raw, `${context}.value`)
|
|
652
713
|
};
|
|
653
|
-
if (field.checked !==
|
|
714
|
+
if (field.checked !== undefined)
|
|
715
|
+
parsed.checked = expectBoolean(field.checked, `${context}.checked`);
|
|
654
716
|
return parsed;
|
|
655
717
|
}
|
|
656
718
|
function parseFormEntry(value, index) {
|
|
@@ -661,11 +723,9 @@ function parseFormEntry(value, index) {
|
|
|
661
723
|
name: expectString(form.name, `${context}.name`),
|
|
662
724
|
action: expectString(form.action, `${context}.action`),
|
|
663
725
|
method: expectString(form.method, `${context}.method`),
|
|
664
|
-
fields: expectArray(form.fields, `${context}.fields`).map(
|
|
665
|
-
(field, fieldIndex) => parseFormField(field, `${context}.fields[${fieldIndex}]`)
|
|
666
|
-
)
|
|
726
|
+
fields: expectArray(form.fields, `${context}.fields`).map((field, fieldIndex) => parseFormField(field, `${context}.fields[${fieldIndex}]`))
|
|
667
727
|
};
|
|
668
|
-
if (form.fieldsTruncated !==
|
|
728
|
+
if (form.fieldsTruncated !== undefined) {
|
|
669
729
|
parsed.fieldsTruncated = expectBoolean(form.fieldsTruncated, `${context}.fieldsTruncated`);
|
|
670
730
|
}
|
|
671
731
|
return parsed;
|
|
@@ -674,15 +734,20 @@ function parseWatchNode(value, context) {
|
|
|
674
734
|
const node = expectRecord(value, context);
|
|
675
735
|
const parsed = { tag: expectString(node.tag, `${context}.tag`) };
|
|
676
736
|
const id = optionalString(node.id, `${context}.id`);
|
|
677
|
-
if (id !==
|
|
737
|
+
if (id !== undefined)
|
|
738
|
+
parsed.id = id;
|
|
678
739
|
const className = optionalString(node.class, `${context}.class`);
|
|
679
|
-
if (className !==
|
|
740
|
+
if (className !== undefined)
|
|
741
|
+
parsed.class = className;
|
|
680
742
|
const text = optionalString(node.text, `${context}.text`);
|
|
681
|
-
if (text !==
|
|
743
|
+
if (text !== undefined)
|
|
744
|
+
parsed.text = text;
|
|
682
745
|
const attr = optionalString(node.attr, `${context}.attr`);
|
|
683
|
-
if (attr !==
|
|
746
|
+
if (attr !== undefined)
|
|
747
|
+
parsed.attr = attr;
|
|
684
748
|
const attrValue = optionalString(node.value, `${context}.value`);
|
|
685
|
-
if (attrValue !==
|
|
749
|
+
if (attrValue !== undefined)
|
|
750
|
+
parsed.value = attrValue;
|
|
686
751
|
return parsed;
|
|
687
752
|
}
|
|
688
753
|
function parseWatchNodes(value, context) {
|
|
@@ -693,9 +758,7 @@ function parseDiffChange(value, index) {
|
|
|
693
758
|
return {
|
|
694
759
|
old: parseSnapshotElement(change.old, index),
|
|
695
760
|
new: parseSnapshotElement(change.new, index),
|
|
696
|
-
changes: expectArray(change.changes, `diff.changed[${index}].changes`).map(
|
|
697
|
-
(entry, entryIndex) => expectString(entry, `diff.changed[${index}].changes[${entryIndex}]`)
|
|
698
|
-
)
|
|
761
|
+
changes: expectArray(change.changes, `diff.changed[${index}].changes`).map((entry, entryIndex) => expectString(entry, `diff.changed[${index}].changes[${entryIndex}]`))
|
|
699
762
|
};
|
|
700
763
|
}
|
|
701
764
|
function parseRecorderEntry(value, index) {
|
|
@@ -706,7 +769,8 @@ function parseRecorderEntry(value, index) {
|
|
|
706
769
|
timestamp: expectNumber(entry.timestamp, `record.stop.entries[${index}].timestamp`)
|
|
707
770
|
};
|
|
708
771
|
}
|
|
709
|
-
|
|
772
|
+
|
|
773
|
+
class HasgardApplication {
|
|
710
774
|
rpc;
|
|
711
775
|
constructor(rpc) {
|
|
712
776
|
this.rpc = rpc;
|
|
@@ -720,7 +784,8 @@ var HasgardApplication = class {
|
|
|
720
784
|
}
|
|
721
785
|
async windows() {
|
|
722
786
|
const value = expectRecord(await this.rpc.call("windows.list"), "windows.list result");
|
|
723
|
-
if (!Array.isArray(value.windows))
|
|
787
|
+
if (!Array.isArray(value.windows))
|
|
788
|
+
throw new Error("windows.list.windows must be an array");
|
|
724
789
|
return value.windows.map((entry, index) => {
|
|
725
790
|
const window = expectRecord(entry, `windows[${index}]`);
|
|
726
791
|
const info = {
|
|
@@ -728,7 +793,7 @@ var HasgardApplication = class {
|
|
|
728
793
|
url: expectString(window.url, `windows[${index}].url`),
|
|
729
794
|
title: expectString(window.title, `windows[${index}].title`)
|
|
730
795
|
};
|
|
731
|
-
if (window.native_id !==
|
|
796
|
+
if (window.native_id !== undefined) {
|
|
732
797
|
info.nativeId = expectNumber(window.native_id, `windows[${index}].native_id`);
|
|
733
798
|
}
|
|
734
799
|
return info;
|
|
@@ -741,9 +806,11 @@ var HasgardApplication = class {
|
|
|
741
806
|
const window = this.window(label);
|
|
742
807
|
try {
|
|
743
808
|
const state = await window.state();
|
|
744
|
-
if (state.url !== "about:blank" && state.readyState !== "loading")
|
|
809
|
+
if (state.url !== "about:blank" && state.readyState !== "loading")
|
|
810
|
+
return window;
|
|
745
811
|
} catch (error) {
|
|
746
|
-
if (!(error instanceof HasgardRpcError) || error.code !== -32002)
|
|
812
|
+
if (!(error instanceof HasgardRpcError) || error.code !== -32002)
|
|
813
|
+
throw error;
|
|
747
814
|
}
|
|
748
815
|
}
|
|
749
816
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
@@ -754,7 +821,8 @@ var HasgardApplication = class {
|
|
|
754
821
|
const window = await this.waitForWindow(label, timeoutMs);
|
|
755
822
|
const deadline = Date.now() + timeoutMs;
|
|
756
823
|
while (Date.now() <= deadline) {
|
|
757
|
-
if (await window.locator(readySelector).count() === 1)
|
|
824
|
+
if (await window.locator(readySelector).count() === 1)
|
|
825
|
+
return window;
|
|
758
826
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
759
827
|
}
|
|
760
828
|
throw new Error(`Window '${label}' did not match ready selector '${readySelector}' within ${timeoutMs}ms`);
|
|
@@ -762,10 +830,6 @@ var HasgardApplication = class {
|
|
|
762
830
|
window(label) {
|
|
763
831
|
return new HasgardWindow(this.rpc, label);
|
|
764
832
|
}
|
|
765
|
-
/**
|
|
766
|
-
* Action recorder. Captures the RPC calls this connection makes into a
|
|
767
|
-
* replayable scenario — this is not video capture.
|
|
768
|
-
*/
|
|
769
833
|
recorder = {
|
|
770
834
|
start: async () => {
|
|
771
835
|
await this.rpc.call("record.start");
|
|
@@ -789,8 +853,9 @@ var HasgardApplication = class {
|
|
|
789
853
|
await this.rpc.call("record.add", entry);
|
|
790
854
|
}
|
|
791
855
|
};
|
|
792
|
-
}
|
|
793
|
-
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
class HasgardWindow {
|
|
794
859
|
label;
|
|
795
860
|
rpc;
|
|
796
861
|
constructor(rpc, label) {
|
|
@@ -803,12 +868,6 @@ var HasgardWindow = class {
|
|
|
803
868
|
getByRole(role, query = {}) {
|
|
804
869
|
return new HasgardLocator(this, { kind: "role", role, query });
|
|
805
870
|
}
|
|
806
|
-
/**
|
|
807
|
-
* Locate by visible text. Matching runs in the page against the live DOM, not
|
|
808
|
-
* against a snapshot's `name`: that field collapses aria-label, alt, `<label>`,
|
|
809
|
-
* placeholder, and text into one 50-character string, so a button with an
|
|
810
|
-
* aria-label would never be findable by the text it actually shows.
|
|
811
|
-
*/
|
|
812
871
|
getByText(text, query = {}) {
|
|
813
872
|
return new HasgardLocator(this, { kind: "query", by: "text", value: text, query });
|
|
814
873
|
}
|
|
@@ -824,27 +883,16 @@ var HasgardWindow = class {
|
|
|
824
883
|
getByTitle(text, query = {}) {
|
|
825
884
|
return new HasgardLocator(this, { kind: "query", by: "title", value: text, query });
|
|
826
885
|
}
|
|
827
|
-
/** Test ids always match exactly, so `save` cannot select `save-draft`. */
|
|
828
886
|
getByTestId(testId) {
|
|
829
887
|
return new HasgardLocator(this, { kind: "query", by: "testid", value: testId, query: {} });
|
|
830
888
|
}
|
|
831
|
-
/**
|
|
832
|
-
* Scope locators to a same-origin `<iframe>`.
|
|
833
|
-
*
|
|
834
|
-
* Cross-origin frames are out of reach: the same-origin policy binds injected
|
|
835
|
-
* script exactly as it binds the page's own, so no bridge can see into a
|
|
836
|
-
* third-party payment or OAuth frame. Those raise an error naming the origin
|
|
837
|
-
* problem rather than reporting a missing element.
|
|
838
|
-
*/
|
|
839
889
|
frameLocator(selector) {
|
|
840
890
|
return new HasgardFrameLocator(this, [selector]);
|
|
841
891
|
}
|
|
842
892
|
async snapshot(options = {}) {
|
|
843
|
-
const raw = expectRecord(
|
|
844
|
-
|
|
845
|
-
"snapshot
|
|
846
|
-
);
|
|
847
|
-
if (!Array.isArray(raw.elements)) throw new Error("snapshot.elements must be an array");
|
|
893
|
+
const raw = expectRecord(await this.rpc.call("snapshot", withWindow(this.label, options)), "snapshot result");
|
|
894
|
+
if (!Array.isArray(raw.elements))
|
|
895
|
+
throw new Error("snapshot.elements must be an array");
|
|
848
896
|
return {
|
|
849
897
|
elements: raw.elements.map((entry, index) => parseSnapshotElement(entry, index))
|
|
850
898
|
};
|
|
@@ -874,31 +922,29 @@ var HasgardWindow = class {
|
|
|
874
922
|
async title() {
|
|
875
923
|
return expectString(await this.rpc.call("title", withWindow(this.label)), "title result");
|
|
876
924
|
}
|
|
877
|
-
/**
|
|
878
|
-
* Navigate this webview by assigning `location.href`. Unlike Playwright's
|
|
879
|
-
* `goto`, this does not wait for the load to finish and returns no response —
|
|
880
|
-
* follow it with `waitFor` or `waitForWindowReady`.
|
|
881
|
-
*/
|
|
882
925
|
async navigate(url) {
|
|
883
926
|
await this.rpc.call("navigate", withWindow(this.label, { url }));
|
|
884
927
|
}
|
|
885
|
-
/** Scroll the document by a relative amount, or to `top`/`bottom`. */
|
|
886
928
|
async scrollBy(options) {
|
|
887
929
|
await this.rpc.call("scroll", withWindow(this.label, options));
|
|
888
930
|
}
|
|
889
931
|
async press(key, options) {
|
|
890
932
|
const params = { key };
|
|
891
933
|
if (options) {
|
|
892
|
-
if (options.completion !==
|
|
893
|
-
|
|
894
|
-
if (options.
|
|
934
|
+
if (options.completion !== undefined)
|
|
935
|
+
params.completion = options.completion;
|
|
936
|
+
if (options.waitFor !== undefined)
|
|
937
|
+
params.waitFor = options.waitFor;
|
|
938
|
+
if (options.timeoutMs !== undefined)
|
|
939
|
+
params.timeout = options.timeoutMs;
|
|
895
940
|
}
|
|
896
941
|
await this.rpc.call("press", withWindow(this.label, params));
|
|
897
942
|
}
|
|
898
943
|
async diff(options = {}) {
|
|
899
944
|
const { reference, ...snapshotOptions } = options;
|
|
900
945
|
const params = { ...snapshotOptions };
|
|
901
|
-
if (reference)
|
|
946
|
+
if (reference)
|
|
947
|
+
params.reference = reference;
|
|
902
948
|
const value = expectRecord(await this.rpc.call("diff", withWindow(this.label, params)), "diff result");
|
|
903
949
|
return {
|
|
904
950
|
added: expectArray(value.added, "diff.added").map(parseSnapshotElement),
|
|
@@ -906,26 +952,25 @@ var HasgardWindow = class {
|
|
|
906
952
|
changed: expectArray(value.changed, "diff.changed").map(parseDiffChange)
|
|
907
953
|
};
|
|
908
954
|
}
|
|
909
|
-
/**
|
|
910
|
-
* Poll a JavaScript expression in the webview until it returns a truthy
|
|
911
|
-
* value, and return that value. The expression is polled rather than driven
|
|
912
|
-
* by DOM mutations, so predicates that flip on a timer or a resolved fetch
|
|
913
|
-
* are caught too. A predicate that throws rejects instead of being retried.
|
|
914
|
-
*/
|
|
915
955
|
async waitForFunction(expression, options = {}) {
|
|
916
956
|
const params = { expression };
|
|
917
|
-
if (options.timeoutMs !==
|
|
918
|
-
|
|
957
|
+
if (options.timeoutMs !== undefined)
|
|
958
|
+
params.timeout = options.timeoutMs;
|
|
959
|
+
if (options.pollMs !== undefined)
|
|
960
|
+
params.poll = options.pollMs;
|
|
919
961
|
const value = expectRecord(await this.rpc.call("wait", withWindow(this.label, params)), "wait result");
|
|
920
962
|
return value.value;
|
|
921
963
|
}
|
|
922
|
-
/** Observe DOM mutations until they settle, or reject when nothing changes. */
|
|
923
964
|
async watch(options = {}) {
|
|
924
965
|
const params = {};
|
|
925
|
-
if (options.selector !==
|
|
926
|
-
|
|
927
|
-
if (options.
|
|
928
|
-
|
|
966
|
+
if (options.selector !== undefined)
|
|
967
|
+
params.selector = options.selector;
|
|
968
|
+
if (options.timeoutMs !== undefined)
|
|
969
|
+
params.timeout = options.timeoutMs;
|
|
970
|
+
if (options.stableMs !== undefined)
|
|
971
|
+
params.stable = options.stableMs;
|
|
972
|
+
if (options.requireMutation !== undefined)
|
|
973
|
+
params.requireMutation = options.requireMutation;
|
|
929
974
|
const value = expectRecord(await this.rpc.call("watch", withWindow(this.label, params)), "watch result");
|
|
930
975
|
return {
|
|
931
976
|
added: parseWatchNodes(value.added, "watch.added"),
|
|
@@ -935,43 +980,31 @@ var HasgardWindow = class {
|
|
|
935
980
|
};
|
|
936
981
|
}
|
|
937
982
|
async consoleLogs(options = {}) {
|
|
938
|
-
const value = await this.rpc.call(
|
|
939
|
-
"console.getLogs",
|
|
940
|
-
withWindow(this.label, options)
|
|
941
|
-
);
|
|
983
|
+
const value = await this.rpc.call("console.getLogs", withWindow(this.label, options));
|
|
942
984
|
return expectArray(value, "console.getLogs result").map(parseConsoleLogEntry);
|
|
943
985
|
}
|
|
944
986
|
async clearConsole() {
|
|
945
987
|
await this.rpc.call("console.clear", withWindow(this.label));
|
|
946
988
|
}
|
|
947
989
|
async networkRequests(options = {}) {
|
|
948
|
-
const value = await this.rpc.call(
|
|
949
|
-
"network.getRequests",
|
|
950
|
-
withWindow(this.label, options)
|
|
951
|
-
);
|
|
990
|
+
const value = await this.rpc.call("network.getRequests", withWindow(this.label, options));
|
|
952
991
|
return expectArray(value, "network.getRequests result").map(parseNetworkRequestEntry);
|
|
953
992
|
}
|
|
954
993
|
async clearNetwork() {
|
|
955
994
|
await this.rpc.call("network.clear", withWindow(this.label));
|
|
956
995
|
}
|
|
957
|
-
/** `localStorage` by default; pass `{ session: true }` for `sessionStorage`. */
|
|
958
996
|
storage = {
|
|
959
997
|
get: async (key, options = {}) => {
|
|
960
|
-
const value = expectRecord(
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
);
|
|
964
|
-
if (!expectBoolean(value.found, "storage.get.found")) return null;
|
|
998
|
+
const value = expectRecord(await this.rpc.call("storage.get", withWindow(this.label, { key, ...options })), "storage.get result");
|
|
999
|
+
if (!expectBoolean(value.found, "storage.get.found"))
|
|
1000
|
+
return null;
|
|
965
1001
|
return expectString(value.value, "storage.get.value");
|
|
966
1002
|
},
|
|
967
1003
|
set: async (key, value, options = {}) => {
|
|
968
1004
|
await this.rpc.call("storage.set", withWindow(this.label, { key, value, ...options }));
|
|
969
1005
|
},
|
|
970
1006
|
list: async (options = {}) => {
|
|
971
|
-
const value = expectRecord(
|
|
972
|
-
await this.rpc.call("storage.list", withWindow(this.label, { ...options })),
|
|
973
|
-
"storage.list result"
|
|
974
|
-
);
|
|
1007
|
+
const value = expectRecord(await this.rpc.call("storage.list", withWindow(this.label, { ...options })), "storage.list result");
|
|
975
1008
|
return {
|
|
976
1009
|
entries: expectArray(value.entries, "storage.list.entries").map((entry, index) => {
|
|
977
1010
|
const parsed = expectRecord(entry, `storage.list.entries[${index}]`);
|
|
@@ -988,7 +1021,7 @@ var HasgardWindow = class {
|
|
|
988
1021
|
}
|
|
989
1022
|
};
|
|
990
1023
|
async forms(selector) {
|
|
991
|
-
const params = selector ? { selector } :
|
|
1024
|
+
const params = selector ? { selector } : undefined;
|
|
992
1025
|
const value = expectRecord(await this.rpc.call("forms.dump", withWindow(this.label, params)), "forms.dump result");
|
|
993
1026
|
return {
|
|
994
1027
|
forms: expectArray(value.forms, "forms.dump.forms").map(parseFormEntry),
|
|
@@ -1002,22 +1035,21 @@ var HasgardWindow = class {
|
|
|
1002
1035
|
return this.rpc.call("ipc", withWindow(this.label, { command, args }));
|
|
1003
1036
|
}
|
|
1004
1037
|
async screenshot(selector) {
|
|
1005
|
-
const params = selector ? { selector } :
|
|
1038
|
+
const params = selector ? { selector } : undefined;
|
|
1006
1039
|
const dataUrl = await this.rpc.call("screenshot", withWindow(this.label, params));
|
|
1007
1040
|
const prefix = "data:image/png;base64,";
|
|
1008
|
-
if (!dataUrl.startsWith(prefix))
|
|
1041
|
+
if (!dataUrl.startsWith(prefix))
|
|
1042
|
+
throw new Error("screenshot result is not a PNG data URL");
|
|
1009
1043
|
return Buffer.from(dataUrl.slice(prefix.length), "base64");
|
|
1010
1044
|
}
|
|
1011
|
-
/** Start a bounded recording of this native window. macOS only; requires ffmpeg. */
|
|
1012
1045
|
async startVideo(options) {
|
|
1013
1046
|
await this.rpc.call("video.start", {
|
|
1014
1047
|
window: this.label,
|
|
1015
1048
|
output_path: options.outputPath,
|
|
1016
|
-
...options.fps ===
|
|
1017
|
-
...options.maxDurationMs ===
|
|
1049
|
+
...options.fps === undefined ? {} : { fps: options.fps },
|
|
1050
|
+
...options.maxDurationMs === undefined ? {} : { max_duration_ms: options.maxDurationMs }
|
|
1018
1051
|
});
|
|
1019
1052
|
}
|
|
1020
|
-
/** Stop recording; capture and encoder failures reject rather than returning an empty artifact. */
|
|
1021
1053
|
async stopVideo() {
|
|
1022
1054
|
const value = expectRecord(await this.rpc.call("video.stop", { window: this.label }), "video.stop result");
|
|
1023
1055
|
return {
|
|
@@ -1034,26 +1066,12 @@ var HasgardWindow = class {
|
|
|
1034
1066
|
pendingResult: expectBoolean(value.pending_result, "video.pending_result")
|
|
1035
1067
|
};
|
|
1036
1068
|
}
|
|
1037
|
-
/**
|
|
1038
|
-
* Capture this window through the native compositor, writing a PNG to
|
|
1039
|
-
* `outputPath`. macOS only.
|
|
1040
|
-
*
|
|
1041
|
-
* Unlike `screenshot`, which renders the DOM inside the page, this records
|
|
1042
|
-
* what the window server actually shows — so it sees native chrome, layered
|
|
1043
|
-
* content, and anything the DOM renderer cannot reproduce.
|
|
1044
|
-
*
|
|
1045
|
-
* The operating-system window id is resolved from this window's label, so
|
|
1046
|
-
* callers do not have to discover it themselves.
|
|
1047
|
-
*/
|
|
1048
1069
|
async screenshotNative(options) {
|
|
1049
1070
|
const windowId = options.windowId ?? await this.nativeId();
|
|
1050
|
-
const value = expectRecord(
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
}),
|
|
1055
|
-
"screenshot_native result"
|
|
1056
|
-
);
|
|
1071
|
+
const value = expectRecord(await this.rpc.call("screenshot_native", {
|
|
1072
|
+
window_id: windowId,
|
|
1073
|
+
output_path: options.outputPath
|
|
1074
|
+
}), "screenshot_native result");
|
|
1057
1075
|
return {
|
|
1058
1076
|
output_path: expectString(value.output_path, "screenshot_native.output_path"),
|
|
1059
1077
|
window_id: expectNumber(value.window_id, "screenshot_native.window_id"),
|
|
@@ -1065,102 +1083,58 @@ var HasgardWindow = class {
|
|
|
1065
1083
|
tcc_denied: expectBoolean(value.tcc_denied, "screenshot_native.tcc_denied")
|
|
1066
1084
|
};
|
|
1067
1085
|
}
|
|
1068
|
-
/**
|
|
1069
|
-
* Scroll the page by wheel.
|
|
1070
|
-
*
|
|
1071
|
-
* The document sees a real `wheel` event first and may cancel it; the scroll
|
|
1072
|
-
* only follows if it does not. Resolves to whether the page cancelled — which
|
|
1073
|
-
* is how you tell "the app intercepted my wheel" from "nothing was
|
|
1074
|
-
* listening". Use the locator method to scroll a nested scrollport.
|
|
1075
|
-
*/
|
|
1076
1086
|
async wheel(deltaX, deltaY) {
|
|
1077
1087
|
const value = expectRecord(await this.call("wheel", { deltaX, deltaY }), "wheel result");
|
|
1078
1088
|
return expectBoolean(value.defaultPrevented, "wheel.defaultPrevented");
|
|
1079
1089
|
}
|
|
1080
|
-
/**
|
|
1081
|
-
* The page's modal dialogs (`alert`, `confirm`, `prompt`).
|
|
1082
|
-
*
|
|
1083
|
-
* These block the webview until answered, so the bridge always answers them —
|
|
1084
|
-
* dismissing by default, as Playwright does. A test that never touches this
|
|
1085
|
-
* namespace still cannot hang on a `confirm()`, and can read back what the
|
|
1086
|
-
* app tried to ask.
|
|
1087
|
-
*/
|
|
1088
1090
|
dialogs = {
|
|
1089
|
-
/** Answer every subsequent dialog with OK. `promptText` fills a `prompt`. */
|
|
1090
1091
|
accept: async (promptText) => {
|
|
1091
1092
|
const params = { action: "accept" };
|
|
1092
|
-
if (promptText !==
|
|
1093
|
+
if (promptText !== undefined)
|
|
1094
|
+
params.promptText = promptText;
|
|
1093
1095
|
return parseDialogPolicy(await this.call("dialog.handle", params));
|
|
1094
1096
|
},
|
|
1095
|
-
/** Answer every subsequent dialog with Cancel. This is the default. */
|
|
1096
1097
|
dismiss: async () => parseDialogPolicy(await this.call("dialog.handle", { action: "dismiss" })),
|
|
1097
|
-
/** Every dialog the page has opened so far, oldest first. */
|
|
1098
1098
|
list: async () => {
|
|
1099
1099
|
const value = expectRecord(await this.call("dialog.list"), "dialog.list result");
|
|
1100
|
-
if (!Array.isArray(value.dialogs))
|
|
1100
|
+
if (!Array.isArray(value.dialogs))
|
|
1101
|
+
throw new Error("dialog.list.dialogs must be an array");
|
|
1101
1102
|
return {
|
|
1102
1103
|
dialogs: value.dialogs.map((entry, index) => parseDialogRecord(entry, index)),
|
|
1103
1104
|
policy: parseDialogPolicy(value.policy)
|
|
1104
1105
|
};
|
|
1105
1106
|
},
|
|
1106
|
-
/** Forget the recorded dialogs. Leaves the accept/dismiss policy alone. */
|
|
1107
1107
|
clear: async () => {
|
|
1108
1108
|
await this.call("dialog.clear");
|
|
1109
1109
|
}
|
|
1110
1110
|
};
|
|
1111
|
-
/**
|
|
1112
|
-
* Shape what the page's own requests receive, without a real backend.
|
|
1113
|
-
*
|
|
1114
|
-
* Rules are declarative rather than Playwright's per-request callback: the
|
|
1115
|
-
* bridge only ever answers requests, so it cannot call back into this process
|
|
1116
|
-
* and await a handler while the page sits inside `fetch`. The same constraint
|
|
1117
|
-
* shaped `dialogs`.
|
|
1118
|
-
*
|
|
1119
|
-
* Rules are checked in registration order and the first match wins, so
|
|
1120
|
-
* register the narrow ones before a catch-all. Both `fetch` and
|
|
1121
|
-
* `XMLHttpRequest` are covered; resources the webview loads itself -- the
|
|
1122
|
-
* document, `<img src>`, stylesheets -- are not, since no page script sees
|
|
1123
|
-
* them.
|
|
1124
|
-
*/
|
|
1125
1111
|
routes = {
|
|
1126
|
-
/** Answer matching requests with this response instead of the network. */
|
|
1127
1112
|
fulfill: async (options) => {
|
|
1128
|
-
const value = expectRecord(
|
|
1129
|
-
await this.call("route.add", { ...routeParams(options), action: "fulfill" }),
|
|
1130
|
-
"route.add result"
|
|
1131
|
-
);
|
|
1113
|
+
const value = expectRecord(await this.call("route.add", { ...routeParams(options), action: "fulfill" }), "route.add result");
|
|
1132
1114
|
return { id: expectNumber(value.id, "route.add.id") };
|
|
1133
1115
|
},
|
|
1134
|
-
/** Fail matching requests the way a dropped connection does. */
|
|
1135
1116
|
abort: async (options) => {
|
|
1136
|
-
const value = expectRecord(
|
|
1137
|
-
await this.call("route.add", { ...routeParams(options), action: "abort" }),
|
|
1138
|
-
"route.add result"
|
|
1139
|
-
);
|
|
1117
|
+
const value = expectRecord(await this.call("route.add", { ...routeParams(options), action: "abort" }), "route.add result");
|
|
1140
1118
|
return { id: expectNumber(value.id, "route.add.id") };
|
|
1141
1119
|
},
|
|
1142
|
-
/** The active rules, their use counts, and every request they answered. */
|
|
1143
1120
|
list: async () => {
|
|
1144
1121
|
const value = expectRecord(await this.call("route.list"), "route.list result");
|
|
1145
1122
|
return {
|
|
1146
1123
|
routes: expectArray(value.routes, "route.list.routes").map((entry, index) => parseRouteRule(entry, index)),
|
|
1147
|
-
intercepted: expectArray(value.intercepted, "route.list.intercepted").map(
|
|
1148
|
-
(entry, index) => parseInterceptedRequest(entry, index)
|
|
1149
|
-
)
|
|
1124
|
+
intercepted: expectArray(value.intercepted, "route.list.intercepted").map((entry, index) => parseInterceptedRequest(entry, index))
|
|
1150
1125
|
};
|
|
1151
1126
|
},
|
|
1152
|
-
/** Remove every rule and its interception log. */
|
|
1153
1127
|
clear: async () => {
|
|
1154
1128
|
const value = expectRecord(await this.call("route.clear"), "route.clear result");
|
|
1155
1129
|
return { removed: expectNumber(value.removed, "route.clear.removed") };
|
|
1156
1130
|
}
|
|
1157
1131
|
};
|
|
1158
|
-
/** This window's operating-system window id, where the platform exposes one. */
|
|
1159
1132
|
async nativeId() {
|
|
1160
1133
|
const listing = await new HasgardApplication(this.rpc).windows();
|
|
1161
1134
|
const self = listing.find((entry) => entry.label === this.label);
|
|
1162
|
-
if (!self)
|
|
1163
|
-
|
|
1135
|
+
if (!self)
|
|
1136
|
+
throw new Error(`Window '${this.label}' is no longer open`);
|
|
1137
|
+
if (self.nativeId === undefined) {
|
|
1164
1138
|
throw new Error(`Window '${this.label}' has no native window id on this platform`);
|
|
1165
1139
|
}
|
|
1166
1140
|
return self.nativeId;
|
|
@@ -1183,17 +1157,17 @@ var HasgardWindow = class {
|
|
|
1183
1157
|
release();
|
|
1184
1158
|
}
|
|
1185
1159
|
}
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
class HasgardFrameLocator {
|
|
1188
1163
|
windowRef;
|
|
1189
1164
|
frame;
|
|
1190
1165
|
constructor(window, frame) {
|
|
1191
1166
|
this.windowRef = window;
|
|
1192
1167
|
this.frame = frame;
|
|
1193
1168
|
}
|
|
1194
|
-
/** Descend into a frame nested inside this one. */
|
|
1195
1169
|
frameLocator(selector) {
|
|
1196
|
-
return new
|
|
1170
|
+
return new HasgardFrameLocator(this.windowRef, [...this.frame, selector]);
|
|
1197
1171
|
}
|
|
1198
1172
|
locator(selector) {
|
|
1199
1173
|
return new HasgardLocator(this.windowRef, { kind: "selector", selector, frame: [...this.frame] });
|
|
@@ -1219,29 +1193,21 @@ var HasgardFrameLocator = class _HasgardFrameLocator {
|
|
|
1219
1193
|
getByTestId(testId) {
|
|
1220
1194
|
return this.byDimension("testid", testId, {});
|
|
1221
1195
|
}
|
|
1222
|
-
/** Snapshot the frame's own document rather than the top one. */
|
|
1223
1196
|
async snapshot(options = {}) {
|
|
1224
1197
|
return this.windowRef.snapshot({ ...options, frame: [...this.frame] });
|
|
1225
1198
|
}
|
|
1226
1199
|
byDimension(by, value, query) {
|
|
1227
1200
|
return new HasgardLocator(this.windowRef, { kind: "query", by, value, query, frame: [...this.frame] });
|
|
1228
1201
|
}
|
|
1229
|
-
}
|
|
1230
|
-
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
class HasgardLocator {
|
|
1231
1205
|
window;
|
|
1232
1206
|
query;
|
|
1233
1207
|
constructor(window, query) {
|
|
1234
1208
|
this.window = window;
|
|
1235
1209
|
this.query = query;
|
|
1236
1210
|
}
|
|
1237
|
-
/**
|
|
1238
|
-
* Click this element.
|
|
1239
|
-
*
|
|
1240
|
-
* A right or middle click raises `contextmenu`/`auxclick` and no `click`, and
|
|
1241
|
-
* `clickCount: 2` escalates `detail` across the presses before a single
|
|
1242
|
-
* `dblclick` -- both matching what the platform does, so a listener bound to
|
|
1243
|
-
* the wrong event fails here rather than in front of a user.
|
|
1244
|
-
*/
|
|
1245
1211
|
async click(options = {}) {
|
|
1246
1212
|
await this.withTarget((target) => this.window.call("click", { ...this.scoped(target), ...clickParams(options) }));
|
|
1247
1213
|
}
|
|
@@ -1251,61 +1217,37 @@ var HasgardLocator = class _HasgardLocator {
|
|
|
1251
1217
|
async type(text) {
|
|
1252
1218
|
await this.withTarget((target) => this.window.call("type", { ...this.scoped(target), text }));
|
|
1253
1219
|
}
|
|
1254
|
-
/** Empty the control, firing `input` and `change` as `fill` does. */
|
|
1255
1220
|
async clear() {
|
|
1256
1221
|
await this.fill("");
|
|
1257
1222
|
}
|
|
1258
|
-
/**
|
|
1259
|
-
* Select files on an `<input type="file">` without a native file chooser,
|
|
1260
|
-
* which an embedded webview gives no way to drive.
|
|
1261
|
-
*
|
|
1262
|
-
* Strings are read from the test machine's disk; `DropFile` payloads let you
|
|
1263
|
-
* supply content that never existed as a file. An empty array deselects.
|
|
1264
|
-
*/
|
|
1265
1223
|
async setInputFiles(files) {
|
|
1266
1224
|
const payloads = await readFileInputs(files);
|
|
1267
|
-
await this.withTarget(
|
|
1268
|
-
(target)
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
);
|
|
1273
|
-
}
|
|
1274
|
-
/**
|
|
1275
|
-
* Scroll this element by wheel.
|
|
1276
|
-
*
|
|
1277
|
-
* The page sees a real `wheel` event first and may cancel it; the scroll only
|
|
1278
|
-
* follows if it does not — the same two steps a physical wheel produces.
|
|
1279
|
-
* Resolves to whether the page cancelled.
|
|
1280
|
-
*/
|
|
1225
|
+
await this.withTarget((target) => this.window.call("setInputFiles", {
|
|
1226
|
+
...this.scoped(target),
|
|
1227
|
+
files: payloads
|
|
1228
|
+
}));
|
|
1229
|
+
}
|
|
1281
1230
|
async wheel(deltaX, deltaY) {
|
|
1282
1231
|
return this.withTarget(async (target) => {
|
|
1283
|
-
const value = expectRecord(
|
|
1284
|
-
await this.window.call("wheel", { ...this.scoped(target), deltaX, deltaY }),
|
|
1285
|
-
"wheel result"
|
|
1286
|
-
);
|
|
1232
|
+
const value = expectRecord(await this.window.call("wheel", { ...this.scoped(target), deltaX, deltaY }), "wheel result");
|
|
1287
1233
|
return expectBoolean(value.defaultPrevented, "wheel.defaultPrevented");
|
|
1288
1234
|
});
|
|
1289
1235
|
}
|
|
1290
1236
|
async selectOption(value) {
|
|
1291
1237
|
await this.withTarget((target) => this.window.call("select", { ...this.scoped(target), value }));
|
|
1292
1238
|
}
|
|
1293
|
-
/** Drive the checkbox to checked. A no-op when it is already checked. */
|
|
1294
1239
|
async check() {
|
|
1295
1240
|
await this.withTarget((target) => this.window.call("check", { ...this.scoped(target), checked: true }));
|
|
1296
1241
|
}
|
|
1297
|
-
/** Drive the checkbox to unchecked. A no-op when it is already unchecked. */
|
|
1298
1242
|
async uncheck() {
|
|
1299
1243
|
await this.withTarget((target) => this.window.call("check", { ...this.scoped(target), checked: false }));
|
|
1300
1244
|
}
|
|
1301
|
-
/** Flip the checkbox, whatever its current state. */
|
|
1302
1245
|
async toggle() {
|
|
1303
1246
|
await this.withTarget((target) => this.window.call("check", this.scoped(target)));
|
|
1304
1247
|
}
|
|
1305
1248
|
async hover() {
|
|
1306
1249
|
await this.withTarget((target) => this.window.call("hover", this.scoped(target)));
|
|
1307
1250
|
}
|
|
1308
|
-
/** Shorthand for `click({ clickCount: 2 })`; other options still apply. */
|
|
1309
1251
|
async dblclick(options = {}) {
|
|
1310
1252
|
await this.click({ ...options, clickCount: 2 });
|
|
1311
1253
|
}
|
|
@@ -1315,11 +1257,8 @@ var HasgardLocator = class _HasgardLocator {
|
|
|
1315
1257
|
async blur() {
|
|
1316
1258
|
await this.withTarget((target) => this.window.call("blur", this.scoped(target)));
|
|
1317
1259
|
}
|
|
1318
|
-
/** Scroll this element's own scrollport. */
|
|
1319
1260
|
async scrollBy(options) {
|
|
1320
|
-
await this.withTarget(
|
|
1321
|
-
(target) => this.window.call("scroll", { ...this.scoped(target), ...options })
|
|
1322
|
-
);
|
|
1261
|
+
await this.withTarget((target) => this.window.call("scroll", { ...this.scoped(target), ...options }));
|
|
1323
1262
|
}
|
|
1324
1263
|
async boundingBox() {
|
|
1325
1264
|
return this.withTarget(async (target) => {
|
|
@@ -1342,25 +1281,18 @@ var HasgardLocator = class _HasgardLocator {
|
|
|
1342
1281
|
return !await this.isDisabled();
|
|
1343
1282
|
}
|
|
1344
1283
|
async textContent() {
|
|
1345
|
-
return this.withTarget(
|
|
1346
|
-
async (target) => expectString(await this.window.call("text", this.scoped(target)), "text result")
|
|
1347
|
-
);
|
|
1284
|
+
return this.withTarget(async (target) => expectString(await this.window.call("text", this.scoped(target)), "text result"));
|
|
1348
1285
|
}
|
|
1349
1286
|
async inputValue() {
|
|
1350
|
-
return this.withTarget(
|
|
1351
|
-
async (target) => expectString(await this.window.call("value", this.scoped(target)), "value result")
|
|
1352
|
-
);
|
|
1287
|
+
return this.withTarget(async (target) => expectString(await this.window.call("value", this.scoped(target)), "value result"));
|
|
1353
1288
|
}
|
|
1354
1289
|
async innerHTML() {
|
|
1355
|
-
return this.withTarget(
|
|
1356
|
-
async (target) => expectString(await this.window.call("html", this.scoped(target)), "html result")
|
|
1357
|
-
);
|
|
1290
|
+
return this.withTarget(async (target) => expectString(await this.window.call("html", this.scoped(target)), "html result"));
|
|
1358
1291
|
}
|
|
1359
1292
|
async getAttribute(name) {
|
|
1360
1293
|
const attributes = await this.attributes();
|
|
1361
|
-
return Object.
|
|
1294
|
+
return Object.hasOwn(attributes, name) ? attributes[name] : null;
|
|
1362
1295
|
}
|
|
1363
|
-
/** Drag this element onto another one in the same window. */
|
|
1364
1296
|
async dragTo(target) {
|
|
1365
1297
|
if (target.window !== this.window) {
|
|
1366
1298
|
throw new Error("dragTo requires both locators to belong to the same window");
|
|
@@ -1374,33 +1306,26 @@ var HasgardLocator = class _HasgardLocator {
|
|
|
1374
1306
|
});
|
|
1375
1307
|
});
|
|
1376
1308
|
}
|
|
1377
|
-
/** Drag this element by a pixel offset from its centre. */
|
|
1378
1309
|
async dragBy(offset) {
|
|
1379
|
-
await this.withTarget(
|
|
1380
|
-
(target) => this.window.call("drag", { source: this.scoped(target), offset: { x: offset.x, y: offset.y } })
|
|
1381
|
-
);
|
|
1310
|
+
await this.withTarget((target) => this.window.call("drag", { source: this.scoped(target), offset: { x: offset.x, y: offset.y } }));
|
|
1382
1311
|
}
|
|
1383
|
-
/** Dispatch a file drop onto this element. `data` is base64-encoded. */
|
|
1384
1312
|
async dropFiles(files) {
|
|
1385
|
-
await this.withTarget(
|
|
1386
|
-
(target)
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
})
|
|
1390
|
-
);
|
|
1313
|
+
await this.withTarget((target) => this.window.call("drop", {
|
|
1314
|
+
...this.scoped(target),
|
|
1315
|
+
files
|
|
1316
|
+
}));
|
|
1391
1317
|
}
|
|
1392
1318
|
async attributes() {
|
|
1393
1319
|
return this.withTarget(async (target) => {
|
|
1394
1320
|
const value = expectRecord(await this.window.call("attrs", this.scoped(target)), "attrs result");
|
|
1395
|
-
return Object.fromEntries(
|
|
1396
|
-
Object.entries(value).map(([name, attr]) => [name, expectString(attr, `attribute ${name}`)])
|
|
1397
|
-
);
|
|
1321
|
+
return Object.fromEntries(Object.entries(value).map(([name, attr]) => [name, expectString(attr, `attribute ${name}`)]));
|
|
1398
1322
|
});
|
|
1399
1323
|
}
|
|
1400
1324
|
async isVisible() {
|
|
1401
1325
|
return this.window.runExclusive(async () => {
|
|
1402
1326
|
const target = await this.resolveUnique(true);
|
|
1403
|
-
if (target === null)
|
|
1327
|
+
if (target === null)
|
|
1328
|
+
return false;
|
|
1404
1329
|
const value = expectRecord(await this.window.call("visible", this.scoped(target)), "visible result");
|
|
1405
1330
|
return expectBoolean(value.visible, "visible.visible");
|
|
1406
1331
|
});
|
|
@@ -1412,19 +1337,14 @@ var HasgardLocator = class _HasgardLocator {
|
|
|
1412
1337
|
});
|
|
1413
1338
|
}
|
|
1414
1339
|
async count() {
|
|
1415
|
-
const total = this.query.kind === "selector" && !this.query.filters?.length ? expectNumber(
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
"count result"
|
|
1419
|
-
).count,
|
|
1420
|
-
"count.count"
|
|
1421
|
-
) : (await this.resolveAll()).length;
|
|
1422
|
-
if (this.query.index === void 0) return total;
|
|
1340
|
+
const total = this.query.kind === "selector" && !this.query.filters?.length ? expectNumber(expectRecord(await this.window.call("count", { selector: this.query.selector, ...this.frameParams() }), "count result").count, "count.count") : (await this.resolveAll()).length;
|
|
1341
|
+
if (this.query.index === undefined)
|
|
1342
|
+
return total;
|
|
1423
1343
|
const position = absoluteIndex(this.query.index, total);
|
|
1424
1344
|
return position >= 0 && position < total ? 1 : 0;
|
|
1425
1345
|
}
|
|
1426
1346
|
async waitFor(options) {
|
|
1427
|
-
if (this.query.kind === "selector" && this.query.index ===
|
|
1347
|
+
if (this.query.kind === "selector" && this.query.index === undefined && !this.query.filters?.length) {
|
|
1428
1348
|
await this.window.call("wait", {
|
|
1429
1349
|
selector: this.query.selector,
|
|
1430
1350
|
gone: options.state === "detached",
|
|
@@ -1436,32 +1356,22 @@ var HasgardLocator = class _HasgardLocator {
|
|
|
1436
1356
|
const deadline = Date.now() + options.timeoutMs;
|
|
1437
1357
|
while (Date.now() <= deadline) {
|
|
1438
1358
|
const found = await this.count() > 0;
|
|
1439
|
-
if (found === (options.state === "attached"))
|
|
1359
|
+
if (found === (options.state === "attached"))
|
|
1360
|
+
return;
|
|
1440
1361
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1441
1362
|
}
|
|
1442
1363
|
throw new Error(`Locator did not become ${options.state} within ${options.timeoutMs}ms`);
|
|
1443
1364
|
}
|
|
1444
|
-
/**
|
|
1445
|
-
* Narrow this locator to a single match by position. A negative index counts
|
|
1446
|
-
* back from the end, so `last()` stays one round trip.
|
|
1447
|
-
*/
|
|
1448
1365
|
nth(index) {
|
|
1449
|
-
if (!Number.isInteger(index))
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
* Narrow this locator to the elements whose text matches.
|
|
1454
|
-
*
|
|
1455
|
-
* Filters accumulate, so `filter({hasText}).filter({hasNotText})` applies
|
|
1456
|
-
* both. Ordering matters against `nth`: filtering happens first, so
|
|
1457
|
-
* `filter({hasText: "x"}).first()` is the first *matching* element, which is
|
|
1458
|
-
* the reading that makes `first()` mean what it says.
|
|
1459
|
-
*/
|
|
1366
|
+
if (!Number.isInteger(index))
|
|
1367
|
+
throw new Error("nth requires an integer index");
|
|
1368
|
+
return new HasgardLocator(this.window, { ...this.query, index });
|
|
1369
|
+
}
|
|
1460
1370
|
filter(options) {
|
|
1461
|
-
if (options.hasText ===
|
|
1371
|
+
if (options.hasText === undefined && options.hasNotText === undefined) {
|
|
1462
1372
|
throw new Error("filter requires hasText or hasNotText");
|
|
1463
1373
|
}
|
|
1464
|
-
return new
|
|
1374
|
+
return new HasgardLocator(this.window, {
|
|
1465
1375
|
...this.query,
|
|
1466
1376
|
filters: [...this.query.filters ?? [], options]
|
|
1467
1377
|
});
|
|
@@ -1474,30 +1384,25 @@ var HasgardLocator = class _HasgardLocator {
|
|
|
1474
1384
|
}
|
|
1475
1385
|
async resolveUnique(allowMissing = false) {
|
|
1476
1386
|
if (this.query.kind === "selector" && !this.query.filters?.length) {
|
|
1477
|
-
return this.query.index ===
|
|
1387
|
+
return this.query.index === undefined ? bySelector(this.query.selector) : { selector: this.query.selector, index: this.query.index };
|
|
1478
1388
|
}
|
|
1479
1389
|
const elements = await this.resolveAll();
|
|
1480
|
-
if (this.query.index !==
|
|
1390
|
+
if (this.query.index !== undefined) {
|
|
1481
1391
|
const element = elements[absoluteIndex(this.query.index, elements.length)];
|
|
1482
1392
|
if (!element) {
|
|
1483
|
-
if (allowMissing)
|
|
1393
|
+
if (allowMissing)
|
|
1394
|
+
return null;
|
|
1484
1395
|
throw new Error(`Locator has no element at index ${this.query.index} (${elements.length} matched)`);
|
|
1485
1396
|
}
|
|
1486
1397
|
return byRef(element.ref);
|
|
1487
1398
|
}
|
|
1488
|
-
if (elements.length === 0 && allowMissing)
|
|
1399
|
+
if (elements.length === 0 && allowMissing)
|
|
1400
|
+
return null;
|
|
1489
1401
|
if (elements.length !== 1) {
|
|
1490
1402
|
throw new Error(`Locator resolved to ${elements.length} elements; expected exactly one`);
|
|
1491
1403
|
}
|
|
1492
1404
|
return byRef(elements[0].ref);
|
|
1493
1405
|
}
|
|
1494
|
-
/**
|
|
1495
|
-
* Serialize a target together with the frame chain this locator lives in.
|
|
1496
|
-
*
|
|
1497
|
-
* A ref carries its own document, so the bridge ignores `frame` for one; the
|
|
1498
|
-
* chain still travels so that a selector or coordinate target -- which the
|
|
1499
|
-
* bridge must resolve itself -- lands in the right document.
|
|
1500
|
-
*/
|
|
1501
1406
|
scoped(target) {
|
|
1502
1407
|
return { ...targetParams(target), ...this.frameParams() };
|
|
1503
1408
|
}
|
|
@@ -1516,36 +1421,38 @@ var HasgardLocator = class _HasgardLocator {
|
|
|
1516
1421
|
}
|
|
1517
1422
|
if (this.query.kind === "query") {
|
|
1518
1423
|
const params = { by: this.query.by, value: this.query.value };
|
|
1519
|
-
if (this.query.query.exact !==
|
|
1424
|
+
if (this.query.query.exact !== undefined)
|
|
1425
|
+
params.exact = this.query.query.exact;
|
|
1520
1426
|
return this.runQuery(params);
|
|
1521
1427
|
}
|
|
1522
1428
|
const query = this.query;
|
|
1523
1429
|
const snapshot = await this.window.snapshot(query.frame ? { frame: [...query.frame] } : {});
|
|
1524
1430
|
return snapshot.elements.filter((element) => {
|
|
1525
|
-
if (element.role !== query.role)
|
|
1431
|
+
if (element.role !== query.role)
|
|
1432
|
+
return false;
|
|
1526
1433
|
const expected = query.query.name;
|
|
1527
|
-
if (expected ===
|
|
1528
|
-
|
|
1529
|
-
if (
|
|
1434
|
+
if (expected === undefined)
|
|
1435
|
+
return true;
|
|
1436
|
+
if (element.name === undefined)
|
|
1437
|
+
return false;
|
|
1438
|
+
if (expected instanceof RegExp)
|
|
1439
|
+
return expected.test(element.name);
|
|
1530
1440
|
return query.query.exact ? element.name === expected : element.name.includes(expected);
|
|
1531
1441
|
});
|
|
1532
1442
|
}
|
|
1533
|
-
/**
|
|
1534
|
-
* Apply each accumulated filter, one round trip per filter.
|
|
1535
|
-
*
|
|
1536
|
-
* Filters run server-side rather than against a snapshot because the text a
|
|
1537
|
-
* filter matches on is not carried in snapshot entries — deliberately, so
|
|
1538
|
-
* that every unrelated `snapshot` call stays small.
|
|
1539
|
-
*/
|
|
1540
1443
|
async applyFilters(elements) {
|
|
1541
1444
|
const filters = this.query.filters ?? [];
|
|
1542
1445
|
let current = elements;
|
|
1543
1446
|
for (const filter of filters) {
|
|
1544
|
-
if (current.length === 0)
|
|
1447
|
+
if (current.length === 0)
|
|
1448
|
+
return current;
|
|
1545
1449
|
const params = { refs: current.map((element) => element.ref) };
|
|
1546
|
-
if (filter.hasText !==
|
|
1547
|
-
|
|
1548
|
-
if (filter.
|
|
1450
|
+
if (filter.hasText !== undefined)
|
|
1451
|
+
params.hasText = filter.hasText;
|
|
1452
|
+
if (filter.hasNotText !== undefined)
|
|
1453
|
+
params.hasNotText = filter.hasNotText;
|
|
1454
|
+
if (filter.exact !== undefined)
|
|
1455
|
+
params.exact = filter.exact;
|
|
1549
1456
|
current = this.parseElements(await this.window.call("filter", params), "filter");
|
|
1550
1457
|
}
|
|
1551
1458
|
return current;
|
|
@@ -1555,10 +1462,11 @@ var HasgardLocator = class _HasgardLocator {
|
|
|
1555
1462
|
}
|
|
1556
1463
|
parseElements(raw, source) {
|
|
1557
1464
|
const value = expectRecord(raw, `${source} result`);
|
|
1558
|
-
if (!Array.isArray(value.elements))
|
|
1465
|
+
if (!Array.isArray(value.elements))
|
|
1466
|
+
throw new Error(`${source}.elements must be an array`);
|
|
1559
1467
|
return value.elements.map((entry, index) => parseSnapshotElement(entry, index));
|
|
1560
1468
|
}
|
|
1561
|
-
}
|
|
1469
|
+
}
|
|
1562
1470
|
function parseDialogPolicy(value) {
|
|
1563
1471
|
const record = expectRecord(value, "dialog policy");
|
|
1564
1472
|
const action = expectString(record.action, "dialog policy.action");
|
|
@@ -1583,10 +1491,11 @@ function parseDialogRecord(value, index) {
|
|
|
1583
1491
|
message: expectString(entry.message, `dialogs[${index}].message`),
|
|
1584
1492
|
accepted: expectBoolean(entry.accepted, `dialogs[${index}].accepted`)
|
|
1585
1493
|
};
|
|
1586
|
-
if (entry.defaultValue !==
|
|
1494
|
+
if (entry.defaultValue !== undefined) {
|
|
1587
1495
|
record.defaultValue = expectString(entry.defaultValue, `dialogs[${index}].defaultValue`);
|
|
1588
1496
|
}
|
|
1589
|
-
if (entry.returned !==
|
|
1497
|
+
if (entry.returned !== undefined)
|
|
1498
|
+
record.returned = expectString(entry.returned, `dialogs[${index}].returned`);
|
|
1590
1499
|
return record;
|
|
1591
1500
|
}
|
|
1592
1501
|
function parseSnapshotElement(value, index) {
|
|
@@ -1596,10 +1505,13 @@ function parseSnapshotElement(value, index) {
|
|
|
1596
1505
|
role: expectString(entry.role, `snapshot.elements[${index}].role`),
|
|
1597
1506
|
depth: expectNumber(entry.depth, `snapshot.elements[${index}].depth`)
|
|
1598
1507
|
};
|
|
1599
|
-
if (entry.name !==
|
|
1600
|
-
|
|
1601
|
-
if (entry.
|
|
1602
|
-
|
|
1508
|
+
if (entry.name !== undefined)
|
|
1509
|
+
element.name = expectString(entry.name, `snapshot.elements[${index}].name`);
|
|
1510
|
+
if (entry.value !== undefined)
|
|
1511
|
+
element.value = expectString(entry.value, `snapshot.elements[${index}].value`);
|
|
1512
|
+
if (entry.checked !== undefined)
|
|
1513
|
+
element.checked = expectBoolean(entry.checked, `snapshot.elements[${index}].checked`);
|
|
1514
|
+
if (entry.disabled !== undefined)
|
|
1603
1515
|
element.disabled = expectBoolean(entry.disabled, `snapshot.elements[${index}].disabled`);
|
|
1604
1516
|
return element;
|
|
1605
1517
|
}
|
|
@@ -1608,23 +1520,26 @@ function parseSnapshotElement(value, index) {
|
|
|
1608
1520
|
function createHasgardTest(config) {
|
|
1609
1521
|
const base = config.test;
|
|
1610
1522
|
const expect = createHasgardExpect(config.expect);
|
|
1611
|
-
const readinessTimeoutMs = config.launch ? config.launch.timeoutMs :
|
|
1612
|
-
const fixtureTimeoutMs = config.launch ? config.launch.timeoutMs +
|
|
1613
|
-
const processes =
|
|
1523
|
+
const readinessTimeoutMs = config.launch ? config.launch.timeoutMs : 30000;
|
|
1524
|
+
const fixtureTimeoutMs = config.launch ? config.launch.timeoutMs + 30000 : 30000;
|
|
1525
|
+
const processes = new WeakMap;
|
|
1614
1526
|
const test = base.extend({
|
|
1615
1527
|
hasgard: [
|
|
1616
1528
|
async ({}, use, workerInfo) => {
|
|
1617
1529
|
const socketPath = typeof config.socketPath === "function" ? config.socketPath(workerInfo.workerIndex) : config.socketPath;
|
|
1618
|
-
const process2 = new HasgardProcess
|
|
1530
|
+
const process2 = new HasgardProcess;
|
|
1619
1531
|
const rpc = new HasgardRpcClient(socketPath);
|
|
1620
1532
|
try {
|
|
1621
|
-
if (config.launch)
|
|
1622
|
-
|
|
1533
|
+
if (config.launch)
|
|
1534
|
+
await process2.start(config.launch, socketPath);
|
|
1535
|
+
else
|
|
1536
|
+
await process2.waitForSocket(socketPath, workerInfo.project.timeout);
|
|
1623
1537
|
await rpc.connect(workerInfo.project.timeout);
|
|
1624
1538
|
const hasgard = new HasgardApplication(rpc);
|
|
1625
1539
|
processes.set(hasgard, process2);
|
|
1626
1540
|
const ping = await hasgard.ping();
|
|
1627
|
-
if (ping.status !== "ok")
|
|
1541
|
+
if (ping.status !== "ok")
|
|
1542
|
+
throw new Error(`Unexpected Hasgard ping status: ${ping.status}`);
|
|
1628
1543
|
await hasgard.waitForWindowReady(config.windowLabel, config.readySelector, readinessTimeoutMs);
|
|
1629
1544
|
await use(hasgard);
|
|
1630
1545
|
} catch (error) {
|
|
@@ -1653,7 +1568,8 @@ function createHasgardTest(config) {
|
|
|
1653
1568
|
body: JSON.stringify(diagnostics, null, 2),
|
|
1654
1569
|
contentType: "application/json"
|
|
1655
1570
|
});
|
|
1656
|
-
if (diagnostics.exit)
|
|
1571
|
+
if (diagnostics.exit)
|
|
1572
|
+
console.error(`Hasgard application exited: ${JSON.stringify(diagnostics)}`);
|
|
1657
1573
|
}
|
|
1658
1574
|
try {
|
|
1659
1575
|
const screenshot = await hasgard.window(config.windowLabel).screenshot();
|