diff --git a/src/commands/audit.ts b/src/commands/audit.ts index a6f693f..830f2b4 100644 --- a/src/commands/audit.ts +++ b/src/commands/audit.ts @@ -351,7 +351,7 @@ export const audit = async (args: string[], flags: Flags) => { await new Promise(r => setTimeout(r, 300)) await cdp.cleanup() - close() + await close() const { contrast, browser } = cdp const headingSkips = findHeadingSkips(data.a11y.headings) diff --git a/src/commands/back.ts b/src/commands/back.ts index 90e316b..22a9c87 100644 --- a/src/commands/back.ts +++ b/src/commands/back.ts @@ -5,5 +5,5 @@ export const back = async (args: string[], flags: Flags) => { const { page, close } = await connect(flags.tab) await page.goBack({ waitUntil: "domcontentloaded", timeout: flags.timeout }) console.log(page.url()) - close() + await close() } diff --git a/src/commands/click.ts b/src/commands/click.ts index 4ace635..7acb24d 100644 --- a/src/commands/click.ts +++ b/src/commands/click.ts @@ -20,5 +20,5 @@ export const click = async (args: string[], flags: Flags) => { button: flags.right ? "right" : "left", clickCount: flags.double ? 2 : 1, }) - close() + await close() } diff --git a/src/commands/drag.ts b/src/commands/drag.ts index 709cda7..6d16407 100644 --- a/src/commands/drag.ts +++ b/src/commands/drag.ts @@ -21,5 +21,5 @@ export const drag = async (args: string[], flags: Flags) => { await page.mouse.move(x2!, y2!, { steps: 10 }) await page.mouse.up() - close() + await close() } diff --git a/src/commands/fill.ts b/src/commands/fill.ts index aee2308..1140a73 100644 --- a/src/commands/fill.ts +++ b/src/commands/fill.ts @@ -122,7 +122,7 @@ export const fill = async (args: string[], flags: Flags) => { return { filled, failed } }, fields) - close() + await close() if (results.filled.length > 0) { console.log(`Filled: ${results.filled.join(", ")}`) diff --git a/src/commands/forward.ts b/src/commands/forward.ts index d7a8595..0ea0598 100644 --- a/src/commands/forward.ts +++ b/src/commands/forward.ts @@ -5,5 +5,5 @@ export const forward = async (args: string[], flags: Flags) => { const { page, close } = await connect(flags.tab) await page.goForward({ waitUntil: "domcontentloaded", timeout: flags.timeout }) console.log(page.url()) - close() + await close() } diff --git a/src/commands/go.ts b/src/commands/go.ts index 3546b2c..efb95b9 100644 --- a/src/commands/go.ts +++ b/src/commands/go.ts @@ -11,5 +11,5 @@ export const go = async (args: string[], flags: Flags) => { const { page, close } = await connect(flags.tab) await page.goto(url, { waitUntil: "domcontentloaded", timeout: flags.timeout }) console.log(page.url()) - close() + await close() } diff --git a/src/commands/js.ts b/src/commands/js.ts index e462f62..9a417da 100644 --- a/src/commands/js.ts +++ b/src/commands/js.ts @@ -14,7 +14,7 @@ export const js = async (args: string[], flags: Flags) => { const { page, close } = await connect(flags.tab) const result = await page.evaluate(code) - close() + await close() if (result === undefined || result === null) return console.log(typeof result === "string" ? result : JSON.stringify(result, null, 2)) diff --git a/src/commands/key.ts b/src/commands/key.ts index ff7ee3f..5e31d62 100644 --- a/src/commands/key.ts +++ b/src/commands/key.ts @@ -25,5 +25,5 @@ export const key = async (args: string[], flags: Flags) => { for (const m of [...modifiers].reverse()) await page.keyboard.up(m as KeyInput) } - close() + await close() } diff --git a/src/commands/move.ts b/src/commands/move.ts index a64d088..c0cd175 100644 --- a/src/commands/move.ts +++ b/src/commands/move.ts @@ -16,5 +16,5 @@ export const move = async (args: string[], flags: Flags) => { const { page, close } = await connect(flags.tab) await page.mouse.move(x, y) - close() + await close() } diff --git a/src/commands/scroll.ts b/src/commands/scroll.ts index 055da1e..82b1886 100644 --- a/src/commands/scroll.ts +++ b/src/commands/scroll.ts @@ -24,5 +24,5 @@ export const scroll = async (args: string[], flags: Flags) => { const { page, close } = await connect(flags.tab) await page.evaluate(({ x, y }) => window.scrollBy(x, y), { x: delta.x * px, y: delta.y * px }) - close() + await close() } diff --git a/src/commands/shot.ts b/src/commands/shot.ts index 1e1f42d..e8387ea 100644 --- a/src/commands/shot.ts +++ b/src/commands/shot.ts @@ -6,5 +6,5 @@ export const shot = async (args: string[], flags: Flags) => { const { page, close } = await connect(flags.tab) await page.screenshot({ path, type: "png" }) console.log(path) - close() + await close() } diff --git a/src/commands/snap.ts b/src/commands/snap.ts index 49b5fec..e9c4196 100644 --- a/src/commands/snap.ts +++ b/src/commands/snap.ts @@ -1,5 +1,4 @@ import { connect } from "../lib/browser" -import { VIEWPORT } from "../lib/config" import type { Flags } from "../lib/flags" // Chrome's AX tree for names/roles + DOM.getBoxModel for coordinates. @@ -25,6 +24,11 @@ interface SnapElement { state: Record } +interface Viewport { + width: number + height: number +} + const getBox = async (client: any, backendNodeId: number) => { const { model } = await client.send("DOM.getBoxModel", { backendNodeId }) as { model: any } if (model.width === 0 || model.height === 0) return null @@ -35,8 +39,8 @@ const getBox = async (client: any, backendNodeId: number) => { } } -const inViewport = ({ x, y }: { x: number; y: number }) => - x >= 0 && x <= VIEWPORT.width && y >= 0 && y <= VIEWPORT.height + VIEWPORT_MARGIN +const inViewport = ({ x, y }: { x: number; y: number }, viewport: Viewport) => + x >= 0 && x <= viewport.width && y >= 0 && y <= viewport.height + VIEWPORT_MARGIN const extractState = (properties: any[]) => (properties ?? []).reduce>((acc, p) => { @@ -65,6 +69,11 @@ export const snap = async (args: string[], flags: Flags) => { const { page, close } = await connect(flags.tab) const client = await page.createCDPSession() + const viewport = await page.evaluate(() => ({ + width: window.innerWidth, + height: window.innerHeight, + })) + const { nodes } = await client.send("Accessibility.getFullAXTree") as { nodes: any[] } const interesting = nodes.filter(n => @@ -75,7 +84,7 @@ export const snap = async (args: string[], flags: Flags) => { interesting.map(async (n): Promise => { try { const box = await getBox(client, n.backendDOMNodeId) - if (!box || !inViewport(box)) return null + if (!box || !inViewport(box, viewport)) return null return { role: n.role.value, @@ -92,7 +101,7 @@ export const snap = async (args: string[], flags: Flags) => { .sort((a, b) => a.y - b.y || a.x - b.x) await client.detach() - close() + await close() if (flags.json) { console.log(JSON.stringify(elements, null, 2)) diff --git a/src/commands/tab.ts b/src/commands/tab.ts new file mode 100644 index 0000000..9d9850b --- /dev/null +++ b/src/commands/tab.ts @@ -0,0 +1,91 @@ +import puppeteer from "puppeteer-core" +import { CDP_URL, VIEWPORT } from "../lib/config" +import type { Flags } from "../lib/flags" + +const subcommands = { list, new: newTab, close } + +const help = `Usage: mb tab [args] + + list List open tabs + new [url] Open new tab, print index + close [n] Close tab (default: last)` + +export const tab = async (args: string[], flags: Flags) => { + const [sub, ...rest] = args + + if (!sub || !(sub in subcommands)) { + console.error(help) + process.exit(1) + } + + const handler = subcommands[sub as keyof typeof subcommands] + await handler(rest, flags) +} + +async function list(_args: string[], flags: Flags) { + const browser = await puppeteer.connect({ browserURL: CDP_URL, defaultViewport: VIEWPORT }) + const pages = await browser.pages() + + const entries = await Promise.all( + pages.map(async (page, i) => ({ + index: i, + url: page.url(), + title: await page.title(), + })) + ) + + await browser.disconnect() + + if (flags.json) { + console.log(JSON.stringify(entries, null, 2)) + return + } + + for (const { index, url, title } of entries) { + console.log(`${index}\t${url}\t${title}`) + } +} + +async function newTab(args: string[], flags: Flags) { + const url = args[0] + const browser = await puppeteer.connect({ browserURL: CDP_URL, defaultViewport: VIEWPORT }) + const page = await browser.newPage() + + if (url) { + try { + await page.goto(url, { waitUntil: "domcontentloaded", timeout: flags.timeout }) + } catch (e) { + await page.close() + await browser.disconnect() + throw e + } + } + + const pages = await browser.pages() + const index = pages.indexOf(page) + await browser.disconnect() + console.log(index) +} + +async function close(args: string[], _flags: Flags) { + const browser = await puppeteer.connect({ browserURL: CDP_URL, defaultViewport: VIEWPORT }) + const pages = await browser.pages() + + if (pages.length <= 1) { + await browser.disconnect() + throw new Error("Cannot close the last tab") + } + + const index = args[0] !== undefined ? Number(args[0]) : pages.length - 1 + + if (!Number.isInteger(index) || index < 0 || index >= pages.length) { + await browser.disconnect() + throw new Error(`Invalid tab index: ${args[0]}. Open tabs: 0-${pages.length - 1}`) + } + + const target = pages[index]! + const url = target.url() + await target.close() + await browser.disconnect() + console.log(`Closed tab ${index}\t${url}`) +} diff --git a/src/commands/text.ts b/src/commands/text.ts index a449ea7..30d5b67 100644 --- a/src/commands/text.ts +++ b/src/commands/text.ts @@ -10,7 +10,7 @@ export const text = async (args: string[], flags: Flags) => { return el?.innerText?.trim() ?? null }, selector) - close() + await close() if (content === null) { console.error(`Selector not found: ${selector}`) diff --git a/src/commands/type.ts b/src/commands/type.ts index 19ad68c..98318a5 100644 --- a/src/commands/type.ts +++ b/src/commands/type.ts @@ -30,5 +30,5 @@ export const type = async (args: string[], flags: Flags) => { // Type replaces selection (or appends if nothing selected) await page.keyboard.type(text) - close() + await close() } diff --git a/src/commands/url.ts b/src/commands/url.ts index cae5637..2d4852a 100644 --- a/src/commands/url.ts +++ b/src/commands/url.ts @@ -4,5 +4,5 @@ import type { Flags } from "../lib/flags" export const url = async (args: string[], flags: Flags) => { const { page, close } = await connect(flags.tab) console.log(page.url()) - close() + await close() } diff --git a/src/commands/wait.ts b/src/commands/wait.ts index 466cf4a..9f4b9be 100644 --- a/src/commands/wait.ts +++ b/src/commands/wait.ts @@ -13,13 +13,13 @@ export const wait = async (args: string[], flags: Flags) => { const ms = +target if (!isNaN(ms) && target === String(ms)) { await new Promise(r => setTimeout(r, ms)) - close() + await close() return } if (target === "networkidle") { await page.waitForNetworkIdle({ timeout: flags.timeout }) - close() + await close() return } @@ -29,10 +29,10 @@ export const wait = async (args: string[], flags: Flags) => { { timeout: flags.timeout }, target.slice(4) ) - close() + await close() return } await page.waitForSelector(target, { timeout: flags.timeout }) - close() + await close() } diff --git a/src/index.ts b/src/index.ts index 646fa47..fc73e17 100755 --- a/src/index.ts +++ b/src/index.ts @@ -16,8 +16,9 @@ import { scroll } from "./commands/scroll" import { js } from "./commands/js" import { wait } from "./commands/wait" import { audit } from "./commands/audit" +import { tab } from "./commands/tab" -const commands = { go, url, back, forward, shot, snap, text, click, type, fill, key, move, drag, scroll, js, wait, audit } +const commands = { go, url, back, forward, shot, snap, text, click, type, fill, key, move, drag, scroll, js, wait, audit, tab } const help = `mb — Browser CLI for Agents @@ -48,6 +49,11 @@ Other: wait Wait for ms/selector/networkidle/url:... audit Design audit (colors, fonts, spacing, contrast) +Tabs: + tab list List open tabs + tab new [url] Open new tab, print index + tab close [n] Close tab (default: last) + Flags: --timeout Timeout (default: 30000) --tab Tab index (default: 0) diff --git a/src/lib/browser.ts b/src/lib/browser.ts index 4fc0204..7eaa179 100644 --- a/src/lib/browser.ts +++ b/src/lib/browser.ts @@ -4,12 +4,16 @@ import { CDP_URL, VIEWPORT } from "./config" export const connect = async (tab = 0) => { const browser = await puppeteer.connect({ browserURL: CDP_URL, + defaultViewport: VIEWPORT, }) const pages = await browser.pages() - const page = pages[tab] ?? pages[0] + if (tab < 0 || tab >= pages.length) { + await browser.disconnect() + throw new Error(`Invalid tab index: ${tab}. Open tabs: 0-${pages.length - 1}`) + } + const page = pages[tab]! if (!page) throw new Error("No pages found") - await page.setViewport(VIEWPORT) - return { browser, page, close: () => browser.disconnect() } + return { browser, page, close: () => browser.disconnect() as Promise } } interface WithPageInput { @@ -24,6 +28,6 @@ export const withPage = async ( try { return await fn(page) } finally { - close() + await close() } }