@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.
Files changed (136) hide show
  1. package/dist/cdp.d.ts.map +1 -1
  2. package/dist/cdp.js +1 -0
  3. package/dist/cdp.js.map +1 -1
  4. package/dist/commands/add-widget.d.ts.map +1 -1
  5. package/dist/commands/add-widget.js +4 -5
  6. package/dist/commands/add-widget.js.map +1 -1
  7. package/dist/commands/add.d.ts.map +1 -1
  8. package/dist/commands/add.js +4 -10
  9. package/dist/commands/add.js.map +1 -1
  10. package/dist/commands/app.d.ts +27 -0
  11. package/dist/commands/app.d.ts.map +1 -0
  12. package/dist/commands/app.js +148 -0
  13. package/dist/commands/app.js.map +1 -0
  14. package/dist/commands/browse-page.d.ts +2 -0
  15. package/dist/commands/browse-page.d.ts.map +1 -0
  16. package/dist/commands/browse-page.js +228 -0
  17. package/dist/commands/browse-page.js.map +1 -0
  18. package/dist/commands/browse-tab.d.ts +80 -0
  19. package/dist/commands/browse-tab.d.ts.map +1 -0
  20. package/dist/commands/browse-tab.js +431 -0
  21. package/dist/commands/browse-tab.js.map +1 -0
  22. package/dist/commands/browse-world.d.ts +36 -0
  23. package/dist/commands/browse-world.d.ts.map +1 -0
  24. package/dist/commands/browse-world.js +108 -0
  25. package/dist/commands/browse-world.js.map +1 -0
  26. package/dist/commands/browse.d.ts +24 -3
  27. package/dist/commands/browse.d.ts.map +1 -1
  28. package/dist/commands/browse.js +341 -407
  29. package/dist/commands/browse.js.map +1 -1
  30. package/dist/commands/check.d.ts +1 -2
  31. package/dist/commands/check.d.ts.map +1 -1
  32. package/dist/commands/check.js +24 -27
  33. package/dist/commands/check.js.map +1 -1
  34. package/dist/commands/map.d.ts +12 -0
  35. package/dist/commands/map.d.ts.map +1 -1
  36. package/dist/commands/map.js +83 -3
  37. package/dist/commands/map.js.map +1 -1
  38. package/dist/commands/migrate-agent.d.ts +12 -0
  39. package/dist/commands/migrate-agent.d.ts.map +1 -0
  40. package/dist/commands/migrate-agent.js +6 -0
  41. package/dist/commands/migrate-agent.js.map +1 -0
  42. package/dist/commands/migrate-app.d.ts +15 -0
  43. package/dist/commands/migrate-app.d.ts.map +1 -0
  44. package/dist/commands/migrate-app.js +334 -0
  45. package/dist/commands/migrate-app.js.map +1 -0
  46. package/dist/commands/migrate-ast.d.ts +63 -0
  47. package/dist/commands/migrate-ast.d.ts.map +1 -0
  48. package/dist/commands/migrate-ast.js +257 -0
  49. package/dist/commands/migrate-ast.js.map +1 -0
  50. package/dist/commands/migrate-forms.d.ts +4 -0
  51. package/dist/commands/migrate-forms.d.ts.map +1 -0
  52. package/dist/commands/migrate-forms.js +120 -0
  53. package/dist/commands/migrate-forms.js.map +1 -0
  54. package/dist/commands/migrate-freshness.d.ts +4 -0
  55. package/dist/commands/migrate-freshness.d.ts.map +1 -0
  56. package/dist/commands/migrate-freshness.js +47 -0
  57. package/dist/commands/migrate-freshness.js.map +1 -0
  58. package/dist/commands/migrate-head.d.ts +8 -0
  59. package/dist/commands/migrate-head.d.ts.map +1 -0
  60. package/dist/commands/migrate-head.js +132 -0
  61. package/dist/commands/migrate-head.js.map +1 -0
  62. package/dist/commands/migrate-links.d.ts +5 -0
  63. package/dist/commands/migrate-links.d.ts.map +1 -0
  64. package/dist/commands/migrate-links.js +55 -0
  65. package/dist/commands/migrate-links.js.map +1 -0
  66. package/dist/commands/migrate-lists.d.ts +9 -0
  67. package/dist/commands/migrate-lists.d.ts.map +1 -0
  68. package/dist/commands/migrate-lists.js +225 -0
  69. package/dist/commands/migrate-lists.js.map +1 -0
  70. package/dist/commands/migrate-normalize.d.ts +27 -0
  71. package/dist/commands/migrate-normalize.d.ts.map +1 -0
  72. package/dist/commands/migrate-normalize.js +252 -0
  73. package/dist/commands/migrate-normalize.js.map +1 -0
  74. package/dist/commands/migrate-ops.d.ts +4 -0
  75. package/dist/commands/migrate-ops.d.ts.map +1 -0
  76. package/dist/commands/migrate-ops.js +192 -0
  77. package/dist/commands/migrate-ops.js.map +1 -0
  78. package/dist/commands/migrate-parts.d.ts +7 -0
  79. package/dist/commands/migrate-parts.d.ts.map +1 -0
  80. package/dist/commands/migrate-parts.js +166 -0
  81. package/dist/commands/migrate-parts.js.map +1 -0
  82. package/dist/commands/migrate-stale.d.ts +24 -0
  83. package/dist/commands/migrate-stale.d.ts.map +1 -0
  84. package/dist/commands/migrate-stale.js +114 -0
  85. package/dist/commands/migrate-stale.js.map +1 -0
  86. package/dist/commands/migrate.d.ts +21 -0
  87. package/dist/commands/migrate.d.ts.map +1 -0
  88. package/dist/commands/migrate.js +358 -0
  89. package/dist/commands/migrate.js.map +1 -0
  90. package/dist/commands/plan.d.ts.map +1 -1
  91. package/dist/commands/plan.js +2 -6
  92. package/dist/commands/plan.js.map +1 -1
  93. package/dist/commands/request.d.ts +13 -16
  94. package/dist/commands/request.d.ts.map +1 -1
  95. package/dist/commands/request.js +140 -161
  96. package/dist/commands/request.js.map +1 -1
  97. package/dist/commands/scaffold.d.ts.map +1 -1
  98. package/dist/commands/scaffold.js +10 -8
  99. package/dist/commands/scaffold.js.map +1 -1
  100. package/dist/commands/seed-lock.d.ts +2 -0
  101. package/dist/commands/seed-lock.d.ts.map +1 -0
  102. package/dist/commands/seed-lock.js +6 -0
  103. package/dist/commands/seed-lock.js.map +1 -0
  104. package/dist/commands/serve.d.ts +6 -0
  105. package/dist/commands/serve.d.ts.map +1 -0
  106. package/dist/commands/serve.js +31 -0
  107. package/dist/commands/serve.js.map +1 -0
  108. package/dist/commands/skill.d.ts.map +1 -1
  109. package/dist/commands/skill.js +1 -2
  110. package/dist/commands/skill.js.map +1 -1
  111. package/dist/commands/validate.d.ts +13 -0
  112. package/dist/commands/validate.d.ts.map +1 -1
  113. package/dist/commands/validate.js +51 -5
  114. package/dist/commands/validate.js.map +1 -1
  115. package/dist/contract.d.ts +124 -7
  116. package/dist/contract.d.ts.map +1 -1
  117. package/dist/main.d.ts +8 -0
  118. package/dist/main.d.ts.map +1 -1
  119. package/dist/main.js +99 -31
  120. package/dist/main.js.map +1 -1
  121. package/dist/output.d.ts.map +1 -1
  122. package/dist/output.js +28 -2
  123. package/dist/output.js.map +1 -1
  124. package/dist/render.d.ts.map +1 -1
  125. package/dist/render.js +5 -1
  126. package/dist/render.js.map +1 -1
  127. package/package.json +7 -7
  128. package/schema/browse.schema.json +180 -0
  129. package/schema/check.schema.json +28 -8
  130. package/schema/inspect.schema.json +114 -3
  131. package/schema/map.schema.json +62 -0
  132. package/schema/migrate.schema.json +649 -0
  133. package/schema/plan.schema.json +8 -22
  134. package/schema/request.schema.json +133 -8
  135. package/schema/skill.schema.json +22 -1
  136. package/schema/validate.schema.json +28 -8
