fix(extension): handle browser error pages gracefully
Testing / test (push) Successful in 37s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 39s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 24s

- Treat chrome error page script failures as transient during injection retries.
- Return safe fallback values for read-only DOM commands when tabs land on browser error pages.
- Improve URL watch handling by checking pending URLs and reporting last seen URL/status on timeout.
- Bump package and extension version to 0.9.6 and add regression coverage for error-page behavior.
This commit is contained in:
2026-05-14 13:39:09 +02:00
parent a8b433aa29
commit f79ff0e3c2
7 changed files with 150 additions and 52 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "browser-cli",
"version": "0.9.5",
"version": "0.9.6",
"description": "Control your browser from the terminal via browser-cli",
"permissions": [
"tabs",
+59 -12
View File
@@ -1,23 +1,60 @@
// @ts-nocheck
import { executeScript, getActiveTab, isScriptableUrl } from '../core';
import { executeScript, getActiveTab, isErrorPageScriptError, isScriptableUrl } from '../core';
import { contentDispatch } from './injected';
export async function domOp(funcName, args) {
const tab = await getActiveTab();
if (!isScriptableUrl(tab.url)) {
throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`);
function fallbackForErrorPageDomOp(funcName, tab) {
switch (funcName) {
case "domExists":
return false;
case "domQuery":
case "domAttr":
case "domText":
case "extractLinks":
case "extractImages":
return [];
case "extractText":
case "extractMarkdown":
return "";
case "pageInfo":
return {
title: tab.title || "",
url: tab.url || tab.pendingUrl || "",
readyState: "error",
lang: null,
meta: {},
};
default:
return undefined;
}
}
export async function domOp(funcName, args = {}) {
const tab = args?.tabId ? await chrome.tabs.get(args.tabId) : await getActiveTab();
const tabUrl = tab.url || tab.pendingUrl || "";
if (!isScriptableUrl(tabUrl)) {
throw new Error(`Cannot run DOM commands on ${tabUrl} — navigate to a regular web page first`);
}
try {
const results = await executeScript({
target: { tabId: tab.id },
func: contentDispatch,
args: [funcName, args],
});
return results[0]?.result;
} catch (e) {
if (isErrorPageScriptError(e)) {
const fallback = fallbackForErrorPageDomOp(funcName, tab);
if (fallback !== undefined) return fallback;
}
throw e;
}
}
export async function domEval({ code, tabId } = {}) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
if (!isScriptableUrl(tab.url)) {
throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`);
const tabUrl = tab.url || tab.pendingUrl || "";
if (!isScriptableUrl(tabUrl)) {
throw new Error(`Cannot run DOM commands on ${tabUrl} — navigate to a regular web page first`);
}
const results = await executeScript({
target: { tabId: tab.id },
@@ -30,11 +67,13 @@ export async function domEval({ code, tabId } = {}) {
export async function domWaitFor({ selector, timeout = 10000, visible = false, hidden = false, tabId } = {}) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
if (!isScriptableUrl(tab.url)) {
throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`);
const tabUrl = tab.url || tab.pendingUrl || "";
if (!isScriptableUrl(tabUrl)) {
throw new Error(`Cannot run DOM commands on ${tabUrl} — navigate to a regular web page first`);
}
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
try {
const results = await executeScript({
target: { tabId: tab.id },
func: (sel, vis, hid) => {
@@ -50,6 +89,10 @@ export async function domWaitFor({ selector, timeout = 10000, visible = false, h
args: [selector, visible, hidden],
});
if (results[0]?.result) return { selector, found: !hidden };
} catch (e) {
if (hidden && isErrorPageScriptError(e)) return { selector, found: false };
if (!isErrorPageScriptError(e)) throw e;
}
await new Promise(r => setTimeout(r, 200));
}
throw new Error(`Selector '${selector}' condition not met within ${timeout}ms`);
@@ -57,12 +100,14 @@ export async function domWaitFor({ selector, timeout = 10000, visible = false, h
export async function domPoll({ selector, pattern, attr, timeout = 30000, interval = 500, tabId } = {}) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
if (!isScriptableUrl(tab.url)) {
throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`);
const tabUrl = tab.url || tab.pendingUrl || "";
if (!isScriptableUrl(tabUrl)) {
throw new Error(`Cannot run DOM commands on ${tabUrl} — navigate to a regular web page first`);
}
const deadline = Date.now() + timeout;
const regex = new RegExp(pattern);
while (Date.now() < deadline) {
try {
const results = await executeScript({
target: { tabId: tab.id },
func: (sel, a) => {
@@ -75,8 +120,10 @@ export async function domPoll({ selector, pattern, attr, timeout = 30000, interv
});
const value = results[0]?.result;
if (value != null && regex.test(String(value))) return { selector, value, pattern };
} catch (e) {
if (!isErrorPageScriptError(e)) throw e;
}
await new Promise(r => setTimeout(r, interval));
}
throw new Error(`Selector '${selector}' did not match '${pattern}' within ${timeout}ms`);
}
+16 -3
View File
@@ -191,13 +191,26 @@ export async function tabsWatchUrl({ pattern, timeout = 30000, tabId } = {}) {
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
const deadline = Date.now() + timeout;
const regex = new RegExp(pattern);
let lastUrl = tab.url || tab.pendingUrl || "";
let lastStatus = tab.status || "unknown";
const matches = (url) => {
regex.lastIndex = 0;
return Boolean(url && regex.test(url));
};
if (matches(lastUrl)) return tabInfo(tab);
while (Date.now() < deadline) {
const t = await chrome.tabs.get(tab.id);
const url = t.url || t.pendingUrl || "";
if (regex.test(url)) return tabInfo(t);
lastUrl = t.url || t.pendingUrl || "";
lastStatus = t.status || "unknown";
if (matches(t.pendingUrl || "") || matches(t.url || "")) return tabInfo(t);
if ((t.url || "").startsWith("chrome-error://")) {
throw new Error(`Tab ${tab.id} is showing an error page while waiting for URL to match '${pattern}'`);
}
await new Promise(r => setTimeout(r, 200));
}
throw new Error(`Tab ${tab.id} URL did not match '${pattern}' within ${timeout}ms`);
throw new Error(`Tab ${tab.id} URL did not match '${pattern}' within ${timeout}ms (last URL: '${lastUrl}', status: '${lastStatus}')`);
}
export async function tabsMute({ tabId }) {
+11 -1
View File
@@ -5,12 +5,22 @@ export async function getProfileAlias() {
return profileAlias || "default";
}
export function isErrorPageScriptError(error) {
const message = String(error?.message || error || "");
return message.includes("error page") || message.includes("chrome-error://chromewebdata");
}
export function isTransientScriptError(error) {
const message = String(error?.message || error || "");
return message.includes("Frame with ID") || message.includes("No tab with id") || isErrorPageScriptError(error);
}
export async function executeScript(options, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
return await chrome.scripting.executeScript(options);
} catch (e) {
if (i < retries - 1 && e.message && (e.message.includes("Frame with ID") || e.message.includes("No tab with id")) && !e.message.includes("error page")) {
if (i < retries - 1 && isTransientScriptError(e)) {
await new Promise(r => setTimeout(r, 300));
continue;
}
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "browser-cli"
version = "0.9.5"
version = "0.9.6"
description = "Control your real running browser from the terminal via a browser extension"
requires-python = ">=3.10"
dependencies = [
@@ -0,0 +1,28 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def test_extension_retries_error_page_script_injection_before_failing():
core = (ROOT / "extension" / "src" / "core.ts").read_text()
assert "isErrorPageScriptError" in core
assert "chrome-error://chromewebdata" in core
assert "isTransientScriptError(e)" in core
def test_read_only_dom_commands_have_error_page_fallbacks():
dom = (ROOT / "extension" / "src" / "commands" / "dom.ts").read_text()
assert "fallbackForErrorPageDomOp" in dom
assert 'case "domExists":' in dom
assert "return false;" in dom
assert 'case "domQuery":' in dom
assert 'case "extractText":' in dom
assert "isErrorPageScriptError(e)" in dom
def test_tabs_watch_url_reports_last_seen_url_on_timeout():
tabs = (ROOT / "extension" / "src" / "commands" / "tabs.ts").read_text()
assert "lastUrl" in tabs
assert "lastStatus" in tabs
assert "showing an error page" in tabs
assert "last URL:" in tabs
Generated
+1 -1
View File
@@ -4,7 +4,7 @@ requires-python = ">=3.10"
[[package]]
name = "browser-cli"
version = "0.9.5"
version = "0.9.6"
source = { editable = "." }
dependencies = [
{ name = "click" },