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;
+ });
});