@runablehq/mini-browser 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +165 -0
- package/dist/mb.js +914 -0
- package/package.json +38 -0
package/README.md
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
```
|
|
2
|
+
┌─────────────────────────────────────────┐
|
|
3
|
+
│ ███╗ ███╗██████╗ │
|
|
4
|
+
│ ████╗ ████║██╔══██╗ │
|
|
5
|
+
│ ██╔████╔██║██████╔╝ │
|
|
6
|
+
│ ██║╚██╔╝██║██╔══██╗ │
|
|
7
|
+
│ ██║ ╚═╝ ██║██████╔╝ │
|
|
8
|
+
│ ╚═╝ ╚═╝╚═════╝ │
|
|
9
|
+
│ │
|
|
10
|
+
│ mini-browser for agents │
|
|
11
|
+
└─────────────────────────────────────────┘
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
A lightweight browser CLI for AI agents. Navigate, observe, interact — all from the command line.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Quick Start
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# 1. Install dependencies
|
|
22
|
+
bun install
|
|
23
|
+
|
|
24
|
+
# 2. Build the CLI
|
|
25
|
+
bun run build
|
|
26
|
+
|
|
27
|
+
# 3. Start Chrome with remote debugging
|
|
28
|
+
./start-chrome.sh
|
|
29
|
+
|
|
30
|
+
# 4. You're ready!
|
|
31
|
+
node dist/mb.js --help
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Demo
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# Navigate to a page
|
|
40
|
+
$ mb go "https://example.com"
|
|
41
|
+
https://example.com/
|
|
42
|
+
|
|
43
|
+
# Get page text
|
|
44
|
+
$ mb text
|
|
45
|
+
Example Domain
|
|
46
|
+
|
|
47
|
+
This domain is for use in documentation examples without needing permission.
|
|
48
|
+
|
|
49
|
+
Learn more
|
|
50
|
+
|
|
51
|
+
# Find interactive elements with coordinates
|
|
52
|
+
$ mb snap
|
|
53
|
+
[0] link "Learn more" (246, 223)
|
|
54
|
+
|
|
55
|
+
# Click by coordinates
|
|
56
|
+
$ mb click 246 223
|
|
57
|
+
|
|
58
|
+
# Check where we ended up
|
|
59
|
+
$ mb url
|
|
60
|
+
https://www.iana.org/help/example-domains
|
|
61
|
+
|
|
62
|
+
# Go back
|
|
63
|
+
$ mb back
|
|
64
|
+
|
|
65
|
+
# Take a screenshot
|
|
66
|
+
$ mb shot page.png
|
|
67
|
+
page.png
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Commands
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
┌─────────────────────────────────────────────────────────────────────┐
|
|
76
|
+
│ NAVIGATION │
|
|
77
|
+
├─────────────────────────────────────────────────────────────────────┤
|
|
78
|
+
│ go <url> Navigate to URL │
|
|
79
|
+
│ url Print current URL │
|
|
80
|
+
│ back Go back │
|
|
81
|
+
│ forward Go forward │
|
|
82
|
+
├─────────────────────────────────────────────────────────────────────┤
|
|
83
|
+
│ OBSERVE │
|
|
84
|
+
├─────────────────────────────────────────────────────────────────────┤
|
|
85
|
+
│ shot [file] Screenshot (default: ./shot.png) │
|
|
86
|
+
│ snap Interactive elements with (x, y) coords │
|
|
87
|
+
│ text [selector] Visible text content │
|
|
88
|
+
├─────────────────────────────────────────────────────────────────────┤
|
|
89
|
+
│ INTERACT │
|
|
90
|
+
├─────────────────────────────────────────────────────────────────────┤
|
|
91
|
+
│ click <x> <y> Click at coordinates │
|
|
92
|
+
│ type [x y] <text> Type text (triple-clicks to select first) │
|
|
93
|
+
│ fill <k=v...> Fill form: Email=a@b.com "Name=Jo Do" │
|
|
94
|
+
│ key <key...> Press keys (Enter, Tab, Meta+a) │
|
|
95
|
+
│ move <x> <y> Move mouse / hover │
|
|
96
|
+
│ drag <x1> <y1> <x2> <y2> Drag from point to point │
|
|
97
|
+
│ scroll <dir> [px] Scroll up/down/left/right │
|
|
98
|
+
├─────────────────────────────────────────────────────────────────────┤
|
|
99
|
+
│ TABS │
|
|
100
|
+
├─────────────────────────────────────────────────────────────────────┤
|
|
101
|
+
│ tab list List open tabs │
|
|
102
|
+
│ tab new [url] Open new tab, print index │
|
|
103
|
+
│ tab close [n] Close tab (default: last) │
|
|
104
|
+
├─────────────────────────────────────────────────────────────────────┤
|
|
105
|
+
│ OTHER │
|
|
106
|
+
├─────────────────────────────────────────────────────────────────────┤
|
|
107
|
+
│ js <code> Run JavaScript in page context │
|
|
108
|
+
│ wait <target> Wait for ms/selector/networkidle/url:... │
|
|
109
|
+
│ audit Design audit (colors, fonts, contrast) │
|
|
110
|
+
│ logs Stream console logs (Ctrl+C to stop) │
|
|
111
|
+
└─────────────────────────────────────────────────────────────────────┘
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Flags
|
|
115
|
+
|
|
116
|
+
| Flag | Description |
|
|
117
|
+
|------|-------------|
|
|
118
|
+
| `--timeout <ms>` | Command timeout (default: 30000) |
|
|
119
|
+
| `--tab <n>` | Target tab index (default: 0) |
|
|
120
|
+
| `--json` | JSON output |
|
|
121
|
+
| `--right` | Right-click |
|
|
122
|
+
| `--double` | Double-click |
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## How It Works
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
┌──────────┐ CDP ┌─────────────────┐
|
|
130
|
+
│ mb │ ◄──────────► │ Chrome/Chromium │
|
|
131
|
+
│ CLI │ (9222) │ (debug mode) │
|
|
132
|
+
└──────────┘ └─────────────────┘
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
1. **Start Chrome** with `--remote-debugging-port=9222`
|
|
136
|
+
2. **Run commands** — `mb` connects via Chrome DevTools Protocol
|
|
137
|
+
3. **Observe + Interact** — screenshots, text, clicks, forms, etc.
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## For Agents
|
|
142
|
+
|
|
143
|
+
The `snap` command is your best friend:
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
$ mb snap
|
|
147
|
+
[0] button "Sign In" (845, 32)
|
|
148
|
+
[1] input[type=email] (512, 245)
|
|
149
|
+
[2] input[type=password] (512, 312)
|
|
150
|
+
[3] button "Submit" (512, 380)
|
|
151
|
+
[4] link "Forgot password?" (512, 420)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Each element has **coordinates** — use them with `click`, `type`, or `fill`.
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
# Click the Sign In button
|
|
158
|
+
mb click 845 32
|
|
159
|
+
|
|
160
|
+
# Or fill a form by label
|
|
161
|
+
mb fill Email=user@example.com Password=secret123
|
|
162
|
+
mb click 512 380
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
---
|
package/dist/mb.js
ADDED
|
@@ -0,0 +1,914 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/lib/flags.ts
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
|
|
6
|
+
// src/lib/config.ts
|
|
7
|
+
var CDP_URL = "http://127.0.0.1:9222";
|
|
8
|
+
var VIEWPORT = { width: 1024, height: 768 };
|
|
9
|
+
var DEFAULT_TIMEOUT = 3e4;
|
|
10
|
+
var DEFAULT_TAB = 0;
|
|
11
|
+
|
|
12
|
+
// src/lib/flags.ts
|
|
13
|
+
var parseNumericFlag = ({ name, value }) => {
|
|
14
|
+
if (typeof value !== "string") {
|
|
15
|
+
throw new Error(`Invalid value for --${name}: "${String(value)}". Expected a number.`);
|
|
16
|
+
}
|
|
17
|
+
const numeric = Number(value);
|
|
18
|
+
if (!Number.isFinite(numeric)) {
|
|
19
|
+
throw new Error(`Invalid value for --${name}: "${value}". Expected a number.`);
|
|
20
|
+
}
|
|
21
|
+
return numeric;
|
|
22
|
+
};
|
|
23
|
+
var formatParseArgsError = ({ error }) => {
|
|
24
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
25
|
+
const unknownFlag = message.match(/Unknown option '([^']+)'/);
|
|
26
|
+
if (unknownFlag?.[1]) {
|
|
27
|
+
return `Unknown flag ${unknownFlag[1]}. Run 'mb --help' for usage.`;
|
|
28
|
+
}
|
|
29
|
+
return `Invalid flags: ${message}`;
|
|
30
|
+
};
|
|
31
|
+
var parse = (argv) => {
|
|
32
|
+
const parsed = (() => {
|
|
33
|
+
try {
|
|
34
|
+
return parseArgs({
|
|
35
|
+
args: argv,
|
|
36
|
+
allowPositionals: true,
|
|
37
|
+
options: {
|
|
38
|
+
timeout: { type: "string", default: String(DEFAULT_TIMEOUT) },
|
|
39
|
+
tab: { type: "string", default: String(DEFAULT_TAB) },
|
|
40
|
+
json: { type: "boolean", default: false },
|
|
41
|
+
right: { type: "boolean", default: false },
|
|
42
|
+
double: { type: "boolean", default: false },
|
|
43
|
+
help: { type: "boolean", short: "h", default: false }
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
} catch (error) {
|
|
47
|
+
throw new Error(formatParseArgsError({ error }));
|
|
48
|
+
}
|
|
49
|
+
})();
|
|
50
|
+
const { values, positionals } = parsed;
|
|
51
|
+
return {
|
|
52
|
+
args: positionals,
|
|
53
|
+
flags: {
|
|
54
|
+
timeout: parseNumericFlag({ name: "timeout", value: values.timeout }),
|
|
55
|
+
tab: parseNumericFlag({ name: "tab", value: values.tab }),
|
|
56
|
+
json: values.json,
|
|
57
|
+
right: values.right,
|
|
58
|
+
double: values.double,
|
|
59
|
+
help: values.help
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// src/lib/browser.ts
|
|
65
|
+
import puppeteer from "puppeteer-core";
|
|
66
|
+
var connect = async (tab2 = 0) => {
|
|
67
|
+
const browser = await puppeteer.connect({
|
|
68
|
+
browserURL: CDP_URL,
|
|
69
|
+
defaultViewport: VIEWPORT
|
|
70
|
+
});
|
|
71
|
+
const pages = await browser.pages();
|
|
72
|
+
if (tab2 < 0 || tab2 >= pages.length) {
|
|
73
|
+
await browser.disconnect();
|
|
74
|
+
throw new Error(`Invalid tab index: ${tab2}. Open tabs: 0-${pages.length - 1}`);
|
|
75
|
+
}
|
|
76
|
+
const page = pages[tab2];
|
|
77
|
+
if (!page) throw new Error("No pages found");
|
|
78
|
+
return { browser, page, close: () => browser.disconnect() };
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// src/commands/go.ts
|
|
82
|
+
var go = async (args, flags) => {
|
|
83
|
+
const url2 = args[0];
|
|
84
|
+
if (!url2) {
|
|
85
|
+
console.error("Usage: mb go <url>");
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
const { page, close: close2 } = await connect(flags.tab);
|
|
89
|
+
await page.goto(url2, { waitUntil: "domcontentloaded", timeout: flags.timeout });
|
|
90
|
+
console.log(page.url());
|
|
91
|
+
await close2();
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// src/commands/url.ts
|
|
95
|
+
var url = async (args, flags) => {
|
|
96
|
+
const { page, close: close2 } = await connect(flags.tab);
|
|
97
|
+
console.log(page.url());
|
|
98
|
+
await close2();
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// src/commands/back.ts
|
|
102
|
+
var back = async (args, flags) => {
|
|
103
|
+
const { page, close: close2 } = await connect(flags.tab);
|
|
104
|
+
await page.goBack({ waitUntil: "domcontentloaded", timeout: flags.timeout });
|
|
105
|
+
console.log(page.url());
|
|
106
|
+
await close2();
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// src/commands/forward.ts
|
|
110
|
+
var forward = async (args, flags) => {
|
|
111
|
+
const { page, close: close2 } = await connect(flags.tab);
|
|
112
|
+
await page.goForward({ waitUntil: "domcontentloaded", timeout: flags.timeout });
|
|
113
|
+
console.log(page.url());
|
|
114
|
+
await close2();
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// src/commands/shot.ts
|
|
118
|
+
var shot = async (args, flags) => {
|
|
119
|
+
const path = args[0] || "./shot.png";
|
|
120
|
+
const { page, close: close2 } = await connect(flags.tab);
|
|
121
|
+
await page.screenshot({ path, type: "png" });
|
|
122
|
+
console.log(path);
|
|
123
|
+
await close2();
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// src/commands/snap.ts
|
|
127
|
+
var INTERACTIVE_ROLES = /* @__PURE__ */ new Set([
|
|
128
|
+
"link",
|
|
129
|
+
"button",
|
|
130
|
+
"textbox",
|
|
131
|
+
"combobox",
|
|
132
|
+
"searchbox",
|
|
133
|
+
"checkbox",
|
|
134
|
+
"radio",
|
|
135
|
+
"switch",
|
|
136
|
+
"slider",
|
|
137
|
+
"spinbutton",
|
|
138
|
+
"tab",
|
|
139
|
+
"menuitem",
|
|
140
|
+
"menuitemcheckbox",
|
|
141
|
+
"menuitemradio",
|
|
142
|
+
"option",
|
|
143
|
+
"treeitem",
|
|
144
|
+
"select"
|
|
145
|
+
]);
|
|
146
|
+
var STATE_PROPS = /* @__PURE__ */ new Set([
|
|
147
|
+
"haspopup",
|
|
148
|
+
"expanded",
|
|
149
|
+
"checked",
|
|
150
|
+
"selected",
|
|
151
|
+
"disabled",
|
|
152
|
+
"pressed"
|
|
153
|
+
]);
|
|
154
|
+
var VIEWPORT_MARGIN = 5;
|
|
155
|
+
var getBox = async (client, backendNodeId) => {
|
|
156
|
+
const { model } = await client.send("DOM.getBoxModel", { backendNodeId });
|
|
157
|
+
if (model.width === 0 || model.height === 0) return null;
|
|
158
|
+
const [x1, y1, x2, , , y3] = model.border;
|
|
159
|
+
return {
|
|
160
|
+
x: Math.round((x1 + x2) / 2),
|
|
161
|
+
y: Math.round((y1 + y3) / 2)
|
|
162
|
+
};
|
|
163
|
+
};
|
|
164
|
+
var inViewport = ({ x, y }, viewport) => x >= 0 && x <= viewport.width && y >= 0 && y <= viewport.height + VIEWPORT_MARGIN;
|
|
165
|
+
var extractState = (properties) => (properties ?? []).reduce((acc, p) => {
|
|
166
|
+
if (STATE_PROPS.has(p.name)) acc[p.name] = p.value.value;
|
|
167
|
+
return acc;
|
|
168
|
+
}, {});
|
|
169
|
+
var nameFallback = (name, role, properties) => {
|
|
170
|
+
if (name) return name;
|
|
171
|
+
const href = properties?.find((p) => p.name === "url")?.value?.value;
|
|
172
|
+
if (href && role === "link") {
|
|
173
|
+
try {
|
|
174
|
+
return new URL(href).pathname.replace(/\/$/, "") || href;
|
|
175
|
+
} catch {
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return "";
|
|
179
|
+
};
|
|
180
|
+
var formatElement = (el, index) => {
|
|
181
|
+
const stateStr = Object.entries(el.state).map(([k, v]) => `[${k}=${v}]`).join(" ");
|
|
182
|
+
return `[${index}] ${el.role} "${el.name}" (${el.x}, ${el.y})${stateStr ? " " + stateStr : ""}`;
|
|
183
|
+
};
|
|
184
|
+
var snap = async (args, flags) => {
|
|
185
|
+
const { page, close: close2 } = await connect(flags.tab);
|
|
186
|
+
const client = await page.createCDPSession();
|
|
187
|
+
const viewport = await page.evaluate(() => ({
|
|
188
|
+
width: window.innerWidth,
|
|
189
|
+
height: window.innerHeight
|
|
190
|
+
}));
|
|
191
|
+
const { nodes } = await client.send("Accessibility.getFullAXTree");
|
|
192
|
+
const interesting = nodes.filter(
|
|
193
|
+
(n) => !n.ignored && n.backendDOMNodeId && INTERACTIVE_ROLES.has(n.role?.value)
|
|
194
|
+
);
|
|
195
|
+
const elements = (await Promise.all(
|
|
196
|
+
interesting.map(async (n) => {
|
|
197
|
+
try {
|
|
198
|
+
const box = await getBox(client, n.backendDOMNodeId);
|
|
199
|
+
if (!box || !inViewport(box, viewport)) return null;
|
|
200
|
+
return {
|
|
201
|
+
role: n.role.value,
|
|
202
|
+
name: nameFallback(n.name?.value || "", n.role.value, n.properties),
|
|
203
|
+
...box,
|
|
204
|
+
state: extractState(n.properties)
|
|
205
|
+
};
|
|
206
|
+
} catch {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
})
|
|
210
|
+
)).filter((el) => el !== null).sort((a, b) => a.y - b.y || a.x - b.x);
|
|
211
|
+
await client.detach();
|
|
212
|
+
await close2();
|
|
213
|
+
if (flags.json) {
|
|
214
|
+
console.log(JSON.stringify(elements, null, 2));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
elements.forEach((el, i) => console.log(formatElement(el, i)));
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
// src/commands/text.ts
|
|
221
|
+
var text = async (args, flags) => {
|
|
222
|
+
const selector = args[0] || "body";
|
|
223
|
+
const { page, close: close2 } = await connect(flags.tab);
|
|
224
|
+
const content = await page.evaluate((sel) => {
|
|
225
|
+
const el = document.querySelector(sel);
|
|
226
|
+
return el?.innerText?.trim() ?? null;
|
|
227
|
+
}, selector);
|
|
228
|
+
await close2();
|
|
229
|
+
if (content === null) {
|
|
230
|
+
console.error(`Selector not found: ${selector}`);
|
|
231
|
+
process.exit(1);
|
|
232
|
+
}
|
|
233
|
+
console.log(content);
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// src/commands/click.ts
|
|
237
|
+
var click = async (args, flags) => {
|
|
238
|
+
const [xStr, yStr] = args;
|
|
239
|
+
if (!xStr || !yStr) {
|
|
240
|
+
console.error("Usage: mb click <x> <y>");
|
|
241
|
+
process.exit(1);
|
|
242
|
+
}
|
|
243
|
+
const x = +xStr;
|
|
244
|
+
const y = +yStr;
|
|
245
|
+
if (isNaN(x) || isNaN(y)) {
|
|
246
|
+
console.error("x and y must be numbers");
|
|
247
|
+
process.exit(1);
|
|
248
|
+
}
|
|
249
|
+
const { page, close: close2 } = await connect(flags.tab);
|
|
250
|
+
await page.mouse.click(x, y, {
|
|
251
|
+
button: flags.right ? "right" : "left",
|
|
252
|
+
clickCount: flags.double ? 2 : 1
|
|
253
|
+
});
|
|
254
|
+
await close2();
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
// src/commands/type.ts
|
|
258
|
+
var type = async (args, flags) => {
|
|
259
|
+
if (args.length === 0) {
|
|
260
|
+
console.error("Usage: mb type <text> OR mb type <x> <y> <text>");
|
|
261
|
+
process.exit(1);
|
|
262
|
+
}
|
|
263
|
+
const maybeX = +args[0];
|
|
264
|
+
const maybeY = +args[1];
|
|
265
|
+
const hasCoords = args.length >= 3 && !isNaN(maybeX) && !isNaN(maybeY);
|
|
266
|
+
const coords = hasCoords ? { x: maybeX, y: maybeY } : null;
|
|
267
|
+
const text2 = hasCoords ? args.slice(2).join(" ") : args.join(" ");
|
|
268
|
+
if (!text2) {
|
|
269
|
+
console.error("Text is required");
|
|
270
|
+
process.exit(1);
|
|
271
|
+
}
|
|
272
|
+
const { page, close: close2 } = await connect(flags.tab);
|
|
273
|
+
if (coords) {
|
|
274
|
+
await page.mouse.click(coords.x, coords.y, { clickCount: 3 });
|
|
275
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
276
|
+
}
|
|
277
|
+
await page.keyboard.type(text2);
|
|
278
|
+
await close2();
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
// src/commands/fill.ts
|
|
282
|
+
var parseKeyValuePairs = ({ args }) => {
|
|
283
|
+
const fields = {};
|
|
284
|
+
for (const arg of args) {
|
|
285
|
+
const eqIndex = arg.indexOf("=");
|
|
286
|
+
if (eqIndex === -1) {
|
|
287
|
+
throw new Error(`Invalid field "${arg}". Use key=value format, e.g.: mb fill Email=test@example.com`);
|
|
288
|
+
}
|
|
289
|
+
const key2 = arg.slice(0, eqIndex);
|
|
290
|
+
const value = arg.slice(eqIndex + 1);
|
|
291
|
+
if (!key2) {
|
|
292
|
+
throw new Error(`Empty field name in "${arg}"`);
|
|
293
|
+
}
|
|
294
|
+
fields[key2] = value;
|
|
295
|
+
}
|
|
296
|
+
return fields;
|
|
297
|
+
};
|
|
298
|
+
var parseFields = ({ args }) => {
|
|
299
|
+
return parseKeyValuePairs({ args });
|
|
300
|
+
};
|
|
301
|
+
var fill = async (args, flags) => {
|
|
302
|
+
if (args.length === 0) {
|
|
303
|
+
throw new Error('Usage: mb fill "Field Name=value" ...');
|
|
304
|
+
}
|
|
305
|
+
const fields = parseFields({ args });
|
|
306
|
+
const { page, close: close2 } = await connect(flags.tab);
|
|
307
|
+
const results = await page.evaluate((fields2) => {
|
|
308
|
+
const filled = [];
|
|
309
|
+
const failed = [];
|
|
310
|
+
const query = (selector, key2) => {
|
|
311
|
+
try {
|
|
312
|
+
return document.querySelector(selector);
|
|
313
|
+
} catch {
|
|
314
|
+
throw new Error(`Invalid selector for field "${key2}": ${selector}`);
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
const escapeSelectorValue = (value) => {
|
|
318
|
+
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
|
319
|
+
return CSS.escape(value);
|
|
320
|
+
}
|
|
321
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
322
|
+
};
|
|
323
|
+
const findInput = (key2) => {
|
|
324
|
+
if (key2.startsWith("#") || key2.startsWith(".") || key2.startsWith("[")) {
|
|
325
|
+
return query(key2, key2);
|
|
326
|
+
}
|
|
327
|
+
const escapedKey = escapeSelectorValue(key2);
|
|
328
|
+
const byAria = query(`[aria-label="${escapedKey}"]`, key2);
|
|
329
|
+
if (byAria) return byAria;
|
|
330
|
+
const byPlaceholder = query(`[placeholder="${escapedKey}"]`, key2);
|
|
331
|
+
if (byPlaceholder) return byPlaceholder;
|
|
332
|
+
const byName = query(`[name="${escapedKey}"]`, key2);
|
|
333
|
+
if (byName) return byName;
|
|
334
|
+
const byId = document.getElementById(key2);
|
|
335
|
+
if (byId) return byId;
|
|
336
|
+
const labels = document.querySelectorAll("label");
|
|
337
|
+
for (const label of labels) {
|
|
338
|
+
if (label.textContent?.trim().toLowerCase().includes(key2.toLowerCase())) {
|
|
339
|
+
const forId = label.getAttribute("for");
|
|
340
|
+
if (forId) {
|
|
341
|
+
const input2 = document.getElementById(forId);
|
|
342
|
+
if (input2) return input2;
|
|
343
|
+
}
|
|
344
|
+
const input = label.querySelector("input, textarea, select");
|
|
345
|
+
if (input) return input;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return null;
|
|
349
|
+
};
|
|
350
|
+
for (const [key2, value] of Object.entries(fields2)) {
|
|
351
|
+
const el = findInput(key2);
|
|
352
|
+
if (!el) {
|
|
353
|
+
failed.push(key2);
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (el.tagName === "SELECT") {
|
|
357
|
+
el.value = value;
|
|
358
|
+
} else {
|
|
359
|
+
el.value = value;
|
|
360
|
+
}
|
|
361
|
+
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
362
|
+
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
363
|
+
filled.push(key2);
|
|
364
|
+
}
|
|
365
|
+
return { filled, failed };
|
|
366
|
+
}, fields);
|
|
367
|
+
await close2();
|
|
368
|
+
if (results.filled.length > 0) {
|
|
369
|
+
console.log(`Filled: ${results.filled.join(", ")}`);
|
|
370
|
+
}
|
|
371
|
+
if (results.failed.length > 0) {
|
|
372
|
+
throw new Error(`Not found: ${results.failed.join(", ")}`);
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
// src/commands/key.ts
|
|
377
|
+
var key = async (args, flags) => {
|
|
378
|
+
if (args.length === 0) {
|
|
379
|
+
console.error("Usage: mb key <key...>");
|
|
380
|
+
process.exit(1);
|
|
381
|
+
}
|
|
382
|
+
const { page, close: close2 } = await connect(flags.tab);
|
|
383
|
+
for (const k of args) {
|
|
384
|
+
if (!k.includes("+")) {
|
|
385
|
+
await page.keyboard.press(k);
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
const parts = k.split("+");
|
|
389
|
+
const modifiers = parts.slice(0, -1);
|
|
390
|
+
const finalKey = parts.at(-1);
|
|
391
|
+
for (const m of modifiers) await page.keyboard.down(m);
|
|
392
|
+
await page.keyboard.press(finalKey);
|
|
393
|
+
for (const m of [...modifiers].reverse()) await page.keyboard.up(m);
|
|
394
|
+
}
|
|
395
|
+
await close2();
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
// src/commands/move.ts
|
|
399
|
+
var move = async (args, flags) => {
|
|
400
|
+
if (args.length < 2) {
|
|
401
|
+
console.error("Usage: mb move <x> <y>");
|
|
402
|
+
process.exit(1);
|
|
403
|
+
}
|
|
404
|
+
const x = +args[0];
|
|
405
|
+
const y = +args[1];
|
|
406
|
+
if (isNaN(x) || isNaN(y)) {
|
|
407
|
+
console.error("x and y must be numbers");
|
|
408
|
+
process.exit(1);
|
|
409
|
+
}
|
|
410
|
+
const { page, close: close2 } = await connect(flags.tab);
|
|
411
|
+
await page.mouse.move(x, y);
|
|
412
|
+
await close2();
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
// src/commands/drag.ts
|
|
416
|
+
var drag = async (args, flags) => {
|
|
417
|
+
if (args.length < 4) {
|
|
418
|
+
console.error("Usage: mb drag <x1> <y1> <x2> <y2>");
|
|
419
|
+
process.exit(1);
|
|
420
|
+
}
|
|
421
|
+
const coords = args.map(Number);
|
|
422
|
+
const [x1, y1, x2, y2] = coords;
|
|
423
|
+
if (coords.some(isNaN)) {
|
|
424
|
+
console.error("All coordinates must be numbers");
|
|
425
|
+
process.exit(1);
|
|
426
|
+
}
|
|
427
|
+
const { page, close: close2 } = await connect(flags.tab);
|
|
428
|
+
await page.mouse.move(x1, y1);
|
|
429
|
+
await page.mouse.down();
|
|
430
|
+
await page.mouse.move(x2, y2, { steps: 10 });
|
|
431
|
+
await page.mouse.up();
|
|
432
|
+
await close2();
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
// src/commands/scroll.ts
|
|
436
|
+
var deltas = {
|
|
437
|
+
up: { x: 0, y: -1 },
|
|
438
|
+
down: { x: 0, y: 1 },
|
|
439
|
+
left: { x: -1, y: 0 },
|
|
440
|
+
right: { x: 1, y: 0 }
|
|
441
|
+
};
|
|
442
|
+
var scroll = async (args, flags) => {
|
|
443
|
+
const dir = args[0] || "down";
|
|
444
|
+
const px = Number(args[1] || 500);
|
|
445
|
+
const delta = deltas[dir];
|
|
446
|
+
if (!delta) {
|
|
447
|
+
console.error("Direction must be: up, down, left, right");
|
|
448
|
+
process.exit(1);
|
|
449
|
+
}
|
|
450
|
+
if (!Number.isFinite(px) || px <= 0) {
|
|
451
|
+
console.error("Pixels must be a positive number");
|
|
452
|
+
process.exit(1);
|
|
453
|
+
}
|
|
454
|
+
const { page, close: close2 } = await connect(flags.tab);
|
|
455
|
+
await page.evaluate(({ x, y }) => window.scrollBy(x, y), { x: delta.x * px, y: delta.y * px });
|
|
456
|
+
await close2();
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
// src/commands/js.ts
|
|
460
|
+
import { text as readStdin } from "node:stream/consumers";
|
|
461
|
+
var js = async (args, flags) => {
|
|
462
|
+
const code = args[0] === "-" ? await readStdin(process.stdin) : args.join(" ");
|
|
463
|
+
if (!code.trim()) {
|
|
464
|
+
console.error("Usage: mb js <code> OR echo 'code' | mb js -");
|
|
465
|
+
process.exit(1);
|
|
466
|
+
}
|
|
467
|
+
const { page, close: close2 } = await connect(flags.tab);
|
|
468
|
+
const result = await page.evaluate(code);
|
|
469
|
+
await close2();
|
|
470
|
+
if (result === void 0 || result === null) return;
|
|
471
|
+
console.log(typeof result === "string" ? result : JSON.stringify(result, null, 2));
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
// src/commands/wait.ts
|
|
475
|
+
var wait = async (args, flags) => {
|
|
476
|
+
const target = args[0];
|
|
477
|
+
if (!target) {
|
|
478
|
+
console.error("Usage: mb wait <ms | selector | networkidle | url:...>");
|
|
479
|
+
process.exit(1);
|
|
480
|
+
}
|
|
481
|
+
const { page, close: close2 } = await connect(flags.tab);
|
|
482
|
+
const ms = +target;
|
|
483
|
+
if (!isNaN(ms) && target === String(ms)) {
|
|
484
|
+
await new Promise((r) => setTimeout(r, ms));
|
|
485
|
+
await close2();
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
if (target === "networkidle") {
|
|
489
|
+
await page.waitForNetworkIdle({ timeout: flags.timeout });
|
|
490
|
+
await close2();
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (target.startsWith("url:")) {
|
|
494
|
+
await page.waitForFunction(
|
|
495
|
+
(p) => location.href.includes(p),
|
|
496
|
+
{ timeout: flags.timeout },
|
|
497
|
+
target.slice(4)
|
|
498
|
+
);
|
|
499
|
+
await close2();
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
await page.waitForSelector(target, { timeout: flags.timeout });
|
|
503
|
+
await close2();
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
// src/commands/audit.ts
|
|
507
|
+
var findHeadingSkips = (headings) => headings.reduce((skips, level, i) => {
|
|
508
|
+
const prev = headings[i - 1];
|
|
509
|
+
if (i > 0 && prev !== void 0 && level > prev + 1)
|
|
510
|
+
return [...skips, `h${prev}\u2192h${level}`];
|
|
511
|
+
return skips;
|
|
512
|
+
}, []);
|
|
513
|
+
var findDesignIssues = ({ colors, families, sizes, radii }) => {
|
|
514
|
+
const checks = [
|
|
515
|
+
{ count: families.length, max: 3, msg: "font families \u2014 limit to 2-3" },
|
|
516
|
+
{ count: sizes.length, max: 8, msg: "font sizes \u2014 consolidate to 5-7 step type scale" },
|
|
517
|
+
{ count: colors.length, max: 15, msg: "colors \u2014 tighten palette to 8-12" },
|
|
518
|
+
{ count: radii.length, max: 4, msg: "border-radius values \u2014 standardize to 2-3" }
|
|
519
|
+
];
|
|
520
|
+
return checks.filter(({ count, max }) => count > max).map(({ count, msg }) => `${count} ${msg}`);
|
|
521
|
+
};
|
|
522
|
+
var findA11yIssues = (a11y, headingSkips) => [
|
|
523
|
+
a11y.imagesNoAlt && `${a11y.imagesNoAlt} images missing alt`,
|
|
524
|
+
a11y.inputsNoLabel && `${a11y.inputsNoLabel} inputs missing label`,
|
|
525
|
+
a11y.linksNoText && `${a11y.linksNoText} links without text`,
|
|
526
|
+
a11y.buttonsNoText && `${a11y.buttonsNoText} buttons without text`,
|
|
527
|
+
headingSkips.length && `heading skip: ${headingSkips.join(", ")}`
|
|
528
|
+
].filter(Boolean);
|
|
529
|
+
var check = (ok, yes, no) => ok ? ` \u2713 ${yes}` : ` \u2717 ${no}`;
|
|
530
|
+
var formatText = ({ data, contrast, browser, designIssues, a11yIssues, headingSkips }) => {
|
|
531
|
+
const { colors, families, sizes, weights, margins, paddings, radii, a11y, seo } = data;
|
|
532
|
+
const lines = [];
|
|
533
|
+
const out = (s) => lines.push(s);
|
|
534
|
+
out(`PALETTE ${colors.length} colors`);
|
|
535
|
+
colors.slice(0, 12).forEach((c) => out(` ${c.hex} ${String(c.count).padStart(3)}x ${c.uses.join(",")}`));
|
|
536
|
+
if (colors.length > 12) out(` ... +${colors.length - 12} more`);
|
|
537
|
+
out(`
|
|
538
|
+
TYPOGRAPHY ${families.length} families, ${sizes.length} sizes`);
|
|
539
|
+
families.forEach((f) => out(` ${f.name} (${f.count})`));
|
|
540
|
+
out(` sizes: ${sizes.map((s) => `${s.value}(${s.count})`).join(" ")}`);
|
|
541
|
+
out(` weights: ${weights.map((w) => `${w.value}(${w.count})`).join(" ")}`);
|
|
542
|
+
out(`
|
|
543
|
+
SPACING`);
|
|
544
|
+
if (margins.length) out(` margins: ${margins.map((m) => `${m.value}(${m.count})`).join(" ")}`);
|
|
545
|
+
if (paddings.length) out(` paddings: ${paddings.map((p) => `${p.value}(${p.count})`).join(" ")}`);
|
|
546
|
+
if (radii.length) out(` radii: ${radii.map((r) => `${r.value}(${r.count})`).join(" ")}`);
|
|
547
|
+
if (contrast.length) {
|
|
548
|
+
out(`
|
|
549
|
+
CONTRAST ${contrast.length} issues (Chrome DevTools)`);
|
|
550
|
+
contrast.slice(0, 10).forEach((c) => out(` ${c.selector} ${c.ratio}:1 (need ${c.threshold}:1) ${c.fontSize} w${c.fontWeight}`));
|
|
551
|
+
if (contrast.length > 10) out(` ... +${contrast.length - 10} more`);
|
|
552
|
+
}
|
|
553
|
+
out(`
|
|
554
|
+
ACCESSIBILITY`);
|
|
555
|
+
out(check(!!a11y.lang, `lang="${a11y.lang}"`, "missing lang attribute"));
|
|
556
|
+
out(check(!!a11y.title, "page has title", "missing page title"));
|
|
557
|
+
a11yIssues.forEach((i) => out(` \u2717 ${i}`));
|
|
558
|
+
if (!a11yIssues.length && a11y.lang && a11y.title) out(" \u2713 no issues");
|
|
559
|
+
out(`
|
|
560
|
+
SEO`);
|
|
561
|
+
out(check(!!seo.title, `title: "${seo.title}"`, "missing title"));
|
|
562
|
+
out(check(!!seo.metaDescription, `meta description (${seo.metaDescription.length} chars)`, "missing meta description"));
|
|
563
|
+
out(check(!!seo.canonical, `canonical: ${seo.canonical}`, "no canonical link"));
|
|
564
|
+
out(check(seo.hasViewport, "viewport meta", "missing viewport meta"));
|
|
565
|
+
out(seo.h1Count === 1 ? ` \u2713 1 h1: "${seo.h1Text}"` : seo.h1Count === 0 ? " \u2717 no h1" : ` \u26A0 ${seo.h1Count} h1 tags (should be 1)`);
|
|
566
|
+
out(` ${seo.og.title ? "\u2713" : "\u2717"} og:title ${seo.og.description ? "\u2713" : "\u2717"} og:description ${seo.og.image ? "\u2713" : "\u2717"} og:image`);
|
|
567
|
+
if (browser.length) {
|
|
568
|
+
const grouped = {};
|
|
569
|
+
for (const i of browser) {
|
|
570
|
+
if (!grouped[i.code]) grouped[i.code] = [];
|
|
571
|
+
grouped[i.code].push(i);
|
|
572
|
+
}
|
|
573
|
+
out(`
|
|
574
|
+
BROWSER ${browser.length} issues`);
|
|
575
|
+
for (const [code, items] of Object.entries(grouped)) {
|
|
576
|
+
const details = items.map((i) => i.detail).filter(Boolean);
|
|
577
|
+
out(` ${code} (${details.length})`);
|
|
578
|
+
details.slice(0, 3).forEach((d) => out(` ${d}`));
|
|
579
|
+
if (details.length > 3) out(` ... +${details.length - 3} more`);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
if (designIssues.length) {
|
|
583
|
+
out(`
|
|
584
|
+
DESIGN`);
|
|
585
|
+
designIssues.forEach((i) => out(` \u2717 ${i}`));
|
|
586
|
+
}
|
|
587
|
+
const total = designIssues.length + contrast.length + a11yIssues.length;
|
|
588
|
+
out(`
|
|
589
|
+
${total} issues total`);
|
|
590
|
+
return lines.join("\n");
|
|
591
|
+
};
|
|
592
|
+
var collectCdpIssues = async (page) => {
|
|
593
|
+
const contrast = [];
|
|
594
|
+
const browser = [];
|
|
595
|
+
try {
|
|
596
|
+
const client = await page.createCDPSession();
|
|
597
|
+
client.on("Audits.issueAdded", ({ issue }) => {
|
|
598
|
+
if (issue.code === "LowTextContrastIssue") {
|
|
599
|
+
const d = issue.details.lowTextContrastIssueDetails;
|
|
600
|
+
contrast.push({
|
|
601
|
+
selector: d.violatingNodeSelector,
|
|
602
|
+
ratio: Math.round(d.contrastRatio * 100) / 100,
|
|
603
|
+
threshold: d.thresholdAA,
|
|
604
|
+
fontSize: d.fontSize,
|
|
605
|
+
fontWeight: d.fontWeight
|
|
606
|
+
});
|
|
607
|
+
} else {
|
|
608
|
+
const detail = issue.details?.mixedContentIssueDetails?.insecureURL ?? issue.details?.deprecationIssueDetails?.type ?? issue.details?.contentSecurityPolicyIssueDetails?.violatedDirective ?? issue.details?.cookieIssueDetails?.cookie?.name ?? "";
|
|
609
|
+
browser.push({ code: issue.code, detail });
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
await client.send("Audits.enable");
|
|
613
|
+
await client.send("Audits.checkContrast", { reportAAA: false });
|
|
614
|
+
return { contrast, browser, cleanup: async () => {
|
|
615
|
+
try {
|
|
616
|
+
await client.send("Audits.disable");
|
|
617
|
+
await client.detach();
|
|
618
|
+
} catch {
|
|
619
|
+
}
|
|
620
|
+
} };
|
|
621
|
+
} catch {
|
|
622
|
+
return { contrast, browser, cleanup: async () => {
|
|
623
|
+
} };
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
var extractDesignData = (page) => page.evaluate(() => {
|
|
627
|
+
const parseRgb = (s) => {
|
|
628
|
+
const m = s.match(/rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)/);
|
|
629
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
630
|
+
};
|
|
631
|
+
const toHex = (rgb) => "#" + rgb.map((c) => c.toString(16).padStart(2, "0")).join("");
|
|
632
|
+
const transparent = (s) => s === "rgba(0, 0, 0, 0)" || s === "transparent";
|
|
633
|
+
const tally = (map, key2) => {
|
|
634
|
+
map[key2] = (map[key2] || 0) + 1;
|
|
635
|
+
};
|
|
636
|
+
const colors = {};
|
|
637
|
+
const families = {};
|
|
638
|
+
const sizes = {};
|
|
639
|
+
const weights = {};
|
|
640
|
+
const marginVals = {};
|
|
641
|
+
const paddingVals = {};
|
|
642
|
+
const radii = {};
|
|
643
|
+
for (const el of document.querySelectorAll("body *")) {
|
|
644
|
+
const rect = el.getBoundingClientRect();
|
|
645
|
+
if (rect.width === 0 || rect.height === 0) continue;
|
|
646
|
+
const cs = getComputedStyle(el);
|
|
647
|
+
if (cs.display === "none" || cs.visibility === "hidden" || cs.opacity === "0") continue;
|
|
648
|
+
for (const [val, use] of [[cs.color, "text"], [cs.backgroundColor, "bg"]]) {
|
|
649
|
+
if (transparent(val)) continue;
|
|
650
|
+
const rgb = parseRgb(val);
|
|
651
|
+
if (!rgb) continue;
|
|
652
|
+
const h = toHex(rgb);
|
|
653
|
+
const entry = colors[h] ?? (colors[h] = { count: 0, uses: /* @__PURE__ */ new Set() });
|
|
654
|
+
entry.count++;
|
|
655
|
+
entry.uses.add(use);
|
|
656
|
+
}
|
|
657
|
+
const fam = cs.fontFamily.split(",")[0]?.trim().replace(/['"]/g, "") ?? "";
|
|
658
|
+
tally(families, fam);
|
|
659
|
+
tally(sizes, cs.fontSize);
|
|
660
|
+
tally(weights, cs.fontWeight);
|
|
661
|
+
for (const dir of ["Top", "Right", "Bottom", "Left"]) {
|
|
662
|
+
const m = cs[`margin${dir}`], p = cs[`padding${dir}`];
|
|
663
|
+
if (m && m !== "0px") tally(marginVals, m);
|
|
664
|
+
if (p && p !== "0px") tally(paddingVals, p);
|
|
665
|
+
}
|
|
666
|
+
if (cs.borderRadius && cs.borderRadius !== "0px") tally(radii, cs.borderRadius);
|
|
667
|
+
}
|
|
668
|
+
const countMatching = (sel, predicate) => [...document.querySelectorAll(sel)].filter(predicate).length;
|
|
669
|
+
const hasAccessibleName = (el) => {
|
|
670
|
+
const text2 = el.innerText?.trim() || el.getAttribute("aria-label") || "";
|
|
671
|
+
return !!text2 || !!el.querySelector("img[alt]");
|
|
672
|
+
};
|
|
673
|
+
const headings = [...document.querySelectorAll("h1,h2,h3,h4,h5,h6")].map((h) => parseInt(h.tagName.charAt(1)));
|
|
674
|
+
const a11y = {
|
|
675
|
+
imagesNoAlt: countMatching("img", (el) => !el.hasAttribute("alt")),
|
|
676
|
+
inputsNoLabel: countMatching("input:not([type=hidden]), textarea, select", (el) => {
|
|
677
|
+
const inp = el;
|
|
678
|
+
const id = inp.id;
|
|
679
|
+
return !(inp.hasAttribute("aria-label") || inp.hasAttribute("aria-labelledby") || inp.hasAttribute("title") || inp.hasAttribute("placeholder") || id && document.querySelector(`label[for="${id}"]`) || inp.closest("label"));
|
|
680
|
+
}),
|
|
681
|
+
linksNoText: countMatching("a[href]", (el) => !hasAccessibleName(el)),
|
|
682
|
+
buttonsNoText: countMatching("button, [role=button]", (el) => !hasAccessibleName(el)),
|
|
683
|
+
headings,
|
|
684
|
+
lang: document.documentElement.getAttribute("lang") || "",
|
|
685
|
+
title: document.title?.trim().slice(0, 80) || ""
|
|
686
|
+
};
|
|
687
|
+
const meta = (sel, attr = "content") => document.querySelector(sel)?.getAttribute(attr) || "";
|
|
688
|
+
const h1 = document.querySelector("h1");
|
|
689
|
+
const seo = {
|
|
690
|
+
title: a11y.title,
|
|
691
|
+
metaDescription: meta('meta[name="description"]').slice(0, 120),
|
|
692
|
+
canonical: meta('link[rel="canonical"]', "href"),
|
|
693
|
+
h1Count: document.querySelectorAll("h1").length,
|
|
694
|
+
h1Text: h1?.innerText?.trim().slice(0, 80) || "",
|
|
695
|
+
hasViewport: !!document.querySelector('meta[name="viewport"]'),
|
|
696
|
+
og: {
|
|
697
|
+
title: !!document.querySelector('meta[property="og:title"]'),
|
|
698
|
+
description: !!document.querySelector('meta[property="og:description"]'),
|
|
699
|
+
image: !!document.querySelector('meta[property="og:image"]')
|
|
700
|
+
}
|
|
701
|
+
};
|
|
702
|
+
const descByCount = (obj) => Object.entries(obj).sort((a, b) => b[1] - a[1]);
|
|
703
|
+
const ascByPx = (obj) => Object.entries(obj).sort((a, b) => parseFloat(a[0]) - parseFloat(b[0]));
|
|
704
|
+
return {
|
|
705
|
+
colors: Object.entries(colors).sort((a, b) => b[1].count - a[1].count).map(([h, v]) => ({ hex: h, count: v.count, uses: [...v.uses] })),
|
|
706
|
+
families: descByCount(families).map(([name, count]) => ({ name, count })),
|
|
707
|
+
sizes: ascByPx(sizes).map(([value, count]) => ({ value, count })),
|
|
708
|
+
weights: ascByPx(weights).map(([value, count]) => ({ value, count })),
|
|
709
|
+
margins: ascByPx(marginVals).map(([value, count]) => ({ value, count })),
|
|
710
|
+
paddings: ascByPx(paddingVals).map(([value, count]) => ({ value, count })),
|
|
711
|
+
radii: ascByPx(radii).map(([value, count]) => ({ value, count })),
|
|
712
|
+
a11y,
|
|
713
|
+
seo
|
|
714
|
+
};
|
|
715
|
+
});
|
|
716
|
+
var audit = async (args, flags) => {
|
|
717
|
+
const { page, close: close2 } = await connect(flags.tab);
|
|
718
|
+
const cdp = await collectCdpIssues(page);
|
|
719
|
+
const data = await extractDesignData(page);
|
|
720
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
721
|
+
await cdp.cleanup();
|
|
722
|
+
await close2();
|
|
723
|
+
const { contrast, browser } = cdp;
|
|
724
|
+
const headingSkips = findHeadingSkips(data.a11y.headings);
|
|
725
|
+
const designIssues = findDesignIssues(data);
|
|
726
|
+
const a11yIssues = findA11yIssues(data.a11y, headingSkips);
|
|
727
|
+
if (flags.json) {
|
|
728
|
+
console.log(JSON.stringify({ ...data, contrast, browser, designIssues, a11yIssues, headingSkips }, null, 2));
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
console.log(formatText({ data, contrast, browser, designIssues, a11yIssues, headingSkips }));
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
// src/commands/tab.ts
|
|
735
|
+
import puppeteer2 from "puppeteer-core";
|
|
736
|
+
var subcommands = { list, new: newTab, close };
|
|
737
|
+
var help = `Usage: mb tab <list|new|close> [args]
|
|
738
|
+
|
|
739
|
+
list List open tabs
|
|
740
|
+
new [url] Open new tab, print index
|
|
741
|
+
close [n] Close tab (default: last)`;
|
|
742
|
+
var tab = async (args, flags) => {
|
|
743
|
+
const [sub, ...rest] = args;
|
|
744
|
+
if (!sub || !(sub in subcommands)) {
|
|
745
|
+
console.error(help);
|
|
746
|
+
process.exit(1);
|
|
747
|
+
}
|
|
748
|
+
const handler = subcommands[sub];
|
|
749
|
+
await handler(rest, flags);
|
|
750
|
+
};
|
|
751
|
+
async function list(_args, flags) {
|
|
752
|
+
const browser = await puppeteer2.connect({ browserURL: CDP_URL, defaultViewport: VIEWPORT });
|
|
753
|
+
const pages = await browser.pages();
|
|
754
|
+
const entries = await Promise.all(
|
|
755
|
+
pages.map(async (page, i) => ({
|
|
756
|
+
index: i,
|
|
757
|
+
url: page.url(),
|
|
758
|
+
title: await page.title()
|
|
759
|
+
}))
|
|
760
|
+
);
|
|
761
|
+
await browser.disconnect();
|
|
762
|
+
if (flags.json) {
|
|
763
|
+
console.log(JSON.stringify(entries, null, 2));
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
for (const { index, url: url2, title } of entries) {
|
|
767
|
+
console.log(`${index} ${url2} ${title}`);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
async function newTab(args, flags) {
|
|
771
|
+
const url2 = args[0];
|
|
772
|
+
const browser = await puppeteer2.connect({ browserURL: CDP_URL, defaultViewport: VIEWPORT });
|
|
773
|
+
const page = await browser.newPage();
|
|
774
|
+
if (url2) {
|
|
775
|
+
try {
|
|
776
|
+
await page.goto(url2, { waitUntil: "domcontentloaded", timeout: flags.timeout });
|
|
777
|
+
} catch (e) {
|
|
778
|
+
await page.close();
|
|
779
|
+
await browser.disconnect();
|
|
780
|
+
throw e;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
const pages = await browser.pages();
|
|
784
|
+
const index = pages.indexOf(page);
|
|
785
|
+
await browser.disconnect();
|
|
786
|
+
console.log(index);
|
|
787
|
+
}
|
|
788
|
+
async function close(args, _flags) {
|
|
789
|
+
const browser = await puppeteer2.connect({ browserURL: CDP_URL, defaultViewport: VIEWPORT });
|
|
790
|
+
const pages = await browser.pages();
|
|
791
|
+
if (pages.length <= 1) {
|
|
792
|
+
await browser.disconnect();
|
|
793
|
+
throw new Error("Cannot close the last tab");
|
|
794
|
+
}
|
|
795
|
+
const index = args[0] !== void 0 ? Number(args[0]) : pages.length - 1;
|
|
796
|
+
if (!Number.isInteger(index) || index < 0 || index >= pages.length) {
|
|
797
|
+
await browser.disconnect();
|
|
798
|
+
throw new Error(`Invalid tab index: ${args[0]}. Open tabs: 0-${pages.length - 1}`);
|
|
799
|
+
}
|
|
800
|
+
const target = pages[index];
|
|
801
|
+
const url2 = target.url();
|
|
802
|
+
await target.close();
|
|
803
|
+
await browser.disconnect();
|
|
804
|
+
console.log(`Closed tab ${index} ${url2}`);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// src/commands/logs.ts
|
|
808
|
+
import puppeteer3 from "puppeteer-core";
|
|
809
|
+
var logs = async (_args, flags) => {
|
|
810
|
+
const browser = await puppeteer3.connect({
|
|
811
|
+
browserURL: CDP_URL,
|
|
812
|
+
defaultViewport: VIEWPORT
|
|
813
|
+
});
|
|
814
|
+
const setupPage = async (page, index) => {
|
|
815
|
+
const client = await page.createCDPSession();
|
|
816
|
+
await client.send("Runtime.enable");
|
|
817
|
+
client.on("Runtime.consoleAPICalled", (event) => {
|
|
818
|
+
const args = event.args.map((a) => a.value !== void 0 ? String(a.value) : a.description ?? "").join(" ");
|
|
819
|
+
const time = (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", { hour12: false });
|
|
820
|
+
const type2 = event.type === "warning" ? "warn" : event.type;
|
|
821
|
+
if (flags.json) {
|
|
822
|
+
console.log(JSON.stringify({ tab: index, type: type2, time, message: args }));
|
|
823
|
+
} else {
|
|
824
|
+
const prefix = index > 0 ? `[${index}] ` : "";
|
|
825
|
+
console.log(`${prefix}[${time}] ${type2}: ${args}`);
|
|
826
|
+
}
|
|
827
|
+
});
|
|
828
|
+
};
|
|
829
|
+
const pages = await browser.pages();
|
|
830
|
+
for (let i = 0; i < pages.length; i++) {
|
|
831
|
+
await setupPage(pages[i], i);
|
|
832
|
+
}
|
|
833
|
+
browser.on("targetcreated", async (target) => {
|
|
834
|
+
if (target.type() === "page") {
|
|
835
|
+
const page = await target.page();
|
|
836
|
+
if (page) {
|
|
837
|
+
const allPages = await browser.pages();
|
|
838
|
+
const index = allPages.indexOf(page);
|
|
839
|
+
await setupPage(page, index);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
});
|
|
843
|
+
console.error("Watching console logs... (Ctrl+C to stop)\n");
|
|
844
|
+
await new Promise((resolve) => {
|
|
845
|
+
process.on("SIGINT", () => {
|
|
846
|
+
console.error("\nStopped");
|
|
847
|
+
resolve();
|
|
848
|
+
});
|
|
849
|
+
});
|
|
850
|
+
await browser.disconnect();
|
|
851
|
+
};
|
|
852
|
+
|
|
853
|
+
// src/index.ts
|
|
854
|
+
var commands = { go, url, back, forward, shot, snap, text, click, type, fill, key, move, drag, scroll, js, wait, audit, tab, logs };
|
|
855
|
+
var help2 = `mb \u2014 Browser CLI for Agents
|
|
856
|
+
|
|
857
|
+
Usage: mb <command> [args] [flags]
|
|
858
|
+
|
|
859
|
+
Navigation:
|
|
860
|
+
go <url> Navigate to URL
|
|
861
|
+
url Print current URL
|
|
862
|
+
back Go back
|
|
863
|
+
forward Go forward
|
|
864
|
+
|
|
865
|
+
Observe:
|
|
866
|
+
shot [file] Screenshot (default: ./shot.png)
|
|
867
|
+
snap Interactive elements with (x, y)
|
|
868
|
+
text [selector] Visible text content
|
|
869
|
+
|
|
870
|
+
Interact:
|
|
871
|
+
click <x> <y> Click at coordinates
|
|
872
|
+
type [x y] <text> Type text (triple-clicks to select first)
|
|
873
|
+
fill <k=v...> Fill form: Email=a@b.com "Name=Jo Do"
|
|
874
|
+
key <key...> Press keys (Enter, Tab, Meta+a)
|
|
875
|
+
move <x> <y> Move mouse / hover
|
|
876
|
+
drag <x1> <y1> <x2> <y2> Drag
|
|
877
|
+
scroll <dir> [px] Scroll up/down/left/right
|
|
878
|
+
|
|
879
|
+
Other:
|
|
880
|
+
js <code> Run JavaScript
|
|
881
|
+
wait <target> Wait for ms/selector/networkidle/url:...
|
|
882
|
+
audit Design audit (colors, fonts, spacing, contrast)
|
|
883
|
+
logs Stream console logs (Ctrl+C to stop)
|
|
884
|
+
|
|
885
|
+
Tabs:
|
|
886
|
+
tab list List open tabs
|
|
887
|
+
tab new [url] Open new tab, print index
|
|
888
|
+
tab close [n] Close tab (default: last)
|
|
889
|
+
|
|
890
|
+
Flags:
|
|
891
|
+
--timeout <ms> Timeout (default: 30000)
|
|
892
|
+
--tab <n> Tab index (default: 0)
|
|
893
|
+
--json JSON output
|
|
894
|
+
--right/--double Right/double click`;
|
|
895
|
+
var main = async () => {
|
|
896
|
+
try {
|
|
897
|
+
const { args, flags } = parse(process.argv.slice(2));
|
|
898
|
+
const [cmd, ...rest] = args;
|
|
899
|
+
if (!cmd || flags.help) {
|
|
900
|
+
console.log(help2);
|
|
901
|
+
process.exit(0);
|
|
902
|
+
}
|
|
903
|
+
const handler = commands[cmd];
|
|
904
|
+
if (!handler) {
|
|
905
|
+
throw new Error(`Unknown command: ${cmd}
|
|
906
|
+
Run 'mb --help' for usage`);
|
|
907
|
+
}
|
|
908
|
+
await handler(rest, flags);
|
|
909
|
+
} catch (e) {
|
|
910
|
+
console.error(`Error: ${e instanceof Error ? e.message : e}`);
|
|
911
|
+
process.exit(1);
|
|
912
|
+
}
|
|
913
|
+
};
|
|
914
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@runablehq/mini-browser",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "A browser CLI for agents, built with Puppeteer",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"browser",
|
|
8
|
+
"cli",
|
|
9
|
+
"puppeteer",
|
|
10
|
+
"automation",
|
|
11
|
+
"agent",
|
|
12
|
+
"screenshot",
|
|
13
|
+
"headless"
|
|
14
|
+
],
|
|
15
|
+
"bin": {
|
|
16
|
+
"mb": "./dist/mb.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "node scripts/build.js",
|
|
24
|
+
"build:binary": "bun build src/index.ts --compile --outfile dist/mb",
|
|
25
|
+
"prepublishOnly": "npm run build"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"puppeteer-core": "^24.37.3"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/bun": "latest",
|
|
32
|
+
"esbuild": "^0.25.12",
|
|
33
|
+
"typescript": "^5.9.3"
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=18"
|
|
37
|
+
}
|
|
38
|
+
}
|