From 7ddbc6e0fdd5c0a4be77d7cb46b0b726d2b687b6 Mon Sep 17 00:00:00 2001 From: aitorllamas Date: Wed, 7 Oct 2026 19:39:51 +0200 Subject: [PATCH] feat: add chromium adapter and extension --- CONTRIBUTING.md | 10 +- README.md | 12 +- docs/chromium.md | 242 ++++++++++++++++ extension/chromium/browser-tabs.html | 19 ++ extension/chromium/manifest.json | 8 + package.json | 2 + scripts/build.mjs | 13 +- scripts/smoke-cdp.mjs | 103 +++++++ scripts/smoke-chromium.mjs | 2 + scripts/smoke-vivaldi.mjs | 46 ++-- src/adapters/chromium/basic.ts | 165 +++++++++++ src/adapters/chromium/browser.ts | 153 +++++++++++ src/adapters/chromium/connection.ts | 95 +++++++ src/adapters/chromium/discovery.ts | 58 ++++ src/adapters/chromium/native-api.ts | 58 ++++ src/adapters/chromium/runtime.ts | 9 + src/adapters/connect.ts | 6 +- src/application/batch.ts | 7 + src/application/execute.ts | 136 +++++---- src/cli/help.ts | 15 +- src/cli/parse.ts | 377 ++++++++++++------------- src/cli/run.ts | 9 +- src/domain/browser.ts | 6 +- src/domain/command.ts | 15 +- src/domain/validation.ts | 89 +++--- src/transport/cdp.ts | 16 +- tests/chromium-basic.test.ts | 371 +++++++++++++++++++++++++ tests/chromium.test.ts | 397 +++++++++++++++++++++++++++ tests/connection.test.ts | 384 +++++++++++++------------- 29 files changed, 2313 insertions(+), 510 deletions(-) create mode 100644 docs/chromium.md create mode 100644 extension/chromium/browser-tabs.html create mode 100644 extension/chromium/manifest.json create mode 100644 scripts/smoke-cdp.mjs create mode 100644 scripts/smoke-chromium.mjs create mode 100644 src/adapters/chromium/basic.ts create mode 100644 src/adapters/chromium/browser.ts create mode 100644 src/adapters/chromium/connection.ts create mode 100644 src/adapters/chromium/discovery.ts create mode 100644 src/adapters/chromium/native-api.ts create mode 100644 src/adapters/chromium/runtime.ts create mode 100644 tests/chromium-basic.test.ts create mode 100644 tests/chromium.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 93c6957..2e0b692 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,7 +7,8 @@ or a `BREAKING CHANGE:` footer. CI validates commit messages against Conventiona ## Architecture The TypeScript code separates a generic browser contract from Vivaldi's private -APIs. The application layer owns batch sequencing, retries, and verification; +APIs and standard Chromium extension APIs. The application layer owns batch +sequencing, retries, and verification; it does not know how any browser stores groups. | Directory | Responsibility | @@ -21,11 +22,14 @@ it does not know how any browser stores groups. | `tab-batch-schema` | Versioned JSON import contract used directly by runtime validation | | `tests` | Application, adapter, CLI, schema, and CDP integration tests | -The build produces a Node.js CLI and a browser runtime. Each command attaches +The build produces a Node.js CLI, a Vivaldi browser runtime, and an unpacked +Chromium extension in `dist/chromium-extension`. Each command attaches once, probes the privileged UI, then evaluates the whole operation once. All per-tab reads, writes, and final verification happen within that evaluation; there is no separate CLI process or tool call per tab. Native calls remain -sequential where order matters. There is no daemon or extra browser extension. +sequential where order matters. There is no daemon. Vivaldi needs no extension; +Chromium browsers use direct CDP for basic commands, or the bundled extension +and its open options page for groups and batches. See [the Chromium guide](docs/chromium.md). ## Add adapters for other browsers diff --git a/README.md b/README.md index d6dec2f..eb3a7b3 100644 --- a/README.md +++ b/README.md @@ -24,13 +24,17 @@ Open, name, and organize tabs in the browser window you already use. Import a JSON file to create native groups, set their colors, and optionally verify the result with one command. The project and package are named `browser-tabs-cli`; the terminal command is `browser-tabs`. -Vivaldi is the first adapter. The CLI connects to an already running browser; +Vivaldi, Helium, Chrome, Chromium, Brave, Edge, and Opera have adapters. +Vivaldi supports custom tab names. The Chromium adapter lists, opens, and closes +tabs directly through CDP, with an optional bundled extension for native groups +and batches. See [Helium and Chromium setup](docs/chromium.md). +The CLI connects to an already running browser; it never launches a browser or replaces your profile. It preserves existing tabs when applying a batch. ## Getting started -1. Install **Node.js 24.x**, Vivaldi, and [pnpm](https://pnpm.io/installation). +1. Install **Node.js 24.x**, a supported browser, and [pnpm](https://pnpm.io/installation). 2. Choose one installation method: @@ -61,7 +65,9 @@ when applying a batch. If pnpm reports a missing global binary directory, run `pnpm setup`, restart your terminal, and retry the global installation. -3. Connect the browser once. **CDP** means **Chrome DevTools Protocol**: a debugging +3. Connect the browser once. For Helium and other Chromium browsers, follow + [the Chromium setup guide](docs/chromium.md). The steps below apply to Vivaldi. + **CDP** means **Chrome DevTools Protocol**: a debugging protocol that lets local tools communicate with Chromium-based browsers. This CLI uses it to reach Vivaldi's own browser interface and its native tab APIs. A website's JavaScript context cannot manage Vivaldi stacks. diff --git a/docs/chromium.md b/docs/chromium.md new file mode 100644 index 0000000..5ae5e8c --- /dev/null +++ b/docs/chromium.md @@ -0,0 +1,242 @@ +# Helium and Chromium browsers + +Select `--browser helium`, `chromium`, `chrome`, `brave`, `edge`, or `opera`. +These selectors share an adapter with direct CDP for basic commands and optional +standard Chromium extension APIs for groups and batches. +Other Chromium forks can use `--browser chromium --cdp PORT` or set +`BROWSER_TABS_DATA_DIR` to their user data directory. Extension features require +`chrome.tabs.group`, `chrome.tabGroups`, and `chrome.storage.session`. +Vivaldi remains the default and uses its own adapter without an extension. + +## Basic commands without an extension + +An extension is optional, but remote debugging is required. `--cdp 9222` selects +an existing endpoint; it does not start a debugging server or enable debugging. + +### Connect an existing Helium session + +In recent Chromium-based versions, including the Chromium 154 build used by +Helium 0.18.3.1, check the remote-debugging page in the intended session: + +1. Open `chrome://inspect/#remote-debugging` in Helium. +2. Enable **Allow remote debugging for this browser instance**, if available. +3. Run `browser-tabs --browser helium doctor` without `--cdp`. +4. If Helium prompts for debugging access, accept the connection. Keep the browser + visible while connecting; the CLI waits up to 60 seconds for the prompt. +5. Run `browser-tabs --browser helium tab list`. + +The [Chromium debugging UI](https://developer.chrome.com/blog/chrome-devtools-mcp-debug-your-browser-session) +lets the browser choose its port. Automatic discovery reads `DevToolsActivePort` +from Helium's user data directory. Do not assume the selected port is 9222. +The CLI has been tested with Helium startup-flag debugging; this settings-based +flow uses Chromium's UI and must be enabled by the user in their session. + +### Why Helium asks for permission on every command + +The browser's settings-based remote debugging runs in approval mode: +[Chromium requires approval for each incoming connection](https://github.com/chromium/chromium/blob/main/chrome/browser/devtools/remote_debugging_server.cc). +The CLI opens one CDP connection per command and closes it when the command +finishes. Therefore, accepting `doctor` does not authorize a later `tab list` or +`group open`. The optional extension does not change this connection lifecycle. +Passing the same port with `--cdp` also does not change the approval mode. + +The CLI currently has no persistent session or daemon to reuse an approved +connection across commands. Startup-flag debugging is a separate browser mode +and can avoid this per-connection approval flow, but must be configured when the +browser starts. It cannot be enabled by a CLI option on an already running +session. The isolated-profile example below is a separate session and does not +expose tabs in your usual profile; it is not a fix for access to those tabs. + +If the setting is unavailable in your build, prepare a separate test profile +with debugging startup flags yourself, or use an endpoint you have already +configured. For macOS, an isolated Helium session can be started with: + +```sh +BROWSER_TABS_HELIUM_DATA_DIR="$(mktemp -d -t helium-cdp)" +export BROWSER_TABS_HELIUM_DATA_DIR +/Applications/Helium.app/Contents/MacOS/Helium \ + --user-data-dir="$BROWSER_TABS_HELIUM_DATA_DIR" \ + --remote-debugging-port=0 \ + --remote-debugging-address=127.0.0.1 \ + --no-first-run --no-default-browser-check about:blank +``` + +Run `browser-tabs --browser helium doctor` from another terminal with the same +`BROWSER_TABS_HELIUM_DATA_DIR` value. This temporary profile has its own tabs; +it does not give access to tabs in your usual profile. Do not restart an existing +profile with flags as a routine troubleshooting step. The CLI itself never +launches or restarts browsers or enables debugging. + +### Select a known endpoint + +Use `--cdp` only when that session already exposes the endpoint: + +```sh +browser-tabs --browser helium --cdp 9222 doctor +browser-tabs --browser helium --cdp 9222 windows +browser-tabs --browser helium --cdp 9222 tab list +browser-tabs --browser helium --cdp 9222 tab open https://example.com +browser-tabs --browser helium --cdp 9222 tab close --tabs cdp:TARGET_ID +``` + +Use the exact `id` returned by `tab list` or `tab open` when closing tabs. Direct +CDP IDs are strings prefixed with `cdp:` and remain valid across CLI connections +while the target exists. Extension connections return native numeric tab IDs; +list tabs again when changing connection modes. + +Without a usable extension options page, the CLI automatically selects basic CDP. +It lists page targets and their window IDs, URLs, and titles. CDP does not expose +native group membership, tab order, pinned/active/loading state, or window +focus/incognito state, so these fields are omitted. Worker, iframe, and DevTools +page targets are excluded. Window discovery requires at least one page target. + +With multiple windows, use `--window ID` to list or close tabs. Opening requires +one window in the selected browser context: CDP's +[`Target.createTarget`](https://chromedevtools.github.io/devtools-protocol/tot/Target/#method-createTarget) +does not accept a destination window ID. New tabs open in the background, and their window is checked after creation. +If a concurrent window change causes placement elsewhere, the new tab is closed +and the command fails. Multiple windows in the same context require the extension +for opening. Groups and batches fail with an extension setup message before +mutation. Native custom tab names remain unsupported in both Chromium modes. + +## Optional extension for groups and batches + +Approving browser debugging grants a CDP connection, not extension API access. +`group open` needs the extension even after you accept the debugging prompt. +Installing the extension alone is insufficient: its options page must stay open +in the browser profile being controlled. + +1. Build the project (`pnpm build`) or install the published package. The extension + is included at `dist/chromium-extension` inside the installed package. + For a global pnpm installation, locate the package under `pnpm root -g`. + Keep that directory in place while the extension is installed. +2. In the intended browser, open `chrome://extensions`, enable **Developer mode**, + choose **Load unpacked**, and select `dist/chromium-extension`. +3. Open **Details → Extension options** for **Browser Tabs CLI**. Keep that page + open while running CLI commands. Reopen it after restarting the browser. +4. Configure an existing loopback CDP endpoint in that browser. Debugging setup + varies by browser/version; where available, use the browser's remote debugging + setting. Otherwise prepare a separate profile with startup flags yourself. + The CLI never launches or restarts browsers or enables debugging. +5. Check the connection: + + ```sh + browser-tabs --browser helium --cdp 9222 doctor + browser-tabs --browser helium --cdp 9222 windows + browser-tabs --browser helium --cdp 9222 tab list --window 123 + browser-tabs --browser helium --cdp 9222 group open "Reading" https://example.com https://codely.com --color blue --window 123 + ``` + +After opening the options page, run: + +```sh +browser-tabs --browser helium doctor +``` + +Check `data.capabilities.mode`: `chromium-extension` reports `groups: true` and +`batches: true`. `basic-cdp` reports both as false. If the mode is still basic, +check that the extension is enabled and its options page is open in this same +browser session. After connecting in extension mode, the original command works: + +```sh +browser-tabs group open "Codely learning" https://codely.com/ https://codely.com/courses --color yellow --browser helium +``` + +If the browser writes `DevToolsActivePort`, omit `--cdp` to discover it from the +browser's default user data directory. For custom directories use +`BROWSER_TABS_HELIUM_DATA_DIR` (or the equivalent browser name), or the shared +`BROWSER_TABS_DATA_DIR`. A browser-specific override takes precedence. +Point to the user data root, not its `Default` or `Profile 1` subdirectory. +`--cdp` or `BROWSER_TABS_CDP` takes precedence over directory discovery. +Helium's macOS default is `~/Library/Application Support/net.imput.helium`. +Use an override if your distribution uses a different location. + +## Extension capabilities and limits + +- List normal windows and tabs; open inactive tabs; close selected tabs. +- Create native groups (including one-tab groups), rename groups, set colors, + and move unpinned tabs into groups in the selected window. +- Open, verify, replay, and close batches. Remove optional tab `name` fields from + batch JSON. `tab rename` and `tab open --name` fail before any tab is created: + the standard [Tabs API](https://developer.chrome.com/docs/extensions/reference/api/tabs) + does not provide native custom tab names. +- Ownership metadata uses + [session storage](https://developer.chrome.com/docs/extensions/reference/api/storage), + surviving CLI disconnections and extension page reloads, but clearing when the + browser restarts or the extension is reloaded, updated, or disabled. Replaying + a batch after that creates new tabs; previous tabs are treated as unowned. +- Incognito windows require enabling the extension for incognito. + +The extension requests `tabs`, `tabGroups`, and `storage` permissions. It has no +host permissions, content scripts, remote code, or network listener. The CLI +only evaluates its connection page, never website JavaScript. It checks the +extension manifest name and native APIs, then loads the runtime bundled with +the CLI into that page for each command. The page does not need its own runtime +script, so an absent or stale extension script cannot force basic CDP mode. + +## Validation + +Automated tests execute the built extension runtime against a local CDP server +and simulated native APIs. They cover native group membership, colors, one-tab +batches, ownership across connections, safe retries/cleanup, window isolation, +and rejection of unsupported names before mutation. Selector support does not +mean every listed browser/version has been tested live. + +## Real-browser smoke test + +After configuring an isolated test profile and opening the extension options page: + +```sh +pnpm build +BROWSER_TABS_CDP=9222 pnpm smoke:chromium +BROWSER_TABS_BROWSER=brave BROWSER_TABS_CDP=9222 pnpm smoke:chromium 123 +``` + +`smoke:chromium` defaults to Helium. It opens eight local pages in four colored +native groups, repeats the batch, exercises unit operations, checks foreign-member +conflicts, closes its own tabs, and compares the original inventory after cleanup. +The script never launches or restarts a browser. + +On 2026-10-07 this passed on macOS with Helium **0.18.3.1** (Chromium +**154.0.8037.97**) and Node **24.19.0**, using an empty temporary profile, +`--headless=new`, `--remote-debugging-port=0`, and the unpacked extension. +The test attached to the extension options page over loopback CDP. Applying and +verifying the batch took 1539 ms; replaying and verifying took 1089 ms. Both +include the explicit one-second verification delay. The initial tabs were +preserved after cleanup. This validates native API state; no visual inspection +of the browser UI was performed. Other listed browser versions remain untested +live. + +## Basic CDP smoke test + +Use an isolated browser session without the Browser Tabs CLI extension options +page. No extension installation is needed: + +```sh +pnpm build +BROWSER_TABS_CDP=9222 pnpm smoke:cdp +``` + +`BROWSER_TABS_BROWSER` selects another Chromium browser; the default is Helium. +The test runs `doctor`, lists windows and tabs, opens two local pages, closes +only their CDP IDs across separate CLI connections, and checks that the original +tabs remain. It also checks that groups and custom names fail explicitly. + +This passed on macOS with Helium **0.18.3.1** (Chromium **154.0.8037.97**) and +Node **24.19.0**, using an empty temporary profile with `--headless=new`, +`--remote-debugging-port=0`, and `--disable-extensions`. The Browser Tabs CLI +extension was not loaded. The final implementation opens URLs directly with +`Target.createTarget`; navigating a newly created blank target separately caused +aborted navigations in this Helium test session. + +## Connection errors + +- **Debugging is unavailable:** no readable `DevToolsActivePort` was found at + the selected user data root. Enable debugging in the intended session or set + `BROWSER_TABS_HELIUM_DATA_DIR` to its actual user data root. +- **Cannot reach CDP** (older versions report **fetch failed**): the selected HTTP + endpoint could not be reached. For `--cdp 9222`, check + `curl http://127.0.0.1:9222/json/version`. If the connection is refused, that + port has no reachable debugging server. Enable debugging and use automatic + discovery or the port the browser actually reports. +- If discovery fails after a restart, recheck the browser's debugging setting. diff --git a/extension/chromium/browser-tabs.html b/extension/chromium/browser-tabs.html new file mode 100644 index 0000000..1bfacfc --- /dev/null +++ b/extension/chromium/browser-tabs.html @@ -0,0 +1,19 @@ + + + + + Browser Tabs CLI connection + + +

