koneck 2.128.59 → 2.128.61
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/browser.d.ts +180 -1
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +503 -28
- package/dist/browser.js.map +1 -1
- package/dist/element-ref.d.ts +32 -0
- package/dist/element-ref.d.ts.map +1 -0
- package/dist/element-ref.js +116 -0
- package/dist/element-ref.js.map +1 -0
- package/dist/source-lookup.d.ts +42 -0
- package/dist/source-lookup.d.ts.map +1 -0
- package/dist/source-lookup.js +122 -0
- package/dist/source-lookup.js.map +1 -0
- package/dist/tools.d.ts +13 -1
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +127 -18
- package/dist/tools.js.map +1 -1
- package/dist/web/api.d.ts.map +1 -1
- package/dist/web/api.js +82 -4
- package/dist/web/api.js.map +1 -1
- package/dist/web/events.d.ts +2 -0
- package/dist/web/events.d.ts.map +1 -1
- package/dist/web/events.js.map +1 -1
- package/dist/web/sessions.d.ts +2 -1
- package/dist/web/sessions.d.ts.map +1 -1
- package/dist/web/sessions.js +5 -2
- package/dist/web/sessions.js.map +1 -1
- package/dist/web/ui-client.d.ts.map +1 -1
- package/dist/web/ui-client.js +338 -10
- package/dist/web/ui-client.js.map +1 -1
- package/dist/web/ui-css.d.ts.map +1 -1
- package/dist/web/ui-css.js +84 -5
- package/dist/web/ui-css.js.map +1 -1
- package/dist/web/ui.d.ts.map +1 -1
- package/dist/web/ui.js +13 -1
- package/dist/web/ui.js.map +1 -1
- package/package.json +1 -1
package/dist/browser.js
CHANGED
|
@@ -184,8 +184,10 @@ export function patienceFrom(value) {
|
|
|
184
184
|
}
|
|
185
185
|
/** Page text handed back, capped. A whole document is not an answer. */
|
|
186
186
|
const MAX_TEXT = 12_000;
|
|
187
|
-
/** Interactive elements listed. Beyond this a page is a haystack
|
|
187
|
+
/** Interactive elements listed at once. Beyond this a page is a haystack; `from` pages through it. */
|
|
188
188
|
const MAX_CONTROLS = 60;
|
|
189
|
+
/** How many controls a page is searched for in all, so a long one can be paged. */
|
|
190
|
+
const MAX_COLLECTED = 400;
|
|
189
191
|
/**
|
|
190
192
|
* The soonest a page is called settled.
|
|
191
193
|
*
|
|
@@ -241,6 +243,101 @@ export function windowSize(configured) {
|
|
|
241
243
|
return '1280,900';
|
|
242
244
|
return `${Number(m[1])},${Number(m[2])}`;
|
|
243
245
|
}
|
|
246
|
+
/**
|
|
247
|
+
* Why an address will not be opened, or null when it may be.
|
|
248
|
+
*
|
|
249
|
+
* `open` took any scheme at all, in a browser started without its sandbox: file:// anywhere on the
|
|
250
|
+
* disk, javascript:, the browser's own chrome:// pages. The browser is for the web — the app being
|
|
251
|
+
* built, its docs, the sites it talks to — so http and https go anywhere, a file only inside the
|
|
252
|
+
* workspace, and schemes that are the browser's own or that run code are refused outright.
|
|
253
|
+
*/
|
|
254
|
+
export function refuseUrl(url, fileRoots) {
|
|
255
|
+
const scheme = (/^([a-z][a-z0-9+.-]*):/i.exec(url)?.[1] ?? '').toLowerCase();
|
|
256
|
+
if (scheme === 'http' || scheme === 'https' || url === 'about:blank')
|
|
257
|
+
return null;
|
|
258
|
+
if (scheme === 'file') {
|
|
259
|
+
let file;
|
|
260
|
+
try {
|
|
261
|
+
file = path.resolve(decodeURIComponent(new URL(url).pathname).replace(/^\/([a-z]:)/i, '$1'));
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
return `${url} is not a file address that can be read.`;
|
|
265
|
+
}
|
|
266
|
+
const inside = fileRoots.some(root => {
|
|
267
|
+
const rel = path.relative(path.resolve(root), file);
|
|
268
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
269
|
+
});
|
|
270
|
+
return inside ? null
|
|
271
|
+
: `${url} is outside the workspace. The browser opens files only from inside it; serve the `
|
|
272
|
+
+ `page, or copy it into the workspace, to look at it.`;
|
|
273
|
+
}
|
|
274
|
+
return `${scheme || 'That'}: addresses are not opened — the browser is for http and https pages, `
|
|
275
|
+
+ `and files inside the workspace.`;
|
|
276
|
+
}
|
|
277
|
+
const NAMED_KEYS = {
|
|
278
|
+
enter: { code: 'Enter', key: 'Enter', windowsVirtualKeyCode: 13, text: '\r' },
|
|
279
|
+
tab: { code: 'Tab', key: 'Tab', windowsVirtualKeyCode: 9, text: '\t' },
|
|
280
|
+
escape: { code: 'Escape', key: 'Escape', windowsVirtualKeyCode: 27 },
|
|
281
|
+
backspace: { code: 'Backspace', key: 'Backspace', windowsVirtualKeyCode: 8 },
|
|
282
|
+
delete: { code: 'Delete', key: 'Delete', windowsVirtualKeyCode: 46 },
|
|
283
|
+
space: { code: 'Space', key: ' ', windowsVirtualKeyCode: 32, text: ' ' },
|
|
284
|
+
arrowdown: { code: 'ArrowDown', key: 'ArrowDown', windowsVirtualKeyCode: 40 },
|
|
285
|
+
arrowup: { code: 'ArrowUp', key: 'ArrowUp', windowsVirtualKeyCode: 38 },
|
|
286
|
+
arrowleft: { code: 'ArrowLeft', key: 'ArrowLeft', windowsVirtualKeyCode: 37 },
|
|
287
|
+
arrowright: { code: 'ArrowRight', key: 'ArrowRight', windowsVirtualKeyCode: 39 },
|
|
288
|
+
home: { code: 'Home', key: 'Home', windowsVirtualKeyCode: 36 },
|
|
289
|
+
end: { code: 'End', key: 'End', windowsVirtualKeyCode: 35 },
|
|
290
|
+
pageup: { code: 'PageUp', key: 'PageUp', windowsVirtualKeyCode: 33 },
|
|
291
|
+
pagedown: { code: 'PageDown', key: 'PageDown', windowsVirtualKeyCode: 34 },
|
|
292
|
+
};
|
|
293
|
+
const KEY_ALIASES = {
|
|
294
|
+
return: 'enter', esc: 'escape', del: 'delete', up: 'arrowup', down: 'arrowdown', left: 'arrowleft',
|
|
295
|
+
right: 'arrowright', pgup: 'pageup', pgdn: 'pagedown', ' ': 'space',
|
|
296
|
+
};
|
|
297
|
+
const MODIFIER_BITS = {
|
|
298
|
+
alt: 1, option: 1, control: 2, ctrl: 2, meta: 4, cmd: 4, command: 4, super: 4, shift: 8,
|
|
299
|
+
};
|
|
300
|
+
/**
|
|
301
|
+
* A key, or a chord like "Control+A" or "Shift+Tab", as the protocol wants it.
|
|
302
|
+
*
|
|
303
|
+
* Anything not in the table used to be pressed as Enter, silently — asked to press Delete, the page
|
|
304
|
+
* submitted a form. An unknown key is now refused and the known ones are named.
|
|
305
|
+
*/
|
|
306
|
+
export function parseKey(said) {
|
|
307
|
+
const raw = String(said ?? '').trim();
|
|
308
|
+
if (!raw)
|
|
309
|
+
return { spec: NAMED_KEYS['enter'], modifiers: 0 };
|
|
310
|
+
// "+" alone is the plus key; "Control++" is Control and plus.
|
|
311
|
+
const parts = raw === '+' ? ['+'] : raw.split(/\+(?!$)/);
|
|
312
|
+
let modifiers = 0;
|
|
313
|
+
for (const m of parts.slice(0, -1)) {
|
|
314
|
+
const bit = MODIFIER_BITS[m.trim().toLowerCase()];
|
|
315
|
+
if (!bit)
|
|
316
|
+
return { error: `"${m}" is not a modifier. Use Control, Shift, Alt or Meta, e.g. "Control+A".` };
|
|
317
|
+
modifiers |= bit;
|
|
318
|
+
}
|
|
319
|
+
const last = parts[parts.length - 1];
|
|
320
|
+
const lower = last.toLowerCase();
|
|
321
|
+
const fn = /^f([1-9]|1[0-2])$/.exec(lower);
|
|
322
|
+
let spec = NAMED_KEYS[KEY_ALIASES[lower] ?? lower];
|
|
323
|
+
if (!spec && fn)
|
|
324
|
+
spec = { code: last.toUpperCase(), key: last.toUpperCase(), windowsVirtualKeyCode: 111 + Number(fn[1]) };
|
|
325
|
+
if (!spec && [...last].length === 1) {
|
|
326
|
+
const ch = last;
|
|
327
|
+
const upper = ch.toUpperCase();
|
|
328
|
+
const code = /[a-z]/i.test(ch) ? 'Key' + upper : /[0-9]/.test(ch) ? 'Digit' + ch : '';
|
|
329
|
+
spec = { code, key: modifiers & 8 ? upper : ch, windowsVirtualKeyCode: upper.charCodeAt(0), text: modifiers & 8 ? upper : ch };
|
|
330
|
+
}
|
|
331
|
+
if (!spec) {
|
|
332
|
+
return { error: `"${last}" is not a key this browser knows. Keys: Enter, Tab, Escape, Backspace, `
|
|
333
|
+
+ `Delete, Space, arrows, Home, End, PageUp, PageDown, F1–F12, any single character, and chords `
|
|
334
|
+
+ `such as "Control+A" or "Shift+Tab".` };
|
|
335
|
+
}
|
|
336
|
+
// A command chord types nothing: Control+A selects all, it does not insert an "a".
|
|
337
|
+
if (modifiers & (2 | 4))
|
|
338
|
+
spec = { ...spec, text: undefined };
|
|
339
|
+
return { spec, modifiers };
|
|
340
|
+
}
|
|
244
341
|
export class Browser {
|
|
245
342
|
child = null;
|
|
246
343
|
socket = null;
|
|
@@ -262,6 +359,9 @@ export class Browser {
|
|
|
262
359
|
patience = 'balanced';
|
|
263
360
|
/** The window size from configuration, as WIDTHxHEIGHT. KONECK_BROWSER_WINDOW outranks it. */
|
|
264
361
|
window;
|
|
362
|
+
onExit = null;
|
|
363
|
+
/** Folders a file:// address may be inside — the workspace. Empty means no file:// at all. */
|
|
364
|
+
fileRoots = [];
|
|
265
365
|
get running() { return this.socket?.readyState === 1; }
|
|
266
366
|
remember(problem) {
|
|
267
367
|
// The same failing request on every render would otherwise fill this with one message.
|
|
@@ -271,6 +371,25 @@ export class Browser {
|
|
|
271
371
|
while (this.problems.length > 25)
|
|
272
372
|
this.problems.shift();
|
|
273
373
|
}
|
|
374
|
+
/**
|
|
375
|
+
* Everything the page logged, and every request it made — not only what went wrong.
|
|
376
|
+
*
|
|
377
|
+
* Only errors and warnings were kept, and only the last 25, cleared when read. That answers "did
|
|
378
|
+
* something fail" and not "what did the page do": whether the save request was sent at all, what
|
|
379
|
+
* the API answered, the log line the developer put there to find out. Both are bounded, and they
|
|
380
|
+
* are kept for the model to ask for and the panel to show, apart from the problems above.
|
|
381
|
+
*/
|
|
382
|
+
consoleLog = [];
|
|
383
|
+
requests = new Map();
|
|
384
|
+
finished = [];
|
|
385
|
+
/** The page's console, oldest first, `n` at most. */
|
|
386
|
+
consoleEntries(n = 50) {
|
|
387
|
+
return this.consoleLog.slice(-Math.max(1, n));
|
|
388
|
+
}
|
|
389
|
+
/** The page's requests, finished and still going, oldest first. */
|
|
390
|
+
networkEntries(n = 50) {
|
|
391
|
+
return [...this.finished, ...this.requests.values()].sort((a, b) => a.start - b.start).slice(-Math.max(1, n));
|
|
392
|
+
}
|
|
274
393
|
/** Problems the page ran into, which nobody would otherwise see. */
|
|
275
394
|
recentProblems() { return [...this.problems]; }
|
|
276
395
|
clearProblems() { this.problems.length = 0; }
|
|
@@ -320,6 +439,18 @@ export class Browser {
|
|
|
320
439
|
];
|
|
321
440
|
const child = spawn(binary, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
322
441
|
this.child = child;
|
|
442
|
+
/*
|
|
443
|
+
* Closed with KONECK, not left for the next one to find.
|
|
444
|
+
*
|
|
445
|
+
* Only an explicit close or the web server shutting down stopped it, so every terminal session
|
|
446
|
+
* that used the browser left a headless Chromium running until the next KONECK reaped it. 'exit'
|
|
447
|
+
* handlers must be synchronous, and a kill is.
|
|
448
|
+
*/
|
|
449
|
+
this.onExit = () => { try {
|
|
450
|
+
child.kill('SIGKILL');
|
|
451
|
+
}
|
|
452
|
+
catch { /* already gone */ } };
|
|
453
|
+
process.once('exit', this.onExit);
|
|
323
454
|
// Written down so a later run can tell a leak from a browser somebody is using.
|
|
324
455
|
noteBrowser(child.pid, this.profile);
|
|
325
456
|
// The port is chosen by the browser and announced on stderr; there is no other way to learn it.
|
|
@@ -408,6 +539,23 @@ export class Browser {
|
|
|
408
539
|
navigating = false;
|
|
409
540
|
noteEvent(message) {
|
|
410
541
|
const method = message.method;
|
|
542
|
+
if (method === 'Page.screencastFrame') {
|
|
543
|
+
const p = message.params;
|
|
544
|
+
// Acknowledged at once: Chromium sends the next frame only after the last is acknowledged.
|
|
545
|
+
if (typeof p?.sessionId === 'number')
|
|
546
|
+
void this.call('Page.screencastFrameAck', { sessionId: p.sessionId });
|
|
547
|
+
if (p?.data) {
|
|
548
|
+
this.lastShot = 'data:image/jpeg;base64,' + p.data;
|
|
549
|
+
const meta = { width: Math.round(p.metadata?.deviceWidth ?? 0), height: Math.round(p.metadata?.deviceHeight ?? 0) };
|
|
550
|
+
for (const fn of this.frameListeners) {
|
|
551
|
+
try {
|
|
552
|
+
fn(this.lastShot, meta);
|
|
553
|
+
}
|
|
554
|
+
catch { /* one bad watcher is not everyone's problem */ }
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
411
559
|
if (method === 'Page.frameStartedLoading' || method === 'Page.navigatedWithinDocument') {
|
|
412
560
|
this.navigating = true;
|
|
413
561
|
return;
|
|
@@ -418,13 +566,43 @@ export class Browser {
|
|
|
418
566
|
}
|
|
419
567
|
if (method === 'Runtime.consoleAPICalled') {
|
|
420
568
|
const p = message.params;
|
|
569
|
+
const text = (p?.args ?? []).map(a => String(a.description ?? a.value ?? '')).join(' ').trim();
|
|
570
|
+
if (text) {
|
|
571
|
+
this.consoleLog.push({ at: Date.now(), level: p?.type ?? 'log', text: text.slice(0, 500) });
|
|
572
|
+
while (this.consoleLog.length > 200)
|
|
573
|
+
this.consoleLog.shift();
|
|
574
|
+
}
|
|
421
575
|
if (p?.type !== 'error' && p?.type !== 'warning')
|
|
422
576
|
return;
|
|
423
|
-
const text = (p.args ?? []).map(a => String(a.description ?? a.value ?? '')).join(' ').trim();
|
|
424
577
|
if (text)
|
|
425
578
|
this.remember(`console ${p.type}: ${text}`);
|
|
426
579
|
return;
|
|
427
580
|
}
|
|
581
|
+
if (method === 'Network.requestWillBeSent') {
|
|
582
|
+
const p = message.params;
|
|
583
|
+
if (!p?.requestId || !p.request?.url || p.request.url.startsWith('data:'))
|
|
584
|
+
return;
|
|
585
|
+
this.requests.set(p.requestId, {
|
|
586
|
+
method: p.request.method ?? 'GET', url: p.request.url.slice(0, 400), type: p.type ?? 'Other',
|
|
587
|
+
start: Date.now(),
|
|
588
|
+
});
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
if (method === 'Network.loadingFinished' || method === 'Network.loadingFailed') {
|
|
592
|
+
const p = message.params;
|
|
593
|
+
const entry = p?.requestId ? this.requests.get(p.requestId) : undefined;
|
|
594
|
+
if (entry) {
|
|
595
|
+
this.requests.delete(p.requestId);
|
|
596
|
+
entry.ms = Date.now() - entry.start;
|
|
597
|
+
if (p.errorText)
|
|
598
|
+
entry.error = p.errorText;
|
|
599
|
+
this.finished.push(entry);
|
|
600
|
+
while (this.finished.length > 200)
|
|
601
|
+
this.finished.shift();
|
|
602
|
+
}
|
|
603
|
+
if (method === 'Network.loadingFinished')
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
428
606
|
if (method === 'Runtime.exceptionThrown') {
|
|
429
607
|
const p = message.params;
|
|
430
608
|
const text = p?.exceptionDetails?.exception?.description ?? p?.exceptionDetails?.text ?? '';
|
|
@@ -435,6 +613,9 @@ export class Browser {
|
|
|
435
613
|
if (method === 'Network.responseReceived') {
|
|
436
614
|
const p = message.params;
|
|
437
615
|
const status = p?.response?.status ?? 0;
|
|
616
|
+
const entry = p?.requestId ? this.requests.get(p.requestId) : undefined;
|
|
617
|
+
if (entry)
|
|
618
|
+
entry.status = status;
|
|
438
619
|
if (status >= 400)
|
|
439
620
|
this.remember(`HTTP ${status} from ${p?.response?.url ?? 'a request'}`);
|
|
440
621
|
return;
|
|
@@ -494,7 +675,13 @@ export class Browser {
|
|
|
494
675
|
if (!started.ok)
|
|
495
676
|
return started;
|
|
496
677
|
}
|
|
497
|
-
|
|
678
|
+
// A scheme is "name://", or one of the few that never take slashes; "example.com:8080" is a host.
|
|
679
|
+
const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(url)
|
|
680
|
+
|| /^(javascript|data|about|chrome|view-source|blob|mailto|vbscript|file):/i.test(url);
|
|
681
|
+
const target = hasScheme ? url : 'http://' + url;
|
|
682
|
+
const refused = refuseUrl(target, this.fileRoots);
|
|
683
|
+
if (refused)
|
|
684
|
+
return { ok: false, error: refused };
|
|
498
685
|
const nav = await this.call('Page.navigate', { url: target });
|
|
499
686
|
if (nav?.errorText)
|
|
500
687
|
return { ok: false, error: `${target} — ${nav.errorText}` };
|
|
@@ -680,10 +867,68 @@ export class Browser {
|
|
|
680
867
|
async signature() {
|
|
681
868
|
return await this.evaluate('location.href + "|" + (document.body ? document.body.innerText.length : 0)') ?? '';
|
|
682
869
|
}
|
|
683
|
-
async state() {
|
|
870
|
+
async state(from = 1) {
|
|
684
871
|
const got = await this.evaluate(`(() => {
|
|
685
872
|
const seen = new Set();
|
|
686
873
|
const controls = [];
|
|
874
|
+
/*
|
|
875
|
+
* Numbers that stay with their element.
|
|
876
|
+
*
|
|
877
|
+
* They were positions in the list, recomputed on every read — so a page that changed between
|
|
878
|
+
* a read and a click (a banner appearing, a row added) moved every number after the change,
|
|
879
|
+
* and "click 7" pressed whatever was seventh now. Each element is given its number the first
|
|
880
|
+
* time it is seen and keeps it while it exists. Held in the page's memory, not written into
|
|
881
|
+
* its markup: the page being tested is not edited to test it. A navigation starts afresh.
|
|
882
|
+
*/
|
|
883
|
+
const reg = window.__koneckRefs || (window.__koneckRefs = { next: 1, of: new WeakMap(), els: new Map() });
|
|
884
|
+
const refOf = (el) => {
|
|
885
|
+
let r = reg.of.get(el);
|
|
886
|
+
if (!r) { r = reg.next++; reg.of.set(el, r); reg.els.set(r, new WeakRef(el)); }
|
|
887
|
+
return r;
|
|
888
|
+
};
|
|
889
|
+
const implicitRole = (el) => {
|
|
890
|
+
const t = el.tagName.toLowerCase();
|
|
891
|
+
if (t === 'a') return 'link';
|
|
892
|
+
if (t === 'button') return 'button';
|
|
893
|
+
if (t === 'select') return 'combobox';
|
|
894
|
+
if (t === 'textarea') return 'textbox';
|
|
895
|
+
if (t === 'input') {
|
|
896
|
+
const ty = (el.getAttribute('type') || 'text').toLowerCase();
|
|
897
|
+
if (ty === 'checkbox' || ty === 'radio') return ty;
|
|
898
|
+
if (ty === 'submit' || ty === 'button' || ty === 'reset') return 'button';
|
|
899
|
+
if (ty === 'range') return 'slider';
|
|
900
|
+
if (ty === 'search') return 'searchbox';
|
|
901
|
+
return 'textbox';
|
|
902
|
+
}
|
|
903
|
+
return t;
|
|
904
|
+
};
|
|
905
|
+
const statesOf = (el) => {
|
|
906
|
+
const out = [];
|
|
907
|
+
const aria = (n) => el.getAttribute('aria-' + n);
|
|
908
|
+
if (el.disabled || aria('disabled') === 'true') out.push('disabled');
|
|
909
|
+
if (el.checked === true || aria('checked') === 'true') out.push('checked');
|
|
910
|
+
if (aria('expanded') === 'true') out.push('expanded');
|
|
911
|
+
else if (aria('expanded') === 'false') out.push('collapsed');
|
|
912
|
+
if (aria('selected') === 'true') out.push('selected');
|
|
913
|
+
if (aria('pressed') === 'true') out.push('pressed');
|
|
914
|
+
if (el.required || aria('required') === 'true') out.push('required');
|
|
915
|
+
if (aria('invalid') === 'true' || (el.validity && el.validity.valid === false && el.value)) out.push('invalid');
|
|
916
|
+
if (el.readOnly) out.push('read-only');
|
|
917
|
+
return out;
|
|
918
|
+
};
|
|
919
|
+
const valueOf = (el) => {
|
|
920
|
+
const t = el.tagName.toLowerCase();
|
|
921
|
+
// A password is never read back, not even to the model driving the page.
|
|
922
|
+
if (t === 'input' && (el.getAttribute('type') || '').toLowerCase() === 'password') return el.value ? '(filled)' : '';
|
|
923
|
+
if (t === 'select') { const o = el.options[el.selectedIndex]; return o ? (o.text || '').trim().slice(0, 40) : ''; }
|
|
924
|
+
if ((t === 'input' || t === 'textarea') && typeof el.value === 'string') {
|
|
925
|
+
const ty = (el.getAttribute('type') || 'text').toLowerCase();
|
|
926
|
+
if (ty === 'checkbox' || ty === 'radio' || ty === 'submit' || ty === 'button') return '';
|
|
927
|
+
return el.value.slice(0, 40);
|
|
928
|
+
}
|
|
929
|
+
return '';
|
|
930
|
+
};
|
|
931
|
+
let total = 0;
|
|
687
932
|
/*
|
|
688
933
|
* What the field is called on screen.
|
|
689
934
|
*
|
|
@@ -759,10 +1004,19 @@ export class Browser {
|
|
|
759
1004
|
const sel = selectorFor(el);
|
|
760
1005
|
if (seen.has(sel)) continue;
|
|
761
1006
|
seen.add(sel);
|
|
1007
|
+
total++;
|
|
1008
|
+
// Paged: the ones before \`from\` are counted but not listed, so the numbers stay put.
|
|
1009
|
+
if (total < ${from}) continue;
|
|
1010
|
+
if (controls.length >= ${MAX_CONTROLS}) { if (total >= ${MAX_COLLECTED}) break; continue; }
|
|
1011
|
+
const states = statesOf(el);
|
|
1012
|
+
const value = valueOf(el);
|
|
762
1013
|
controls.push({
|
|
763
1014
|
// A number, because that is what a model reaches for when handed a numbered list — and
|
|
764
|
-
// it cannot be got subtly wrong the way a CSS path can.
|
|
765
|
-
ref:
|
|
1015
|
+
// it cannot be got subtly wrong the way a CSS path can. It stays with the element.
|
|
1016
|
+
ref: refOf(el),
|
|
1017
|
+
role: el.getAttribute('role') || implicitRole(el),
|
|
1018
|
+
...(states.length ? { states } : {}),
|
|
1019
|
+
...(value ? { value } : {}),
|
|
766
1020
|
tag: el.tagName.toLowerCase(),
|
|
767
1021
|
label: label(el),
|
|
768
1022
|
selector: sel,
|
|
@@ -780,13 +1034,19 @@ export class Browser {
|
|
|
780
1034
|
w: Math.round(box.width), h: Math.round(box.height),
|
|
781
1035
|
},
|
|
782
1036
|
});
|
|
783
|
-
if (controls.length >= ${MAX_CONTROLS}) break;
|
|
784
1037
|
}
|
|
1038
|
+
const headings = [...document.querySelectorAll('h1,h2,h3')]
|
|
1039
|
+
.filter(h => { const b = h.getBoundingClientRect(); return b.width > 0 && b.height > 0; })
|
|
1040
|
+
.slice(0, 20)
|
|
1041
|
+
.map(h => h.tagName.toLowerCase() + ' ' + (h.innerText || '').replace(/\\s+/g, ' ').trim().slice(0, 80))
|
|
1042
|
+
.filter(h => h.length > 3);
|
|
785
1043
|
return {
|
|
786
1044
|
url: location.href,
|
|
787
1045
|
title: document.title,
|
|
788
1046
|
text: (document.body ? document.body.innerText : '').trim().slice(0, ${MAX_TEXT}),
|
|
789
1047
|
controls,
|
|
1048
|
+
total,
|
|
1049
|
+
headings,
|
|
790
1050
|
};
|
|
791
1051
|
})()`);
|
|
792
1052
|
return got ?? { url: '', title: '', text: '', controls: [] };
|
|
@@ -811,9 +1071,9 @@ export class Browser {
|
|
|
811
1071
|
*/
|
|
812
1072
|
target) {
|
|
813
1073
|
if (typeof target.ref === 'number') {
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
return
|
|
1074
|
+
// The element that was given this number, wherever it is now — not whatever is at that
|
|
1075
|
+
// position in a fresh list. See state().
|
|
1076
|
+
return this.selectorOfRef(target.ref);
|
|
817
1077
|
}
|
|
818
1078
|
const said = target.text ?? target.label;
|
|
819
1079
|
if (said && said.trim()) {
|
|
@@ -876,6 +1136,24 @@ export class Browser {
|
|
|
876
1136
|
return parts.join(' > ') || null;
|
|
877
1137
|
})()`);
|
|
878
1138
|
}
|
|
1139
|
+
/** A selector for the element holding a ref, built now, or null if it is gone. */
|
|
1140
|
+
async selectorOfRef(ref) {
|
|
1141
|
+
return this.evaluate(`(() => {
|
|
1142
|
+
const reg = window.__koneckRefs;
|
|
1143
|
+
const el = reg && reg.els.get(${Math.trunc(ref)}) && reg.els.get(${Math.trunc(ref)}).deref();
|
|
1144
|
+
if (!el || !el.isConnected) return null;
|
|
1145
|
+
if (el.id) return '#' + CSS.escape(el.id);
|
|
1146
|
+
const parts = [];
|
|
1147
|
+
for (let n = el; n && n.nodeType === 1 && n !== document.body; n = n.parentElement) {
|
|
1148
|
+
if (n.id) { parts.unshift('#' + CSS.escape(n.id)); break; }
|
|
1149
|
+
let i = 1, sib = n;
|
|
1150
|
+
while ((sib = sib.previousElementSibling)) i++;
|
|
1151
|
+
parts.unshift(n.tagName.toLowerCase() + ':nth-child(' + i + ')');
|
|
1152
|
+
}
|
|
1153
|
+
const path = parts.join(' > ');
|
|
1154
|
+
return path ? ((parts[0][0] === '#') ? path : 'body > ' + path) : null;
|
|
1155
|
+
})()`);
|
|
1156
|
+
}
|
|
879
1157
|
/** What is on the page to act on, for an error message that helps rather than scolds. */
|
|
880
1158
|
async describeControls() {
|
|
881
1159
|
const state = await this.state();
|
|
@@ -1081,12 +1359,14 @@ export class Browser {
|
|
|
1081
1359
|
const name = field.label ?? field.selector ?? (field.ref !== undefined ? `#${field.ref}` : '?');
|
|
1082
1360
|
let selector = null;
|
|
1083
1361
|
if (typeof field.ref === 'number') {
|
|
1084
|
-
selector =
|
|
1362
|
+
selector = await this.selectorOfRef(field.ref);
|
|
1085
1363
|
}
|
|
1086
1364
|
else if (field.label) {
|
|
1087
1365
|
const lower = field.label.toLowerCase();
|
|
1088
1366
|
selector = state.controls.find(c => c.label.toLowerCase() === lower)?.selector
|
|
1089
|
-
?? state.controls.find(c => c.label.toLowerCase().includes(lower))?.selector
|
|
1367
|
+
?? state.controls.find(c => c.label.toLowerCase().includes(lower))?.selector
|
|
1368
|
+
// The same last look at the live page a click gets, which fill never had.
|
|
1369
|
+
?? await this.findVisibleByText(field.label);
|
|
1090
1370
|
}
|
|
1091
1371
|
else if (field.selector) {
|
|
1092
1372
|
selector = field.selector;
|
|
@@ -1184,25 +1464,214 @@ export class Browser {
|
|
|
1184
1464
|
return { ok: true };
|
|
1185
1465
|
}
|
|
1186
1466
|
async press(key) {
|
|
1187
|
-
const
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
await this.call('Input.dispatchKeyEvent', { type: 'keyDown', ...down });
|
|
1202
|
-
await this.call('Input.dispatchKeyEvent', { type: 'keyUp', ...spec });
|
|
1467
|
+
const parsed = parseKey(key);
|
|
1468
|
+
if ('error' in parsed)
|
|
1469
|
+
return { ok: false, error: parsed.error };
|
|
1470
|
+
/*
|
|
1471
|
+
* The `text` field is what makes Enter submit a form.
|
|
1472
|
+
*
|
|
1473
|
+
* A key event without it produces keydown and keyup and no keypress, and a browser only runs
|
|
1474
|
+
* default behaviour — submitting the form, inserting the newline — off the character. Measured:
|
|
1475
|
+
* typing into a field and pressing Enter left the page exactly where it was, and the form never
|
|
1476
|
+
* fired. Only keys that carry a character get one; Escape and the arrows must not, and neither
|
|
1477
|
+
* does a chord with Control or Meta, which is a command rather than typing.
|
|
1478
|
+
*/
|
|
1479
|
+
const { spec, modifiers } = parsed;
|
|
1480
|
+
const down = spec.text !== undefined ? { ...spec, unmodifiedText: spec.text } : spec;
|
|
1481
|
+
await this.call('Input.dispatchKeyEvent', { type: 'keyDown', modifiers, ...down });
|
|
1482
|
+
await this.call('Input.dispatchKeyEvent', { type: 'keyUp', modifiers, ...spec, text: undefined });
|
|
1203
1483
|
await this.settleAfterAction();
|
|
1204
1484
|
return { ok: true };
|
|
1205
1485
|
}
|
|
1486
|
+
/**
|
|
1487
|
+
* The element at a point on the page, as the panel's picker points at it.
|
|
1488
|
+
*
|
|
1489
|
+
* x and y are in the page's own viewport coordinates — the panel scales its picture back the same
|
|
1490
|
+
* way a click does. Nothing on the page is touched: no click, no focus, no outline written into
|
|
1491
|
+
* it. Null when the point is off the page.
|
|
1492
|
+
*/
|
|
1493
|
+
async inspect(x, y) {
|
|
1494
|
+
if (!this.running)
|
|
1495
|
+
return null;
|
|
1496
|
+
return this.evaluate(`(() => {
|
|
1497
|
+
let el = document.elementFromPoint(${Math.round(x)}, ${Math.round(y)});
|
|
1498
|
+
// The page itself is not an element anybody points at: nothing to name, nothing to find.
|
|
1499
|
+
if (!el || el === document.documentElement || el === document.body) return null;
|
|
1500
|
+
// A bare text span inside a button is not what anybody means by pointing at the button.
|
|
1501
|
+
const lift = el.closest('button,a[href],label,input,select,textarea,[role=button],[role=link],[role=tab],[role=option],[role=menuitem]');
|
|
1502
|
+
if (lift && lift.contains(el)) el = lift;
|
|
1503
|
+
const reg = window.__koneckRefs || (window.__koneckRefs = { next: 1, of: new WeakMap(), els: new Map() });
|
|
1504
|
+
let ref = reg.of.get(el);
|
|
1505
|
+
if (!ref) { ref = reg.next++; reg.of.set(el, ref); reg.els.set(ref, new WeakRef(el)); }
|
|
1506
|
+
const esc = (s) => CSS.escape(s);
|
|
1507
|
+
const unique = (sel) => { try { return document.querySelectorAll(sel).length === 1; } catch (e) { return false; } };
|
|
1508
|
+
const tag = el.tagName.toLowerCase();
|
|
1509
|
+
const classes = [...el.classList].filter(c => c.length < 60).slice(0, 12);
|
|
1510
|
+
const testId = el.getAttribute('data-testid') || el.getAttribute('data-test') || el.getAttribute('data-cy');
|
|
1511
|
+
let selector = '';
|
|
1512
|
+
if (el.id && unique('#' + esc(el.id))) selector = '#' + esc(el.id);
|
|
1513
|
+
else if (testId && unique('[data-testid="' + testId + '"]')) selector = '[data-testid="' + testId + '"]';
|
|
1514
|
+
else if (el.getAttribute('name') && unique(tag + '[name="' + el.getAttribute('name') + '"]')) selector = tag + '[name="' + el.getAttribute('name') + '"]';
|
|
1515
|
+
if (!selector) {
|
|
1516
|
+
const parts = [];
|
|
1517
|
+
for (let n = el; n && n.nodeType === 1 && n !== document.body; n = n.parentElement) {
|
|
1518
|
+
if (n.id) { parts.unshift('#' + esc(n.id)); break; }
|
|
1519
|
+
let i = 1, sib = n;
|
|
1520
|
+
while ((sib = sib.previousElementSibling)) i++;
|
|
1521
|
+
parts.unshift(n.tagName.toLowerCase() + ':nth-child(' + i + ')');
|
|
1522
|
+
}
|
|
1523
|
+
selector = (parts[0] && parts[0][0] === '#' ? '' : 'body > ') + parts.join(' > ');
|
|
1524
|
+
}
|
|
1525
|
+
const attrs = {};
|
|
1526
|
+
for (const a of ['name', 'type', 'href', 'src', 'alt', 'placeholder', 'aria-label', 'title', 'for', 'action', 'role', 'data-testid', 'data-test', 'data-cy']) {
|
|
1527
|
+
const v = el.getAttribute(a);
|
|
1528
|
+
if (v) attrs[a] = v.slice(0, 160);
|
|
1529
|
+
}
|
|
1530
|
+
const describe = (n) => n.tagName.toLowerCase() + (n.id ? '#' + n.id : '')
|
|
1531
|
+
+ [...n.classList].slice(0, 3).map(c => '.' + c).join('');
|
|
1532
|
+
const path = [];
|
|
1533
|
+
for (let n = el.parentElement; n && n !== document.body && path.length < 5; n = n.parentElement) path.unshift(describe(n));
|
|
1534
|
+
const cs = getComputedStyle(el);
|
|
1535
|
+
const styles = {};
|
|
1536
|
+
for (const k of ['color', 'background-color', 'font-size', 'font-weight', 'font-family', 'line-height',
|
|
1537
|
+
'padding', 'margin', 'border', 'border-radius', 'display', 'gap', 'text-align']) {
|
|
1538
|
+
const v = cs.getPropertyValue(k);
|
|
1539
|
+
if (v && v !== 'normal' && v !== 'none' && v !== '0px' && v !== 'rgba(0, 0, 0, 0)') styles[k] = v.slice(0, 80);
|
|
1540
|
+
}
|
|
1541
|
+
const b = el.getBoundingClientRect();
|
|
1542
|
+
// The markup, with a password's value and any inline data URIs kept out of it.
|
|
1543
|
+
let html = el.outerHTML.replace(/ value="[^"]*"/g, (m) => (el.type === 'password' ? '' : m)).replace(/data:[^"')]{80,}/g, 'data:…');
|
|
1544
|
+
if (html.length > 700) {
|
|
1545
|
+
const open = html.slice(0, html.indexOf('>') + 1);
|
|
1546
|
+
html = open.slice(0, 500) + ' … </' + tag + '>';
|
|
1547
|
+
}
|
|
1548
|
+
return {
|
|
1549
|
+
tag, ...(el.id ? { id: el.id } : {}), classes,
|
|
1550
|
+
...(el.getAttribute('role') ? { role: el.getAttribute('role') } : {}),
|
|
1551
|
+
text: (el.innerText || el.value && el.type !== 'password' && el.value || '').replace(/\\s+/g, ' ').trim().slice(0, 200),
|
|
1552
|
+
attrs, selector, path,
|
|
1553
|
+
box: { x: Math.round(b.left), y: Math.round(b.top), w: Math.round(b.width), h: Math.round(b.height) },
|
|
1554
|
+
styles, html, ref, url: location.href,
|
|
1555
|
+
};
|
|
1556
|
+
})()`);
|
|
1557
|
+
}
|
|
1558
|
+
/*
|
|
1559
|
+
* The live picture.
|
|
1560
|
+
*
|
|
1561
|
+
* The panel showed a still taken after each action, so a page animating, loading or being typed
|
|
1562
|
+
* into was a series of snapshots with nothing between them. Chromium can stream its compositor
|
|
1563
|
+
* frames; they are passed on while anybody is watching and stopped when nobody is, because a
|
|
1564
|
+
* screencast is not free on a four-core machine.
|
|
1565
|
+
*/
|
|
1566
|
+
frameListeners = new Set();
|
|
1567
|
+
casting = false;
|
|
1568
|
+
/** Subscribes to live frames; returns the unsubscribe. The first subscriber starts the cast. */
|
|
1569
|
+
watch(onFrame) {
|
|
1570
|
+
this.frameListeners.add(onFrame);
|
|
1571
|
+
if (!this.casting && this.running) {
|
|
1572
|
+
this.casting = true;
|
|
1573
|
+
void this.call('Page.startScreencast', { format: 'jpeg', quality: 60, everyNthFrame: 1 });
|
|
1574
|
+
}
|
|
1575
|
+
return () => {
|
|
1576
|
+
this.frameListeners.delete(onFrame);
|
|
1577
|
+
if (this.frameListeners.size === 0 && this.casting) {
|
|
1578
|
+
this.casting = false;
|
|
1579
|
+
void this.call('Page.stopScreencast', {});
|
|
1580
|
+
}
|
|
1581
|
+
};
|
|
1582
|
+
}
|
|
1583
|
+
/** How many are watching live — for the panel to say it is live, and for tests. */
|
|
1584
|
+
get watchers() { return this.frameListeners.size; }
|
|
1585
|
+
/** Reloads the page and waits for it, which the panel's refresh button never actually did. */
|
|
1586
|
+
async reload() {
|
|
1587
|
+
if (!this.running)
|
|
1588
|
+
return { ok: false, error: 'no page is open' };
|
|
1589
|
+
await this.call('Page.reload', { ignoreCache: false });
|
|
1590
|
+
const how = await this.settle();
|
|
1591
|
+
return how.settled ? { ok: true } : { ok: true, error: 'the page was still loading when it was read' };
|
|
1592
|
+
}
|
|
1593
|
+
/** Moves the pointer over something, for the menus and tooltips that only appear on hover. */
|
|
1594
|
+
async hover(target) {
|
|
1595
|
+
const selector = await this.resolve(target);
|
|
1596
|
+
if (!selector)
|
|
1597
|
+
return { ok: false, error: `could not find that on the page. What is here: ${await this.describeControls()}` };
|
|
1598
|
+
const at = await this.centreOf(selector);
|
|
1599
|
+
if (!at)
|
|
1600
|
+
return { ok: false, error: `${selector} is not visible` };
|
|
1601
|
+
await this.call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: at.x, y: at.y });
|
|
1602
|
+
await new Promise(r => setTimeout(r, 180)); // a hover menu animates in
|
|
1603
|
+
await this.settleQuick();
|
|
1604
|
+
return { ok: true };
|
|
1605
|
+
}
|
|
1606
|
+
/**
|
|
1607
|
+
* Chooses an option by what it says: a native <select>, or a custom dropdown opened and picked.
|
|
1608
|
+
*
|
|
1609
|
+
* The model was left to work out which of the two it had — fill for a select, click then click
|
|
1610
|
+
* for a component library's — and got it wrong often enough to be reported. This does both.
|
|
1611
|
+
*/
|
|
1612
|
+
async choose(target, option) {
|
|
1613
|
+
const selector = await this.resolve(target);
|
|
1614
|
+
if (!selector)
|
|
1615
|
+
return { ok: false, error: `could not find that on the page. What is here: ${await this.describeControls()}` };
|
|
1616
|
+
const native = await this.selectNativeOption(selector, option);
|
|
1617
|
+
if (native === 'set') {
|
|
1618
|
+
await this.settleAfterAction();
|
|
1619
|
+
return { ok: true };
|
|
1620
|
+
}
|
|
1621
|
+
if (native === 'nomatch') {
|
|
1622
|
+
const options = await this.evaluate(`[...document.querySelector(${JSON.stringify(selector)}).options].map(o => o.text.trim()).slice(0, 30).join(', ')`);
|
|
1623
|
+
return { ok: false, error: `no option "${option}". It has: ${options ?? '(none)'}` };
|
|
1624
|
+
}
|
|
1625
|
+
const opened = await this.click({ selector });
|
|
1626
|
+
if (!opened.ok)
|
|
1627
|
+
return opened;
|
|
1628
|
+
await new Promise(r => setTimeout(r, 150));
|
|
1629
|
+
const item = await this.findVisibleByText(option);
|
|
1630
|
+
if (!item)
|
|
1631
|
+
return { ok: false, error: `opened it, but no option "${option}" appeared. What is here: ${await this.describeControls()}` };
|
|
1632
|
+
return this.click({ selector: item });
|
|
1633
|
+
}
|
|
1634
|
+
/** Ticks or unticks a checkbox, radio or switch — only clicking when it is not already so. */
|
|
1635
|
+
async check(target, on = true) {
|
|
1636
|
+
const selector = await this.resolve(target);
|
|
1637
|
+
if (!selector)
|
|
1638
|
+
return { ok: false, error: `could not find that on the page. What is here: ${await this.describeControls()}` };
|
|
1639
|
+
const now = await this.evaluate(`(() => {
|
|
1640
|
+
const el = document.querySelector(${JSON.stringify(selector)});
|
|
1641
|
+
if (!el) return null;
|
|
1642
|
+
if (typeof el.checked === 'boolean') return el.checked;
|
|
1643
|
+
const a = el.getAttribute('aria-checked') || el.getAttribute('aria-pressed');
|
|
1644
|
+
return a === null ? null : a === 'true';
|
|
1645
|
+
})()`);
|
|
1646
|
+
if (now === on)
|
|
1647
|
+
return { ok: true, already: true };
|
|
1648
|
+
return this.click({ selector });
|
|
1649
|
+
}
|
|
1650
|
+
/**
|
|
1651
|
+
* Waits until some text or an element appears — or goes — rather than for a guessed time.
|
|
1652
|
+
*
|
|
1653
|
+
* A spinner, a toast, a row that arrives after a request: the model had no way to wait for any of
|
|
1654
|
+
* them but to read again and again, each read a whole round trip.
|
|
1655
|
+
*/
|
|
1656
|
+
async waitFor(what, timeoutMs = 10_000) {
|
|
1657
|
+
const started = Date.now();
|
|
1658
|
+
const limit = Math.max(200, Math.min(timeoutMs, 30_000));
|
|
1659
|
+
const probe = what.selector
|
|
1660
|
+
? `(() => { const el = document.querySelector(${JSON.stringify(what.selector)});
|
|
1661
|
+
if (!el) return false; const b = el.getBoundingClientRect(); return b.width > 0 && b.height > 0; })()`
|
|
1662
|
+
: `(document.body ? document.body.innerText : '').toLowerCase().includes(${JSON.stringify((what.text ?? '').toLowerCase())})`;
|
|
1663
|
+
for (;;) {
|
|
1664
|
+
const seen = await this.evaluate(probe);
|
|
1665
|
+
if (seen === !what.gone)
|
|
1666
|
+
return { ok: true, ms: Date.now() - started };
|
|
1667
|
+
if (Date.now() - started >= limit) {
|
|
1668
|
+
const thing = what.selector ?? `"${what.text}"`;
|
|
1669
|
+
return { ok: false, ms: Date.now() - started,
|
|
1670
|
+
error: `${thing} ${what.gone ? 'was still there' : 'did not appear'} after ${Math.round(limit / 100) / 10}s` };
|
|
1671
|
+
}
|
|
1672
|
+
await new Promise(r => setTimeout(r, 150));
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1206
1675
|
/**
|
|
1207
1676
|
* Back, and forward.
|
|
1208
1677
|
*
|
|
@@ -1281,6 +1750,8 @@ export class Browser {
|
|
|
1281
1750
|
return this.lastShot;
|
|
1282
1751
|
}
|
|
1283
1752
|
async stop() {
|
|
1753
|
+
// A stopped browser casts nothing; whoever was watching is left with the last frame.
|
|
1754
|
+
this.casting = false;
|
|
1284
1755
|
for (const { timer, resolve } of this.pending.values()) {
|
|
1285
1756
|
clearTimeout(timer);
|
|
1286
1757
|
resolve(null);
|
|
@@ -1293,6 +1764,10 @@ export class Browser {
|
|
|
1293
1764
|
this.socket = null;
|
|
1294
1765
|
const child = this.child;
|
|
1295
1766
|
this.child = null;
|
|
1767
|
+
if (this.onExit) {
|
|
1768
|
+
process.off('exit', this.onExit);
|
|
1769
|
+
this.onExit = null;
|
|
1770
|
+
}
|
|
1296
1771
|
if (child && child.exitCode === null) {
|
|
1297
1772
|
try {
|
|
1298
1773
|
child.kill('SIGKILL');
|