@nyssance/tauri-hasgard 0.6.0-alpha.2 → 0.6.0-alpha.5
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 +383 -463
- package/dist/process-supervisor.js +78 -29
- package/dist/window.d.ts +1 -1
- package/package.json +9 -10
- package/dist/chunk-N7YDYAKI.js +0 -31
- package/dist/endpoint.test.d.ts +0 -1
- package/dist/expect.test.d.ts +0 -1
- package/dist/process-group.test.d.ts +0 -1
- package/dist/process-manager.test.d.ts +0 -1
- package/dist/protocol.test.d.ts +0 -1
- package/dist/rpc-client.test.d.ts +0 -1
- package/dist/window.test.d.ts +0 -1
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
|
}
|
|
@@ -127,7 +152,9 @@ var HasgardProcess = class {
|
|
|
127
152
|
try {
|
|
128
153
|
await this.stopChild();
|
|
129
154
|
} catch (cleanupError) {
|
|
130
|
-
throw new AggregateError([error, cleanupError], "Hasgard launch failed and process cleanup failed"
|
|
155
|
+
throw new AggregateError([error, cleanupError], "Hasgard launch failed and process cleanup failed", {
|
|
156
|
+
cause: cleanupError
|
|
157
|
+
});
|
|
131
158
|
}
|
|
132
159
|
throw error;
|
|
133
160
|
}
|
|
@@ -151,26 +178,28 @@ var HasgardProcess = class {
|
|
|
151
178
|
};
|
|
152
179
|
child.once("error", failed);
|
|
153
180
|
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();
|
|
181
|
+
child.on("message", (message) => {
|
|
182
|
+
if (message.type === "stdout")
|
|
183
|
+
this.stdout = appendOutput(this.stdout, message.data);
|
|
184
|
+
else if (message.type === "stderr")
|
|
185
|
+
this.stderr = appendOutput(this.stderr, message.data);
|
|
186
|
+
else if (message.type === "exit")
|
|
187
|
+
this.applicationExit = { code: message.code, signal: message.signal };
|
|
188
|
+
else if (message.type === "error")
|
|
189
|
+
failed(Object.assign(new Error(message.message), { code: message.code }));
|
|
190
|
+
else if (message.type === "started") {
|
|
191
|
+
if (!Number.isSafeInteger(message.pid) || message.pid <= 1) {
|
|
192
|
+
failed(new Error("Invalid supervised application PID"));
|
|
193
|
+
return;
|
|
169
194
|
}
|
|
195
|
+
this.applicationPid = message.pid;
|
|
196
|
+
clearTimeout(timer);
|
|
197
|
+
resolve();
|
|
170
198
|
}
|
|
171
|
-
);
|
|
199
|
+
});
|
|
172
200
|
child.send({ ...config, env: { ...process.env, ...config.env, TAURI_HASGARD_SOCKET: socketPath } }, (error) => {
|
|
173
|
-
if (error)
|
|
201
|
+
if (error)
|
|
202
|
+
failed(error);
|
|
174
203
|
});
|
|
175
204
|
});
|
|
176
205
|
await this.waitForSocket(socketPath, config.timeoutMs);
|
|
@@ -179,7 +208,9 @@ var HasgardProcess = class {
|
|
|
179
208
|
try {
|
|
180
209
|
await this.stopChild();
|
|
181
210
|
} catch (cleanupError) {
|
|
182
|
-
throw new AggregateError([error, cleanupError], "Hasgard launch failed and process cleanup failed"
|
|
211
|
+
throw new AggregateError([error, cleanupError], "Hasgard launch failed and process cleanup failed", {
|
|
212
|
+
cause: cleanupError
|
|
213
|
+
});
|
|
183
214
|
}
|
|
184
215
|
throw error;
|
|
185
216
|
}
|
|
@@ -190,114 +221,120 @@ var HasgardProcess = class {
|
|
|
190
221
|
const child = this.child;
|
|
191
222
|
if (this.applicationExit) {
|
|
192
223
|
const { code, signal } = this.applicationExit;
|
|
193
|
-
throw new Error(
|
|
194
|
-
`Tauri process exited with ${signal ? `signal ${signal}` : `code ${code}`}
|
|
224
|
+
throw new Error(`Tauri process exited with ${signal ? `signal ${signal}` : `code ${code}`}
|
|
195
225
|
stdout:
|
|
196
226
|
${this.stdout}
|
|
197
227
|
stderr:
|
|
198
|
-
${this.stderr}`
|
|
199
|
-
);
|
|
228
|
+
${this.stderr}`);
|
|
200
229
|
}
|
|
201
230
|
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}`}
|
|
231
|
+
throw new Error(`Tauri process exited with ${child.signalCode ? `signal ${child.signalCode}` : `code ${child.exitCode}`}
|
|
204
232
|
stdout:
|
|
205
233
|
${this.stdout}
|
|
206
234
|
stderr:
|
|
207
|
-
${this.stderr}`
|
|
208
|
-
);
|
|
235
|
+
${this.stderr}`);
|
|
209
236
|
}
|
|
210
|
-
if (await endpointAcceptsConnections(socketPath))
|
|
211
|
-
|
|
237
|
+
if (await endpointAcceptsConnections(socketPath))
|
|
238
|
+
return;
|
|
239
|
+
await delay2(50);
|
|
212
240
|
}
|
|
213
241
|
throw new Error(`Hasgard socket did not appear within ${timeoutMs}ms: ${socketPath}`);
|
|
214
242
|
}
|
|
215
243
|
async confirmEndpointOwnership(child, path, timeoutMs) {
|
|
216
244
|
const stat = await lstat(path);
|
|
217
|
-
if (!stat.isSocket())
|
|
245
|
+
if (!stat.isSocket())
|
|
246
|
+
throw new Error(`Hasgard endpoint is not a socket: ${path}`);
|
|
218
247
|
await new Promise((resolve, reject) => {
|
|
219
248
|
const done = (error) => {
|
|
220
249
|
clearTimeout(timer);
|
|
221
250
|
child.off("message", message);
|
|
222
251
|
child.off("exit", exited);
|
|
223
|
-
if (error)
|
|
224
|
-
|
|
252
|
+
if (error)
|
|
253
|
+
reject(error);
|
|
254
|
+
else
|
|
255
|
+
resolve();
|
|
225
256
|
};
|
|
226
257
|
const message = (value) => {
|
|
227
|
-
if (value.type === "endpoint-owned")
|
|
228
|
-
|
|
258
|
+
if (value.type === "endpoint-owned")
|
|
259
|
+
done();
|
|
260
|
+
else if (value.type === "error")
|
|
261
|
+
done(new Error(value.message));
|
|
229
262
|
};
|
|
230
263
|
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
|
-
);
|
|
264
|
+
const timer = setTimeout(() => done(new Error("Hasgard supervisor did not acknowledge endpoint ownership")), timeoutMs);
|
|
235
265
|
child.on("message", message);
|
|
236
266
|
child.once("exit", exited);
|
|
237
267
|
child.send({ type: "endpoint", identity: { dev: stat.dev, ino: stat.ino } }, (error) => {
|
|
238
|
-
if (error)
|
|
268
|
+
if (error)
|
|
269
|
+
done(error);
|
|
239
270
|
});
|
|
240
271
|
});
|
|
241
272
|
}
|
|
242
273
|
async stop() {
|
|
243
|
-
if (this.starting)
|
|
244
|
-
|
|
274
|
+
if (this.starting)
|
|
275
|
+
await this.starting;
|
|
276
|
+
if (this.stopping)
|
|
277
|
+
return this.stopping;
|
|
245
278
|
this.stopping = this.stopChild();
|
|
246
279
|
try {
|
|
247
280
|
await this.stopping;
|
|
248
281
|
} finally {
|
|
249
|
-
this.stopping =
|
|
282
|
+
this.stopping = undefined;
|
|
250
283
|
}
|
|
251
284
|
}
|
|
252
285
|
async stopChild() {
|
|
253
286
|
const child = this.child;
|
|
254
|
-
if (!child)
|
|
287
|
+
if (!child)
|
|
288
|
+
return;
|
|
255
289
|
if (process.platform !== "win32") {
|
|
256
|
-
if (this.applicationPid !==
|
|
290
|
+
if (this.applicationPid !== undefined) {
|
|
257
291
|
await stopProcessGroup(this.applicationPid);
|
|
258
292
|
}
|
|
259
|
-
const
|
|
260
|
-
if (child.connected)
|
|
261
|
-
|
|
293
|
+
const exited = child.exitCode !== null || child.signalCode !== null ? Promise.resolve() : new Promise((resolve) => child.once("exit", () => resolve()));
|
|
294
|
+
if (child.connected)
|
|
295
|
+
child.disconnect();
|
|
296
|
+
const abort = new AbortController;
|
|
262
297
|
try {
|
|
263
|
-
if (await Promise.race([
|
|
298
|
+
if (await Promise.race([exited, delay2(11000, "timeout", { signal: abort.signal })]) === "timeout") {
|
|
264
299
|
throw new Error(`Hasgard process supervisor ${child.pid} did not finish cleanup`);
|
|
265
300
|
}
|
|
266
301
|
} finally {
|
|
267
|
-
|
|
302
|
+
abort.abort();
|
|
268
303
|
}
|
|
269
|
-
this.child =
|
|
270
|
-
this.applicationPid =
|
|
304
|
+
this.child = undefined;
|
|
305
|
+
this.applicationPid = undefined;
|
|
271
306
|
if (child.exitCode !== 0 || child.signalCode !== null) {
|
|
272
307
|
throw new Error(`Hasgard supervisor cleanup failed (${child.signalCode ?? child.exitCode}):
|
|
273
308
|
${this.stderr}`);
|
|
274
309
|
}
|
|
275
310
|
return;
|
|
276
311
|
}
|
|
277
|
-
if (child.pid ===
|
|
278
|
-
this.child =
|
|
312
|
+
if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) {
|
|
313
|
+
this.child = undefined;
|
|
279
314
|
return;
|
|
280
315
|
}
|
|
281
316
|
const exited = new Promise((resolve) => child.once("exit", () => resolve()));
|
|
282
|
-
if (process.platform === "win32" && child.pid !==
|
|
317
|
+
if (process.platform === "win32" && child.pid !== undefined) {
|
|
283
318
|
await terminateWindowsProcessTree(child.pid);
|
|
284
|
-
} else if (child.pid !==
|
|
319
|
+
} else if (child.pid !== undefined) {
|
|
285
320
|
process.kill(-child.pid, "SIGTERM");
|
|
286
321
|
}
|
|
287
|
-
const abort = new AbortController
|
|
288
|
-
const timeout =
|
|
322
|
+
const abort = new AbortController;
|
|
323
|
+
const timeout = delay2(5000, "timeout", { signal: abort.signal });
|
|
289
324
|
try {
|
|
290
325
|
if (await Promise.race([exited, timeout]) === "timeout") {
|
|
291
|
-
if (process.platform === "win32" && child.pid !==
|
|
292
|
-
|
|
326
|
+
if (process.platform === "win32" && child.pid !== undefined)
|
|
327
|
+
await terminateWindowsProcessTree(child.pid);
|
|
328
|
+
else if (child.pid !== undefined)
|
|
329
|
+
process.kill(-child.pid, "SIGKILL");
|
|
293
330
|
await exited;
|
|
294
331
|
}
|
|
295
332
|
} finally {
|
|
296
333
|
abort.abort();
|
|
297
334
|
}
|
|
298
|
-
this.child =
|
|
335
|
+
this.child = undefined;
|
|
299
336
|
}
|
|
300
|
-
}
|
|
337
|
+
}
|
|
301
338
|
function appendOutput(previous, chunk) {
|
|
302
339
|
const output = previous + chunk;
|
|
303
340
|
return output.length > 65536 ? `[earlier output truncated]
|
|
@@ -306,9 +343,11 @@ ${output.slice(-65536)}` : output;
|
|
|
306
343
|
async function endpointAcceptsConnections(path) {
|
|
307
344
|
if (process.platform !== "win32") {
|
|
308
345
|
try {
|
|
309
|
-
if (!(await lstat(path)).isSocket())
|
|
346
|
+
if (!(await lstat(path)).isSocket())
|
|
347
|
+
throw new Error(`Hasgard endpoint exists but is not a socket: ${path}`);
|
|
310
348
|
} catch (error) {
|
|
311
|
-
if (error.code === "ENOENT")
|
|
349
|
+
if (error.code === "ENOENT")
|
|
350
|
+
return false;
|
|
312
351
|
throw error;
|
|
313
352
|
}
|
|
314
353
|
}
|
|
@@ -337,8 +376,11 @@ async function terminateWindowsProcessTree(pid) {
|
|
|
337
376
|
}
|
|
338
377
|
|
|
339
378
|
// src/rpc-client.ts
|
|
340
|
-
import { createConnection as createConnection2 } from "net";
|
|
341
|
-
import { createInterface } from "readline";
|
|
379
|
+
import { createConnection as createConnection2 } from "node:net";
|
|
380
|
+
import { createInterface } from "node:readline";
|
|
381
|
+
|
|
382
|
+
// src/protocol-limits.ts
|
|
383
|
+
var MAX_REQUEST_BYTES = 1048576;
|
|
342
384
|
|
|
343
385
|
// src/protocol.ts
|
|
344
386
|
function isObject(value) {
|
|
@@ -347,12 +389,16 @@ function isObject(value) {
|
|
|
347
389
|
function parseRpcResponse(line) {
|
|
348
390
|
const value = JSON.parse(line);
|
|
349
391
|
const invalid = () => new Error(`Invalid Hasgard JSON-RPC envelope: ${line}`);
|
|
350
|
-
if (!isObject(value) || value.jsonrpc !== "2.0" || !Object.hasOwn(value, "id"))
|
|
351
|
-
|
|
392
|
+
if (!isObject(value) || value.jsonrpc !== "2.0" || !Object.hasOwn(value, "id"))
|
|
393
|
+
throw invalid();
|
|
394
|
+
if (value.id !== null && (!Number.isSafeInteger(value.id) || value.id < 0))
|
|
395
|
+
throw invalid();
|
|
352
396
|
const hasResult = Object.hasOwn(value, "result");
|
|
353
397
|
const hasError = Object.hasOwn(value, "error");
|
|
354
|
-
if (hasResult === hasError)
|
|
355
|
-
|
|
398
|
+
if (hasResult === hasError)
|
|
399
|
+
throw invalid();
|
|
400
|
+
if (hasResult && value.id === null)
|
|
401
|
+
throw invalid();
|
|
356
402
|
if (hasError) {
|
|
357
403
|
const error = value.error;
|
|
358
404
|
if (!isObject(error) || !Number.isInteger(error.code) || error.code < -2147483648 || error.code > 2147483647 || typeof error.message !== "string")
|
|
@@ -361,11 +407,8 @@ function parseRpcResponse(line) {
|
|
|
361
407
|
return value;
|
|
362
408
|
}
|
|
363
409
|
|
|
364
|
-
// src/protocol-limits.ts
|
|
365
|
-
var MAX_REQUEST_BYTES = 1048576;
|
|
366
|
-
|
|
367
410
|
// src/rpc-client.ts
|
|
368
|
-
|
|
411
|
+
class HasgardRpcError extends Error {
|
|
369
412
|
code;
|
|
370
413
|
data;
|
|
371
414
|
constructor(code, message, data) {
|
|
@@ -374,18 +417,20 @@ var HasgardRpcError = class extends Error {
|
|
|
374
417
|
this.code = code;
|
|
375
418
|
this.data = data;
|
|
376
419
|
}
|
|
377
|
-
}
|
|
378
|
-
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
class HasgardRpcClient {
|
|
379
423
|
socketPath;
|
|
380
424
|
socket;
|
|
381
425
|
lines;
|
|
382
426
|
nextId = 1;
|
|
383
|
-
pending =
|
|
427
|
+
pending = new Map;
|
|
384
428
|
constructor(socketPath) {
|
|
385
429
|
this.socketPath = socketPath;
|
|
386
430
|
}
|
|
387
431
|
async connect(timeoutMs) {
|
|
388
|
-
if (this.socket)
|
|
432
|
+
if (this.socket)
|
|
433
|
+
throw new Error("Hasgard RPC client is already connected");
|
|
389
434
|
const socket = createConnection2({ path: this.socketPath });
|
|
390
435
|
this.socket = socket;
|
|
391
436
|
try {
|
|
@@ -414,24 +459,28 @@ var HasgardRpcClient = class {
|
|
|
414
459
|
socket.once("close", closed);
|
|
415
460
|
});
|
|
416
461
|
} catch (error) {
|
|
417
|
-
if (this.socket === socket)
|
|
462
|
+
if (this.socket === socket)
|
|
463
|
+
this.socket = undefined;
|
|
418
464
|
socket.destroy();
|
|
419
465
|
throw error;
|
|
420
466
|
}
|
|
421
467
|
this.lines = createInterface({ input: socket });
|
|
422
468
|
this.lines.on("line", (line) => this.handleLine(line));
|
|
423
469
|
socket.on("close", () => {
|
|
424
|
-
if (this.socket !== socket)
|
|
470
|
+
if (this.socket !== socket)
|
|
471
|
+
return;
|
|
425
472
|
this.rejectPending(new Error("Hasgard socket closed"));
|
|
426
473
|
this.disconnect();
|
|
427
474
|
});
|
|
428
475
|
socket.on("error", (error) => {
|
|
429
|
-
if (this.socket === socket)
|
|
476
|
+
if (this.socket === socket)
|
|
477
|
+
this.rejectPending(error);
|
|
430
478
|
});
|
|
431
479
|
}
|
|
432
480
|
async call(method, params) {
|
|
433
481
|
const socket = this.socket;
|
|
434
|
-
if (!socket || socket.destroyed || !socket.writable)
|
|
482
|
+
if (!socket || socket.destroyed || !socket.writable)
|
|
483
|
+
throw new Error("Hasgard RPC client is not connected");
|
|
435
484
|
const id = this.nextId;
|
|
436
485
|
this.nextId += 1;
|
|
437
486
|
const request = params ? { jsonrpc: "2.0", id, method, params } : { jsonrpc: "2.0", id, method };
|
|
@@ -448,9 +497,11 @@ var HasgardRpcClient = class {
|
|
|
448
497
|
});
|
|
449
498
|
});
|
|
450
499
|
socket.write(line, (error) => {
|
|
451
|
-
if (!error)
|
|
500
|
+
if (!error)
|
|
501
|
+
return;
|
|
452
502
|
const pending = this.pending.get(id);
|
|
453
|
-
if (!pending)
|
|
503
|
+
if (!pending)
|
|
504
|
+
return;
|
|
454
505
|
this.pending.delete(id);
|
|
455
506
|
pending.reject(error);
|
|
456
507
|
});
|
|
@@ -458,9 +509,9 @@ var HasgardRpcClient = class {
|
|
|
458
509
|
}
|
|
459
510
|
disconnect() {
|
|
460
511
|
this.lines?.close();
|
|
461
|
-
this.lines =
|
|
512
|
+
this.lines = undefined;
|
|
462
513
|
this.socket?.destroy();
|
|
463
|
-
this.socket =
|
|
514
|
+
this.socket = undefined;
|
|
464
515
|
this.rejectPending(new Error("Hasgard RPC client disconnected"));
|
|
465
516
|
}
|
|
466
517
|
handleLine(line) {
|
|
@@ -475,7 +526,7 @@ var HasgardRpcClient = class {
|
|
|
475
526
|
this.rejectPending(new HasgardRpcError(response.error.code, response.error.message, response.error.data));
|
|
476
527
|
return;
|
|
477
528
|
}
|
|
478
|
-
const pending = response.id === null ?
|
|
529
|
+
const pending = response.id === null ? undefined : this.pending.get(response.id);
|
|
479
530
|
if (!pending) {
|
|
480
531
|
this.rejectPending(new Error(`Unexpected Hasgard JSON-RPC response id: ${response.id}`));
|
|
481
532
|
return;
|
|
@@ -488,19 +539,20 @@ var HasgardRpcClient = class {
|
|
|
488
539
|
pending.resolve(response.result);
|
|
489
540
|
}
|
|
490
541
|
rejectPending(error) {
|
|
491
|
-
for (const pending of this.pending.values())
|
|
542
|
+
for (const pending of this.pending.values())
|
|
543
|
+
pending.reject(error);
|
|
492
544
|
this.pending.clear();
|
|
493
545
|
}
|
|
494
|
-
}
|
|
546
|
+
}
|
|
495
547
|
|
|
496
548
|
// src/window.ts
|
|
497
|
-
import { readFile } from "fs/promises";
|
|
498
|
-
import { basename } from "path";
|
|
499
|
-
var locks =
|
|
549
|
+
import { readFile } from "node:fs/promises";
|
|
550
|
+
import { basename } from "node:path";
|
|
551
|
+
var locks = new WeakMap;
|
|
500
552
|
function lockFor(rpc, label) {
|
|
501
553
|
let clientLocks = locks.get(rpc);
|
|
502
554
|
if (!clientLocks) {
|
|
503
|
-
clientLocks =
|
|
555
|
+
clientLocks = new Map;
|
|
504
556
|
locks.set(rpc, clientLocks);
|
|
505
557
|
}
|
|
506
558
|
let lock = clientLocks.get(label);
|
|
@@ -518,36 +570,44 @@ function withWindow(window, params) {
|
|
|
518
570
|
}
|
|
519
571
|
async function readFileInputs(files) {
|
|
520
572
|
const list = Array.isArray(files) ? files : [files];
|
|
521
|
-
return Promise.all(
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
);
|
|
573
|
+
return Promise.all(list.map(async (entry) => {
|
|
574
|
+
if (typeof entry !== "string")
|
|
575
|
+
return entry;
|
|
576
|
+
const contents = await readFile(entry);
|
|
577
|
+
return { name: basename(entry), data: contents.toString("base64") };
|
|
578
|
+
}));
|
|
528
579
|
}
|
|
529
580
|
function targetParams(target) {
|
|
530
|
-
if ("ref" in target)
|
|
581
|
+
if ("ref" in target)
|
|
582
|
+
return { ref: target.ref };
|
|
531
583
|
if ("selector" in target) {
|
|
532
|
-
return target.index ===
|
|
584
|
+
return target.index === undefined ? { selector: target.selector } : { selector: target.selector, index: target.index };
|
|
533
585
|
}
|
|
534
586
|
return { x: target.x, y: target.y };
|
|
535
587
|
}
|
|
536
588
|
function clickParams(options) {
|
|
537
589
|
const params = {};
|
|
538
|
-
if (options.modifiers !==
|
|
539
|
-
|
|
540
|
-
if (options.
|
|
541
|
-
|
|
590
|
+
if (options.modifiers !== undefined)
|
|
591
|
+
params.modifiers = options.modifiers;
|
|
592
|
+
if (options.button !== undefined)
|
|
593
|
+
params.button = options.button;
|
|
594
|
+
if (options.clickCount !== undefined)
|
|
595
|
+
params.clickCount = options.clickCount;
|
|
596
|
+
if (options.position !== undefined)
|
|
597
|
+
params.position = { ...options.position };
|
|
542
598
|
return params;
|
|
543
599
|
}
|
|
544
600
|
function routeParams(options) {
|
|
545
601
|
const params = { pattern: options.pattern };
|
|
546
|
-
if (options.method !==
|
|
547
|
-
|
|
548
|
-
if (
|
|
549
|
-
|
|
550
|
-
if ("
|
|
602
|
+
if (options.method !== undefined)
|
|
603
|
+
params.method = options.method;
|
|
604
|
+
if (options.times !== undefined)
|
|
605
|
+
params.times = options.times;
|
|
606
|
+
if ("status" in options && options.status !== undefined)
|
|
607
|
+
params.status = options.status;
|
|
608
|
+
if ("body" in options && options.body !== undefined)
|
|
609
|
+
params.body = options.body;
|
|
610
|
+
if ("contentType" in options && options.contentType !== undefined) {
|
|
551
611
|
params.contentType = options.contentType;
|
|
552
612
|
}
|
|
553
613
|
return params;
|
|
@@ -595,27 +655,32 @@ function expectRecord(value, context) {
|
|
|
595
655
|
return value;
|
|
596
656
|
}
|
|
597
657
|
function expectString(value, context) {
|
|
598
|
-
if (typeof value !== "string")
|
|
658
|
+
if (typeof value !== "string")
|
|
659
|
+
throw new Error(`${context} must be a string`);
|
|
599
660
|
return value;
|
|
600
661
|
}
|
|
601
662
|
function expectBoolean(value, context) {
|
|
602
|
-
if (typeof value !== "boolean")
|
|
663
|
+
if (typeof value !== "boolean")
|
|
664
|
+
throw new Error(`${context} must be a boolean`);
|
|
603
665
|
return value;
|
|
604
666
|
}
|
|
605
667
|
function expectNumber(value, context) {
|
|
606
|
-
if (typeof value !== "number")
|
|
668
|
+
if (typeof value !== "number")
|
|
669
|
+
throw new Error(`${context} must be a number`);
|
|
607
670
|
return value;
|
|
608
671
|
}
|
|
609
672
|
function expectArray(value, context) {
|
|
610
|
-
if (!Array.isArray(value))
|
|
673
|
+
if (!Array.isArray(value))
|
|
674
|
+
throw new Error(`${context} must be an array`);
|
|
611
675
|
return value;
|
|
612
676
|
}
|
|
613
677
|
function expectNullableString(value, context) {
|
|
614
|
-
if (value === null)
|
|
678
|
+
if (value === null)
|
|
679
|
+
return null;
|
|
615
680
|
return expectString(value, context);
|
|
616
681
|
}
|
|
617
682
|
function optionalString(value, context) {
|
|
618
|
-
return value ===
|
|
683
|
+
return value === undefined ? undefined : expectString(value, context);
|
|
619
684
|
}
|
|
620
685
|
function parseConsoleLogEntry(value, index) {
|
|
621
686
|
const entry = expectRecord(value, `console.getLogs[${index}]`);
|
|
@@ -650,7 +715,8 @@ function parseFormField(value, context) {
|
|
|
650
715
|
name: expectString(field.name, `${context}.name`),
|
|
651
716
|
value: Array.isArray(raw) ? raw.map((option, index) => expectString(option, `${context}.value[${index}]`)) : expectString(raw, `${context}.value`)
|
|
652
717
|
};
|
|
653
|
-
if (field.checked !==
|
|
718
|
+
if (field.checked !== undefined)
|
|
719
|
+
parsed.checked = expectBoolean(field.checked, `${context}.checked`);
|
|
654
720
|
return parsed;
|
|
655
721
|
}
|
|
656
722
|
function parseFormEntry(value, index) {
|
|
@@ -661,11 +727,9 @@ function parseFormEntry(value, index) {
|
|
|
661
727
|
name: expectString(form.name, `${context}.name`),
|
|
662
728
|
action: expectString(form.action, `${context}.action`),
|
|
663
729
|
method: expectString(form.method, `${context}.method`),
|
|
664
|
-
fields: expectArray(form.fields, `${context}.fields`).map(
|
|
665
|
-
(field, fieldIndex) => parseFormField(field, `${context}.fields[${fieldIndex}]`)
|
|
666
|
-
)
|
|
730
|
+
fields: expectArray(form.fields, `${context}.fields`).map((field, fieldIndex) => parseFormField(field, `${context}.fields[${fieldIndex}]`))
|
|
667
731
|
};
|
|
668
|
-
if (form.fieldsTruncated !==
|
|
732
|
+
if (form.fieldsTruncated !== undefined) {
|
|
669
733
|
parsed.fieldsTruncated = expectBoolean(form.fieldsTruncated, `${context}.fieldsTruncated`);
|
|
670
734
|
}
|
|
671
735
|
return parsed;
|
|
@@ -674,15 +738,20 @@ function parseWatchNode(value, context) {
|
|
|
674
738
|
const node = expectRecord(value, context);
|
|
675
739
|
const parsed = { tag: expectString(node.tag, `${context}.tag`) };
|
|
676
740
|
const id = optionalString(node.id, `${context}.id`);
|
|
677
|
-
if (id !==
|
|
741
|
+
if (id !== undefined)
|
|
742
|
+
parsed.id = id;
|
|
678
743
|
const className = optionalString(node.class, `${context}.class`);
|
|
679
|
-
if (className !==
|
|
744
|
+
if (className !== undefined)
|
|
745
|
+
parsed.class = className;
|
|
680
746
|
const text = optionalString(node.text, `${context}.text`);
|
|
681
|
-
if (text !==
|
|
747
|
+
if (text !== undefined)
|
|
748
|
+
parsed.text = text;
|
|
682
749
|
const attr = optionalString(node.attr, `${context}.attr`);
|
|
683
|
-
if (attr !==
|
|
750
|
+
if (attr !== undefined)
|
|
751
|
+
parsed.attr = attr;
|
|
684
752
|
const attrValue = optionalString(node.value, `${context}.value`);
|
|
685
|
-
if (attrValue !==
|
|
753
|
+
if (attrValue !== undefined)
|
|
754
|
+
parsed.value = attrValue;
|
|
686
755
|
return parsed;
|
|
687
756
|
}
|
|
688
757
|
function parseWatchNodes(value, context) {
|
|
@@ -693,9 +762,7 @@ function parseDiffChange(value, index) {
|
|
|
693
762
|
return {
|
|
694
763
|
old: parseSnapshotElement(change.old, index),
|
|
695
764
|
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
|
-
)
|
|
765
|
+
changes: expectArray(change.changes, `diff.changed[${index}].changes`).map((entry, entryIndex) => expectString(entry, `diff.changed[${index}].changes[${entryIndex}]`))
|
|
699
766
|
};
|
|
700
767
|
}
|
|
701
768
|
function parseRecorderEntry(value, index) {
|
|
@@ -706,7 +773,8 @@ function parseRecorderEntry(value, index) {
|
|
|
706
773
|
timestamp: expectNumber(entry.timestamp, `record.stop.entries[${index}].timestamp`)
|
|
707
774
|
};
|
|
708
775
|
}
|
|
709
|
-
|
|
776
|
+
|
|
777
|
+
class HasgardApplication {
|
|
710
778
|
rpc;
|
|
711
779
|
constructor(rpc) {
|
|
712
780
|
this.rpc = rpc;
|
|
@@ -720,7 +788,8 @@ var HasgardApplication = class {
|
|
|
720
788
|
}
|
|
721
789
|
async windows() {
|
|
722
790
|
const value = expectRecord(await this.rpc.call("windows.list"), "windows.list result");
|
|
723
|
-
if (!Array.isArray(value.windows))
|
|
791
|
+
if (!Array.isArray(value.windows))
|
|
792
|
+
throw new Error("windows.list.windows must be an array");
|
|
724
793
|
return value.windows.map((entry, index) => {
|
|
725
794
|
const window = expectRecord(entry, `windows[${index}]`);
|
|
726
795
|
const info = {
|
|
@@ -728,7 +797,7 @@ var HasgardApplication = class {
|
|
|
728
797
|
url: expectString(window.url, `windows[${index}].url`),
|
|
729
798
|
title: expectString(window.title, `windows[${index}].title`)
|
|
730
799
|
};
|
|
731
|
-
if (window.native_id !==
|
|
800
|
+
if (window.native_id !== undefined) {
|
|
732
801
|
info.nativeId = expectNumber(window.native_id, `windows[${index}].native_id`);
|
|
733
802
|
}
|
|
734
803
|
return info;
|
|
@@ -741,9 +810,11 @@ var HasgardApplication = class {
|
|
|
741
810
|
const window = this.window(label);
|
|
742
811
|
try {
|
|
743
812
|
const state = await window.state();
|
|
744
|
-
if (state.url !== "about:blank" && state.readyState !== "loading")
|
|
813
|
+
if (state.url !== "about:blank" && state.readyState !== "loading")
|
|
814
|
+
return window;
|
|
745
815
|
} catch (error) {
|
|
746
|
-
if (!(error instanceof HasgardRpcError) || error.code !== -32002)
|
|
816
|
+
if (!(error instanceof HasgardRpcError) || error.code !== -32002)
|
|
817
|
+
throw error;
|
|
747
818
|
}
|
|
748
819
|
}
|
|
749
820
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
@@ -754,7 +825,8 @@ var HasgardApplication = class {
|
|
|
754
825
|
const window = await this.waitForWindow(label, timeoutMs);
|
|
755
826
|
const deadline = Date.now() + timeoutMs;
|
|
756
827
|
while (Date.now() <= deadline) {
|
|
757
|
-
if (await window.locator(readySelector).count() === 1)
|
|
828
|
+
if (await window.locator(readySelector).count() === 1)
|
|
829
|
+
return window;
|
|
758
830
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
759
831
|
}
|
|
760
832
|
throw new Error(`Window '${label}' did not match ready selector '${readySelector}' within ${timeoutMs}ms`);
|
|
@@ -762,10 +834,6 @@ var HasgardApplication = class {
|
|
|
762
834
|
window(label) {
|
|
763
835
|
return new HasgardWindow(this.rpc, label);
|
|
764
836
|
}
|
|
765
|
-
/**
|
|
766
|
-
* Action recorder. Captures the RPC calls this connection makes into a
|
|
767
|
-
* replayable scenario — this is not video capture.
|
|
768
|
-
*/
|
|
769
837
|
recorder = {
|
|
770
838
|
start: async () => {
|
|
771
839
|
await this.rpc.call("record.start");
|
|
@@ -789,8 +857,9 @@ var HasgardApplication = class {
|
|
|
789
857
|
await this.rpc.call("record.add", entry);
|
|
790
858
|
}
|
|
791
859
|
};
|
|
792
|
-
}
|
|
793
|
-
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
class HasgardWindow {
|
|
794
863
|
label;
|
|
795
864
|
rpc;
|
|
796
865
|
constructor(rpc, label) {
|
|
@@ -803,12 +872,6 @@ var HasgardWindow = class {
|
|
|
803
872
|
getByRole(role, query = {}) {
|
|
804
873
|
return new HasgardLocator(this, { kind: "role", role, query });
|
|
805
874
|
}
|
|
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
875
|
getByText(text, query = {}) {
|
|
813
876
|
return new HasgardLocator(this, { kind: "query", by: "text", value: text, query });
|
|
814
877
|
}
|
|
@@ -824,27 +887,16 @@ var HasgardWindow = class {
|
|
|
824
887
|
getByTitle(text, query = {}) {
|
|
825
888
|
return new HasgardLocator(this, { kind: "query", by: "title", value: text, query });
|
|
826
889
|
}
|
|
827
|
-
/** Test ids always match exactly, so `save` cannot select `save-draft`. */
|
|
828
890
|
getByTestId(testId) {
|
|
829
891
|
return new HasgardLocator(this, { kind: "query", by: "testid", value: testId, query: {} });
|
|
830
892
|
}
|
|
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
893
|
frameLocator(selector) {
|
|
840
894
|
return new HasgardFrameLocator(this, [selector]);
|
|
841
895
|
}
|
|
842
896
|
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");
|
|
897
|
+
const raw = expectRecord(await this.rpc.call("snapshot", withWindow(this.label, options)), "snapshot result");
|
|
898
|
+
if (!Array.isArray(raw.elements))
|
|
899
|
+
throw new Error("snapshot.elements must be an array");
|
|
848
900
|
return {
|
|
849
901
|
elements: raw.elements.map((entry, index) => parseSnapshotElement(entry, index))
|
|
850
902
|
};
|
|
@@ -874,31 +926,29 @@ var HasgardWindow = class {
|
|
|
874
926
|
async title() {
|
|
875
927
|
return expectString(await this.rpc.call("title", withWindow(this.label)), "title result");
|
|
876
928
|
}
|
|
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
929
|
async navigate(url) {
|
|
883
930
|
await this.rpc.call("navigate", withWindow(this.label, { url }));
|
|
884
931
|
}
|
|
885
|
-
/** Scroll the document by a relative amount, or to `top`/`bottom`. */
|
|
886
932
|
async scrollBy(options) {
|
|
887
933
|
await this.rpc.call("scroll", withWindow(this.label, options));
|
|
888
934
|
}
|
|
889
935
|
async press(key, options) {
|
|
890
936
|
const params = { key };
|
|
891
937
|
if (options) {
|
|
892
|
-
if (options.completion !==
|
|
893
|
-
|
|
894
|
-
if (options.
|
|
938
|
+
if (options.completion !== undefined)
|
|
939
|
+
params.completion = options.completion;
|
|
940
|
+
if (options.waitFor !== undefined)
|
|
941
|
+
params.waitFor = options.waitFor;
|
|
942
|
+
if (options.timeoutMs !== undefined)
|
|
943
|
+
params.timeout = options.timeoutMs;
|
|
895
944
|
}
|
|
896
945
|
await this.rpc.call("press", withWindow(this.label, params));
|
|
897
946
|
}
|
|
898
947
|
async diff(options = {}) {
|
|
899
948
|
const { reference, ...snapshotOptions } = options;
|
|
900
949
|
const params = { ...snapshotOptions };
|
|
901
|
-
if (reference)
|
|
950
|
+
if (reference)
|
|
951
|
+
params.reference = reference;
|
|
902
952
|
const value = expectRecord(await this.rpc.call("diff", withWindow(this.label, params)), "diff result");
|
|
903
953
|
return {
|
|
904
954
|
added: expectArray(value.added, "diff.added").map(parseSnapshotElement),
|
|
@@ -906,26 +956,25 @@ var HasgardWindow = class {
|
|
|
906
956
|
changed: expectArray(value.changed, "diff.changed").map(parseDiffChange)
|
|
907
957
|
};
|
|
908
958
|
}
|
|
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
959
|
async waitForFunction(expression, options = {}) {
|
|
916
960
|
const params = { expression };
|
|
917
|
-
if (options.timeoutMs !==
|
|
918
|
-
|
|
961
|
+
if (options.timeoutMs !== undefined)
|
|
962
|
+
params.timeout = options.timeoutMs;
|
|
963
|
+
if (options.pollMs !== undefined)
|
|
964
|
+
params.poll = options.pollMs;
|
|
919
965
|
const value = expectRecord(await this.rpc.call("wait", withWindow(this.label, params)), "wait result");
|
|
920
966
|
return value.value;
|
|
921
967
|
}
|
|
922
|
-
/** Observe DOM mutations until they settle, or reject when nothing changes. */
|
|
923
968
|
async watch(options = {}) {
|
|
924
969
|
const params = {};
|
|
925
|
-
if (options.selector !==
|
|
926
|
-
|
|
927
|
-
if (options.
|
|
928
|
-
|
|
970
|
+
if (options.selector !== undefined)
|
|
971
|
+
params.selector = options.selector;
|
|
972
|
+
if (options.timeoutMs !== undefined)
|
|
973
|
+
params.timeout = options.timeoutMs;
|
|
974
|
+
if (options.stableMs !== undefined)
|
|
975
|
+
params.stable = options.stableMs;
|
|
976
|
+
if (options.requireMutation !== undefined)
|
|
977
|
+
params.requireMutation = options.requireMutation;
|
|
929
978
|
const value = expectRecord(await this.rpc.call("watch", withWindow(this.label, params)), "watch result");
|
|
930
979
|
return {
|
|
931
980
|
added: parseWatchNodes(value.added, "watch.added"),
|
|
@@ -935,43 +984,31 @@ var HasgardWindow = class {
|
|
|
935
984
|
};
|
|
936
985
|
}
|
|
937
986
|
async consoleLogs(options = {}) {
|
|
938
|
-
const value = await this.rpc.call(
|
|
939
|
-
"console.getLogs",
|
|
940
|
-
withWindow(this.label, options)
|
|
941
|
-
);
|
|
987
|
+
const value = await this.rpc.call("console.getLogs", withWindow(this.label, options));
|
|
942
988
|
return expectArray(value, "console.getLogs result").map(parseConsoleLogEntry);
|
|
943
989
|
}
|
|
944
990
|
async clearConsole() {
|
|
945
991
|
await this.rpc.call("console.clear", withWindow(this.label));
|
|
946
992
|
}
|
|
947
993
|
async networkRequests(options = {}) {
|
|
948
|
-
const value = await this.rpc.call(
|
|
949
|
-
"network.getRequests",
|
|
950
|
-
withWindow(this.label, options)
|
|
951
|
-
);
|
|
994
|
+
const value = await this.rpc.call("network.getRequests", withWindow(this.label, options));
|
|
952
995
|
return expectArray(value, "network.getRequests result").map(parseNetworkRequestEntry);
|
|
953
996
|
}
|
|
954
997
|
async clearNetwork() {
|
|
955
998
|
await this.rpc.call("network.clear", withWindow(this.label));
|
|
956
999
|
}
|
|
957
|
-
/** `localStorage` by default; pass `{ session: true }` for `sessionStorage`. */
|
|
958
1000
|
storage = {
|
|
959
1001
|
get: async (key, options = {}) => {
|
|
960
|
-
const value = expectRecord(
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
);
|
|
964
|
-
if (!expectBoolean(value.found, "storage.get.found")) return null;
|
|
1002
|
+
const value = expectRecord(await this.rpc.call("storage.get", withWindow(this.label, { key, ...options })), "storage.get result");
|
|
1003
|
+
if (!expectBoolean(value.found, "storage.get.found"))
|
|
1004
|
+
return null;
|
|
965
1005
|
return expectString(value.value, "storage.get.value");
|
|
966
1006
|
},
|
|
967
1007
|
set: async (key, value, options = {}) => {
|
|
968
1008
|
await this.rpc.call("storage.set", withWindow(this.label, { key, value, ...options }));
|
|
969
1009
|
},
|
|
970
1010
|
list: async (options = {}) => {
|
|
971
|
-
const value = expectRecord(
|
|
972
|
-
await this.rpc.call("storage.list", withWindow(this.label, { ...options })),
|
|
973
|
-
"storage.list result"
|
|
974
|
-
);
|
|
1011
|
+
const value = expectRecord(await this.rpc.call("storage.list", withWindow(this.label, { ...options })), "storage.list result");
|
|
975
1012
|
return {
|
|
976
1013
|
entries: expectArray(value.entries, "storage.list.entries").map((entry, index) => {
|
|
977
1014
|
const parsed = expectRecord(entry, `storage.list.entries[${index}]`);
|
|
@@ -988,7 +1025,7 @@ var HasgardWindow = class {
|
|
|
988
1025
|
}
|
|
989
1026
|
};
|
|
990
1027
|
async forms(selector) {
|
|
991
|
-
const params = selector ? { selector } :
|
|
1028
|
+
const params = selector ? { selector } : undefined;
|
|
992
1029
|
const value = expectRecord(await this.rpc.call("forms.dump", withWindow(this.label, params)), "forms.dump result");
|
|
993
1030
|
return {
|
|
994
1031
|
forms: expectArray(value.forms, "forms.dump.forms").map(parseFormEntry),
|
|
@@ -1002,22 +1039,21 @@ var HasgardWindow = class {
|
|
|
1002
1039
|
return this.rpc.call("ipc", withWindow(this.label, { command, args }));
|
|
1003
1040
|
}
|
|
1004
1041
|
async screenshot(selector) {
|
|
1005
|
-
const params = selector ? { selector } :
|
|
1042
|
+
const params = selector ? { selector } : undefined;
|
|
1006
1043
|
const dataUrl = await this.rpc.call("screenshot", withWindow(this.label, params));
|
|
1007
1044
|
const prefix = "data:image/png;base64,";
|
|
1008
|
-
if (!dataUrl.startsWith(prefix))
|
|
1045
|
+
if (!dataUrl.startsWith(prefix))
|
|
1046
|
+
throw new Error("screenshot result is not a PNG data URL");
|
|
1009
1047
|
return Buffer.from(dataUrl.slice(prefix.length), "base64");
|
|
1010
1048
|
}
|
|
1011
|
-
/** Start a bounded recording of this native window. macOS only; requires ffmpeg. */
|
|
1012
1049
|
async startVideo(options) {
|
|
1013
1050
|
await this.rpc.call("video.start", {
|
|
1014
1051
|
window: this.label,
|
|
1015
1052
|
output_path: options.outputPath,
|
|
1016
|
-
...options.fps ===
|
|
1017
|
-
...options.maxDurationMs ===
|
|
1053
|
+
...options.fps === undefined ? {} : { fps: options.fps },
|
|
1054
|
+
...options.maxDurationMs === undefined ? {} : { max_duration_ms: options.maxDurationMs }
|
|
1018
1055
|
});
|
|
1019
1056
|
}
|
|
1020
|
-
/** Stop recording; capture and encoder failures reject rather than returning an empty artifact. */
|
|
1021
1057
|
async stopVideo() {
|
|
1022
1058
|
const value = expectRecord(await this.rpc.call("video.stop", { window: this.label }), "video.stop result");
|
|
1023
1059
|
return {
|
|
@@ -1034,26 +1070,12 @@ var HasgardWindow = class {
|
|
|
1034
1070
|
pendingResult: expectBoolean(value.pending_result, "video.pending_result")
|
|
1035
1071
|
};
|
|
1036
1072
|
}
|
|
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
1073
|
async screenshotNative(options) {
|
|
1049
1074
|
const windowId = options.windowId ?? await this.nativeId();
|
|
1050
|
-
const value = expectRecord(
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
}),
|
|
1055
|
-
"screenshot_native result"
|
|
1056
|
-
);
|
|
1075
|
+
const value = expectRecord(await this.rpc.call("screenshot_native", {
|
|
1076
|
+
window_id: windowId,
|
|
1077
|
+
output_path: options.outputPath
|
|
1078
|
+
}), "screenshot_native result");
|
|
1057
1079
|
return {
|
|
1058
1080
|
output_path: expectString(value.output_path, "screenshot_native.output_path"),
|
|
1059
1081
|
window_id: expectNumber(value.window_id, "screenshot_native.window_id"),
|
|
@@ -1065,102 +1087,58 @@ var HasgardWindow = class {
|
|
|
1065
1087
|
tcc_denied: expectBoolean(value.tcc_denied, "screenshot_native.tcc_denied")
|
|
1066
1088
|
};
|
|
1067
1089
|
}
|
|
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
1090
|
async wheel(deltaX, deltaY) {
|
|
1077
1091
|
const value = expectRecord(await this.call("wheel", { deltaX, deltaY }), "wheel result");
|
|
1078
1092
|
return expectBoolean(value.defaultPrevented, "wheel.defaultPrevented");
|
|
1079
1093
|
}
|
|
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
1094
|
dialogs = {
|
|
1089
|
-
/** Answer every subsequent dialog with OK. `promptText` fills a `prompt`. */
|
|
1090
1095
|
accept: async (promptText) => {
|
|
1091
1096
|
const params = { action: "accept" };
|
|
1092
|
-
if (promptText !==
|
|
1097
|
+
if (promptText !== undefined)
|
|
1098
|
+
params.promptText = promptText;
|
|
1093
1099
|
return parseDialogPolicy(await this.call("dialog.handle", params));
|
|
1094
1100
|
},
|
|
1095
|
-
/** Answer every subsequent dialog with Cancel. This is the default. */
|
|
1096
1101
|
dismiss: async () => parseDialogPolicy(await this.call("dialog.handle", { action: "dismiss" })),
|
|
1097
|
-
/** Every dialog the page has opened so far, oldest first. */
|
|
1098
1102
|
list: async () => {
|
|
1099
1103
|
const value = expectRecord(await this.call("dialog.list"), "dialog.list result");
|
|
1100
|
-
if (!Array.isArray(value.dialogs))
|
|
1104
|
+
if (!Array.isArray(value.dialogs))
|
|
1105
|
+
throw new Error("dialog.list.dialogs must be an array");
|
|
1101
1106
|
return {
|
|
1102
1107
|
dialogs: value.dialogs.map((entry, index) => parseDialogRecord(entry, index)),
|
|
1103
1108
|
policy: parseDialogPolicy(value.policy)
|
|
1104
1109
|
};
|
|
1105
1110
|
},
|
|
1106
|
-
/** Forget the recorded dialogs. Leaves the accept/dismiss policy alone. */
|
|
1107
1111
|
clear: async () => {
|
|
1108
1112
|
await this.call("dialog.clear");
|
|
1109
1113
|
}
|
|
1110
1114
|
};
|
|
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
1115
|
routes = {
|
|
1126
|
-
/** Answer matching requests with this response instead of the network. */
|
|
1127
1116
|
fulfill: async (options) => {
|
|
1128
|
-
const value = expectRecord(
|
|
1129
|
-
await this.call("route.add", { ...routeParams(options), action: "fulfill" }),
|
|
1130
|
-
"route.add result"
|
|
1131
|
-
);
|
|
1117
|
+
const value = expectRecord(await this.call("route.add", { ...routeParams(options), action: "fulfill" }), "route.add result");
|
|
1132
1118
|
return { id: expectNumber(value.id, "route.add.id") };
|
|
1133
1119
|
},
|
|
1134
|
-
/** Fail matching requests the way a dropped connection does. */
|
|
1135
1120
|
abort: async (options) => {
|
|
1136
|
-
const value = expectRecord(
|
|
1137
|
-
await this.call("route.add", { ...routeParams(options), action: "abort" }),
|
|
1138
|
-
"route.add result"
|
|
1139
|
-
);
|
|
1121
|
+
const value = expectRecord(await this.call("route.add", { ...routeParams(options), action: "abort" }), "route.add result");
|
|
1140
1122
|
return { id: expectNumber(value.id, "route.add.id") };
|
|
1141
1123
|
},
|
|
1142
|
-
/** The active rules, their use counts, and every request they answered. */
|
|
1143
1124
|
list: async () => {
|
|
1144
1125
|
const value = expectRecord(await this.call("route.list"), "route.list result");
|
|
1145
1126
|
return {
|
|
1146
1127
|
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
|
-
)
|
|
1128
|
+
intercepted: expectArray(value.intercepted, "route.list.intercepted").map((entry, index) => parseInterceptedRequest(entry, index))
|
|
1150
1129
|
};
|
|
1151
1130
|
},
|
|
1152
|
-
/** Remove every rule and its interception log. */
|
|
1153
1131
|
clear: async () => {
|
|
1154
1132
|
const value = expectRecord(await this.call("route.clear"), "route.clear result");
|
|
1155
1133
|
return { removed: expectNumber(value.removed, "route.clear.removed") };
|
|
1156
1134
|
}
|
|
1157
1135
|
};
|
|
1158
|
-
/** This window's operating-system window id, where the platform exposes one. */
|
|
1159
1136
|
async nativeId() {
|
|
1160
1137
|
const listing = await new HasgardApplication(this.rpc).windows();
|
|
1161
1138
|
const self = listing.find((entry) => entry.label === this.label);
|
|
1162
|
-
if (!self)
|
|
1163
|
-
|
|
1139
|
+
if (!self)
|
|
1140
|
+
throw new Error(`Window '${this.label}' is no longer open`);
|
|
1141
|
+
if (self.nativeId === undefined) {
|
|
1164
1142
|
throw new Error(`Window '${this.label}' has no native window id on this platform`);
|
|
1165
1143
|
}
|
|
1166
1144
|
return self.nativeId;
|
|
@@ -1183,17 +1161,17 @@ var HasgardWindow = class {
|
|
|
1183
1161
|
release();
|
|
1184
1162
|
}
|
|
1185
1163
|
}
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
class HasgardFrameLocator {
|
|
1188
1167
|
windowRef;
|
|
1189
1168
|
frame;
|
|
1190
1169
|
constructor(window, frame) {
|
|
1191
1170
|
this.windowRef = window;
|
|
1192
1171
|
this.frame = frame;
|
|
1193
1172
|
}
|
|
1194
|
-
/** Descend into a frame nested inside this one. */
|
|
1195
1173
|
frameLocator(selector) {
|
|
1196
|
-
return new
|
|
1174
|
+
return new HasgardFrameLocator(this.windowRef, [...this.frame, selector]);
|
|
1197
1175
|
}
|
|
1198
1176
|
locator(selector) {
|
|
1199
1177
|
return new HasgardLocator(this.windowRef, { kind: "selector", selector, frame: [...this.frame] });
|
|
@@ -1219,29 +1197,21 @@ var HasgardFrameLocator = class _HasgardFrameLocator {
|
|
|
1219
1197
|
getByTestId(testId) {
|
|
1220
1198
|
return this.byDimension("testid", testId, {});
|
|
1221
1199
|
}
|
|
1222
|
-
/** Snapshot the frame's own document rather than the top one. */
|
|
1223
1200
|
async snapshot(options = {}) {
|
|
1224
1201
|
return this.windowRef.snapshot({ ...options, frame: [...this.frame] });
|
|
1225
1202
|
}
|
|
1226
1203
|
byDimension(by, value, query) {
|
|
1227
1204
|
return new HasgardLocator(this.windowRef, { kind: "query", by, value, query, frame: [...this.frame] });
|
|
1228
1205
|
}
|
|
1229
|
-
}
|
|
1230
|
-
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
class HasgardLocator {
|
|
1231
1209
|
window;
|
|
1232
1210
|
query;
|
|
1233
1211
|
constructor(window, query) {
|
|
1234
1212
|
this.window = window;
|
|
1235
1213
|
this.query = query;
|
|
1236
1214
|
}
|
|
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
1215
|
async click(options = {}) {
|
|
1246
1216
|
await this.withTarget((target) => this.window.call("click", { ...this.scoped(target), ...clickParams(options) }));
|
|
1247
1217
|
}
|
|
@@ -1251,61 +1221,37 @@ var HasgardLocator = class _HasgardLocator {
|
|
|
1251
1221
|
async type(text) {
|
|
1252
1222
|
await this.withTarget((target) => this.window.call("type", { ...this.scoped(target), text }));
|
|
1253
1223
|
}
|
|
1254
|
-
/** Empty the control, firing `input` and `change` as `fill` does. */
|
|
1255
1224
|
async clear() {
|
|
1256
1225
|
await this.fill("");
|
|
1257
1226
|
}
|
|
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
1227
|
async setInputFiles(files) {
|
|
1266
1228
|
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
|
-
*/
|
|
1229
|
+
await this.withTarget((target) => this.window.call("setInputFiles", {
|
|
1230
|
+
...this.scoped(target),
|
|
1231
|
+
files: payloads
|
|
1232
|
+
}));
|
|
1233
|
+
}
|
|
1281
1234
|
async wheel(deltaX, deltaY) {
|
|
1282
1235
|
return this.withTarget(async (target) => {
|
|
1283
|
-
const value = expectRecord(
|
|
1284
|
-
await this.window.call("wheel", { ...this.scoped(target), deltaX, deltaY }),
|
|
1285
|
-
"wheel result"
|
|
1286
|
-
);
|
|
1236
|
+
const value = expectRecord(await this.window.call("wheel", { ...this.scoped(target), deltaX, deltaY }), "wheel result");
|
|
1287
1237
|
return expectBoolean(value.defaultPrevented, "wheel.defaultPrevented");
|
|
1288
1238
|
});
|
|
1289
1239
|
}
|
|
1290
1240
|
async selectOption(value) {
|
|
1291
1241
|
await this.withTarget((target) => this.window.call("select", { ...this.scoped(target), value }));
|
|
1292
1242
|
}
|
|
1293
|
-
/** Drive the checkbox to checked. A no-op when it is already checked. */
|
|
1294
1243
|
async check() {
|
|
1295
1244
|
await this.withTarget((target) => this.window.call("check", { ...this.scoped(target), checked: true }));
|
|
1296
1245
|
}
|
|
1297
|
-
/** Drive the checkbox to unchecked. A no-op when it is already unchecked. */
|
|
1298
1246
|
async uncheck() {
|
|
1299
1247
|
await this.withTarget((target) => this.window.call("check", { ...this.scoped(target), checked: false }));
|
|
1300
1248
|
}
|
|
1301
|
-
/** Flip the checkbox, whatever its current state. */
|
|
1302
1249
|
async toggle() {
|
|
1303
1250
|
await this.withTarget((target) => this.window.call("check", this.scoped(target)));
|
|
1304
1251
|
}
|
|
1305
1252
|
async hover() {
|
|
1306
1253
|
await this.withTarget((target) => this.window.call("hover", this.scoped(target)));
|
|
1307
1254
|
}
|
|
1308
|
-
/** Shorthand for `click({ clickCount: 2 })`; other options still apply. */
|
|
1309
1255
|
async dblclick(options = {}) {
|
|
1310
1256
|
await this.click({ ...options, clickCount: 2 });
|
|
1311
1257
|
}
|
|
@@ -1315,11 +1261,8 @@ var HasgardLocator = class _HasgardLocator {
|
|
|
1315
1261
|
async blur() {
|
|
1316
1262
|
await this.withTarget((target) => this.window.call("blur", this.scoped(target)));
|
|
1317
1263
|
}
|
|
1318
|
-
/** Scroll this element's own scrollport. */
|
|
1319
1264
|
async scrollBy(options) {
|
|
1320
|
-
await this.withTarget(
|
|
1321
|
-
(target) => this.window.call("scroll", { ...this.scoped(target), ...options })
|
|
1322
|
-
);
|
|
1265
|
+
await this.withTarget((target) => this.window.call("scroll", { ...this.scoped(target), ...options }));
|
|
1323
1266
|
}
|
|
1324
1267
|
async boundingBox() {
|
|
1325
1268
|
return this.withTarget(async (target) => {
|
|
@@ -1342,25 +1285,18 @@ var HasgardLocator = class _HasgardLocator {
|
|
|
1342
1285
|
return !await this.isDisabled();
|
|
1343
1286
|
}
|
|
1344
1287
|
async textContent() {
|
|
1345
|
-
return this.withTarget(
|
|
1346
|
-
async (target) => expectString(await this.window.call("text", this.scoped(target)), "text result")
|
|
1347
|
-
);
|
|
1288
|
+
return this.withTarget(async (target) => expectString(await this.window.call("text", this.scoped(target)), "text result"));
|
|
1348
1289
|
}
|
|
1349
1290
|
async inputValue() {
|
|
1350
|
-
return this.withTarget(
|
|
1351
|
-
async (target) => expectString(await this.window.call("value", this.scoped(target)), "value result")
|
|
1352
|
-
);
|
|
1291
|
+
return this.withTarget(async (target) => expectString(await this.window.call("value", this.scoped(target)), "value result"));
|
|
1353
1292
|
}
|
|
1354
1293
|
async innerHTML() {
|
|
1355
|
-
return this.withTarget(
|
|
1356
|
-
async (target) => expectString(await this.window.call("html", this.scoped(target)), "html result")
|
|
1357
|
-
);
|
|
1294
|
+
return this.withTarget(async (target) => expectString(await this.window.call("html", this.scoped(target)), "html result"));
|
|
1358
1295
|
}
|
|
1359
1296
|
async getAttribute(name) {
|
|
1360
1297
|
const attributes = await this.attributes();
|
|
1361
|
-
return Object.
|
|
1298
|
+
return Object.hasOwn(attributes, name) ? attributes[name] : null;
|
|
1362
1299
|
}
|
|
1363
|
-
/** Drag this element onto another one in the same window. */
|
|
1364
1300
|
async dragTo(target) {
|
|
1365
1301
|
if (target.window !== this.window) {
|
|
1366
1302
|
throw new Error("dragTo requires both locators to belong to the same window");
|
|
@@ -1374,33 +1310,26 @@ var HasgardLocator = class _HasgardLocator {
|
|
|
1374
1310
|
});
|
|
1375
1311
|
});
|
|
1376
1312
|
}
|
|
1377
|
-
/** Drag this element by a pixel offset from its centre. */
|
|
1378
1313
|
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
|
-
);
|
|
1314
|
+
await this.withTarget((target) => this.window.call("drag", { source: this.scoped(target), offset: { x: offset.x, y: offset.y } }));
|
|
1382
1315
|
}
|
|
1383
|
-
/** Dispatch a file drop onto this element. `data` is base64-encoded. */
|
|
1384
1316
|
async dropFiles(files) {
|
|
1385
|
-
await this.withTarget(
|
|
1386
|
-
(target)
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
})
|
|
1390
|
-
);
|
|
1317
|
+
await this.withTarget((target) => this.window.call("drop", {
|
|
1318
|
+
...this.scoped(target),
|
|
1319
|
+
files
|
|
1320
|
+
}));
|
|
1391
1321
|
}
|
|
1392
1322
|
async attributes() {
|
|
1393
1323
|
return this.withTarget(async (target) => {
|
|
1394
1324
|
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
|
-
);
|
|
1325
|
+
return Object.fromEntries(Object.entries(value).map(([name, attr]) => [name, expectString(attr, `attribute ${name}`)]));
|
|
1398
1326
|
});
|
|
1399
1327
|
}
|
|
1400
1328
|
async isVisible() {
|
|
1401
1329
|
return this.window.runExclusive(async () => {
|
|
1402
1330
|
const target = await this.resolveUnique(true);
|
|
1403
|
-
if (target === null)
|
|
1331
|
+
if (target === null)
|
|
1332
|
+
return false;
|
|
1404
1333
|
const value = expectRecord(await this.window.call("visible", this.scoped(target)), "visible result");
|
|
1405
1334
|
return expectBoolean(value.visible, "visible.visible");
|
|
1406
1335
|
});
|
|
@@ -1412,19 +1341,14 @@ var HasgardLocator = class _HasgardLocator {
|
|
|
1412
1341
|
});
|
|
1413
1342
|
}
|
|
1414
1343
|
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;
|
|
1344
|
+
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;
|
|
1345
|
+
if (this.query.index === undefined)
|
|
1346
|
+
return total;
|
|
1423
1347
|
const position = absoluteIndex(this.query.index, total);
|
|
1424
1348
|
return position >= 0 && position < total ? 1 : 0;
|
|
1425
1349
|
}
|
|
1426
1350
|
async waitFor(options) {
|
|
1427
|
-
if (this.query.kind === "selector" && this.query.index ===
|
|
1351
|
+
if (this.query.kind === "selector" && this.query.index === undefined && !this.query.filters?.length) {
|
|
1428
1352
|
await this.window.call("wait", {
|
|
1429
1353
|
selector: this.query.selector,
|
|
1430
1354
|
gone: options.state === "detached",
|
|
@@ -1436,32 +1360,22 @@ var HasgardLocator = class _HasgardLocator {
|
|
|
1436
1360
|
const deadline = Date.now() + options.timeoutMs;
|
|
1437
1361
|
while (Date.now() <= deadline) {
|
|
1438
1362
|
const found = await this.count() > 0;
|
|
1439
|
-
if (found === (options.state === "attached"))
|
|
1363
|
+
if (found === (options.state === "attached"))
|
|
1364
|
+
return;
|
|
1440
1365
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1441
1366
|
}
|
|
1442
1367
|
throw new Error(`Locator did not become ${options.state} within ${options.timeoutMs}ms`);
|
|
1443
1368
|
}
|
|
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
1369
|
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
|
-
*/
|
|
1370
|
+
if (!Number.isInteger(index))
|
|
1371
|
+
throw new Error("nth requires an integer index");
|
|
1372
|
+
return new HasgardLocator(this.window, { ...this.query, index });
|
|
1373
|
+
}
|
|
1460
1374
|
filter(options) {
|
|
1461
|
-
if (options.hasText ===
|
|
1375
|
+
if (options.hasText === undefined && options.hasNotText === undefined) {
|
|
1462
1376
|
throw new Error("filter requires hasText or hasNotText");
|
|
1463
1377
|
}
|
|
1464
|
-
return new
|
|
1378
|
+
return new HasgardLocator(this.window, {
|
|
1465
1379
|
...this.query,
|
|
1466
1380
|
filters: [...this.query.filters ?? [], options]
|
|
1467
1381
|
});
|
|
@@ -1474,30 +1388,25 @@ var HasgardLocator = class _HasgardLocator {
|
|
|
1474
1388
|
}
|
|
1475
1389
|
async resolveUnique(allowMissing = false) {
|
|
1476
1390
|
if (this.query.kind === "selector" && !this.query.filters?.length) {
|
|
1477
|
-
return this.query.index ===
|
|
1391
|
+
return this.query.index === undefined ? bySelector(this.query.selector) : { selector: this.query.selector, index: this.query.index };
|
|
1478
1392
|
}
|
|
1479
1393
|
const elements = await this.resolveAll();
|
|
1480
|
-
if (this.query.index !==
|
|
1394
|
+
if (this.query.index !== undefined) {
|
|
1481
1395
|
const element = elements[absoluteIndex(this.query.index, elements.length)];
|
|
1482
1396
|
if (!element) {
|
|
1483
|
-
if (allowMissing)
|
|
1397
|
+
if (allowMissing)
|
|
1398
|
+
return null;
|
|
1484
1399
|
throw new Error(`Locator has no element at index ${this.query.index} (${elements.length} matched)`);
|
|
1485
1400
|
}
|
|
1486
1401
|
return byRef(element.ref);
|
|
1487
1402
|
}
|
|
1488
|
-
if (elements.length === 0 && allowMissing)
|
|
1403
|
+
if (elements.length === 0 && allowMissing)
|
|
1404
|
+
return null;
|
|
1489
1405
|
if (elements.length !== 1) {
|
|
1490
1406
|
throw new Error(`Locator resolved to ${elements.length} elements; expected exactly one`);
|
|
1491
1407
|
}
|
|
1492
1408
|
return byRef(elements[0].ref);
|
|
1493
1409
|
}
|
|
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
1410
|
scoped(target) {
|
|
1502
1411
|
return { ...targetParams(target), ...this.frameParams() };
|
|
1503
1412
|
}
|
|
@@ -1516,36 +1425,38 @@ var HasgardLocator = class _HasgardLocator {
|
|
|
1516
1425
|
}
|
|
1517
1426
|
if (this.query.kind === "query") {
|
|
1518
1427
|
const params = { by: this.query.by, value: this.query.value };
|
|
1519
|
-
if (this.query.query.exact !==
|
|
1428
|
+
if (this.query.query.exact !== undefined)
|
|
1429
|
+
params.exact = this.query.query.exact;
|
|
1520
1430
|
return this.runQuery(params);
|
|
1521
1431
|
}
|
|
1522
1432
|
const query = this.query;
|
|
1523
1433
|
const snapshot = await this.window.snapshot(query.frame ? { frame: [...query.frame] } : {});
|
|
1524
1434
|
return snapshot.elements.filter((element) => {
|
|
1525
|
-
if (element.role !== query.role)
|
|
1435
|
+
if (element.role !== query.role)
|
|
1436
|
+
return false;
|
|
1526
1437
|
const expected = query.query.name;
|
|
1527
|
-
if (expected ===
|
|
1528
|
-
|
|
1529
|
-
if (
|
|
1438
|
+
if (expected === undefined)
|
|
1439
|
+
return true;
|
|
1440
|
+
if (element.name === undefined)
|
|
1441
|
+
return false;
|
|
1442
|
+
if (expected instanceof RegExp)
|
|
1443
|
+
return expected.test(element.name);
|
|
1530
1444
|
return query.query.exact ? element.name === expected : element.name.includes(expected);
|
|
1531
1445
|
});
|
|
1532
1446
|
}
|
|
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
1447
|
async applyFilters(elements) {
|
|
1541
1448
|
const filters = this.query.filters ?? [];
|
|
1542
1449
|
let current = elements;
|
|
1543
1450
|
for (const filter of filters) {
|
|
1544
|
-
if (current.length === 0)
|
|
1451
|
+
if (current.length === 0)
|
|
1452
|
+
return current;
|
|
1545
1453
|
const params = { refs: current.map((element) => element.ref) };
|
|
1546
|
-
if (filter.hasText !==
|
|
1547
|
-
|
|
1548
|
-
if (filter.
|
|
1454
|
+
if (filter.hasText !== undefined)
|
|
1455
|
+
params.hasText = filter.hasText;
|
|
1456
|
+
if (filter.hasNotText !== undefined)
|
|
1457
|
+
params.hasNotText = filter.hasNotText;
|
|
1458
|
+
if (filter.exact !== undefined)
|
|
1459
|
+
params.exact = filter.exact;
|
|
1549
1460
|
current = this.parseElements(await this.window.call("filter", params), "filter");
|
|
1550
1461
|
}
|
|
1551
1462
|
return current;
|
|
@@ -1555,10 +1466,11 @@ var HasgardLocator = class _HasgardLocator {
|
|
|
1555
1466
|
}
|
|
1556
1467
|
parseElements(raw, source) {
|
|
1557
1468
|
const value = expectRecord(raw, `${source} result`);
|
|
1558
|
-
if (!Array.isArray(value.elements))
|
|
1469
|
+
if (!Array.isArray(value.elements))
|
|
1470
|
+
throw new Error(`${source}.elements must be an array`);
|
|
1559
1471
|
return value.elements.map((entry, index) => parseSnapshotElement(entry, index));
|
|
1560
1472
|
}
|
|
1561
|
-
}
|
|
1473
|
+
}
|
|
1562
1474
|
function parseDialogPolicy(value) {
|
|
1563
1475
|
const record = expectRecord(value, "dialog policy");
|
|
1564
1476
|
const action = expectString(record.action, "dialog policy.action");
|
|
@@ -1583,10 +1495,11 @@ function parseDialogRecord(value, index) {
|
|
|
1583
1495
|
message: expectString(entry.message, `dialogs[${index}].message`),
|
|
1584
1496
|
accepted: expectBoolean(entry.accepted, `dialogs[${index}].accepted`)
|
|
1585
1497
|
};
|
|
1586
|
-
if (entry.defaultValue !==
|
|
1498
|
+
if (entry.defaultValue !== undefined) {
|
|
1587
1499
|
record.defaultValue = expectString(entry.defaultValue, `dialogs[${index}].defaultValue`);
|
|
1588
1500
|
}
|
|
1589
|
-
if (entry.returned !==
|
|
1501
|
+
if (entry.returned !== undefined)
|
|
1502
|
+
record.returned = expectString(entry.returned, `dialogs[${index}].returned`);
|
|
1590
1503
|
return record;
|
|
1591
1504
|
}
|
|
1592
1505
|
function parseSnapshotElement(value, index) {
|
|
@@ -1596,10 +1509,13 @@ function parseSnapshotElement(value, index) {
|
|
|
1596
1509
|
role: expectString(entry.role, `snapshot.elements[${index}].role`),
|
|
1597
1510
|
depth: expectNumber(entry.depth, `snapshot.elements[${index}].depth`)
|
|
1598
1511
|
};
|
|
1599
|
-
if (entry.name !==
|
|
1600
|
-
|
|
1601
|
-
if (entry.
|
|
1602
|
-
|
|
1512
|
+
if (entry.name !== undefined)
|
|
1513
|
+
element.name = expectString(entry.name, `snapshot.elements[${index}].name`);
|
|
1514
|
+
if (entry.value !== undefined)
|
|
1515
|
+
element.value = expectString(entry.value, `snapshot.elements[${index}].value`);
|
|
1516
|
+
if (entry.checked !== undefined)
|
|
1517
|
+
element.checked = expectBoolean(entry.checked, `snapshot.elements[${index}].checked`);
|
|
1518
|
+
if (entry.disabled !== undefined)
|
|
1603
1519
|
element.disabled = expectBoolean(entry.disabled, `snapshot.elements[${index}].disabled`);
|
|
1604
1520
|
return element;
|
|
1605
1521
|
}
|
|
@@ -1608,23 +1524,26 @@ function parseSnapshotElement(value, index) {
|
|
|
1608
1524
|
function createHasgardTest(config) {
|
|
1609
1525
|
const base = config.test;
|
|
1610
1526
|
const expect = createHasgardExpect(config.expect);
|
|
1611
|
-
const readinessTimeoutMs = config.launch ? config.launch.timeoutMs :
|
|
1612
|
-
const fixtureTimeoutMs = config.launch ? config.launch.timeoutMs +
|
|
1613
|
-
const processes =
|
|
1527
|
+
const readinessTimeoutMs = config.launch ? config.launch.timeoutMs : 30000;
|
|
1528
|
+
const fixtureTimeoutMs = config.launch ? config.launch.timeoutMs + 30000 : 30000;
|
|
1529
|
+
const processes = new WeakMap;
|
|
1614
1530
|
const test = base.extend({
|
|
1615
1531
|
hasgard: [
|
|
1616
1532
|
async ({}, use, workerInfo) => {
|
|
1617
1533
|
const socketPath = typeof config.socketPath === "function" ? config.socketPath(workerInfo.workerIndex) : config.socketPath;
|
|
1618
|
-
const process2 = new HasgardProcess
|
|
1534
|
+
const process2 = new HasgardProcess;
|
|
1619
1535
|
const rpc = new HasgardRpcClient(socketPath);
|
|
1620
1536
|
try {
|
|
1621
|
-
if (config.launch)
|
|
1622
|
-
|
|
1537
|
+
if (config.launch)
|
|
1538
|
+
await process2.start(config.launch, socketPath);
|
|
1539
|
+
else
|
|
1540
|
+
await process2.waitForSocket(socketPath, workerInfo.project.timeout);
|
|
1623
1541
|
await rpc.connect(workerInfo.project.timeout);
|
|
1624
1542
|
const hasgard = new HasgardApplication(rpc);
|
|
1625
1543
|
processes.set(hasgard, process2);
|
|
1626
1544
|
const ping = await hasgard.ping();
|
|
1627
|
-
if (ping.status !== "ok")
|
|
1545
|
+
if (ping.status !== "ok")
|
|
1546
|
+
throw new Error(`Unexpected Hasgard ping status: ${ping.status}`);
|
|
1628
1547
|
await hasgard.waitForWindowReady(config.windowLabel, config.readySelector, readinessTimeoutMs);
|
|
1629
1548
|
await use(hasgard);
|
|
1630
1549
|
} catch (error) {
|
|
@@ -1653,7 +1572,8 @@ function createHasgardTest(config) {
|
|
|
1653
1572
|
body: JSON.stringify(diagnostics, null, 2),
|
|
1654
1573
|
contentType: "application/json"
|
|
1655
1574
|
});
|
|
1656
|
-
if (diagnostics.exit)
|
|
1575
|
+
if (diagnostics.exit)
|
|
1576
|
+
console.error(`Hasgard application exited: ${JSON.stringify(diagnostics)}`);
|
|
1657
1577
|
}
|
|
1658
1578
|
try {
|
|
1659
1579
|
const screenshot = await hasgard.window(config.windowLabel).screenshot();
|