Browser Tabs CLI

+

+ Keep this page open while using the CLI. Enable a local debugging + endpoint, then run browser-tabs --browser helium doctor (or + select your Chromium browser). +

+

+ Custom tab names are unavailable. Native groups, titles and colors are + supported. Batch ownership lasts until the browser or extension restarts. +

+ + diff --git a/extension/chromium/manifest.json b/extension/chromium/manifest.json new file mode 100644 index 0000000..7dca8f7 --- /dev/null +++ b/extension/chromium/manifest.json @@ -0,0 +1,8 @@ +{ + "manifest_version": 3, + "name": "Browser Tabs CLI", + "version": "0.1.0", + "description": "Native tab and group operations for the local browser-tabs CLI.", + "permissions": ["tabs", "tabGroups", "storage"], + "options_page": "browser-tabs.html" +} diff --git a/package.json b/package.json index 5f834ed..fa4d5d5 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,8 @@ "format": "oxfmt --write .", "format:check": "oxfmt --check .", "check": "pnpm lint && pnpm typecheck && pnpm test && pnpm format:check", + "smoke:cdp": "node scripts/smoke-cdp.mjs", + "smoke:chromium": "node scripts/smoke-chromium.mjs", "smoke:vivaldi": "node scripts/smoke-vivaldi.mjs", "lint": "oxlint --deny-warnings .", "lint:fix": "oxlint --fix .", diff --git a/scripts/build.mjs b/scripts/build.mjs index 13c4644..5c1cc20 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -1,5 +1,5 @@ import { build } from "esbuild"; -import { chmod, rm } from "node:fs/promises"; +import { chmod, rm, cp } from "node:fs/promises"; await rm("dist", { recursive: true, force: true }); await build({ entryPoints: ["src/cli/main.ts"], @@ -21,3 +21,14 @@ await build({ target: "chrome120", }); await chmod("dist/main.js", 0o755); + +await cp("extension/chromium", "dist/chromium-extension", { recursive: true }); +await build({ + entryPoints: ["src/adapters/chromium/runtime.ts"], + outfile: "dist/chromium-extension/runtime.js", + bundle: true, + platform: "browser", + format: "iife", + globalName: "BrowserTabsRuntime", + target: "chrome120", +}); diff --git a/scripts/smoke-cdp.mjs b/scripts/smoke-cdp.mjs new file mode 100644 index 0000000..17f5973 --- /dev/null +++ b/scripts/smoke-cdp.mjs @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { once } from "node:events"; +import { createServer } from "node:http"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const stable = (tabs) => + tabs + .map(({ id, windowId, url }) => ({ id, windowId, url })) + .toSorted((a, b) => a.id.localeCompare(b.id)); +const browser = process.env.BROWSER_TABS_BROWSER ?? "helium"; +if ( + !process.env.BROWSER_TABS_CDP && + !process.env.BROWSER_TABS_DATA_DIR && + !process.env[`BROWSER_TABS_${browser.toUpperCase()}_DATA_DIR`] +) + throw new Error( + "Set the CDP endpoint or data directory of an isolated Chromium browser without the extension first.", + ); +const exec = promisify(execFile); +const binary = fileURLToPath(new URL("../dist/main.js", import.meta.url)); +const run = async (...args) => { + const { stdout } = await exec( + process.execPath, + [binary, "--browser", browser, ...args], + { timeout: 90_000 }, + ); + const result = JSON.parse(stdout); + assert.equal(result.ok, true); + return result.data; +}; +const target = process.argv[2] ? ["--window", process.argv[2]] : []; +const before = await run("tab", "list", ...target); +assert.ok( + before.length && + before.every( + (tab) => typeof tab.id === "string" && tab.id.startsWith("cdp:"), + ), + "This smoke test requires the basic connection without an extension page.", +); +const server = createServer((_request, response) => { + response.setHeader("Content-Type", "text/html"); + response.end("CDP smoke test"); +}); +const sockets = new Set(); +server.on("connection", (socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); +}); +server.listen(0, "127.0.0.1"); +await once(server, "listening"); +const base = `http://127.0.0.1:${server.address().port}`; +const created = []; +try { + assert.equal((await run("doctor")).connected, true); + assert.ok( + (await run("windows")).some((window) => window.id === before[0].windowId), + ); + for (const path of ["one", "two"]) { + const tab = await run("tab", "open", `${base}/${path}`, ...target); + created.push(tab.id); + assert.equal(tab.windowId, before[0].windowId); + assert.ok(tab.id.startsWith("cdp:")); + } + const listed = await run("tab", "list", ...target); + for (const id of created) + assert.ok(listed.some((tab) => tab.id === id && tab.url.startsWith(base))); + await assert.rejects(run("group", "list", ...target), (error) => + /requires the Browser Tabs CLI extension/.test(error.stdout ?? ""), + ); + await assert.rejects( + run("tab", "open", `${base}/named`, "--name", "Name", ...target), + (error) => /native custom tab names/.test(error.stdout ?? ""), + ); + console.log( + JSON.stringify( + { + passed: true, + browser, + mode: "basic-cdp", + opened: created.length, + operations: + "doctor, windows, list, open, close, unsupported-operation errors", + }, + null, + 2, + ), + ); +} catch (error) { + console.error(error); + throw error; +} finally { + try { + if (created.length) + await run("tab", "close", "--tabs", created.join(","), ...target); + const after = await run("tab", "list", ...target); + assert.deepEqual(stable(after), stable(before)); + } finally { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(resolve)); + } +} diff --git a/scripts/smoke-chromium.mjs b/scripts/smoke-chromium.mjs new file mode 100644 index 0000000..fd01cdf --- /dev/null +++ b/scripts/smoke-chromium.mjs @@ -0,0 +1,2 @@ +process.env.BROWSER_TABS_BROWSER ??= "helium"; +await import("./smoke-vivaldi.mjs"); diff --git a/scripts/smoke-vivaldi.mjs b/scripts/smoke-vivaldi.mjs index 41256c0..4297677 100644 --- a/scripts/smoke-vivaldi.mjs +++ b/scripts/smoke-vivaldi.mjs @@ -9,17 +9,28 @@ import { join } from "node:path"; import { randomUUID } from "node:crypto"; import { fileURLToPath } from "node:url"; +const browser = process.env.BROWSER_TABS_BROWSER ?? "vivaldi"; +const supportsNames = browser === "vivaldi"; + // Opt in explicitly. The script never launches or restarts a browser. -if (!process.env.BROWSER_TABS_CDP && !process.env.BROWSER_TABS_VIVALDI_DATA_DIR) +if ( + !process.env.BROWSER_TABS_CDP && + !process.env[`BROWSER_TABS_${browser.toUpperCase()}_DATA_DIR`] && + !(browser !== "vivaldi" && process.env.BROWSER_TABS_DATA_DIR) +) throw new Error( - "Set BROWSER_TABS_CDP or BROWSER_TABS_VIVALDI_DATA_DIR to the test browser first.", + "Set BROWSER_TABS_CDP or the selected browser data directory to the test browser first.", ); const exec = promisify(execFile); const binary = fileURLToPath(new URL("../dist/main.js", import.meta.url)); const run = async (...args) => { - const { stdout } = await exec(process.execPath, [binary, ...args], { - timeout: 90_000, - }); + const { stdout } = await exec( + process.execPath, + [binary, "--browser", browser, ...args], + { + timeout: 90_000, + }, + ); const result = JSON.parse(stdout); assert.equal(result.ok, true); return result; @@ -49,7 +60,7 @@ const plan = { tabs: Array.from({ length: 8 }, (_, index) => ({ key: `tab-${index}`, url: `${base}/${index}`, - name: `Test page ${index}`, + ...(supportsNames ? { name: `Test page ${index}` } : {}), groupKey: `group-${Math.floor(index / 2)}`, })), groups: ["yellow", "blue", "green", "purple"].map((color, index) => ({ @@ -87,20 +98,20 @@ try { "tab", "open", `${base}/unit`, - "--name", - "Unit tab", + ...(supportsNames ? ["--name", "Unit tab"] : []), ...cleanupTarget, ); extraIds.push(opened.data.id); const second = await run("tab", "open", `${base}/second`, ...cleanupTarget); extraIds.push(second.data.id); - await run( - "tab", - "rename", - String(second.data.id), - "Second tab", - ...cleanupTarget, - ); + if (supportsNames) + await run( + "tab", + "rename", + String(second.data.id), + "Second tab", + ...cleanupTarget, + ); const group = await run( "group", "create", @@ -144,11 +155,14 @@ try { JSON.stringify( { passed: true, + browser, batchTabs: 8, batchGroups: 4, applyMs: first.elapsedMs, replayMs: retry.elapsedMs, - unitOperations: "open, rename, create, rename group, color, move, list", + unitOperations: supportsNames + ? "open, rename, create, rename group, color, move, list" + : "open, create, rename group, color, move, list", }, null, 2, diff --git a/src/adapters/chromium/basic.ts b/src/adapters/chromium/basic.ts new file mode 100644 index 0000000..ac3256f --- /dev/null +++ b/src/adapters/chromium/basic.ts @@ -0,0 +1,165 @@ +import type { Command, Connection } from "../../domain/command.js"; +import type { CdpClient } from "../../transport/cdp.js"; + +interface Target { + targetId: string; + type: string; + url: string; + title: string; + browserContextId?: string; +} +interface BasicTab { + id: string; + windowId: number; + url: string; + title: string; + browserContextId?: string; +} +const visible = ({ browserContextId: _context, ...tab }: BasicTab) => tab; +function window(tabsInBrowser: BasicTab[], requested?: number) { + const ids = [...new Set(tabsInBrowser.map((tab) => tab.windowId))]; + if (requested !== undefined) { + if (!ids.includes(requested)) + throw new Error(`Window ${requested} is not open.`); + return requested; + } + if (!ids.length) throw new Error("No browser window is open."); + if (ids.length !== 1) + throw new Error( + "Multiple browser windows are open. Run windows and specify --window or windowId.", + ); + return ids[0]!; +} +export function basicConnection(cdp: CdpClient): Connection { + async function tabs(): Promise { + const { targetInfos } = await cdp.request<{ targetInfos: Target[] }>( + "Target.getTargets", + { filter: [{}] }, + ); + const result: BasicTab[] = []; + for (const target of targetInfos.filter( + (candidate) => + candidate.type === "page" && !candidate.url.startsWith("devtools://"), + )) { + const { windowId } = await cdp.request<{ windowId: number }>( + "Browser.getWindowForTarget", + { targetId: target.targetId }, + ); + result.push({ + id: `cdp:${target.targetId}`, + windowId, + url: target.url, + title: target.title, + browserContextId: target.browserContextId, + }); + } + return result; + } + return { + capabilities: { + mode: "basic-cdp", + groups: false, + batches: false, + tabNames: false, + }, + async execute(command: Command) { + if ( + command.type === "tab.rename" || + (command.type === "tab.open" && command.name !== undefined) + ) + throw new Error( + "This browser does not support native custom tab names.", + ); + if ( + command.type !== "windows" && + command.type !== "tabs" && + command.type !== "tab.open" && + command.type !== "tab.close" + ) + throw new Error( + "This command requires the Browser Tabs CLI extension. The connection is in basic CDP mode. In chrome://extensions, load dist/chromium-extension, then open Details > Extension options for Browser Tabs CLI and keep that page open. Accepting the debugging prompt does not enable group APIs. See docs/chromium.md. No tabs were changed.", + ); + const snapshot = await tabs(); + if (command.type === "windows") + return [...new Set(snapshot.map((tab) => tab.windowId))].map((id) => ({ + id, + })); + const windowId = window(snapshot, command.windowId); + if (command.type === "tabs") + return snapshot.filter((tab) => tab.windowId === windowId).map(visible); + if (command.type === "tab.close") { + if ( + !command.tabIds.length || + new Set(command.tabIds).size !== command.tabIds.length + ) + throw new Error("Select distinct tab IDs."); + const selected = command.tabIds.map((id) => { + const tab = snapshot.find((candidate) => candidate.id === id); + if (!tab) + throw new Error( + `Tab ${id} is not open. Run tab list to get CDP tab IDs.`, + ); + if (tab.windowId !== windowId) + throw new Error("A selected tab belongs to another window."); + return tab; + }); + for (const tab of selected) { + // Recheck immediately before closing; tabs can move between windows. + const { windowId: currentWindow } = await cdp.request<{ + windowId: number; + }>("Browser.getWindowForTarget", { targetId: tab.id.slice(4) }); + if (currentWindow !== windowId) + throw new Error("A selected tab belongs to another window."); + const { success } = await cdp.request<{ success: boolean }>( + "Target.closeTarget", + { targetId: tab.id.slice(4) }, + ); + if (!success) + throw new Error( + `CDP did not close tab ${tab.id}. Inspect partial changes before retrying.`, + ); + } + return { windowId, closed: command.tabIds }; + } + if (command.type !== "tab.open") + throw new Error("Unsupported basic CDP command."); + const reference = snapshot.find((tab) => tab.windowId === windowId); + if (!reference) { + throw new Error("Unable to find reference on snapshot"); + } + + const contextWindows = new Set( + snapshot + .filter((tab) => tab.browserContextId === reference.browserContextId) + .map((tab) => tab.windowId), + ); + if (contextWindows.size > 1) + throw new Error( + "Opening in a specific window when this browser context has multiple windows requires the Browser Tabs CLI extension. No tabs were changed.", + ); + // CDP creates in the sole window of this context; verify the returned placement. + const { targetId } = await cdp.request<{ targetId: string }>( + "Target.createTarget", + { + url: command.url, + background: true, + newWindow: false, + ...(reference.browserContextId === undefined + ? {} + : { browserContextId: reference.browserContextId }), + }, + ); + const { windowId: actualWindow } = await cdp.request<{ + windowId: number; + }>("Browser.getWindowForTarget", { targetId }); + if (actualWindow !== windowId) { + await cdp.request("Target.closeTarget", { targetId }); + throw new Error( + "CDP opened the tab in another window; it was closed. Use the extension for window-specific opening.", + ); + } + return { id: `cdp:${targetId}`, windowId, url: command.url, title: "" }; + }, + close: () => cdp.close(), + }; +} diff --git a/src/adapters/chromium/browser.ts b/src/adapters/chromium/browser.ts new file mode 100644 index 0000000..bf0af96 --- /dev/null +++ b/src/adapters/chromium/browser.ts @@ -0,0 +1,153 @@ +import type { Browser, Color, Ownership, Tab } from "../../domain/browser.js"; +import type { NativeApi } from "./native-api.js"; +const key = (id: number) => `browser-tabs:${id}`; +export class ChromiumBrowser implements Browser { + readonly limits = { minGroupSize: 1, tabNames: false }; + constructor(private readonly api: NativeApi) {} + async windows() { + return (await this.api.windows.getAll({ windowTypes: ["normal"] })).map( + (window) => ({ + id: window.id, + focused: Boolean(window.focused), + incognito: Boolean(window.incognito), + }), + ); + } + async tabs(windowId: number): Promise { + const tabs = await this.api.tabs.query({ windowId }); + const groups = await this.api.tabGroups.query({ windowId }); + const ownership = await this.api.storage.session.get( + tabs.map((tab) => key(tab.id)), + ); + return tabs.map((tab) => { + const group = groups.find((candidate) => candidate.id === tab.groupId); + return { + id: tab.id, + windowId: tab.windowId, + index: tab.index, + url: tab.pendingUrl ?? tab.url ?? "", + title: tab.title ?? "", + active: Boolean(tab.active), + pinned: Boolean(tab.pinned), + loaded: tab.status === "complete" && !tab.discarded, + ...(group + ? { + group: { + id: String(group.id), + title: group.title ?? "", + color: group.color, + }, + } + : {}), + ...(ownership[key(tab.id)] + ? { ownership: ownership[key(tab.id)] } + : {}), + }; + }); + } + private async selected(windowId: number, ids: number[]) { + if (!ids.length || new Set(ids).size !== ids.length) + throw new Error("Select distinct tab IDs."); + const tabs = await Promise.all(ids.map((id) => this.api.tabs.get(id))); + if (tabs.some((tab) => tab.windowId !== windowId)) + throw new Error("A selected tab belongs to another window."); + return tabs; + } + private async group(windowId: number, groupId: string) { + if (!/^\d+$/.test(groupId) || !Number.isSafeInteger(Number(groupId))) + throw new Error("Invalid Chromium group ID."); + const group = await this.api.tabGroups.get(Number(groupId)); + if (group.windowId !== windowId) + throw new Error("Group is not in the selected window."); + return group; + } + async open(windowId: number, url: string, ownership?: Ownership) { + const before = await this.api.tabs.query({ windowId }); + const tab = await this.api.tabs.create({ + windowId, + url, + active: false, + index: before.length, + }); + if (ownership) + await this.api.storage.session.set({ [key(tab.id)]: ownership }); + const actual = (await this.tabs(windowId)).find( + (candidate) => candidate.id === tab.id, + ); + if ( + !actual || + actual.group || + (ownership && + JSON.stringify(actual.ownership) !== JSON.stringify(ownership)) + ) + throw new Error( + `Created tab ${tab.id}, but placement or ownership was not persisted. Inspect it before retrying.`, + ); + return actual; + } + async rename( + _windowId: number, + _tabId: number, + _name: string, + ): Promise { + throw new Error("This browser does not support native custom tab names."); + } + async close(windowId: number, ids: number[]) { + await this.selected(windowId, ids); + await this.api.tabs.remove(ids); + await this.api.storage.session.remove(ids.map(key)); + } + async createGroup( + windowId: number, + ids: number[], + title: string, + color?: Color, + ) { + if ((await this.selected(windowId, ids)).some((tab) => tab.pinned)) + throw new Error("Unpin tabs before grouping them."); + const groupId = await this.api.tabs.group({ + tabIds: ids, + createProperties: { windowId }, + }); + await this.updateGroup(windowId, String(groupId), { title, color }); + const actual = (await this.tabs(windowId)).filter( + (tab) => tab.group?.id === String(groupId), + ); + if ( + actual.length !== ids.length || + actual.some((tab) => !ids.includes(tab.id)) + ) + throw new Error("Chromium returned unexpected group members."); + return actual[0]!.group!; + } + async updateGroup( + windowId: number, + groupId: string, + changes: { title?: string; color?: Color }, + ) { + const group = await this.group(windowId, groupId); + const patch = { + ...(changes.title === undefined ? {} : { title: changes.title }), + ...(changes.color === undefined ? {} : { color: changes.color }), + }; + await this.api.tabGroups.update(group.id, patch); + const actual = await this.group(windowId, groupId); + if ( + (changes.title !== undefined && actual.title !== changes.title) || + (changes.color !== undefined && actual.color !== changes.color) + ) + throw new Error("Chromium did not save the group properties."); + } + async moveToGroup(windowId: number, ids: number[], groupId: string) { + const group = await this.group(windowId, groupId); + if ((await this.selected(windowId, ids)).some((tab) => tab.pinned)) + throw new Error("Unpin tabs before grouping them."); + await this.api.tabs.group({ tabIds: ids, groupId: group.id }); + if ( + (await this.selected(windowId, ids)).some( + (tab) => tab.groupId !== group.id, + ) + ) + throw new Error("Chromium did not move the tabs into the group."); + } +} diff --git a/src/adapters/chromium/connection.ts b/src/adapters/chromium/connection.ts new file mode 100644 index 0000000..ca6feb4 --- /dev/null +++ b/src/adapters/chromium/connection.ts @@ -0,0 +1,95 @@ +import { readFile } from "node:fs/promises"; +import type { Command, Connection } from "../../domain/command.js"; +import { CdpClient } from "../../transport/cdp.js"; +import { basicConnection } from "./basic.js"; +import { type ChromiumName, discoverChromiumEndpoint } from "./discovery.js"; + +interface Evaluation { + result: { value?: unknown }; + exceptionDetails?: { text: string; exception?: { description?: string } }; +} +export async function connectChromium( + browser: ChromiumName, + endpoint: string, + runtimeFile = new URL("./chromium-extension/runtime.js", import.meta.url), +): Promise { + const cdp = await CdpClient.connect( + endpoint === "auto" ? await discoverChromiumEndpoint(browser) : endpoint, + ); + try { + const { targetInfos } = await cdp.request<{ + targetInfos: { targetId: string; type: string; url: string }[]; + }>("Target.getTargets", { filter: [{}] }); + let attached: string | undefined; + for (const target of targetInfos.filter( + (candidate) => + candidate.type === "page" && + /^chrome-extension:\/\/[a-p]{32}\/browser-tabs\.html$/.test( + candidate.url, + ), + )) { + let sessionId: string | undefined; + try { + ({ sessionId } = await cdp.request<{ sessionId: string }>( + "Target.attachToTarget", + { targetId: target.targetId, flatten: true }, + )); + const probe = await cdp.request( + "Runtime.evaluate", + { + expression: + 'Boolean(globalThis.chrome?.runtime?.getManifest?.().name === "Browser Tabs CLI" && globalThis.chrome?.tabs?.group && globalThis.chrome?.windows && globalThis.chrome?.tabGroups && globalThis.chrome?.storage?.session)', + returnByValue: true, + }, + sessionId, + ); + if (probe.result.value === true) { + attached = sessionId; + break; + } + } catch { + /* Extension pages may close while attaching. */ + } + if (sessionId) + await cdp + .request("Target.detachFromTarget", { sessionId }) + .catch(() => undefined); + } + if (!attached) return basicConnection(cdp); + // Use the CLI's runtime even if the installed page has no script or an older one. + // Read outside the probe catch so packaging failures are never silently downgraded. + const source = await readFile(runtimeFile, "utf8"); + return { + capabilities: { + mode: "chromium-extension", + groups: true, + batches: true, + tabNames: false, + }, + async execute(command: Command) { + const delay = + "verifyAfterMs" in command ? (command.verifyAfterMs ?? 0) : 0; + const result = await cdp.request( + "Runtime.evaluate", + { + expression: `(() => {\n${source}\nreturn BrowserTabsRuntime.run(${JSON.stringify(command)});\n})()`, + awaitPromise: true, + returnByValue: true, + }, + attached, + Math.max(60_000, delay + 30_000), + ); + if (result.exceptionDetails) + throw new Error( + result.exceptionDetails.exception?.description ?? + result.exceptionDetails.text, + ); + return result.result.value; + }, + close: () => cdp.close(), + }; + } catch (error) { + cdp.close(); + throw error; + } +} diff --git a/src/adapters/chromium/discovery.ts b/src/adapters/chromium/discovery.ts new file mode 100644 index 0000000..30c6070 --- /dev/null +++ b/src/adapters/chromium/discovery.ts @@ -0,0 +1,58 @@ +import { homedir, platform } from "node:os"; +import { join } from "node:path"; +import { discoverEndpoint as readEndpoint } from "../vivaldi/discovery.js"; +export const chromiumBrowsers = [ + "helium", + "chromium", + "chrome", + "brave", + "edge", + "opera", +] as const; +export type ChromiumName = (typeof chromiumBrowsers)[number]; +const paths: Record = { + helium: ["net.imput.helium", "Helium/User Data", "helium"], + chromium: ["Chromium", "Chromium/User Data", "chromium"], + chrome: ["Google/Chrome", "Google/Chrome/User Data", "google-chrome"], + brave: [ + "BraveSoftware/Brave-Browser", + "BraveSoftware/Brave-Browser/User Data", + "BraveSoftware/Brave-Browser", + ], + edge: ["Microsoft Edge", "Microsoft/Edge/User Data", "microsoft-edge"], + opera: ["com.operasoftware.Opera", "Opera Software/Opera Stable", "opera"], +}; +export function dataDirectory( + browser: ChromiumName, + os = platform(), + home = homedir(), + env = process.env, +): string { + const override = + env[`BROWSER_TABS_${browser.toUpperCase()}_DATA_DIR`] ?? + env.BROWSER_TABS_DATA_DIR; + if (override) return override; + if (os === "darwin") + return join(home, "Library", "Application Support", paths[browser][0]); + if (os === "win32") + return join( + browser === "opera" ? (env.APPDATA ?? "") : (env.LOCALAPPDATA ?? ""), + paths[browser][1], + ); + return join(env.XDG_CONFIG_HOME ?? join(home, ".config"), paths[browser][2]); +} +export async function discoverChromiumEndpoint(browser: ChromiumName) { + try { + return await readEndpoint(dataDirectory(browser)); + } catch (error) { + if ( + error instanceof Error && + error.message === "Invalid DevToolsActivePort file." + ) + throw error; + throw new Error( + `${browser} debugging is unavailable: cannot read DevToolsActivePort in ${dataDirectory(browser)}. Enable remote debugging at chrome://inspect/#remote-debugging if available, then retry without --cdp. For an already configured endpoint use --cdp; for a custom profile use BROWSER_TABS_${browser.toUpperCase()}_DATA_DIR. See docs/chromium.md.`, + { cause: error }, + ); + } +} diff --git a/src/adapters/chromium/native-api.ts b/src/adapters/chromium/native-api.ts new file mode 100644 index 0000000..99af3ae --- /dev/null +++ b/src/adapters/chromium/native-api.ts @@ -0,0 +1,58 @@ +import type { Color, Ownership } from "../../domain/browser.js"; +export interface NativeTab { + id: number; + windowId: number; + index: number; + url?: string; + pendingUrl?: string; + title?: string; + active?: boolean; + pinned?: boolean; + status?: string; + discarded?: boolean; + groupId: number; +} +export interface NativeGroup { + id: number; + windowId: number; + title?: string; + color: Color; +} +export interface NativeApi { + windows: { + getAll(options: { + windowTypes: string[]; + }): Promise<{ id: number; focused?: boolean; incognito?: boolean }[]>; + }; + tabs: { + query(options: { windowId: number }): Promise; + get(id: number): Promise; + create(options: { + windowId: number; + url: string; + active: false; + index: number; + }): Promise; + remove(ids: number[]): Promise; + group(options: { + tabIds: number[]; + groupId?: number; + createProperties?: { windowId: number }; + }): Promise; + }; + tabGroups: { + query(options: { windowId: number }): Promise; + get(id: number): Promise; + update( + id: number, + changes: { title?: string; color?: Color }, + ): Promise; + }; + storage: { + session: { + get(keys: string[]): Promise>; + set(values: Record): Promise; + remove(keys: string[]): Promise; + }; + }; +} diff --git a/src/adapters/chromium/runtime.ts b/src/adapters/chromium/runtime.ts new file mode 100644 index 0000000..df2e1ba --- /dev/null +++ b/src/adapters/chromium/runtime.ts @@ -0,0 +1,9 @@ +import { execute } from "../../application/execute.js"; +import type { Command } from "../../domain/command.js"; +import { ChromiumBrowser } from "./browser.js"; +import type { NativeApi } from "./native-api.js"; +declare const chrome: NativeApi; +export const adapter = "browser-tabs-chromium-v1"; +export function run(command: Command) { + return execute(new ChromiumBrowser(chrome), command); +} diff --git a/src/adapters/connect.ts b/src/adapters/connect.ts index adc6657..57b5e83 100644 --- a/src/adapters/connect.ts +++ b/src/adapters/connect.ts @@ -1,11 +1,15 @@ import type { Connection } from "../domain/command.js"; import { connectVivaldi } from "./vivaldi/connection.js"; +import { connectChromium } from "./chromium/connection.js"; +import { chromiumBrowsers, type ChromiumName } from "./chromium/discovery.js"; export function connect( browser: string, endpoint: string, ): Promise { if (browser === "vivaldi") return connectVivaldi(endpoint); + if (chromiumBrowsers.includes(browser as ChromiumName)) + return connectChromium(browser as ChromiumName, endpoint); throw new Error( - `Unsupported browser: ${browser}. Available adapter: vivaldi.`, + `Unsupported browser: ${browser}. Available adapters: vivaldi, ${chromiumBrowsers.join(", ")}.`, ); } diff --git a/src/application/batch.ts b/src/application/batch.ts index 8912218..0a2a120 100644 --- a/src/application/batch.ts +++ b/src/application/batch.ts @@ -89,6 +89,13 @@ export class BatchService { ); } private checkLimits(plan: Plan) { + if ( + this.browser.limits.tabNames === false && + plan.tabs.some((tab) => tab.name !== undefined) + ) + throw new Error( + "This browser does not support native custom tab names. Remove name fields from the plan.", + ); for (const group of plan.groups) if ( plan.tabs.filter((tab) => tab.groupKey === group.key).length < diff --git a/src/application/execute.ts b/src/application/execute.ts index 08381ec..42ff148 100644 --- a/src/application/execute.ts +++ b/src/application/execute.ts @@ -1,65 +1,83 @@ -import { groupsFromTabs, type Browser } from "../domain/browser.js"; +import { type Browser, groupsFromTabs } from "../domain/browser.js"; import type { Command } from "../domain/command.js"; import { BatchService } from "./batch.js"; import { selectWindow } from "./window.js"; + +function nativeTabId(value: number | string): number { + if (typeof value !== "number") + throw new Error( + "CDP tab IDs require the basic CDP connection. Run tab list again to get native tab IDs.", + ); + return value; +} export async function execute( - browser: Browser, - command: Command, + browser: Browser, + command: Command, ): Promise { - if (command.type === "windows") return browser.windows(); - if (command.type === "batch.open") - return new BatchService(browser).open(command.plan, command.verifyAfterMs); - if (command.type === "batch.verify") - return new BatchService(browser).verify( - command.plan, - command.verifyAfterMs, - ); - const windowId = await selectWindow(browser, command.windowId); - switch (command.type) { - case "tabs": - return browser.tabs(windowId); - case "groups": - return groupsFromTabs(await browser.tabs(windowId)); - case "tab.open": { - if ( - command.name && - browser.limits.maxTabNameLength && - command.name.length > browser.limits.maxTabNameLength - ) - throw new Error("Tab name exceeds the browser limit."); - const tab = await browser.open(windowId, command.url); - if (command.name) await browser.rename(windowId, tab.id, command.name); - return { ...tab, ...(command.name ? { name: command.name } : {}) }; - } - case "tab.rename": - await browser.rename(windowId, command.tabId, command.name); - return { windowId, renamed: command.tabId }; - case "tab.close": - await browser.close(windowId, command.tabIds); - return { windowId, closed: command.tabIds }; - case "group.create": - return browser.createGroup( - windowId, - command.tabIds, - command.title, - command.color, - ); - case "group.update": - await browser.updateGroup(windowId, command.groupId, command); - return { windowId, updated: command.groupId }; - case "group.move": - await browser.moveToGroup(windowId, command.tabIds, command.groupId); - return { windowId, moved: command.tabIds, groupId: command.groupId }; - case "batch.close": { - const ids = (await browser.tabs(windowId)) - .filter((tab) => tab.ownership?.batchId === command.batchId) - .map((tab) => tab.id); - if (ids.length) await browser.close(windowId, ids); - return { windowId, closed: ids.length }; - } - default: { - const unhandled: never = command; - throw new Error(`Unsupported command: ${JSON.stringify(unhandled)}`); - } - } + if ( + browser.limits.tabNames === false && + (command.type === "tab.rename" || + (command.type === "tab.open" && command.name !== undefined)) + ) + throw new Error("This browser does not support native custom tab names."); + if (command.type === "windows") return browser.windows(); + if (command.type === "batch.open") + return new BatchService(browser).open(command.plan, command.verifyAfterMs); + if (command.type === "batch.verify") + return new BatchService(browser).verify( + command.plan, + command.verifyAfterMs, + ); + const windowId = await selectWindow(browser, command.windowId); + switch (command.type) { + case "tabs": + return browser.tabs(windowId); + case "groups": + return groupsFromTabs(await browser.tabs(windowId)); + case "tab.open": { + if ( + command.name && + browser.limits.maxTabNameLength && + command.name.length > browser.limits.maxTabNameLength + ) + throw new Error("Tab name exceeds the browser limit."); + const tab = await browser.open(windowId, command.url); + if (command.name) await browser.rename(windowId, tab.id, command.name); + return { ...tab, ...(command.name ? { name: command.name } : {}) }; + } + case "tab.rename": + await browser.rename(windowId, nativeTabId(command.tabId), command.name); + return { windowId, renamed: command.tabId }; + case "tab.close": + await browser.close(windowId, command.tabIds.map(nativeTabId)); + return { windowId, closed: command.tabIds }; + case "group.create": + return browser.createGroup( + windowId, + command.tabIds.map(nativeTabId), + command.title, + command.color, + ); + case "group.update": + await browser.updateGroup(windowId, command.groupId, command); + return { windowId, updated: command.groupId }; + case "group.move": + await browser.moveToGroup( + windowId, + command.tabIds.map(nativeTabId), + command.groupId, + ); + return { windowId, moved: command.tabIds, groupId: command.groupId }; + case "batch.close": { + const ids = (await browser.tabs(windowId)) + .filter((tab) => tab.ownership?.batchId === command.batchId) + .map((tab) => tab.id); + if (ids.length) await browser.close(windowId, ids); + return { windowId, closed: ids.length }; + } + default: { + const unhandled: never = command; + throw new Error(`Unsupported command: ${JSON.stringify(unhandled)}`); + } + } } diff --git a/src/cli/help.ts b/src/cli/help.ts index c9396cd..e29ef6d 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -1,6 +1,6 @@ -export const help = `browser-tabs [--browser vivaldi] [--cdp auto|port|url] +export const help = `browser-tabs [--browser NAME] [--cdp auto|port|url] - doctor Check the existing browser connection + doctor Check the connection and Chromium capabilities windows List open windows tab list [--window ID] List tabs and native group membership tab open URL [--name NAME] [--window ID] @@ -23,5 +23,14 @@ Opening a batch or group skips post-open verification by default. batch verify checks immediately unless a verification delay is supplied. CDP means Chrome DevTools Protocol. The browser must already expose a local endpoint. This CLI never launches a browser, creates a profile, or enables debugging. -Set BROWSER_TABS_CDP or BROWSER_TABS_VIVALDI_DATA_DIR to customize discovery. +Browsers: vivaldi (default), helium, chromium, chrome, brave, edge, opera. +Chromium basic commands (doctor, windows, tab list/open/close) work without an extension. +Groups and batches require the bundled extension; doctor reports their availability. +When debugging is enabled via the browser UI, each CLI command requests permission. +See docs/chromium.md for extension setup and connection behavior. +Without the extension, tab IDs use cdp:TARGET; native tab metadata is unavailable. +Opening with multiple windows in the same browser context requires the extension. +Native custom tab names are supported only by Vivaldi. +Set BROWSER_TABS_CDP or BROWSER_TABS__DATA_DIR to customize discovery. +BROWSER_TABS_DATA_DIR sets a shared data directory for Chromium adapters. `; diff --git a/src/cli/parse.ts b/src/cli/parse.ts index 1dfac4b..76a5542 100644 --- a/src/cli/parse.ts +++ b/src/cli/parse.ts @@ -4,195 +4,196 @@ import { parseArgs } from "node:util"; import type { Command } from "../domain/command.js"; import type { Plan } from "../domain/plan.js"; import { - color, - id, - ids, - navigationUrl, - parsePlan, - text, + color, + id, + ids, + navigationUrl, + parsePlan, + tabId, + text, } from "../domain/validation.js"; export type Input = - | { kind: "help" } - | { kind: "validate"; plan: Plan } - | { - kind: "execute"; - browser: string; - endpoint: string; - command: Command; - doctor: boolean; - }; + | { kind: "help" } + | { kind: "validate"; plan: Plan } + | { + kind: "execute"; + browser: string; + endpoint: string; + command: Command; + doctor: boolean; + }; export function parse(argv: string[]): Input { - const { values, positionals: words } = parseArgs({ - args: argv, - allowPositionals: true, - options: { - help: { type: "boolean" }, - browser: { type: "string" }, - cdp: { type: "string" }, - window: { type: "string" }, - name: { type: "string" }, - tabs: { type: "string" }, - color: { type: "string" }, - batch: { type: "string" }, - "verify-after-seconds": { type: "string" }, - }, - }); - if (values.help || !words.length) return { kind: "help" }; - const args = words.slice(2); - const arg = (index: number) => text(args[index], "command argument"); - const check = (count: number | undefined, allowed: string[] = []) => { - if (count !== undefined && args.length !== count) - throw new Error("Unexpected or missing arguments. See --help."); - for (const key of Object.keys(values)) - if (!["browser", "cdp", "help", ...allowed].includes(key)) - throw new Error(`--${key} does not apply to this command.`); - }; - const windowId = values.window === undefined ? undefined : id(values.window); - const target = windowId === undefined ? {} : { windowId }; - const verificationOptions = () => { - const value = values["verify-after-seconds"]; - if (value === undefined) return {}; - const seconds = Number(text(value, "--verify-after-seconds")); - if (!Number.isFinite(seconds) || seconds < 0 || seconds > 300) - throw new Error( - "--verify-after-seconds must be between 0 and 300 seconds.", - ); - return { verifyAfterMs: seconds * 1000 }; - }; - let command: Command; - if (words[0] === "doctor" || words[0] === "windows") { - check(undefined); - if (words.length !== 1) throw new Error("Unexpected arguments."); - command = { type: "windows" }; - } else - switch (`${words[0]} ${words[1]}`) { - case "tab list": - check(0, ["window"]); - command = { type: "tabs", ...target }; - break; - case "group list": - check(0, ["window"]); - command = { type: "groups", ...target }; - break; - case "tab open": - check(1, ["window", "name"]); - command = { - type: "tab.open", - ...target, - url: navigationUrl(arg(0)), - ...(values.name === undefined - ? {} - : { name: text(values.name, "name") }), - }; - break; - case "tab rename": - check(2, ["window"]); - command = { - type: "tab.rename", - ...target, - tabId: id(arg(0)), - name: arg(1), - }; - break; - case "tab close": - check(0, ["window", "tabs"]); - command = { type: "tab.close", ...target, tabIds: ids(values.tabs) }; - break; - case "group create": - check(1, ["window", "tabs", "color"]); - command = { - type: "group.create", - ...target, - tabIds: ids(values.tabs), - title: arg(0), - color: color(values.color), - }; - break; - case "group rename": - check(2, ["window"]); - command = { - type: "group.update", - ...target, - groupId: arg(0), - title: arg(1), - }; - break; - case "group color": - check(2, ["window"]); - command = { - type: "group.update", - ...target, - groupId: arg(0), - color: color(arg(1)), - }; - break; - case "group move": - check(1, ["window", "tabs"]); - command = { - type: "group.move", - ...target, - groupId: arg(0), - tabIds: ids(values.tabs), - }; - break; - case "group open": { - check(undefined, ["window", "color", "batch", "verify-after-seconds"]); - if (args.length < 2) - throw new Error("Provide a group title and its URLs."); - const plan = parsePlan({ - version: 1, - batchId: values.batch ?? randomUUID(), - ...target, - tabs: args.slice(1).map((url, index) => ({ - key: `tab-${index + 1}`, - url, - groupKey: "group", - })), - groups: [ - { - key: "group", - title: arg(0), - ...(values.color === undefined - ? {} - : { color: color(values.color) }), - }, - ], - }); - command = { type: "batch.open", plan, ...verificationOptions() }; - break; - } - case "batch validate": - case "batch open": - case "batch verify": { - const local = words[1] === "validate"; - check(1, local ? [] : ["window", "verify-after-seconds"]); - const plan = parsePlan(JSON.parse(readFileSync(arg(0), "utf8"))); - if (local) return { kind: "validate", plan }; - if ( - windowId !== undefined && - plan.windowId !== undefined && - windowId !== plan.windowId - ) - throw new Error("--window conflicts with windowId in the JSON."); - command = { - type: words[1] === "open" ? "batch.open" : "batch.verify", - plan: { ...plan, ...target }, - ...verificationOptions(), - }; - break; - } - case "batch close": - check(1, ["window"]); - command = { type: "batch.close", ...target, batchId: arg(0) }; - break; - default: - throw new Error("Unknown command. See --help."); - } - return { - kind: "execute", - browser: values.browser ?? "vivaldi", - endpoint: values.cdp ?? process.env.BROWSER_TABS_CDP ?? "auto", - command, - doctor: words[0] === "doctor", - }; + const { values, positionals: words } = parseArgs({ + args: argv, + allowPositionals: true, + options: { + help: { type: "boolean" }, + browser: { type: "string" }, + cdp: { type: "string" }, + window: { type: "string" }, + name: { type: "string" }, + tabs: { type: "string" }, + color: { type: "string" }, + batch: { type: "string" }, + "verify-after-seconds": { type: "string" }, + }, + }); + if (values.help || !words.length) return { kind: "help" }; + const args = words.slice(2); + const arg = (index: number) => text(args[index], "command argument"); + const check = (count: number | undefined, allowed: string[] = []) => { + if (count !== undefined && args.length !== count) + throw new Error("Unexpected or missing arguments. See --help."); + for (const key of Object.keys(values)) + if (!["browser", "cdp", "help", ...allowed].includes(key)) + throw new Error(`--${key} does not apply to this command.`); + }; + const windowId = values.window === undefined ? undefined : id(values.window); + const target = windowId === undefined ? {} : { windowId }; + const verificationOptions = () => { + const value = values["verify-after-seconds"]; + if (value === undefined) return {}; + const seconds = Number(text(value, "--verify-after-seconds")); + if (!Number.isFinite(seconds) || seconds < 0 || seconds > 300) + throw new Error( + "--verify-after-seconds must be between 0 and 300 seconds.", + ); + return { verifyAfterMs: seconds * 1000 }; + }; + let command: Command; + if (words[0] === "doctor" || words[0] === "windows") { + check(undefined); + if (words.length !== 1) throw new Error("Unexpected arguments."); + command = { type: "windows" }; + } else + switch (`${words[0]} ${words[1]}`) { + case "tab list": + check(0, ["window"]); + command = { type: "tabs", ...target }; + break; + case "group list": + check(0, ["window"]); + command = { type: "groups", ...target }; + break; + case "tab open": + check(1, ["window", "name"]); + command = { + type: "tab.open", + ...target, + url: navigationUrl(arg(0)), + ...(values.name === undefined + ? {} + : { name: text(values.name, "name") }), + }; + break; + case "tab rename": + check(2, ["window"]); + command = { + type: "tab.rename", + ...target, + tabId: tabId(arg(0)), + name: arg(1), + }; + break; + case "tab close": + check(0, ["window", "tabs"]); + command = { type: "tab.close", ...target, tabIds: ids(values.tabs) }; + break; + case "group create": + check(1, ["window", "tabs", "color"]); + command = { + type: "group.create", + ...target, + tabIds: ids(values.tabs), + title: arg(0), + color: color(values.color), + }; + break; + case "group rename": + check(2, ["window"]); + command = { + type: "group.update", + ...target, + groupId: arg(0), + title: arg(1), + }; + break; + case "group color": + check(2, ["window"]); + command = { + type: "group.update", + ...target, + groupId: arg(0), + color: color(arg(1)), + }; + break; + case "group move": + check(1, ["window", "tabs"]); + command = { + type: "group.move", + ...target, + groupId: arg(0), + tabIds: ids(values.tabs), + }; + break; + case "group open": { + check(undefined, ["window", "color", "batch", "verify-after-seconds"]); + if (args.length < 2) + throw new Error("Provide a group title and its URLs."); + const plan = parsePlan({ + version: 1, + batchId: values.batch ?? randomUUID(), + ...target, + tabs: args.slice(1).map((url, index) => ({ + key: `tab-${index + 1}`, + url, + groupKey: "group", + })), + groups: [ + { + key: "group", + title: arg(0), + ...(values.color === undefined + ? {} + : { color: color(values.color) }), + }, + ], + }); + command = { type: "batch.open", plan, ...verificationOptions() }; + break; + } + case "batch validate": + case "batch open": + case "batch verify": { + const local = words[1] === "validate"; + check(1, local ? [] : ["window", "verify-after-seconds"]); + const plan = parsePlan(JSON.parse(readFileSync(arg(0), "utf8"))); + if (local) return { kind: "validate", plan }; + if ( + windowId !== undefined && + plan.windowId !== undefined && + windowId !== plan.windowId + ) + throw new Error("--window conflicts with windowId in the JSON."); + command = { + type: words[1] === "open" ? "batch.open" : "batch.verify", + plan: { ...plan, ...target }, + ...verificationOptions(), + }; + break; + } + case "batch close": + check(1, ["window"]); + command = { type: "batch.close", ...target, batchId: arg(0) }; + break; + default: + throw new Error("Unknown command. See --help."); + } + return { + kind: "execute", + browser: values.browser ?? "vivaldi", + endpoint: values.cdp ?? process.env.BROWSER_TABS_CDP ?? "auto", + command, + doctor: words[0] === "doctor", + }; } diff --git a/src/cli/run.ts b/src/cli/run.ts index 3df1c06..251c53e 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -19,7 +19,14 @@ export async function run( try { const result = await connection.execute(input.command); const data = input.doctor - ? { connected: true, adapter: input.browser, windows: result } + ? { + connected: true, + adapter: input.browser, + windows: result, + ...(connection.capabilities + ? { capabilities: connection.capabilities } + : {}), + } : result; return { data, exitCode: statusCode(data) }; } finally { diff --git a/src/domain/browser.ts b/src/domain/browser.ts index 820c7c9..d22ec2a 100644 --- a/src/domain/browser.ts +++ b/src/domain/browser.ts @@ -42,7 +42,11 @@ export interface GroupWithTabs extends Group { tabIds: number[]; } export interface Browser { - readonly limits: { minGroupSize: number; maxTabNameLength?: number }; + readonly limits: { + minGroupSize: number; + maxTabNameLength?: number; + tabNames?: boolean; + }; windows(): Promise; tabs(windowId: number): Promise; open(windowId: number, url: string, ownership?: Ownership): Promise; diff --git a/src/domain/command.ts b/src/domain/command.ts index dbe0968..65a46bb 100644 --- a/src/domain/command.ts +++ b/src/domain/command.ts @@ -1,16 +1,17 @@ import type { Color } from "./browser.js"; import type { Plan } from "./plan.js"; +export type TabId = number | string; type WindowTarget = { windowId?: number }; export type Command = | { type: "windows" } | ({ type: "tabs" | "groups" } & WindowTarget) | ({ type: "tab.open"; url: string; name?: string } & WindowTarget) - | ({ type: "tab.rename"; tabId: number; name: string } & WindowTarget) - | ({ type: "tab.close"; tabIds: number[] } & WindowTarget) + | ({ type: "tab.rename"; tabId: TabId; name: string } & WindowTarget) + | ({ type: "tab.close"; tabIds: TabId[] } & WindowTarget) | ({ type: "group.create"; title: string; - tabIds: number[]; + tabIds: TabId[]; color?: Color; } & WindowTarget) | ({ @@ -19,11 +20,17 @@ export type Command = title?: string; color?: Color; } & WindowTarget) - | ({ type: "group.move"; groupId: string; tabIds: number[] } & WindowTarget) + | ({ type: "group.move"; groupId: string; tabIds: TabId[] } & WindowTarget) | { type: "batch.open"; plan: Plan; verifyAfterMs?: number } | { type: "batch.verify"; plan: Plan; verifyAfterMs?: number } | ({ type: "batch.close"; batchId: string } & WindowTarget); export interface Connection { + readonly capabilities?: { + mode: "basic-cdp" | "chromium-extension"; + groups: boolean; + batches: boolean; + tabNames: boolean; + }; execute(command: Command): Promise; close(): void; } diff --git a/src/domain/validation.ts b/src/domain/validation.ts index eb571af..6282c49 100644 --- a/src/domain/validation.ts +++ b/src/domain/validation.ts @@ -1,62 +1,67 @@ import Ajv from "ajv/dist/2020.js"; import addFormats from "ajv-formats"; import schema from "../../tab-batch-schema/schema.json"; -import { colors, type Color } from "./browser.js"; +import { type Color, colors } from "./browser.js"; import type { Plan } from "./plan.js"; + const ajv = new Ajv({ allErrors: true }); addFormats(ajv); const matchesSchema = ajv.compile(schema); export function parsePlan(value: unknown): Plan { - if (!matchesSchema(value)) - throw new Error( - `Invalid batch: ${ajv.errorsText(matchesSchema.errors, { separator: "; " })}`, - ); - unique( - value.tabs.map((tab) => tab.key), - "tab keys", - ); - unique( - value.groups.map((group) => group.key), - "group keys", - ); - const groups = new Set(value.groups.map((group) => group.key)); - for (const tab of value.tabs) { - navigationUrl(tab.url); - if (tab.groupKey && !groups.has(tab.groupKey)) - throw new Error(`Unknown group key: ${tab.groupKey}.`); - } - for (const group of value.groups) - if (!value.tabs.some((tab) => tab.groupKey === group.key)) - throw new Error(`Empty group: ${group.key}.`); - return value; + if (!matchesSchema(value)) + throw new Error( + `Invalid batch: ${ajv.errorsText(matchesSchema.errors, { separator: "; " })}`, + ); + unique( + value.tabs.map((tab) => tab.key), + "tab keys", + ); + unique( + value.groups.map((group) => group.key), + "group keys", + ); + const groups = new Set(value.groups.map((group) => group.key)); + for (const tab of value.tabs) { + navigationUrl(tab.url); + if (tab.groupKey && !groups.has(tab.groupKey)) + throw new Error(`Unknown group key: ${tab.groupKey}.`); + } + for (const group of value.groups) + if (!value.tabs.some((tab) => tab.groupKey === group.key)) + throw new Error(`Empty group: ${group.key}.`); + return value; } function unique(values: (string | number)[], label: string) { - if (new Set(values).size !== values.length) - throw new Error(`Duplicate ${label}.`); + if (new Set(values).size !== values.length) + throw new Error(`Duplicate ${label}.`); } export function text(value: string | undefined, label: string): string { - if (!value?.trim()) throw new Error(`Missing ${label}.`); - return value; + if (!value?.trim()) throw new Error(`Missing ${label}.`); + return value; } export function id(value: string): number { - if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value))) - throw new Error(`Invalid ID: ${value}.`); - return Number(value); + if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value))) + throw new Error(`Invalid ID: ${value}.`); + return Number(value); } -export function ids(value: string | undefined): number[] { - const result = text(value, "--tabs").split(",").map(id); - unique(result, "tab IDs"); - return result; +export function tabId(value: string): number | string { + if (/^cdp:[a-zA-Z0-9_-]+$/.test(value)) return value; + return id(value); +} +export function ids(value: string | undefined): (number | string)[] { + const result = text(value, "--tabs").split(",").map(tabId); + unique(result, "tab IDs"); + return result; } export function color(value: string | undefined): Color | undefined { - if (value === undefined) return undefined; - if (!colors.includes(value as Color)) - throw new Error(`Unknown color: ${value}.`); - return value as Color; + if (value === undefined) return undefined; + if (!colors.includes(value as Color)) + throw new Error(`Unknown color: ${value}.`); + return value as Color; } export function navigationUrl(value: string): string { - const url = new URL(value); - if (["javascript:", "vbscript:", "data:"].includes(url.protocol)) - throw new Error("Script and data URLs are not supported."); - return value; + const url = new URL(value); + if (["javascript:", "vbscript:", "data:"].includes(url.protocol)) + throw new Error("Script and data URLs are not supported."); + return value; } diff --git a/src/transport/cdp.ts b/src/transport/cdp.ts index 1e61019..aac9294 100644 --- a/src/transport/cdp.ts +++ b/src/transport/cdp.ts @@ -52,10 +52,18 @@ export class CdpClient { static async connect(endpoint: string, connectTimeoutMs = 60_000) { let url = localEndpoint(endpoint); if (url.protocol === "http:") { - const response = await fetch(new URL("/json/version", url), { - redirect: "error", - signal: AbortSignal.timeout(3000), - }); + let response: Response; + try { + response = await fetch(new URL("/json/version", url), { + redirect: "error", + signal: AbortSignal.timeout(3000), + }); + } catch (error) { + throw new Error( + `Cannot reach CDP at ${url.origin}. Enable remote debugging in the intended browser and check the endpoint. --cdp selects an existing endpoint; it does not enable debugging.`, + { cause: error }, + ); + } if (!response.ok) throw new Error(`CDP discovery returned HTTP ${response.status}.`); const info = (await response.json()) as { webSocketDebuggerUrl?: string }; diff --git a/tests/chromium-basic.test.ts b/tests/chromium-basic.test.ts new file mode 100644 index 0000000..0b88dd6 --- /dev/null +++ b/tests/chromium-basic.test.ts @@ -0,0 +1,371 @@ +import assert from "node:assert/strict"; +import { once } from "node:events"; +import test from "node:test"; +import { WebSocketServer } from "ws"; +import { connect } from "../src/adapters/connect.js"; +import { parse } from "../src/cli/parse.js"; +import { run } from "../src/cli/run.js"; + +async function fixture() { + const targets = [ + { + targetId: "FIRST", + type: "page", + windowId: 1, + url: "https://example.test/first", + title: "First", + }, + { + targetId: "SECOND", + type: "page", + windowId: 1, + url: "https://example.test/second", + title: "Second", + }, + { + targetId: "WORKER", + type: "service_worker", + windowId: 1, + url: "https://example.test/worker", + title: "Worker", + }, + { + targetId: "TOOLS", + type: "page", + windowId: 1, + url: "devtools://devtools/bundled/inspector.html", + title: "Tools", + }, + ]; + const calls: { method: string; params: Record }[] = []; + const options = { + createWindow: 1, + createError: "", + movedOnClose: false, + failClose: false, + }; + const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + await once(wss, "listening"); + const address = wss.address(); + assert.ok(address && typeof address !== "string"); + wss.on("connection", (socket) => + socket.on("message", (raw) => { + const { id, method, params } = JSON.parse(String(raw)); + calls.push({ method, params }); + let result: unknown = {}; + if (method === "Target.getTargets") result = { targetInfos: targets }; + if (method === "Browser.getWindowForTarget") { + const target = targets.find( + (candidate) => candidate.targetId === params.targetId, + ); + if (!target) { + socket.send( + JSON.stringify({ id, error: { message: "Missing target" } }), + ); + return; + } + const checks = calls.filter( + (call) => + call.method === method && call.params.targetId === params.targetId, + ).length; + result = { + windowId: options.movedOnClose && checks > 1 ? 2 : target.windowId, + }; + } + if (method === "Target.createTarget") { + if (options.createError) { + socket.send( + JSON.stringify({ id, error: { message: options.createError } }), + ); + return; + } + targets.push({ + targetId: "CREATED", + type: "page", + windowId: options.createWindow, + url: params.url, + title: "", + }); + result = { targetId: "CREATED" }; + } + if (method === "Target.closeTarget") { + if (!options.failClose) + targets.splice( + targets.findIndex((target) => target.targetId === params.targetId), + 1, + ); + result = { success: !options.failClose }; + } + socket.send(JSON.stringify({ id, result })); + }), + ); + return { + targets, + calls, + options, + endpoint: `ws://127.0.0.1:${address.port}`, + async close() { + for (const socket of wss.clients) socket.terminate(); + await new Promise((resolve) => wss.close(() => resolve())); + }, + }; +} +test("basic CLI connects without extensions, opens and closes stable CDP IDs across connections", async () => { + const f = await fixture(); + const dispatch = (browser: string) => connect(browser, f.endpoint); + try { + const doctor = await run(["--browser", "helium", "doctor"], dispatch); + assert.deepEqual(doctor.data, { + connected: true, + adapter: "helium", + windows: [{ id: 1 }], + capabilities: { + mode: "basic-cdp", + groups: false, + batches: false, + tabNames: false, + }, + }); + const listed = await run(["--browser", "helium", "tab", "list"], dispatch); + assert.deepEqual(listed.data, [ + { + id: "cdp:FIRST", + windowId: 1, + url: "https://example.test/first", + title: "First", + }, + { + id: "cdp:SECOND", + windowId: 1, + url: "https://example.test/second", + title: "Second", + }, + ]); + const opened = await run( + ["--browser", "helium", "tab", "open", "https://example.test/new"], + dispatch, + ); + assert.deepEqual(opened.data, { + id: "cdp:CREATED", + windowId: 1, + url: "https://example.test/new", + title: "", + }); + assert.deepEqual( + f.calls.find((call) => call.method === "Target.createTarget")?.params, + { url: "https://example.test/new", background: true, newWindow: false }, + ); + await run( + ["--browser", "helium", "tab", "close", "--tabs", "cdp:CREATED"], + dispatch, + ); + assert.equal( + f.targets.some((target) => target.targetId === "CREATED"), + false, + ); + assert.equal( + f.targets.some((target) => target.targetId === "FIRST"), + true, + ); + assert.equal( + f.calls.some((call) => call.method === "Runtime.evaluate"), + false, + ); + } finally { + await f.close(); + } +}); +test("basic operations reject unsupported commands and names before mutations", async () => { + const f = await fixture(); + const connection = await connect("helium", f.endpoint); + try { + for (const command of [ + { type: "groups" as const }, + { type: "group.create" as const, title: "Group", tabIds: ["cdp:FIRST"] }, + { type: "batch.close" as const, batchId: "owned" }, + { + type: "batch.open" as const, + plan: { + version: 1 as const, + batchId: "owned", + tabs: [{ key: "one", url: "https://example.test" }], + groups: [], + }, + }, + ]) + await assert.rejects( + connection.execute(command), + /requires the Browser Tabs CLI extension/, + ); + await assert.rejects( + connection.execute({ + type: "tab.open", + url: "https://example.test", + name: "Name", + }), + /native custom tab names/, + ); + await assert.rejects( + connection.execute({ + type: "tab.rename", + tabId: "cdp:FIRST", + name: "Name", + }), + /native custom tab names/, + ); + assert.deepEqual( + f.calls.map((call) => call.method), + ["Target.getTargets"], + ); + } finally { + connection.close(); + await f.close(); + } +}); +test("multiple windows require explicit selection and closing checks all IDs before mutation", async () => { + const f = await fixture(); + f.targets[1]!.windowId = 2; + const connection = await connect("helium", f.endpoint); + try { + assert.deepEqual(await connection.execute({ type: "windows" }), [ + { id: 1 }, + { id: 2 }, + ]); + await assert.rejects(connection.execute({ type: "tabs" }), /Multiple/); + const selected = (await connection.execute({ + type: "tabs", + windowId: 2, + })) as { id: string }[]; + assert.deepEqual( + selected.map((tab) => tab.id), + ["cdp:SECOND"], + ); + await assert.rejects( + connection.execute({ type: "tabs", windowId: 99 }), + /not open/, + ); + await assert.rejects( + connection.execute({ + type: "tab.close", + windowId: 1, + tabIds: ["cdp:FIRST", "cdp:SECOND"], + }), + /another window/, + ); + await assert.rejects( + connection.execute({ + type: "tab.close", + windowId: 1, + tabIds: ["cdp:FIRST", "cdp:MISSING"], + }), + /not open/, + ); + await assert.rejects( + connection.execute({ + type: "tab.close", + windowId: 1, + tabIds: ["cdp:FIRST", "cdp:FIRST"], + }), + /distinct/, + ); + await assert.rejects( + connection.execute({ + type: "tab.open", + windowId: 1, + url: "https://example.test", + }), + /multiple windows requires/, + ); + assert.equal( + f.calls.some((call) => + ["Target.closeTarget", "Target.createTarget"].includes(call.method), + ), + false, + ); + } finally { + connection.close(); + await f.close(); + } +}); +test("a misplaced created tab is closed without touching pre-existing tabs", async () => { + const f = await fixture(); + f.options.createWindow = 2; + const connection = await connect("helium", f.endpoint); + try { + await assert.rejects( + connection.execute({ type: "tab.open", url: "https://example.test" }), + /another window; it was closed/, + ); + assert.equal( + f.calls.some((call) => call.method === "Page.navigate"), + false, + ); + assert.equal( + f.targets.some((target) => target.targetId === "CREATED"), + false, + ); + assert.equal(f.targets[0]!.targetId, "FIRST"); + } finally { + connection.close(); + await f.close(); + } +}); +test("target creation failure is reported without leaving a new tab", async () => { + const f = await fixture(); + f.options.createError = "Cannot create target"; + const connection = await connect("helium", f.endpoint); + try { + await assert.rejects( + connection.execute({ type: "tab.open", url: "https://example.test" }), + /Cannot create target/, + ); + assert.equal( + f.targets.some((target) => target.targetId === "CREATED"), + false, + ); + } finally { + connection.close(); + await f.close(); + } +}); +test("closing a tab moved to another window fails without closing it", async () => { + const f = await fixture(); + f.options.movedOnClose = true; + const connection = await connect("helium", f.endpoint); + try { + await assert.rejects( + connection.execute({ type: "tab.close", tabIds: ["cdp:FIRST"] }), + /another window/, + ); + assert.equal( + f.calls.some((call) => call.method === "Target.closeTarget"), + false, + ); + } finally { + connection.close(); + await f.close(); + } +}); +test("CDP close failure is propagated", async () => { + const f = await fixture(); + f.options.failClose = true; + const connection = await connect("helium", f.endpoint); + try { + await assert.rejects( + connection.execute({ type: "tab.close", tabIds: ["cdp:FIRST"] }), + /did not close/, + ); + } finally { + connection.close(); + await f.close(); + } +}); +test("CLI rejects malformed or repeated CDP tab IDs", () => { + for (const ids of [ + "cdp:", + "cdp:FIRST,cdp:FIRST", + "cdp:FIRST bad", + "not-an-id", + ]) + assert.throws(() => parse(["tab", "close", "--tabs", ids])); +}); diff --git a/tests/chromium.test.ts b/tests/chromium.test.ts new file mode 100644 index 0000000..3316378 --- /dev/null +++ b/tests/chromium.test.ts @@ -0,0 +1,397 @@ +import assert from "node:assert/strict"; +import { once } from "node:events"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; +import { WebSocketServer } from "ws"; +import { ChromiumBrowser } from "../src/adapters/chromium/browser.js"; +import { connectChromium } from "../src/adapters/chromium/connection.js"; +import { + chromiumBrowsers, + dataDirectory, +} from "../src/adapters/chromium/discovery.js"; +import type { + NativeApi, + NativeGroup, + NativeTab, +} from "../src/adapters/chromium/native-api.js"; +import { connect } from "../src/adapters/connect.js"; +import { execute } from "../src/application/execute.js"; +import { parse } from "../src/cli/parse.js"; +import type { Ownership } from "../src/domain/browser.js"; + +function fixture() { + const tabs: NativeTab[] = [ + { + id: 1, + windowId: 1, + index: 0, + url: "https://existing.test", + active: true, + pinned: false, + status: "complete", + groupId: -1, + }, + { id: 2, windowId: 2, index: 0, groupId: -1 }, + ]; + const groups: NativeGroup[] = []; + const stored: Record = {}; + let nextId = 3; + const api: NativeApi = { + windows: { getAll: async () => [{ id: 1, focused: true }, { id: 2 }] }, + tabs: { + query: async ({ windowId }) => + tabs + .filter((tab) => tab.windowId === windowId) + .map((tab) => ({ ...tab })), + get: async (id) => { + const tab = tabs.find((candidate) => candidate.id === id); + if (!tab) throw new Error("Missing tab"); + return { ...tab }; + }, + create: async (options) => { + const tab = { + ...options, + id: nextId++, + groupId: -1, + status: "complete", + }; + tabs.push(tab); + return { ...tab }; + }, + remove: async (ids) => { + for (const id of ids) + tabs.splice( + tabs.findIndex((tab) => tab.id === id), + 1, + ); + }, + group: async ({ tabIds, groupId, createProperties }) => { + const id = groupId ?? groups.length + 10; + if (groupId === undefined) + groups.push({ + id, + windowId: createProperties!.windowId, + title: "", + color: "grey", + }); + for (const tab of tabs.filter((candidate) => + tabIds.includes(candidate.id), + )) + tab.groupId = id; + return id; + }, + }, + tabGroups: { + query: async ({ windowId }) => + groups.filter((group) => group.windowId === windowId), + get: async (id) => { + const group = groups.find((candidate) => candidate.id === id); + if (!group) throw new Error("Missing group"); + return { ...group }; + }, + update: async (id, changes) => { + const group = groups.find((candidate) => candidate.id === id)!; + Object.assign(group, changes); + return { ...group }; + }, + }, + storage: { + session: { + get: async (keys) => + Object.fromEntries( + keys.filter((key) => stored[key]).map((key) => [key, stored[key]!]), + ), + set: async (values) => { + Object.assign(stored, values); + }, + remove: async (keys) => { + for (const key of keys) delete stored[key]; + }, + }, + }, + }; + return { api, tabs, groups, stored, browser: new ChromiumBrowser(api) }; +} +const plan = { + version: 1 as const, + batchId: "chromium-test", + windowId: 1, + tabs: [{ key: "one", url: "https://example.test/one", groupKey: "g" }], + groups: [{ key: "g", title: "Native group", color: "blue" as const }], +}; +test("Chromium batches preserve existing tabs, allow single-tab groups, reuse ownership and close only their tabs", async () => { + const f = fixture(); + const before = structuredClone(f.tabs); + const result = (await execute(f.browser, { + type: "batch.open", + plan, + verifyAfterMs: 0, + })) as { created: number; verified: boolean }; + assert.equal(result.created, 1); + assert.equal(result.verified, true); + assert.deepEqual(f.tabs.slice(0, 2), before); + const retry = (await execute(new ChromiumBrowser(f.api), { + type: "batch.open", + plan, + })) as { reused: number }; + assert.equal(retry.reused, 1); + assert.equal(f.groups.length, 1); + await execute(f.browser, { + type: "batch.close", + windowId: 1, + batchId: plan.batchId, + }); + assert.deepEqual(f.tabs, before); + assert.deepEqual(f.stored, {}); +}); +test("unsupported native names fail before tab creation", async () => { + const f = fixture(); + for (const command of [ + { + type: "tab.open" as const, + windowId: 1, + url: "https://example.test", + name: "Name", + }, + { type: "tab.rename" as const, windowId: 1, tabId: 1, name: "Name" }, + { + type: "batch.open" as const, + plan: { ...plan, tabs: [{ ...plan.tabs[0]!, name: "Name" }] }, + }, + ]) + await assert.rejects( + execute(f.browser, command), + /native custom tab names/, + ); + assert.equal(f.tabs.length, 2); +}); +test("Chromium native operations validate window membership and pinned tabs before mutations", async () => { + const f = fixture(); + await assert.rejects(f.browser.close(1, [2]), /another window/); + await assert.rejects(f.browser.createGroup(1, [1, 1], "Group"), /distinct/); + f.tabs[0]!.pinned = true; + await assert.rejects(f.browser.createGroup(1, [1], "Group"), /Unpin/); + f.tabs[0]!.pinned = false; + const group = await f.browser.createGroup(1, [1], "Group", "green"); + await assert.rejects( + f.browser.updateGroup(2, group.id, { title: "Wrong" }), + /selected window/, + ); + await assert.rejects( + f.browser.moveToGroup(1, [2], group.id), + /another window/, + ); + await assert.rejects(f.browser.updateGroup(1, "1e1", {}), /Invalid/); + const opened = await f.browser.open(1, "https://example.test"); + await f.browser.moveToGroup(1, [opened.id], group.id); + await f.browser.updateGroup(1, group.id, { + title: "Updated", + color: "orange", + }); + assert.equal( + (await f.browser.tabs(1)).find((tab) => tab.id === opened.id)?.group?.title, + "Updated", + ); + assert.equal(f.tabs[1]!.windowId, 2); +}); +test("browser selectors and discovery honor platform paths and overrides", () => { + for (const browser of chromiumBrowsers) { + const input = parse(["--browser", browser, "doctor"]); + assert.ok(input.kind === "execute" && input.browser === browser); + } + assert.equal( + dataDirectory("helium", "darwin", "/home/test", {}), + "/home/test/Library/Application Support/net.imput.helium", + ); + assert.equal( + dataDirectory("brave", "linux", "/home/test", { + XDG_CONFIG_HOME: "/custom", + }), + "/custom/BraveSoftware/Brave-Browser", + ); + assert.equal( + dataDirectory("chrome", "win32", "/home/test", { LOCALAPPDATA: "/local" }), + "/local/Google/Chrome/User Data", + ); + assert.equal( + dataDirectory("helium", "darwin", "/home", { + BROWSER_TABS_DATA_DIR: "/generic", + BROWSER_TABS_HELIUM_DATA_DIR: "/specific", + }), + "/specific", + ); +}); +for (const installedRuntime of ["missing", "preloaded", "stale"]) + test(`Chromium injects the CLI runtime when the extension script is ${installedRuntime}`, async () => { + const f = fixture(); + const context = vm.createContext({ + chrome: { + ...f.api, + runtime: { getManifest: () => ({ name: "Browser Tabs CLI" }) }, + }, + setTimeout, + }); + if (installedRuntime === "stale") + vm.runInContext( + 'globalThis.BrowserTabsRuntime = { run() { throw new Error("Stale extension runtime"); } };', + context, + ); + if (installedRuntime === "preloaded") + vm.runInContext( + readFileSync( + new URL("../dist/chromium-extension/runtime.js", import.meta.url), + "utf8", + ), + context, + ); + const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + await once(wss, "listening"); + const address = wss.address(); + assert.ok(address && typeof address !== "string"); + const attached: string[] = []; + wss.on("connection", (socket) => + socket.on("message", async (raw) => { + const { id, method, params } = JSON.parse(String(raw)); + let result: unknown = {}; + if (method === "Target.getTargets") + result = { + targetInfos: [ + { + targetId: "website", + type: "page", + url: "https://example.test/browser-tabs.html", + }, + { + targetId: "extension", + type: "page", + url: `chrome-extension://${"a".repeat(32)}/browser-tabs.html`, + }, + ], + }; + if (method === "Target.attachToTarget") { + attached.push(params.targetId); + result = { sessionId: "extension" }; + } + if (method === "Runtime.evaluate") { + try { + result = { + result: { + value: await vm.runInContext(params.expression, context), + }, + }; + } catch (error) { + result = { result: {}, exceptionDetails: { text: String(error) } }; + } + } + socket.send(JSON.stringify({ id, result })); + }), + ); + try { + const connection = await connectChromium( + "helium", + `ws://127.0.0.1:${address.port}`, + new URL("../dist/chromium-extension/runtime.js", import.meta.url), + ); + try { + assert.deepEqual(connection.capabilities, { + mode: "chromium-extension", + groups: true, + batches: true, + tabNames: false, + }); + const result = (await connection.execute({ + type: "batch.open", + plan, + verifyAfterMs: 0, + })) as { verified: boolean }; + assert.equal(result.verified, true); + await assert.rejects( + connection.execute({ + type: "tab.open", + windowId: 1, + url: "https://example.test", + name: "No", + }), + /native custom tab names/, + ); + assert.deepEqual(attached, ["extension"]); + } finally { + connection.close(); + } + } finally { + for (const client of wss.clients) client.terminate(); + await new Promise((resolve) => wss.close(() => resolve())); + } + }); +test("unsupported browser fails with available adapters", () => { + assert.throws(() => connect("firefox", "9222"), /helium, chromium/); +}); +test("commands requiring an extension fail without evaluating website JavaScript", async () => { + const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + await once(wss, "listening"); + const address = wss.address(); + assert.ok(address && typeof address !== "string"); + const methods: string[] = []; + wss.on("connection", (socket) => + socket.on("message", (raw) => { + const { id, method } = JSON.parse(String(raw)); + methods.push(method); + socket.send( + JSON.stringify({ + id, + result: { + targetInfos: [ + { + targetId: "website", + type: "page", + url: "https://example.test/", + }, + ], + }, + }), + ); + }), + ); + try { + const connection = await connect( + "helium", + `ws://127.0.0.1:${address.port}`, + ); + try { + await assert.rejects( + connection.execute({ type: "groups" }), + /requires the Browser Tabs CLI extension/, + ); + } finally { + connection.close(); + } + assert.deepEqual(methods, ["Target.getTargets"]); + } finally { + for (const client of wss.clients) client.terminate(); + await new Promise((resolve) => wss.close(() => resolve())); + } +}); +test("missing automatic endpoint includes the data directory and setup instructions", async (context) => { + const { mkdtemp, rm } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { discoverChromiumEndpoint } = await import( + "../src/adapters/chromium/discovery.js" + ); + const directory = await mkdtemp(join(tmpdir(), "helium-discovery-")); + const previous = process.env.BROWSER_TABS_HELIUM_DATA_DIR; + process.env.BROWSER_TABS_HELIUM_DATA_DIR = directory; + context.after(async () => { + if (previous === undefined) delete process.env.BROWSER_TABS_HELIUM_DATA_DIR; + else process.env.BROWSER_TABS_HELIUM_DATA_DIR = previous; + await rm(directory, { recursive: true }); + }); + await assert.rejects(discoverChromiumEndpoint("helium"), (error: unknown) => { + assert.ok(error instanceof Error); + assert.ok(error.message.includes(directory)); + assert.match(error.message, /chrome:\/\/inspect\/#remote-debugging/); + assert.match(error.message, /retry without --cdp/); + return true; + }); +}); diff --git a/tests/connection.test.ts b/tests/connection.test.ts index a78fc3d..fd83553 100644 --- a/tests/connection.test.ts +++ b/tests/connection.test.ts @@ -1,207 +1,223 @@ import assert from "node:assert/strict"; import { once } from "node:events"; -import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import vm from "node:vm"; -import { WebSocketServer, type WebSocket } from "ws"; +import { type WebSocket, WebSocketServer } from "ws"; import { connectVivaldi } from "../src/adapters/vivaldi/connection.js"; import { discoverEndpoint } from "../src/adapters/vivaldi/discovery.js"; import { CdpClient, localEndpoint } from "../src/transport/cdp.js"; import { FakeNative, plan } from "./support/native.js"; async function server() { - const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 }); - await once(wss, "listening"); - const address = wss.address(); - if (typeof address === "string" || !address) - throw new Error("Missing address"); - return { - wss, - endpoint: `ws://127.0.0.1:${address.port}`, - close: async () => { - for (const client of wss.clients) client.terminate(); - await new Promise((resolve) => wss.close(() => resolve())); - }, - }; + const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + await once(wss, "listening"); + const address = wss.address(); + if (typeof address === "string" || !address) + throw new Error("Missing address"); + return { + wss, + endpoint: `ws://127.0.0.1:${address.port}`, + close: async () => { + for (const client of wss.clients) client.terminate(); + await new Promise((resolve) => wss.close(() => resolve())); + }, + }; } test("the compiled runtime applies and verifies a batch with one execution over CDP", async () => { - const fixture = await server(); - const api = new FakeNative(); - const requests: { method: string; params: Record }[] = []; - const context = vm.createContext({ - chrome: api, - vivaldi: { tabsPrivate: api.stacks }, - performance, - setTimeout, - }); - fixture.wss.on("connection", (socket) => - socket.on("message", async (raw) => { - const request = JSON.parse(String(raw)); - requests.push(request); - const { id, method, params } = request; - let result: unknown = {}; - if (method === "Target.getTargets") - result = { - targetInfos: [ - { targetId: "website", type: "page", url: "https://example.com/" }, - { - targetId: "unsupported", - type: "other", - url: "chrome-extension://mpognobbkildjkofajifpdfhcoklimli/browser.html", - }, - { - targetId: "ui", - type: "other", - url: "chrome-extension://mpognobbkildjkofajifpdfhcoklimli/browser.html", - }, - ], - }; - if (method === "Target.attachToTarget") - result = { sessionId: params.targetId }; - if (method === "Runtime.evaluate") { - if (request.sessionId === "unsupported") { - socket.send( - JSON.stringify({ id, error: { message: "Runtime unavailable" } }), - ); - return; - } - try { - result = { - result: { - value: await vm.runInContext(params.expression, context), - }, - }; - } catch (error) { - result = { result: {}, exceptionDetails: { text: String(error) } }; - } - } - socket.send(JSON.stringify({ id, result })); - }), - ); - try { - const connection = await connectVivaldi( - fixture.endpoint, - new URL("../dist/vivaldi-runtime.js", import.meta.url), - ); - try { - const result = (await connection.execute({ - type: "batch.open", - plan, - verifyAfterMs: 0, - })) as { verified: boolean; created: number }; - assert.equal(result.verified, true); - assert.equal(result.created, 3); - assert.equal( - requests.filter((request) => request.params.awaitPromise).length, - 1, - ); - const reused = (await connection.execute({ - type: "batch.open", - plan, - })) as { - verified: null; - ready: null; - reused: number; - }; - assert.equal(reused.verified, null); - assert.equal(reused.ready, null); - assert.equal(reused.reused, 3); - assert.equal( - requests.some((request) => request.params.targetId === "website"), - false, - ); - assert.ok( - requests.some( - (request) => request.method === "Target.detachFromTarget", - ), - ); - await assert.rejects( - connection.execute({ type: "tabs", windowId: 999 }), - /window/i, - ); - } finally { - connection.close(); - } - } finally { - await fixture.close(); - } + const fixture = await server(); + const api = new FakeNative(); + const requests: { method: string; params: Record }[] = []; + const context = vm.createContext({ + chrome: api, + vivaldi: { tabsPrivate: api.stacks }, + performance, + setTimeout, + }); + fixture.wss.on("connection", (socket) => + socket.on("message", async (raw) => { + const request = JSON.parse(String(raw)); + requests.push(request); + const { id, method, params } = request; + let result: unknown = {}; + if (method === "Target.getTargets") + result = { + targetInfos: [ + { targetId: "website", type: "page", url: "https://example.com/" }, + { + targetId: "unsupported", + type: "other", + url: "chrome-extension://mpognobbkildjkofajifpdfhcoklimli/browser.html", + }, + { + targetId: "ui", + type: "other", + url: "chrome-extension://mpognobbkildjkofajifpdfhcoklimli/browser.html", + }, + ], + }; + if (method === "Target.attachToTarget") + result = { sessionId: params.targetId }; + if (method === "Runtime.evaluate") { + if (request.sessionId === "unsupported") { + socket.send( + JSON.stringify({ id, error: { message: "Runtime unavailable" } }), + ); + return; + } + try { + result = { + result: { + value: await vm.runInContext(params.expression, context), + }, + }; + } catch (error) { + result = { result: {}, exceptionDetails: { text: String(error) } }; + } + } + socket.send(JSON.stringify({ id, result })); + }), + ); + try { + const connection = await connectVivaldi( + fixture.endpoint, + new URL("../dist/vivaldi-runtime.js", import.meta.url), + ); + try { + const result = (await connection.execute({ + type: "batch.open", + plan, + verifyAfterMs: 0, + })) as { verified: boolean; created: number }; + assert.equal(result.verified, true); + assert.equal(result.created, 3); + assert.equal( + requests.filter((request) => request.params.awaitPromise).length, + 1, + ); + const reused = (await connection.execute({ + type: "batch.open", + plan, + })) as { + verified: null; + ready: null; + reused: number; + }; + assert.equal(reused.verified, null); + assert.equal(reused.ready, null); + assert.equal(reused.reused, 3); + assert.equal( + requests.some((request) => request.params.targetId === "website"), + false, + ); + assert.ok( + requests.some( + (request) => request.method === "Target.detachFromTarget", + ), + ); + await assert.rejects( + connection.execute({ type: "tabs", windowId: 999 }), + /window/i, + ); + } finally { + connection.close(); + } + } finally { + await fixture.close(); + } }); test("missing privileged UI fails without executing page JavaScript", async () => { - const fixture = await server(); - const calls: string[] = []; - fixture.wss.on("connection", (socket) => - socket.on("message", (raw) => { - const request = JSON.parse(String(raw)); - calls.push(request.method); - socket.send( - JSON.stringify({ - id: request.id, - result: { - targetInfos: [ - { targetId: "site", type: "page", url: "https://example.com/" }, - ], - }, - }), - ); - }), - ); - try { - await assert.rejects( - connectVivaldi( - fixture.endpoint, - new URL("../dist/vivaldi-runtime.js", import.meta.url), - ), - /privileged UI is unavailable/, - ); - assert.deepEqual(calls, ["Target.getTargets"]); - } finally { - await fixture.close(); - } + const fixture = await server(); + const calls: string[] = []; + fixture.wss.on("connection", (socket) => + socket.on("message", (raw) => { + const request = JSON.parse(String(raw)); + calls.push(request.method); + socket.send( + JSON.stringify({ + id: request.id, + result: { + targetInfos: [ + { targetId: "site", type: "page", url: "https://example.com/" }, + ], + }, + }), + ); + }), + ); + try { + await assert.rejects( + connectVivaldi( + fixture.endpoint, + new URL("../dist/vivaldi-runtime.js", import.meta.url), + ), + /privileged UI is unavailable/, + ); + assert.deepEqual(calls, ["Target.getTargets"]); + } finally { + await fixture.close(); + } }); test("CDP requests time out and reject promptly on disconnect", async () => { - const fixture = await server(); - let socket: WebSocket | undefined; - fixture.wss.on("connection", (client) => { - socket = client; - }); - const client = await CdpClient.connect(fixture.endpoint); - try { - await assert.rejects( - client.request("Unanswered", {}, undefined, 15), - /timed out/, - ); - const pending = client.request("Disconnected", {}, undefined, 1000); - socket!.terminate(); - await assert.rejects(pending, /disconnected|connection failed/); - } finally { - client.close(); - await fixture.close(); - } + const fixture = await server(); + let socket: WebSocket | undefined; + fixture.wss.on("connection", (client) => { + socket = client; + }); + const client = await CdpClient.connect(fixture.endpoint); + try { + await assert.rejects( + client.request("Unanswered", {}, undefined, 15), + /timed out/, + ); + const pending = client.request("Disconnected", {}, undefined, 1000); + socket!.terminate(); + await assert.rejects(pending, /disconnected|connection failed/); + } finally { + client.close(); + await fixture.close(); + } }); test("endpoint validation keeps browser control local", () => { - assert.equal(localEndpoint("9222").host, "127.0.0.1:9222"); - for (const endpoint of [ - "ws://example.com/", - "https://localhost/", - "ws://user:secret@localhost/", - "file:///tmp/socket", - ]) - assert.throws(() => localEndpoint(endpoint), /loopback/); + assert.equal(localEndpoint("9222").host, "127.0.0.1:9222"); + for (const endpoint of [ + "ws://example.com/", + "https://localhost/", + "ws://user:secret@localhost/", + "file:///tmp/socket", + ]) + assert.throws(() => localEndpoint(endpoint), /loopback/); }); test("automatic discovery validates DevToolsActivePort without modifying it", async () => { - const dir = await mkdtemp(join(tmpdir(), "browser-tabs-discovery-")); - try { - const file = join(dir, "DevToolsActivePort"); - await writeFile(file, "9222\n/devtools/browser/abc-123\n"); - assert.equal( - await discoverEndpoint(dir), - "ws://127.0.0.1:9222/devtools/browser/abc-123", - ); - await writeFile(file, "99999\n/devtools/browser/abc\n"); - await assert.rejects(discoverEndpoint(dir), /Invalid/); - } finally { - await rm(dir, { recursive: true }); - } + const dir = await mkdtemp(join(tmpdir(), "browser-tabs-discovery-")); + try { + const file = join(dir, "DevToolsActivePort"); + await writeFile(file, "9222\n/devtools/browser/abc-123\n"); + assert.equal( + await discoverEndpoint(dir), + "ws://127.0.0.1:9222/devtools/browser/abc-123", + ); + await writeFile(file, "99999\n/devtools/browser/abc\n"); + await assert.rejects(discoverEndpoint(dir), /Invalid/); + } finally { + await rm(dir, { recursive: true }); + } +}); +test("unreachable HTTP endpoints explain that --cdp does not enable debugging", async (context) => { + const cause = new TypeError("fetch failed"); + context.mock.method(globalThis, "fetch", async () => { + throw cause; + }); + await assert.rejects(CdpClient.connect("9222"), (error: unknown) => { + assert.ok(error instanceof Error); + assert.match( + error.message, + /Cannot reach CDP at http:\/\/127\.0\.0\.1:9222/, + ); + assert.match(error.message, /does not enable debugging/); + assert.equal(error.cause, cause); + return true; + }); });