@@ -1,464 +1,398 @@
1
- import { existsSync } from 'node:fs';
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, extname, join, relative, resolve } from 'node:path';
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 { appParts } from './request.js';
8
- const ORIGIN = 'http://localhost';
6
+ import { sleep, Tab, World } from './browse-tab.js';
7
+ import { describeElement, parseSession } from './request.js';
9
8
  const LIMIT = 1500;
10
- const QUIET_MS = 300;
11
- const SETTLE_MS = 500;
12
- const CAP_MS = 8000;
13
- const MIME = {
14
- '.png': 'image/png',
15
- '.jpg': 'image/jpeg',
16
- '.jpeg': 'image/jpeg',
17
- '.svg': 'image/svg+xml',
18
- '.ico': 'image/x-icon',
19
- '.webp': 'image/webp',
20
- '.json': 'application/json',
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
- return { el: hits[0], count: hits.length }
70
- }
71
- const setValue = (el, value) => {
72
- const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : el instanceof HTMLSelectElement ? HTMLSelectElement.prototype : HTMLInputElement.prototype
73
- el.focus()
74
- if (el.isContentEditable) el.textContent = value
75
- else Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, value)
76
- el.dispatchEvent(new Event('input', { bubbles: true }))
77
- el.dispatchEvent(new Event('change', { bubbles: true }))
78
- }
79
- const note = (count) => (count > 1 ? count + ' matched; used the first' : null)
80
- return {
81
- point(kind, name) {
82
- const f = find(kind, name)
83
- if (f.error) return f
84
- f.el.scrollIntoView({ block: 'center', inline: 'center' })
85
- const r = f.el.getBoundingClientRect()
86
- return { x: r.left + r.width / 2, y: r.top + r.height / 2, note: note(f.count) }
87
- },
88
- fill(name, value) {
89
- const f = find('fill', name)
90
- if (f.error) return f
91
- setValue(f.el, value)
92
- return { note: note(f.count) }
93
- },
94
- select(name, option) {
95
- const f = find('select', name)
96
- if (f.error) return f
97
- const o = [...f.el.options].find((o) => norm(o.textContent) === norm(option) || o.value === option)
98
- if (!o) return { error: 'Select "' + name + '" has no option "' + option + '". Options: ' + [...f.el.options].map((o) => JSON.stringify(o.textContent.trim())).join(', ') }
99
- setValue(f.el, o.value)
100
- return { note: note(f.count) }
101
- },
102
- report(selectors, expected) {
103
- const widgets = []
104
- const hydrated = document.documentElement.hasAttribute('data-hozu-ready')
105
- for (const el of document.querySelectorAll('[data-hozu-widget]')) {
106
- const name = el.getAttribute('data-hozu-widget')
107
- const state = el.getAttribute('data-hozu-widget-state')
108
- const load = expected.find(([n]) => n === name)?.[1]
109
- const r = el.getBoundingClientRect()
110
- const empty = !el.children.length && !el.textContent.trim()
111
- widgets.push({
112
- name,
113
- state: state === 'loading' ? 'not mounted' : state,
114
- width: Math.round(r.width),
115
- height: Math.round(r.height),
116
- canvases: el.querySelectorAll('canvas').length,
117
- elements: el.querySelectorAll('*').length,
118
- hint:
119
- state === 'failed'
120
- ? 'its setup threw; see errors'
121
- : state === 'loading'
122
- ? load === 'visible' && (r.bottom < 0 || r.top > innerHeight)
123
- ? "load: 'visible' mounts it when it scrolls into view"
124
- : load === 'idle'
125
- ? "load: 'idle' mounts it when the browser is idle"
126
- : 'its module has not loaded; see errors'
127
- : r.height === 0
128
- ? 'it has no height, so a map or canvas inside shows nothing: give it a height class such as h-64'
129
- : empty
130
- ? 'it mounted but rendered nothing'
131
- : null,
132
- })
133
- }
134
- if (!hydrated)
135
- for (const [name] of expected)
136
- widgets.push({
137
- name, state: 'not mounted', width: null, height: null, canvases: 0, elements: 0,
138
- hint: 'the page did not hydrate; see errors',
139
- })
140
- const elements = []
141
- for (const sel of selectors)
142
- for (const el of document.querySelectorAll(sel)) {
143
- const attrs = {}
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 parts = await appParts(loaded, 'browse', options.session);
174
- const root = dirname(loaded.path);
175
- const build = loaded.build(false);
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
- const cdp = launch(browser, profile);
144
+ let cdp = null;
202
145
  const errors = [];
203
- const steps = [];
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 { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' });
210
- const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
211
- const send = (method, params = {}) => cdp.send(method, params, sessionId);
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
- if (from !== sessionId)
152
+ const tab = from ? bySession.get(from) : undefined;
153
+ if (!tab)
215
154
  return;
216
- if (method === 'Fetch.requestPaused') {
217
- const r = params.request;
218
- void (async () => {
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
- if (options.reducedMotion)
302
- await send('Emulation.setEmulatedMedia', {
303
- features: [{ name: 'prefers-reduced-motion', value: 'reduce' }],
304
- });
305
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
306
- const evaluate = async (expression) => {
307
- const r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
308
- if (r.exceptionDetails)
309
- throw new Error(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text);
310
- return r.result.value;
311
- };
312
- const settle = async () => {
313
- const start = Date.now();
314
- await sleep(SETTLE_MS);
315
- while (Date.now() - start < CAP_MS) {
316
- if (inflight <= 0 && Date.now() - lastActivity >= QUIET_MS) {
317
- const ready = await evaluate("!document.getElementById('hozu-payload') || document.documentElement.hasAttribute('data-hozu-ready')").catch(() => false);
318
- if (ready && loaded_)
319
- return;
320
- }
321
- await sleep(50);
322
- }
323
- };
324
- const open = async (to) => {
325
- loaded_ = false;
326
- lastActivity = Date.now();
327
- await send('Page.navigate', { url: ORIGIN + to });
328
- await settle();
329
- };
330
- const mouse = async (x, y) => {
331
- for (const type of ['mouseMoved', 'mousePressed', 'mouseReleased'])
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
- await send('Input.dispatchKeyEvent', {
380
- type: 'keyUp',
381
- key: rest,
382
- code: key.code,
383
- windowsVirtualKeyCode: key.keyCode,
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
- else if (verb === 'wait') {
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
- catch (error) {
401
- result = { error: error instanceof Error ? error.message : String(error) };
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
- steps.push({ step: text, ok: !result.error, note: result.error ?? result.note ?? null });
404
- if (result.error)
405
- break;
406
- if (verb !== 'wait' && verb !== 'goto')
407
- await settle();
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
- for (const skipped of options.steps.slice(steps.length))
410
- steps.push({ step: skipped.trim(), ok: false, note: 'skipped after a failed step' });
411
- const expected = Object.entries((await evaluate("(() => { try { return JSON.parse(document.getElementById('hozu-payload')?.textContent ?? '{}').widgets ?? {} } catch { return {} } })()"))).map(([name, w]) => [name, w.load]);
412
- const report = await evaluate(`${PAGE}.report(${JSON.stringify(options.select)}, ${JSON.stringify(expected)})`);
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 full = report.text;
422
- return {
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: report.url,
425
- status: firstStatus,
426
- title: report.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.length > LIMIT && !options.full ? `${full.slice(0, LIMIT)}…` : 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.close().catch(() => { });
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 || out.steps.some((s) => !s.ok) || out.widgets.some((w) => w.state === 'failed');
443
- export function describeBrowse(out) {
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
- for (const s of out.steps)
449
- lines.push(` ${s.ok ? 'step' : 'FAILED'}: ${s.step}${s.note ? ` — ${s.note}` : ''}`);
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
- lines.push(` text: ${out.text.replace(/\n/g, ' · ')}${out.truncated ? ' (truncated; --full shows all)' : ''}`);
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(` ${e.selector}: <${e.tag}${Object.entries(e.attrs)
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`;