fix(extension): handle browser error pages gracefully
- 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:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "browser-cli",
|
"name": "browser-cli",
|
||||||
"version": "0.9.5",
|
"version": "0.9.6",
|
||||||
"description": "Control your browser from the terminal via browser-cli",
|
"description": "Control your browser from the terminal via browser-cli",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"tabs",
|
"tabs",
|
||||||
|
|||||||
@@ -1,23 +1,60 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { executeScript, getActiveTab, isScriptableUrl } from '../core';
|
import { executeScript, getActiveTab, isErrorPageScriptError, isScriptableUrl } from '../core';
|
||||||
import { contentDispatch } from './injected';
|
import { contentDispatch } from './injected';
|
||||||
export async function domOp(funcName, args) {
|
|
||||||
const tab = await getActiveTab();
|
function fallbackForErrorPageDomOp(funcName, tab) {
|
||||||
if (!isScriptableUrl(tab.url)) {
|
switch (funcName) {
|
||||||
throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`);
|
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;
|
||||||
}
|
}
|
||||||
const results = await executeScript({
|
|
||||||
target: { tabId: tab.id },
|
|
||||||
func: contentDispatch,
|
|
||||||
args: [funcName, args],
|
|
||||||
});
|
|
||||||
return results[0]?.result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function domEval({ code, tabId } = {}) {
|
export async function domEval({ code, tabId } = {}) {
|
||||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||||
if (!isScriptableUrl(tab.url)) {
|
const tabUrl = tab.url || tab.pendingUrl || "";
|
||||||
throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`);
|
if (!isScriptableUrl(tabUrl)) {
|
||||||
|
throw new Error(`Cannot run DOM commands on ${tabUrl} — navigate to a regular web page first`);
|
||||||
}
|
}
|
||||||
const results = await executeScript({
|
const results = await executeScript({
|
||||||
target: { tabId: tab.id },
|
target: { tabId: tab.id },
|
||||||
@@ -30,26 +67,32 @@ export async function domEval({ code, tabId } = {}) {
|
|||||||
|
|
||||||
export async function domWaitFor({ selector, timeout = 10000, visible = false, hidden = false, tabId } = {}) {
|
export async function domWaitFor({ selector, timeout = 10000, visible = false, hidden = false, tabId } = {}) {
|
||||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||||
if (!isScriptableUrl(tab.url)) {
|
const tabUrl = tab.url || tab.pendingUrl || "";
|
||||||
throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`);
|
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 deadline = Date.now() + timeout;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
const results = await executeScript({
|
try {
|
||||||
target: { tabId: tab.id },
|
const results = await executeScript({
|
||||||
func: (sel, vis, hid) => {
|
target: { tabId: tab.id },
|
||||||
const el = document.querySelector(sel);
|
func: (sel, vis, hid) => {
|
||||||
if (hid) return !el || el.offsetParent === null;
|
const el = document.querySelector(sel);
|
||||||
if (!el) return false;
|
if (hid) return !el || el.offsetParent === null;
|
||||||
if (vis) {
|
if (!el) return false;
|
||||||
const r = el.getBoundingClientRect();
|
if (vis) {
|
||||||
return r.width > 0 && r.height > 0;
|
const r = el.getBoundingClientRect();
|
||||||
}
|
return r.width > 0 && r.height > 0;
|
||||||
return true;
|
}
|
||||||
},
|
return true;
|
||||||
args: [selector, visible, hidden],
|
},
|
||||||
});
|
args: [selector, visible, hidden],
|
||||||
if (results[0]?.result) return { selector, found: !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));
|
await new Promise(r => setTimeout(r, 200));
|
||||||
}
|
}
|
||||||
throw new Error(`Selector '${selector}' condition not met within ${timeout}ms`);
|
throw new Error(`Selector '${selector}' condition not met within ${timeout}ms`);
|
||||||
@@ -57,26 +100,30 @@ export async function domWaitFor({ selector, timeout = 10000, visible = false, h
|
|||||||
|
|
||||||
export async function domPoll({ selector, pattern, attr, timeout = 30000, interval = 500, tabId } = {}) {
|
export async function domPoll({ selector, pattern, attr, timeout = 30000, interval = 500, tabId } = {}) {
|
||||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||||
if (!isScriptableUrl(tab.url)) {
|
const tabUrl = tab.url || tab.pendingUrl || "";
|
||||||
throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`);
|
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 deadline = Date.now() + timeout;
|
||||||
const regex = new RegExp(pattern);
|
const regex = new RegExp(pattern);
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
const results = await executeScript({
|
try {
|
||||||
target: { tabId: tab.id },
|
const results = await executeScript({
|
||||||
func: (sel, a) => {
|
target: { tabId: tab.id },
|
||||||
const el = document.querySelector(sel);
|
func: (sel, a) => {
|
||||||
if (!el) return null;
|
const el = document.querySelector(sel);
|
||||||
if (a) return el.getAttribute(a) ?? el[a] ?? null;
|
if (!el) return null;
|
||||||
return el.value !== undefined ? el.value : el.textContent.trim();
|
if (a) return el.getAttribute(a) ?? el[a] ?? null;
|
||||||
},
|
return el.value !== undefined ? el.value : el.textContent.trim();
|
||||||
args: [selector, attr || null],
|
},
|
||||||
});
|
args: [selector, attr || null],
|
||||||
const value = results[0]?.result;
|
});
|
||||||
if (value != null && regex.test(String(value))) return { selector, value, pattern };
|
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));
|
await new Promise(r => setTimeout(r, interval));
|
||||||
}
|
}
|
||||||
throw new Error(`Selector '${selector}' did not match '${pattern}' within ${timeout}ms`);
|
throw new Error(`Selector '${selector}' did not match '${pattern}' within ${timeout}ms`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -191,13 +191,26 @@ export async function tabsWatchUrl({ pattern, timeout = 30000, tabId } = {}) {
|
|||||||
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
||||||
const deadline = Date.now() + timeout;
|
const deadline = Date.now() + timeout;
|
||||||
const regex = new RegExp(pattern);
|
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) {
|
while (Date.now() < deadline) {
|
||||||
const t = await chrome.tabs.get(tab.id);
|
const t = await chrome.tabs.get(tab.id);
|
||||||
const url = t.url || t.pendingUrl || "";
|
lastUrl = t.url || t.pendingUrl || "";
|
||||||
if (regex.test(url)) return tabInfo(t);
|
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));
|
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 }) {
|
export async function tabsMute({ tabId }) {
|
||||||
|
|||||||
+11
-1
@@ -5,12 +5,22 @@ export async function getProfileAlias() {
|
|||||||
return profileAlias || "default";
|
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) {
|
export async function executeScript(options, retries = 3) {
|
||||||
for (let i = 0; i < retries; i++) {
|
for (let i = 0; i < retries; i++) {
|
||||||
try {
|
try {
|
||||||
return await chrome.scripting.executeScript(options);
|
return await chrome.scripting.executeScript(options);
|
||||||
} catch (e) {
|
} 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));
|
await new Promise(r => setTimeout(r, 300));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "browser-cli"
|
name = "browser-cli"
|
||||||
version = "0.9.5"
|
version = "0.9.6"
|
||||||
description = "Control your real running browser from the terminal via a browser extension"
|
description = "Control your real running browser from the terminal via a browser extension"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
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
|
||||||
@@ -4,7 +4,7 @@ requires-python = ">=3.10"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "browser-cli"
|
name = "browser-cli"
|
||||||
version = "0.9.5"
|
version = "0.9.6"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
|
|||||||
Reference in New Issue
Block a user