@nyssance/tauri-hasgard 0.2.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -0
- package/dist/chunk-N7YDYAKI.js +31 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +533 -107
- package/dist/process-group.test.d.ts +1 -0
- package/dist/process-manager.d.ts +18 -0
- package/dist/process-manager.test.d.ts +1 -0
- package/dist/process-supervisor.js +154 -0
- package/dist/protocol-limits.d.ts +1 -0
- package/dist/protocol.d.ts +16 -0
- package/dist/protocol.test.d.ts +1 -0
- package/dist/types.d.ts +79 -0
- package/dist/window.d.ts +87 -4
- package/package.json +6 -4
package/dist/index.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import {
|
|
2
|
+
stopProcessGroup
|
|
3
|
+
} from "./chunk-N7YDYAKI.js";
|
|
4
|
+
|
|
1
5
|
// src/endpoint.ts
|
|
2
6
|
import { tmpdir } from "os";
|
|
3
7
|
import { join } from "path";
|
|
@@ -11,20 +15,18 @@ function hasgardEndpointPath(name) {
|
|
|
11
15
|
|
|
12
16
|
// src/expect.ts
|
|
13
17
|
async function poll(read, matches, timeoutMs) {
|
|
18
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 0)
|
|
19
|
+
throw new RangeError("Assertion timeout must be a finite non-negative number");
|
|
14
20
|
const deadline = Date.now() + timeoutMs;
|
|
15
|
-
|
|
16
|
-
let error;
|
|
17
|
-
while (Date.now() <= deadline) {
|
|
21
|
+
for (; ; ) {
|
|
18
22
|
try {
|
|
19
|
-
value = await read();
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
error = caught;
|
|
23
|
+
const value = await read();
|
|
24
|
+
if (matches(value) || Date.now() >= deadline) return value;
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (Date.now() >= deadline) throw error;
|
|
24
27
|
}
|
|
25
|
-
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
28
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(50, Math.max(0, deadline - Date.now()))));
|
|
26
29
|
}
|
|
27
|
-
return { pass: false, value, error };
|
|
28
30
|
}
|
|
29
31
|
function createHasgardExpect(playwrightExpect) {
|
|
30
32
|
return playwrightExpect.extend({
|
|
@@ -36,8 +38,8 @@ function createHasgardExpect(playwrightExpect) {
|
|
|
36
38
|
timeout
|
|
37
39
|
);
|
|
38
40
|
return {
|
|
39
|
-
pass: result
|
|
40
|
-
message: () => this.isNot ? "expected Hasgard locator not to be visible" : `expected Hasgard locator to be visible within ${timeout}ms
|
|
41
|
+
pass: result === true,
|
|
42
|
+
message: () => this.isNot ? "expected Hasgard locator not to be visible" : `expected Hasgard locator to be visible within ${timeout}ms`
|
|
41
43
|
};
|
|
42
44
|
},
|
|
43
45
|
async toHaveText(locator, expected, options = {}) {
|
|
@@ -53,24 +55,55 @@ function createHasgardExpect(playwrightExpect) {
|
|
|
53
55
|
timeout
|
|
54
56
|
);
|
|
55
57
|
return {
|
|
56
|
-
pass:
|
|
57
|
-
message: () => this.isNot ? `expected Hasgard locator text not to match ${String(expected)}, received ${String(result
|
|
58
|
+
pass: matches(result),
|
|
59
|
+
message: () => this.isNot ? `expected Hasgard locator text not to match ${String(expected)}, received ${String(result)}` : `expected Hasgard locator text to match ${String(expected)}, received ${String(result)}`
|
|
58
60
|
};
|
|
59
61
|
}
|
|
60
62
|
});
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
// src/process-manager.ts
|
|
64
|
-
import { spawn } from "child_process";
|
|
65
|
-
import {
|
|
66
|
+
import { fork, spawn } from "child_process";
|
|
67
|
+
import { lstat } from "fs/promises";
|
|
66
68
|
import { createConnection } from "net";
|
|
69
|
+
import { setTimeout as delay } from "timers/promises";
|
|
67
70
|
var HasgardProcess = class {
|
|
68
71
|
child;
|
|
69
72
|
stdout = "";
|
|
70
73
|
stderr = "";
|
|
74
|
+
starting;
|
|
75
|
+
stopping;
|
|
76
|
+
applicationPid;
|
|
77
|
+
applicationExit;
|
|
78
|
+
/** Bounded log tails and the supervisor's actual child-close result. */
|
|
79
|
+
async diagnostics(waitForExitMs = 0) {
|
|
80
|
+
if (!Number.isFinite(waitForExitMs) || waitForExitMs < 0) throw new RangeError("Invalid diagnostics wait");
|
|
81
|
+
const deadline = Date.now() + waitForExitMs;
|
|
82
|
+
while (this.child && !this.applicationExit && Date.now() < deadline)
|
|
83
|
+
await delay(Math.min(25, deadline - Date.now()));
|
|
84
|
+
return { pid: this.applicationPid, exit: this.applicationExit, stdout: this.stdout, stderr: this.stderr };
|
|
85
|
+
}
|
|
71
86
|
async start(config, socketPath) {
|
|
72
|
-
if (this.child) throw new Error("Hasgard app process is already running");
|
|
73
|
-
|
|
87
|
+
if (this.child || this.stopping || this.starting) throw new Error("Hasgard app process is already running");
|
|
88
|
+
this.starting = this.launch(config, socketPath);
|
|
89
|
+
try {
|
|
90
|
+
await this.starting;
|
|
91
|
+
} finally {
|
|
92
|
+
this.starting = void 0;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async launch(config, socketPath) {
|
|
96
|
+
this.stdout = "";
|
|
97
|
+
this.stderr = "";
|
|
98
|
+
this.applicationPid = void 0;
|
|
99
|
+
this.applicationExit = void 0;
|
|
100
|
+
if (process.platform !== "win32" && await endpointAcceptsConnections(socketPath)) {
|
|
101
|
+
throw new Error(`Hasgard endpoint is already in use: ${socketPath}`);
|
|
102
|
+
}
|
|
103
|
+
if (process.platform !== "win32") {
|
|
104
|
+
await this.launchSupervised(config, socketPath);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
74
107
|
const child = spawn(config.command, config.args, {
|
|
75
108
|
cwd: config.cwd,
|
|
76
109
|
env: { ...process.env, ...config.env, TAURI_HASGARD_SOCKET: socketPath },
|
|
@@ -79,68 +112,206 @@ var HasgardProcess = class {
|
|
|
79
112
|
});
|
|
80
113
|
this.child = child;
|
|
81
114
|
child.stdout?.on("data", (data) => {
|
|
82
|
-
this.stdout
|
|
115
|
+
this.stdout = appendOutput(this.stdout, data.toString());
|
|
83
116
|
});
|
|
84
117
|
child.stderr?.on("data", (data) => {
|
|
85
|
-
this.stderr
|
|
118
|
+
this.stderr = appendOutput(this.stderr, data.toString());
|
|
86
119
|
});
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
120
|
+
try {
|
|
121
|
+
await new Promise((resolve, reject) => {
|
|
122
|
+
child.once("spawn", resolve);
|
|
123
|
+
child.once("error", reject);
|
|
124
|
+
});
|
|
125
|
+
await this.waitForSocket(socketPath, config.timeoutMs);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
try {
|
|
128
|
+
await this.stopChild();
|
|
129
|
+
} catch (cleanupError) {
|
|
130
|
+
throw new AggregateError([error, cleanupError], "Hasgard launch failed and process cleanup failed");
|
|
131
|
+
}
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
async launchSupervised(config, socketPath) {
|
|
136
|
+
const child = fork(new URL("./process-supervisor.js", import.meta.url), [], {
|
|
137
|
+
detached: true,
|
|
138
|
+
execArgv: [],
|
|
139
|
+
stdio: ["ignore", "ignore", "pipe", "ipc"]
|
|
140
|
+
});
|
|
141
|
+
this.child = child;
|
|
142
|
+
child.stderr?.on("data", (data) => {
|
|
143
|
+
this.stderr = appendOutput(this.stderr, String(data));
|
|
90
144
|
});
|
|
91
145
|
try {
|
|
146
|
+
await new Promise((resolve, reject) => {
|
|
147
|
+
const timer = setTimeout(() => reject(new Error("Hasgard process supervisor did not start")), config.timeoutMs);
|
|
148
|
+
const failed = (error) => {
|
|
149
|
+
clearTimeout(timer);
|
|
150
|
+
reject(error);
|
|
151
|
+
};
|
|
152
|
+
child.once("error", failed);
|
|
153
|
+
child.once("exit", () => failed(new Error("Hasgard process supervisor exited")));
|
|
154
|
+
child.on(
|
|
155
|
+
"message",
|
|
156
|
+
(message) => {
|
|
157
|
+
if (message.type === "stdout") this.stdout = appendOutput(this.stdout, message.data);
|
|
158
|
+
else if (message.type === "stderr") this.stderr = appendOutput(this.stderr, message.data);
|
|
159
|
+
else if (message.type === "exit") this.applicationExit = { code: message.code, signal: message.signal };
|
|
160
|
+
else if (message.type === "error") failed(Object.assign(new Error(message.message), { code: message.code }));
|
|
161
|
+
else if (message.type === "started") {
|
|
162
|
+
if (!Number.isSafeInteger(message.pid) || message.pid <= 1) {
|
|
163
|
+
failed(new Error("Invalid supervised application PID"));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
this.applicationPid = message.pid;
|
|
167
|
+
clearTimeout(timer);
|
|
168
|
+
resolve();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
);
|
|
172
|
+
child.send({ ...config, env: { ...process.env, ...config.env, TAURI_HASGARD_SOCKET: socketPath } }, (error) => {
|
|
173
|
+
if (error) failed(error);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
92
176
|
await this.waitForSocket(socketPath, config.timeoutMs);
|
|
177
|
+
await this.confirmEndpointOwnership(child, socketPath, config.timeoutMs);
|
|
93
178
|
} catch (error) {
|
|
94
|
-
|
|
179
|
+
try {
|
|
180
|
+
await this.stopChild();
|
|
181
|
+
} catch (cleanupError) {
|
|
182
|
+
throw new AggregateError([error, cleanupError], "Hasgard launch failed and process cleanup failed");
|
|
183
|
+
}
|
|
95
184
|
throw error;
|
|
96
185
|
}
|
|
97
186
|
}
|
|
98
187
|
async waitForSocket(socketPath, timeoutMs) {
|
|
99
188
|
const deadline = Date.now() + timeoutMs;
|
|
100
189
|
while (Date.now() <= deadline) {
|
|
101
|
-
|
|
190
|
+
const child = this.child;
|
|
191
|
+
if (this.applicationExit) {
|
|
192
|
+
const { code, signal } = this.applicationExit;
|
|
102
193
|
throw new Error(
|
|
103
|
-
`Tauri process exited with code ${
|
|
194
|
+
`Tauri process exited with ${signal ? `signal ${signal}` : `code ${code}`}
|
|
104
195
|
stdout:
|
|
105
196
|
${this.stdout}
|
|
106
197
|
stderr:
|
|
107
198
|
${this.stderr}`
|
|
108
199
|
);
|
|
109
200
|
}
|
|
110
|
-
if (
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if (code !== "ENOENT") throw error;
|
|
119
|
-
}
|
|
201
|
+
if (child && (child.exitCode !== null || child.signalCode !== null)) {
|
|
202
|
+
throw new Error(
|
|
203
|
+
`Tauri process exited with ${child.signalCode ? `signal ${child.signalCode}` : `code ${child.exitCode}`}
|
|
204
|
+
stdout:
|
|
205
|
+
${this.stdout}
|
|
206
|
+
stderr:
|
|
207
|
+
${this.stderr}`
|
|
208
|
+
);
|
|
120
209
|
}
|
|
121
|
-
|
|
210
|
+
if (await endpointAcceptsConnections(socketPath)) return;
|
|
211
|
+
await delay(50);
|
|
122
212
|
}
|
|
123
213
|
throw new Error(`Hasgard socket did not appear within ${timeoutMs}ms: ${socketPath}`);
|
|
124
214
|
}
|
|
215
|
+
async confirmEndpointOwnership(child, path, timeoutMs) {
|
|
216
|
+
const stat = await lstat(path);
|
|
217
|
+
if (!stat.isSocket()) throw new Error(`Hasgard endpoint is not a socket: ${path}`);
|
|
218
|
+
await new Promise((resolve, reject) => {
|
|
219
|
+
const done = (error) => {
|
|
220
|
+
clearTimeout(timer);
|
|
221
|
+
child.off("message", message);
|
|
222
|
+
child.off("exit", exited);
|
|
223
|
+
if (error) reject(error);
|
|
224
|
+
else resolve();
|
|
225
|
+
};
|
|
226
|
+
const message = (value) => {
|
|
227
|
+
if (value.type === "endpoint-owned") done();
|
|
228
|
+
else if (value.type === "error") done(new Error(value.message));
|
|
229
|
+
};
|
|
230
|
+
const exited = () => done(new Error("Hasgard supervisor exited before acknowledging endpoint ownership"));
|
|
231
|
+
const timer = setTimeout(
|
|
232
|
+
() => done(new Error("Hasgard supervisor did not acknowledge endpoint ownership")),
|
|
233
|
+
timeoutMs
|
|
234
|
+
);
|
|
235
|
+
child.on("message", message);
|
|
236
|
+
child.once("exit", exited);
|
|
237
|
+
child.send({ type: "endpoint", identity: { dev: stat.dev, ino: stat.ino } }, (error) => {
|
|
238
|
+
if (error) done(error);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
}
|
|
125
242
|
async stop() {
|
|
243
|
+
if (this.starting) await this.starting;
|
|
244
|
+
if (this.stopping) return this.stopping;
|
|
245
|
+
this.stopping = this.stopChild();
|
|
246
|
+
try {
|
|
247
|
+
await this.stopping;
|
|
248
|
+
} finally {
|
|
249
|
+
this.stopping = void 0;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
async stopChild() {
|
|
126
253
|
const child = this.child;
|
|
127
|
-
if (!child
|
|
254
|
+
if (!child) return;
|
|
255
|
+
if (process.platform !== "win32") {
|
|
256
|
+
if (this.applicationPid !== void 0) {
|
|
257
|
+
await stopProcessGroup(this.applicationPid);
|
|
258
|
+
}
|
|
259
|
+
const exited2 = child.exitCode !== null || child.signalCode !== null ? Promise.resolve() : new Promise((resolve) => child.once("exit", () => resolve()));
|
|
260
|
+
if (child.connected) child.disconnect();
|
|
261
|
+
const abort2 = new AbortController();
|
|
262
|
+
try {
|
|
263
|
+
if (await Promise.race([exited2, delay(11e3, "timeout", { signal: abort2.signal })]) === "timeout") {
|
|
264
|
+
throw new Error(`Hasgard process supervisor ${child.pid} did not finish cleanup`);
|
|
265
|
+
}
|
|
266
|
+
} finally {
|
|
267
|
+
abort2.abort();
|
|
268
|
+
}
|
|
269
|
+
this.child = void 0;
|
|
270
|
+
this.applicationPid = void 0;
|
|
271
|
+
if (child.exitCode !== 0 || child.signalCode !== null) {
|
|
272
|
+
throw new Error(`Hasgard supervisor cleanup failed (${child.signalCode ?? child.exitCode}):
|
|
273
|
+
${this.stderr}`);
|
|
274
|
+
}
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (child.pid === void 0 || child.exitCode !== null || child.signalCode !== null) {
|
|
278
|
+
this.child = void 0;
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
128
281
|
const exited = new Promise((resolve) => child.once("exit", () => resolve()));
|
|
129
282
|
if (process.platform === "win32" && child.pid !== void 0) {
|
|
130
283
|
await terminateWindowsProcessTree(child.pid);
|
|
131
284
|
} else if (child.pid !== void 0) {
|
|
132
285
|
process.kill(-child.pid, "SIGTERM");
|
|
133
286
|
}
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
287
|
+
const abort = new AbortController();
|
|
288
|
+
const timeout = delay(5e3, "timeout", { signal: abort.signal });
|
|
289
|
+
try {
|
|
290
|
+
if (await Promise.race([exited, timeout]) === "timeout") {
|
|
291
|
+
if (process.platform === "win32" && child.pid !== void 0) await terminateWindowsProcessTree(child.pid);
|
|
292
|
+
else if (child.pid !== void 0) process.kill(-child.pid, "SIGKILL");
|
|
293
|
+
await exited;
|
|
294
|
+
}
|
|
295
|
+
} finally {
|
|
296
|
+
abort.abort();
|
|
139
297
|
}
|
|
140
298
|
this.child = void 0;
|
|
141
299
|
}
|
|
142
300
|
};
|
|
143
|
-
|
|
301
|
+
function appendOutput(previous, chunk) {
|
|
302
|
+
const output = previous + chunk;
|
|
303
|
+
return output.length > 65536 ? `[earlier output truncated]
|
|
304
|
+
${output.slice(-65536)}` : output;
|
|
305
|
+
}
|
|
306
|
+
async function endpointAcceptsConnections(path) {
|
|
307
|
+
if (process.platform !== "win32") {
|
|
308
|
+
try {
|
|
309
|
+
if (!(await lstat(path)).isSocket()) throw new Error(`Hasgard endpoint exists but is not a socket: ${path}`);
|
|
310
|
+
} catch (error) {
|
|
311
|
+
if (error.code === "ENOENT") return false;
|
|
312
|
+
throw error;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
144
315
|
return new Promise((resolve, reject) => {
|
|
145
316
|
const socket = createConnection({ path });
|
|
146
317
|
socket.once("connect", () => {
|
|
@@ -168,6 +339,32 @@ async function terminateWindowsProcessTree(pid) {
|
|
|
168
339
|
// src/rpc-client.ts
|
|
169
340
|
import { createConnection as createConnection2 } from "net";
|
|
170
341
|
import { createInterface } from "readline";
|
|
342
|
+
|
|
343
|
+
// src/protocol.ts
|
|
344
|
+
function isObject(value) {
|
|
345
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
346
|
+
}
|
|
347
|
+
function parseRpcResponse(line) {
|
|
348
|
+
const value = JSON.parse(line);
|
|
349
|
+
const invalid = () => new Error(`Invalid Hasgard JSON-RPC envelope: ${line}`);
|
|
350
|
+
if (!isObject(value) || value.jsonrpc !== "2.0" || !Object.hasOwn(value, "id")) throw invalid();
|
|
351
|
+
if (value.id !== null && (!Number.isSafeInteger(value.id) || value.id < 0)) throw invalid();
|
|
352
|
+
const hasResult = Object.hasOwn(value, "result");
|
|
353
|
+
const hasError = Object.hasOwn(value, "error");
|
|
354
|
+
if (hasResult === hasError) throw invalid();
|
|
355
|
+
if (hasResult && value.id === null) throw invalid();
|
|
356
|
+
if (hasError) {
|
|
357
|
+
const error = value.error;
|
|
358
|
+
if (!isObject(error) || !Number.isInteger(error.code) || error.code < -2147483648 || error.code > 2147483647 || typeof error.message !== "string")
|
|
359
|
+
throw invalid();
|
|
360
|
+
}
|
|
361
|
+
return value;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// src/protocol-limits.ts
|
|
365
|
+
var MAX_REQUEST_BYTES = 1048576;
|
|
366
|
+
|
|
367
|
+
// src/rpc-client.ts
|
|
171
368
|
var HasgardRpcError = class extends Error {
|
|
172
369
|
code;
|
|
173
370
|
data;
|
|
@@ -191,39 +388,66 @@ var HasgardRpcClient = class {
|
|
|
191
388
|
if (this.socket) throw new Error("Hasgard RPC client is already connected");
|
|
192
389
|
const socket = createConnection2({ path: this.socketPath });
|
|
193
390
|
this.socket = socket;
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
391
|
+
try {
|
|
392
|
+
await new Promise((resolve, reject) => {
|
|
393
|
+
const cleanup = () => {
|
|
394
|
+
clearTimeout(timeout);
|
|
395
|
+
socket.off("connect", connected);
|
|
396
|
+
socket.off("error", failed);
|
|
397
|
+
socket.off("close", closed);
|
|
398
|
+
};
|
|
399
|
+
const connected = () => {
|
|
400
|
+
cleanup();
|
|
401
|
+
resolve();
|
|
402
|
+
};
|
|
403
|
+
const failed = (error) => {
|
|
404
|
+
cleanup();
|
|
405
|
+
reject(error);
|
|
406
|
+
};
|
|
407
|
+
const closed = () => failed(new Error("Hasgard socket closed while connecting"));
|
|
408
|
+
const timeout = setTimeout(() => {
|
|
409
|
+
failed(new Error(`Hasgard socket connection timed out after ${timeoutMs}ms: ${this.socketPath}`));
|
|
410
|
+
socket.destroy();
|
|
411
|
+
}, timeoutMs);
|
|
412
|
+
socket.once("connect", connected);
|
|
413
|
+
socket.once("error", failed);
|
|
414
|
+
socket.once("close", closed);
|
|
206
415
|
});
|
|
207
|
-
})
|
|
416
|
+
} catch (error) {
|
|
417
|
+
if (this.socket === socket) this.socket = void 0;
|
|
418
|
+
socket.destroy();
|
|
419
|
+
throw error;
|
|
420
|
+
}
|
|
208
421
|
this.lines = createInterface({ input: socket });
|
|
209
422
|
this.lines.on("line", (line) => this.handleLine(line));
|
|
210
|
-
socket.on("close", () =>
|
|
211
|
-
|
|
423
|
+
socket.on("close", () => {
|
|
424
|
+
if (this.socket !== socket) return;
|
|
425
|
+
this.rejectPending(new Error("Hasgard socket closed"));
|
|
426
|
+
this.disconnect();
|
|
427
|
+
});
|
|
428
|
+
socket.on("error", (error) => {
|
|
429
|
+
if (this.socket === socket) this.rejectPending(error);
|
|
430
|
+
});
|
|
212
431
|
}
|
|
213
432
|
async call(method, params) {
|
|
214
433
|
const socket = this.socket;
|
|
215
|
-
if (!socket) throw new Error("Hasgard RPC client is not connected");
|
|
434
|
+
if (!socket || socket.destroyed || !socket.writable) throw new Error("Hasgard RPC client is not connected");
|
|
216
435
|
const id = this.nextId;
|
|
217
436
|
this.nextId += 1;
|
|
218
437
|
const request = params ? { jsonrpc: "2.0", id, method, params } : { jsonrpc: "2.0", id, method };
|
|
438
|
+
const line = `${JSON.stringify(request)}
|
|
439
|
+
`;
|
|
440
|
+
const bytes = Buffer.byteLength(line, "utf8");
|
|
441
|
+
if (bytes > MAX_REQUEST_BYTES) {
|
|
442
|
+
throw new Error(`Hasgard request is ${bytes} bytes; maximum is ${MAX_REQUEST_BYTES} bytes including newline`);
|
|
443
|
+
}
|
|
219
444
|
const result = new Promise((resolve, reject) => {
|
|
220
445
|
this.pending.set(id, {
|
|
221
446
|
resolve: (value) => resolve(value),
|
|
222
447
|
reject
|
|
223
448
|
});
|
|
224
449
|
});
|
|
225
|
-
socket.write(
|
|
226
|
-
`, (error) => {
|
|
450
|
+
socket.write(line, (error) => {
|
|
227
451
|
if (!error) return;
|
|
228
452
|
const pending = this.pending.get(id);
|
|
229
453
|
if (!pending) return;
|
|
@@ -242,16 +466,16 @@ var HasgardRpcClient = class {
|
|
|
242
466
|
handleLine(line) {
|
|
243
467
|
let response;
|
|
244
468
|
try {
|
|
245
|
-
response =
|
|
246
|
-
} catch {
|
|
247
|
-
this.rejectPending(
|
|
469
|
+
response = parseRpcResponse(line);
|
|
470
|
+
} catch (error) {
|
|
471
|
+
this.rejectPending(error instanceof Error ? error : new Error(String(error)));
|
|
248
472
|
return;
|
|
249
473
|
}
|
|
250
|
-
if (
|
|
251
|
-
this.rejectPending(new
|
|
474
|
+
if ("error" in response && response.id === null) {
|
|
475
|
+
this.rejectPending(new HasgardRpcError(response.error.code, response.error.message, response.error.data));
|
|
252
476
|
return;
|
|
253
477
|
}
|
|
254
|
-
const pending = this.pending.get(response.id);
|
|
478
|
+
const pending = response.id === null ? void 0 : this.pending.get(response.id);
|
|
255
479
|
if (!pending) {
|
|
256
480
|
this.rejectPending(new Error(`Unexpected Hasgard JSON-RPC response id: ${response.id}`));
|
|
257
481
|
return;
|
|
@@ -309,6 +533,58 @@ function targetParams(target) {
|
|
|
309
533
|
}
|
|
310
534
|
return { x: target.x, y: target.y };
|
|
311
535
|
}
|
|
536
|
+
function clickParams(options) {
|
|
537
|
+
const params = {};
|
|
538
|
+
if (options.modifiers !== void 0) params.modifiers = options.modifiers;
|
|
539
|
+
if (options.button !== void 0) params.button = options.button;
|
|
540
|
+
if (options.clickCount !== void 0) params.clickCount = options.clickCount;
|
|
541
|
+
if (options.position !== void 0) params.position = { ...options.position };
|
|
542
|
+
return params;
|
|
543
|
+
}
|
|
544
|
+
function routeParams(options) {
|
|
545
|
+
const params = { pattern: options.pattern };
|
|
546
|
+
if (options.method !== void 0) params.method = options.method;
|
|
547
|
+
if (options.times !== void 0) params.times = options.times;
|
|
548
|
+
if ("status" in options && options.status !== void 0) params.status = options.status;
|
|
549
|
+
if ("body" in options && options.body !== void 0) params.body = options.body;
|
|
550
|
+
if ("contentType" in options && options.contentType !== void 0) {
|
|
551
|
+
params.contentType = options.contentType;
|
|
552
|
+
}
|
|
553
|
+
return params;
|
|
554
|
+
}
|
|
555
|
+
function parseRouteAction(value, context) {
|
|
556
|
+
const action = expectString(value, context);
|
|
557
|
+
if (action !== "fulfill" && action !== "abort") {
|
|
558
|
+
throw new Error(`${context} must be "fulfill" or "abort", got ${action}`);
|
|
559
|
+
}
|
|
560
|
+
return action;
|
|
561
|
+
}
|
|
562
|
+
function parseRouteRule(value, index) {
|
|
563
|
+
const context = `route.list.routes[${index}]`;
|
|
564
|
+
const rule = expectRecord(value, context);
|
|
565
|
+
return {
|
|
566
|
+
id: expectNumber(rule.id, `${context}.id`),
|
|
567
|
+
pattern: expectString(rule.pattern, `${context}.pattern`),
|
|
568
|
+
method: expectNullableString(rule.method, `${context}.method`),
|
|
569
|
+
action: parseRouteAction(rule.action, `${context}.action`),
|
|
570
|
+
status: expectNumber(rule.status, `${context}.status`),
|
|
571
|
+
times: rule.times === null ? null : expectNumber(rule.times, `${context}.times`),
|
|
572
|
+
used: expectNumber(rule.used, `${context}.used`)
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
function parseInterceptedRequest(value, index) {
|
|
576
|
+
const context = `route.list.intercepted[${index}]`;
|
|
577
|
+
const entry = expectRecord(value, context);
|
|
578
|
+
return {
|
|
579
|
+
id: expectNumber(entry.id, `${context}.id`),
|
|
580
|
+
timestamp: expectNumber(entry.timestamp, `${context}.timestamp`),
|
|
581
|
+
route_id: expectNumber(entry.route_id, `${context}.route_id`),
|
|
582
|
+
method: expectString(entry.method, `${context}.method`),
|
|
583
|
+
url: expectString(entry.url, `${context}.url`),
|
|
584
|
+
action: parseRouteAction(entry.action, `${context}.action`),
|
|
585
|
+
status: expectNumber(entry.status, `${context}.status`)
|
|
586
|
+
};
|
|
587
|
+
}
|
|
312
588
|
function absoluteIndex(index, total) {
|
|
313
589
|
return index < 0 ? total + index : index;
|
|
314
590
|
}
|
|
@@ -550,6 +826,17 @@ var HasgardWindow = class {
|
|
|
550
826
|
getByTestId(testId) {
|
|
551
827
|
return new HasgardLocator(this, { kind: "query", by: "testid", value: testId, query: {} });
|
|
552
828
|
}
|
|
829
|
+
/**
|
|
830
|
+
* Scope locators to a same-origin `<iframe>`.
|
|
831
|
+
*
|
|
832
|
+
* Cross-origin frames are out of reach: the same-origin policy binds injected
|
|
833
|
+
* script exactly as it binds the page's own, so no bridge can see into a
|
|
834
|
+
* third-party payment or OAuth frame. Those raise an error naming the origin
|
|
835
|
+
* problem rather than reporting a missing element.
|
|
836
|
+
*/
|
|
837
|
+
frameLocator(selector) {
|
|
838
|
+
return new HasgardFrameLocator(this, [selector]);
|
|
839
|
+
}
|
|
553
840
|
async snapshot(options = {}) {
|
|
554
841
|
const raw = expectRecord(
|
|
555
842
|
await this.rpc.call("snapshot", withWindow(this.label, options)),
|
|
@@ -597,8 +884,14 @@ var HasgardWindow = class {
|
|
|
597
884
|
async scrollBy(options) {
|
|
598
885
|
await this.rpc.call("scroll", withWindow(this.label, options));
|
|
599
886
|
}
|
|
600
|
-
async press(key) {
|
|
601
|
-
|
|
887
|
+
async press(key, options) {
|
|
888
|
+
const params = { key };
|
|
889
|
+
if (options) {
|
|
890
|
+
if (options.completion !== void 0) params.completion = options.completion;
|
|
891
|
+
if (options.waitFor !== void 0) params.waitFor = options.waitFor;
|
|
892
|
+
if (options.timeoutMs !== void 0) params.timeout = options.timeoutMs;
|
|
893
|
+
}
|
|
894
|
+
await this.rpc.call("press", withWindow(this.label, params));
|
|
602
895
|
}
|
|
603
896
|
async diff(options = {}) {
|
|
604
897
|
const { reference, ...snapshotOptions } = options;
|
|
@@ -787,6 +1080,53 @@ var HasgardWindow = class {
|
|
|
787
1080
|
await this.call("dialog.clear");
|
|
788
1081
|
}
|
|
789
1082
|
};
|
|
1083
|
+
/**
|
|
1084
|
+
* Shape what the page's own requests receive, without a real backend.
|
|
1085
|
+
*
|
|
1086
|
+
* Rules are declarative rather than Playwright's per-request callback: the
|
|
1087
|
+
* bridge only ever answers requests, so it cannot call back into this process
|
|
1088
|
+
* and await a handler while the page sits inside `fetch`. The same constraint
|
|
1089
|
+
* shaped `dialogs`.
|
|
1090
|
+
*
|
|
1091
|
+
* Rules are checked in registration order and the first match wins, so
|
|
1092
|
+
* register the narrow ones before a catch-all. Both `fetch` and
|
|
1093
|
+
* `XMLHttpRequest` are covered; resources the webview loads itself -- the
|
|
1094
|
+
* document, `<img src>`, stylesheets -- are not, since no page script sees
|
|
1095
|
+
* them.
|
|
1096
|
+
*/
|
|
1097
|
+
routes = {
|
|
1098
|
+
/** Answer matching requests with this response instead of the network. */
|
|
1099
|
+
fulfill: async (options) => {
|
|
1100
|
+
const value = expectRecord(
|
|
1101
|
+
await this.call("route.add", { ...routeParams(options), action: "fulfill" }),
|
|
1102
|
+
"route.add result"
|
|
1103
|
+
);
|
|
1104
|
+
return { id: expectNumber(value.id, "route.add.id") };
|
|
1105
|
+
},
|
|
1106
|
+
/** Fail matching requests the way a dropped connection does. */
|
|
1107
|
+
abort: async (options) => {
|
|
1108
|
+
const value = expectRecord(
|
|
1109
|
+
await this.call("route.add", { ...routeParams(options), action: "abort" }),
|
|
1110
|
+
"route.add result"
|
|
1111
|
+
);
|
|
1112
|
+
return { id: expectNumber(value.id, "route.add.id") };
|
|
1113
|
+
},
|
|
1114
|
+
/** The active rules, their use counts, and every request they answered. */
|
|
1115
|
+
list: async () => {
|
|
1116
|
+
const value = expectRecord(await this.call("route.list"), "route.list result");
|
|
1117
|
+
return {
|
|
1118
|
+
routes: expectArray(value.routes, "route.list.routes").map((entry, index) => parseRouteRule(entry, index)),
|
|
1119
|
+
intercepted: expectArray(value.intercepted, "route.list.intercepted").map(
|
|
1120
|
+
(entry, index) => parseInterceptedRequest(entry, index)
|
|
1121
|
+
)
|
|
1122
|
+
};
|
|
1123
|
+
},
|
|
1124
|
+
/** Remove every rule and its interception log. */
|
|
1125
|
+
clear: async () => {
|
|
1126
|
+
const value = expectRecord(await this.call("route.clear"), "route.clear result");
|
|
1127
|
+
return { removed: expectNumber(value.removed, "route.clear.removed") };
|
|
1128
|
+
}
|
|
1129
|
+
};
|
|
790
1130
|
/** This window's operating-system window id, where the platform exposes one. */
|
|
791
1131
|
async nativeId() {
|
|
792
1132
|
const listing = await new HasgardApplication(this.rpc).windows();
|
|
@@ -816,6 +1156,49 @@ var HasgardWindow = class {
|
|
|
816
1156
|
}
|
|
817
1157
|
}
|
|
818
1158
|
};
|
|
1159
|
+
var HasgardFrameLocator = class _HasgardFrameLocator {
|
|
1160
|
+
windowRef;
|
|
1161
|
+
frame;
|
|
1162
|
+
constructor(window, frame) {
|
|
1163
|
+
this.windowRef = window;
|
|
1164
|
+
this.frame = frame;
|
|
1165
|
+
}
|
|
1166
|
+
/** Descend into a frame nested inside this one. */
|
|
1167
|
+
frameLocator(selector) {
|
|
1168
|
+
return new _HasgardFrameLocator(this.windowRef, [...this.frame, selector]);
|
|
1169
|
+
}
|
|
1170
|
+
locator(selector) {
|
|
1171
|
+
return new HasgardLocator(this.windowRef, { kind: "selector", selector, frame: [...this.frame] });
|
|
1172
|
+
}
|
|
1173
|
+
getByRole(role, query = {}) {
|
|
1174
|
+
return new HasgardLocator(this.windowRef, { kind: "role", role, query, frame: [...this.frame] });
|
|
1175
|
+
}
|
|
1176
|
+
getByText(text, query = {}) {
|
|
1177
|
+
return this.byDimension("text", text, query);
|
|
1178
|
+
}
|
|
1179
|
+
getByLabel(text, query = {}) {
|
|
1180
|
+
return this.byDimension("label", text, query);
|
|
1181
|
+
}
|
|
1182
|
+
getByPlaceholder(text, query = {}) {
|
|
1183
|
+
return this.byDimension("placeholder", text, query);
|
|
1184
|
+
}
|
|
1185
|
+
getByAltText(text, query = {}) {
|
|
1186
|
+
return this.byDimension("alt", text, query);
|
|
1187
|
+
}
|
|
1188
|
+
getByTitle(text, query = {}) {
|
|
1189
|
+
return this.byDimension("title", text, query);
|
|
1190
|
+
}
|
|
1191
|
+
getByTestId(testId) {
|
|
1192
|
+
return this.byDimension("testid", testId, {});
|
|
1193
|
+
}
|
|
1194
|
+
/** Snapshot the frame's own document rather than the top one. */
|
|
1195
|
+
async snapshot(options = {}) {
|
|
1196
|
+
return this.windowRef.snapshot({ ...options, frame: [...this.frame] });
|
|
1197
|
+
}
|
|
1198
|
+
byDimension(by, value, query) {
|
|
1199
|
+
return new HasgardLocator(this.windowRef, { kind: "query", by, value, query, frame: [...this.frame] });
|
|
1200
|
+
}
|
|
1201
|
+
};
|
|
819
1202
|
var HasgardLocator = class _HasgardLocator {
|
|
820
1203
|
window;
|
|
821
1204
|
query;
|
|
@@ -823,14 +1206,22 @@ var HasgardLocator = class _HasgardLocator {
|
|
|
823
1206
|
this.window = window;
|
|
824
1207
|
this.query = query;
|
|
825
1208
|
}
|
|
826
|
-
|
|
827
|
-
|
|
1209
|
+
/**
|
|
1210
|
+
* Click this element.
|
|
1211
|
+
*
|
|
1212
|
+
* A right or middle click raises `contextmenu`/`auxclick` and no `click`, and
|
|
1213
|
+
* `clickCount: 2` escalates `detail` across the presses before a single
|
|
1214
|
+
* `dblclick` -- both matching what the platform does, so a listener bound to
|
|
1215
|
+
* the wrong event fails here rather than in front of a user.
|
|
1216
|
+
*/
|
|
1217
|
+
async click(options = {}) {
|
|
1218
|
+
await this.withTarget((target) => this.window.call("click", { ...this.scoped(target), ...clickParams(options) }));
|
|
828
1219
|
}
|
|
829
1220
|
async fill(value) {
|
|
830
|
-
await this.withTarget((target) => this.window.call("fill", { ...
|
|
1221
|
+
await this.withTarget((target) => this.window.call("fill", { ...this.scoped(target), value }));
|
|
831
1222
|
}
|
|
832
1223
|
async type(text) {
|
|
833
|
-
await this.withTarget((target) => this.window.call("type", { ...
|
|
1224
|
+
await this.withTarget((target) => this.window.call("type", { ...this.scoped(target), text }));
|
|
834
1225
|
}
|
|
835
1226
|
/** Empty the control, firing `input` and `change` as `fill` does. */
|
|
836
1227
|
async clear() {
|
|
@@ -847,7 +1238,7 @@ var HasgardLocator = class _HasgardLocator {
|
|
|
847
1238
|
const payloads = await readFileInputs(files);
|
|
848
1239
|
await this.withTarget(
|
|
849
1240
|
(target) => this.window.call("setInputFiles", {
|
|
850
|
-
...
|
|
1241
|
+
...this.scoped(target),
|
|
851
1242
|
files: payloads
|
|
852
1243
|
})
|
|
853
1244
|
);
|
|
@@ -862,48 +1253,49 @@ var HasgardLocator = class _HasgardLocator {
|
|
|
862
1253
|
async wheel(deltaX, deltaY) {
|
|
863
1254
|
return this.withTarget(async (target) => {
|
|
864
1255
|
const value = expectRecord(
|
|
865
|
-
await this.window.call("wheel", { ...
|
|
1256
|
+
await this.window.call("wheel", { ...this.scoped(target), deltaX, deltaY }),
|
|
866
1257
|
"wheel result"
|
|
867
1258
|
);
|
|
868
1259
|
return expectBoolean(value.defaultPrevented, "wheel.defaultPrevented");
|
|
869
1260
|
});
|
|
870
1261
|
}
|
|
871
1262
|
async selectOption(value) {
|
|
872
|
-
await this.withTarget((target) => this.window.call("select", { ...
|
|
1263
|
+
await this.withTarget((target) => this.window.call("select", { ...this.scoped(target), value }));
|
|
873
1264
|
}
|
|
874
1265
|
/** Drive the checkbox to checked. A no-op when it is already checked. */
|
|
875
1266
|
async check() {
|
|
876
|
-
await this.withTarget((target) => this.window.call("check", { ...
|
|
1267
|
+
await this.withTarget((target) => this.window.call("check", { ...this.scoped(target), checked: true }));
|
|
877
1268
|
}
|
|
878
1269
|
/** Drive the checkbox to unchecked. A no-op when it is already unchecked. */
|
|
879
1270
|
async uncheck() {
|
|
880
|
-
await this.withTarget((target) => this.window.call("check", { ...
|
|
1271
|
+
await this.withTarget((target) => this.window.call("check", { ...this.scoped(target), checked: false }));
|
|
881
1272
|
}
|
|
882
1273
|
/** Flip the checkbox, whatever its current state. */
|
|
883
1274
|
async toggle() {
|
|
884
|
-
await this.withTarget((target) => this.window.call("check",
|
|
1275
|
+
await this.withTarget((target) => this.window.call("check", this.scoped(target)));
|
|
885
1276
|
}
|
|
886
1277
|
async hover() {
|
|
887
|
-
await this.withTarget((target) => this.window.call("hover",
|
|
1278
|
+
await this.withTarget((target) => this.window.call("hover", this.scoped(target)));
|
|
888
1279
|
}
|
|
889
|
-
|
|
890
|
-
|
|
1280
|
+
/** Shorthand for `click({ clickCount: 2 })`; other options still apply. */
|
|
1281
|
+
async dblclick(options = {}) {
|
|
1282
|
+
await this.click({ ...options, clickCount: 2 });
|
|
891
1283
|
}
|
|
892
1284
|
async focus() {
|
|
893
|
-
await this.withTarget((target) => this.window.call("focus",
|
|
1285
|
+
await this.withTarget((target) => this.window.call("focus", this.scoped(target)));
|
|
894
1286
|
}
|
|
895
1287
|
async blur() {
|
|
896
|
-
await this.withTarget((target) => this.window.call("blur",
|
|
1288
|
+
await this.withTarget((target) => this.window.call("blur", this.scoped(target)));
|
|
897
1289
|
}
|
|
898
1290
|
/** Scroll this element's own scrollport. */
|
|
899
1291
|
async scrollBy(options) {
|
|
900
1292
|
await this.withTarget(
|
|
901
|
-
(target) => this.window.call("scroll", { ...
|
|
1293
|
+
(target) => this.window.call("scroll", { ...this.scoped(target), ...options })
|
|
902
1294
|
);
|
|
903
1295
|
}
|
|
904
1296
|
async boundingBox() {
|
|
905
1297
|
return this.withTarget(async (target) => {
|
|
906
|
-
const value = expectRecord(await this.window.call("boundingBox",
|
|
1298
|
+
const value = expectRecord(await this.window.call("boundingBox", this.scoped(target)), "boundingBox result");
|
|
907
1299
|
return {
|
|
908
1300
|
x: expectNumber(value.x, "boundingBox.x"),
|
|
909
1301
|
y: expectNumber(value.y, "boundingBox.y"),
|
|
@@ -914,7 +1306,7 @@ var HasgardLocator = class _HasgardLocator {
|
|
|
914
1306
|
}
|
|
915
1307
|
async isDisabled() {
|
|
916
1308
|
return this.withTarget(async (target) => {
|
|
917
|
-
const value = expectRecord(await this.window.call("disabled",
|
|
1309
|
+
const value = expectRecord(await this.window.call("disabled", this.scoped(target)), "disabled result");
|
|
918
1310
|
return expectBoolean(value.disabled, "disabled.disabled");
|
|
919
1311
|
});
|
|
920
1312
|
}
|
|
@@ -923,17 +1315,17 @@ var HasgardLocator = class _HasgardLocator {
|
|
|
923
1315
|
}
|
|
924
1316
|
async textContent() {
|
|
925
1317
|
return this.withTarget(
|
|
926
|
-
async (target) => expectString(await this.window.call("text",
|
|
1318
|
+
async (target) => expectString(await this.window.call("text", this.scoped(target)), "text result")
|
|
927
1319
|
);
|
|
928
1320
|
}
|
|
929
1321
|
async inputValue() {
|
|
930
1322
|
return this.withTarget(
|
|
931
|
-
async (target) => expectString(await this.window.call("value",
|
|
1323
|
+
async (target) => expectString(await this.window.call("value", this.scoped(target)), "value result")
|
|
932
1324
|
);
|
|
933
1325
|
}
|
|
934
1326
|
async innerHTML() {
|
|
935
1327
|
return this.withTarget(
|
|
936
|
-
async (target) => expectString(await this.window.call("html",
|
|
1328
|
+
async (target) => expectString(await this.window.call("html", this.scoped(target)), "html result")
|
|
937
1329
|
);
|
|
938
1330
|
}
|
|
939
1331
|
async getAttribute(name) {
|
|
@@ -957,41 +1349,46 @@ var HasgardLocator = class _HasgardLocator {
|
|
|
957
1349
|
/** Drag this element by a pixel offset from its centre. */
|
|
958
1350
|
async dragBy(offset) {
|
|
959
1351
|
await this.withTarget(
|
|
960
|
-
(target) => this.window.call("drag", { source:
|
|
1352
|
+
(target) => this.window.call("drag", { source: this.scoped(target), offset: { x: offset.x, y: offset.y } })
|
|
961
1353
|
);
|
|
962
1354
|
}
|
|
963
1355
|
/** Dispatch a file drop onto this element. `data` is base64-encoded. */
|
|
964
1356
|
async dropFiles(files) {
|
|
965
1357
|
await this.withTarget(
|
|
966
1358
|
(target) => this.window.call("drop", {
|
|
967
|
-
...
|
|
1359
|
+
...this.scoped(target),
|
|
968
1360
|
files
|
|
969
1361
|
})
|
|
970
1362
|
);
|
|
971
1363
|
}
|
|
972
1364
|
async attributes() {
|
|
973
1365
|
return this.withTarget(async (target) => {
|
|
974
|
-
const value = expectRecord(await this.window.call("attrs",
|
|
1366
|
+
const value = expectRecord(await this.window.call("attrs", this.scoped(target)), "attrs result");
|
|
975
1367
|
return Object.fromEntries(
|
|
976
1368
|
Object.entries(value).map(([name, attr]) => [name, expectString(attr, `attribute ${name}`)])
|
|
977
1369
|
);
|
|
978
1370
|
});
|
|
979
1371
|
}
|
|
980
1372
|
async isVisible() {
|
|
981
|
-
return this.
|
|
982
|
-
const
|
|
1373
|
+
return this.window.runExclusive(async () => {
|
|
1374
|
+
const target = await this.resolveUnique(true);
|
|
1375
|
+
if (target === null) return false;
|
|
1376
|
+
const value = expectRecord(await this.window.call("visible", this.scoped(target)), "visible result");
|
|
983
1377
|
return expectBoolean(value.visible, "visible.visible");
|
|
984
1378
|
});
|
|
985
1379
|
}
|
|
986
1380
|
async isChecked() {
|
|
987
1381
|
return this.withTarget(async (target) => {
|
|
988
|
-
const value = expectRecord(await this.window.call("checked",
|
|
1382
|
+
const value = expectRecord(await this.window.call("checked", this.scoped(target)), "checked result");
|
|
989
1383
|
return expectBoolean(value.checked, "checked.checked");
|
|
990
1384
|
});
|
|
991
1385
|
}
|
|
992
1386
|
async count() {
|
|
993
1387
|
const total = this.query.kind === "selector" && !this.query.filters?.length ? expectNumber(
|
|
994
|
-
expectRecord(
|
|
1388
|
+
expectRecord(
|
|
1389
|
+
await this.window.call("count", { selector: this.query.selector, ...this.frameParams() }),
|
|
1390
|
+
"count result"
|
|
1391
|
+
).count,
|
|
995
1392
|
"count.count"
|
|
996
1393
|
) : (await this.resolveAll()).length;
|
|
997
1394
|
if (this.query.index === void 0) return total;
|
|
@@ -1003,7 +1400,8 @@ var HasgardLocator = class _HasgardLocator {
|
|
|
1003
1400
|
await this.window.call("wait", {
|
|
1004
1401
|
selector: this.query.selector,
|
|
1005
1402
|
gone: options.state === "detached",
|
|
1006
|
-
timeout: options.timeoutMs
|
|
1403
|
+
timeout: options.timeoutMs,
|
|
1404
|
+
...this.frameParams()
|
|
1007
1405
|
});
|
|
1008
1406
|
return;
|
|
1009
1407
|
}
|
|
@@ -1046,7 +1444,7 @@ var HasgardLocator = class _HasgardLocator {
|
|
|
1046
1444
|
last() {
|
|
1047
1445
|
return this.nth(-1);
|
|
1048
1446
|
}
|
|
1049
|
-
async resolveUnique() {
|
|
1447
|
+
async resolveUnique(allowMissing = false) {
|
|
1050
1448
|
if (this.query.kind === "selector" && !this.query.filters?.length) {
|
|
1051
1449
|
return this.query.index === void 0 ? bySelector(this.query.selector) : { selector: this.query.selector, index: this.query.index };
|
|
1052
1450
|
}
|
|
@@ -1054,15 +1452,30 @@ var HasgardLocator = class _HasgardLocator {
|
|
|
1054
1452
|
if (this.query.index !== void 0) {
|
|
1055
1453
|
const element = elements[absoluteIndex(this.query.index, elements.length)];
|
|
1056
1454
|
if (!element) {
|
|
1455
|
+
if (allowMissing) return null;
|
|
1057
1456
|
throw new Error(`Locator has no element at index ${this.query.index} (${elements.length} matched)`);
|
|
1058
1457
|
}
|
|
1059
1458
|
return byRef(element.ref);
|
|
1060
1459
|
}
|
|
1460
|
+
if (elements.length === 0 && allowMissing) return null;
|
|
1061
1461
|
if (elements.length !== 1) {
|
|
1062
1462
|
throw new Error(`Locator resolved to ${elements.length} elements; expected exactly one`);
|
|
1063
1463
|
}
|
|
1064
1464
|
return byRef(elements[0].ref);
|
|
1065
1465
|
}
|
|
1466
|
+
/**
|
|
1467
|
+
* Serialize a target together with the frame chain this locator lives in.
|
|
1468
|
+
*
|
|
1469
|
+
* A ref carries its own document, so the bridge ignores `frame` for one; the
|
|
1470
|
+
* chain still travels so that a selector or coordinate target -- which the
|
|
1471
|
+
* bridge must resolve itself -- lands in the right document.
|
|
1472
|
+
*/
|
|
1473
|
+
scoped(target) {
|
|
1474
|
+
return { ...targetParams(target), ...this.frameParams() };
|
|
1475
|
+
}
|
|
1476
|
+
frameParams() {
|
|
1477
|
+
return this.query.frame ? { frame: [...this.query.frame] } : {};
|
|
1478
|
+
}
|
|
1066
1479
|
async withTarget(operation) {
|
|
1067
1480
|
return this.window.runExclusive(async () => operation(await this.resolveUnique()));
|
|
1068
1481
|
}
|
|
@@ -1079,7 +1492,7 @@ var HasgardLocator = class _HasgardLocator {
|
|
|
1079
1492
|
return this.runQuery(params);
|
|
1080
1493
|
}
|
|
1081
1494
|
const query = this.query;
|
|
1082
|
-
const snapshot = await this.window.snapshot();
|
|
1495
|
+
const snapshot = await this.window.snapshot(query.frame ? { frame: [...query.frame] } : {});
|
|
1083
1496
|
return snapshot.elements.filter((element) => {
|
|
1084
1497
|
if (element.role !== query.role) return false;
|
|
1085
1498
|
const expected = query.query.name;
|
|
@@ -1110,7 +1523,7 @@ var HasgardLocator = class _HasgardLocator {
|
|
|
1110
1523
|
return current;
|
|
1111
1524
|
}
|
|
1112
1525
|
async runQuery(params) {
|
|
1113
|
-
return this.parseElements(await this.window.call("query", params), "query");
|
|
1526
|
+
return this.parseElements(await this.window.call("query", { ...params, ...this.frameParams() }), "query");
|
|
1114
1527
|
}
|
|
1115
1528
|
parseElements(raw, source) {
|
|
1116
1529
|
const value = expectRecord(raw, `${source} result`);
|
|
@@ -1169,6 +1582,7 @@ function createHasgardTest(config) {
|
|
|
1169
1582
|
const expect = createHasgardExpect(config.expect);
|
|
1170
1583
|
const readinessTimeoutMs = config.launch ? config.launch.timeoutMs : 3e4;
|
|
1171
1584
|
const fixtureTimeoutMs = config.launch ? config.launch.timeoutMs + 3e4 : 3e4;
|
|
1585
|
+
const processes = /* @__PURE__ */ new WeakMap();
|
|
1172
1586
|
const test = base.extend({
|
|
1173
1587
|
hasgard: [
|
|
1174
1588
|
async ({}, use, workerInfo) => {
|
|
@@ -1180,6 +1594,7 @@ function createHasgardTest(config) {
|
|
|
1180
1594
|
else await process2.waitForSocket(socketPath, workerInfo.project.timeout);
|
|
1181
1595
|
await rpc.connect(workerInfo.project.timeout);
|
|
1182
1596
|
const hasgard = new HasgardApplication(rpc);
|
|
1597
|
+
processes.set(hasgard, process2);
|
|
1183
1598
|
const ping = await hasgard.ping();
|
|
1184
1599
|
if (ping.status !== "ok") throw new Error(`Unexpected Hasgard ping status: ${ping.status}`);
|
|
1185
1600
|
await hasgard.waitForWindowReady(config.windowLabel, config.readySelector, readinessTimeoutMs);
|
|
@@ -1194,11 +1609,21 @@ function createHasgardTest(config) {
|
|
|
1194
1609
|
window: async ({ hasgard }, use, testInfo) => {
|
|
1195
1610
|
await use(hasgard.window(config.windowLabel));
|
|
1196
1611
|
if (testInfo.status !== testInfo.expectedStatus) {
|
|
1197
|
-
const
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1612
|
+
const managed = processes.get(hasgard);
|
|
1613
|
+
if (managed && config.launch) {
|
|
1614
|
+
const diagnostics = await managed.diagnostics(250);
|
|
1615
|
+
await testInfo.attach("hasgard-process", {
|
|
1616
|
+
body: JSON.stringify(diagnostics, null, 2),
|
|
1617
|
+
contentType: "application/json"
|
|
1618
|
+
});
|
|
1619
|
+
if (diagnostics.exit) console.error(`Hasgard application exited: ${JSON.stringify(diagnostics)}`);
|
|
1620
|
+
}
|
|
1621
|
+
try {
|
|
1622
|
+
const screenshot = await hasgard.window(config.windowLabel).screenshot();
|
|
1623
|
+
await testInfo.attach("hasgard-screenshot", { body: screenshot, contentType: "image/png" });
|
|
1624
|
+
} catch (error) {
|
|
1625
|
+
await testInfo.attach("hasgard-screenshot-error", { body: String(error), contentType: "text/plain" });
|
|
1626
|
+
}
|
|
1202
1627
|
}
|
|
1203
1628
|
}
|
|
1204
1629
|
});
|
|
@@ -1206,6 +1631,7 @@ function createHasgardTest(config) {
|
|
|
1206
1631
|
}
|
|
1207
1632
|
export {
|
|
1208
1633
|
HasgardApplication,
|
|
1634
|
+
HasgardFrameLocator,
|
|
1209
1635
|
HasgardLocator,
|
|
1210
1636
|
HasgardProcess,
|
|
1211
1637
|
HasgardRpcClient,
|