mindweave 2.5.1 → 3.0.1

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 (236) hide show
  1. package/README.md +50 -114
  2. package/dist/cli/App.js +580 -305
  3. package/dist/cli/App.js.map +1 -1
  4. package/dist/cli/analytics.js +27 -18
  5. package/dist/cli/analytics.js.map +1 -1
  6. package/dist/cli/attachments.js +34 -0
  7. package/dist/cli/attachments.js.map +1 -1
  8. package/dist/cli/bannerFit.js +70 -0
  9. package/dist/cli/bannerFit.js.map +1 -0
  10. package/dist/cli/bootstrap.js +41 -3
  11. package/dist/cli/bootstrap.js.map +1 -1
  12. package/dist/cli/commands.js +4 -1
  13. package/dist/cli/commands.js.map +1 -1
  14. package/dist/cli/components/ApprovalBox.js +3 -2
  15. package/dist/cli/components/ApprovalBox.js.map +1 -1
  16. package/dist/cli/components/BlockView.js +27 -6
  17. package/dist/cli/components/BlockView.js.map +1 -1
  18. package/dist/cli/components/FirstRunFrame.js +2 -1
  19. package/dist/cli/components/FirstRunFrame.js.map +1 -1
  20. package/dist/cli/components/KeyManager.js +5 -4
  21. package/dist/cli/components/KeyManager.js.map +1 -1
  22. package/dist/cli/components/KeySetup.js +29 -13
  23. package/dist/cli/components/KeySetup.js.map +1 -1
  24. package/dist/cli/components/MarathonBox.js +64 -0
  25. package/dist/cli/components/MarathonBox.js.map +1 -0
  26. package/dist/cli/components/McpMinitabs.js +8 -7
  27. package/dist/cli/components/McpMinitabs.js.map +1 -1
  28. package/dist/cli/components/Picker.js +4 -3
  29. package/dist/cli/components/Picker.js.map +1 -1
  30. package/dist/cli/components/PromptInput.js +47 -8
  31. package/dist/cli/components/PromptInput.js.map +1 -1
  32. package/dist/cli/components/PulseDot.js +42 -0
  33. package/dist/cli/components/PulseDot.js.map +1 -0
  34. package/dist/cli/components/SubagentView.js +9 -6
  35. package/dist/cli/components/SubagentView.js.map +1 -1
  36. package/dist/cli/components/TipLine.js +2 -1
  37. package/dist/cli/components/TipLine.js.map +1 -1
  38. package/dist/cli/components/ToolGroup.js +65 -49
  39. package/dist/cli/components/ToolGroup.js.map +1 -1
  40. package/dist/cli/components/ToolLine.js +20 -19
  41. package/dist/cli/components/ToolLine.js.map +1 -1
  42. package/dist/cli/components/TrustGate.js +2 -1
  43. package/dist/cli/components/TrustGate.js.map +1 -1
  44. package/dist/cli/components/minitabs.js +3 -2
  45. package/dist/cli/components/minitabs.js.map +1 -1
  46. package/dist/cli/desktopApp.js +45 -0
  47. package/dist/cli/desktopApp.js.map +1 -0
  48. package/dist/cli/help.js +1 -0
  49. package/dist/cli/help.js.map +1 -1
  50. package/dist/cli/inkOutputPatch.js +285 -0
  51. package/dist/cli/inkOutputPatch.js.map +1 -0
  52. package/dist/cli/inkSpeed.js +29 -0
  53. package/dist/cli/inkSpeed.js.map +1 -0
  54. package/dist/cli/inkSpeedLoader.js +47 -0
  55. package/dist/cli/inkSpeedLoader.js.map +1 -0
  56. package/dist/cli/inputText.js +30 -0
  57. package/dist/cli/inputText.js.map +1 -0
  58. package/dist/cli/keyManager.js +2 -1
  59. package/dist/cli/keyManager.js.map +1 -1
  60. package/dist/cli/keyPrefs.js +90 -0
  61. package/dist/cli/keyPrefs.js.map +1 -0
  62. package/dist/cli/keySetup.js +5 -2
  63. package/dist/cli/keySetup.js.map +1 -1
  64. package/dist/cli/marathonUi.js +113 -0
  65. package/dist/cli/marathonUi.js.map +1 -0
  66. package/dist/cli/markdown.js +63 -11
  67. package/dist/cli/markdown.js.map +1 -1
  68. package/dist/cli/modes.js +3 -3
  69. package/dist/cli/modes.js.map +1 -1
  70. package/dist/cli/prodEnv.js +43 -0
  71. package/dist/cli/prodEnv.js.map +1 -0
  72. package/dist/cli/revealPace.js +5 -5
  73. package/dist/cli/revealPace.js.map +1 -1
  74. package/dist/cli/revealQueue.js +120 -0
  75. package/dist/cli/revealQueue.js.map +1 -0
  76. package/dist/cli/startupArgs.js +1 -1
  77. package/dist/cli/theme.js +47 -0
  78. package/dist/cli/theme.js.map +1 -0
  79. package/dist/cli/toolDisplay.js +112 -18
  80. package/dist/cli/toolDisplay.js.map +1 -1
  81. package/dist/cli/transcript.js +1 -1
  82. package/dist/cli/transcript.js.map +1 -1
  83. package/dist/cli/virtualWindow.js +14 -2
  84. package/dist/cli/virtualWindow.js.map +1 -1
  85. package/dist/cli/wheelAccel.js +73 -0
  86. package/dist/cli/wheelAccel.js.map +1 -0
  87. package/dist/cli/widestLinePatch.js +48 -0
  88. package/dist/cli/widestLinePatch.js.map +1 -0
  89. package/dist/cli/workingVerb.js +88 -28
  90. package/dist/cli/workingVerb.js.map +1 -1
  91. package/dist/cli/wrap.js +1 -1
  92. package/dist/cli/wrap.js.map +1 -1
  93. package/dist/core/contextSettings.js +84 -0
  94. package/dist/core/contextSettings.js.map +1 -0
  95. package/dist/core/limitsView.js +40 -0
  96. package/dist/core/limitsView.js.map +1 -0
  97. package/dist/core/mcpServers.js +164 -0
  98. package/dist/core/mcpServers.js.map +1 -0
  99. package/dist/core/permissions.js +209 -0
  100. package/dist/core/permissions.js.map +1 -0
  101. package/dist/core/providerKeys.js +187 -0
  102. package/dist/core/providerKeys.js.map +1 -0
  103. package/dist/core/rulesSkills.js +142 -0
  104. package/dist/core/rulesSkills.js.map +1 -0
  105. package/dist/core/spendView.js +90 -0
  106. package/dist/core/spendView.js.map +1 -0
  107. package/dist/core/turnRunner.js +453 -60
  108. package/dist/core/turnRunner.js.map +1 -1
  109. package/dist/drivers/anthropic/client.js +24 -5
  110. package/dist/drivers/anthropic/client.js.map +1 -1
  111. package/dist/drivers/anthropic/manifest.js +15 -2
  112. package/dist/drivers/anthropic/manifest.js.map +1 -1
  113. package/dist/drivers/cerebras/manifest.js +6 -2
  114. package/dist/drivers/cerebras/manifest.js.map +1 -1
  115. package/dist/drivers/deepseek/manifest.js +12 -3
  116. package/dist/drivers/deepseek/manifest.js.map +1 -1
  117. package/dist/drivers/glm/manifest.js +3 -1
  118. package/dist/drivers/glm/manifest.js.map +1 -1
  119. package/dist/drivers/groq/manifest.js +5 -2
  120. package/dist/drivers/groq/manifest.js.map +1 -1
  121. package/dist/drivers/ollama/catalog.js +71 -0
  122. package/dist/drivers/ollama/catalog.js.map +1 -0
  123. package/dist/drivers/ollama/client.js +164 -0
  124. package/dist/drivers/ollama/client.js.map +1 -0
  125. package/dist/drivers/ollama/endpoint.js +42 -0
  126. package/dist/drivers/ollama/endpoint.js.map +1 -0
  127. package/dist/drivers/ollama/index.js +5 -0
  128. package/dist/drivers/ollama/index.js.map +1 -0
  129. package/dist/drivers/ollama/manifest.js +64 -0
  130. package/dist/drivers/ollama/manifest.js.map +1 -0
  131. package/dist/drivers/openai/client.js +10 -4
  132. package/dist/drivers/openai/client.js.map +1 -1
  133. package/dist/drivers/openai/manifest.js +46 -12
  134. package/dist/drivers/openai/manifest.js.map +1 -1
  135. package/dist/drivers/registry.js +11 -0
  136. package/dist/drivers/registry.js.map +1 -1
  137. package/dist/drivers/retryPolicy.js +26 -1
  138. package/dist/drivers/retryPolicy.js.map +1 -1
  139. package/dist/dynamo/auxModel.js +54 -0
  140. package/dist/dynamo/auxModel.js.map +1 -0
  141. package/dist/dynamo/engine.js +380 -99
  142. package/dist/dynamo/engine.js.map +1 -1
  143. package/dist/dynamo/guard.js +10 -0
  144. package/dist/dynamo/guard.js.map +1 -1
  145. package/dist/dynamo/liveMeter.js +25 -6
  146. package/dist/dynamo/liveMeter.js.map +1 -1
  147. package/dist/dynamo/marathon.js +409 -0
  148. package/dist/dynamo/marathon.js.map +1 -0
  149. package/dist/dynamo/marathonPrompt.js +45 -0
  150. package/dist/dynamo/marathonPrompt.js.map +1 -0
  151. package/dist/dynamo/model.js +6 -2
  152. package/dist/dynamo/model.js.map +1 -1
  153. package/dist/dynamo/toolBatches.js +58 -0
  154. package/dist/dynamo/toolBatches.js.map +1 -0
  155. package/dist/dynamo/usageLimits.js +446 -0
  156. package/dist/dynamo/usageLimits.js.map +1 -0
  157. package/dist/governor/freshness.js +10 -7
  158. package/dist/governor/freshness.js.map +1 -1
  159. package/dist/governor/index.js +76 -9
  160. package/dist/governor/index.js.map +1 -1
  161. package/dist/governor/write.js +43 -14
  162. package/dist/governor/write.js.map +1 -1
  163. package/dist/index.js +18 -151
  164. package/dist/index.js.map +1 -1
  165. package/dist/main.js +162 -0
  166. package/dist/main.js.map +1 -0
  167. package/dist/mcp/manager.js +18 -0
  168. package/dist/mcp/manager.js.map +1 -1
  169. package/dist/memory/allMetas.js +69 -0
  170. package/dist/memory/allMetas.js.map +1 -0
  171. package/dist/memory/compaction.js +69 -14
  172. package/dist/memory/compaction.js.map +1 -1
  173. package/dist/memory/earlier.js +15 -0
  174. package/dist/memory/earlier.js.map +1 -0
  175. package/dist/memory/profile.js +93 -0
  176. package/dist/memory/profile.js.map +1 -0
  177. package/dist/memory/rewind.js +262 -0
  178. package/dist/memory/rewind.js.map +1 -0
  179. package/dist/memory/session.js +42 -10
  180. package/dist/memory/session.js.map +1 -1
  181. package/dist/memory/sessionMemory.js +48 -13
  182. package/dist/memory/sessionMemory.js.map +1 -1
  183. package/dist/memory/sessionMemoryCompact.js +1 -1
  184. package/dist/memory/sessionMemoryCompact.js.map +1 -1
  185. package/dist/memory/sessionTitle.js +50 -0
  186. package/dist/memory/sessionTitle.js.map +1 -0
  187. package/dist/memory/store.js +42 -1
  188. package/dist/memory/store.js.map +1 -1
  189. package/dist/memory/types.js.map +1 -1
  190. package/dist/tools/askUser.js +14 -0
  191. package/dist/tools/askUser.js.map +1 -1
  192. package/dist/tools/backgroundShells.js +208 -5
  193. package/dist/tools/backgroundShells.js.map +1 -1
  194. package/dist/tools/browser.js +176 -0
  195. package/dist/tools/browser.js.map +1 -0
  196. package/dist/tools/cdp.js +165 -0
  197. package/dist/tools/cdp.js.map +1 -0
  198. package/dist/tools/checkpoints.js +208 -5
  199. package/dist/tools/checkpoints.js.map +1 -1
  200. package/dist/tools/deferredNative.js +2 -0
  201. package/dist/tools/deferredNative.js.map +1 -1
  202. package/dist/tools/exitPlan.js +4 -0
  203. package/dist/tools/exitPlan.js.map +1 -1
  204. package/dist/tools/hiddenDesktop.js +273 -0
  205. package/dist/tools/hiddenDesktop.js.map +1 -0
  206. package/dist/tools/listeningPorts.js +134 -0
  207. package/dist/tools/listeningPorts.js.map +1 -0
  208. package/dist/tools/mcpSearch.js +8 -0
  209. package/dist/tools/mcpSearch.js.map +1 -1
  210. package/dist/tools/mindweaveStatus.js +1 -1
  211. package/dist/tools/mindweaveStatus.js.map +1 -1
  212. package/dist/tools/registry.js +3 -0
  213. package/dist/tools/registry.js.map +1 -1
  214. package/dist/tools/runCommand.js +107 -6
  215. package/dist/tools/runCommand.js.map +1 -1
  216. package/dist/tools/screenshot.js +4 -6
  217. package/dist/tools/screenshot.js.map +1 -1
  218. package/dist/tools/screenshotWin.js +26 -9
  219. package/dist/tools/screenshotWin.js.map +1 -1
  220. package/dist/tools/stateCheckpoint.js +128 -0
  221. package/dist/tools/stateCheckpoint.js.map +1 -0
  222. package/dist/tools/todo.js +6 -0
  223. package/dist/tools/todo.js.map +1 -1
  224. package/dist/tools/ui.js +1295 -0
  225. package/dist/tools/ui.js.map +1 -0
  226. package/dist/tools/uiHide.js +356 -0
  227. package/dist/tools/uiHide.js.map +1 -0
  228. package/dist/tools/uiPage.js +861 -0
  229. package/dist/tools/uiPage.js.map +1 -0
  230. package/dist/tools/uiWin.js +506 -0
  231. package/dist/tools/uiWin.js.map +1 -0
  232. package/dist/tools/webFetch.js +32 -25
  233. package/dist/tools/webFetch.js.map +1 -1
  234. package/dist/tools/webSearch.js +12 -0
  235. package/dist/tools/webSearch.js.map +1 -1
  236. package/package.json +78 -78
