@nyssance/tauri-hasgard 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +3 -3
- package/dist/index.js +822 -36
- package/dist/types.d.ts +278 -0
- package/dist/window.d.ts +257 -3
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
// src/endpoint.ts
|
|
2
|
+
import { tmpdir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
function endpointPathForPlatform(name, platform, temporaryDirectory) {
|
|
5
|
+
if (platform === "win32") return `\\\\.\\pipe\\${name}`;
|
|
6
|
+
return join(temporaryDirectory, `${name}.sock`);
|
|
7
|
+
}
|
|
8
|
+
function hasgardEndpointPath(name) {
|
|
9
|
+
return endpointPathForPlatform(name, process.platform, tmpdir());
|
|
10
|
+
}
|
|
11
|
+
|
|
1
12
|
// src/expect.ts
|
|
2
13
|
async function poll(read, matches, timeoutMs) {
|
|
3
14
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -49,17 +60,6 @@ function createHasgardExpect(playwrightExpect) {
|
|
|
49
60
|
});
|
|
50
61
|
}
|
|
51
62
|
|
|
52
|
-
// src/endpoint.ts
|
|
53
|
-
import { tmpdir } from "os";
|
|
54
|
-
import { join } from "path";
|
|
55
|
-
function endpointPathForPlatform(name, platform, temporaryDirectory) {
|
|
56
|
-
if (platform === "win32") return `\\\\.\\pipe\\${name}`;
|
|
57
|
-
return join(temporaryDirectory, `${name}.sock`);
|
|
58
|
-
}
|
|
59
|
-
function hasgardEndpointPath(name) {
|
|
60
|
-
return endpointPathForPlatform(name, process.platform, tmpdir());
|
|
61
|
-
}
|
|
62
|
-
|
|
63
63
|
// src/process-manager.ts
|
|
64
64
|
import { spawn } from "child_process";
|
|
65
65
|
import { access, rm } from "fs/promises";
|
|
@@ -270,6 +270,8 @@ var HasgardRpcClient = class {
|
|
|
270
270
|
};
|
|
271
271
|
|
|
272
272
|
// src/window.ts
|
|
273
|
+
import { readFile } from "fs/promises";
|
|
274
|
+
import { basename } from "path";
|
|
273
275
|
var locks = /* @__PURE__ */ new WeakMap();
|
|
274
276
|
function lockFor(rpc, label) {
|
|
275
277
|
let clientLocks = locks.get(rpc);
|
|
@@ -290,11 +292,78 @@ var byPoint = (x, y) => ({ x, y });
|
|
|
290
292
|
function withWindow(window, params) {
|
|
291
293
|
return params ? { ...params, window } : { window };
|
|
292
294
|
}
|
|
295
|
+
async function readFileInputs(files) {
|
|
296
|
+
const list = Array.isArray(files) ? files : [files];
|
|
297
|
+
return Promise.all(
|
|
298
|
+
list.map(async (entry) => {
|
|
299
|
+
if (typeof entry !== "string") return entry;
|
|
300
|
+
const contents = await readFile(entry);
|
|
301
|
+
return { name: basename(entry), data: contents.toString("base64") };
|
|
302
|
+
})
|
|
303
|
+
);
|
|
304
|
+
}
|
|
293
305
|
function targetParams(target) {
|
|
294
306
|
if ("ref" in target) return { ref: target.ref };
|
|
295
|
-
if ("selector" in target)
|
|
307
|
+
if ("selector" in target) {
|
|
308
|
+
return target.index === void 0 ? { selector: target.selector } : { selector: target.selector, index: target.index };
|
|
309
|
+
}
|
|
296
310
|
return { x: target.x, y: target.y };
|
|
297
311
|
}
|
|
312
|
+
function clickParams(options) {
|
|
313
|
+
const params = {};
|
|
314
|
+
if (options.modifiers !== void 0) params.modifiers = options.modifiers;
|
|
315
|
+
if (options.button !== void 0) params.button = options.button;
|
|
316
|
+
if (options.clickCount !== void 0) params.clickCount = options.clickCount;
|
|
317
|
+
if (options.position !== void 0) params.position = { ...options.position };
|
|
318
|
+
return params;
|
|
319
|
+
}
|
|
320
|
+
function routeParams(options) {
|
|
321
|
+
const params = { pattern: options.pattern };
|
|
322
|
+
if (options.method !== void 0) params.method = options.method;
|
|
323
|
+
if (options.times !== void 0) params.times = options.times;
|
|
324
|
+
if ("status" in options && options.status !== void 0) params.status = options.status;
|
|
325
|
+
if ("body" in options && options.body !== void 0) params.body = options.body;
|
|
326
|
+
if ("contentType" in options && options.contentType !== void 0) {
|
|
327
|
+
params.contentType = options.contentType;
|
|
328
|
+
}
|
|
329
|
+
return params;
|
|
330
|
+
}
|
|
331
|
+
function parseRouteAction(value, context) {
|
|
332
|
+
const action = expectString(value, context);
|
|
333
|
+
if (action !== "fulfill" && action !== "abort") {
|
|
334
|
+
throw new Error(`${context} must be "fulfill" or "abort", got ${action}`);
|
|
335
|
+
}
|
|
336
|
+
return action;
|
|
337
|
+
}
|
|
338
|
+
function parseRouteRule(value, index) {
|
|
339
|
+
const context = `route.list.routes[${index}]`;
|
|
340
|
+
const rule = expectRecord(value, context);
|
|
341
|
+
return {
|
|
342
|
+
id: expectNumber(rule.id, `${context}.id`),
|
|
343
|
+
pattern: expectString(rule.pattern, `${context}.pattern`),
|
|
344
|
+
method: expectNullableString(rule.method, `${context}.method`),
|
|
345
|
+
action: parseRouteAction(rule.action, `${context}.action`),
|
|
346
|
+
status: expectNumber(rule.status, `${context}.status`),
|
|
347
|
+
times: rule.times === null ? null : expectNumber(rule.times, `${context}.times`),
|
|
348
|
+
used: expectNumber(rule.used, `${context}.used`)
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
function parseInterceptedRequest(value, index) {
|
|
352
|
+
const context = `route.list.intercepted[${index}]`;
|
|
353
|
+
const entry = expectRecord(value, context);
|
|
354
|
+
return {
|
|
355
|
+
id: expectNumber(entry.id, `${context}.id`),
|
|
356
|
+
timestamp: expectNumber(entry.timestamp, `${context}.timestamp`),
|
|
357
|
+
route_id: expectNumber(entry.route_id, `${context}.route_id`),
|
|
358
|
+
method: expectString(entry.method, `${context}.method`),
|
|
359
|
+
url: expectString(entry.url, `${context}.url`),
|
|
360
|
+
action: parseRouteAction(entry.action, `${context}.action`),
|
|
361
|
+
status: expectNumber(entry.status, `${context}.status`)
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
function absoluteIndex(index, total) {
|
|
365
|
+
return index < 0 ? total + index : index;
|
|
366
|
+
}
|
|
298
367
|
function expectRecord(value, context) {
|
|
299
368
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
300
369
|
throw new Error(`${context} must be an object`);
|
|
@@ -313,6 +382,106 @@ function expectNumber(value, context) {
|
|
|
313
382
|
if (typeof value !== "number") throw new Error(`${context} must be a number`);
|
|
314
383
|
return value;
|
|
315
384
|
}
|
|
385
|
+
function expectArray(value, context) {
|
|
386
|
+
if (!Array.isArray(value)) throw new Error(`${context} must be an array`);
|
|
387
|
+
return value;
|
|
388
|
+
}
|
|
389
|
+
function expectNullableString(value, context) {
|
|
390
|
+
if (value === null) return null;
|
|
391
|
+
return expectString(value, context);
|
|
392
|
+
}
|
|
393
|
+
function optionalString(value, context) {
|
|
394
|
+
return value === void 0 ? void 0 : expectString(value, context);
|
|
395
|
+
}
|
|
396
|
+
function parseConsoleLogEntry(value, index) {
|
|
397
|
+
const entry = expectRecord(value, `console.getLogs[${index}]`);
|
|
398
|
+
return {
|
|
399
|
+
id: expectNumber(entry.id, `console.getLogs[${index}].id`),
|
|
400
|
+
timestamp: expectNumber(entry.timestamp, `console.getLogs[${index}].timestamp`),
|
|
401
|
+
level: expectString(entry.level, `console.getLogs[${index}].level`),
|
|
402
|
+
args: expectArray(entry.args, `console.getLogs[${index}].args`),
|
|
403
|
+
source: expectNullableString(entry.source, `console.getLogs[${index}].source`)
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
function parseNetworkRequestEntry(value, index) {
|
|
407
|
+
const entry = expectRecord(value, `network.getRequests[${index}]`);
|
|
408
|
+
return {
|
|
409
|
+
id: expectNumber(entry.id, `network.getRequests[${index}].id`),
|
|
410
|
+
timestamp: expectNumber(entry.timestamp, `network.getRequests[${index}].timestamp`),
|
|
411
|
+
method: expectString(entry.method, `network.getRequests[${index}].method`),
|
|
412
|
+
url: expectString(entry.url, `network.getRequests[${index}].url`),
|
|
413
|
+
status: expectNumber(entry.status, `network.getRequests[${index}].status`),
|
|
414
|
+
duration_ms: expectNumber(entry.duration_ms, `network.getRequests[${index}].duration_ms`),
|
|
415
|
+
error: expectNullableString(entry.error, `network.getRequests[${index}].error`),
|
|
416
|
+
request_size: expectNumber(entry.request_size, `network.getRequests[${index}].request_size`),
|
|
417
|
+
response_size: expectNumber(entry.response_size, `network.getRequests[${index}].response_size`)
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
function parseFormField(value, context) {
|
|
421
|
+
const field = expectRecord(value, context);
|
|
422
|
+
const raw = field.value;
|
|
423
|
+
const parsed = {
|
|
424
|
+
tag: expectString(field.tag, `${context}.tag`),
|
|
425
|
+
type: expectNullableString(field.type, `${context}.type`),
|
|
426
|
+
name: expectString(field.name, `${context}.name`),
|
|
427
|
+
value: Array.isArray(raw) ? raw.map((option, index) => expectString(option, `${context}.value[${index}]`)) : expectString(raw, `${context}.value`)
|
|
428
|
+
};
|
|
429
|
+
if (field.checked !== void 0) parsed.checked = expectBoolean(field.checked, `${context}.checked`);
|
|
430
|
+
return parsed;
|
|
431
|
+
}
|
|
432
|
+
function parseFormEntry(value, index) {
|
|
433
|
+
const context = `forms.dump.forms[${index}]`;
|
|
434
|
+
const form = expectRecord(value, context);
|
|
435
|
+
const parsed = {
|
|
436
|
+
id: expectString(form.id, `${context}.id`),
|
|
437
|
+
name: expectString(form.name, `${context}.name`),
|
|
438
|
+
action: expectString(form.action, `${context}.action`),
|
|
439
|
+
method: expectString(form.method, `${context}.method`),
|
|
440
|
+
fields: expectArray(form.fields, `${context}.fields`).map(
|
|
441
|
+
(field, fieldIndex) => parseFormField(field, `${context}.fields[${fieldIndex}]`)
|
|
442
|
+
)
|
|
443
|
+
};
|
|
444
|
+
if (form.fieldsTruncated !== void 0) {
|
|
445
|
+
parsed.fieldsTruncated = expectBoolean(form.fieldsTruncated, `${context}.fieldsTruncated`);
|
|
446
|
+
}
|
|
447
|
+
return parsed;
|
|
448
|
+
}
|
|
449
|
+
function parseWatchNode(value, context) {
|
|
450
|
+
const node = expectRecord(value, context);
|
|
451
|
+
const parsed = { tag: expectString(node.tag, `${context}.tag`) };
|
|
452
|
+
const id = optionalString(node.id, `${context}.id`);
|
|
453
|
+
if (id !== void 0) parsed.id = id;
|
|
454
|
+
const className = optionalString(node.class, `${context}.class`);
|
|
455
|
+
if (className !== void 0) parsed.class = className;
|
|
456
|
+
const text = optionalString(node.text, `${context}.text`);
|
|
457
|
+
if (text !== void 0) parsed.text = text;
|
|
458
|
+
const attr = optionalString(node.attr, `${context}.attr`);
|
|
459
|
+
if (attr !== void 0) parsed.attr = attr;
|
|
460
|
+
const attrValue = optionalString(node.value, `${context}.value`);
|
|
461
|
+
if (attrValue !== void 0) parsed.value = attrValue;
|
|
462
|
+
return parsed;
|
|
463
|
+
}
|
|
464
|
+
function parseWatchNodes(value, context) {
|
|
465
|
+
return expectArray(value, context).map((node, index) => parseWatchNode(node, `${context}[${index}]`));
|
|
466
|
+
}
|
|
467
|
+
function parseDiffChange(value, index) {
|
|
468
|
+
const change = expectRecord(value, `diff.changed[${index}]`);
|
|
469
|
+
return {
|
|
470
|
+
old: parseSnapshotElement(change.old, index),
|
|
471
|
+
new: parseSnapshotElement(change.new, index),
|
|
472
|
+
changes: expectArray(change.changes, `diff.changed[${index}].changes`).map(
|
|
473
|
+
(entry, entryIndex) => expectString(entry, `diff.changed[${index}].changes[${entryIndex}]`)
|
|
474
|
+
)
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
function parseRecorderEntry(value, index) {
|
|
478
|
+
const entry = expectRecord(value, `record.stop.entries[${index}]`);
|
|
479
|
+
return {
|
|
480
|
+
...entry,
|
|
481
|
+
action: expectString(entry.action, `record.stop.entries[${index}].action`),
|
|
482
|
+
timestamp: expectNumber(entry.timestamp, `record.stop.entries[${index}].timestamp`)
|
|
483
|
+
};
|
|
484
|
+
}
|
|
316
485
|
var HasgardApplication = class {
|
|
317
486
|
rpc;
|
|
318
487
|
constructor(rpc) {
|
|
@@ -330,11 +499,15 @@ var HasgardApplication = class {
|
|
|
330
499
|
if (!Array.isArray(value.windows)) throw new Error("windows.list.windows must be an array");
|
|
331
500
|
return value.windows.map((entry, index) => {
|
|
332
501
|
const window = expectRecord(entry, `windows[${index}]`);
|
|
333
|
-
|
|
502
|
+
const info = {
|
|
334
503
|
label: expectString(window.label, `windows[${index}].label`),
|
|
335
504
|
url: expectString(window.url, `windows[${index}].url`),
|
|
336
505
|
title: expectString(window.title, `windows[${index}].title`)
|
|
337
506
|
};
|
|
507
|
+
if (window.native_id !== void 0) {
|
|
508
|
+
info.nativeId = expectNumber(window.native_id, `windows[${index}].native_id`);
|
|
509
|
+
}
|
|
510
|
+
return info;
|
|
338
511
|
});
|
|
339
512
|
}
|
|
340
513
|
async waitForWindow(label, timeoutMs) {
|
|
@@ -363,6 +536,33 @@ var HasgardApplication = class {
|
|
|
363
536
|
window(label) {
|
|
364
537
|
return new HasgardWindow(this.rpc, label);
|
|
365
538
|
}
|
|
539
|
+
/**
|
|
540
|
+
* Action recorder. Captures the RPC calls this connection makes into a
|
|
541
|
+
* replayable scenario — this is not video capture.
|
|
542
|
+
*/
|
|
543
|
+
recorder = {
|
|
544
|
+
start: async () => {
|
|
545
|
+
await this.rpc.call("record.start");
|
|
546
|
+
},
|
|
547
|
+
stop: async () => {
|
|
548
|
+
const value = expectRecord(await this.rpc.call("record.stop"), "record.stop result");
|
|
549
|
+
return {
|
|
550
|
+
entries: expectArray(value.entries, "record.stop.entries").map(parseRecorderEntry),
|
|
551
|
+
count: expectNumber(value.count, "record.stop.count")
|
|
552
|
+
};
|
|
553
|
+
},
|
|
554
|
+
status: async () => {
|
|
555
|
+
const value = expectRecord(await this.rpc.call("record.status"), "record.status result");
|
|
556
|
+
return {
|
|
557
|
+
active: expectBoolean(value.active, "record.status.active"),
|
|
558
|
+
count: expectNumber(value.count, "record.status.count"),
|
|
559
|
+
elapsed_ms: expectNumber(value.elapsed_ms, "record.status.elapsed_ms")
|
|
560
|
+
};
|
|
561
|
+
},
|
|
562
|
+
add: async (entry) => {
|
|
563
|
+
await this.rpc.call("record.add", entry);
|
|
564
|
+
}
|
|
565
|
+
};
|
|
366
566
|
};
|
|
367
567
|
var HasgardWindow = class {
|
|
368
568
|
label;
|
|
@@ -377,6 +577,42 @@ var HasgardWindow = class {
|
|
|
377
577
|
getByRole(role, query = {}) {
|
|
378
578
|
return new HasgardLocator(this, { kind: "role", role, query });
|
|
379
579
|
}
|
|
580
|
+
/**
|
|
581
|
+
* Locate by visible text. Matching runs in the page against the live DOM, not
|
|
582
|
+
* against a snapshot's `name`: that field collapses aria-label, alt, `<label>`,
|
|
583
|
+
* placeholder, and text into one 50-character string, so a button with an
|
|
584
|
+
* aria-label would never be findable by the text it actually shows.
|
|
585
|
+
*/
|
|
586
|
+
getByText(text, query = {}) {
|
|
587
|
+
return new HasgardLocator(this, { kind: "query", by: "text", value: text, query });
|
|
588
|
+
}
|
|
589
|
+
getByLabel(text, query = {}) {
|
|
590
|
+
return new HasgardLocator(this, { kind: "query", by: "label", value: text, query });
|
|
591
|
+
}
|
|
592
|
+
getByPlaceholder(text, query = {}) {
|
|
593
|
+
return new HasgardLocator(this, { kind: "query", by: "placeholder", value: text, query });
|
|
594
|
+
}
|
|
595
|
+
getByAltText(text, query = {}) {
|
|
596
|
+
return new HasgardLocator(this, { kind: "query", by: "alt", value: text, query });
|
|
597
|
+
}
|
|
598
|
+
getByTitle(text, query = {}) {
|
|
599
|
+
return new HasgardLocator(this, { kind: "query", by: "title", value: text, query });
|
|
600
|
+
}
|
|
601
|
+
/** Test ids always match exactly, so `save` cannot select `save-draft`. */
|
|
602
|
+
getByTestId(testId) {
|
|
603
|
+
return new HasgardLocator(this, { kind: "query", by: "testid", value: testId, query: {} });
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Scope locators to a same-origin `<iframe>`.
|
|
607
|
+
*
|
|
608
|
+
* Cross-origin frames are out of reach: the same-origin policy binds injected
|
|
609
|
+
* script exactly as it binds the page's own, so no bridge can see into a
|
|
610
|
+
* third-party payment or OAuth frame. Those raise an error naming the origin
|
|
611
|
+
* problem rather than reporting a missing element.
|
|
612
|
+
*/
|
|
613
|
+
frameLocator(selector) {
|
|
614
|
+
return new HasgardFrameLocator(this, [selector]);
|
|
615
|
+
}
|
|
380
616
|
async snapshot(options = {}) {
|
|
381
617
|
const raw = expectRecord(
|
|
382
618
|
await this.rpc.call("snapshot", withWindow(this.label, options)),
|
|
@@ -406,9 +642,127 @@ var HasgardWindow = class {
|
|
|
406
642
|
plugin_version: expectString(value.plugin_version, "state.plugin_version")
|
|
407
643
|
};
|
|
408
644
|
}
|
|
645
|
+
async url() {
|
|
646
|
+
return expectString(await this.rpc.call("url", withWindow(this.label)), "url result");
|
|
647
|
+
}
|
|
648
|
+
async title() {
|
|
649
|
+
return expectString(await this.rpc.call("title", withWindow(this.label)), "title result");
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Navigate this webview by assigning `location.href`. Unlike Playwright's
|
|
653
|
+
* `goto`, this does not wait for the load to finish and returns no response —
|
|
654
|
+
* follow it with `waitFor` or `waitForWindowReady`.
|
|
655
|
+
*/
|
|
656
|
+
async navigate(url) {
|
|
657
|
+
await this.rpc.call("navigate", withWindow(this.label, { url }));
|
|
658
|
+
}
|
|
659
|
+
/** Scroll the document by a relative amount, or to `top`/`bottom`. */
|
|
660
|
+
async scrollBy(options) {
|
|
661
|
+
await this.rpc.call("scroll", withWindow(this.label, options));
|
|
662
|
+
}
|
|
409
663
|
async press(key) {
|
|
410
664
|
await this.rpc.call("press", withWindow(this.label, { key }));
|
|
411
665
|
}
|
|
666
|
+
async diff(options = {}) {
|
|
667
|
+
const { reference, ...snapshotOptions } = options;
|
|
668
|
+
const params = { ...snapshotOptions };
|
|
669
|
+
if (reference) params.reference = reference;
|
|
670
|
+
const value = expectRecord(await this.rpc.call("diff", withWindow(this.label, params)), "diff result");
|
|
671
|
+
return {
|
|
672
|
+
added: expectArray(value.added, "diff.added").map(parseSnapshotElement),
|
|
673
|
+
removed: expectArray(value.removed, "diff.removed").map(parseSnapshotElement),
|
|
674
|
+
changed: expectArray(value.changed, "diff.changed").map(parseDiffChange)
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Poll a JavaScript expression in the webview until it returns a truthy
|
|
679
|
+
* value, and return that value. The expression is polled rather than driven
|
|
680
|
+
* by DOM mutations, so predicates that flip on a timer or a resolved fetch
|
|
681
|
+
* are caught too. A predicate that throws rejects instead of being retried.
|
|
682
|
+
*/
|
|
683
|
+
async waitForFunction(expression, options = {}) {
|
|
684
|
+
const params = { expression };
|
|
685
|
+
if (options.timeoutMs !== void 0) params.timeout = options.timeoutMs;
|
|
686
|
+
if (options.pollMs !== void 0) params.poll = options.pollMs;
|
|
687
|
+
const value = expectRecord(await this.rpc.call("wait", withWindow(this.label, params)), "wait result");
|
|
688
|
+
return value.value;
|
|
689
|
+
}
|
|
690
|
+
/** Observe DOM mutations until they settle, or reject when nothing changes. */
|
|
691
|
+
async watch(options = {}) {
|
|
692
|
+
const params = {};
|
|
693
|
+
if (options.selector !== void 0) params.selector = options.selector;
|
|
694
|
+
if (options.timeoutMs !== void 0) params.timeout = options.timeoutMs;
|
|
695
|
+
if (options.stableMs !== void 0) params.stable = options.stableMs;
|
|
696
|
+
if (options.requireMutation !== void 0) params.requireMutation = options.requireMutation;
|
|
697
|
+
const value = expectRecord(await this.rpc.call("watch", withWindow(this.label, params)), "watch result");
|
|
698
|
+
return {
|
|
699
|
+
added: parseWatchNodes(value.added, "watch.added"),
|
|
700
|
+
removed: parseWatchNodes(value.removed, "watch.removed"),
|
|
701
|
+
modified: parseWatchNodes(value.modified, "watch.modified"),
|
|
702
|
+
truncated: expectBoolean(value.truncated, "watch.truncated")
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
async consoleLogs(options = {}) {
|
|
706
|
+
const value = await this.rpc.call(
|
|
707
|
+
"console.getLogs",
|
|
708
|
+
withWindow(this.label, options)
|
|
709
|
+
);
|
|
710
|
+
return expectArray(value, "console.getLogs result").map(parseConsoleLogEntry);
|
|
711
|
+
}
|
|
712
|
+
async clearConsole() {
|
|
713
|
+
await this.rpc.call("console.clear", withWindow(this.label));
|
|
714
|
+
}
|
|
715
|
+
async networkRequests(options = {}) {
|
|
716
|
+
const value = await this.rpc.call(
|
|
717
|
+
"network.getRequests",
|
|
718
|
+
withWindow(this.label, options)
|
|
719
|
+
);
|
|
720
|
+
return expectArray(value, "network.getRequests result").map(parseNetworkRequestEntry);
|
|
721
|
+
}
|
|
722
|
+
async clearNetwork() {
|
|
723
|
+
await this.rpc.call("network.clear", withWindow(this.label));
|
|
724
|
+
}
|
|
725
|
+
/** `localStorage` by default; pass `{ session: true }` for `sessionStorage`. */
|
|
726
|
+
storage = {
|
|
727
|
+
get: async (key, options = {}) => {
|
|
728
|
+
const value = expectRecord(
|
|
729
|
+
await this.rpc.call("storage.get", withWindow(this.label, { key, ...options })),
|
|
730
|
+
"storage.get result"
|
|
731
|
+
);
|
|
732
|
+
if (!expectBoolean(value.found, "storage.get.found")) return null;
|
|
733
|
+
return expectString(value.value, "storage.get.value");
|
|
734
|
+
},
|
|
735
|
+
set: async (key, value, options = {}) => {
|
|
736
|
+
await this.rpc.call("storage.set", withWindow(this.label, { key, value, ...options }));
|
|
737
|
+
},
|
|
738
|
+
list: async (options = {}) => {
|
|
739
|
+
const value = expectRecord(
|
|
740
|
+
await this.rpc.call("storage.list", withWindow(this.label, { ...options })),
|
|
741
|
+
"storage.list result"
|
|
742
|
+
);
|
|
743
|
+
return {
|
|
744
|
+
entries: expectArray(value.entries, "storage.list.entries").map((entry, index) => {
|
|
745
|
+
const parsed = expectRecord(entry, `storage.list.entries[${index}]`);
|
|
746
|
+
return {
|
|
747
|
+
key: expectString(parsed.key, `storage.list.entries[${index}].key`),
|
|
748
|
+
value: expectString(parsed.value, `storage.list.entries[${index}].value`)
|
|
749
|
+
};
|
|
750
|
+
}),
|
|
751
|
+
truncated: expectBoolean(value.truncated, "storage.list.truncated")
|
|
752
|
+
};
|
|
753
|
+
},
|
|
754
|
+
clear: async (options = {}) => {
|
|
755
|
+
await this.rpc.call("storage.clear", withWindow(this.label, { ...options }));
|
|
756
|
+
}
|
|
757
|
+
};
|
|
758
|
+
async forms(selector) {
|
|
759
|
+
const params = selector ? { selector } : void 0;
|
|
760
|
+
const value = expectRecord(await this.rpc.call("forms.dump", withWindow(this.label, params)), "forms.dump result");
|
|
761
|
+
return {
|
|
762
|
+
forms: expectArray(value.forms, "forms.dump.forms").map(parseFormEntry),
|
|
763
|
+
truncated: expectBoolean(value.truncated, "forms.dump.truncated")
|
|
764
|
+
};
|
|
765
|
+
}
|
|
412
766
|
async evaluate(script) {
|
|
413
767
|
return this.rpc.call("eval", withWindow(this.label, { script }));
|
|
414
768
|
}
|
|
@@ -422,6 +776,137 @@ var HasgardWindow = class {
|
|
|
422
776
|
if (!dataUrl.startsWith(prefix)) throw new Error("screenshot result is not a PNG data URL");
|
|
423
777
|
return Buffer.from(dataUrl.slice(prefix.length), "base64");
|
|
424
778
|
}
|
|
779
|
+
/**
|
|
780
|
+
* Capture this window through the native compositor, writing a PNG to
|
|
781
|
+
* `outputPath`. macOS only.
|
|
782
|
+
*
|
|
783
|
+
* Unlike `screenshot`, which renders the DOM inside the page, this records
|
|
784
|
+
* what the window server actually shows — so it sees native chrome, layered
|
|
785
|
+
* content, and anything the DOM renderer cannot reproduce.
|
|
786
|
+
*
|
|
787
|
+
* The operating-system window id is resolved from this window's label, so
|
|
788
|
+
* callers do not have to discover it themselves.
|
|
789
|
+
*/
|
|
790
|
+
async screenshotNative(options) {
|
|
791
|
+
const windowId = options.windowId ?? await this.nativeId();
|
|
792
|
+
const value = expectRecord(
|
|
793
|
+
await this.rpc.call("screenshot_native", {
|
|
794
|
+
window_id: windowId,
|
|
795
|
+
output_path: options.outputPath
|
|
796
|
+
}),
|
|
797
|
+
"screenshot_native result"
|
|
798
|
+
);
|
|
799
|
+
return {
|
|
800
|
+
output_path: expectString(value.output_path, "screenshot_native.output_path"),
|
|
801
|
+
window_id: expectNumber(value.window_id, "screenshot_native.window_id"),
|
|
802
|
+
width: expectNumber(value.width, "screenshot_native.width"),
|
|
803
|
+
height: expectNumber(value.height, "screenshot_native.height"),
|
|
804
|
+
scale_factor: expectNumber(value.scale_factor, "screenshot_native.scale_factor"),
|
|
805
|
+
byte_size: expectNumber(value.byte_size, "screenshot_native.byte_size"),
|
|
806
|
+
backend: expectString(value.backend, "screenshot_native.backend"),
|
|
807
|
+
tcc_denied: expectBoolean(value.tcc_denied, "screenshot_native.tcc_denied")
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* Scroll the page by wheel.
|
|
812
|
+
*
|
|
813
|
+
* The document sees a real `wheel` event first and may cancel it; the scroll
|
|
814
|
+
* only follows if it does not. Resolves to whether the page cancelled — which
|
|
815
|
+
* is how you tell "the app intercepted my wheel" from "nothing was
|
|
816
|
+
* listening". Use the locator method to scroll a nested scrollport.
|
|
817
|
+
*/
|
|
818
|
+
async wheel(deltaX, deltaY) {
|
|
819
|
+
const value = expectRecord(await this.call("wheel", { deltaX, deltaY }), "wheel result");
|
|
820
|
+
return expectBoolean(value.defaultPrevented, "wheel.defaultPrevented");
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* The page's modal dialogs (`alert`, `confirm`, `prompt`).
|
|
824
|
+
*
|
|
825
|
+
* These block the webview until answered, so the bridge always answers them —
|
|
826
|
+
* dismissing by default, as Playwright does. A test that never touches this
|
|
827
|
+
* namespace still cannot hang on a `confirm()`, and can read back what the
|
|
828
|
+
* app tried to ask.
|
|
829
|
+
*/
|
|
830
|
+
dialogs = {
|
|
831
|
+
/** Answer every subsequent dialog with OK. `promptText` fills a `prompt`. */
|
|
832
|
+
accept: async (promptText) => {
|
|
833
|
+
const params = { action: "accept" };
|
|
834
|
+
if (promptText !== void 0) params.promptText = promptText;
|
|
835
|
+
return parseDialogPolicy(await this.call("dialog.handle", params));
|
|
836
|
+
},
|
|
837
|
+
/** Answer every subsequent dialog with Cancel. This is the default. */
|
|
838
|
+
dismiss: async () => parseDialogPolicy(await this.call("dialog.handle", { action: "dismiss" })),
|
|
839
|
+
/** Every dialog the page has opened so far, oldest first. */
|
|
840
|
+
list: async () => {
|
|
841
|
+
const value = expectRecord(await this.call("dialog.list"), "dialog.list result");
|
|
842
|
+
if (!Array.isArray(value.dialogs)) throw new Error("dialog.list.dialogs must be an array");
|
|
843
|
+
return {
|
|
844
|
+
dialogs: value.dialogs.map((entry, index) => parseDialogRecord(entry, index)),
|
|
845
|
+
policy: parseDialogPolicy(value.policy)
|
|
846
|
+
};
|
|
847
|
+
},
|
|
848
|
+
/** Forget the recorded dialogs. Leaves the accept/dismiss policy alone. */
|
|
849
|
+
clear: async () => {
|
|
850
|
+
await this.call("dialog.clear");
|
|
851
|
+
}
|
|
852
|
+
};
|
|
853
|
+
/**
|
|
854
|
+
* Shape what the page's own requests receive, without a real backend.
|
|
855
|
+
*
|
|
856
|
+
* Rules are declarative rather than Playwright's per-request callback: the
|
|
857
|
+
* bridge only ever answers requests, so it cannot call back into this process
|
|
858
|
+
* and await a handler while the page sits inside `fetch`. The same constraint
|
|
859
|
+
* shaped `dialogs`.
|
|
860
|
+
*
|
|
861
|
+
* Rules are checked in registration order and the first match wins, so
|
|
862
|
+
* register the narrow ones before a catch-all. Both `fetch` and
|
|
863
|
+
* `XMLHttpRequest` are covered; resources the webview loads itself -- the
|
|
864
|
+
* document, `<img src>`, stylesheets -- are not, since no page script sees
|
|
865
|
+
* them.
|
|
866
|
+
*/
|
|
867
|
+
routes = {
|
|
868
|
+
/** Answer matching requests with this response instead of the network. */
|
|
869
|
+
fulfill: async (options) => {
|
|
870
|
+
const value = expectRecord(
|
|
871
|
+
await this.call("route.add", { ...routeParams(options), action: "fulfill" }),
|
|
872
|
+
"route.add result"
|
|
873
|
+
);
|
|
874
|
+
return { id: expectNumber(value.id, "route.add.id") };
|
|
875
|
+
},
|
|
876
|
+
/** Fail matching requests the way a dropped connection does. */
|
|
877
|
+
abort: async (options) => {
|
|
878
|
+
const value = expectRecord(
|
|
879
|
+
await this.call("route.add", { ...routeParams(options), action: "abort" }),
|
|
880
|
+
"route.add result"
|
|
881
|
+
);
|
|
882
|
+
return { id: expectNumber(value.id, "route.add.id") };
|
|
883
|
+
},
|
|
884
|
+
/** The active rules, their use counts, and every request they answered. */
|
|
885
|
+
list: async () => {
|
|
886
|
+
const value = expectRecord(await this.call("route.list"), "route.list result");
|
|
887
|
+
return {
|
|
888
|
+
routes: expectArray(value.routes, "route.list.routes").map((entry, index) => parseRouteRule(entry, index)),
|
|
889
|
+
intercepted: expectArray(value.intercepted, "route.list.intercepted").map(
|
|
890
|
+
(entry, index) => parseInterceptedRequest(entry, index)
|
|
891
|
+
)
|
|
892
|
+
};
|
|
893
|
+
},
|
|
894
|
+
/** Remove every rule and its interception log. */
|
|
895
|
+
clear: async () => {
|
|
896
|
+
const value = expectRecord(await this.call("route.clear"), "route.clear result");
|
|
897
|
+
return { removed: expectNumber(value.removed, "route.clear.removed") };
|
|
898
|
+
}
|
|
899
|
+
};
|
|
900
|
+
/** This window's operating-system window id, where the platform exposes one. */
|
|
901
|
+
async nativeId() {
|
|
902
|
+
const listing = await new HasgardApplication(this.rpc).windows();
|
|
903
|
+
const self = listing.find((entry) => entry.label === this.label);
|
|
904
|
+
if (!self) throw new Error(`Window '${this.label}' is no longer open`);
|
|
905
|
+
if (self.nativeId === void 0) {
|
|
906
|
+
throw new Error(`Window '${this.label}' has no native window id on this platform`);
|
|
907
|
+
}
|
|
908
|
+
return self.nativeId;
|
|
909
|
+
}
|
|
425
910
|
async call(method, params) {
|
|
426
911
|
return this.rpc.call(method, withWindow(this.label, params));
|
|
427
912
|
}
|
|
@@ -441,41 +926,214 @@ var HasgardWindow = class {
|
|
|
441
926
|
}
|
|
442
927
|
}
|
|
443
928
|
};
|
|
444
|
-
var
|
|
929
|
+
var HasgardFrameLocator = class _HasgardFrameLocator {
|
|
930
|
+
windowRef;
|
|
931
|
+
frame;
|
|
932
|
+
constructor(window, frame) {
|
|
933
|
+
this.windowRef = window;
|
|
934
|
+
this.frame = frame;
|
|
935
|
+
}
|
|
936
|
+
/** Descend into a frame nested inside this one. */
|
|
937
|
+
frameLocator(selector) {
|
|
938
|
+
return new _HasgardFrameLocator(this.windowRef, [...this.frame, selector]);
|
|
939
|
+
}
|
|
940
|
+
locator(selector) {
|
|
941
|
+
return new HasgardLocator(this.windowRef, { kind: "selector", selector, frame: [...this.frame] });
|
|
942
|
+
}
|
|
943
|
+
getByRole(role, query = {}) {
|
|
944
|
+
return new HasgardLocator(this.windowRef, { kind: "role", role, query, frame: [...this.frame] });
|
|
945
|
+
}
|
|
946
|
+
getByText(text, query = {}) {
|
|
947
|
+
return this.byDimension("text", text, query);
|
|
948
|
+
}
|
|
949
|
+
getByLabel(text, query = {}) {
|
|
950
|
+
return this.byDimension("label", text, query);
|
|
951
|
+
}
|
|
952
|
+
getByPlaceholder(text, query = {}) {
|
|
953
|
+
return this.byDimension("placeholder", text, query);
|
|
954
|
+
}
|
|
955
|
+
getByAltText(text, query = {}) {
|
|
956
|
+
return this.byDimension("alt", text, query);
|
|
957
|
+
}
|
|
958
|
+
getByTitle(text, query = {}) {
|
|
959
|
+
return this.byDimension("title", text, query);
|
|
960
|
+
}
|
|
961
|
+
getByTestId(testId) {
|
|
962
|
+
return this.byDimension("testid", testId, {});
|
|
963
|
+
}
|
|
964
|
+
/** Snapshot the frame's own document rather than the top one. */
|
|
965
|
+
async snapshot(options = {}) {
|
|
966
|
+
return this.windowRef.snapshot({ ...options, frame: [...this.frame] });
|
|
967
|
+
}
|
|
968
|
+
byDimension(by, value, query) {
|
|
969
|
+
return new HasgardLocator(this.windowRef, { kind: "query", by, value, query, frame: [...this.frame] });
|
|
970
|
+
}
|
|
971
|
+
};
|
|
972
|
+
var HasgardLocator = class _HasgardLocator {
|
|
445
973
|
window;
|
|
446
974
|
query;
|
|
447
975
|
constructor(window, query) {
|
|
448
976
|
this.window = window;
|
|
449
977
|
this.query = query;
|
|
450
978
|
}
|
|
451
|
-
|
|
452
|
-
|
|
979
|
+
/**
|
|
980
|
+
* Click this element.
|
|
981
|
+
*
|
|
982
|
+
* A right or middle click raises `contextmenu`/`auxclick` and no `click`, and
|
|
983
|
+
* `clickCount: 2` escalates `detail` across the presses before a single
|
|
984
|
+
* `dblclick` -- both matching what the platform does, so a listener bound to
|
|
985
|
+
* the wrong event fails here rather than in front of a user.
|
|
986
|
+
*/
|
|
987
|
+
async click(options = {}) {
|
|
988
|
+
await this.withTarget((target) => this.window.call("click", { ...this.scoped(target), ...clickParams(options) }));
|
|
453
989
|
}
|
|
454
990
|
async fill(value) {
|
|
455
|
-
await this.withTarget((target) => this.window.call("fill", { ...
|
|
991
|
+
await this.withTarget((target) => this.window.call("fill", { ...this.scoped(target), value }));
|
|
456
992
|
}
|
|
457
993
|
async type(text) {
|
|
458
|
-
await this.withTarget((target) => this.window.call("type", { ...
|
|
994
|
+
await this.withTarget((target) => this.window.call("type", { ...this.scoped(target), text }));
|
|
995
|
+
}
|
|
996
|
+
/** Empty the control, firing `input` and `change` as `fill` does. */
|
|
997
|
+
async clear() {
|
|
998
|
+
await this.fill("");
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* Select files on an `<input type="file">` without a native file chooser,
|
|
1002
|
+
* which an embedded webview gives no way to drive.
|
|
1003
|
+
*
|
|
1004
|
+
* Strings are read from the test machine's disk; `DropFile` payloads let you
|
|
1005
|
+
* supply content that never existed as a file. An empty array deselects.
|
|
1006
|
+
*/
|
|
1007
|
+
async setInputFiles(files) {
|
|
1008
|
+
const payloads = await readFileInputs(files);
|
|
1009
|
+
await this.withTarget(
|
|
1010
|
+
(target) => this.window.call("setInputFiles", {
|
|
1011
|
+
...this.scoped(target),
|
|
1012
|
+
files: payloads
|
|
1013
|
+
})
|
|
1014
|
+
);
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* Scroll this element by wheel.
|
|
1018
|
+
*
|
|
1019
|
+
* The page sees a real `wheel` event first and may cancel it; the scroll only
|
|
1020
|
+
* follows if it does not — the same two steps a physical wheel produces.
|
|
1021
|
+
* Resolves to whether the page cancelled.
|
|
1022
|
+
*/
|
|
1023
|
+
async wheel(deltaX, deltaY) {
|
|
1024
|
+
return this.withTarget(async (target) => {
|
|
1025
|
+
const value = expectRecord(
|
|
1026
|
+
await this.window.call("wheel", { ...this.scoped(target), deltaX, deltaY }),
|
|
1027
|
+
"wheel result"
|
|
1028
|
+
);
|
|
1029
|
+
return expectBoolean(value.defaultPrevented, "wheel.defaultPrevented");
|
|
1030
|
+
});
|
|
459
1031
|
}
|
|
460
1032
|
async selectOption(value) {
|
|
461
|
-
await this.withTarget((target) => this.window.call("select", { ...
|
|
1033
|
+
await this.withTarget((target) => this.window.call("select", { ...this.scoped(target), value }));
|
|
462
1034
|
}
|
|
1035
|
+
/** Drive the checkbox to checked. A no-op when it is already checked. */
|
|
463
1036
|
async check() {
|
|
464
|
-
await this.withTarget((target) => this.window.call("check",
|
|
1037
|
+
await this.withTarget((target) => this.window.call("check", { ...this.scoped(target), checked: true }));
|
|
1038
|
+
}
|
|
1039
|
+
/** Drive the checkbox to unchecked. A no-op when it is already unchecked. */
|
|
1040
|
+
async uncheck() {
|
|
1041
|
+
await this.withTarget((target) => this.window.call("check", { ...this.scoped(target), checked: false }));
|
|
1042
|
+
}
|
|
1043
|
+
/** Flip the checkbox, whatever its current state. */
|
|
1044
|
+
async toggle() {
|
|
1045
|
+
await this.withTarget((target) => this.window.call("check", this.scoped(target)));
|
|
1046
|
+
}
|
|
1047
|
+
async hover() {
|
|
1048
|
+
await this.withTarget((target) => this.window.call("hover", this.scoped(target)));
|
|
1049
|
+
}
|
|
1050
|
+
/** Shorthand for `click({ clickCount: 2 })`; other options still apply. */
|
|
1051
|
+
async dblclick(options = {}) {
|
|
1052
|
+
await this.click({ ...options, clickCount: 2 });
|
|
1053
|
+
}
|
|
1054
|
+
async focus() {
|
|
1055
|
+
await this.withTarget((target) => this.window.call("focus", this.scoped(target)));
|
|
1056
|
+
}
|
|
1057
|
+
async blur() {
|
|
1058
|
+
await this.withTarget((target) => this.window.call("blur", this.scoped(target)));
|
|
1059
|
+
}
|
|
1060
|
+
/** Scroll this element's own scrollport. */
|
|
1061
|
+
async scrollBy(options) {
|
|
1062
|
+
await this.withTarget(
|
|
1063
|
+
(target) => this.window.call("scroll", { ...this.scoped(target), ...options })
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
async boundingBox() {
|
|
1067
|
+
return this.withTarget(async (target) => {
|
|
1068
|
+
const value = expectRecord(await this.window.call("boundingBox", this.scoped(target)), "boundingBox result");
|
|
1069
|
+
return {
|
|
1070
|
+
x: expectNumber(value.x, "boundingBox.x"),
|
|
1071
|
+
y: expectNumber(value.y, "boundingBox.y"),
|
|
1072
|
+
width: expectNumber(value.width, "boundingBox.width"),
|
|
1073
|
+
height: expectNumber(value.height, "boundingBox.height")
|
|
1074
|
+
};
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
async isDisabled() {
|
|
1078
|
+
return this.withTarget(async (target) => {
|
|
1079
|
+
const value = expectRecord(await this.window.call("disabled", this.scoped(target)), "disabled result");
|
|
1080
|
+
return expectBoolean(value.disabled, "disabled.disabled");
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
async isEnabled() {
|
|
1084
|
+
return !await this.isDisabled();
|
|
465
1085
|
}
|
|
466
1086
|
async textContent() {
|
|
467
1087
|
return this.withTarget(
|
|
468
|
-
async (target) => expectString(await this.window.call("text",
|
|
1088
|
+
async (target) => expectString(await this.window.call("text", this.scoped(target)), "text result")
|
|
469
1089
|
);
|
|
470
1090
|
}
|
|
471
1091
|
async inputValue() {
|
|
472
1092
|
return this.withTarget(
|
|
473
|
-
async (target) => expectString(await this.window.call("value",
|
|
1093
|
+
async (target) => expectString(await this.window.call("value", this.scoped(target)), "value result")
|
|
1094
|
+
);
|
|
1095
|
+
}
|
|
1096
|
+
async innerHTML() {
|
|
1097
|
+
return this.withTarget(
|
|
1098
|
+
async (target) => expectString(await this.window.call("html", this.scoped(target)), "html result")
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
1101
|
+
async getAttribute(name) {
|
|
1102
|
+
const attributes = await this.attributes();
|
|
1103
|
+
return Object.prototype.hasOwnProperty.call(attributes, name) ? attributes[name] : null;
|
|
1104
|
+
}
|
|
1105
|
+
/** Drag this element onto another one in the same window. */
|
|
1106
|
+
async dragTo(target) {
|
|
1107
|
+
if (target.window !== this.window) {
|
|
1108
|
+
throw new Error("dragTo requires both locators to belong to the same window");
|
|
1109
|
+
}
|
|
1110
|
+
await this.window.runExclusive(async () => {
|
|
1111
|
+
const source = await this.resolveUnique();
|
|
1112
|
+
const destination = await target.resolveUnique();
|
|
1113
|
+
await this.window.call("drag", {
|
|
1114
|
+
source: targetParams(source),
|
|
1115
|
+
target: targetParams(destination)
|
|
1116
|
+
});
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
/** Drag this element by a pixel offset from its centre. */
|
|
1120
|
+
async dragBy(offset) {
|
|
1121
|
+
await this.withTarget(
|
|
1122
|
+
(target) => this.window.call("drag", { source: this.scoped(target), offset: { x: offset.x, y: offset.y } })
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
/** Dispatch a file drop onto this element. `data` is base64-encoded. */
|
|
1126
|
+
async dropFiles(files) {
|
|
1127
|
+
await this.withTarget(
|
|
1128
|
+
(target) => this.window.call("drop", {
|
|
1129
|
+
...this.scoped(target),
|
|
1130
|
+
files
|
|
1131
|
+
})
|
|
474
1132
|
);
|
|
475
1133
|
}
|
|
476
1134
|
async attributes() {
|
|
477
1135
|
return this.withTarget(async (target) => {
|
|
478
|
-
const value = expectRecord(await this.window.call("attrs",
|
|
1136
|
+
const value = expectRecord(await this.window.call("attrs", this.scoped(target)), "attrs result");
|
|
479
1137
|
return Object.fromEntries(
|
|
480
1138
|
Object.entries(value).map(([name, attr]) => [name, expectString(attr, `attribute ${name}`)])
|
|
481
1139
|
);
|
|
@@ -483,55 +1141,124 @@ var HasgardLocator = class {
|
|
|
483
1141
|
}
|
|
484
1142
|
async isVisible() {
|
|
485
1143
|
return this.withTarget(async (target) => {
|
|
486
|
-
const value = expectRecord(await this.window.call("visible",
|
|
1144
|
+
const value = expectRecord(await this.window.call("visible", this.scoped(target)), "visible result");
|
|
487
1145
|
return expectBoolean(value.visible, "visible.visible");
|
|
488
1146
|
});
|
|
489
1147
|
}
|
|
490
1148
|
async isChecked() {
|
|
491
1149
|
return this.withTarget(async (target) => {
|
|
492
|
-
const value = expectRecord(await this.window.call("checked",
|
|
1150
|
+
const value = expectRecord(await this.window.call("checked", this.scoped(target)), "checked result");
|
|
493
1151
|
return expectBoolean(value.checked, "checked.checked");
|
|
494
1152
|
});
|
|
495
1153
|
}
|
|
496
1154
|
async count() {
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
1155
|
+
const total = this.query.kind === "selector" && !this.query.filters?.length ? expectNumber(
|
|
1156
|
+
expectRecord(
|
|
1157
|
+
await this.window.call("count", { selector: this.query.selector, ...this.frameParams() }),
|
|
1158
|
+
"count result"
|
|
1159
|
+
).count,
|
|
1160
|
+
"count.count"
|
|
1161
|
+
) : (await this.resolveAll()).length;
|
|
1162
|
+
if (this.query.index === void 0) return total;
|
|
1163
|
+
const position = absoluteIndex(this.query.index, total);
|
|
1164
|
+
return position >= 0 && position < total ? 1 : 0;
|
|
500
1165
|
}
|
|
501
1166
|
async waitFor(options) {
|
|
502
|
-
if (this.query.kind === "selector") {
|
|
1167
|
+
if (this.query.kind === "selector" && this.query.index === void 0 && !this.query.filters?.length) {
|
|
503
1168
|
await this.window.call("wait", {
|
|
504
1169
|
selector: this.query.selector,
|
|
505
1170
|
gone: options.state === "detached",
|
|
506
|
-
timeout: options.timeoutMs
|
|
1171
|
+
timeout: options.timeoutMs,
|
|
1172
|
+
...this.frameParams()
|
|
507
1173
|
});
|
|
508
1174
|
return;
|
|
509
1175
|
}
|
|
510
1176
|
const deadline = Date.now() + options.timeoutMs;
|
|
511
1177
|
while (Date.now() <= deadline) {
|
|
512
|
-
const found =
|
|
1178
|
+
const found = await this.count() > 0;
|
|
513
1179
|
if (found === (options.state === "attached")) return;
|
|
514
1180
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
515
1181
|
}
|
|
516
|
-
throw new Error(`
|
|
1182
|
+
throw new Error(`Locator did not become ${options.state} within ${options.timeoutMs}ms`);
|
|
1183
|
+
}
|
|
1184
|
+
/**
|
|
1185
|
+
* Narrow this locator to a single match by position. A negative index counts
|
|
1186
|
+
* back from the end, so `last()` stays one round trip.
|
|
1187
|
+
*/
|
|
1188
|
+
nth(index) {
|
|
1189
|
+
if (!Number.isInteger(index)) throw new Error("nth requires an integer index");
|
|
1190
|
+
return new _HasgardLocator(this.window, { ...this.query, index });
|
|
1191
|
+
}
|
|
1192
|
+
/**
|
|
1193
|
+
* Narrow this locator to the elements whose text matches.
|
|
1194
|
+
*
|
|
1195
|
+
* Filters accumulate, so `filter({hasText}).filter({hasNotText})` applies
|
|
1196
|
+
* both. Ordering matters against `nth`: filtering happens first, so
|
|
1197
|
+
* `filter({hasText: "x"}).first()` is the first *matching* element, which is
|
|
1198
|
+
* the reading that makes `first()` mean what it says.
|
|
1199
|
+
*/
|
|
1200
|
+
filter(options) {
|
|
1201
|
+
if (options.hasText === void 0 && options.hasNotText === void 0) {
|
|
1202
|
+
throw new Error("filter requires hasText or hasNotText");
|
|
1203
|
+
}
|
|
1204
|
+
return new _HasgardLocator(this.window, {
|
|
1205
|
+
...this.query,
|
|
1206
|
+
filters: [...this.query.filters ?? [], options]
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
first() {
|
|
1210
|
+
return this.nth(0);
|
|
1211
|
+
}
|
|
1212
|
+
last() {
|
|
1213
|
+
return this.nth(-1);
|
|
517
1214
|
}
|
|
518
1215
|
async resolveUnique() {
|
|
519
|
-
if (this.query.kind === "selector"
|
|
1216
|
+
if (this.query.kind === "selector" && !this.query.filters?.length) {
|
|
1217
|
+
return this.query.index === void 0 ? bySelector(this.query.selector) : { selector: this.query.selector, index: this.query.index };
|
|
1218
|
+
}
|
|
520
1219
|
const elements = await this.resolveAll();
|
|
1220
|
+
if (this.query.index !== void 0) {
|
|
1221
|
+
const element = elements[absoluteIndex(this.query.index, elements.length)];
|
|
1222
|
+
if (!element) {
|
|
1223
|
+
throw new Error(`Locator has no element at index ${this.query.index} (${elements.length} matched)`);
|
|
1224
|
+
}
|
|
1225
|
+
return byRef(element.ref);
|
|
1226
|
+
}
|
|
521
1227
|
if (elements.length !== 1) {
|
|
522
|
-
throw new Error(`
|
|
1228
|
+
throw new Error(`Locator resolved to ${elements.length} elements; expected exactly one`);
|
|
523
1229
|
}
|
|
524
1230
|
return byRef(elements[0].ref);
|
|
525
1231
|
}
|
|
1232
|
+
/**
|
|
1233
|
+
* Serialize a target together with the frame chain this locator lives in.
|
|
1234
|
+
*
|
|
1235
|
+
* A ref carries its own document, so the bridge ignores `frame` for one; the
|
|
1236
|
+
* chain still travels so that a selector or coordinate target -- which the
|
|
1237
|
+
* bridge must resolve itself -- lands in the right document.
|
|
1238
|
+
*/
|
|
1239
|
+
scoped(target) {
|
|
1240
|
+
return { ...targetParams(target), ...this.frameParams() };
|
|
1241
|
+
}
|
|
1242
|
+
frameParams() {
|
|
1243
|
+
return this.query.frame ? { frame: [...this.query.frame] } : {};
|
|
1244
|
+
}
|
|
526
1245
|
async withTarget(operation) {
|
|
527
1246
|
return this.window.runExclusive(async () => operation(await this.resolveUnique()));
|
|
528
1247
|
}
|
|
529
1248
|
async resolveAll() {
|
|
1249
|
+
return this.applyFilters(await this.resolveBase());
|
|
1250
|
+
}
|
|
1251
|
+
async resolveBase() {
|
|
530
1252
|
if (this.query.kind === "selector") {
|
|
531
|
-
|
|
1253
|
+
return this.runQuery({ by: "selector", value: this.query.selector });
|
|
1254
|
+
}
|
|
1255
|
+
if (this.query.kind === "query") {
|
|
1256
|
+
const params = { by: this.query.by, value: this.query.value };
|
|
1257
|
+
if (this.query.query.exact !== void 0) params.exact = this.query.query.exact;
|
|
1258
|
+
return this.runQuery(params);
|
|
532
1259
|
}
|
|
533
1260
|
const query = this.query;
|
|
534
|
-
const snapshot = await this.window.snapshot();
|
|
1261
|
+
const snapshot = await this.window.snapshot(query.frame ? { frame: [...query.frame] } : {});
|
|
535
1262
|
return snapshot.elements.filter((element) => {
|
|
536
1263
|
if (element.role !== query.role) return false;
|
|
537
1264
|
const expected = query.query.name;
|
|
@@ -541,7 +1268,65 @@ var HasgardLocator = class {
|
|
|
541
1268
|
return query.query.exact ? element.name === expected : element.name.includes(expected);
|
|
542
1269
|
});
|
|
543
1270
|
}
|
|
1271
|
+
/**
|
|
1272
|
+
* Apply each accumulated filter, one round trip per filter.
|
|
1273
|
+
*
|
|
1274
|
+
* Filters run server-side rather than against a snapshot because the text a
|
|
1275
|
+
* filter matches on is not carried in snapshot entries — deliberately, so
|
|
1276
|
+
* that every unrelated `snapshot` call stays small.
|
|
1277
|
+
*/
|
|
1278
|
+
async applyFilters(elements) {
|
|
1279
|
+
const filters = this.query.filters ?? [];
|
|
1280
|
+
let current = elements;
|
|
1281
|
+
for (const filter of filters) {
|
|
1282
|
+
if (current.length === 0) return current;
|
|
1283
|
+
const params = { refs: current.map((element) => element.ref) };
|
|
1284
|
+
if (filter.hasText !== void 0) params.hasText = filter.hasText;
|
|
1285
|
+
if (filter.hasNotText !== void 0) params.hasNotText = filter.hasNotText;
|
|
1286
|
+
if (filter.exact !== void 0) params.exact = filter.exact;
|
|
1287
|
+
current = this.parseElements(await this.window.call("filter", params), "filter");
|
|
1288
|
+
}
|
|
1289
|
+
return current;
|
|
1290
|
+
}
|
|
1291
|
+
async runQuery(params) {
|
|
1292
|
+
return this.parseElements(await this.window.call("query", { ...params, ...this.frameParams() }), "query");
|
|
1293
|
+
}
|
|
1294
|
+
parseElements(raw, source) {
|
|
1295
|
+
const value = expectRecord(raw, `${source} result`);
|
|
1296
|
+
if (!Array.isArray(value.elements)) throw new Error(`${source}.elements must be an array`);
|
|
1297
|
+
return value.elements.map((entry, index) => parseSnapshotElement(entry, index));
|
|
1298
|
+
}
|
|
544
1299
|
};
|
|
1300
|
+
function parseDialogPolicy(value) {
|
|
1301
|
+
const record = expectRecord(value, "dialog policy");
|
|
1302
|
+
const action = expectString(record.action, "dialog policy.action");
|
|
1303
|
+
if (action !== "accept" && action !== "dismiss") {
|
|
1304
|
+
throw new Error(`dialog policy.action must be "accept" or "dismiss", got ${action}`);
|
|
1305
|
+
}
|
|
1306
|
+
return {
|
|
1307
|
+
action,
|
|
1308
|
+
promptText: record.promptText == null ? null : expectString(record.promptText, "dialog policy.promptText")
|
|
1309
|
+
};
|
|
1310
|
+
}
|
|
1311
|
+
function parseDialogRecord(value, index) {
|
|
1312
|
+
const entry = expectRecord(value, `dialogs[${index}]`);
|
|
1313
|
+
const type = expectString(entry.type, `dialogs[${index}].type`);
|
|
1314
|
+
if (type !== "alert" && type !== "confirm" && type !== "prompt") {
|
|
1315
|
+
throw new Error(`dialogs[${index}].type must be alert, confirm, or prompt, got ${type}`);
|
|
1316
|
+
}
|
|
1317
|
+
const record = {
|
|
1318
|
+
id: expectNumber(entry.id, `dialogs[${index}].id`),
|
|
1319
|
+
timestamp: expectNumber(entry.timestamp, `dialogs[${index}].timestamp`),
|
|
1320
|
+
type,
|
|
1321
|
+
message: expectString(entry.message, `dialogs[${index}].message`),
|
|
1322
|
+
accepted: expectBoolean(entry.accepted, `dialogs[${index}].accepted`)
|
|
1323
|
+
};
|
|
1324
|
+
if (entry.defaultValue !== void 0) {
|
|
1325
|
+
record.defaultValue = expectString(entry.defaultValue, `dialogs[${index}].defaultValue`);
|
|
1326
|
+
}
|
|
1327
|
+
if (entry.returned !== void 0) record.returned = expectString(entry.returned, `dialogs[${index}].returned`);
|
|
1328
|
+
return record;
|
|
1329
|
+
}
|
|
545
1330
|
function parseSnapshotElement(value, index) {
|
|
546
1331
|
const entry = expectRecord(value, `snapshot.elements[${index}]`);
|
|
547
1332
|
const element = {
|
|
@@ -600,6 +1385,7 @@ function createHasgardTest(config) {
|
|
|
600
1385
|
}
|
|
601
1386
|
export {
|
|
602
1387
|
HasgardApplication,
|
|
1388
|
+
HasgardFrameLocator,
|
|
603
1389
|
HasgardLocator,
|
|
604
1390
|
HasgardProcess,
|
|
605
1391
|
HasgardRpcClient,
|