diff --git a/extension/manifest.json b/extension/manifest.json index f48f5b2..ea4dba9 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -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", diff --git a/extension/src/commands/dom.ts b/extension/src/commands/dom.ts index 119cccd..910180e 100644 --- a/extension/src/commands/dom.ts +++ b/extension/src/commands/dom.ts @@ -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; } - const results = await executeScript({ - target: { tabId: tab.id }, - func: contentDispatch, - args: [funcName, args], - }); - return results[0]?.result; } 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,26 +67,32 @@ 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) { - const results = await executeScript({ - target: { tabId: tab.id }, - func: (sel, vis, hid) => { - const el = document.querySelector(sel); - if (hid) return !el || el.offsetParent === null; - if (!el) return false; - if (vis) { - const r = el.getBoundingClientRect(); - return r.width > 0 && r.height > 0; - } - return true; - }, - args: [selector, visible, hidden], - }); - if (results[0]?.result) return { selector, found: !hidden }; + try { + const results = await executeScript({ + target: { tabId: tab.id }, + func: (sel, vis, hid) => { + const el = document.querySelector(sel); + if (hid) return !el || el.offsetParent === null; + if (!el) return false; + if (vis) { + const r = el.getBoundingClientRect(); + return r.width > 0 && r.height > 0; + } + return true; + }, + 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,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 } = {}) { 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) { - const results = await executeScript({ - target: { tabId: tab.id }, - func: (sel, a) => { - const el = document.querySelector(sel); - if (!el) return null; - if (a) return el.getAttribute(a) ?? el[a] ?? null; - return el.value !== undefined ? el.value : el.textContent.trim(); - }, - args: [selector, attr || null], - }); - const value = results[0]?.result; - if (value != null && regex.test(String(value))) return { selector, value, pattern }; + try { + const results = await executeScript({ + target: { tabId: tab.id }, + func: (sel, a) => { + const el = document.querySelector(sel); + if (!el) return null; + if (a) return el.getAttribute(a) ?? el[a] ?? null; + return el.value !== undefined ? el.value : el.textContent.trim(); + }, + args: [selector, attr || null], + }); + 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`); } - diff --git a/extension/src/commands/tabs.ts b/extension/src/commands/tabs.ts index c5c6a4e..e5e2f9f 100644 --- a/extension/src/commands/tabs.ts +++ b/extension/src/commands/tabs.ts @@ -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 }) { diff --git a/extension/src/core.ts b/extension/src/core.ts index fca3a09..e8502f8 100644 --- a/extension/src/core.ts +++ b/extension/src/core.ts @@ -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; } diff --git a/pyproject.toml b/pyproject.toml index b74faa4..75e5ebb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/tests/test_extension_error_page_handling.py b/tests/test_extension_error_page_handling.py new file mode 100644 index 0000000..2016f2b --- /dev/null +++ b/tests/test_extension_error_page_handling.py @@ -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 diff --git a/uv.lock b/uv.lock index 653f650..f84ba30 100644 --- a/uv.lock +++ b/uv.lock @@ -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" },