@tiens.nguyen/gu-cli 1.0.833 → 1.0.835
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/browser-bridge.mjs +335 -0
- package/browser-command.mjs +310 -0
- package/browser-extension/README.md +151 -0
- package/browser-extension/actions.js +319 -0
- package/browser-extension/background.js +261 -0
- package/browser-extension/icons/icon128.png +0 -0
- package/browser-extension/icons/icon16.png +0 -0
- package/browser-extension/icons/icon32.png +0 -0
- package/browser-extension/icons/icon48.png +0 -0
- package/browser-extension/manifest.json +37 -0
- package/browser-extension/popup.html +26 -0
- package/browser-extension/popup.js +45 -0
- package/client-expert-api.mjs +43 -2
- package/gonext_agent_chat.py +325 -19
- package/gu-cli.mjs +14 -12
- package/gu-repl.mjs +170 -2
- package/gu_browser.py +335 -0
- package/package.json +11 -1
- package/proc-identity.mjs +53 -0
- package/shell-details.mjs +98 -10
- package/terminal-layout.mjs +1 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# gu browser — the agent's browser
|
|
2
|
+
|
|
3
|
+
The agent's `browser(...)` tool opens a web page, sees it, and clicks and types with a real mouse and keyboard, to test a UI flow or click through an app. It follows the same idea as Claude in Chrome. The tasks are #363 (GitHub #144) and #369 (GitHub #150).
|
|
4
|
+
|
|
5
|
+
**Nothing has to be installed.** The first time a turn uses the browser, gu asks you. After you allow it, the agent works in one of two places:
|
|
6
|
+
|
|
7
|
+
- **Your own Chrome**, through this extension, if the extension is installed. The agent works in its own tabs (the "gu" tab group) and in any tab you share, and it has your logins.
|
|
8
|
+
- **gu's own Chrome window**, if the extension is not installed. The bridge starts a Chrome of its own: a visible window on a separate profile (`~/.gonext/browser-profile`), started with its DevTools port open. It is not signed in to your accounts; sign in there once if a test needs it, and the profile keeps it.
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
agent turn (python, a new process each turn) — asks you first (#150)
|
|
12
|
+
│ HTTP + token ~/.gonext/browser-bridge.json (0600)
|
|
13
|
+
▼
|
|
14
|
+
browser-bridge.mjs 127.0.0.1:4127, started on demand, outlives the turn
|
|
15
|
+
│ the extension when it is connected, else gu's own window
|
|
16
|
+
├─ WebSocket (only a chrome-extension:// origin) → this extension → chrome.debugger
|
|
17
|
+
└─ DevTools WebSocket → gu's own Chrome window (browser-chrome.mjs)
|
|
18
|
+
both run the same actions.js: real Input.dispatchMouseEvent / insertText / key events
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Use it (a user)
|
|
22
|
+
|
|
23
|
+
- **Ask gu to check a page.** The first browser call of a turn shows:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
🌐 Allow gu to use a web browser?
|
|
27
|
+
It wants to: open http://localhost:5173
|
|
28
|
+
In your Chrome if the gu extension is there, else in gu's own Chrome window.
|
|
29
|
+
▸ Yes, this time
|
|
30
|
+
Yes, always in this folder
|
|
31
|
+
No
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
"Always" adds the folder to `~/.gonext/browser-allow.json`. A "No" holds for the rest of the turn, and the model carries on without the browser.
|
|
35
|
+
- **`/browser`** shows where the agent works and whether it asks in this folder.
|
|
36
|
+
- **`/browser allow` / `/browser forget`** stop or restart the question for this folder.
|
|
37
|
+
- **Share a tab** (extension only): click the gu icon on a tab, then **Let gu use this tab**. The tab joins the "gu" group and the agent can use it. **Stop sharing this tab** takes it back.
|
|
38
|
+
- **Turning it off:** `GU_BROWSER=0` in the worker's environment removes the tool.
|
|
39
|
+
|
|
40
|
+
### The extension, to use your own Chrome (optional)
|
|
41
|
+
|
|
42
|
+
Chrome lets no program turn on Developer mode or load an unpacked extension, which is why the default is gu's own window.
|
|
43
|
+
|
|
44
|
+
- **Once the extension is on the Chrome Web Store:** `/browser install` opens its page; click **Add to Chrome**. No toggle is involved. Set `GU_BROWSER_STORE_URL`, then put the link in `STORE_URL` once it is published.
|
|
45
|
+
- **Until then, the developer way** (`/browser install` shows it and copies the folder): turn on **Developer mode** in `chrome://extensions`, click **Load unpacked**, and choose `~/.gonext/browser-extension` (on a Mac, ⌘⇧G and paste). `/browser` should then say connected.
|
|
46
|
+
|
|
47
|
+
Some notes:
|
|
48
|
+
|
|
49
|
+
- **Why a fixed folder:** the extension is loaded from `~/.gonext/browser-extension`, not from the npm package, because an update moves the package and Chrome would lose an extension loaded from there. After a gu update, click reload on the extension's card.
|
|
50
|
+
- **Chrome closed or asleep:** the extension reconnects within 30 seconds of Chrome starting, or immediately with its icon's **Connect now** button. It shows a purple `on` badge while connected. Meanwhile the agent uses gu's own window.
|
|
51
|
+
- **The "debugging this browser" bar:** Chrome shows "gu browser started debugging this browser" while the agent works. That is the `chrome.debugger` permission, which is what makes a click a real, trusted click.
|
|
52
|
+
- **Other Chromium browsers** (Edge, Brave, Arc, Chromium) load it the same way. gu's own window uses the first of Chrome, Chromium, Edge or Brave it finds; set `GU_BROWSER_CHROME` to choose.
|
|
53
|
+
|
|
54
|
+
## Switch from the developer install to the store one
|
|
55
|
+
|
|
56
|
+
Run this before installing the Chrome Web Store copy (#145):
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
/browser cleanup
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
1. **It lists what it found:**
|
|
63
|
+
- every Chrome, Chrome Beta/Canary, Chromium, Edge and Brave profile with "gu browser" loaded unpacked. It reads each profile's `Preferences` and `Secure Preferences` and never writes them;
|
|
64
|
+
- the connected copy;
|
|
65
|
+
- gu's files in `~/.gonext`: `browser-extension/`, `browser-bridge.json`, `browser-extension-seen`, `browser-bridge.log`.
|
|
66
|
+
2. **On "Clean up", the connected developer copy removes itself** with `chrome.management.uninstallSelf`, which needs no extra permission. A store copy is never removed this way.
|
|
67
|
+
3. **Copies in other profiles need your click.** Chrome signs its profile settings, so no other program can uninstall an extension. gu opens `chrome://extensions` (on a Mac) and waits while you click Remove on "gu browser" in each one.
|
|
68
|
+
4. **Then it stops the bridge, deletes gu's files, scans again,** and says either `✓ The developer install is gone` or what is still there.
|
|
69
|
+
|
|
70
|
+
Once the store copy is installed, `/browser` shows `(… Chrome Web Store)` and does not copy the developer folder back. The agent's not-connected answer then says "open Chrome", not "Load unpacked".
|
|
71
|
+
|
|
72
|
+
If both copies stay installed, the bridge keeps the one that connected first and turns the other away with close code 4001, and the refused copy retries every 30 s instead of every 3 s. `/browser` warns `⚠ A second gu extension … — /browser cleanup removes the developer copy`.
|
|
73
|
+
|
|
74
|
+
## Develop
|
|
75
|
+
|
|
76
|
+
| What | How |
|
|
77
|
+
| --- | --- |
|
|
78
|
+
| Bridge (frames, origin/host/token checks) | `node --test tests/browser-bridge.test.mjs` |
|
|
79
|
+
| `/browser` text and file copy | `node --test tests/browser-command.test.mjs` |
|
|
80
|
+
| Python client and agent turns, with a stand-in extension | `python3 -m unittest tests.test_browser` |
|
|
81
|
+
| **Real Chrome**, two copies of this extension unpacked (20 checks: the click must be trusted; one copy refused; the developer copy removes itself) | see below |
|
|
82
|
+
| **Real Chrome, gu's own window** (no extension; 16 checks) | the same script with `--own` |
|
|
83
|
+
|
|
84
|
+
The real-Chrome check needs **Chrome for Testing** or Chromium. Branded Chrome has ignored `--load-extension` since version 137.
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
curl -sSfLO https://storage.googleapis.com/chrome-for-testing-public/<version>/mac-arm64/chrome-mac-arm64.zip
|
|
88
|
+
ditto -x -k chrome-mac-arm64.zip cft/ # not npx @puppeteer/browsers: it left the app without Frameworks
|
|
89
|
+
pkill -f "Google Chrome for Testing" # a leftover one on the same profile swallows the launch
|
|
90
|
+
GU_E2E_CHROME="cft/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing" \
|
|
91
|
+
python3 scripts/browser-e2e.py
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The check runs on a free port, with copies of the extension pointed at that port, so a gu running on 4127 with your own Chrome is left alone.
|
|
95
|
+
|
|
96
|
+
To work on the extension in your own Chrome:
|
|
97
|
+
|
|
98
|
+
1. Load `tools/gonext-local-worker/browser-extension` itself with **Load unpacked**.
|
|
99
|
+
2. After an edit, click reload on its card.
|
|
100
|
+
3. Its service worker console is under **Inspect views: service worker**.
|
|
101
|
+
|
|
102
|
+
`connect()` must set its `connecting` flag before its first `await`. Chrome fires it several times at startup, and two sockets made every command hang.
|
|
103
|
+
|
|
104
|
+
Which files make the extension is listed in two places, and both must change together:
|
|
105
|
+
|
|
106
|
+
- `EXT_FILES` in `gu_browser.py` and in `browser-command.mjs`: what gets copied and packed.
|
|
107
|
+
- `files` in `package.json`: what ships in the npm package.
|
|
108
|
+
|
|
109
|
+
## Publish to the Chrome Web Store
|
|
110
|
+
|
|
111
|
+
**The quick way (steps 1–2):** in the deploy app (`electric-app-deploy`), choose patch / minor / major next to **Build Chrome extension** and press it. The button:
|
|
112
|
+
- raises the version in `manifest.json`;
|
|
113
|
+
- checks that the scripts parse, that every file the manifest names is there, and that the bridge and `/browser` tests pass;
|
|
114
|
+
- writes `release/chrome-extension/gu-browser-<version>.zip` (git-ignored) and its `.sha256` at the repo root.
|
|
115
|
+
|
|
116
|
+
If any step fails, the old version is put back. The panel has **Show zip in Finder** and **Open store dashboard**. Nothing is uploaded or committed: upload the zip, then commit `manifest.json`.
|
|
117
|
+
|
|
118
|
+
Publishing is optional; Load unpacked works without it. Once published, a user installs with one click, and Chrome updates the extension by itself.
|
|
119
|
+
|
|
120
|
+
1. **Bump the version.** Raise `"version"` in `manifest.json`. The store refuses a version it has seen.
|
|
121
|
+
2. **Build the zip.**
|
|
122
|
+
|
|
123
|
+
```sh
|
|
124
|
+
python3 scripts/pack-browser-extension.py # → dist/gu-browser-<version>.zip (dist/ is git-ignored)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
It packs exactly `EXT_FILES`, with `manifest.json` at the zip's root, and checks the icons exist.
|
|
128
|
+
3. **Developer account** (once). Register at <https://chrome.google.com/webstore/devconsole>; there is a one-time US$5 fee. Verify the contact email.
|
|
129
|
+
4. **New item.** Choose **Add new item**, upload the zip, then fill in the listing:
|
|
130
|
+
- **Description:** what it does and that it needs the gu CLI. Copy from the top of this file.
|
|
131
|
+
- **Category:** Developer Tools.
|
|
132
|
+
- **Images:** the 128×128 icon (`icons/icon128.png`) and at least one 1280×800 screenshot. A page in the "gu" tab group with the `on` badge works.
|
|
133
|
+
- **Visibility:** **Unlisted** for gu users only (installed from the link that `/browser` can print), or **Public**.
|
|
134
|
+
5. **Privacy tab.** The review needs all of the following:
|
|
135
|
+
- **Single purpose:** "Lets the gu coding agent on this computer open, read and operate web pages in its own tab group, for testing web UIs."
|
|
136
|
+
- **Permission justifications:**
|
|
137
|
+
- `debugger`: send real mouse and keyboard input, take screenshots, and read console errors in gu's own tabs.
|
|
138
|
+
- `tabs`, `tabGroups`: open gu's tabs and keep them in the "gu" group.
|
|
139
|
+
- `alarms`: reconnect to the local gu bridge after Chrome puts the worker to sleep.
|
|
140
|
+
- `storage`: remember which tabs are gu's.
|
|
141
|
+
- Host permission `<all_urls>`: the agent may be asked to test any site, including localhost.
|
|
142
|
+
- **Remote code:** "No". Everything runs from the packaged files.
|
|
143
|
+
- **Data use:** website content is read only on the user's machine and sent only to the gu bridge on 127.0.0.1. Nothing is sold or transferred, and nothing is used for advertising.
|
|
144
|
+
- **Privacy policy URL:** required, because the extension handles website content. Host a short page that states the above.
|
|
145
|
+
6. **Submit for review.** `debugger` and `<all_urls>` trigger an in-depth review, which can take days to a few weeks. Answer any questions in the developer console.
|
|
146
|
+
7. **After approval:**
|
|
147
|
+
- The store build has a fixed extension ID. The bridge accepts any `chrome-extension://` origin, so nothing in gu changes for it.
|
|
148
|
+
- Put the store link in `/browser` (`browserReport` in `browser-command.mjs`) as the first option, and keep Load unpacked as the fallback.
|
|
149
|
+
- **Each later release:** bump the version, run `pack-browser-extension.py`, then **Package → Upload new package → Submit**.
|
|
150
|
+
|
|
151
|
+
Microsoft Edge Add-ons (<https://partner.microsoft.com/dashboard/microsoftedge>) takes the same zip, with a similar listing and review.
|
|
@@ -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
|
+
}
|