@@ -0,0 +1,861 @@
1
+ /**
2
+ * uiPage.ts — the page route of `ui`: drive web content over the DevTools protocol.
3
+ *
4
+ * Where uiWin.ts asks the operating system's accessibility layer, this works inside the
5
+ * page itself, which buys what accessibility cannot: real keys (Enter to submit, Tab,
6
+ * Escape to close a dialog), hover, the page's own errors, and a truthful answer to
7
+ * "could a person actually click this" — a click lands at the control's centre as a real
8
+ * input event, after checking what is really at that spot. A button hidden under a
9
+ * transparent overlay is reported as covered, which is exactly the bug a person would hit.
10
+ *
11
+ * All input goes to the page through the protocol. The user's mouse and keyboard are
12
+ * never involved, and a headless page has no window at all.
13
+ *
14
+ * Controls are tagged inside the page (a WeakMap from element to number, kept on
15
+ * `window.__mwui`), so the number behind a control survives re-renders that keep the
16
+ * element and turns into "gone" when the element is replaced or the page navigates.
17
+ */
18
+ import { createHash } from "node:crypto";
19
+ import { writeFile } from "node:fs/promises";
20
+ import { CdpConnection, listTargets, pageTargets } from "./cdp.js";
21
+ // ── the script that runs inside the page ────────────────────────────────────
22
+ /**
23
+ * Reads the page: every control a person could use, its name and state, and the text
24
+ * around them. Returns plain data. Kept as one string so it is sent whole each time and
25
+ * never depends on anything the page defined.
26
+ */
27
+ const READ_SCRIPT = String.raw `(() => {
28
+ const S = window.__mwui || (window.__mwui = { ids: new WeakMap(), els: new Map(), next: 1 });
29
+ const idOf = (el) => { let id = S.ids.get(el); if (!id) { id = S.next++; S.ids.set(el, id); } S.els.set(id, new WeakRef(el)); return id; };
30
+ const txt = (s) => (s || "").replace(/\s+/g, " ").trim();
31
+ const clip = (s, n) => (s.length > n ? s.slice(0, n - 1) + "…" : s);
32
+ const SKIP = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "HEAD", "META", "LINK"]);
33
+ const ROLE = { button: "button", link: "hyperlink", tab: "tabitem", menuitem: "menuitem", menuitemcheckbox: "menuitem",
34
+ menuitemradio: "menuitem", checkbox: "checkbox", switch: "checkbox", radio: "radiobutton", option: "listitem",
35
+ treeitem: "treeitem", combobox: "combobox", textbox: "edit", searchbox: "edit", slider: "slider", spinbutton: "edit" };
36
+ const styleOf = (el) => { try { return getComputedStyle(el); } catch { return null; } };
37
+ function kindOf(el) {
38
+ const role = (el.getAttribute("role") || "").trim().split(/\s+/)[0];
39
+ if (role && ROLE[role]) return ROLE[role];
40
+ const tag = el.tagName;
41
+ if (tag === "A" && el.hasAttribute("href")) return "hyperlink";
42
+ if (tag === "BUTTON" || tag === "SUMMARY") return "button";
43
+ if (tag === "SELECT") return "combobox";
44
+ if (tag === "TEXTAREA") return "edit";
45
+ if (tag === "INPUT") {
46
+ const t = (el.type || "text").toLowerCase();
47
+ if (t === "hidden") return null;
48
+ if (["button", "submit", "reset", "image", "file", "color"].includes(t)) return "button";
49
+ if (t === "checkbox") return "checkbox";
50
+ if (t === "radio") return "radiobutton";
51
+ if (t === "range") return "slider";
52
+ return "edit";
53
+ }
54
+ if (el.isContentEditable && !(el.parentElement && el.parentElement.isContentEditable)) return "edit";
55
+ if (tag === "LABEL") return null; // its input is listed instead
56
+ // Anything the page made clickable itself (a div with a click handler shows a pointer):
57
+ // listed once, at the outermost element with the pointer, not at every child inheriting it.
58
+ const st = styleOf(el);
59
+ if (st && st.cursor === "pointer") {
60
+ const ps = el.parentElement && styleOf(el.parentElement);
61
+ if (!ps || ps.cursor !== "pointer") return "clickable";
62
+ }
63
+ if (el.hasAttribute("onclick")) return "clickable";
64
+ return null;
65
+ }
66
+ const shown = (el) => {
67
+ const r = el.getBoundingClientRect();
68
+ if (r.width < 1 || r.height < 1) return null;
69
+ if (el.checkVisibility && !el.checkVisibility({ opacityProperty: true, visibilityProperty: true })) return null;
70
+ return r;
71
+ };
72
+ function nameOf(el, kind) {
73
+ const by = el.getAttribute("aria-labelledby");
74
+ if (by) { const t = txt(by.split(/\s+/).map((id) => (document.getElementById(id) || {}).textContent || "").join(" ")); if (t) return t; }
75
+ const al = txt(el.getAttribute("aria-label")); if (al) return al;
76
+ if (el.labels && el.labels.length) { const t = txt([...el.labels].map((l) => l.innerText).join(" ")); if (t) return t; }
77
+ if (el.tagName === "INPUT" && ["button", "submit", "reset"].includes(el.type)) { const v = txt(el.value); if (v) return v; }
78
+ if (el.tagName === "INPUT" && el.type === "image") { const a = txt(el.alt); if (a) return a; }
79
+ if (kind !== "edit" && kind !== "combobox") {
80
+ const inner = txt(el.innerText); if (inner) return inner;
81
+ const img = el.querySelector("img[alt]"); if (img && txt(img.alt)) return txt(img.alt);
82
+ const st = el.querySelector("svg title"); if (st && txt(st.textContent)) return txt(st.textContent);
83
+ }
84
+ const title = txt(el.getAttribute("title")); if (title) return title;
85
+ const ph = txt(el.getAttribute("placeholder") || el.getAttribute("data-placeholder")); if (ph) return ph;
86
+ return "";
87
+ }
88
+ function valueOf(el, kind) {
89
+ if (el.tagName === "INPUT" && el.type === "password") return "";
90
+ if (el.tagName === "SELECT") return txt(el.selectedOptions[0] ? el.selectedOptions[0].text : "");
91
+ if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
92
+ if (["checkbox", "radio", "button", "submit", "reset", "image", "file", "color"].includes(el.type)) return "";
93
+ return txt(el.value);
94
+ }
95
+ if (kind === "edit" && el.isContentEditable) return txt(el.innerText);
96
+ return "";
97
+ }
98
+ function stateOf(el) {
99
+ const s = [];
100
+ const a = (n) => el.getAttribute(n);
101
+ if (el.type === "checkbox" || el.type === "radio") s.push(el.checked ? "checked" : "unchecked");
102
+ else if (a("aria-checked") === "true") s.push("checked");
103
+ else if (a("aria-checked") === "false") s.push("unchecked");
104
+ if (a("aria-selected") === "true") s.push("selected");
105
+ if (a("aria-pressed") === "true") s.push("pressed");
106
+ if (a("aria-expanded") === "true") s.push("expanded");
107
+ else if (a("aria-expanded") === "false") s.push("collapsed");
108
+ if (a("aria-current") && a("aria-current") !== "false") s.push("current");
109
+ if (el.disabled || a("aria-disabled") === "true") s.push("disabled");
110
+ if (el.tagName === "INPUT" && el.type === "password") s.push("password");
111
+ if (el.required || a("aria-required") === "true") s.push("required");
112
+ if (a("aria-invalid") === "true" || (el.validity && el.willValidate && !el.validity.valid && el.value)) s.push("invalid");
113
+ return s;
114
+ }
115
+ const inView = (r) => r.bottom > 0 && r.right > 0 && r.top < innerHeight && r.left < innerWidth;
116
+
117
+ const controls = [];
118
+ const listed = new Set();
119
+ let walked = 0, more = 0;
120
+ const MAX = 400;
121
+ function consider(el) {
122
+ const kind = kindOf(el);
123
+ if (!kind) return;
124
+ let r = shown(el);
125
+ let viaLabel = false;
126
+ // A styled checkbox is often an invisible input behind its visible label.
127
+ if (!r && (el.type === "checkbox" || el.type === "radio") && el.labels && el.labels[0]) { r = shown(el.labels[0]); viaLabel = true; }
128
+ if (!r) return;
129
+ if (controls.length >= MAX) { more++; return; }
130
+ listed.add(el);
131
+ const state = stateOf(el);
132
+ if (!inView(r)) state.push("out of view");
133
+ // Something else is on top of it at its centre: a person could not press it (a dialog's backdrop, an overlay).
134
+ if (!viaLabel && inView(r)) {
135
+ const cx = Math.min(Math.max(r.left + r.width / 2, 0), innerWidth - 1), cy = Math.min(Math.max(r.top + r.height / 2, 0), innerHeight - 1);
136
+ const root = el.getRootNode();
137
+ const hit = root.elementFromPoint ? root.elementFromPoint(cx, cy) : document.elementFromPoint(cx, cy);
138
+ if (hit && hit !== el && !el.contains(hit) && !hit.contains(el)) state.push("covered");
139
+ }
140
+ const actions = kind === "edit" || kind === "combobox" && el.tagName === "SELECT" ? ["type"] : ["click"];
141
+ if (kind === "combobox" && el.tagName !== "SELECT") actions.push("type");
142
+ controls.push({ id: String(idOf(el)), window: 0, kind, name: clip(nameOf(el, kind), 80), value: clip(valueOf(el, kind), 80), actions, state });
143
+ }
144
+ function walk(root) {
145
+ const tw = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, {
146
+ acceptNode: (n) => (SKIP.has(n.tagName) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT),
147
+ });
148
+ for (let el = tw.nextNode(); el; el = tw.nextNode()) {
149
+ if (++walked > 12000) return;
150
+ consider(el);
151
+ if (el.shadowRoot) walk(el.shadowRoot);
152
+ }
153
+ }
154
+ if (document.body) walk(document.body);
155
+
156
+ // The words on the page that are not controls, one line per block, in reading order.
157
+ const texts = [];
158
+ const blockCache = new Map();
159
+ const blockOf = (el) => {
160
+ let e = el;
161
+ while (e && e !== document.body) {
162
+ if (blockCache.has(e)) return blockCache.get(e);
163
+ const st = styleOf(e);
164
+ if (st && st.display !== "inline" && st.display !== "contents") { blockCache.set(e, e); return e; }
165
+ e = e.parentElement;
166
+ }
167
+ return document.body;
168
+ };
169
+ const insideControl = (el) => { for (let e = el, i = 0; e && i < 12; e = e.parentElement, i++) if (listed.has(e)) return true; return false; };
170
+ let cur = null, buf = "", total = 0;
171
+ const flush = () => { const t = txt(buf); if (t && texts.length < 80) { texts.push({ window: 0, text: clip(t, 200) }); total += t.length; } buf = ""; };
172
+ if (document.body) {
173
+ const tw = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
174
+ for (let n = tw.nextNode(); n && texts.length < 80 && total < 6000; n = tw.nextNode()) {
175
+ const t = n.data;
176
+ if (!t || !t.trim()) continue;
177
+ const p = n.parentElement;
178
+ if (!p || SKIP.has(p.tagName) || insideControl(p) || !shown(p)) continue;
179
+ const b = blockOf(p);
180
+ if (b !== cur) { flush(); cur = b; }
181
+ buf += " " + t;
182
+ }
183
+ flush();
184
+ }
185
+ // What "scrolled" means here: the page itself, or, in an app whose content scrolls
186
+ // inside a panel (a chat, a feed), the biggest such panel.
187
+ let se = document.scrollingElement || document.documentElement;
188
+ if (se.scrollHeight - innerHeight < 2) {
189
+ let best = null, area = 0;
190
+ for (const el of document.querySelectorAll("body *")) {
191
+ if (el.scrollHeight - el.clientHeight < 2) continue;
192
+ const oy = (styleOf(el) || {}).overflowY;
193
+ if (oy !== "auto" && oy !== "scroll") continue;
194
+ const a = el.clientWidth * el.clientHeight;
195
+ if (a > area) { area = a; best = el; }
196
+ }
197
+ if (best) se = best;
198
+ }
199
+ const scrollMax = se === document.scrollingElement || se === document.documentElement ? se.scrollHeight - innerHeight : se.scrollHeight - se.clientHeight;
200
+ return {
201
+ controls, texts, more,
202
+ page: { url: location.href, title: document.title, scrollY: Math.round(se.scrollTop), scrollMax: Math.max(0, Math.round(scrollMax)), width: innerWidth, height: innerHeight },
203
+ };
204
+ })()`;
205
+ /** Find a tagged control again, or report it gone. Shared by every action below. */
206
+ const RESOLVE = String.raw `const S = window.__mwui; const ref = S && S.els.get(ID); const el = ref && ref.deref();
207
+ if (!el || !el.isConnected) return { error: "GONE" };
208
+ const describe = (e) => { if (!e || !e.tagName) return "something else"; let d = "<" + e.tagName.toLowerCase();
209
+ if (e.id) d += ' id="' + e.id + '"'; const c = typeof e.className === "string" ? e.className.trim().split(/\s+/).slice(0, 2).join(" ") : "";
210
+ if (c) d += ' class="' + c + '"'; d += ">"; const t = (e.innerText || "").replace(/\s+/g, " ").trim().slice(0, 40); return t ? d + ' "' + t + '"' : d; };`;
211
+ /** Where to click: the control's centre, after bringing it into view, if a click there
212
+ * would really reach it. */
213
+ const CLICK_POINT = (id) => String.raw `(() => { const ID = ${id}; ${RESOLVE}
214
+ if (el.disabled || el.getAttribute("aria-disabled") === "true") return { error: "it is disabled" };
215
+ if (el.tagName === "SELECT") return { error: "it is a dropdown: use type with the option to pick" };
216
+ let t = el;
217
+ const box = (e) => { const r = e.getBoundingClientRect(); return r.width >= 1 && r.height >= 1 ? r : null; };
218
+ if (!box(t) && el.labels && el.labels[0] && box(el.labels[0])) t = el.labels[0];
219
+ t.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
220
+ const r = box(t);
221
+ if (!r) return { error: "it has no size on the page" };
222
+ const x = r.left + r.width / 2, y = r.top + r.height / 2;
223
+ const root = t.getRootNode();
224
+ const hit = root.elementFromPoint ? root.elementFromPoint(x, y) : document.elementFromPoint(x, y);
225
+ if (hit && hit !== t && !t.contains(hit) && !(t === el.labels?.[0] && hit === el)) {
226
+ return { error: "it is covered by " + describe(hit) + " at that spot, so a click there would land on that instead" };
227
+ }
228
+ return { x, y };
229
+ })()`;
230
+ /** Focus a text box and select what is in it, so typing replaces it. A dropdown picks
231
+ * the option instead. */
232
+ const TYPE_PREP = (id, text) => String.raw `(() => { const ID = ${id}; const TEXT = ${JSON.stringify(text)}; ${RESOLVE}
233
+ if (el.disabled || el.readOnly || el.getAttribute("aria-disabled") === "true") return { error: "it is disabled or read-only" };
234
+ if (el.tagName === "INPUT" && el.type === "password") return { error: "PASSWORD" };
235
+ if (el.tagName === "SELECT") {
236
+ const want = TEXT.trim().toLowerCase();
237
+ const opts = [...el.options];
238
+ const opt = opts.find((o) => o.text.trim().toLowerCase() === want || o.value.toLowerCase() === want)
239
+ || opts.find((o) => o.text.trim().toLowerCase().includes(want));
240
+ if (!opt) return { error: "it has no option " + JSON.stringify(TEXT) + ". Its options: " + opts.slice(0, 20).map((o) => JSON.stringify(o.text.trim())).join(", ") };
241
+ el.value = opt.value;
242
+ el.dispatchEvent(new Event("input", { bubbles: true }));
243
+ el.dispatchEvent(new Event("change", { bubbles: true }));
244
+ return { done: "picked " + JSON.stringify(opt.text.trim()) };
245
+ }
246
+ if (el.type === "checkbox" || el.type === "radio") return { error: "it is a " + el.type + ": use click" };
247
+ el.scrollIntoView({ block: "center", behavior: "instant" });
248
+ el.focus();
249
+ if (typeof el.select === "function" && (el.tagName === "INPUT" || el.tagName === "TEXTAREA")) el.select();
250
+ else if (el.isContentEditable) { const range = document.createRange(); range.selectNodeContents(el); const s = getSelection(); s.removeAllRanges(); s.addRange(range); }
251
+ if (document.activeElement !== el && !el.contains(document.activeElement)) return { error: "it would not take focus" };
252
+ return { focused: true };
253
+ })()`;
254
+ /** After typing: did the text arrive? Some inputs (date pickers, custom widgets) ignore
255
+ * typed text, and those get the value set directly with the events a person's typing
256
+ * would have fired. */
257
+ const TYPE_CHECK = (id, text) => String.raw `(() => { const ID = ${id}; const TEXT = ${JSON.stringify(text)}; ${RESOLVE}
258
+ const now = el.isContentEditable ? el.innerText : el.value;
259
+ if (now === TEXT || !(el.tagName === "INPUT" || el.tagName === "TEXTAREA")) return { value: now };
260
+ const proto = el.tagName === "TEXTAREA" ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
261
+ Object.getOwnPropertyDescriptor(proto, "value").set.call(el, TEXT);
262
+ el.dispatchEvent(new Event("input", { bubbles: true }));
263
+ el.dispatchEvent(new Event("change", { bubbles: true }));
264
+ return { value: el.value, set: true };
265
+ })()`;
266
+ /** The centre of a control (brought into view), or of the viewport when none is named. */
267
+ const POINT = (id) => String.raw `(() => {
268
+ ${id === null ? "return { x: innerWidth / 2, y: innerHeight / 2 };" : `const ID = ${id}; ${RESOLVE}
269
+ el.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "instant" });
270
+ const r = el.getBoundingClientRect();
271
+ if (r.width < 1 || r.height < 1) return { error: "it has no size on the page" };
272
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 };`}
273
+ })()`;
274
+ /** A control's box on the page (document coordinates, padded a little so its edge shows),
275
+ * after bringing it into view. */
276
+ const BOX = (id) => String.raw `(() => { const ID = ${id}; ${RESOLVE}
277
+ el.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
278
+ const r = el.getBoundingClientRect();
279
+ if (r.width < 1 || r.height < 1) return { error: "it has no size on the page" };
280
+ const pad = 8, x = Math.max(0, r.left + scrollX - pad), y = Math.max(0, r.top + scrollY - pad);
281
+ return { x, y, w: Math.min(r.width + 2 * pad, 4000), h: Math.min(r.height + 2 * pad, 4000) };
282
+ })()`;
283
+ /** The facts a person judges a control by, read off the page: size, colours, type, and the
284
+ * things that are usually the bug (too small, cut off, hard to read). */
285
+ const INSPECT = (id) => String.raw `(() => { const ID = ${id}; ${RESOLVE}
286
+ el.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "instant" });
287
+ const r = el.getBoundingClientRect(); const st = getComputedStyle(el);
288
+ const rgb = (c) => { const m = /rgba?\(([^)]+)\)/.exec(c); if (!m) return null; const p = m[1].split(/[ ,\/]+/).filter(Boolean).map(Number); return { r: p[0], g: p[1], b: p[2], a: p.length > 3 ? p[3] : 1 }; };
289
+ const hex = (c) => { const v = rgb(c); return v ? "#" + [v.r, v.g, v.b].map((n) => Math.round(n).toString(16).padStart(2, "0")).join("") + (v.a < 1 ? " @" + Math.round(v.a * 100) + "%" : "") : c; };
290
+ const lum = (v) => { const f = (n) => { n /= 255; return n <= 0.03928 ? n / 12.92 : Math.pow((n + 0.055) / 1.055, 2.4); }; return 0.2126 * f(v.r) + 0.7152 * f(v.g) + 0.0722 * f(v.b); };
291
+ let bg = null;
292
+ for (let e = el; e && !bg; e = e.parentElement) { const v = rgb(getComputedStyle(e).backgroundColor); if (v && v.a > 0.95) bg = v; }
293
+ if (!bg) bg = { r: 255, g: 255, b: 255, a: 1 };
294
+ const fg = rgb(st.color);
295
+ let contrast = null;
296
+ if (fg) { const a = lum(fg), b = lum(bg); contrast = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); }
297
+ const lines = [];
298
+ lines.push("box: " + Math.round(r.width) + "x" + Math.round(r.height) + " at " + Math.round(r.left) + "," + Math.round(r.top) + " (viewport " + innerWidth + "x" + innerHeight + ")");
299
+ lines.push("text: " + st.fontSize + " " + st.fontFamily.split(",")[0].replace(/["']/g, "") + " weight " + st.fontWeight + ", colour " + hex(st.color) + " on " + hex("rgb(" + bg.r + "," + bg.g + "," + bg.b + ")") + (contrast ? " (contrast " + contrast.toFixed(1) + ":1)" : ""));
300
+ lines.push("surface: background " + hex(st.backgroundColor) + ", border " + st.borderTopWidth + " " + st.borderTopStyle + " " + hex(st.borderTopColor) + ", radius " + st.borderTopLeftRadius + ", padding " + st.padding + ", margin " + st.margin);
301
+ lines.push("behaviour: display " + st.display + ", opacity " + st.opacity + ", cursor " + st.cursor + (el.disabled ? ", disabled" : "") + (document.activeElement === el ? ", focused" : ""));
302
+ const issues = [];
303
+ const clickable = el.tagName === "BUTTON" || el.tagName === "A" || el.tagName === "INPUT" || el.getAttribute("role") === "button" || st.cursor === "pointer";
304
+ if (clickable && (r.width < 24 || r.height < 24)) issues.push("small press target (" + Math.round(r.width) + "x" + Math.round(r.height) + "; 24x24 is the least that is comfortable)");
305
+ if ((st.overflow !== "visible" || st.textOverflow === "ellipsis") && (el.scrollWidth > el.clientWidth + 1 || el.scrollHeight > el.clientHeight + 1)) issues.push("its content is cut off (" + el.scrollWidth + "x" + el.scrollHeight + " of content in " + el.clientWidth + "x" + el.clientHeight + ")");
306
+ if (contrast && contrast < 4.5 && (el.innerText || "").trim() && parseFloat(st.fontSize) < 24) issues.push("low contrast for text this size (" + contrast.toFixed(1) + ":1; 4.5:1 is the usual bar)");
307
+ if (r.right < 0 || r.bottom < 0 || r.left > innerWidth || r.top > innerHeight) issues.push("it is outside the visible area");
308
+ if (r.right > innerWidth + 1) issues.push("it runs past the right edge of the viewport by " + Math.round(r.right - innerWidth) + "px");
309
+ if (st.visibility === "hidden" || Number(st.opacity) < 0.05) issues.push("it is invisible (visibility/opacity)");
310
+ lines.push(issues.length ? "worth a look: " + issues.join("; ") : "nothing obviously wrong with it");
311
+ return { lines };
312
+ })()`;
313
+ /** Draw each listed control's number over it, in a layer that takes no clicks, so the picture
314
+ * and the list speak the same numbers. Removed again right after the picture is taken. */
315
+ const ANNOTATE = (pairs) => String.raw `(() => {
316
+ const S = window.__mwui; const old = document.getElementById("__mwui_overlay"); if (old) old.remove();
317
+ const layer = document.createElement("div"); layer.id = "__mwui_overlay";
318
+ layer.style.cssText = "position:absolute;left:0;top:0;width:0;height:0;z-index:2147483647;pointer-events:none";
319
+ let drawn = 0;
320
+ for (const p of ${JSON.stringify(pairs)}) {
321
+ const ref = S && S.els.get(Number(p.id)); const el = ref && ref.deref(); if (!el || !el.isConnected) continue;
322
+ const r = el.getBoundingClientRect(); if (r.width < 1 || r.height < 1) continue;
323
+ const box = document.createElement("div");
324
+ box.style.cssText = "position:absolute;box-sizing:border-box;border:1.5px solid #ff2d95;left:" + (r.left + scrollX) + "px;top:" + (r.top + scrollY) + "px;width:" + r.width + "px;height:" + r.height + "px";
325
+ const tag = document.createElement("div");
326
+ tag.textContent = String(p.n);
327
+ tag.style.cssText = "position:absolute;left:-1.5px;top:-14px;min-width:14px;padding:0 3px;height:14px;line-height:14px;font:700 10px/14px monospace;color:#fff;background:#ff2d95;text-align:center;border-radius:2px";
328
+ box.appendChild(tag); layer.appendChild(box); drawn++;
329
+ }
330
+ document.documentElement.appendChild(layer);
331
+ return drawn;
332
+ })()`;
333
+ const FOCUS = (id) => String.raw `(() => { const ID = ${id}; ${RESOLVE} el.focus(); return { ok: true }; })()`;
334
+ const NAMED_KEYS = {
335
+ enter: ["Enter", 13, "\r"],
336
+ return: ["Enter", 13, "\r"],
337
+ tab: ["Tab", 9],
338
+ escape: ["Escape", 27],
339
+ esc: ["Escape", 27],
340
+ backspace: ["Backspace", 8],
341
+ delete: ["Delete", 46],
342
+ space: [" ", 32, " "],
343
+ arrowup: ["ArrowUp", 38],
344
+ up: ["ArrowUp", 38],
345
+ arrowdown: ["ArrowDown", 40],
346
+ down: ["ArrowDown", 40],
347
+ arrowleft: ["ArrowLeft", 37],
348
+ left: ["ArrowLeft", 37],
349
+ arrowright: ["ArrowRight", 39],
350
+ right: ["ArrowRight", 39],
351
+ home: ["Home", 36],
352
+ end: ["End", 35],
353
+ pageup: ["PageUp", 33],
354
+ pagedown: ["PageDown", 34],
355
+ };
356
+ const MODIFIER_BITS = { alt: 1, ctrl: 2, control: 2, meta: 4, cmd: 4, command: 4, win: 4, shift: 8 };
357
+ /** Turn "Enter", "Ctrl+A", "Shift+Tab" into what the protocol needs, or null. Pure. */
358
+ export function parseKey(combo) {
359
+ const parts = combo.split("+").map((p) => p.trim()).filter(Boolean);
360
+ if (parts.length === 0)
361
+ return null;
362
+ let modifiers = 0;
363
+ for (const m of parts.slice(0, -1)) {
364
+ const bit = MODIFIER_BITS[m.toLowerCase()];
365
+ if (bit === undefined)
366
+ return null;
367
+ modifiers |= bit;
368
+ }
369
+ const last = parts[parts.length - 1];
370
+ const named = NAMED_KEYS[last.toLowerCase()];
371
+ const shift = (modifiers & 8) !== 0;
372
+ const plain = (modifiers & 7) === 0; // no Ctrl/Alt/Meta: the key types its character
373
+ if (named) {
374
+ const [key, keyCode, text] = named;
375
+ return { key, code: key === " " ? "Space" : key, keyCode, text: plain ? text : undefined, modifiers };
376
+ }
377
+ const f = /^f([1-9]|1[0-2])$/i.exec(last);
378
+ if (f)
379
+ return { key: `F${f[1]}`, code: `F${f[1]}`, keyCode: 111 + Number(f[1]), modifiers };
380
+ if (last.length === 1) {
381
+ const ch = last;
382
+ const up = ch.toUpperCase();
383
+ const letter = /[a-z]/i.test(ch);
384
+ const digit = /[0-9]/.test(ch);
385
+ const key = letter ? (shift ? up : ch.toLowerCase()) : ch;
386
+ return {
387
+ key,
388
+ code: letter ? `Key${up}` : digit ? `Digit${ch}` : "",
389
+ keyCode: letter || digit ? up.charCodeAt(0) : 0,
390
+ text: plain ? key : undefined,
391
+ modifiers,
392
+ };
393
+ }
394
+ return null;
395
+ }
396
+ const MAX_ERRORS = 12;
397
+ const MAX_WARNINGS = 6;
398
+ /** What the page has to be doing nothing for, before a step counts as finished. */
399
+ const QUIET_MS = 150;
400
+ /** Resolves once the page has gone quiet: nothing in the document changed for QUIET_MS and
401
+ * no animation or transition that has an end is still running (a spinner that never ends
402
+ * does not count, or nothing would ever settle). Gives up at `max`, so a page that keeps
403
+ * moving is still looked at. Runs inside the page. */
404
+ const QUIET_SCRIPT = (quiet, max) => String.raw `new Promise((resolve) => {
405
+ const start = Date.now(); let last = start;
406
+ const mo = new MutationObserver(() => { last = Date.now(); });
407
+ mo.observe(document, { subtree: true, childList: true, attributes: true, characterData: true });
408
+ const moving = () => { try { return document.getAnimations().some((a) => { if (a.playState !== "running") return false;
409
+ const t = a.effect && a.effect.getComputedTiming && a.effect.getComputedTiming(); return !t || t.endTime !== Infinity; }); } catch { return false; } };
410
+ const tick = () => { const now = Date.now();
411
+ if (now - start >= ${max} || (now - last >= ${quiet} && !moving())) { mo.disconnect(); resolve(true); } else setTimeout(tick, 25); };
412
+ setTimeout(tick, 25);
413
+ })`;
414
+ /** One page being driven: its connection, and what it has reported since the last step. */
415
+ export class PageSession {
416
+ conn;
417
+ port;
418
+ target;
419
+ owned;
420
+ errors = [];
421
+ warnings = [];
422
+ dialogs = [];
423
+ /** Requests the page has started and not finished: a step is not over while data is on its way. */
424
+ inflight = new Set();
425
+ /** The viewport this session forced (a phone, a narrow window), if it did. */
426
+ emulated = null;
427
+ /** Where screencast frames go while the page is being streamed (see startCast). */
428
+ castTo = null;
429
+ casting = false;
430
+ constructor(conn, port, target,
431
+ /** Set when this session started the browser, so closing it stops the browser too. */
432
+ owned) {
433
+ this.conn = conn;
434
+ this.port = port;
435
+ this.target = target;
436
+ this.owned = owned;
437
+ conn.on("Runtime.exceptionThrown", (p) => {
438
+ const d = p.exceptionDetails;
439
+ const what = (d?.exception?.description ?? d?.text ?? "an error").split("\n")[0];
440
+ this.error(`${what}${d?.url ? ` (${shortUrl(d.url)}:${(d.lineNumber ?? 0) + 1})` : ""}`);
441
+ });
442
+ conn.on("Runtime.consoleAPICalled", (p) => {
443
+ if (p.type !== "error" && p.type !== "assert" && p.type !== "warning")
444
+ return;
445
+ const args = p.args ?? [];
446
+ const line = args.map((a) => (a.value !== undefined ? String(a.value) : a.description ?? "")).join(" ").split("\n")[0];
447
+ if (p.type === "warning")
448
+ this.warn(`console.warn: ${line}`);
449
+ else
450
+ this.error(`console.error: ${line}`);
451
+ });
452
+ conn.on("Network.requestWillBeSent", (p) => {
453
+ this.inflight.add(String(p.requestId));
454
+ });
455
+ for (const done of ["Network.loadingFinished", "Network.loadingFailed"]) {
456
+ conn.on(done, (p) => {
457
+ this.inflight.delete(String(p.requestId));
458
+ });
459
+ }
460
+ conn.on("Log.entryAdded", (p) => {
461
+ const e = p.entry;
462
+ if (e?.level !== "error")
463
+ return;
464
+ // The browser asks every site for a favicon on its own; a missing one is not the
465
+ // page's error and sends the model chasing a file nothing asked for.
466
+ if (e.url && /\/favicon\.ico(\?|$)/i.test(e.url))
467
+ return;
468
+ this.error(`${e.text ?? "error"}${e.url ? ` (${shortUrl(e.url)})` : ""}`);
469
+ });
470
+ // A dialog stops the page until it is answered, so it is answered at once: accepted,
471
+ // as the person who pressed the button meant, and reported so the model knows it
472
+ // happened and what it said.
473
+ // Each frame must be acknowledged before the browser sends the next, which is also
474
+ // what keeps a slow receiver from being buried: frames it has not taken are skipped.
475
+ conn.on("Page.screencastFrame", (p) => {
476
+ void conn.send("Page.screencastFrameAck", { sessionId: p.sessionId }).catch(() => { });
477
+ const m = (p.metadata ?? {});
478
+ this.castTo?.({
479
+ data: String(p.data ?? ""),
480
+ ts: typeof m.timestamp === "number" ? Math.round(m.timestamp * 1000) : Date.now(),
481
+ width: Math.round(m.deviceWidth ?? 0),
482
+ height: Math.round(m.deviceHeight ?? 0),
483
+ });
484
+ });
485
+ conn.on("Page.javascriptDialogOpening", (p) => {
486
+ this.dialogs.push(`${String(p.type ?? "alert")} dialog: "${String(p.message ?? "").slice(0, 200)}" (accepted)`);
487
+ void conn.send("Page.handleJavaScriptDialog", { accept: true, promptText: "" }).catch(() => { });
488
+ });
489
+ }
490
+ /** Attach to a page behind a local debugging port. `pick` narrows by title or address. */
491
+ static async attach(port, pick, owned) {
492
+ const pages = pageTargets(await listTargets(port));
493
+ if (pages.length === 0)
494
+ throw new Error(`port ${port} has no page to drive`);
495
+ const needle = pick?.toLowerCase();
496
+ const target = (needle && pages.find((p) => p.title.toLowerCase().includes(needle) || p.url.toLowerCase().includes(needle))) || pages[0];
497
+ const conn = await CdpConnection.open(target.webSocketDebuggerUrl);
498
+ const session = new PageSession(conn, port, target, owned);
499
+ await Promise.all([
500
+ conn.send("Runtime.enable"),
501
+ conn.send("Log.enable"),
502
+ conn.send("Page.enable"),
503
+ conn.send("Network.enable"),
504
+ ]);
505
+ return { session, others: pages.filter((p) => p !== target) };
506
+ }
507
+ get closed() {
508
+ return this.conn.closed;
509
+ }
510
+ /**
511
+ * Stream the page as it changes. The browser sends a frame only when something on it
512
+ * moved, so a still page costs nothing, and a moving one arrives as video.
513
+ */
514
+ async startCast(to) {
515
+ this.castTo = to;
516
+ if (this.casting || this.conn.closed)
517
+ return;
518
+ this.casting = true;
519
+ try {
520
+ await this.conn.send("Page.startScreencast", { format: "jpeg", quality: 60, maxWidth: 1280, maxHeight: 900, everyNthFrame: 1 });
521
+ }
522
+ catch {
523
+ this.casting = false; // a page that cannot stream still works; it just is not shown live
524
+ }
525
+ }
526
+ async stopCast() {
527
+ if (!this.casting)
528
+ return;
529
+ this.casting = false;
530
+ await this.conn.send("Page.stopScreencast").catch(() => { });
531
+ }
532
+ error(line) {
533
+ if (this.errors.length < MAX_ERRORS)
534
+ this.errors.push(line.slice(0, 300));
535
+ }
536
+ warn(line) {
537
+ const l = line.slice(0, 300);
538
+ if (this.warnings.length < MAX_WARNINGS && !this.warnings.includes(l))
539
+ this.warnings.push(l);
540
+ }
541
+ async evaluate(expression) {
542
+ const res = await this.conn.send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true });
543
+ const ex = res.exceptionDetails;
544
+ if (ex)
545
+ throw new Error((ex.exception?.description ?? ex.text ?? "the page script failed").split("\n")[0]);
546
+ return res.result?.value;
547
+ }
548
+ /** Wait until the page has finished loading, riding out a navigation in progress. */
549
+ async settle(ms) {
550
+ if (ms > 0) {
551
+ // `ms` is the most to wait, not a fixed pause: a click on a page that answers at once
552
+ // is over in a fraction of it. A moment for the handler to run, then until the requests
553
+ // it started are back, then until the page has stopped changing.
554
+ const t0 = Date.now();
555
+ await new Promise((r) => setTimeout(r, Math.min(ms, 60)));
556
+ while (this.inflight.size > 0 && Date.now() - t0 < ms)
557
+ await new Promise((r) => setTimeout(r, 40));
558
+ const left = Math.max(ms - (Date.now() - t0), QUIET_MS + 50);
559
+ try {
560
+ await this.evaluate(QUIET_SCRIPT(QUIET_MS, left));
561
+ }
562
+ catch {
563
+ // A navigation replaced the document under the wait; the load check below takes over.
564
+ await new Promise((r) => setTimeout(r, Math.min(left, 300)));
565
+ }
566
+ }
567
+ const deadline = Date.now() + 8000;
568
+ while (Date.now() < deadline) {
569
+ try {
570
+ if ((await this.evaluate("document.readyState")) === "complete")
571
+ return;
572
+ }
573
+ catch {
574
+ // The old document is gone and the new one is not ready to answer yet.
575
+ }
576
+ await new Promise((r) => setTimeout(r, 150));
577
+ }
578
+ }
579
+ /** Read the page: controls, text, where it is scrolled, and what it reported. */
580
+ async read() {
581
+ let raw;
582
+ for (let i = 0; i < 3 && !raw; i++) {
583
+ try {
584
+ raw = await this.evaluate(READ_SCRIPT);
585
+ }
586
+ catch (e) {
587
+ if (i === 2)
588
+ throw e;
589
+ await this.settle(300); // navigating mid-read; wait for the new page
590
+ }
591
+ }
592
+ const snap = {
593
+ windows: [{ handle: this.target.id, title: raw.page.title || shortUrl(raw.page.url) }],
594
+ controls: raw.controls,
595
+ texts: raw.texts,
596
+ more: raw.more,
597
+ page: raw.page,
598
+ };
599
+ if (this.errors.length)
600
+ snap.errors = this.errors.splice(0);
601
+ if (this.warnings.length)
602
+ snap.warnings = this.warnings.splice(0);
603
+ if (this.emulated)
604
+ snap.emulated = { ...this.emulated };
605
+ if (this.dialogs.length)
606
+ snap.dialogs = this.dialogs.splice(0);
607
+ return snap;
608
+ }
609
+ /** Go to an address, waiting for it to load. */
610
+ async navigate(url) {
611
+ const res = await this.conn.send("Page.navigate", { url });
612
+ if (typeof res.errorText === "string" && res.errorText)
613
+ throw new Error(`could not open ${url}: ${res.errorText}`);
614
+ await this.settle(200);
615
+ }
616
+ async back(settleMs) {
617
+ const had = await this.evaluate("history.length");
618
+ if (had <= 1)
619
+ return "ERR there is no page to go back to";
620
+ await this.evaluate("history.back()");
621
+ await this.settle(settleMs);
622
+ return "OK went back";
623
+ }
624
+ /** Carry out one action on a control. Returns "OK …" or "ERR …" like the window route. */
625
+ async act(controlId, action, text, settleMs) {
626
+ const id = /^\d+$/.test(controlId) ? controlId : "0";
627
+ try {
628
+ if (action === "click") {
629
+ const at = await this.evaluate(CLICK_POINT(id));
630
+ if (at.error)
631
+ return `ERR ${at.error}`;
632
+ await this.mouse(at.x, at.y, true);
633
+ await this.settle(settleMs);
634
+ return "OK pressed";
635
+ }
636
+ if (action === "hover") {
637
+ const at = await this.evaluate(POINT(id));
638
+ if (at.error)
639
+ return `ERR ${at.error}`;
640
+ await this.conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: at.x, y: at.y });
641
+ await this.settle(settleMs);
642
+ return "OK hovering";
643
+ }
644
+ if (action === "type") {
645
+ const prep = await this.evaluate(TYPE_PREP(id, text));
646
+ if (prep.error === "PASSWORD")
647
+ return "ERR it is a password field; ask the user to type it";
648
+ if (prep.error)
649
+ return `ERR ${prep.error}`;
650
+ if (prep.done) {
651
+ await this.settle(settleMs);
652
+ return `OK ${prep.done}`;
653
+ }
654
+ if (text)
655
+ await this.conn.send("Input.insertText", { text });
656
+ else
657
+ await this.key(parseKey("Delete"));
658
+ await this.evaluate(TYPE_CHECK(id, text));
659
+ await this.settle(settleMs);
660
+ return "OK typed";
661
+ }
662
+ // scroll: a real wheel over the control (or the middle of the page), which scrolls
663
+ // whatever would scroll under a person's pointer there.
664
+ const at = await this.evaluate(POINT(controlId === "" ? null : id));
665
+ if (at.error)
666
+ return `ERR ${at.error}`;
667
+ const before = await this.scrollState(at.x, at.y);
668
+ const h = await this.evaluate("innerHeight");
669
+ const step = Math.round(h * 0.8);
670
+ const dir = action.slice("scroll-".length);
671
+ await this.conn.send("Input.dispatchMouseEvent", {
672
+ type: "mouseWheel",
673
+ x: at.x,
674
+ y: at.y,
675
+ deltaX: dir === "left" ? -step : dir === "right" ? step : 0,
676
+ deltaY: dir === "up" ? -step : dir === "down" ? step : 0,
677
+ });
678
+ await this.settle(Math.max(settleMs, 300));
679
+ const after = await this.scrollState(at.x, at.y);
680
+ return after === before ? "ERR nothing moved: it is already at that end, or nothing there scrolls" : "OK scrolled";
681
+ }
682
+ catch (e) {
683
+ const msg = e instanceof Error ? e.message : String(e);
684
+ return `ERR ${msg}`;
685
+ }
686
+ }
687
+ /** Press a key (or combination), in the named control or wherever focus is. */
688
+ async pressKey(combo, controlId, settleMs) {
689
+ const k = parseKey(combo);
690
+ if (!k)
691
+ return `ERR "${combo}" is not a key this knows. Use names like Enter, Tab, Escape, ArrowDown, or a combination like Ctrl+A`;
692
+ if (controlId !== null) {
693
+ const f = await this.evaluate(FOCUS(/^\d+$/.test(controlId) ? controlId : "0"));
694
+ if (f.error)
695
+ return `ERR ${f.error === "GONE" ? "GONE" : f.error}`;
696
+ }
697
+ await this.key(k);
698
+ await this.settle(settleMs);
699
+ return "OK pressed";
700
+ }
701
+ async key(k) {
702
+ const base = { key: k.key, code: k.code, windowsVirtualKeyCode: k.keyCode, nativeVirtualKeyCode: k.keyCode, modifiers: k.modifiers };
703
+ await this.conn.send("Input.dispatchKeyEvent", { type: k.text ? "keyDown" : "rawKeyDown", ...base, ...(k.text ? { text: k.text, unmodifiedText: k.text } : {}) });
704
+ await this.conn.send("Input.dispatchKeyEvent", { type: "keyUp", ...base });
705
+ }
706
+ async mouse(x, y, click) {
707
+ await this.conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
708
+ if (!click)
709
+ return;
710
+ await this.conn.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button: "left", buttons: 1, clickCount: 1 });
711
+ await this.conn.send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button: "left", buttons: 0, clickCount: 1 });
712
+ }
713
+ /** Where everything that could scroll under (x, y) is scrolled to, as one string to
714
+ * compare: the element there and each of its ancestors, then the page. An app whose
715
+ * content scrolls inside a panel (a chat, a sidebar) never moves the page itself. */
716
+ async scrollState(x, y) {
717
+ try {
718
+ return await this.evaluate(`(() => { const out = []; let e = document.elementFromPoint(${x}, ${y}); ` +
719
+ `for (; e; e = e.parentElement) out.push(e.scrollTop + "," + e.scrollLeft); ` +
720
+ `const se = document.scrollingElement || document.documentElement; out.push(se.scrollTop + "," + se.scrollLeft); return out.join("|"); })()`);
721
+ }
722
+ catch {
723
+ return "";
724
+ }
725
+ }
726
+ /** Wait until some text is on the page (or, with `gone`, is no longer), up to `timeoutMs`.
727
+ * Returns "OK …" once it is, or "ERR …" saying what was there instead. */
728
+ async waitFor(opts) {
729
+ const want = (opts.text ?? "").trim().toLowerCase();
730
+ const leave = (opts.gone ?? "").trim().toLowerCase();
731
+ const deadline = Date.now() + opts.ms;
732
+ const has = (needle) => this.evaluate(`(() => { const n = ${JSON.stringify(needle)}; const all = (document.body ? document.body.innerText : "").toLowerCase(); if (all.includes(n)) return true;` +
733
+ ` for (const el of document.querySelectorAll("input,textarea,select,[aria-label],[title]")) { const v = ((el.value || "") + " " + (el.getAttribute("aria-label") || "") + " " + (el.getAttribute("title") || "")).toLowerCase(); if (v.includes(n)) return true; } return false; })()`).catch(() => false);
734
+ const started = Date.now();
735
+ for (;;) {
736
+ const okWant = !want || (await has(want));
737
+ const okGone = !leave || !(await has(leave));
738
+ if (okWant && okGone)
739
+ return `OK ${want ? `"${opts.text}" is on the page` : `"${opts.gone}" is gone`} (after ${Date.now() - started}ms)`;
740
+ if (Date.now() >= deadline) {
741
+ return `ERR after ${Math.round(opts.ms / 1000)}s ${want && !okWant ? `"${opts.text}" never appeared` : `"${opts.gone}" never went away`}`;
742
+ }
743
+ await new Promise((r) => setTimeout(r, 100));
744
+ }
745
+ }
746
+ /** Force the viewport to a size (a phone, a narrow window), or give it back with 0x0. */
747
+ async resize(width, height, mobile) {
748
+ if (width <= 0 || height <= 0) {
749
+ await this.conn.send("Emulation.clearDeviceMetricsOverride");
750
+ this.emulated = null;
751
+ await this.settle(250);
752
+ return "OK viewport back to the window's own size";
753
+ }
754
+ await this.conn.send("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor: 1, mobile, screenWidth: width, screenHeight: height });
755
+ await this.conn.send("Emulation.setTouchEmulationEnabled", { enabled: mobile }).catch(() => { });
756
+ this.emulated = { width, height, mobile };
757
+ await this.settle(300);
758
+ return `OK viewport is now ${width}x${height}${mobile ? " (phone-style, with touch)" : ""}`;
759
+ }
760
+ /** What a control looks like and is: its box, its computed style, and what is wrong with
761
+ * it a person would notice (too small to press, text cut off, low contrast). */
762
+ async inspect(controlId) {
763
+ const id = /^\d+$/.test(controlId) ? controlId : "0";
764
+ const r = await this.evaluate(INSPECT(id));
765
+ if (r.error)
766
+ return `ERR ${r.error}`;
767
+ return `OK ${(r.lines ?? []).join("\n")}`;
768
+ }
769
+ /** A picture of what the page shows now: the viewport, the whole page, or one control. */
770
+ async screenshot(path, opts = {}) {
771
+ const params = { format: "png" };
772
+ if (opts.annotate?.length)
773
+ await this.evaluate(ANNOTATE(opts.annotate)).catch(() => { });
774
+ if (opts.controlId) {
775
+ const id = /^\d+$/.test(opts.controlId) ? opts.controlId : "0";
776
+ const box = await this.evaluate(BOX(id));
777
+ if (box.error)
778
+ throw new Error(box.error === "GONE" ? "that control is gone: the page changed since the last look" : box.error);
779
+ params.clip = { x: box.x, y: box.y, width: box.w, height: box.h, scale: 1 };
780
+ params.captureBeyondViewport = true;
781
+ }
782
+ else if (opts.full) {
783
+ const size = await this.evaluate(`({ w: Math.max(document.documentElement.scrollWidth, innerWidth), h: Math.max(document.documentElement.scrollHeight, innerHeight) })`);
784
+ params.clip = { x: 0, y: 0, width: size.w, height: Math.min(size.h, 8000), scale: 1 };
785
+ params.captureBeyondViewport = true;
786
+ }
787
+ let res;
788
+ try {
789
+ res = await this.conn.send("Page.captureScreenshot", params, 20_000);
790
+ }
791
+ finally {
792
+ if (opts.annotate?.length)
793
+ await this.evaluate('document.getElementById("__mwui_overlay")?.remove()').catch(() => { });
794
+ }
795
+ const data = Buffer.from(String(res.data ?? ""), "base64");
796
+ await writeFile(path, data);
797
+ // The picture's bytes, so the caller can tell when nothing changed.
798
+ this.lastShot = createHash("sha1").update(data).digest("hex");
799
+ // PNG header: width and height at bytes 16 and 20.
800
+ return data.length > 24 ? { width: data.readUInt32BE(16), height: data.readUInt32BE(20) } : { width: 0, height: 0 };
801
+ }
802
+ /** A fingerprint of the last picture taken. */
803
+ lastShot = "";
804
+ /** Pages behind the same port that were not there before (a link opened a new tab). */
805
+ async newPages(known) {
806
+ try {
807
+ return pageTargets(await listTargets(this.port)).filter((t) => !known.has(t.id));
808
+ }
809
+ catch {
810
+ return [];
811
+ }
812
+ }
813
+ async pageIds() {
814
+ try {
815
+ return new Set(pageTargets(await listTargets(this.port)).map((t) => t.id));
816
+ }
817
+ catch {
818
+ return new Set();
819
+ }
820
+ }
821
+ /** Move to another page behind the same port (a tab the last step opened). */
822
+ async switchTo(target) {
823
+ const owned = this.owned;
824
+ this.owned = undefined; // the browser stays up: it now belongs to the new session
825
+ this.conn.close();
826
+ const conn = await CdpConnection.open(target.webSocketDebuggerUrl);
827
+ const next = new PageSession(conn, this.port, target, owned);
828
+ await Promise.all([conn.send("Runtime.enable"), conn.send("Log.enable"), conn.send("Page.enable"), conn.send("Network.enable")]);
829
+ return next;
830
+ }
831
+ /** Stop driving this page; a browser this session started is stopped too. */
832
+ async close() {
833
+ this.conn.close();
834
+ await this.owned?.close();
835
+ }
836
+ }
837
+ /** An address short enough for an error line: host and path, no query. Pure. */
838
+ export function shortUrl(url) {
839
+ try {
840
+ const u = new URL(url);
841
+ if (u.protocol === "file:")
842
+ return u.pathname.split("/").slice(-2).join("/");
843
+ return `${u.host}${u.pathname === "/" ? "" : u.pathname}`;
844
+ }
845
+ catch {
846
+ return url.slice(0, 80);
847
+ }
848
+ }
849
+ /** Is this address on this machine (a dev server, a local file)? Pure. */
850
+ export function isLocalUrl(url) {
851
+ try {
852
+ const u = new URL(url);
853
+ if (u.protocol === "file:" || u.protocol === "about:" || u.protocol === "data:")
854
+ return true;
855
+ return ["localhost", "127.0.0.1", "[::1]", "0.0.0.0"].includes(u.hostname) || u.hostname.endsWith(".localhost");
856
+ }
857
+ catch {
858
+ return false;
859
+ }
860
+ }
861
+ //# sourceMappingURL=uiPage.js.map