feat: tab management

This commit is contained in:
cloudycotton
2026-02-14 17:04:09 +05:30
parent 28228775ab
commit 3eda6a416d
20 changed files with 139 additions and 29 deletions
+1 -1
View File
@@ -351,7 +351,7 @@ export const audit = async (args: string[], flags: Flags) => {
await new Promise(r => setTimeout(r, 300)) await new Promise(r => setTimeout(r, 300))
await cdp.cleanup() await cdp.cleanup()
close() await close()
const { contrast, browser } = cdp const { contrast, browser } = cdp
const headingSkips = findHeadingSkips(data.a11y.headings) const headingSkips = findHeadingSkips(data.a11y.headings)
+1 -1
View File
@@ -5,5 +5,5 @@ export const back = async (args: string[], flags: Flags) => {
const { page, close } = await connect(flags.tab) const { page, close } = await connect(flags.tab)
await page.goBack({ waitUntil: "domcontentloaded", timeout: flags.timeout }) await page.goBack({ waitUntil: "domcontentloaded", timeout: flags.timeout })
console.log(page.url()) console.log(page.url())
close() await close()
} }
+1 -1
View File
@@ -20,5 +20,5 @@ export const click = async (args: string[], flags: Flags) => {
button: flags.right ? "right" : "left", button: flags.right ? "right" : "left",
clickCount: flags.double ? 2 : 1, clickCount: flags.double ? 2 : 1,
}) })
close() await close()
} }
+1 -1
View File
@@ -21,5 +21,5 @@ export const drag = async (args: string[], flags: Flags) => {
await page.mouse.move(x2!, y2!, { steps: 10 }) await page.mouse.move(x2!, y2!, { steps: 10 })
await page.mouse.up() await page.mouse.up()
close() await close()
} }
+1 -1
View File
@@ -122,7 +122,7 @@ export const fill = async (args: string[], flags: Flags) => {
return { filled, failed } return { filled, failed }
}, fields) }, fields)
close() await close()
if (results.filled.length > 0) { if (results.filled.length > 0) {
console.log(`Filled: ${results.filled.join(", ")}`) console.log(`Filled: ${results.filled.join(", ")}`)
+1 -1
View File
@@ -5,5 +5,5 @@ export const forward = async (args: string[], flags: Flags) => {
const { page, close } = await connect(flags.tab) const { page, close } = await connect(flags.tab)
await page.goForward({ waitUntil: "domcontentloaded", timeout: flags.timeout }) await page.goForward({ waitUntil: "domcontentloaded", timeout: flags.timeout })
console.log(page.url()) console.log(page.url())
close() await close()
} }
+1 -1
View File
@@ -11,5 +11,5 @@ export const go = async (args: string[], flags: Flags) => {
const { page, close } = await connect(flags.tab) const { page, close } = await connect(flags.tab)
await page.goto(url, { waitUntil: "domcontentloaded", timeout: flags.timeout }) await page.goto(url, { waitUntil: "domcontentloaded", timeout: flags.timeout })
console.log(page.url()) console.log(page.url())
close() await close()
} }
+1 -1
View File
@@ -14,7 +14,7 @@ export const js = async (args: string[], flags: Flags) => {
const { page, close } = await connect(flags.tab) const { page, close } = await connect(flags.tab)
const result = await page.evaluate(code) const result = await page.evaluate(code)
close() await close()
if (result === undefined || result === null) return if (result === undefined || result === null) return
console.log(typeof result === "string" ? result : JSON.stringify(result, null, 2)) console.log(typeof result === "string" ? result : JSON.stringify(result, null, 2))
+1 -1
View File
@@ -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) for (const m of [...modifiers].reverse()) await page.keyboard.up(m as KeyInput)
} }
close() await close()
} }
+1 -1
View File
@@ -16,5 +16,5 @@ export const move = async (args: string[], flags: Flags) => {
const { page, close } = await connect(flags.tab) const { page, close } = await connect(flags.tab)
await page.mouse.move(x, y) await page.mouse.move(x, y)
close() await close()
} }
+1 -1
View File
@@ -24,5 +24,5 @@ export const scroll = async (args: string[], flags: Flags) => {
const { page, close } = await connect(flags.tab) const { page, close } = await connect(flags.tab)
await page.evaluate(({ x, y }) => window.scrollBy(x, y), { x: delta.x * px, y: delta.y * px }) await page.evaluate(({ x, y }) => window.scrollBy(x, y), { x: delta.x * px, y: delta.y * px })
close() await close()
} }
+1 -1
View File
@@ -6,5 +6,5 @@ export const shot = async (args: string[], flags: Flags) => {
const { page, close } = await connect(flags.tab) const { page, close } = await connect(flags.tab)
await page.screenshot({ path, type: "png" }) await page.screenshot({ path, type: "png" })
console.log(path) console.log(path)
close() await close()
} }
+14 -5
View File
@@ -1,5 +1,4 @@
import { connect } from "../lib/browser" import { connect } from "../lib/browser"
import { VIEWPORT } from "../lib/config"
import type { Flags } from "../lib/flags" import type { Flags } from "../lib/flags"
// Chrome's AX tree for names/roles + DOM.getBoxModel for coordinates. // Chrome's AX tree for names/roles + DOM.getBoxModel for coordinates.
@@ -25,6 +24,11 @@ interface SnapElement {
state: Record<string, unknown> state: Record<string, unknown>
} }
interface Viewport {
width: number
height: number
}
const getBox = async (client: any, backendNodeId: number) => { const getBox = async (client: any, backendNodeId: number) => {
const { model } = await client.send("DOM.getBoxModel", { backendNodeId }) as { model: any } const { model } = await client.send("DOM.getBoxModel", { backendNodeId }) as { model: any }
if (model.width === 0 || model.height === 0) return null 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 }) => const inViewport = ({ x, y }: { x: number; y: number }, viewport: Viewport) =>
x >= 0 && x <= VIEWPORT.width && y >= 0 && y <= VIEWPORT.height + VIEWPORT_MARGIN x >= 0 && x <= viewport.width && y >= 0 && y <= viewport.height + VIEWPORT_MARGIN
const extractState = (properties: any[]) => const extractState = (properties: any[]) =>
(properties ?? []).reduce<Record<string, unknown>>((acc, p) => { (properties ?? []).reduce<Record<string, unknown>>((acc, p) => {
@@ -65,6 +69,11 @@ export const snap = async (args: string[], flags: Flags) => {
const { page, close } = await connect(flags.tab) const { page, close } = await connect(flags.tab)
const client = await page.createCDPSession() 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 { nodes } = await client.send("Accessibility.getFullAXTree") as { nodes: any[] }
const interesting = nodes.filter(n => const interesting = nodes.filter(n =>
@@ -75,7 +84,7 @@ export const snap = async (args: string[], flags: Flags) => {
interesting.map(async (n): Promise<SnapElement | null> => { interesting.map(async (n): Promise<SnapElement | null> => {
try { try {
const box = await getBox(client, n.backendDOMNodeId) const box = await getBox(client, n.backendDOMNodeId)
if (!box || !inViewport(box)) return null if (!box || !inViewport(box, viewport)) return null
return { return {
role: n.role.value, 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) .sort((a, b) => a.y - b.y || a.x - b.x)
await client.detach() await client.detach()
close() await close()
if (flags.json) { if (flags.json) {
console.log(JSON.stringify(elements, null, 2)) console.log(JSON.stringify(elements, null, 2))
+91
View File
@@ -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 <list|new|close> [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}`)
}
+1 -1
View File
@@ -10,7 +10,7 @@ export const text = async (args: string[], flags: Flags) => {
return el?.innerText?.trim() ?? null return el?.innerText?.trim() ?? null
}, selector) }, selector)
close() await close()
if (content === null) { if (content === null) {
console.error(`Selector not found: ${selector}`) console.error(`Selector not found: ${selector}`)
+1 -1
View File
@@ -30,5 +30,5 @@ export const type = async (args: string[], flags: Flags) => {
// Type replaces selection (or appends if nothing selected) // Type replaces selection (or appends if nothing selected)
await page.keyboard.type(text) await page.keyboard.type(text)
close() await close()
} }
+1 -1
View File
@@ -4,5 +4,5 @@ import type { Flags } from "../lib/flags"
export const url = async (args: string[], flags: Flags) => { export const url = async (args: string[], flags: Flags) => {
const { page, close } = await connect(flags.tab) const { page, close } = await connect(flags.tab)
console.log(page.url()) console.log(page.url())
close() await close()
} }
+4 -4
View File
@@ -13,13 +13,13 @@ export const wait = async (args: string[], flags: Flags) => {
const ms = +target const ms = +target
if (!isNaN(ms) && target === String(ms)) { if (!isNaN(ms) && target === String(ms)) {
await new Promise(r => setTimeout(r, ms)) await new Promise(r => setTimeout(r, ms))
close() await close()
return return
} }
if (target === "networkidle") { if (target === "networkidle") {
await page.waitForNetworkIdle({ timeout: flags.timeout }) await page.waitForNetworkIdle({ timeout: flags.timeout })
close() await close()
return return
} }
@@ -29,10 +29,10 @@ export const wait = async (args: string[], flags: Flags) => {
{ timeout: flags.timeout }, { timeout: flags.timeout },
target.slice(4) target.slice(4)
) )
close() await close()
return return
} }
await page.waitForSelector(target, { timeout: flags.timeout }) await page.waitForSelector(target, { timeout: flags.timeout })
close() await close()
} }
+7 -1
View File
@@ -16,8 +16,9 @@ import { scroll } from "./commands/scroll"
import { js } from "./commands/js" import { js } from "./commands/js"
import { wait } from "./commands/wait" import { wait } from "./commands/wait"
import { audit } from "./commands/audit" 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 const help = `mb — Browser CLI for Agents
@@ -48,6 +49,11 @@ Other:
wait <target> Wait for ms/selector/networkidle/url:... wait <target> Wait for ms/selector/networkidle/url:...
audit Design audit (colors, fonts, spacing, contrast) 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: Flags:
--timeout <ms> Timeout (default: 30000) --timeout <ms> Timeout (default: 30000)
--tab <n> Tab index (default: 0) --tab <n> Tab index (default: 0)
+8 -4
View File
@@ -4,12 +4,16 @@ import { CDP_URL, VIEWPORT } from "./config"
export const connect = async (tab = 0) => { export const connect = async (tab = 0) => {
const browser = await puppeteer.connect({ const browser = await puppeteer.connect({
browserURL: CDP_URL, browserURL: CDP_URL,
defaultViewport: VIEWPORT,
}) })
const pages = await browser.pages() 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") if (!page) throw new Error("No pages found")
await page.setViewport(VIEWPORT) return { browser, page, close: () => browser.disconnect() as Promise<void> }
return { browser, page, close: () => browser.disconnect() }
} }
interface WithPageInput { interface WithPageInput {
@@ -24,6 +28,6 @@ export const withPage = async <T>(
try { try {
return await fn(page) return await fn(page)
} finally { } finally {
close() await close()
} }
} }