@hozu/cli 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cdp.d.ts.map +1 -1
- package/dist/cdp.js +1 -0
- package/dist/cdp.js.map +1 -1
- package/dist/commands/add-widget.d.ts.map +1 -1
- package/dist/commands/add-widget.js +4 -5
- package/dist/commands/add-widget.js.map +1 -1
- package/dist/commands/add.d.ts.map +1 -1
- package/dist/commands/add.js +4 -10
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/app.d.ts +27 -0
- package/dist/commands/app.d.ts.map +1 -0
- package/dist/commands/app.js +148 -0
- package/dist/commands/app.js.map +1 -0
- package/dist/commands/browse-page.d.ts +2 -0
- package/dist/commands/browse-page.d.ts.map +1 -0
- package/dist/commands/browse-page.js +228 -0
- package/dist/commands/browse-page.js.map +1 -0
- package/dist/commands/browse-tab.d.ts +80 -0
- package/dist/commands/browse-tab.d.ts.map +1 -0
- package/dist/commands/browse-tab.js +431 -0
- package/dist/commands/browse-tab.js.map +1 -0
- package/dist/commands/browse-world.d.ts +36 -0
- package/dist/commands/browse-world.d.ts.map +1 -0
- package/dist/commands/browse-world.js +108 -0
- package/dist/commands/browse-world.js.map +1 -0
- package/dist/commands/browse.d.ts +24 -3
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +341 -407
- package/dist/commands/browse.js.map +1 -1
- package/dist/commands/check.d.ts +1 -2
- package/dist/commands/check.d.ts.map +1 -1
- package/dist/commands/check.js +24 -27
- package/dist/commands/check.js.map +1 -1
- package/dist/commands/map.d.ts +12 -0
- package/dist/commands/map.d.ts.map +1 -1
- package/dist/commands/map.js +83 -3
- package/dist/commands/map.js.map +1 -1
- package/dist/commands/migrate-agent.d.ts +12 -0
- package/dist/commands/migrate-agent.d.ts.map +1 -0
- package/dist/commands/migrate-agent.js +6 -0
- package/dist/commands/migrate-agent.js.map +1 -0
- package/dist/commands/migrate-app.d.ts +15 -0
- package/dist/commands/migrate-app.d.ts.map +1 -0
- package/dist/commands/migrate-app.js +334 -0
- package/dist/commands/migrate-app.js.map +1 -0
- package/dist/commands/migrate-ast.d.ts +63 -0
- package/dist/commands/migrate-ast.d.ts.map +1 -0
- package/dist/commands/migrate-ast.js +257 -0
- package/dist/commands/migrate-ast.js.map +1 -0
- package/dist/commands/migrate-forms.d.ts +4 -0
- package/dist/commands/migrate-forms.d.ts.map +1 -0
- package/dist/commands/migrate-forms.js +120 -0
- package/dist/commands/migrate-forms.js.map +1 -0
- package/dist/commands/migrate-freshness.d.ts +4 -0
- package/dist/commands/migrate-freshness.d.ts.map +1 -0
- package/dist/commands/migrate-freshness.js +47 -0
- package/dist/commands/migrate-freshness.js.map +1 -0
- package/dist/commands/migrate-head.d.ts +8 -0
- package/dist/commands/migrate-head.d.ts.map +1 -0
- package/dist/commands/migrate-head.js +132 -0
- package/dist/commands/migrate-head.js.map +1 -0
- package/dist/commands/migrate-links.d.ts +5 -0
- package/dist/commands/migrate-links.d.ts.map +1 -0
- package/dist/commands/migrate-links.js +55 -0
- package/dist/commands/migrate-links.js.map +1 -0
- package/dist/commands/migrate-lists.d.ts +9 -0
- package/dist/commands/migrate-lists.d.ts.map +1 -0
- package/dist/commands/migrate-lists.js +225 -0
- package/dist/commands/migrate-lists.js.map +1 -0
- package/dist/commands/migrate-normalize.d.ts +27 -0
- package/dist/commands/migrate-normalize.d.ts.map +1 -0
- package/dist/commands/migrate-normalize.js +252 -0
- package/dist/commands/migrate-normalize.js.map +1 -0
- package/dist/commands/migrate-ops.d.ts +4 -0
- package/dist/commands/migrate-ops.d.ts.map +1 -0
- package/dist/commands/migrate-ops.js +192 -0
- package/dist/commands/migrate-ops.js.map +1 -0
- package/dist/commands/migrate-parts.d.ts +7 -0
- package/dist/commands/migrate-parts.d.ts.map +1 -0
- package/dist/commands/migrate-parts.js +166 -0
- package/dist/commands/migrate-parts.js.map +1 -0
- package/dist/commands/migrate-stale.d.ts +24 -0
- package/dist/commands/migrate-stale.d.ts.map +1 -0
- package/dist/commands/migrate-stale.js +114 -0
- package/dist/commands/migrate-stale.js.map +1 -0
- package/dist/commands/migrate.d.ts +21 -0
- package/dist/commands/migrate.d.ts.map +1 -0
- package/dist/commands/migrate.js +358 -0
- package/dist/commands/migrate.js.map +1 -0
- package/dist/commands/plan.d.ts.map +1 -1
- package/dist/commands/plan.js +2 -6
- package/dist/commands/plan.js.map +1 -1
- package/dist/commands/request.d.ts +13 -16
- package/dist/commands/request.d.ts.map +1 -1
- package/dist/commands/request.js +140 -161
- package/dist/commands/request.js.map +1 -1
- package/dist/commands/scaffold.d.ts.map +1 -1
- package/dist/commands/scaffold.js +10 -8
- package/dist/commands/scaffold.js.map +1 -1
- package/dist/commands/seed-lock.d.ts +2 -0
- package/dist/commands/seed-lock.d.ts.map +1 -0
- package/dist/commands/seed-lock.js +6 -0
- package/dist/commands/seed-lock.js.map +1 -0
- package/dist/commands/serve.d.ts +6 -0
- package/dist/commands/serve.d.ts.map +1 -0
- package/dist/commands/serve.js +31 -0
- package/dist/commands/serve.js.map +1 -0
- package/dist/commands/skill.d.ts.map +1 -1
- package/dist/commands/skill.js +1 -2
- package/dist/commands/skill.js.map +1 -1
- package/dist/commands/validate.d.ts +13 -0
- package/dist/commands/validate.d.ts.map +1 -1
- package/dist/commands/validate.js +51 -5
- package/dist/commands/validate.js.map +1 -1
- package/dist/contract.d.ts +124 -7
- package/dist/contract.d.ts.map +1 -1
- package/dist/main.d.ts +8 -0
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +99 -31
- package/dist/main.js.map +1 -1
- package/dist/output.d.ts.map +1 -1
- package/dist/output.js +28 -2
- package/dist/output.js.map +1 -1
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +5 -1
- package/dist/render.js.map +1 -1
- package/package.json +7 -7
- package/schema/browse.schema.json +180 -0
- package/schema/check.schema.json +28 -8
- package/schema/inspect.schema.json +114 -3
- package/schema/map.schema.json +62 -0
- package/schema/migrate.schema.json +649 -0
- package/schema/plan.schema.json +8 -22
- package/schema/request.schema.json +133 -8
- package/schema/skill.schema.json +22 -1
- package/schema/validate.schema.json +28 -8
package/dist/commands/browse.js
CHANGED
|
@@ -1,464 +1,398 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
3
2
|
import { tmpdir } from 'node:os';
|
|
4
|
-
import { dirname,
|
|
3
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
5
4
|
import { findBrowser, launch } from '../cdp.js';
|
|
6
5
|
import { HozuCliError } from '../errors.js';
|
|
7
|
-
import {
|
|
8
|
-
|
|
6
|
+
import { sleep, Tab, World } from './browse-tab.js';
|
|
7
|
+
import { describeElement, parseSession } from './request.js';
|
|
9
8
|
const LIMIT = 1500;
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
'
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
'.txt': 'text/plain',
|
|
22
|
-
'.css': 'text/css',
|
|
23
|
-
'.js': 'text/javascript',
|
|
24
|
-
'.woff2': 'font/woff2',
|
|
25
|
-
};
|
|
26
|
-
const KEYS = {
|
|
27
|
-
Enter: { code: 'Enter', keyCode: 13, text: '\r' },
|
|
28
|
-
Escape: { code: 'Escape', keyCode: 27 },
|
|
29
|
-
Tab: { code: 'Tab', keyCode: 9 },
|
|
30
|
-
Backspace: { code: 'Backspace', keyCode: 8 },
|
|
31
|
-
Space: { code: 'Space', keyCode: 32, text: ' ' },
|
|
32
|
-
ArrowUp: { code: 'ArrowUp', keyCode: 38 },
|
|
33
|
-
ArrowDown: { code: 'ArrowDown', keyCode: 40 },
|
|
34
|
-
ArrowLeft: { code: 'ArrowLeft', keyCode: 37 },
|
|
35
|
-
ArrowRight: { code: 'ArrowRight', keyCode: 39 },
|
|
36
|
-
};
|
|
37
|
-
const PAGE = String.raw `(() => {
|
|
38
|
-
const norm = (s) => (s ?? '').replace(/\s+/g, ' ').trim().toLowerCase()
|
|
39
|
-
const shown = (el) => { const r = el.getBoundingClientRect(); return r.width > 0 || r.height > 0 }
|
|
40
|
-
const nameOf = (el) => {
|
|
41
|
-
const aria = el.getAttribute('aria-label')
|
|
42
|
-
if (aria) return aria
|
|
43
|
-
const by = el.getAttribute('aria-labelledby')
|
|
44
|
-
if (by) return by.split(/\s+/).map((id) => document.getElementById(id)?.textContent ?? '').join(' ')
|
|
45
|
-
if (el.labels && el.labels.length) return [...el.labels].map((l) => l.textContent).join(' ')
|
|
46
|
-
if (el.tagName === 'INPUT' && ['submit', 'button'].includes(el.type)) return el.value
|
|
47
|
-
if (el.placeholder) return el.placeholder
|
|
48
|
-
if (el.getAttribute('title') && !el.textContent.trim()) return el.getAttribute('title')
|
|
49
|
-
return el.textContent
|
|
50
|
-
}
|
|
51
|
-
const KINDS = {
|
|
52
|
-
fill: 'input:not([type=hidden]):not([type=checkbox]):not([type=radio]):not([type=submit]):not([type=button]), textarea, [contenteditable=true]',
|
|
53
|
-
select: 'select',
|
|
54
|
-
check: 'input[type=checkbox], input[type=radio]',
|
|
55
|
-
click: 'button, a[href], [role=button], [role=link], [role=tab], [role=option], [role=menuitem], [role=checkbox], [role=switch], input[type=submit], input[type=button], summary, [title], label',
|
|
56
|
-
}
|
|
57
|
-
const find = (kind, name) => {
|
|
58
|
-
const all = [...document.querySelectorAll(KINDS[kind])].filter(shown)
|
|
59
|
-
const want = norm(name)
|
|
60
|
-
let hits = all.filter((el) => norm(nameOf(el)) === want || norm(el.getAttribute('title')) === want)
|
|
61
|
-
if (!hits.length && kind === 'click')
|
|
62
|
-
hits = [...document.body.querySelectorAll('*')].filter(
|
|
63
|
-
(el) => shown(el) && norm(el.textContent) === want && ![...el.children].some((c) => norm(c.textContent) === want),
|
|
64
|
-
)
|
|
65
|
-
if (!hits.length) {
|
|
66
|
-
const names = [...new Set(all.map((el) => (nameOf(el) ?? '').replace(/\s+/g, ' ').trim()).filter(Boolean))]
|
|
67
|
-
return { error: 'No ' + kind + ' target named "' + name + '"' + (names.length ? '. On the page: ' + names.slice(0, 20).map((n) => JSON.stringify(n.slice(0, 40))).join(', ') : '') }
|
|
9
|
+
const TARGETED = new Set(['fill', 'select', 'check', 'uncheck', 'click', 'submit']);
|
|
10
|
+
const VERBS = 'fill <label>=<value>, select <label>=<option>, check <label>, uncheck <label>, click <name>, submit "<form>", press <key>, wait <ms>, goto <path> (targets take in "<text>")';
|
|
11
|
+
export function parseStep(text) {
|
|
12
|
+
const space = text.indexOf(' ');
|
|
13
|
+
const verb = space < 0 ? text : text.slice(0, space);
|
|
14
|
+
let rest = space < 0 ? '' : text.slice(space + 1).trim();
|
|
15
|
+
let within = null;
|
|
16
|
+
const scoped = TARGETED.has(verb) ? /^(.*?)\s+in\s+"([^"]+)"$/.exec(rest) : null;
|
|
17
|
+
if (scoped) {
|
|
18
|
+
rest = scoped[1].trim();
|
|
19
|
+
within = scoped[2];
|
|
68
20
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
for (const a of el.attributes) attrs[a.name] = a.value
|
|
145
|
-
const text = (el.innerText ?? el.textContent ?? '').replace(/\s+/g, ' ').trim()
|
|
146
|
-
elements.push({ selector: sel, tag: el.tagName.toLowerCase(), attrs, text: text.length > 120 ? text.slice(0, 120) + '…' : text })
|
|
147
|
-
}
|
|
148
|
-
return {
|
|
149
|
-
title: document.title,
|
|
150
|
-
url: location.pathname + location.search,
|
|
151
|
-
hydrated,
|
|
152
|
-
text: (document.body?.innerText ?? '').replace(/[ \t]+/g, ' ').replace(/\n\s*\n+/g, '\n').trim(),
|
|
153
|
-
widgets,
|
|
154
|
-
elements,
|
|
155
|
-
}
|
|
156
|
-
},
|
|
157
|
-
}
|
|
158
|
-
})()`;
|
|
159
|
-
async function bodyOf(request) {
|
|
160
|
-
if (request.postDataEntries?.length)
|
|
161
|
-
return Buffer.concat(request.postDataEntries.map((e) => Buffer.from(e.bytes ?? '', 'base64')));
|
|
162
|
-
return request.postData === undefined ? undefined : Buffer.from(request.postData);
|
|
21
|
+
if (verb === 'fill' || verb === 'select') {
|
|
22
|
+
const eq = rest.indexOf('=');
|
|
23
|
+
if (eq <= 0)
|
|
24
|
+
throw new Error(`"${text}" needs <label>=<value>`);
|
|
25
|
+
return { verb, target: rest.slice(0, eq).trim(), value: rest.slice(eq + 1), within };
|
|
26
|
+
}
|
|
27
|
+
return { verb, target: verb === 'submit' ? rest.replace(/^"(.*)"$/, '$1') : rest, value: '', within };
|
|
28
|
+
}
|
|
29
|
+
const q = JSON.stringify;
|
|
30
|
+
async function aim(tab, call) {
|
|
31
|
+
const start = Date.now();
|
|
32
|
+
for (;;) {
|
|
33
|
+
const at = await tab.page(call);
|
|
34
|
+
if (at.error || !at.covered)
|
|
35
|
+
return at;
|
|
36
|
+
if (Date.now() - start > 1500)
|
|
37
|
+
return { error: `${at.covered} covers the target where it would be clicked` };
|
|
38
|
+
await sleep(50);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export async function act(tab, p) {
|
|
42
|
+
const off = tab.mode === 'off';
|
|
43
|
+
const done = (r) => ({
|
|
44
|
+
ok: !r.error,
|
|
45
|
+
note: r.error ?? r.note ?? null,
|
|
46
|
+
jsOnly: null,
|
|
47
|
+
});
|
|
48
|
+
if (p.verb === 'fill' || p.verb === 'select')
|
|
49
|
+
return done(await tab.page(`${p.verb}(${q(p.target)}, ${q(p.value)}, ${q(p.within)})`));
|
|
50
|
+
if (p.verb === 'submit')
|
|
51
|
+
return done(await tab.page(`submit(${q(p.target)}, ${q(p.within)})`));
|
|
52
|
+
if (p.verb === 'check' || p.verb === 'uncheck') {
|
|
53
|
+
const at = await aim(tab, `checkable(${q(p.target)}, ${q(p.within)})`);
|
|
54
|
+
if (at.error)
|
|
55
|
+
return done(at);
|
|
56
|
+
const want = p.verb === 'check';
|
|
57
|
+
if (at.radio && !want)
|
|
58
|
+
return done({ error: 'A radio button cannot be unchecked: check another option' });
|
|
59
|
+
if (at.checked === want)
|
|
60
|
+
return done({ note: `already ${p.verb}ed` });
|
|
61
|
+
await tab.mouse(at.x, at.y);
|
|
62
|
+
return done(at);
|
|
63
|
+
}
|
|
64
|
+
if (p.verb === 'click') {
|
|
65
|
+
const at = await aim(tab, `point(${q(p.target)}, ${q(p.within)})`);
|
|
66
|
+
if (at.error)
|
|
67
|
+
return done(at);
|
|
68
|
+
if (off && at.jsOnly)
|
|
69
|
+
return { ok: true, note: at.note, jsOnly: at.jsOnly };
|
|
70
|
+
await tab.mouse(at.x, at.y);
|
|
71
|
+
return done(at);
|
|
72
|
+
}
|
|
73
|
+
if (p.verb === 'press') {
|
|
74
|
+
if (!p.target)
|
|
75
|
+
throw new Error('press takes a key such as Enter');
|
|
76
|
+
const reason = off ? await tab.page(`keyJsOnly(${q(p.target)})`) : null;
|
|
77
|
+
if (reason)
|
|
78
|
+
return { ok: true, note: null, jsOnly: reason };
|
|
79
|
+
await tab.key(p.target);
|
|
80
|
+
return done({});
|
|
81
|
+
}
|
|
82
|
+
if (p.verb === 'wait') {
|
|
83
|
+
const ms = Number(p.target);
|
|
84
|
+
if (!p.target || !Number.isFinite(ms) || ms < 0 || ms > 30000)
|
|
85
|
+
throw new Error('wait takes milliseconds (0–30000)');
|
|
86
|
+
await sleep(ms);
|
|
87
|
+
return done({});
|
|
88
|
+
}
|
|
89
|
+
if (p.verb === 'goto') {
|
|
90
|
+
if (!p.target.startsWith('/'))
|
|
91
|
+
throw new Error('goto takes a path such as /items/1');
|
|
92
|
+
await tab.open(p.target);
|
|
93
|
+
return done({});
|
|
94
|
+
}
|
|
95
|
+
throw new Error(`Unknown step "${p.verb}": use ${VERBS}`);
|
|
163
96
|
}
|
|
97
|
+
const linesOf = (s) => s.text.split('\n').filter((l) => l.trim());
|
|
98
|
+
const minus = (a, b) => {
|
|
99
|
+
const left = new Map();
|
|
100
|
+
for (const l of b)
|
|
101
|
+
left.set(l, (left.get(l) ?? 0) + 1);
|
|
102
|
+
return a.filter((l) => {
|
|
103
|
+
const n = left.get(l) ?? 0;
|
|
104
|
+
if (n > 0)
|
|
105
|
+
left.set(l, n - 1);
|
|
106
|
+
return n === 0;
|
|
107
|
+
});
|
|
108
|
+
};
|
|
109
|
+
const comparable = (s) => minus(linesOf(s), s.widget);
|
|
110
|
+
const same = (a, b) => {
|
|
111
|
+
const x = comparable(a);
|
|
112
|
+
const y = comparable(b);
|
|
113
|
+
return a.url === b.url && x.length === y.length && minus(x, y).length === 0;
|
|
114
|
+
};
|
|
115
|
+
const delta = (before, after) => before.url !== after.url
|
|
116
|
+
? { added: linesOf(after), removed: [] }
|
|
117
|
+
: { added: minus(linesOf(after), linesOf(before)), removed: minus(linesOf(before), linesOf(after)) };
|
|
118
|
+
const combine = (changes) => {
|
|
119
|
+
const notes = changes.map((c) => c.note);
|
|
120
|
+
if (notes.every((n) => n === notes[0]))
|
|
121
|
+
return notes[0] ?? null;
|
|
122
|
+
return (changes
|
|
123
|
+
.filter((c) => c.note)
|
|
124
|
+
.map((c) => `${c.mode}: ${c.note}`)
|
|
125
|
+
.join('; ') || null);
|
|
126
|
+
};
|
|
164
127
|
export async function runBrowse(loaded, options) {
|
|
165
128
|
const path = options.path ?? '/';
|
|
166
129
|
if (!path.startsWith('/'))
|
|
167
130
|
throw new HozuCliError('usage', 'hozu browse needs a path', ['hozu browse /']);
|
|
131
|
+
for (const a of options.actors)
|
|
132
|
+
if (a.session !== undefined)
|
|
133
|
+
parseSession(a.session);
|
|
168
134
|
const browser = findBrowser();
|
|
169
135
|
if (!browser)
|
|
170
136
|
throw new HozuCliError('config', 'hozu browse needs Chrome, Chromium or Edge, and none was found', [
|
|
171
137
|
'Install Google Chrome, or set HOZU_CHROME=/path/to/chrome',
|
|
138
|
+
'hozu get <path> reads pages without a browser',
|
|
172
139
|
]);
|
|
173
|
-
const
|
|
174
|
-
const
|
|
175
|
-
const
|
|
176
|
-
const server = await parts.importFrom('@hozu/runtime-server', ['npm install @hozu/runtime-server']);
|
|
177
|
-
const styles = await (await parts.importFrom('@hozu/css', ['npm install @hozu/css'])).compileStyles(build, { base: root });
|
|
178
|
-
const used = server.usedWidgets(build.ir);
|
|
179
|
-
const widgets = used.length
|
|
180
|
-
? await (await parts.importFrom('@hozu/bundle', ['npm install @hozu/bundle'])).bundleWidgets(build)
|
|
181
|
-
: null;
|
|
182
|
-
const handler = server.createHandler({
|
|
183
|
-
build,
|
|
184
|
-
styles,
|
|
185
|
-
widgets,
|
|
186
|
-
resolvers: parts.resolvers,
|
|
187
|
-
session: parts.session,
|
|
188
|
-
env: process.env,
|
|
189
|
-
});
|
|
190
|
-
const publicDir = join(root, 'public');
|
|
191
|
-
const respond = async (request) => {
|
|
192
|
-
const response = await handler.fetch(request);
|
|
193
|
-
const file = join(publicDir, decodeURIComponent(new URL(request.url).pathname));
|
|
194
|
-
if (response.status !== 404 || !file.startsWith(publicDir) || !existsSync(file))
|
|
195
|
-
return response;
|
|
196
|
-
return new Response(await readFile(file).catch(() => null), {
|
|
197
|
-
headers: { 'content-type': MIME[extname(file)] ?? 'application/octet-stream' },
|
|
198
|
-
});
|
|
199
|
-
};
|
|
140
|
+
const modes = options.js === 'both' ? ['on', 'off'] : [options.js];
|
|
141
|
+
const sessions = options.actors.map((a) => a.session);
|
|
142
|
+
const worlds = modes.map(() => new World(loaded.path, dirname(loaded.path), sessions));
|
|
200
143
|
const profile = await mkdtemp(join(tmpdir(), 'hozu-browse-'));
|
|
201
|
-
|
|
144
|
+
let cdp = null;
|
|
202
145
|
const errors = [];
|
|
203
|
-
const
|
|
204
|
-
let status = 0;
|
|
205
|
-
let inflight = 0;
|
|
206
|
-
let lastActivity = Date.now();
|
|
207
|
-
let loaded_ = false;
|
|
146
|
+
const tabs = [];
|
|
208
147
|
try {
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
const documentUrls = new Set();
|
|
148
|
+
const cookies = await Promise.all(worlds.map((w) => w.cookies));
|
|
149
|
+
cdp = launch(browser, profile);
|
|
150
|
+
const bySession = new Map();
|
|
213
151
|
cdp.on((method, params, from) => {
|
|
214
|
-
|
|
152
|
+
const tab = from ? bySession.get(from) : undefined;
|
|
153
|
+
if (!tab)
|
|
215
154
|
return;
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
try {
|
|
220
|
-
const response = await respond(new Request(r.url, {
|
|
221
|
-
method: r.method,
|
|
222
|
-
headers: r.headers,
|
|
223
|
-
...(r.method === 'GET' || r.method === 'HEAD' ? {} : { body: (await bodyOf(r)) ?? null }),
|
|
224
|
-
}));
|
|
225
|
-
const headers = [...response.headers]
|
|
226
|
-
.filter(([k]) => k !== 'set-cookie')
|
|
227
|
-
.map(([name, value]) => ({ name, value }));
|
|
228
|
-
for (const c of response.headers.getSetCookie())
|
|
229
|
-
headers.push({ name: 'set-cookie', value: c });
|
|
230
|
-
const body = Buffer.from(await response.arrayBuffer()).toString('base64');
|
|
231
|
-
await send('Fetch.fulfillRequest', {
|
|
232
|
-
requestId: params.requestId,
|
|
233
|
-
responseCode: response.status,
|
|
234
|
-
responseHeaders: headers,
|
|
235
|
-
body,
|
|
236
|
-
});
|
|
237
|
-
}
|
|
238
|
-
catch (error) {
|
|
239
|
-
errors.push({ kind: 'request', text: `the app threw: ${String(error)}`, at: r.url });
|
|
240
|
-
await send('Fetch.failRequest', { requestId: params.requestId, errorReason: 'Failed' }).catch(() => { });
|
|
241
|
-
}
|
|
242
|
-
})();
|
|
243
|
-
}
|
|
244
|
-
if (method.startsWith('Network.'))
|
|
245
|
-
lastActivity = Date.now();
|
|
246
|
-
if (method === 'Network.requestWillBeSent') {
|
|
247
|
-
inflight++;
|
|
248
|
-
if (params.type === 'Document')
|
|
249
|
-
documentUrls.add(params.requestId);
|
|
250
|
-
}
|
|
251
|
-
if (method === 'Network.loadingFinished')
|
|
252
|
-
inflight--;
|
|
253
|
-
if (method === 'Network.loadingFailed') {
|
|
254
|
-
inflight--;
|
|
255
|
-
if (!params.canceled && params.blockedReason !== 'inspector')
|
|
256
|
-
errors.push({ kind: 'request', text: params.errorText, at: null });
|
|
257
|
-
}
|
|
258
|
-
if (method === 'Network.responseReceived') {
|
|
259
|
-
const url = params.response.url.startsWith(ORIGIN)
|
|
260
|
-
? params.response.url.slice(ORIGIN.length)
|
|
261
|
-
: params.response.url;
|
|
262
|
-
if (documentUrls.has(params.requestId))
|
|
263
|
-
status = params.response.status;
|
|
264
|
-
if (params.response.status >= 400)
|
|
265
|
-
errors.push({ kind: 'request', text: `${params.response.status} ${url}`, at: url });
|
|
266
|
-
}
|
|
267
|
-
if (method === 'Runtime.exceptionThrown') {
|
|
268
|
-
const d = params.exceptionDetails;
|
|
269
|
-
const frame = d.stackTrace?.callFrames?.[0];
|
|
270
|
-
errors.push({
|
|
271
|
-
kind: 'exception',
|
|
272
|
-
text: d.exception?.description?.split('\n')[0] ?? d.text,
|
|
273
|
-
at: frame ? `${frame.url.replace(ORIGIN, '')}:${frame.lineNumber + 1}` : (d.url ?? null),
|
|
274
|
-
});
|
|
275
|
-
}
|
|
276
|
-
if (method === 'Runtime.consoleAPICalled' && params.type === 'error') {
|
|
277
|
-
const frame = params.stackTrace?.callFrames?.[0];
|
|
278
|
-
errors.push({
|
|
279
|
-
kind: 'console',
|
|
280
|
-
text: params.args
|
|
281
|
-
.map((a) => a.value !== undefined ? String(a.value) : String(a.description ?? a.type).split('\n')[0])
|
|
282
|
-
.join(' '),
|
|
283
|
-
at: frame ? `${frame.url.replace(ORIGIN, '')}:${frame.lineNumber + 1}` : null,
|
|
284
|
-
});
|
|
285
|
-
}
|
|
286
|
-
if (method === 'Page.frameStartedLoading' && params.frameId === targetId)
|
|
287
|
-
loaded_ = false;
|
|
288
|
-
if (method === 'Page.loadEventFired')
|
|
289
|
-
loaded_ = true;
|
|
290
|
-
});
|
|
291
|
-
await send('Fetch.enable', { patterns: [{ urlPattern: `${ORIGIN}/*` }] });
|
|
292
|
-
await send('Network.enable');
|
|
293
|
-
await send('Runtime.enable');
|
|
294
|
-
await send('Page.enable');
|
|
295
|
-
await send('Emulation.setDeviceMetricsOverride', {
|
|
296
|
-
width: 1280,
|
|
297
|
-
height: 800,
|
|
298
|
-
deviceScaleFactor: 1,
|
|
299
|
-
mobile: false,
|
|
155
|
+
tab.handle(method, params, from);
|
|
156
|
+
if (method === 'Target.attachedToTarget')
|
|
157
|
+
bySession.set(params.sessionId, tab);
|
|
300
158
|
});
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
if (
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
await send('Input.dispatchMouseEvent', { type, x, y, button: 'left', clickCount: 1 });
|
|
333
|
-
};
|
|
334
|
-
await open(path);
|
|
335
|
-
const firstStatus = status;
|
|
336
|
-
for (const raw of options.steps) {
|
|
337
|
-
const text = raw.trim();
|
|
338
|
-
const space = text.indexOf(' ');
|
|
339
|
-
const verb = space < 0 ? text : text.slice(0, space);
|
|
340
|
-
const rest = space < 0 ? '' : text.slice(space + 1).trim();
|
|
341
|
-
const pair = () => {
|
|
342
|
-
const eq = rest.indexOf('=');
|
|
343
|
-
if (eq <= 0)
|
|
344
|
-
throw new Error(`"${text}" needs <label>=<value>`);
|
|
345
|
-
return [rest.slice(0, eq).trim(), rest.slice(eq + 1)];
|
|
346
|
-
};
|
|
347
|
-
let result = {};
|
|
348
|
-
try {
|
|
349
|
-
if (verb === 'fill') {
|
|
350
|
-
const [label, value] = pair();
|
|
351
|
-
result = await evaluate(`${PAGE}.fill(${JSON.stringify(label)}, ${JSON.stringify(value)})`);
|
|
352
|
-
}
|
|
353
|
-
else if (verb === 'select') {
|
|
354
|
-
const [label, value] = pair();
|
|
355
|
-
result = await evaluate(`${PAGE}.select(${JSON.stringify(label)}, ${JSON.stringify(value)})`);
|
|
356
|
-
}
|
|
357
|
-
else if (verb === 'click' || verb === 'check') {
|
|
358
|
-
const at = await evaluate(`${PAGE}.point(${JSON.stringify(verb === 'click' ? 'click' : 'check')}, ${JSON.stringify(rest)})`);
|
|
359
|
-
if (at.error)
|
|
360
|
-
result = at;
|
|
361
|
-
else {
|
|
362
|
-
await mouse(at.x, at.y);
|
|
363
|
-
result = { note: at.note };
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
else if (verb === 'press') {
|
|
367
|
-
const key = KEYS[rest] ?? {
|
|
368
|
-
code: `Key${rest.toUpperCase()}`,
|
|
369
|
-
keyCode: rest.toUpperCase().charCodeAt(0),
|
|
370
|
-
text: rest,
|
|
371
|
-
};
|
|
372
|
-
await send('Input.dispatchKeyEvent', {
|
|
373
|
-
type: 'keyDown',
|
|
374
|
-
key: rest,
|
|
375
|
-
code: key.code,
|
|
376
|
-
windowsVirtualKeyCode: key.keyCode,
|
|
377
|
-
...(key.text ? { text: key.text } : {}),
|
|
159
|
+
const tabOf = (mode, actor) => tabs.find((t) => t.mode === mode && t.actor === options.actors[actor].name);
|
|
160
|
+
const nameOf = (actor) => options.actors[actor].name;
|
|
161
|
+
const steps = [];
|
|
162
|
+
const initial = new Map();
|
|
163
|
+
let failed = false;
|
|
164
|
+
let opened = 0;
|
|
165
|
+
for (const item of options.plan) {
|
|
166
|
+
if ('open' in item) {
|
|
167
|
+
await Promise.all(modes.map(async (mode, m) => {
|
|
168
|
+
const tab = new Tab(cdp, worlds[m], mode, nameOf(item.open), errors);
|
|
169
|
+
tabs.push(tab);
|
|
170
|
+
await tab.start(cookies[m][item.open] ?? null, options.reducedMotion);
|
|
171
|
+
bySession.set(tab.sessionId, tab);
|
|
172
|
+
await tab.open(path);
|
|
173
|
+
initial.set(tab, tab.status);
|
|
174
|
+
await tab.look();
|
|
175
|
+
}));
|
|
176
|
+
if (opened++ > 0) {
|
|
177
|
+
const changes = modes.map((mode) => {
|
|
178
|
+
const tab = tabOf(mode, item.open);
|
|
179
|
+
return {
|
|
180
|
+
mode,
|
|
181
|
+
ok: true,
|
|
182
|
+
note: null,
|
|
183
|
+
jsOnly: null,
|
|
184
|
+
requested: true,
|
|
185
|
+
navigated: true,
|
|
186
|
+
url: tab.snapshot.url,
|
|
187
|
+
added: linesOf(tab.snapshot),
|
|
188
|
+
removed: [],
|
|
189
|
+
};
|
|
378
190
|
});
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
191
|
+
steps.push({
|
|
192
|
+
step: `open ${path}`,
|
|
193
|
+
ok: true,
|
|
194
|
+
note: null,
|
|
195
|
+
actor: nameOf(item.open),
|
|
196
|
+
modes: changes,
|
|
384
197
|
});
|
|
385
198
|
}
|
|
386
|
-
|
|
387
|
-
const ms = Number(rest);
|
|
388
|
-
if (!Number.isFinite(ms) || ms < 0 || ms > 30000)
|
|
389
|
-
throw new Error('wait takes milliseconds (0–30000)');
|
|
390
|
-
await sleep(ms);
|
|
391
|
-
}
|
|
392
|
-
else if (verb === 'goto') {
|
|
393
|
-
if (!rest.startsWith('/'))
|
|
394
|
-
throw new Error('goto takes a path such as /items/1');
|
|
395
|
-
await open(rest);
|
|
396
|
-
}
|
|
397
|
-
else
|
|
398
|
-
throw new Error(`Unknown step "${verb}": use fill <label>=<value>, select <label>=<option>, check <label>, click <name>, press <key>, wait <ms>, goto <path>`);
|
|
199
|
+
continue;
|
|
399
200
|
}
|
|
400
|
-
|
|
401
|
-
|
|
201
|
+
const actor = nameOf(item.actor);
|
|
202
|
+
const text = item.step.trim();
|
|
203
|
+
const base = { step: text, ...(actor === null ? {} : { actor }) };
|
|
204
|
+
if (failed) {
|
|
205
|
+
steps.push({ ...base, ok: false, note: 'skipped after a failed step' });
|
|
206
|
+
continue;
|
|
402
207
|
}
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
208
|
+
const results = await Promise.all(modes.map(async (mode) => {
|
|
209
|
+
const tab = tabOf(mode, item.actor);
|
|
210
|
+
const others = tabs.filter((t) => t.mode === mode && t !== tab);
|
|
211
|
+
const before = tab.snapshot;
|
|
212
|
+
tab.requested = false;
|
|
213
|
+
tab.mark();
|
|
214
|
+
for (const o of others)
|
|
215
|
+
o.mark();
|
|
216
|
+
let r;
|
|
217
|
+
let verb = '';
|
|
218
|
+
try {
|
|
219
|
+
const parsed = parseStep(text);
|
|
220
|
+
verb = parsed.verb;
|
|
221
|
+
r = await act(tab, parsed);
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
r = { ok: false, note: error instanceof Error ? error.message : String(error), jsOnly: null };
|
|
225
|
+
}
|
|
226
|
+
const acted = r.ok && !r.jsOnly && verb !== 'wait' && verb !== 'goto';
|
|
227
|
+
if (acted)
|
|
228
|
+
await tab.settle();
|
|
229
|
+
await Promise.all(others.map((o) => o.settle()));
|
|
230
|
+
const after = await tab.look();
|
|
231
|
+
const elsewhere = [];
|
|
232
|
+
for (const o of others) {
|
|
233
|
+
const was = o.snapshot;
|
|
234
|
+
const d = delta(was, await o.look());
|
|
235
|
+
if (d.added.length || d.removed.length)
|
|
236
|
+
elsewhere.push({ actor: o.actor ?? '', mode, ...d });
|
|
237
|
+
}
|
|
238
|
+
const change = {
|
|
239
|
+
mode,
|
|
240
|
+
ok: r.ok,
|
|
241
|
+
note: r.note,
|
|
242
|
+
jsOnly: r.jsOnly,
|
|
243
|
+
requested: tab.requested,
|
|
244
|
+
navigated: before.url !== after.url,
|
|
245
|
+
url: after.url,
|
|
246
|
+
...delta(before, after),
|
|
247
|
+
};
|
|
248
|
+
return { change, before, after, elsewhere };
|
|
249
|
+
}));
|
|
250
|
+
const changes = results.map((x) => x.change);
|
|
251
|
+
const [on, off] = results;
|
|
252
|
+
const differs = on !== undefined &&
|
|
253
|
+
off !== undefined &&
|
|
254
|
+
same(on.before, off.before) &&
|
|
255
|
+
on.change.requested &&
|
|
256
|
+
off.change.requested &&
|
|
257
|
+
!same(on.after, off.after);
|
|
258
|
+
const elsewhere = results.flatMap((x) => x.elsewhere);
|
|
259
|
+
const ok = changes.every((c) => c.ok);
|
|
260
|
+
steps.push({
|
|
261
|
+
...base,
|
|
262
|
+
ok,
|
|
263
|
+
note: combine(changes),
|
|
264
|
+
modes: changes,
|
|
265
|
+
...(differs ? { differs } : {}),
|
|
266
|
+
...(elsewhere.length ? { elsewhere } : {}),
|
|
267
|
+
});
|
|
268
|
+
if (!ok)
|
|
269
|
+
failed = true;
|
|
408
270
|
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
const
|
|
412
|
-
const
|
|
271
|
+
const order = (e) => options.actors.findIndex((a) => a.name === (e.actor ?? null)) * 2 + modes.indexOf(e.mode ?? modes[0]);
|
|
272
|
+
errors.sort((a, b) => order(a) - order(b));
|
|
273
|
+
const first = tabs.find((t) => t.actor === options.actors[0].name);
|
|
274
|
+
const on = tabs.find((t) => t.actor === first.actor && t.mode === 'on');
|
|
275
|
+
const expected = on
|
|
276
|
+
? Object.entries((await on.evaluate("(() => { try { return JSON.parse(document.getElementById('hozu-payload')?.textContent ?? '{}').widgets ?? {} } catch { return {} } })()"))).map(([name, w]) => [name, w.load])
|
|
277
|
+
: [];
|
|
278
|
+
const report = on
|
|
279
|
+
? await on.page(`report(${q(options.select)}, ${q(expected)})`)
|
|
280
|
+
: { hydrated: false, widgets: [], ...(await first.page(`report(${q(options.select)}, [])`)) };
|
|
413
281
|
let screenshot = null;
|
|
414
282
|
if (options.screenshot) {
|
|
415
|
-
const shot = await send('Page.captureScreenshot', { format: 'png' });
|
|
283
|
+
const shot = await first.send('Page.captureScreenshot', { format: 'png' });
|
|
416
284
|
const file = resolve(options.screenshot);
|
|
417
285
|
await writeFile(file, Buffer.from(shot.data, 'base64'));
|
|
418
286
|
const near = relative(process.cwd(), file);
|
|
419
287
|
screenshot = near && !near.startsWith('..') ? near : file;
|
|
420
288
|
}
|
|
421
|
-
const
|
|
422
|
-
|
|
289
|
+
const clip = (text) => (text.length > LIMIT && !options.full ? `${text.slice(0, LIMIT)}…` : text);
|
|
290
|
+
const full = first.snapshot.text;
|
|
291
|
+
const output = {
|
|
423
292
|
path,
|
|
424
|
-
url:
|
|
425
|
-
status:
|
|
426
|
-
title:
|
|
427
|
-
hydrated: report.hydrated,
|
|
293
|
+
url: first.snapshot.url,
|
|
294
|
+
status: initial.get(first) ?? first.status,
|
|
295
|
+
title: first.snapshot.title,
|
|
296
|
+
hydrated: on ? report.hydrated : false,
|
|
428
297
|
steps,
|
|
429
298
|
errors,
|
|
430
|
-
widgets: report.widgets,
|
|
431
|
-
text: full
|
|
299
|
+
widgets: (on ? report.widgets : []),
|
|
300
|
+
text: clip(full),
|
|
432
301
|
truncated: full.length > LIMIT && !options.full,
|
|
433
|
-
elements: report.elements,
|
|
302
|
+
elements: (on ? report.elements : report.elements),
|
|
434
303
|
screenshot,
|
|
304
|
+
modes,
|
|
435
305
|
};
|
|
306
|
+
if (tabs.length > 1)
|
|
307
|
+
output.actors = tabs.map((t) => ({
|
|
308
|
+
name: t.actor ?? '',
|
|
309
|
+
mode: t.mode,
|
|
310
|
+
url: t.snapshot.url,
|
|
311
|
+
status: initial.get(t) ?? t.status,
|
|
312
|
+
title: t.snapshot.title,
|
|
313
|
+
steps: steps.filter((s) => (s.actor ?? null) === t.actor),
|
|
314
|
+
errors: errors.filter((e) => (e.actor ?? null) === t.actor && e.mode === t.mode),
|
|
315
|
+
text: clip(t.snapshot.text),
|
|
316
|
+
}));
|
|
317
|
+
return output;
|
|
436
318
|
}
|
|
437
319
|
finally {
|
|
438
|
-
await cdp
|
|
320
|
+
await cdp?.close().catch(() => { });
|
|
321
|
+
await Promise.all(worlds.map((w) => w.close().catch(() => 0)));
|
|
439
322
|
await rm(profile, { recursive: true, force: true }).catch(() => { });
|
|
440
323
|
}
|
|
441
324
|
}
|
|
442
|
-
export const browseFailed = (out) => out.errors.length > 0 ||
|
|
443
|
-
|
|
325
|
+
export const browseFailed = (out) => out.errors.length > 0 ||
|
|
326
|
+
out.steps.some((s) => !s.ok || s.differs) ||
|
|
327
|
+
out.widgets.some((w) => w.state === 'failed');
|
|
328
|
+
const ITEMS = 6;
|
|
329
|
+
const WIDTH = 60;
|
|
330
|
+
const TEXT = 600;
|
|
331
|
+
const cut = (s, n, full) => (!full && s.length > n ? `${s.slice(0, n)}…` : s);
|
|
332
|
+
function describeChange(c, full) {
|
|
333
|
+
if (!c.ok)
|
|
334
|
+
return `FAILED — ${c.note}`;
|
|
335
|
+
if (c.jsOnly)
|
|
336
|
+
return `js-only (${c.jsOnly})`;
|
|
337
|
+
const moved = c.navigated;
|
|
338
|
+
const items = [
|
|
339
|
+
...c.added.map((l) => (moved ? cut(l, WIDTH, full) : `+ ${cut(l, WIDTH, full)}`)),
|
|
340
|
+
...c.removed.map((l) => `− ${cut(l, WIDTH, full)}`),
|
|
341
|
+
];
|
|
342
|
+
const shown = full ? items : items.slice(0, ITEMS);
|
|
343
|
+
const more = items.length - shown.length;
|
|
344
|
+
const list = [...shown, ...(more ? [`… ${more} more`] : [])].join(' · ');
|
|
345
|
+
return [moved ? `→ ${c.url}` : '', list].filter(Boolean).join(': ');
|
|
346
|
+
}
|
|
347
|
+
export function describeBrowse(out, full = false) {
|
|
348
|
+
const modes = out.modes ?? ['on'];
|
|
444
349
|
const lines = [
|
|
445
|
-
`BROWSE ${out.path} → ${out.status}${out.url !== out.path ? ` (now ${out.url})` : ''} · ${out.hydrated ? 'hydrated' : 'not hydrated'}`,
|
|
350
|
+
`BROWSE ${out.path} → ${out.status}${out.url !== out.path ? ` (now ${out.url})` : ''} · ${modes.includes('on') ? (out.hydrated ? 'hydrated' : 'not hydrated') : 'no JS'} · js ${modes.join('+')}`,
|
|
446
351
|
` title: ${out.title}`,
|
|
447
352
|
];
|
|
448
|
-
|
|
449
|
-
|
|
353
|
+
out.steps.forEach((s, i) => {
|
|
354
|
+
const who = s.actor ? `${s.actor}: ` : '';
|
|
355
|
+
const head = ` ${i + 1} ${who}${s.step}`;
|
|
356
|
+
const changes = s.modes ?? [];
|
|
357
|
+
const texts = changes.map((c) => describeChange(c, full));
|
|
358
|
+
const flag = s.differs ? ' ≠ DIFFERS: both modes made a request and the resulting text differs' : '';
|
|
359
|
+
if (!changes.length)
|
|
360
|
+
lines.push(`${head}${s.note ? ` — ${s.note}` : ''}`);
|
|
361
|
+
else if (texts.every((t) => t === texts[0]))
|
|
362
|
+
lines.push(`${head}${texts[0] ? `: ${texts[0]}` : ''}${s.ok && s.note ? ` — ${s.note}` : ''}${flag}`);
|
|
363
|
+
else {
|
|
364
|
+
lines.push(`${head}${flag}`);
|
|
365
|
+
changes.forEach((c, k) => {
|
|
366
|
+
lines.push(` ${c.mode}: ${texts[k] || '(no change)'}${c.ok && c.note ? ` — ${c.note}` : ''}`);
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
for (const e of s.elsewhere ?? [])
|
|
370
|
+
lines.push(` ${e.actor || 'page'}${modes.length > 1 ? ` (${e.mode})` : ''}: ${describeChange({
|
|
371
|
+
mode: e.mode,
|
|
372
|
+
ok: true,
|
|
373
|
+
note: null,
|
|
374
|
+
jsOnly: null,
|
|
375
|
+
requested: false,
|
|
376
|
+
navigated: false,
|
|
377
|
+
url: '',
|
|
378
|
+
added: e.added,
|
|
379
|
+
removed: e.removed,
|
|
380
|
+
}, full)}`);
|
|
381
|
+
});
|
|
450
382
|
if (!out.errors.length)
|
|
451
383
|
lines.push(' errors: none');
|
|
452
384
|
for (const e of out.errors)
|
|
453
|
-
lines.push(` error (${e.kind}): ${e.text}${e.at ? ` at ${e.at}` : ''}`);
|
|
385
|
+
lines.push(` error (${[e.kind, e.type, e.actor, modes.length > 1 ? e.mode : undefined].filter(Boolean).join(', ')}): ${e.text}${e.at ? ` at ${e.at}` : ''}${e.url && e.url !== e.at ? ` on ${e.url}` : ''}`);
|
|
454
386
|
for (const w of out.widgets)
|
|
455
387
|
lines.push(` widget ${w.name}: ${w.state}${w.width === null ? '' : ` ${w.width}×${w.height}`}${w.state === 'mounted' ? `, ${w.canvases} canvas, ${w.elements} elements` : ''}${w.hint ? ` — ${w.hint}` : ''}`);
|
|
456
|
-
|
|
388
|
+
const text = out.text.replace(/\n/g, ' · ');
|
|
389
|
+
lines.push(` text: ${cut(text, TEXT, full)}${out.truncated || (!full && text.length > TEXT) ? ' (truncated; --full shows all)' : ''}`);
|
|
390
|
+
const firstName = out.actors?.[0]?.name;
|
|
391
|
+
for (const a of out.actors ?? [])
|
|
392
|
+
if (a.name !== firstName && a.mode === modes[0])
|
|
393
|
+
lines.push(` ${a.name}: ${a.url} · ${cut(a.text.replace(/\n/g, ' · '), 200, full)}`);
|
|
457
394
|
for (const e of out.elements)
|
|
458
|
-
lines.push(` ${
|
|
459
|
-
.filter(([k]) => k !== 'class' && k !== 'style')
|
|
460
|
-
.map(([k, v]) => (v === '' ? ` ${k}` : ` ${k}="${v}"`))
|
|
461
|
-
.join('')}>${e.text ? ` ${e.text}` : ''}`);
|
|
395
|
+
lines.push(` ${describeElement(e)}`);
|
|
462
396
|
if (out.screenshot)
|
|
463
397
|
lines.push(` screenshot: ${out.screenshot}`);
|
|
464
398
|
return `${lines.join('\n')}\n`;
|