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.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, not a form. */
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
- const target = /^[a-z]+:\/\//i.test(url) ? url : 'http://' + url;
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: controls.length + 1,
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
- const state = await this.state();
815
- const found = state.controls.find(c => c.ref === target.ref);
816
- return found?.selector ?? null;
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 = state.controls.find(c => c.ref === field.ref)?.selector ?? null;
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 ?? null;
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 known = {
1188
- Enter: { code: 'Enter', key: 'Enter', windowsVirtualKeyCode: 13, text: '\r' },
1189
- Tab: { code: 'Tab', key: 'Tab', windowsVirtualKeyCode: 9, text: '\t' },
1190
- Escape: { code: 'Escape', key: 'Escape', windowsVirtualKeyCode: 27 },
1191
- Backspace: { code: 'Backspace', key: 'Backspace', windowsVirtualKeyCode: 8 },
1192
- ArrowDown: { code: 'ArrowDown', key: 'ArrowDown', windowsVirtualKeyCode: 40 },
1193
- ArrowUp: { code: 'ArrowUp', key: 'ArrowUp', windowsVirtualKeyCode: 38 },
1194
- ArrowLeft: { code: 'ArrowLeft', key: 'ArrowLeft', windowsVirtualKeyCode: 37 },
1195
- ArrowRight: { code: 'ArrowRight', key: 'ArrowRight', windowsVirtualKeyCode: 39 },
1196
- };
1197
- const spec = known[key] ?? known['Enter'];
1198
- const down = spec.text !== undefined
1199
- ? { ...spec, unmodifiedText: spec.text }
1200
- : spec;
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');