@tiens.nguyen/gu-cli 1.0.834 → 1.0.836

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.
@@ -0,0 +1,319 @@
1
+ // What every browser command DOES (task #363/#369, GitHub #144/#150), shared by the two ways gu
2
+ // reaches Chrome:
3
+ // • the gu Chrome extension (background.js) — chrome.debugger in the user's own Chrome;
4
+ // • gu's own Chrome window (browser-chrome.mjs, in the bridge) — the DevTools protocol over a
5
+ // WebSocket, no extension, nothing to install.
6
+ // Both speak the same DevTools protocol, so one implementation serves both. A "host" supplies the
7
+ // tabs and the protocol:
8
+ // host.cdp(tabId, method, params) → result host.attach(tabId)
9
+ // host.open(url, newTab) → tabId host.current() → tabId (throws when none)
10
+ // host.tabs() → [{id, title, url, current}] host.switchTo(tabId) → boolean
11
+ // host.info(tabId) → {url, title, status} | null host.activate(tabId)
12
+ // host.close(tabId)
13
+ // No chrome.* and no Node API in here: it runs in an extension service worker and in Node.
14
+
15
+ const consoleBuf = new Map(); // tabId → [{level, text}] since the last "console" read
16
+ const dialogs = new Map(); // tabId → [text] of alerts/confirms gu accepted
17
+ const freshErrors = new Map(); // tabId → errors not yet reported after a command
18
+
19
+ export function sleep(ms) {
20
+ return new Promise((r) => setTimeout(r, ms));
21
+ }
22
+
23
+ export function forgetTab(tabId) {
24
+ consoleBuf.delete(tabId);
25
+ freshErrors.delete(tabId);
26
+ dialogs.delete(tabId);
27
+ }
28
+
29
+ function pushConsole(tabId, level, text) {
30
+ const buf = consoleBuf.get(tabId) || [];
31
+ buf.push({ level, text: String(text).slice(0, 500) });
32
+ if (buf.length > 100) buf.splice(0, buf.length - 100);
33
+ consoleBuf.set(tabId, buf);
34
+ if (level === "error") freshErrors.set(tabId, [...(freshErrors.get(tabId) || []), String(text).slice(0, 300)].slice(-5));
35
+ }
36
+
37
+ /** Every DevTools event of a tab goes through here (both hosts call it). */
38
+ export function onCdpEvent(tabId, method, params, cdp) {
39
+ if (method === "Runtime.consoleAPICalled") {
40
+ const text = (params.args || []).map((a) => (a.value !== undefined ? String(a.value) : a.description || a.type)).join(" ");
41
+ pushConsole(tabId, params.type === "warning" ? "warn" : params.type, text);
42
+ } else if (method === "Runtime.exceptionThrown") {
43
+ const d = params.exceptionDetails || {};
44
+ pushConsole(tabId, "error", `Uncaught ${d.exception?.description || d.text || "exception"}`);
45
+ } else if (method === "Log.entryAdded") {
46
+ const e = params.entry || {};
47
+ if (e.level === "error" || e.level === "warning") pushConsole(tabId, e.level === "warning" ? "warn" : "error", `${e.text}${e.url ? ` (${e.url})` : ""}`);
48
+ } else if (method === "Page.javascriptDialogOpening") {
49
+ // An alert/confirm would freeze the page and every later command; accept it and report it.
50
+ const list = dialogs.get(tabId) || [];
51
+ list.push(`${params.type}: ${params.message}`);
52
+ dialogs.set(tabId, list);
53
+ cdp(tabId, "Page.handleJavaScriptDialog", { accept: true, promptText: params.defaultPrompt || "" }).catch(() => {});
54
+ }
55
+ }
56
+
57
+ /** The domains a freshly attached tab needs, for the events above. */
58
+ export async function enableDomains(cdp, tabId) {
59
+ await cdp(tabId, "Runtime.enable");
60
+ await cdp(tabId, "Page.enable");
61
+ await cdp(tabId, "Log.enable").catch(() => {});
62
+ }
63
+
64
+ async function evaluate(host, tabId, expression) {
65
+ const r = await host.cdp(tabId, "Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true, userGesture: true });
66
+ if (r.exceptionDetails) {
67
+ const d = r.exceptionDetails;
68
+ throw new Error(`page script error: ${d.exception?.description || d.text}`);
69
+ }
70
+ return r.result?.value;
71
+ }
72
+
73
+ export async function waitLoaded(host, tabId, ms = 20000) {
74
+ const end = Date.now() + ms;
75
+ await sleep(150);
76
+ while (Date.now() < end) {
77
+ const t = await host.info(tabId);
78
+ if (!t) throw new Error("the tab was closed");
79
+ if (t.status === "complete") return t;
80
+ await sleep(150);
81
+ }
82
+ return host.info(tabId);
83
+ }
84
+
85
+ // ── reading the page ────────────────────────────────────────────────────────────────────────
86
+ // Runs IN the page. Numbers the visible interactive elements (data-gu-ref) so the agent can say
87
+ // "click 7"; the numbers are good until the next read.
88
+ function readPage(opts) {
89
+ const sel = 'a[href],button,input:not([type=hidden]),select,textarea,summary,[role=button],[role=link],[role=checkbox],[role=radio],[role=tab],[role=menuitem],[role=option],[role=switch],[role=combobox],[role=textbox],[contenteditable=""],[contenteditable=true],[onclick],[tabindex]:not([tabindex="-1"])';
90
+ const vw = innerWidth;
91
+ const vh = innerHeight;
92
+ for (const e of document.querySelectorAll("[data-gu-ref]")) e.removeAttribute("data-gu-ref");
93
+ const clean = (s) => String(s || "").replace(/\s+/g, " ").trim();
94
+ const lines = [];
95
+ let n = 0;
96
+ let below = 0;
97
+ for (const el of document.querySelectorAll(sel)) {
98
+ const r = el.getBoundingClientRect();
99
+ if (r.width < 1 || r.height < 1) continue;
100
+ const cs = getComputedStyle(el);
101
+ if (cs.visibility === "hidden" || cs.display === "none" || Number(cs.opacity) === 0) continue;
102
+ if (!(r.bottom > 0 && r.right > 0 && r.top < vh && r.left < vw)) {
103
+ below++;
104
+ continue;
105
+ }
106
+ if (lines.length >= (opts.max || 150)) {
107
+ below++;
108
+ continue;
109
+ }
110
+ const ref = ++n;
111
+ el.setAttribute("data-gu-ref", String(ref));
112
+ const tag = el.tagName.toLowerCase();
113
+ const type = tag === "input" ? (el.getAttribute("type") || "text").toLowerCase() : "";
114
+ const role = el.getAttribute("role") || (tag === "a" ? "link" : tag === "input" ? (["checkbox", "radio", "submit", "button"].includes(type) ? type : `textbox(${type})`) : tag);
115
+ const label = el.labels && el.labels[0] ? el.labels[0].innerText : "";
116
+ const name = clean(el.getAttribute("aria-label") || label || (tag === "input" || tag === "textarea" || tag === "select" ? "" : el.innerText) || el.getAttribute("placeholder") || el.getAttribute("title") || el.getAttribute("alt") || el.getAttribute("name") || "").slice(0, 80);
117
+ const bits = [];
118
+ if ((tag === "input" && !["checkbox", "radio", "submit", "button"].includes(type)) || tag === "textarea") bits.push(`value=${JSON.stringify(String(el.value).slice(0, 60))}`);
119
+ if (tag === "select") bits.push(`selected=${JSON.stringify(clean(el.options[el.selectedIndex]?.text))}`);
120
+ if (type === "checkbox" || type === "radio") bits.push(el.checked ? "checked" : "unchecked");
121
+ if (el.disabled || el.getAttribute("aria-disabled") === "true") bits.push("disabled");
122
+ if (tag === "a") bits.push(`→ ${clean(el.getAttribute("href")).slice(0, 60)}`);
123
+ if (el === document.activeElement) bits.push("focused");
124
+ lines.push(`[${ref}] ${role} ${JSON.stringify(name)}${bits.length ? ` ${bits.join(" ")}` : ""} @${Math.round(r.left + r.width / 2)},${Math.round(r.top + r.height / 2)}`);
125
+ }
126
+ const de = document.documentElement;
127
+ return {
128
+ url: location.href,
129
+ title: document.title,
130
+ viewport: `${vw}x${vh}`,
131
+ scroll: `${Math.round(scrollY)} of ${Math.max(0, de.scrollHeight - vh)}`,
132
+ elements: lines,
133
+ offscreen: below,
134
+ text: String((document.body && document.body.innerText) || "").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim().slice(0, opts.maxText || 6000),
135
+ };
136
+ }
137
+
138
+ async function refPoint(host, tabId, ref) {
139
+ const p = await evaluate(host, tabId, `(() => {
140
+ const el = document.querySelector('[data-gu-ref="${Number(ref)}"]');
141
+ if (!el) return null;
142
+ el.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
143
+ const r = el.getBoundingClientRect();
144
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
145
+ })()`);
146
+ if (!p) throw new Error(`no element [${ref}] on the page now — read the page again for fresh numbers`);
147
+ return p;
148
+ }
149
+
150
+ // What is really under the point — an overlay or a cookie banner takes the click, not the button.
151
+ async function hitAt(host, tabId, x, y) {
152
+ return evaluate(host, tabId, `(() => {
153
+ const el = document.elementFromPoint(${x}, ${y});
154
+ if (!el) return "nothing";
155
+ const t = (el.innerText || el.value || el.getAttribute("aria-label") || "").replace(/\\s+/g, " ").trim().slice(0, 50);
156
+ return el.tagName.toLowerCase() + (el.id ? "#" + el.id : "") + (t ? ' "' + t + '"' : "");
157
+ })()`);
158
+ }
159
+
160
+ const hasRef = (cmd) => cmd.ref !== undefined && cmd.ref !== null && cmd.ref !== "";
161
+
162
+ async function point(host, tabId, cmd) {
163
+ if (hasRef(cmd)) return refPoint(host, tabId, cmd.ref);
164
+ if (Number.isFinite(cmd.x) && Number.isFinite(cmd.y)) return { x: cmd.x, y: cmd.y };
165
+ throw new Error("say where: ref (a number from read) or x and y");
166
+ }
167
+
168
+ async function mouse(host, tabId, x, y, button = "left", clicks = 1) {
169
+ await host.cdp(tabId, "Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
170
+ for (let i = 1; i <= clicks; i++) {
171
+ await host.cdp(tabId, "Input.dispatchMouseEvent", { type: "mousePressed", x, y, button, clickCount: i });
172
+ await host.cdp(tabId, "Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button, clickCount: i });
173
+ }
174
+ }
175
+
176
+ // ── keys ────────────────────────────────────────────────────────────────────────────────────
177
+ const KEYS = {
178
+ enter: ["Enter", "Enter", 13, "\r"], tab: ["Tab", "Tab", 9], escape: ["Escape", "Escape", 27], esc: ["Escape", "Escape", 27],
179
+ backspace: ["Backspace", "Backspace", 8], delete: ["Delete", "Delete", 46], space: [" ", "Space", 32, " "],
180
+ arrowup: ["ArrowUp", "ArrowUp", 38], arrowdown: ["ArrowDown", "ArrowDown", 40], arrowleft: ["ArrowLeft", "ArrowLeft", 37],
181
+ arrowright: ["ArrowRight", "ArrowRight", 39], up: ["ArrowUp", "ArrowUp", 38], down: ["ArrowDown", "ArrowDown", 40],
182
+ left: ["ArrowLeft", "ArrowLeft", 37], right: ["ArrowRight", "ArrowRight", 39], home: ["Home", "Home", 36], end: ["End", "End", 35],
183
+ pageup: ["PageUp", "PageUp", 33], pagedown: ["PageDown", "PageDown", 34],
184
+ };
185
+ const MODS = { alt: 1, option: 1, ctrl: 2, control: 2, meta: 4, cmd: 4, command: 4, shift: 8 };
186
+
187
+ async function pressKey(host, tabId, combo) {
188
+ const parts = String(combo).split("+").map((s) => s.trim()).filter(Boolean);
189
+ let mods = 0;
190
+ for (const p of parts.slice(0, -1)) mods |= MODS[p.toLowerCase()] || 0;
191
+ const k = parts[parts.length - 1] || "";
192
+ // Select-all and friends are editor COMMANDS in Chrome: a bare ctrl+a does not select on a Mac.
193
+ const cmdName = (mods & 6) && /^[acvxz]$/i.test(k) ? { a: "selectAll", c: "copy", v: "paste", x: "cut", z: "undo" }[k.toLowerCase()] : null;
194
+ let key, code, keyCode, text;
195
+ if (KEYS[k.toLowerCase()]) [key, code, keyCode, text] = KEYS[k.toLowerCase()];
196
+ else if (k.length === 1) {
197
+ key = mods & 8 ? k.toUpperCase() : k;
198
+ code = /[a-z]/i.test(k) ? `Key${k.toUpperCase()}` : /\d/.test(k) ? `Digit${k}` : "";
199
+ keyCode = k.toUpperCase().charCodeAt(0);
200
+ text = key;
201
+ } else throw new Error(`unknown key "${k}" — use Enter, Tab, Escape, Backspace, Delete, ArrowUp/Down/Left/Right, Home, End, PageUp, PageDown, Space or one character, with ctrl+ / shift+ / alt+ / meta+`);
202
+ if (mods & 7) text = undefined; // a shortcut types nothing
203
+ const base = { key, code, windowsVirtualKeyCode: keyCode, modifiers: mods };
204
+ await host.cdp(tabId, "Input.dispatchKeyEvent", { ...base, type: text ? "keyDown" : "rawKeyDown", text, ...(cmdName ? { commands: [cmdName] } : {}) });
205
+ await host.cdp(tabId, "Input.dispatchKeyEvent", { ...base, type: "keyUp" });
206
+ }
207
+
208
+ // ── the commands ────────────────────────────────────────────────────────────────────────────
209
+ async function settle(host, tabId) {
210
+ await sleep(350);
211
+ const t = await host.info(tabId);
212
+ if (t && t.status === "loading") await waitLoaded(host, tabId, 15000);
213
+ }
214
+
215
+ async function after(host, tabId, extra = {}) {
216
+ const t = await host.info(tabId);
217
+ const errs = freshErrors.get(tabId) || [];
218
+ const dl = dialogs.get(tabId) || [];
219
+ freshErrors.delete(tabId);
220
+ dialogs.delete(tabId);
221
+ return { tab: tabId, url: t?.url || "", title: t?.title || "", errors: errs, dialogs: dl, ...extra };
222
+ }
223
+
224
+ export async function runAction(cmd, host) {
225
+ const a = String(cmd.action || "").toLowerCase();
226
+ if (a === "open") {
227
+ const url = String(cmd.url || "");
228
+ if (!/^(https?|file):\/\//i.test(url) && !/^about:blank$/i.test(url)) throw new Error("open needs a full http(s):// or file:// url");
229
+ const id = await host.open(url, Boolean(cmd.new_tab));
230
+ await host.attach(id);
231
+ await waitLoaded(host, id);
232
+ return after(host, id);
233
+ }
234
+ if (a === "tabs") return { tabs: await host.tabs() };
235
+ if (a === "switch") {
236
+ const id = cmd.tab;
237
+ if (!(await host.switchTo(id))) throw new Error(`tab ${cmd.tab} is not one of gu's tabs — see action "tabs"`);
238
+ return after(host, id);
239
+ }
240
+ const id = await host.current();
241
+ if (a === "close") {
242
+ await host.close(id);
243
+ forgetTab(id);
244
+ return { closed: id };
245
+ }
246
+ await host.attach(id);
247
+ switch (a) {
248
+ case "read":
249
+ return { ...(await evaluate(host, id, `(${readPage})(${JSON.stringify({ maxText: cmd.max_text || 6000 })})`)), ...(await after(host, id)) };
250
+ case "screenshot": {
251
+ await host.activate(id); // a background tab is not painted
252
+ const m = await host.cdp(id, "Page.getLayoutMetrics");
253
+ const vp = m.cssVisualViewport || m.visualViewport || {};
254
+ const shot = await host.cdp(id, "Page.captureScreenshot", { format: "jpeg", quality: 80 });
255
+ return { ...(await after(host, id)), image: shot.data, mime: "image/jpeg", width: Math.round(vp.clientWidth), height: Math.round(vp.clientHeight) };
256
+ }
257
+ case "click":
258
+ case "double_click":
259
+ case "right_click":
260
+ case "hover": {
261
+ const p = await point(host, id, cmd);
262
+ const hit = await hitAt(host, id, p.x, p.y);
263
+ if (a === "hover") await host.cdp(id, "Input.dispatchMouseEvent", { type: "mouseMoved", x: p.x, y: p.y });
264
+ else await mouse(host, id, p.x, p.y, a === "right_click" ? "right" : "left", a === "double_click" ? 2 : 1);
265
+ await settle(host, id);
266
+ return after(host, id, { at: `${Math.round(p.x)},${Math.round(p.y)}`, hit });
267
+ }
268
+ case "type": {
269
+ if (hasRef(cmd)) {
270
+ const p = await refPoint(host, id, cmd.ref);
271
+ await mouse(host, id, p.x, p.y);
272
+ if (cmd.clear) await pressKey(host, id, "ctrl+a");
273
+ }
274
+ await host.cdp(id, "Input.insertText", { text: String(cmd.text ?? "") });
275
+ if (cmd.submit) await pressKey(host, id, "Enter");
276
+ await settle(host, id);
277
+ return after(host, id);
278
+ }
279
+ case "key":
280
+ for (const k of String(cmd.text || "").split(/\s+/).filter(Boolean)) await pressKey(host, id, k);
281
+ await settle(host, id);
282
+ return after(host, id);
283
+ case "scroll": {
284
+ const m = await host.cdp(id, "Page.getLayoutMetrics");
285
+ const vp = m.cssVisualViewport || {};
286
+ const x = Number.isFinite(cmd.x) ? cmd.x : vp.clientWidth / 2;
287
+ const y = Number.isFinite(cmd.y) ? cmd.y : vp.clientHeight / 2;
288
+ const amount = Number(cmd.amount) || Math.round((vp.clientHeight || 800) * 0.8);
289
+ const dir = String(cmd.direction || "down").toLowerCase();
290
+ const dx = dir === "left" ? -amount : dir === "right" ? amount : 0;
291
+ const dy = dir === "up" ? -amount : dir === "down" ? amount : 0;
292
+ await host.cdp(id, "Input.dispatchMouseEvent", { type: "mouseWheel", x, y, deltaX: dx, deltaY: dy });
293
+ await sleep(300);
294
+ return after(host, id, { scroll: await evaluate(host, id, "Math.round(scrollY) + ' of ' + Math.max(0, document.documentElement.scrollHeight - innerHeight)") });
295
+ }
296
+ case "back":
297
+ await evaluate(host, id, "history.back()");
298
+ await settle(host, id);
299
+ return after(host, id);
300
+ case "reload":
301
+ await host.cdp(id, "Page.reload", { ignoreCache: true });
302
+ await settle(host, id);
303
+ return after(host, id);
304
+ case "wait":
305
+ await sleep(Math.max(0, Math.min(Number(cmd.seconds) || 1, 30)) * 1000);
306
+ return after(host, id);
307
+ case "js": {
308
+ const value = await evaluate(host, id, String(cmd.text || ""));
309
+ return after(host, id, { value: value === undefined ? "undefined" : JSON.stringify(value)?.slice(0, 8000) });
310
+ }
311
+ case "console": {
312
+ const all = consoleBuf.get(id) || [];
313
+ consoleBuf.set(id, []);
314
+ return after(host, id, { console: all });
315
+ }
316
+ default:
317
+ throw new Error(`unknown action "${cmd.action}"`);
318
+ }
319
+ }