juce-webview-agent-bridge 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/LICENSE +21 -0
- package/README.md +232 -0
- package/package.json +51 -0
- package/tools/e2e.d.mts +359 -0
- package/tools/e2e.mjs +998 -0
- package/tools/shared.d.mts +21 -0
- package/tools/shared.mjs +76 -0
- package/tools/web-agent.d.mts +2 -0
- package/tools/web-agent.mjs +251 -0
package/tools/e2e.mjs
ADDED
|
@@ -0,0 +1,998 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* e2e.mjs — a tiny Playwright-shaped E2E client for the web_agent_bridge.
|
|
3
|
+
*
|
|
4
|
+
* There is no CDP/WebDriver for an embedded WKWebView, so this drives the live
|
|
5
|
+
* WebView purely through the bridge's `eval` op: every locator query and action
|
|
6
|
+
* compiles to a JS snippet, and the auto-wait/retry loop runs HERE (Node side),
|
|
7
|
+
* replicating Playwright's "wait until actionable" ergonomics over a persistent
|
|
8
|
+
* loopback connection.
|
|
9
|
+
*
|
|
10
|
+
* import { connect, expect } from './e2e.mjs';
|
|
11
|
+
* const page = await connect(); // auto-discovers port + token
|
|
12
|
+
* await page.locator('text=Save').click(); // waits for visible+stable+enabled+hit
|
|
13
|
+
* await page.locator('input[name=email]').fill('a@b.c');
|
|
14
|
+
* await expect(page.locator('role=button[name="Submit"]')).toBeVisible();
|
|
15
|
+
* await expect(page.locator('.row')).toHaveCount(8);
|
|
16
|
+
* page.close();
|
|
17
|
+
*
|
|
18
|
+
* App-agnostic: knows DOM + (for JUCE hosts) the generic __juce__invoke bridge —
|
|
19
|
+
* never any specific app's selectors, native-function names, or page globals.
|
|
20
|
+
* Build those as thin wrappers over page.backend()/fireBackend()/readBig() in your
|
|
21
|
+
* own project layer.
|
|
22
|
+
*
|
|
23
|
+
* Selector engines: css (default) | text=<exact text> | role=<role>[name="<accessible name>"]
|
|
24
|
+
*
|
|
25
|
+
* Hard ceiling (inherent to driving via in-page JS, same as the bridge):
|
|
26
|
+
* - clicks/keys are synthetic (isTrusted=false) — gesture-gated APIs won't fire;
|
|
27
|
+
* - no native dialogs / file pickers; no request interception; single page only.
|
|
28
|
+
*/
|
|
29
|
+
import { execFile } from 'node:child_process';
|
|
30
|
+
import net from 'node:net';
|
|
31
|
+
import fs from 'node:fs';
|
|
32
|
+
import os from 'node:os';
|
|
33
|
+
import path from 'node:path';
|
|
34
|
+
import { DEFAULT_PORT, loadDiscovery, onJsonLines } from './shared.mjs';
|
|
35
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
36
|
+
/** Bring the host app's window to the foreground (macOS, best-effort; resolves
|
|
37
|
+
* false elsewhere). A backgrounded WebView reports document.hidden === true and
|
|
38
|
+
* many apps pause timers/polling/state-sync — an agent then reads stale or empty
|
|
39
|
+
* state even though eval works. Foregrounding the REAL window (not faking the
|
|
40
|
+
* visibility signal) reproduces the user-visible condition, so tests assert on
|
|
41
|
+
* live state. connect({ activate: '<App Name>' }) calls this for you. */
|
|
42
|
+
export function activateApp(appName) {
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
if (process.platform !== 'darwin' || !appName)
|
|
45
|
+
return resolve(false);
|
|
46
|
+
execFile('osascript', ['-e', `tell application ${JSON.stringify(appName)} to activate`], (err) => resolve(!err));
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/** A logger that appends timestamped action lines to ONE file and (by default)
|
|
50
|
+
* echoes them to stderr so a live run shows progress instead of going silent.
|
|
51
|
+
* Generic/agnostic: it records whatever the caller logs. connect() wires one up
|
|
52
|
+
* automatically (override via { log } or { logFile }, or $WAE_LOG_FILE). */
|
|
53
|
+
/** Parse a `layertree` op dump (the `_caLayerTreeAsText` format) into a flat
|
|
54
|
+
array of layer bounds `{ x, y, width, height }` — the programmatic
|
|
55
|
+
compositing-layer census. Pure; pairs with `page.layerTree()`:
|
|
56
|
+
|
|
57
|
+
const layers = parseLayerTree(await page.layerTree());
|
|
58
|
+
const canvases = layers.filter(l => l.width === 398 && l.height === 209);
|
|
59
|
+
|
|
60
|
+
The dump nests layers as parenthesized blocks; for a census the flat list
|
|
61
|
+
of `(layer bounds [x: … y: … width: … height: …])` entries is what counts,
|
|
62
|
+
so nesting is deliberately ignored. */
|
|
63
|
+
export function parseLayerTree(text) {
|
|
64
|
+
const layers = [];
|
|
65
|
+
const re = /\(layer bounds \[x: (-?[\d.]+) y: (-?[\d.]+) width: (-?[\d.]+) height: (-?[\d.]+)\]\)/g;
|
|
66
|
+
for (const m of String(text ?? '').matchAll(re)) {
|
|
67
|
+
layers.push({ x: Number(m[1]), y: Number(m[2]), width: Number(m[3]), height: Number(m[4]) });
|
|
68
|
+
}
|
|
69
|
+
return layers;
|
|
70
|
+
}
|
|
71
|
+
export function fileLogger(file, { echo = true } = {}) {
|
|
72
|
+
const stamp = () => new Date().toISOString().slice(11, 23);
|
|
73
|
+
return (msg) => {
|
|
74
|
+
const line = `${stamp()} ${msg}\n`;
|
|
75
|
+
try {
|
|
76
|
+
fs.appendFileSync(file, line);
|
|
77
|
+
}
|
|
78
|
+
catch { /* logging must never break a run */ }
|
|
79
|
+
if (echo)
|
|
80
|
+
process.stderr.write(line);
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const defaultLogFile = () => process.env.WAE_LOG_FILE || path.join(os.tmpdir(), 'web_agent_e2e.log');
|
|
84
|
+
// ---- page-side helpers, injected once per session -------------------------
|
|
85
|
+
// Defines window.__wae.{resolveAll, resolve, state}. resolveAll returns matches
|
|
86
|
+
// for a selector; state(el) reports everything the actionability loop needs in
|
|
87
|
+
// ONE round-trip (WKWebView eval is synchronous and does not await promises, so
|
|
88
|
+
// cross-frame "stability" is judged client-side across two polls instead).
|
|
89
|
+
// Exported for tests/page-helpers.test.mjs, which runs this page-side bundle
|
|
90
|
+
// under node:vm against a stubbed DOM — the selector engine, actionability
|
|
91
|
+
// probe, and aria snapshot are behaviourally pinned there, not just by name.
|
|
92
|
+
export const PAGE_HELPERS = `(() => {
|
|
93
|
+
// Reuse any existing helper object (the WebView persists across client runs) and
|
|
94
|
+
// always re-define methods so a client upgrade takes effect without a reload;
|
|
95
|
+
// persistent state (_calls/_beHooked/the completion listener) is preserved below.
|
|
96
|
+
var W = window.__wae || {};
|
|
97
|
+
W.resolveAll = function (sel) {
|
|
98
|
+
try {
|
|
99
|
+
if (sel.indexOf('text=') === 0) {
|
|
100
|
+
const t = sel.slice(5).trim();
|
|
101
|
+
return Array.prototype.slice.call(document.querySelectorAll('body *')).filter(function (e) {
|
|
102
|
+
return (e.textContent || '').trim() === t;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
if (sel.indexOf('role=') === 0) {
|
|
106
|
+
const m = sel.slice(5).match(/^([a-zA-Z]+)(?:\\[name=(?:"([^"]*)"|'([^']*)')\\])?$/);
|
|
107
|
+
if (!m) return [];
|
|
108
|
+
const role = m[1], name = m[2] != null ? m[2] : m[3];
|
|
109
|
+
const map = { button: ['button'], link: ['a[href]'], textbox: ['input:not([type=hidden])', 'textarea'],
|
|
110
|
+
checkbox: ['input[type=checkbox]'], heading: ['h1','h2','h3','h4','h5','h6'] };
|
|
111
|
+
const sels = (map[role] || []).concat('[role=' + role + ']');
|
|
112
|
+
let els = [];
|
|
113
|
+
sels.forEach(function (s) { try { els = els.concat(Array.prototype.slice.call(document.querySelectorAll(s))); } catch (e) {} });
|
|
114
|
+
els = els.filter(function (e, i) { return els.indexOf(e) === i; });
|
|
115
|
+
if (name != null) els = els.filter(function (e) { return ((e.getAttribute('aria-label') || e.textContent || '').trim()) === name; });
|
|
116
|
+
return els;
|
|
117
|
+
}
|
|
118
|
+
return Array.prototype.slice.call(document.querySelectorAll(sel));
|
|
119
|
+
} catch (e) { return []; }
|
|
120
|
+
};
|
|
121
|
+
W.resolve = function (sel) { const a = W.resolveAll(sel); return a.length ? a[a.length - 1] : null; };
|
|
122
|
+
W.pick = function (sel, idx) { var a = W.resolveAll(sel); if (!a.length) return null; return (idx === null || idx === undefined || idx < 0) ? a[a.length - 1] : a[idx]; };
|
|
123
|
+
W.state = function (el) {
|
|
124
|
+
const cs = getComputedStyle(el), r = el.getBoundingClientRect();
|
|
125
|
+
const visible = r.width > 0 && r.height > 0 && cs.display !== 'none' && cs.visibility !== 'hidden' && Number(cs.opacity) > 0;
|
|
126
|
+
const disabled = el.disabled === true || el.getAttribute('aria-disabled') === 'true';
|
|
127
|
+
const tag = el.tagName;
|
|
128
|
+
const editable = (tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable === true) && !el.readOnly;
|
|
129
|
+
// Hit-test several points across the element box, not just the exact centre.
|
|
130
|
+
// In dense layouts (compact columns, a Radix indicator span, a focus ring) a
|
|
131
|
+
// thin occluder over the centre pixel would otherwise read as not-clickable
|
|
132
|
+
// even though the control is fully reachable. A real blocking overlay covers
|
|
133
|
+
// the whole box, so every probe point fails the same way — only a small
|
|
134
|
+
// centre-only occluder is bypassed, which is exactly the false negative to fix.
|
|
135
|
+
let hit = false;
|
|
136
|
+
try {
|
|
137
|
+
const pts = [[0.5, 0.5], [0.25, 0.25], [0.75, 0.25], [0.25, 0.75], [0.75, 0.75]];
|
|
138
|
+
hit = pts.some(function (p) {
|
|
139
|
+
const top = document.elementFromPoint(r.x + r.width * p[0], r.y + r.height * p[1]);
|
|
140
|
+
return !!top && (top === el || el.contains(top) || top.contains(el));
|
|
141
|
+
});
|
|
142
|
+
} catch (e) {}
|
|
143
|
+
return { visible: visible, enabled: !disabled, editable: editable, hit: hit,
|
|
144
|
+
box: { x: r.x, y: r.y, w: r.width, h: r.height }, text: (el.textContent || '').trim(),
|
|
145
|
+
value: (el.value != null ? String(el.value) : null), checked: el.checked === true };
|
|
146
|
+
};
|
|
147
|
+
// --- backend (native fn) bridge: mirrors the app's juceInterop pattern ---
|
|
148
|
+
W._calls = W._calls || {}; W._rid = W._rid || 100000; W._beHooked = W._beHooked || false;
|
|
149
|
+
W._hookBackend = function () {
|
|
150
|
+
if (W._beHooked) return true;
|
|
151
|
+
var b = window.__JUCE__ && window.__JUCE__.backend; if (!b) return false;
|
|
152
|
+
b.addEventListener('__juce__complete', function (p) {
|
|
153
|
+
var id = p && p.promiseId; id = (typeof id === 'string') ? parseInt(id, 10) : id;
|
|
154
|
+
if (W._calls[id] !== undefined) W._calls[id] = { done: true, value: p.result };
|
|
155
|
+
});
|
|
156
|
+
W._beHooked = true; return true;
|
|
157
|
+
};
|
|
158
|
+
W.invoke = function (name, params) {
|
|
159
|
+
if (!W._hookBackend()) return -1;
|
|
160
|
+
var id = ++W._rid; W._calls[id] = { done: false };
|
|
161
|
+
window.__JUCE__.backend.emitEvent('__juce__invoke', { name: name, params: params || [], resultId: id });
|
|
162
|
+
return id;
|
|
163
|
+
};
|
|
164
|
+
W.callDone = function (id) { var c = W._calls[id]; return c ? (c.done ? 1 : 0) : -1; };
|
|
165
|
+
// Native results often arrive already-JSON-stringified; pass strings through
|
|
166
|
+
// verbatim (no double-encode) and stringify everything else.
|
|
167
|
+
W.callJson = function (id) { var c = W._calls[id]; var v = c ? c.value : null; if (v === undefined) v = null; if (typeof v === 'string') return v; try { return JSON.stringify(v); } catch (e) { return String(v); } };
|
|
168
|
+
W.fire = function (name, params) {
|
|
169
|
+
var b = window.__JUCE__ && window.__JUCE__.backend; if (!b) return false;
|
|
170
|
+
b.emitEvent('__juce__invoke', { name: name, params: params || [], resultId: -1 }); return true;
|
|
171
|
+
};
|
|
172
|
+
// --- chunked transfer: WKWebView evaluateJavascript stalls on large returns
|
|
173
|
+
// (>~100KB), so big values are read in <=32KB slices instead. ---
|
|
174
|
+
W.chunkInit = function (s) { W.__chunk = (s == null ? '' : String(s)); return W.__chunk.length; };
|
|
175
|
+
W.chunkAt = function (off, n) { return W.__chunk.substr(off, n); };
|
|
176
|
+
// --- structured accessibility snapshot: a compact role/name tree (generic
|
|
177
|
+
// containers flattened away) — far cheaper to read back than outerHTML, and
|
|
178
|
+
// it surfaces what an agent acts on (roles, names, values, state). ---
|
|
179
|
+
W.ariaSnapshot = function (root) {
|
|
180
|
+
var ROLE_BY_TAG = { A: 'link', BUTTON: 'button', SELECT: 'combobox', TEXTAREA: 'textbox',
|
|
181
|
+
H1: 'heading', H2: 'heading', H3: 'heading', H4: 'heading', H5: 'heading', H6: 'heading',
|
|
182
|
+
NAV: 'navigation', MAIN: 'main', HEADER: 'banner', FOOTER: 'contentinfo',
|
|
183
|
+
UL: 'list', OL: 'list', LI: 'listitem', IMG: 'img', TABLE: 'table', FORM: 'form', LABEL: 'label' };
|
|
184
|
+
function vis(e) {
|
|
185
|
+
try { var cs = getComputedStyle(e), r = e.getBoundingClientRect();
|
|
186
|
+
return r.width > 0 && r.height > 0 && cs.display !== 'none' && cs.visibility !== 'hidden' && Number(cs.opacity) > 0;
|
|
187
|
+
} catch (_) { return false; }
|
|
188
|
+
}
|
|
189
|
+
function roleOf(e) {
|
|
190
|
+
var ar = e.getAttribute && e.getAttribute('role'); if (ar) return ar;
|
|
191
|
+
if (e.tagName === 'INPUT') { var t = (e.getAttribute('type') || 'text').toLowerCase();
|
|
192
|
+
if (t === 'hidden') return null; if (t === 'checkbox') return 'checkbox'; if (t === 'radio') return 'radio';
|
|
193
|
+
if (t === 'button' || t === 'submit' || t === 'reset') return 'button'; return 'textbox'; }
|
|
194
|
+
return ROLE_BY_TAG[e.tagName] || null;
|
|
195
|
+
}
|
|
196
|
+
function nameOf(e) {
|
|
197
|
+
var al = e.getAttribute && e.getAttribute('aria-label'); if (al && al.trim()) return al.trim();
|
|
198
|
+
var lb = e.getAttribute && e.getAttribute('aria-labelledby');
|
|
199
|
+
if (lb) { var s = lb.trim().split(' ').map(function (id) { var n = id && document.getElementById(id); return n ? (n.textContent || '') : ''; }).join(' ').trim(); if (s) return s; }
|
|
200
|
+
if (e.tagName === 'IMG') return (e.getAttribute('alt') || '').trim();
|
|
201
|
+
if (e.tagName === 'INPUT' || e.tagName === 'TEXTAREA') { var ph = e.getAttribute('placeholder'); if (ph && ph.trim()) return ph.trim(); }
|
|
202
|
+
var own = ''; for (var i = 0; i < e.childNodes.length; i++) { var c = e.childNodes[i]; if (c.nodeType === 3) own += c.nodeValue; }
|
|
203
|
+
own = own.trim(); if (own) return own.length > 100 ? own.slice(0, 100) : own;
|
|
204
|
+
var tx = (e.textContent || '').trim(); return tx.length > 100 ? tx.slice(0, 100) : tx;
|
|
205
|
+
}
|
|
206
|
+
function build(e) {
|
|
207
|
+
if (!vis(e)) return null;
|
|
208
|
+
var r = roleOf(e), node = null;
|
|
209
|
+
if (r) {
|
|
210
|
+
node = { role: r };
|
|
211
|
+
var nm = nameOf(e); if (nm) node.name = nm;
|
|
212
|
+
if ((e.tagName === 'INPUT' || e.tagName === 'TEXTAREA' || e.tagName === 'SELECT') && e.type !== 'checkbox' && e.type !== 'radio' && e.value != null && e.value !== '') node.value = String(e.value);
|
|
213
|
+
if (e.type === 'checkbox' || e.type === 'radio' || (e.getAttribute && e.getAttribute('aria-checked'))) node.checked = e.checked === true || (e.getAttribute && e.getAttribute('aria-checked') === 'true');
|
|
214
|
+
if (e.disabled === true || (e.getAttribute && e.getAttribute('aria-disabled') === 'true')) node.disabled = true;
|
|
215
|
+
}
|
|
216
|
+
var kids = [];
|
|
217
|
+
for (var j = 0; j < e.children.length; j++) { var ch = build(e.children[j]); if (ch) { if (Array.isArray(ch)) kids = kids.concat(ch); else kids.push(ch); } }
|
|
218
|
+
if (node) { if (kids.length) node.children = kids; return node; }
|
|
219
|
+
return kids.length ? kids : null; // generic container -> flatten its children up
|
|
220
|
+
}
|
|
221
|
+
var out = build(root || document.body);
|
|
222
|
+
return out || [];
|
|
223
|
+
};
|
|
224
|
+
window.__wae = W;
|
|
225
|
+
return 'ok';
|
|
226
|
+
})()`;
|
|
227
|
+
const idxArg = (idx) => (idx == null ? 'null' : String(idx));
|
|
228
|
+
const probeCode = (sel, idx) => `JSON.stringify((() => {
|
|
229
|
+
const a = window.__wae.resolveAll(${JSON.stringify(sel)});
|
|
230
|
+
const el = window.__wae.pick(${JSON.stringify(sel)}, ${idxArg(idx)});
|
|
231
|
+
return { n: a.length, state: el ? window.__wae.state(el) : null };
|
|
232
|
+
})())`;
|
|
233
|
+
// Shared preamble for the pointer-sequence actions (click / hover / dblclick):
|
|
234
|
+
// resolve + scroll into view, compute the centre, pick the dispatch target, and
|
|
235
|
+
// define ptr()/mse() dispatch helpers. isTrusted is still false (in-page JS), so
|
|
236
|
+
// user-gesture-gated APIs remain out of reach — this only makes ordinary
|
|
237
|
+
// interactions faithful for pointer-driven UIs (Radix, headless menus) that a
|
|
238
|
+
// bare el.click() silently misses.
|
|
239
|
+
// onEl (force): dispatch on the element itself, bypassing any overlay at its
|
|
240
|
+
// centre; otherwise hit the topmost element at the point like a real cursor.
|
|
241
|
+
const pointerPreamble = (sel, idx, onEl) => `
|
|
242
|
+
const el = window.__wae.pick(${JSON.stringify(sel)}, ${idxArg(idx)});
|
|
243
|
+
if (!el) return 'gone';
|
|
244
|
+
el.scrollIntoView({ block: 'center', inline: 'center' });
|
|
245
|
+
const r = el.getBoundingClientRect();
|
|
246
|
+
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
|
|
247
|
+
const target = ${onEl ? 'el' : '(document.elementFromPoint(cx, cy) || el)'};
|
|
248
|
+
const PE = window.PointerEvent;
|
|
249
|
+
const common = { bubbles: true, cancelable: true, composed: true, clientX: cx, clientY: cy, button: 0, view: window };
|
|
250
|
+
const ptr = (type, buttons) => { if (PE) target.dispatchEvent(new PE(type, Object.assign({ pointerId: 1, pointerType: 'mouse', isPrimary: true, buttons }, common))); };
|
|
251
|
+
const mse = (type, buttons, detail) => target.dispatchEvent(new MouseEvent(type, Object.assign({ buttons, detail }, common)));`;
|
|
252
|
+
const clickCode = (sel, idx, onEl) => `(() => {${pointerPreamble(sel, idx, onEl)}
|
|
253
|
+
ptr('pointerover', 0); ptr('pointerenter', 0); mse('mouseover', 0); mse('mousemove', 0);
|
|
254
|
+
ptr('pointerdown', 1); mse('mousedown', 1);
|
|
255
|
+
ptr('pointerup', 0); mse('mouseup', 0);
|
|
256
|
+
mse('click', 0);
|
|
257
|
+
return 'ok';
|
|
258
|
+
})()`;
|
|
259
|
+
const fillCode = (sel, val, idx) => `(() => {
|
|
260
|
+
const el = window.__wae.pick(${JSON.stringify(sel)}, ${idxArg(idx)});
|
|
261
|
+
if (!el) return 'gone';
|
|
262
|
+
if (!(el instanceof window.HTMLInputElement || el instanceof window.HTMLTextAreaElement)) return 'not-input';
|
|
263
|
+
// Focus BEFORE mutating the value (like a real edit, and like typeCode). Some
|
|
264
|
+
// controlled inputs gate their commit on focus->change->blur ordering — e.g. a
|
|
265
|
+
// draft flag set on change and reset on focus, so onBlur only commits when focus
|
|
266
|
+
// preceded change. A value-then-focus order (which fill({enter}) would otherwise
|
|
267
|
+
// produce, since pressKeyCode focuses before blur) silently drops that commit.
|
|
268
|
+
if (typeof el.focus === 'function') el.focus();
|
|
269
|
+
const proto = el instanceof window.HTMLTextAreaElement ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
|
|
270
|
+
Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, ${JSON.stringify(val)});
|
|
271
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
272
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
273
|
+
return 'ok';
|
|
274
|
+
})()`;
|
|
275
|
+
// Dispatch a key on an element — e.g. Enter to commit inputs that only persist on
|
|
276
|
+
// keydown/blur (not on every change). blur=true also blurs so onBlur-commit inputs
|
|
277
|
+
// fire too (used by fill({enter})); plain Locator.press keeps focus (blur=false).
|
|
278
|
+
const pressKeyCode = (sel, key, idx, blur) => `(() => {
|
|
279
|
+
const el = window.__wae.pick(${JSON.stringify(sel)}, ${idxArg(idx)});
|
|
280
|
+
if (!el) return 'gone';
|
|
281
|
+
if (typeof el.focus === 'function') el.focus();
|
|
282
|
+
const opt = { key: ${JSON.stringify(key)}, code: ${JSON.stringify(key)}, bubbles: true, cancelable: true };
|
|
283
|
+
el.dispatchEvent(new KeyboardEvent('keydown', opt));
|
|
284
|
+
el.dispatchEvent(new KeyboardEvent('keyup', opt));
|
|
285
|
+
${blur ? 'if (typeof el.blur === "function") el.blur();' : ''}
|
|
286
|
+
return 'ok';
|
|
287
|
+
})()`;
|
|
288
|
+
// Hover: realistic pointer+mouse over-sequence at the element centre (opens menus/
|
|
289
|
+
// tooltips that arm on pointerover/mouseover). onEl bypasses an overlay like click.
|
|
290
|
+
const hoverCode = (sel, idx, onEl) => `(() => {${pointerPreamble(sel, idx, onEl)}
|
|
291
|
+
ptr('pointerover', 0); ptr('pointerenter', 0);
|
|
292
|
+
mse('mouseover', 0); mse('mousemove', 0);
|
|
293
|
+
return 'ok';
|
|
294
|
+
})()`;
|
|
295
|
+
// Double click: the full single-click sequence twice, then a dblclick (detail tracks
|
|
296
|
+
// the click ordinal, as a real device reports). Mirrors clickCode's overlay handling.
|
|
297
|
+
const dblclickCode = (sel, idx, onEl) => `(() => {${pointerPreamble(sel, idx, onEl)}
|
|
298
|
+
ptr('pointerover', 0); mse('mouseover', 0); mse('mousemove', 0);
|
|
299
|
+
for (let i = 1; i <= 2; i++) { ptr('pointerdown', 1); mse('mousedown', 1, i); ptr('pointerup', 0); mse('mouseup', 0, i); mse('click', 0, i); }
|
|
300
|
+
mse('dblclick', 0, 2);
|
|
301
|
+
return 'ok';
|
|
302
|
+
})()`;
|
|
303
|
+
// Type char-by-char: keydown/keyup per char, appending to the value (React-safe
|
|
304
|
+
// native setter + input) for input/textarea; for other elements, key events only.
|
|
305
|
+
const typeCode = (sel, val, idx) => `(() => {
|
|
306
|
+
const el = window.__wae.pick(${JSON.stringify(sel)}, ${idxArg(idx)});
|
|
307
|
+
if (!el) return 'gone';
|
|
308
|
+
if (typeof el.focus === 'function') el.focus();
|
|
309
|
+
const text = ${JSON.stringify(val)};
|
|
310
|
+
const isField = el instanceof window.HTMLInputElement || el instanceof window.HTMLTextAreaElement;
|
|
311
|
+
let setter = null;
|
|
312
|
+
if (isField) { const proto = el instanceof window.HTMLTextAreaElement ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype; setter = Object.getOwnPropertyDescriptor(proto, 'value').set; }
|
|
313
|
+
for (const ch of text) {
|
|
314
|
+
const opt = { key: ch, bubbles: true, cancelable: true };
|
|
315
|
+
el.dispatchEvent(new KeyboardEvent('keydown', opt));
|
|
316
|
+
if (setter) { setter.call(el, el.value + ch); el.dispatchEvent(new Event('input', { bubbles: true })); }
|
|
317
|
+
el.dispatchEvent(new KeyboardEvent('keyup', opt));
|
|
318
|
+
}
|
|
319
|
+
if (setter) el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
320
|
+
return 'ok';
|
|
321
|
+
})()`;
|
|
322
|
+
// Select an <option> by value, then by visible label/text. Fires input+change.
|
|
323
|
+
const selectOptionCode = (sel, val, idx) => `(() => {
|
|
324
|
+
const el = window.__wae.pick(${JSON.stringify(sel)}, ${idxArg(idx)});
|
|
325
|
+
if (!el) return 'gone';
|
|
326
|
+
if (el.tagName !== 'SELECT') return 'not-select';
|
|
327
|
+
const want = ${JSON.stringify(val)};
|
|
328
|
+
let matched = false;
|
|
329
|
+
for (const o of Array.prototype.slice.call(el.options)) {
|
|
330
|
+
if (o.value === want || (o.label || o.textContent || '').trim() === want) { el.value = o.value; matched = true; break; }
|
|
331
|
+
}
|
|
332
|
+
if (!matched) return 'no-option';
|
|
333
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
334
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
335
|
+
return 'ok';
|
|
336
|
+
})()`;
|
|
337
|
+
// Check/uncheck a checkbox (or check a radio) via a real click so React's onChange
|
|
338
|
+
// fires; a no-op when already in the desired state. Returns 'failed' if it couldn't.
|
|
339
|
+
const checkCode = (sel, idx, desired) => `(() => {
|
|
340
|
+
const el = window.__wae.pick(${JSON.stringify(sel)}, ${idxArg(idx)});
|
|
341
|
+
if (!el) return 'gone';
|
|
342
|
+
if (el.type !== 'checkbox' && el.type !== 'radio') return 'not-checkbox';
|
|
343
|
+
${desired ? 'if (!el.checked) el.click();' : 'if (el.checked) el.click();'}
|
|
344
|
+
return el.checked === ${desired ? 'true' : 'false'} ? 'ok' : 'failed';
|
|
345
|
+
})()`;
|
|
346
|
+
// Focus an element (native el.focus() dispatches the focus event itself).
|
|
347
|
+
const focusCode = (sel, idx) => `(() => {
|
|
348
|
+
const el = window.__wae.pick(${JSON.stringify(sel)}, ${idxArg(idx)});
|
|
349
|
+
if (!el) return 'gone';
|
|
350
|
+
if (typeof el.focus !== 'function') return 'not-focusable';
|
|
351
|
+
el.focus();
|
|
352
|
+
return 'ok';
|
|
353
|
+
})()`;
|
|
354
|
+
const attrCode = (sel, name, idx) => `(() => {
|
|
355
|
+
const el = window.__wae.pick(${JSON.stringify(sel)}, ${idxArg(idx)});
|
|
356
|
+
return el ? el.getAttribute(${JSON.stringify(name)}) : null;
|
|
357
|
+
})()`;
|
|
358
|
+
// --- drag: press on the element, move across the document, release. Drives the
|
|
359
|
+
// real component handlers (custom knobs/sliders that compute a value from a mouse
|
|
360
|
+
// or pointer drag) — isTrusted is false, so only user-gesture-gated APIs are out
|
|
361
|
+
// of reach; ordinary drag handlers fire normally. mousedown is dispatched on the
|
|
362
|
+
// element; move/up go to `document`, where such widgets attach their listeners.
|
|
363
|
+
const evObj = (x, y, b) => `{ bubbles:true, cancelable:true, composed:true, clientX:${x}, clientY:${y}, button:0, buttons:${b}, view:window }`;
|
|
364
|
+
const ptrLine = (target, type, x, y, b) => `if (window.PointerEvent) ${target}.dispatchEvent(new PointerEvent(${JSON.stringify(type)}, Object.assign({ pointerId:1, pointerType:'mouse', isPrimary:true }, ${evObj(x, y, b)})));`;
|
|
365
|
+
// Mouse-only by default. Pointer events are opt-in (ptr): some widgets (Radix
|
|
366
|
+
// sliders) need them, but others — e.g. a knob whose ancestor starts a drag-to-
|
|
367
|
+
// connect on pointerdown — break under synthetic pointer events, so plain mouse
|
|
368
|
+
// drag is the safe default for value drags.
|
|
369
|
+
const dragDownCode = (sel, idx, x, y, ptr) => `(() => {
|
|
370
|
+
const el = window.__wae.pick(${JSON.stringify(sel)}, ${idxArg(idx)});
|
|
371
|
+
if (!el) return 'gone';
|
|
372
|
+
// Dispatch the press on the resolved element itself (not elementFromPoint): the
|
|
373
|
+
// grab must reach the widget's own handler even if another panel visually overlaps
|
|
374
|
+
// it in the current layout. Subsequent move/up go to document, where drag widgets
|
|
375
|
+
// attach their listeners. Still the real component path (real onChange).
|
|
376
|
+
${ptr ? ptrLine('el', 'pointerdown', x, y, 1) : ''}
|
|
377
|
+
el.dispatchEvent(new MouseEvent('mousedown', ${evObj(x, y, 1)}));
|
|
378
|
+
return 'ok';
|
|
379
|
+
})()`;
|
|
380
|
+
const dragMoveCode = (x, y, ptr) => `(() => {
|
|
381
|
+
${ptr ? ptrLine('document', 'pointermove', x, y, 1) : ''}
|
|
382
|
+
document.dispatchEvent(new MouseEvent('mousemove', ${evObj(x, y, 1)}));
|
|
383
|
+
return 'ok';
|
|
384
|
+
})()`;
|
|
385
|
+
const dragUpCode = (x, y, ptr) => `(() => {
|
|
386
|
+
${ptr ? ptrLine('document', 'pointerup', x, y, 0) : ''}
|
|
387
|
+
document.dispatchEvent(new MouseEvent('mouseup', ${evObj(x, y, 0)}));
|
|
388
|
+
return 'ok';
|
|
389
|
+
})()`;
|
|
390
|
+
// ---- transport: one persistent socket, replies routed by id ---------------
|
|
391
|
+
class Session {
|
|
392
|
+
sock;
|
|
393
|
+
token;
|
|
394
|
+
pending = new Map();
|
|
395
|
+
_id = 0;
|
|
396
|
+
sinkListeners = new Set();
|
|
397
|
+
constructor(sock, token) {
|
|
398
|
+
this.sock = sock;
|
|
399
|
+
this.token = token;
|
|
400
|
+
onJsonLines(sock, (m) => {
|
|
401
|
+
if (m.op === 'sink') {
|
|
402
|
+
this._emitSink(m.event);
|
|
403
|
+
return;
|
|
404
|
+
} // console/network/error stream
|
|
405
|
+
const id = Number(m.id);
|
|
406
|
+
const p = this.pending.get(id);
|
|
407
|
+
if (p) {
|
|
408
|
+
this.pending.delete(id);
|
|
409
|
+
p.resolve(m);
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
sock.on('error', (e) => this._failAll(e));
|
|
413
|
+
sock.on('close', () => this._failAll(new Error('bridge connection closed')));
|
|
414
|
+
}
|
|
415
|
+
// Subscribe to the unsolicited sink stream (the same frames the CLI `logs`
|
|
416
|
+
// command prints). Returns an unsubscribe fn. A listener must never throw.
|
|
417
|
+
onSink(fn) { this.sinkListeners.add(fn); return () => this.sinkListeners.delete(fn); }
|
|
418
|
+
_emitSink(event) {
|
|
419
|
+
if (!event)
|
|
420
|
+
return;
|
|
421
|
+
for (const fn of this.sinkListeners) {
|
|
422
|
+
try {
|
|
423
|
+
fn(event);
|
|
424
|
+
}
|
|
425
|
+
catch { /* a listener must not break the stream */ }
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
_failAll(err) { for (const p of this.pending.values())
|
|
429
|
+
p.reject(err); this.pending.clear(); }
|
|
430
|
+
request(obj, { timeoutMs = 15000 } = {}) {
|
|
431
|
+
return new Promise((resolve, reject) => {
|
|
432
|
+
const id = ++this._id;
|
|
433
|
+
const timer = setTimeout(() => { if (this.pending.delete(id))
|
|
434
|
+
reject(new Error('bridge request timeout')); }, timeoutMs);
|
|
435
|
+
this.pending.set(id, {
|
|
436
|
+
resolve: (v) => { clearTimeout(timer); resolve(v); },
|
|
437
|
+
reject: (e) => { clearTimeout(timer); reject(e); },
|
|
438
|
+
});
|
|
439
|
+
this.sock.write(JSON.stringify({ ...obj, id, ...(this.token ? { token: this.token } : {}) }) + '\n');
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
async evalRaw(code, opts) {
|
|
443
|
+
const r = await this.request({ op: 'eval', code }, opts);
|
|
444
|
+
if (!r.ok)
|
|
445
|
+
throw new Error(r.error || 'eval failed');
|
|
446
|
+
return r.result;
|
|
447
|
+
}
|
|
448
|
+
close() { try {
|
|
449
|
+
this.sock.destroy();
|
|
450
|
+
}
|
|
451
|
+
catch { } }
|
|
452
|
+
}
|
|
453
|
+
// ---- public API -----------------------------------------------------------
|
|
454
|
+
export async function connect({ host = '127.0.0.1', port, token, timeout = 5000, interval = 50, log, logFile, logEcho, backendTimeoutMs = 10000, activate } = {}) {
|
|
455
|
+
// Foreground the host window first (see activateApp) so polling/timers are live
|
|
456
|
+
// by the time state is read; the brief wait lets visibilitychange handlers run.
|
|
457
|
+
if (activate) {
|
|
458
|
+
await activateApp(activate);
|
|
459
|
+
await sleep(300);
|
|
460
|
+
}
|
|
461
|
+
const portHint = port ?? (process.env.WEB_AGENT_PORT ? Number(process.env.WEB_AGENT_PORT) : undefined);
|
|
462
|
+
const disc = loadDiscovery(portHint);
|
|
463
|
+
const resolvedPort = portHint ?? disc.port ?? DEFAULT_PORT;
|
|
464
|
+
const resolvedToken = token ?? process.env.WEB_AGENT_TOKEN ?? disc.token ?? '';
|
|
465
|
+
// Resolve the action logger: an explicit log fn wins; otherwise log to ONE file
|
|
466
|
+
// (logFile / $WAE_LOG_FILE / a default in tmp) so every run leaves a trace.
|
|
467
|
+
const file = (typeof log === 'function') ? null : (logFile || defaultLogFile());
|
|
468
|
+
const logger = (typeof log === 'function') ? log : fileLogger(file, { echo: logEcho ?? true });
|
|
469
|
+
const sock = await new Promise((resolve, reject) => {
|
|
470
|
+
const s = net.connect({ host, port: resolvedPort }, () => resolve(s));
|
|
471
|
+
s.on('error', reject);
|
|
472
|
+
});
|
|
473
|
+
sock.unref(); // the persistent client socket must not, by itself, keep node alive
|
|
474
|
+
const session = new Session(sock, resolvedToken);
|
|
475
|
+
if (resolvedToken)
|
|
476
|
+
await session.request({ op: 'auth', token: resolvedToken });
|
|
477
|
+
await session.request({ op: 'eval', code: PAGE_HELPERS }); // inject helpers once
|
|
478
|
+
const page = new Page(session, { defaultTimeout: timeout, interval, log: logger, backendTimeoutMs });
|
|
479
|
+
page.logFile = file; // where actions are being written (null if a custom log fn was passed)
|
|
480
|
+
return page;
|
|
481
|
+
}
|
|
482
|
+
export class Page {
|
|
483
|
+
session;
|
|
484
|
+
defaultTimeout;
|
|
485
|
+
interval;
|
|
486
|
+
backendTimeoutMs;
|
|
487
|
+
log;
|
|
488
|
+
logFile = null;
|
|
489
|
+
// opts.log?: (msg) => void — when given, every action (click/fill/drag/backend/
|
|
490
|
+
// fire) emits a concise progress line as it runs. Off by default (no-op).
|
|
491
|
+
constructor(session, { defaultTimeout, interval, log, backendTimeoutMs = 10000 }) {
|
|
492
|
+
this.session = session;
|
|
493
|
+
this.defaultTimeout = defaultTimeout;
|
|
494
|
+
this.interval = interval;
|
|
495
|
+
this.backendTimeoutMs = backendTimeoutMs;
|
|
496
|
+
this.log = typeof log === 'function' ? log : () => { };
|
|
497
|
+
}
|
|
498
|
+
locator(selector) { return new Locator(this, selector); }
|
|
499
|
+
getByTestId(id) { return new Locator(this, `[data-testid="${id}"]`); }
|
|
500
|
+
/** Escape hatch: run arbitrary JS in the page and get the result (small results). */
|
|
501
|
+
evaluate(code, opts) { return this.session.evalRaw(code, opts); }
|
|
502
|
+
/** Read a string-valued JS expression in <=chunk slices. WKWebView's
|
|
503
|
+
evaluateJavascript stalls on large (>~100KB) returns, so big values are
|
|
504
|
+
pulled in pieces. `expr` must evaluate to (or stringify to) a string. */
|
|
505
|
+
async readBig(expr, { chunk = 32000, timeoutMs } = {}) {
|
|
506
|
+
const len = await this.session.evalRaw(`window.__wae.chunkInit(${expr})`, { timeoutMs });
|
|
507
|
+
let out = '';
|
|
508
|
+
for (let off = 0; off < len; off += chunk)
|
|
509
|
+
out += await this.session.evalRaw(`window.__wae.chunkAt(${off}, ${chunk})`, { timeoutMs });
|
|
510
|
+
return out;
|
|
511
|
+
}
|
|
512
|
+
/** Structured accessibility snapshot of the page (or a subtree) — a compact
|
|
513
|
+
role/name tree with value/checked/disabled, generic containers flattened away.
|
|
514
|
+
Token-cheap vs outerHTML. Read via readBig so a large tree doesn't stall. */
|
|
515
|
+
async ariaSnapshot() {
|
|
516
|
+
const raw = await this.readBig(`JSON.stringify(window.__wae.ariaSnapshot(document.body))`);
|
|
517
|
+
return JSON.parse(raw);
|
|
518
|
+
}
|
|
519
|
+
/** Invoke a JUCE native function (registered via withNativeFunction) by name and
|
|
520
|
+
await its result. Good for small/medium results. Very large results (>~100KB)
|
|
521
|
+
stall JUCE's C->JS completion delivery regardless of timeout — that ceiling is
|
|
522
|
+
WKWebView's, not the bridge's, so a longer wait will not help. For bulk state,
|
|
523
|
+
read the juce:// resource route instead (e.g. juce://juce.backend/sequencerState.json
|
|
524
|
+
or dialogueGroups.json), or stash the value in a page variable and pull it with
|
|
525
|
+
readBig(). Requires a JUCE WebView host. The completion-poll deadline is the
|
|
526
|
+
connect() `backendTimeoutMs` option (default 10s). */
|
|
527
|
+
async backend(name, ...params) {
|
|
528
|
+
this.log(`backend ${name}(${params.map((p) => JSON.stringify(p)).join(', ')})`);
|
|
529
|
+
const id = await this.session.evalRaw(`window.__wae.invoke(${JSON.stringify(name)}, ${JSON.stringify(params)})`);
|
|
530
|
+
if (id === -1)
|
|
531
|
+
throw new Error('JUCE backend unavailable (window.__JUCE__.backend missing)');
|
|
532
|
+
const deadline = Date.now() + this.backendTimeoutMs;
|
|
533
|
+
for (;;) {
|
|
534
|
+
if ((await this.session.evalRaw(`window.__wae.callDone(${id})`)) === 1)
|
|
535
|
+
break;
|
|
536
|
+
if (Date.now() >= deadline)
|
|
537
|
+
throw new Error(`backend("${name}") timed out`);
|
|
538
|
+
await sleep(this.interval);
|
|
539
|
+
}
|
|
540
|
+
const raw = await this.readBig(`window.__wae.callJson(${id})`);
|
|
541
|
+
try {
|
|
542
|
+
return JSON.parse(raw);
|
|
543
|
+
}
|
|
544
|
+
catch {
|
|
545
|
+
return raw;
|
|
546
|
+
} // tolerate plain-string results
|
|
547
|
+
}
|
|
548
|
+
/** Fire a JUCE native function without awaiting a result (resultId = -1). */
|
|
549
|
+
fireBackend(name, ...params) {
|
|
550
|
+
this.log(`fire ${name}(${params.map((p) => JSON.stringify(p)).join(', ')})`);
|
|
551
|
+
return this.session.evalRaw(`window.__wae.fire(${JSON.stringify(name)}, ${JSON.stringify(params)})`);
|
|
552
|
+
}
|
|
553
|
+
// ---- live page event stream (console / network / error) -----------------
|
|
554
|
+
// The bridge broadcasts captured console/network/error events as sink frames
|
|
555
|
+
// on this same authenticated socket. The capture lives in the host's injected
|
|
556
|
+
// script (withCapture), so a freshly-connected client only sees events from
|
|
557
|
+
// *now on* — set up a wait BEFORE the action that triggers it, Playwright-style:
|
|
558
|
+
// const [resp] = await Promise.all([page.waitForResponse('/api/save'), button.click()]);
|
|
559
|
+
/** Subscribe to live page events. kind: 'console' | 'error' | 'net' | '*'.
|
|
560
|
+
The handler receives the raw sink event { kind, t, data }. Returns an
|
|
561
|
+
unsubscribe fn. (data shapes mirror the CLI `logs` output.) */
|
|
562
|
+
on(kind, handler) {
|
|
563
|
+
return this.session.onSink((ev) => { if (kind === '*' || ev.kind === kind) {
|
|
564
|
+
try {
|
|
565
|
+
handler(ev);
|
|
566
|
+
}
|
|
567
|
+
catch { }
|
|
568
|
+
} });
|
|
569
|
+
}
|
|
570
|
+
/** Resolve with the first sink event of `kind` (optionally matching predicate),
|
|
571
|
+
or reject on timeout. predicate receives the raw event { kind, t, data }. */
|
|
572
|
+
waitForEvent(kind, predicate, { timeout } = {}) {
|
|
573
|
+
if (typeof predicate === 'object' && predicate !== null) {
|
|
574
|
+
timeout = predicate.timeout;
|
|
575
|
+
predicate = undefined;
|
|
576
|
+
}
|
|
577
|
+
const ms = timeout ?? this.defaultTimeout;
|
|
578
|
+
this.log(`waitForEvent ${kind}`);
|
|
579
|
+
return new Promise((resolve, reject) => {
|
|
580
|
+
let off = () => { };
|
|
581
|
+
const timer = setTimeout(() => { off(); reject(new Error(`waitForEvent(${kind}) timed out after ${ms}ms`)); }, ms);
|
|
582
|
+
off = this.session.onSink((ev) => {
|
|
583
|
+
if (kind !== '*' && ev.kind !== kind)
|
|
584
|
+
return;
|
|
585
|
+
if (predicate) {
|
|
586
|
+
let ok = false;
|
|
587
|
+
try {
|
|
588
|
+
ok = !!predicate(ev);
|
|
589
|
+
}
|
|
590
|
+
catch { }
|
|
591
|
+
if (!ok)
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
clearTimeout(timer);
|
|
595
|
+
off();
|
|
596
|
+
resolve(ev);
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
/** Resolve with the network event `data` for the first fetch/XHR whose URL
|
|
601
|
+
contains `urlOrPredicate` (string) or for which predicate(data) is true.
|
|
602
|
+
Mirrors Playwright's page.waitForResponse over the observe-only net stream. */
|
|
603
|
+
waitForResponse(urlOrPredicate, opts = {}) {
|
|
604
|
+
const match = typeof urlOrPredicate === 'function'
|
|
605
|
+
? (ev) => { try {
|
|
606
|
+
return !!urlOrPredicate(ev.data);
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
return false;
|
|
610
|
+
} }
|
|
611
|
+
: (ev) => typeof ev.data.url === 'string' && ev.data.url.includes(urlOrPredicate);
|
|
612
|
+
this.log(`waitForResponse ${typeof urlOrPredicate === 'function' ? '<predicate>' : urlOrPredicate}`);
|
|
613
|
+
return this.waitForEvent('net', match, opts).then((ev) => ev.data);
|
|
614
|
+
}
|
|
615
|
+
/** Ask the host to re-send buffered sink events with seq > since (default 0) on
|
|
616
|
+
THIS socket — catch-up without the read-backlog / open-stream race. Replayed
|
|
617
|
+
events flow through the same on()/waitForEvent listeners (each carries a
|
|
618
|
+
monotonic `seq` for dedup). Resolves with the number of events replayed. */
|
|
619
|
+
async replayEvents({ since = 0 } = {}) {
|
|
620
|
+
const r = await this.session.request({ op: 'sink_replay', since });
|
|
621
|
+
return r.count;
|
|
622
|
+
}
|
|
623
|
+
/** Poll a JS boolean expression in the page until it is truthy (or time out).
|
|
624
|
+
`expr` is evaluated as `!!(expr)`; exceptions count as not-yet-true. */
|
|
625
|
+
async waitForFunction(expr, { timeout, interval } = {}) {
|
|
626
|
+
const deadline = Date.now() + (timeout ?? this.defaultTimeout);
|
|
627
|
+
const code = `(() => { try { return !!(${expr}); } catch (e) { return false; } })()`;
|
|
628
|
+
for (;;) {
|
|
629
|
+
if (await this.session.evalRaw(code) === true)
|
|
630
|
+
return;
|
|
631
|
+
if (Date.now() >= deadline)
|
|
632
|
+
throw new Error(`waitForFunction timed out after ${timeout ?? this.defaultTimeout}ms: ${expr}`);
|
|
633
|
+
await sleep(interval ?? this.interval);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
/** Evaluate a small JS expression repeatedly until pred(value) holds (or timeout).
|
|
637
|
+
Settle primitive — replaces fixed sleeps after an action. Unlike waitForFunction
|
|
638
|
+
it returns the LAST VALUE seen (never throws on timeout), so the caller asserts
|
|
639
|
+
on it and a timeout surfaces as a normal assertion failure with the real value. */
|
|
640
|
+
async poll(expr, pred, { timeout, interval = 100 } = {}) {
|
|
641
|
+
const deadline = Date.now() + (timeout ?? this.defaultTimeout);
|
|
642
|
+
let v;
|
|
643
|
+
for (;;) {
|
|
644
|
+
v = JSON.parse(await this.session.evalRaw(`JSON.stringify(${expr} ?? null)`));
|
|
645
|
+
if (pred(v))
|
|
646
|
+
return v;
|
|
647
|
+
if (Date.now() >= deadline)
|
|
648
|
+
return v;
|
|
649
|
+
await sleep(interval);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
/** Read a small expression until it stops changing (`settles` equal reads in a row)
|
|
653
|
+
or timeout — for values that ramp over several frames (a knob drag updates via
|
|
654
|
+
rAF + a native round-trip), so assertions see the SETTLED value, not a mid-ramp
|
|
655
|
+
one. Returns the last value read. */
|
|
656
|
+
async pollStable(expr, { timeout, interval = 120, settles = 2 } = {}) {
|
|
657
|
+
const deadline = Date.now() + (timeout ?? this.defaultTimeout);
|
|
658
|
+
let last = await this.session.evalRaw(`JSON.stringify(${expr} ?? null)`);
|
|
659
|
+
let stable = 0;
|
|
660
|
+
for (;;) {
|
|
661
|
+
await sleep(interval);
|
|
662
|
+
const cur = await this.session.evalRaw(`JSON.stringify(${expr} ?? null)`);
|
|
663
|
+
if (cur === last) {
|
|
664
|
+
if (++stable >= settles)
|
|
665
|
+
return JSON.parse(cur);
|
|
666
|
+
}
|
|
667
|
+
else {
|
|
668
|
+
stable = 0;
|
|
669
|
+
last = cur;
|
|
670
|
+
}
|
|
671
|
+
if (Date.now() >= deadline)
|
|
672
|
+
return JSON.parse(cur);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
/** Main-thread render-perf probe. Over durationMs, measures React commit rate
|
|
676
|
+
(via the DevTools onCommitFiberRoot hook, when present) and rAF frame-gap
|
|
677
|
+
percentiles — the UI thread any canvas/Pixi/WebGL animation shares, so a high
|
|
678
|
+
p99 gap IS a visible stutter. Gap thresholds are refresh-relative (median gap
|
|
679
|
+
= the monitor's frame period), so numbers are valid on 60/120/144Hz alike.
|
|
680
|
+
Pass { motionSelector } (a querySelectorAll selector) to also report `motion`:
|
|
681
|
+
whether any matched element's `d`/`transform`/`style` changed during the
|
|
682
|
+
window (e.g. "did the modulated knobs actually move"). */
|
|
683
|
+
async measureRenderPerf({ durationMs = 5000, motionSelector = null } = {}) {
|
|
684
|
+
this.log(`measureRenderPerf ${durationMs}ms`);
|
|
685
|
+
const sigExpr = motionSelector
|
|
686
|
+
? `[...document.querySelectorAll(${JSON.stringify(motionSelector)})].map(e=>(e.getAttribute('d')||'')+(e.getAttribute('transform')||'')+(e.getAttribute('style')||'')).join('|')`
|
|
687
|
+
: `''`;
|
|
688
|
+
await this.session.evalRaw(`(function(){
|
|
689
|
+
const J={running:true,frames:0,maxGap:0,last:performance.now(),t0:performance.now(),gaps:[],commits:0,n24:0,n50:0};
|
|
690
|
+
window.__waePerfProbe=J;
|
|
691
|
+
J.sig0=${sigExpr};
|
|
692
|
+
const h=window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
693
|
+
if(h&&h.onCommitFiberRoot){J.orig=h.onCommitFiberRoot;h.onCommitFiberRoot=function(){J.commits++;return J.orig.apply(this,arguments);};}
|
|
694
|
+
function t(){if(!J.running)return;const n=performance.now();const g=n-J.last;J.last=n;J.frames++;if(J.frames>2){if(g>J.maxGap)J.maxGap=g;if(g>24)J.n24++;if(g>50)J.n50++;J.gaps.push(g);}requestAnimationFrame(t);}
|
|
695
|
+
requestAnimationFrame(t);return 1;
|
|
696
|
+
})()`);
|
|
697
|
+
await sleep(durationMs);
|
|
698
|
+
return JSON.parse(await this.readBig(`(function(){
|
|
699
|
+
const J=window.__waePerfProbe;J.running=false;
|
|
700
|
+
const h=window.__REACT_DEVTOOLS_GLOBAL_HOOK__;if(h&&J.orig)h.onCommitFiberRoot=J.orig;
|
|
701
|
+
const d=performance.now()-J.t0;const g=J.gaps.slice().sort((a,b)=>a-b);const p=q=>Math.round(g[Math.floor(g.length*q)]||0);
|
|
702
|
+
const sig=${sigExpr};
|
|
703
|
+
const med=g[Math.floor(g.length*0.5)]||16.7;const hz=Math.round(1000/med);
|
|
704
|
+
const dropRel=g.filter(x=>x>med*1.5).length;const drop2x=g.filter(x=>x>med*2).length;
|
|
705
|
+
return JSON.stringify({durMs:Math.round(d),commitsPerSec:Math.round(J.commits/(d/1000)),p50gap:p(.5),p95gap:p(.95),p99gap:p(.99),maxGap:Math.round(J.maxGap),framesOver24:J.n24,framesOver50:J.n50,measuredHz:hz,frameBudgetMs:Math.round(med*100)/100,framesDroppedRel:dropRel,framesDropped2x:drop2x,p99Frames:Math.round((p(.99)/med)*100)/100,motion:sig!==J.sig0});
|
|
706
|
+
})()`));
|
|
707
|
+
}
|
|
708
|
+
/** Capabilities handshake: { protocolVersion, platform, ops, screenshotAvailable,
|
|
709
|
+
authRequired }. Lets a caller branch on what the host supports (e.g. skip
|
|
710
|
+
screenshots when screenshotAvailable is false) without probing op-by-op. */
|
|
711
|
+
async capabilities() {
|
|
712
|
+
const r = await this.session.request({ op: 'hello' });
|
|
713
|
+
return {
|
|
714
|
+
protocolVersion: r.protocolVersion, platform: r.platform, ops: r.ops,
|
|
715
|
+
screenshotAvailable: r.screenshotAvailable, authRequired: r.authRequired,
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
/** Toggle WebKit's compositing debug overlays (layer borders + repaint
|
|
719
|
+
counters) on the host's WKWebView via the bridge `layerdebug` op. The
|
|
720
|
+
overlays render into the window, so `screenshot()` captures them — count
|
|
721
|
+
layers / attribute repaints from a script, no Web Inspector session.
|
|
722
|
+
macOS-only; throws where the backend has no such SPI. Remember to turn it
|
|
723
|
+
OFF before any pixel-comparison capture: the overlays are pixels too. */
|
|
724
|
+
async layerDebug(enabled = true) {
|
|
725
|
+
this.log(`layerdebug ${enabled ? 'on' : 'off'}`);
|
|
726
|
+
const r = await this.session.request({ op: 'layerdebug', enabled }, { timeoutMs: 10000 });
|
|
727
|
+
if (!r.ok)
|
|
728
|
+
throw new Error(r.error || 'layerdebug unavailable');
|
|
729
|
+
return true;
|
|
730
|
+
}
|
|
731
|
+
/** Dump the WKWebView's remote CALayer tree as text via the bridge
|
|
732
|
+
`layertree` op — the programmatic counterpart of layerDebug(): parse it
|
|
733
|
+
to census compositing layers (count, geometry) from a script instead of
|
|
734
|
+
reading overlay pixels off a screenshot. macOS-only; throws elsewhere. */
|
|
735
|
+
async layerTree() {
|
|
736
|
+
this.log('layertree');
|
|
737
|
+
const r = await this.session.request({ op: 'layertree' }, { timeoutMs: 10000 });
|
|
738
|
+
if (!r.ok)
|
|
739
|
+
throw new Error(r.error || 'layertree unavailable');
|
|
740
|
+
return r.text;
|
|
741
|
+
}
|
|
742
|
+
/** Native screenshot of the host window (incl. WebGL) via the bridge `shot` op.
|
|
743
|
+
Writes a PNG host-side and returns its path. Pass { path } to choose where, and
|
|
744
|
+
{ clip: {x,y,w,h} } (CSS px) to crop to a UI region for a much smaller PNG. */
|
|
745
|
+
async screenshot({ path, clip } = {}) {
|
|
746
|
+
this.log(`screenshot${clip ? ' (region)' : ''}${path ? ' ' + path : ''}`);
|
|
747
|
+
const r = await this.session.request({ op: 'shot', ...(path ? { path } : {}), ...(clip ? { rect: clip } : {}) }, { timeoutMs: 30000 });
|
|
748
|
+
if (!r.ok)
|
|
749
|
+
throw new Error(r.error || 'native screenshot failed');
|
|
750
|
+
return r.path;
|
|
751
|
+
}
|
|
752
|
+
close() { this.session.close(); }
|
|
753
|
+
}
|
|
754
|
+
export class Locator {
|
|
755
|
+
page;
|
|
756
|
+
selector;
|
|
757
|
+
index;
|
|
758
|
+
constructor(page, selector, index = null) { this.page = page; this.selector = selector; this.index = index; }
|
|
759
|
+
/** Narrow to the i-th match (0-based); negative or null = last match. */
|
|
760
|
+
nth(i) { return new Locator(this.page, this.selector, i); }
|
|
761
|
+
first() { return this.nth(0); }
|
|
762
|
+
async _probe(opts) {
|
|
763
|
+
const r = await this.page.session.evalRaw(probeCode(this.selector, this.index), opts);
|
|
764
|
+
return typeof r === 'string' ? JSON.parse(r) : r;
|
|
765
|
+
}
|
|
766
|
+
async count() { return (await this._probe()).n; }
|
|
767
|
+
async isVisible() { const p = await this._probe(); return !!(p.state && p.state.visible); }
|
|
768
|
+
async textContent() { const p = await this._probe(); return p.state ? p.state.text : null; }
|
|
769
|
+
async getAttribute(name) { return this.page.session.evalRaw(attrCode(this.selector, name, this.index)); }
|
|
770
|
+
// Actionability wait shared by the pointer actions: visible (+ enabled + hit
|
|
771
|
+
// unless relaxed) AND a box that held still across two consecutive polls.
|
|
772
|
+
// force skips hit-testing + stability entirely (visible is enough) — for a
|
|
773
|
+
// control sitting under a (often decorative) overlay that a centre-point
|
|
774
|
+
// hit-test would otherwise see as occluded.
|
|
775
|
+
_waitStable({ needEnabled = true, force, timeout, what }) {
|
|
776
|
+
let prevBox = null;
|
|
777
|
+
return this._waitUntil((p) => {
|
|
778
|
+
if (force)
|
|
779
|
+
return !!(p.state && p.state.visible);
|
|
780
|
+
const ok = p.state && p.state.visible && (!needEnabled || p.state.enabled) && p.state.hit;
|
|
781
|
+
const box = p.state && p.state.box;
|
|
782
|
+
const stable = box && prevBox && box.x === prevBox.x && box.y === prevBox.y && box.w === prevBox.w && box.h === prevBox.h;
|
|
783
|
+
prevBox = box;
|
|
784
|
+
return !!(ok && stable);
|
|
785
|
+
}, timeout, what);
|
|
786
|
+
}
|
|
787
|
+
async click({ timeout, force } = {}) {
|
|
788
|
+
this.page.log(`click ${this.selector}${this.index != null ? `[${this.index}]` : ''}${force ? ' (force)' : ''}`);
|
|
789
|
+
await this._waitStable({ force, timeout, what: 'click' });
|
|
790
|
+
const r = await this.page.session.evalRaw(clickCode(this.selector, this.index, !!force));
|
|
791
|
+
if (r !== 'ok')
|
|
792
|
+
throw new Error(`click failed on ${JSON.stringify(this.selector)}: ${r}`);
|
|
793
|
+
}
|
|
794
|
+
// enter: after setting the value, press Enter + blur so inputs that commit on
|
|
795
|
+
// keydown/blur (not on every change) actually persist.
|
|
796
|
+
async fill(value, { timeout, enter } = {}) {
|
|
797
|
+
this.page.log(`fill ${this.selector} = ${JSON.stringify(value)}${enter ? ' ⏎' : ''}`);
|
|
798
|
+
await this._waitUntil((p) => !!(p.state && p.state.visible && p.state.enabled && p.state.editable), timeout, 'fill');
|
|
799
|
+
const r = await this.page.session.evalRaw(fillCode(this.selector, value, this.index));
|
|
800
|
+
if (r !== 'ok')
|
|
801
|
+
throw new Error(`fill failed on ${JSON.stringify(this.selector)}: ${r}`);
|
|
802
|
+
if (enter)
|
|
803
|
+
await this.page.session.evalRaw(pressKeyCode(this.selector, 'Enter', this.index, true));
|
|
804
|
+
}
|
|
805
|
+
// ---- more element-level actions (all wait for actionability first) ------
|
|
806
|
+
/** Hover the element centre (pointerover/mouseover) — opens hover menus/tooltips.
|
|
807
|
+
Hover doesn't require enabled: tooltips on disabled controls are legitimate. */
|
|
808
|
+
async hover({ timeout, force } = {}) {
|
|
809
|
+
this.page.log(`hover ${this.selector}${force ? ' (force)' : ''}`);
|
|
810
|
+
await this._waitStable({ needEnabled: false, force, timeout, what: 'hover' });
|
|
811
|
+
const r = await this.page.session.evalRaw(hoverCode(this.selector, this.index, !!force));
|
|
812
|
+
if (r !== 'ok')
|
|
813
|
+
throw new Error(`hover failed on ${JSON.stringify(this.selector)}: ${r}`);
|
|
814
|
+
}
|
|
815
|
+
/** Double click (full single-click sequence twice + dblclick). */
|
|
816
|
+
async dblclick({ timeout, force } = {}) {
|
|
817
|
+
this.page.log(`dblclick ${this.selector}${force ? ' (force)' : ''}`);
|
|
818
|
+
await this._waitStable({ force, timeout, what: 'dblclick' });
|
|
819
|
+
const r = await this.page.session.evalRaw(dblclickCode(this.selector, this.index, !!force));
|
|
820
|
+
if (r !== 'ok')
|
|
821
|
+
throw new Error(`dblclick failed on ${JSON.stringify(this.selector)}: ${r}`);
|
|
822
|
+
}
|
|
823
|
+
/** Type char-by-char (per-key events) — for inputs that react to keydown, not
|
|
824
|
+
just value changes. Use fill() for a one-shot set; type() for keystroke fidelity. */
|
|
825
|
+
async type(value, { timeout } = {}) {
|
|
826
|
+
this.page.log(`type ${this.selector} = ${JSON.stringify(value)}`);
|
|
827
|
+
await this._waitUntil((p) => !!(p.state && p.state.visible && p.state.enabled), timeout, 'type');
|
|
828
|
+
const r = await this.page.session.evalRaw(typeCode(this.selector, value, this.index));
|
|
829
|
+
if (r !== 'ok')
|
|
830
|
+
throw new Error(`type failed on ${JSON.stringify(this.selector)}: ${r}`);
|
|
831
|
+
}
|
|
832
|
+
/** Press a single key on the element (keydown+keyup), keeping focus. */
|
|
833
|
+
async press(key, { timeout } = {}) {
|
|
834
|
+
this.page.log(`press ${this.selector} ${key}`);
|
|
835
|
+
await this._waitUntil((p) => !!(p.state && p.state.visible && p.state.enabled), timeout, 'press');
|
|
836
|
+
const r = await this.page.session.evalRaw(pressKeyCode(this.selector, key, this.index, false));
|
|
837
|
+
if (r !== 'ok')
|
|
838
|
+
throw new Error(`press failed on ${JSON.stringify(this.selector)}: ${r}`);
|
|
839
|
+
}
|
|
840
|
+
/** Select an <option> by value (then by visible label/text). */
|
|
841
|
+
async selectOption(value, { timeout } = {}) {
|
|
842
|
+
this.page.log(`selectOption ${this.selector} = ${JSON.stringify(value)}`);
|
|
843
|
+
await this._waitUntil((p) => !!(p.state && p.state.visible && p.state.enabled), timeout, 'selectOption');
|
|
844
|
+
const r = await this.page.session.evalRaw(selectOptionCode(this.selector, value, this.index));
|
|
845
|
+
if (r !== 'ok')
|
|
846
|
+
throw new Error(`selectOption failed on ${JSON.stringify(this.selector)}: ${r}`);
|
|
847
|
+
}
|
|
848
|
+
/** Ensure a checkbox/radio is checked (no-op if already). */
|
|
849
|
+
async check({ timeout } = {}) { await this._setChecked(true, timeout); }
|
|
850
|
+
/** Ensure a checkbox is unchecked (no-op if already). */
|
|
851
|
+
async uncheck({ timeout } = {}) { await this._setChecked(false, timeout); }
|
|
852
|
+
async _setChecked(desired, timeout) {
|
|
853
|
+
const what = desired ? 'check' : 'uncheck';
|
|
854
|
+
this.page.log(`${what} ${this.selector}`);
|
|
855
|
+
await this._waitUntil((p) => !!(p.state && p.state.visible && p.state.enabled), timeout, what);
|
|
856
|
+
const r = await this.page.session.evalRaw(checkCode(this.selector, this.index, desired));
|
|
857
|
+
if (r !== 'ok')
|
|
858
|
+
throw new Error(`${what} failed on ${JSON.stringify(this.selector)}: ${r}`);
|
|
859
|
+
}
|
|
860
|
+
/** Focus the element. */
|
|
861
|
+
async focus({ timeout } = {}) {
|
|
862
|
+
this.page.log(`focus ${this.selector}`);
|
|
863
|
+
await this._waitUntil((p) => !!(p.state && p.state.visible), timeout, 'focus');
|
|
864
|
+
const r = await this.page.session.evalRaw(focusCode(this.selector, this.index));
|
|
865
|
+
if (r !== 'ok')
|
|
866
|
+
throw new Error(`focus failed on ${JSON.stringify(this.selector)}: ${r}`);
|
|
867
|
+
}
|
|
868
|
+
/** Structured accessibility snapshot rooted at this element (see Page.ariaSnapshot). */
|
|
869
|
+
async ariaSnapshot({ timeout } = {}) {
|
|
870
|
+
await this._waitUntil((p) => !!(p.state && p.state.visible), timeout, 'ariaSnapshot');
|
|
871
|
+
const expr = `JSON.stringify((() => { const el = window.__wae.pick(${JSON.stringify(this.selector)}, ${idxArg(this.index)}); return el ? window.__wae.ariaSnapshot(el) : null; })())`;
|
|
872
|
+
const raw = await this.page.readBig(expr);
|
|
873
|
+
return JSON.parse(raw);
|
|
874
|
+
}
|
|
875
|
+
/** Native screenshot cropped to this element's bounding box (a small PNG — only
|
|
876
|
+
the element's pixels, so far cheaper to read back than a full-window shot). */
|
|
877
|
+
async screenshot({ path, timeout } = {}) {
|
|
878
|
+
this.page.log(`screenshot ${this.selector}`);
|
|
879
|
+
const p = await this._waitUntil((q) => !!(q.state && q.state.visible), timeout, 'screenshot');
|
|
880
|
+
const b = p.state.box;
|
|
881
|
+
const clip = { x: Math.round(b.x), y: Math.round(b.y), w: Math.round(b.w), h: Math.round(b.h) };
|
|
882
|
+
return this.page.screenshot({ path, clip });
|
|
883
|
+
}
|
|
884
|
+
/** Press at the element's centre and drag by (dx, dy) px (default vertical, for
|
|
885
|
+
knobs/sliders). steps interpolates the move; settleMs lets the component
|
|
886
|
+
attach its document move/up listeners (a React effect) after mousedown. */
|
|
887
|
+
async drag({ dx = 0, dy = 0, steps = 10, settleMs = 160, stepMs = 16, pointer = false, timeout } = {}) {
|
|
888
|
+
this.page.log(`drag ${this.selector} dx=${dx} dy=${dy}`);
|
|
889
|
+
// Require visible + enabled, but NOT hit-testing: the press is dispatched on
|
|
890
|
+
// the resolved element directly (dragDownCode), so an overlapping panel in the
|
|
891
|
+
// current layout doesn't block grabbing the real widget.
|
|
892
|
+
let cx = 0, cy = 0;
|
|
893
|
+
await this._waitUntil((p) => {
|
|
894
|
+
const ok = p.state && p.state.visible && p.state.enabled;
|
|
895
|
+
if (ok) {
|
|
896
|
+
const b = p.state.box;
|
|
897
|
+
cx = b.x + b.w / 2;
|
|
898
|
+
cy = b.y + b.h / 2;
|
|
899
|
+
}
|
|
900
|
+
return !!ok;
|
|
901
|
+
}, timeout, 'drag');
|
|
902
|
+
let r = await this.page.session.evalRaw(dragDownCode(this.selector, this.index, cx, cy, pointer));
|
|
903
|
+
if (r !== 'ok')
|
|
904
|
+
throw new Error(`drag mousedown failed on ${JSON.stringify(this.selector)}: ${r}`);
|
|
905
|
+
await sleep(settleMs); // let the widget's onMouseDown effect attach its document listeners
|
|
906
|
+
for (let s = 1; s <= steps; s++) {
|
|
907
|
+
await this.page.session.evalRaw(dragMoveCode(cx + (dx * s) / steps, cy + (dy * s) / steps, pointer));
|
|
908
|
+
if (stepMs)
|
|
909
|
+
await sleep(stepMs);
|
|
910
|
+
}
|
|
911
|
+
await this.page.session.evalRaw(dragUpCode(cx + dx, cy + dy, pointer));
|
|
912
|
+
}
|
|
913
|
+
async waitFor({ state = 'visible', timeout } = {}) {
|
|
914
|
+
await this._waitUntil((p) => {
|
|
915
|
+
if (state === 'attached')
|
|
916
|
+
return p.n > 0;
|
|
917
|
+
if (state === 'detached')
|
|
918
|
+
return p.n === 0;
|
|
919
|
+
if (state === 'hidden')
|
|
920
|
+
return !(p.state && p.state.visible);
|
|
921
|
+
return !!(p.state && p.state.visible); // 'visible'
|
|
922
|
+
}, timeout, `waitFor:${state}`);
|
|
923
|
+
}
|
|
924
|
+
// Poll the probe until pred(probe) holds, or throw a descriptive timeout.
|
|
925
|
+
async _waitUntil(pred, timeout, what) {
|
|
926
|
+
const deadline = Date.now() + (timeout ?? this.page.defaultTimeout);
|
|
927
|
+
let last;
|
|
928
|
+
for (;;) {
|
|
929
|
+
last = await this._probe();
|
|
930
|
+
if (pred(last))
|
|
931
|
+
return last;
|
|
932
|
+
if (Date.now() >= deadline)
|
|
933
|
+
throw new Error(`locator(${JSON.stringify(this.selector)}) not ready for "${what}" within ${timeout ?? this.page.defaultTimeout}ms `
|
|
934
|
+
+ `(n=${last.n}, state=${JSON.stringify(last.state)})`);
|
|
935
|
+
await sleep(this.page.interval);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
const safeJson = (v) => { try {
|
|
940
|
+
return JSON.stringify(v);
|
|
941
|
+
}
|
|
942
|
+
catch {
|
|
943
|
+
return String(v);
|
|
944
|
+
} };
|
|
945
|
+
function expectLocator(locator) {
|
|
946
|
+
// make(invert) builds the matcher set; expect(loc).not.* reuses it with the
|
|
947
|
+
// predicate negated (each matcher auto-retries until it holds, or times out).
|
|
948
|
+
const make = (invert) => {
|
|
949
|
+
const wait = (pred, what, opts) => locator._waitUntil((p) => pred(p) !== invert, opts && opts.timeout, (invert ? 'not.' : '') + what);
|
|
950
|
+
return {
|
|
951
|
+
toBeVisible: (o) => wait((p) => !!(p.state && p.state.visible), 'toBeVisible', o),
|
|
952
|
+
toBeHidden: (o) => wait((p) => !(p.state && p.state.visible), 'toBeHidden', o),
|
|
953
|
+
toBeEnabled: (o) => wait((p) => !!(p.state && p.state.enabled), 'toBeEnabled', o),
|
|
954
|
+
toBeDisabled: (o) => wait((p) => !!(p.state && !p.state.enabled), 'toBeDisabled', o),
|
|
955
|
+
toBeChecked: (o) => wait((p) => !!(p.state && p.state.checked), 'toBeChecked', o),
|
|
956
|
+
toHaveCount: (n, o) => wait((p) => p.n === n, `toHaveCount:${n}`, o),
|
|
957
|
+
toHaveText: (expected, o) => wait((p) => !!(p.state && (expected instanceof RegExp ? expected.test(p.state.text) : p.state.text === expected)), `toHaveText:${expected}`, o),
|
|
958
|
+
toContainText: (sub, o) => wait((p) => !!(p.state && p.state.text.includes(sub)), `toContainText:${sub}`, o),
|
|
959
|
+
toHaveValue: (expected, o) => wait((p) => !!(p.state && p.state.value != null && (expected instanceof RegExp ? expected.test(p.state.value) : p.state.value === expected)), `toHaveValue:${expected}`, o),
|
|
960
|
+
};
|
|
961
|
+
};
|
|
962
|
+
return { ...make(false), not: make(true) };
|
|
963
|
+
}
|
|
964
|
+
const pollExpect = (fn, { timeout = 5000, interval = 50, message } = {}) => {
|
|
965
|
+
const make = (invert) => {
|
|
966
|
+
const run = (check, desc) => (async () => {
|
|
967
|
+
const deadline = Date.now() + timeout;
|
|
968
|
+
let last;
|
|
969
|
+
for (;;) {
|
|
970
|
+
try {
|
|
971
|
+
last = await fn();
|
|
972
|
+
}
|
|
973
|
+
catch {
|
|
974
|
+
last = undefined;
|
|
975
|
+
}
|
|
976
|
+
if (check(last) !== invert)
|
|
977
|
+
return last;
|
|
978
|
+
if (Date.now() >= deadline)
|
|
979
|
+
throw new Error(`${message ? message + ': ' : ''}expect.poll ${invert ? 'not ' : ''}${desc} not met within ${timeout}ms (last=${safeJson(last)})`);
|
|
980
|
+
await sleep(interval);
|
|
981
|
+
}
|
|
982
|
+
})();
|
|
983
|
+
return {
|
|
984
|
+
toBe: (v) => run((x) => x === v, `toBe(${safeJson(v)})`),
|
|
985
|
+
toEqual: (v) => run((x) => safeJson(x) === safeJson(v), `toEqual(${safeJson(v)})`),
|
|
986
|
+
toBeTruthy: () => run((x) => !!x, 'toBeTruthy'),
|
|
987
|
+
toBeFalsy: () => run((x) => !x, 'toBeFalsy'),
|
|
988
|
+
toContain: (s) => run((x) => x != null && (Array.isArray(x) ? x.includes(s) : String(x).includes(String(s))), `toContain(${safeJson(s)})`),
|
|
989
|
+
toBeGreaterThan: (n) => run((x) => Number(x) > n, `toBeGreaterThan(${n})`),
|
|
990
|
+
toBeGreaterThanOrEqual: (n) => run((x) => Number(x) >= n, `toBeGreaterThanOrEqual(${n})`),
|
|
991
|
+
toBeLessThan: (n) => run((x) => Number(x) < n, `toBeLessThan(${n})`),
|
|
992
|
+
toBeLessThanOrEqual: (n) => run((x) => Number(x) <= n, `toBeLessThanOrEqual(${n})`),
|
|
993
|
+
toSatisfy: (pred) => run((x) => !!pred(x), 'toSatisfy(<predicate>)'),
|
|
994
|
+
};
|
|
995
|
+
};
|
|
996
|
+
return { ...make(false), not: make(true) };
|
|
997
|
+
};
|
|
998
|
+
export const expect = Object.assign(expectLocator, { poll: pollExpect });
|