mirror of
https://github.com/runablehq/mini-browser.git
synced 2026-09-20 13:45:46 +08:00
feat: tab management
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(", ")}`)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -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}`)
|
||||||
|
}
|
||||||
@@ -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}`)
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user