From 545abeb515c088638209412a66a75684383d54dc Mon Sep 17 00:00:00 2001 From: Daniel Dolezal Date: Wed, 20 May 2026 22:13:57 +0200 Subject: [PATCH] feat: add performance controls for large browser ops - Add throttled large-operation handling for tab, group, and session commands. - Introduce performance profiles, audible-tab aware gentle mode, and job progress tracking. - Support background session restores with status/cancel commands and lazy placeholders. - Expose new perf and extension CLI groups plus matching Python SDK methods. - Preserve pinned tabs during session snapshots and debounce auto-save updates. - Bump browser-cli and extension versions to 0.10.0 and add pytest-cov to dev deps. - Add coverage for performance controls, background jobs, lazy restores, and tab metadata. --- browser_cli/__init__.py | 57 +++++- browser_cli/cli.py | 4 + browser_cli/commands/extension.py | 21 ++ browser_cli/commands/groups.py | 5 +- browser_cli/commands/perf.py | 45 +++++ browser_cli/commands/session.py | 43 ++++- browser_cli/commands/tabs.py | 20 +- extension/manifest.json | 2 +- extension/src/commands/groups.ts | 22 ++- extension/src/commands/session.ts | 202 ++++++++++++++++---- extension/src/commands/tabs.ts | 126 +++++++----- extension/src/core.ts | 86 ++++++++- extension/src/index.ts | 133 ++++++++++++- pyproject.toml | 7 +- tests/test_extension_error_page_handling.py | 103 ++++++++++ tests/test_performance_integration.py | 157 +++++++++++++++ tests/test_tabs.py | 17 +- uv.lock | 152 ++++++++++++++- 18 files changed, 1054 insertions(+), 148 deletions(-) create mode 100644 browser_cli/commands/extension.py create mode 100644 browser_cli/commands/perf.py create mode 100644 tests/test_performance_integration.py diff --git a/browser_cli/__init__.py b/browser_cli/__init__.py index 027e89e..0186a77 100644 --- a/browser_cli/__init__.py +++ b/browser_cli/__init__.py @@ -758,8 +758,61 @@ class BrowserCLI: def session_save(self, name: str) -> None: self._cmd("session.save", {"name": name}) - def session_load(self, name: str) -> None: - self._cmd("session.load", {"name": name}) + def session_load( + self, + name: str, + *, + gentle_mode: str = "auto", + discard_background_tabs: bool = False, + lazy: bool = False, + eager_tabs: int = 10, + ) -> None: + self._cmd("session.load", { + "name": name, + "gentleMode": gentle_mode, + "discardBackgroundTabs": discard_background_tabs, + "lazy": lazy, + "eagerTabs": eager_tabs, + }) + + def session_load_background( + self, + name: str, + *, + gentle_mode: str = "auto", + discard_background_tabs: bool = False, + lazy: bool = False, + eager_tabs: int = 10, + ) -> dict: + return self._cmd("session.load", { + "name": name, + "gentleMode": gentle_mode, + "discardBackgroundTabs": discard_background_tabs, + "lazy": lazy, + "eagerTabs": eager_tabs, + "__background": True, + }) or {} + + def job_status(self, job_id: str) -> dict: + return self._cmd("jobs.status", {"jobId": job_id}) or {} + + def job_cancel(self, job_id: str) -> dict: + return self._cmd("jobs.cancel", {"jobId": job_id}) or {} + + def reload_extension(self) -> None: + """Reload the browser-cli extension service worker. + + Schedules a ``chrome.runtime.reload()`` inside the extension and returns + immediately. The extension restarts ~200 ms later and reconnects via the + keepalive alarm within ~25 seconds. + """ + self._cmd("extension.reload", {}) + + def perf_status(self) -> dict: + return self._cmd("perf.status", {}) or {} + + def set_performance_profile(self, profile: str) -> dict: + return self._cmd("perf.set_profile", {"profile": profile}) or {} def session_diff(self, name_a: str, name_b: str) -> dict: return self._cmd("session.diff", {"nameA": name_a, "nameB": name_b}) or {} diff --git a/browser_cli/cli.py b/browser_cli/cli.py index 223d3d5..de9f771 100755 --- a/browser_cli/cli.py +++ b/browser_cli/cli.py @@ -23,6 +23,8 @@ from browser_cli.commands.search import search_group from browser_cli.commands.page import page_group from browser_cli.commands.storage import storage_group from browser_cli.commands.cookies import cookies_group +from browser_cli.commands.perf import perf_group +from browser_cli.commands.extension import extension_group from browser_cli.commands.serve import cmd_serve from browser_cli.client import ( send_command, @@ -375,6 +377,8 @@ main.add_command(search_group) main.add_command(page_group) main.add_command(storage_group) main.add_command(cookies_group) +main.add_command(perf_group) +main.add_command(extension_group) main.add_command(cmd_serve) diff --git a/browser_cli/commands/extension.py b/browser_cli/commands/extension.py new file mode 100644 index 0000000..5b06585 --- /dev/null +++ b/browser_cli/commands/extension.py @@ -0,0 +1,21 @@ +import time +import click +from rich.console import Console +from browser_cli.commands import _handle + +console = Console() + +@click.group("extension") +def extension_group(): + """Manage the browser-cli browser extension.""" + +@extension_group.command("reload") +def extension_reload(): + """Reload the browser-cli extension service worker. + + Useful after updating background.js without restarting the browser. + The command returns immediately; the extension restarts ~200 ms later. + Re-connects automatically via the keepalive alarm within ~25 seconds. + """ + _handle("extension.reload") + console.print("[green]Extension reloading…[/green] reconnects automatically") diff --git a/browser_cli/commands/groups.py b/browser_cli/commands/groups.py index bb760c4..b3a758f 100644 --- a/browser_cli/commands/groups.py +++ b/browser_cli/commands/groups.py @@ -103,9 +103,10 @@ def group_query(search): @group_group.command("close") @click.argument("group_id", type=int) -def group_close(group_id): +@click.option("--gentle-mode", type=click.Choice(["auto", "normal", "gentle", "ultra"]), default="auto", show_default=True, help="Throttle mode for large group operations.") +def group_close(group_id, gentle_mode): """Close (ungroup and optionally close) a tab group.""" - _handle("group.close", {"groupId": group_id}) + _handle("group.close", {"groupId": group_id, "gentleMode": gentle_mode}) console.print(f"[green]Group {group_id} closed[/green]") diff --git a/browser_cli/commands/perf.py b/browser_cli/commands/perf.py new file mode 100644 index 0000000..d9a2dc7 --- /dev/null +++ b/browser_cli/commands/perf.py @@ -0,0 +1,45 @@ +import click +from rich.console import Console +from rich.table import Table +from browser_cli.commands import _handle + +console = Console() + +@click.group("perf") +def perf_group(): + """Inspect and tune browser-cli performance behavior.""" + +@perf_group.command("status") +def perf_status(): + """Show performance profile, throttle and running jobs.""" + result = _handle("perf.status") or {} + console.print(f"Profile: [bold]{result.get('performanceProfile', 'auto')}[/bold]") + console.print(f"Audible tabs: {'yes' if result.get('audible') else 'no'}") + throttle = result.get("throttle") or {} + console.print(f"Throttle: batch={throttle.get('batchSize')} pause={throttle.get('pauseMs')}ms mode={throttle.get('mode')}") + + jobs = result.get("jobs") or [] + if not jobs: + console.print("[yellow]No running jobs[/yellow]") + return + + table = Table(show_header=True, header_style="bold cyan") + table.add_column("Job") + table.add_column("Command") + table.add_column("Status") + table.add_column("Progress", justify="right") + table.add_column("Phase") + for job in jobs: + total = job.get("total") + current = job.get("current") or 0 + percent = job.get("percent") or 0 + progress = f"{current}/{total} ({percent}%)" if total else f"{percent}%" + table.add_row(job.get("id", ""), job.get("command", ""), job.get("status", ""), progress, job.get("phase", "")) + console.print(table) + +@perf_group.command("profile") +@click.argument("profile", type=click.Choice(["auto", "normal", "gentle", "ultra"])) +def perf_profile(profile): + """Set global performance profile.""" + result = _handle("perf.set_profile", {"profile": profile}) or {} + console.print(f"[green]Performance profile set to {result.get('performanceProfile', profile)}[/green]") diff --git a/browser_cli/commands/session.py b/browser_cli/commands/session.py index f94be6b..26aaa3e 100644 --- a/browser_cli/commands/session.py +++ b/browser_cli/commands/session.py @@ -4,12 +4,10 @@ from rich.console import Console console = Console() - @click.group("session") def session_group(): """Save and restore browser sessions.""" - @session_group.command("save") @click.argument("name") def session_save(name): @@ -18,16 +16,29 @@ def session_save(name): count = result.get("tabs", 0) if isinstance(result, dict) else 0 console.print(f"[green]Session '{name}' saved[/green] ({count} tabs)") - @session_group.command("load") @click.argument("name") -def session_load(name): +@click.option("--gentle-mode", type=click.Choice(["auto", "normal", "gentle", "ultra"]), default="auto", show_default=True, help="Throttle mode for large restores.") +@click.option("--discard-background-tabs", is_flag=True, help="Discard restored background tabs after opening to reduce load.") +@click.option("--lazy", is_flag=True, help="Create lightweight placeholder tabs after --eager-tabs; placeholders load when selected.") +@click.option("--eager-tabs", type=int, default=10, show_default=True, help="Number of real tabs to open before lazy placeholders.") +@click.option("--background", "background_job", is_flag=True, help="Start restore as a background job and return immediately.") +def session_load(name, gentle_mode, discard_background_tabs, lazy, eager_tabs, background_job): """Restore session NAME (opens all saved tabs).""" - result = _handle("session.load", {"name": name}) + result = _handle("session.load", { + "name": name, + "gentleMode": gentle_mode, + "discardBackgroundTabs": discard_background_tabs, + "lazy": lazy, + "eagerTabs": eager_tabs, + "__background": background_job, + }) + if background_job and isinstance(result, dict) and result.get("jobId"): + console.print(f"[green]Session restore started[/green] job={result['jobId']}") + return count = result.get("tabs", 0) if isinstance(result, dict) else 0 console.print(f"[green]Session '{name}' loaded[/green] ({count} tabs opened)") - @session_group.command("diff") @click.argument("name_a") @click.argument("name_b") @@ -54,7 +65,6 @@ def session_diff(name_a, name_b): if not added and not removed: console.print("[green]Sessions are identical[/green]") - @session_group.command("list") def session_list(): """List all saved sessions.""" @@ -90,7 +100,6 @@ def session_list(): table.add_row(*row) console.print(table) - @session_group.command("remove") @click.argument("name") def session_remove(name): @@ -98,6 +107,24 @@ def session_remove(name): _handle("session.remove", {"name": name}) console.print(f"[green]Session '{name}' removed[/green]") +@session_group.command("job-status") +@click.argument("job_id") +def session_job_status(job_id): + """Show status for a background session job.""" + result = _handle("jobs.status", {"jobId": job_id}) or {} + status = result.get("status", "unknown") + console.print(f"[bold]{job_id}[/bold]: {status}") + if result.get("error"): + console.print(f"[red]{result['error']}[/red]") + elif result.get("result"): + console.print(result["result"]) + +@session_group.command("job-cancel") +@click.argument("job_id") +def session_job_cancel(job_id): + """Cancel a running background job.""" + _handle("jobs.cancel", {"jobId": job_id}) + console.print(f"[green]Cancel requested for {job_id}[/green]") @session_group.command("auto-save") @click.argument("state", type=click.Choice(["on", "off"])) diff --git a/browser_cli/commands/tabs.py b/browser_cli/commands/tabs.py index 3a4d2d0..4e76839 100644 --- a/browser_cli/commands/tabs.py +++ b/browser_cli/commands/tabs.py @@ -66,9 +66,10 @@ def tabs_list(): @click.argument("tab_id", type=int, required=False) @click.option("--inactive", is_flag=True, help="Close all inactive tabs") @click.option("--duplicates", is_flag=True, help="Close duplicate tabs (keep first)") -def tabs_close(tab_id, inactive, duplicates): +@click.option("--gentle-mode", type=click.Choice(["auto", "normal", "gentle", "ultra"]), default="auto", show_default=True, help="Throttle mode for large close operations.") +def tabs_close(tab_id, inactive, duplicates, gentle_mode): """Close a tab, all inactive tabs, or all duplicate tabs.""" - result = _handle("tabs.close", {"tabId": tab_id, "inactive": inactive, "duplicates": duplicates}) + result = _handle("tabs.close", {"tabId": tab_id, "inactive": inactive, "duplicates": duplicates, "gentleMode": gentle_mode}) count = result.get("closed", 0) if isinstance(result, dict) else 1 console.print(f"[green]Closed {count} tab(s)[/green]") @@ -171,25 +172,28 @@ def tabs_html(tab_id): @tabs_group.command("dedupe") -def tabs_dedupe(): +@click.option("--gentle-mode", type=click.Choice(["auto", "normal", "gentle", "ultra"]), default="auto", show_default=True, help="Throttle mode for large dedupe operations.") +def tabs_dedupe(gentle_mode): """Close duplicate tabs (keep the first occurrence of each URL).""" - result = _handle("tabs.dedupe") + result = _handle("tabs.dedupe", {"gentleMode": gentle_mode}) count = result.get("closed", 0) if isinstance(result, dict) else 0 console.print(f"[green]Closed {count} duplicate tab(s)[/green]") @tabs_group.command("sort") @click.option("--by", type=click.Choice(["domain", "title", "time"]), default="domain", show_default=True) -def tabs_sort(by): +@click.option("--gentle-mode", type=click.Choice(["auto", "normal", "gentle", "ultra"]), default="auto", show_default=True, help="Throttle mode for large sort operations.") +def tabs_sort(by, gentle_mode): """Sort tabs within each window.""" - _handle("tabs.sort", {"by": by}) + _handle("tabs.sort", {"by": by, "gentleMode": gentle_mode}) console.print(f"[green]Tabs sorted by {by}[/green]") @tabs_group.command("merge-windows") -def tabs_merge_windows(): +@click.option("--gentle-mode", type=click.Choice(["auto", "normal", "gentle", "ultra"]), default="auto", show_default=True, help="Throttle mode for large merge operations.") +def tabs_merge_windows(gentle_mode): """Move all tabs into the focused window.""" - result = _handle("tabs.merge_windows") + result = _handle("tabs.merge_windows", {"gentleMode": gentle_mode}) count = result.get("moved", 0) if isinstance(result, dict) else 0 console.print(f"[green]Merged — moved {count} tab(s) into current window[/green]") diff --git a/extension/manifest.json b/extension/manifest.json index 360c492..f273f78 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "browser-cli", - "version": "0.9.9", + "version": "0.10.0", "description": "Control your browser from the terminal or Python SDK", "permissions": [ "tabs", diff --git a/extension/src/commands/groups.ts b/extension/src/commands/groups.ts index 5fa1a8d..1eed4b8 100644 --- a/extension/src/commands/groups.ts +++ b/extension/src/commands/groups.ts @@ -1,5 +1,5 @@ // @ts-nocheck -import { buildTabBlocks, resolveGroupId, tabInfo } from '../core'; +import { buildTabBlocks, getLargeOperationThrottle, resolveGroupId, runLargeOperation, tabInfo, throwIfJobCancelled, updateJobProgress, yieldForLargeOperation } from '../core'; export async function groupList() { const groups = await chrome.tabGroups.query({}); const all = await chrome.tabs.query({}); @@ -29,11 +29,21 @@ export async function groupQuery({ search }) { return groups.filter(g => g.title && g.title.toLowerCase().includes(q)); } -export async function groupClose({ groupId }) { - const tabs = await chrome.tabs.query({}); - const groupTabs = tabs.filter(t => t.groupId === groupId); - await chrome.tabs.ungroup(groupTabs.map(t => t.id)); - return { groupId }; +export async function groupClose({ groupId, gentleMode, __job } = {}) { + return runLargeOperation("group.close", async () => { + const tabs = await chrome.tabs.query({}); + const groupTabs = tabs.filter(t => t.groupId === groupId); + const tabIds = groupTabs.map(t => t.id); + const throttle = await getLargeOperationThrottle(tabIds.length, gentleMode); + updateJobProgress(__job, { phase: "ungrouping tabs", current: 0, total: tabIds.length }); + for (let i = 0; i < tabIds.length; i += throttle.batchSize) { + throwIfJobCancelled(__job); + await chrome.tabs.ungroup(tabIds.slice(i, i + throttle.batchSize)); + updateJobProgress(__job, { phase: "ungrouping tabs", current: Math.min(i + throttle.batchSize, tabIds.length), total: tabIds.length }); + await yieldForLargeOperation(i + throttle.batchSize, throttle.batchSize, throttle.pauseMs); + } + return { groupId, gentle: throttle.gentle, audible: throttle.audible }; + }); } export async function groupOpen({ name }) { diff --git a/extension/src/commands/session.ts b/extension/src/commands/session.ts index b04dd77..6d9edff 100644 --- a/extension/src/commands/session.ts +++ b/extension/src/commands/session.ts @@ -1,14 +1,14 @@ // @ts-nocheck -import { getProfileAlias, getSessionTabs, getSessions, normalizeGroupColor } from '../core'; -export async function sessionSave({ name }) { - const tabs = await chrome.tabs.query({}); - const groups = await chrome.tabGroups.query({}); +import { getLargeOperationThrottle, getProfileAlias, getSessionTabs, getSessions, normalizeGroupColor, runLargeOperation, throwIfJobCancelled, updateJobProgress, yieldForLargeOperation } from '../core'; + +function buildSessionSnapshot(tabs, groups) { const groupById = new Map(groups.map(group => [group.id, group])); - const sessionTabs = tabs - .filter(tab => Boolean(tab.url)) + return tabs + .filter(tab => Boolean(tab.url || tab.pendingUrl)) .sort((a, b) => (a.windowId - b.windowId) || (a.index - b.index)) .map(tab => { - const entry = { url: tab.url }; + const entry = { url: tab.url || tab.pendingUrl }; + if (tab.pinned) entry.pinned = true; if (tab.groupId >= 0) { const group = groupById.get(tab.groupId); entry.group = { @@ -20,49 +20,110 @@ export async function sessionSave({ name }) { } return entry; }); +} + +function sessionSignature(sessionTabs) { + return JSON.stringify(sessionTabs.map(tab => ({ + url: tab.url, + pinned: Boolean(tab.pinned), + group: tab.group ? { + key: tab.group.key || "", + title: tab.group.title || "", + color: normalizeGroupColor(tab.group.color), + collapsed: Boolean(tab.group.collapsed), + } : null, + }))); +} + +export async function sessionSave({ name }) { + const tabs = await chrome.tabs.query({}); + const groups = await chrome.tabGroups.query({}); + const sessionTabs = buildSessionSnapshot(tabs, groups); + const signature = sessionSignature(sessionTabs); const sessions = await getSessions(); sessions[name] = { tabs: sessionTabs, urls: sessionTabs.map(tab => tab.url), savedAt: Date.now(), + signature, }; await chrome.storage.local.set({ sessions }); return { name, tabs: sessionTabs.length }; } -export async function sessionLoad({ name }) { - const sessions = await getSessions(); - const session = sessions[name]; - if (!session) throw new Error(`Session '${name}' not found`); +function lazyPlaceholderUrl(url) { + const escaped = String(url).replace(/[&<>"']/g, ch => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }[ch])); + const html = `Lazy tab

Lazy tab

This tab will load when selected.

${escaped}

`; + return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; +} - const sessionTabs = getSessionTabs(session); - const createdTabs = []; +export async function activateLazyTab(tabId) { + const { lazySessionTabs } = await chrome.storage.local.get("lazySessionTabs"); + const entry = lazySessionTabs?.[tabId]; + if (!entry?.url) return false; + delete lazySessionTabs[tabId]; + await chrome.storage.local.set({ lazySessionTabs }); + await chrome.tabs.update(Number(tabId), { url: entry.url }); + return true; +} - for (const entry of sessionTabs) { - const tab = await chrome.tabs.create({ url: entry.url, active: false }); - createdTabs.push({ tabId: tab.id, entry }); - } +export async function sessionLoad({ name, gentleMode, discardBackgroundTabs = false, lazy = false, eagerTabs = 10, __job } = {}) { + return runLargeOperation("session.load", async () => { + const sessions = await getSessions(); + const session = sessions[name]; + if (!session) throw new Error(`Session '${name}' not found`); - const groups = new Map(); - for (const { tabId, entry } of createdTabs) { - if (!entry.group) continue; - const key = entry.group.key || `${entry.group.title || "group"}:${groups.size}`; - if (!groups.has(key)) { - groups.set(key, { meta: entry.group, tabIds: [] }); + const sessionTabs = getSessionTabs(session).sort((a, b) => Number(Boolean(b.pinned)) - Number(Boolean(a.pinned))); + const createdTabs = []; + const throttle = await getLargeOperationThrottle(sessionTabs.length, gentleMode); + const createBatchSize = Math.max(1, Math.min(10, throttle.batchSize)); + const eagerLimit = lazy ? Math.max(0, Number(eagerTabs) || 0) : sessionTabs.length; + const { lazySessionTabs } = await chrome.storage.local.get("lazySessionTabs"); + const lazyMap = lazySessionTabs || {}; + updateJobProgress(__job, { phase: "opening", current: 0, total: sessionTabs.length }); + + for (const [idx, entry] of sessionTabs.entries()) { + throwIfJobCancelled(__job); + const shouldLazy = lazy && idx >= eagerLimit; + const tab = await chrome.tabs.create({ url: shouldLazy ? lazyPlaceholderUrl(entry.url) : entry.url, active: false, pinned: Boolean(entry.pinned) }); + createdTabs.push({ tabId: tab.id, entry }); + if (shouldLazy) { + lazyMap[String(tab.id)] = { url: entry.url, createdAt: Date.now() }; + } else if (discardBackgroundTabs && !entry.pinned && chrome.tabs.discard) { + try { await chrome.tabs.discard(tab.id); } catch (_) {} + } + updateJobProgress(__job, { phase: shouldLazy ? "creating lazy placeholders" : "opening", current: createdTabs.length, total: sessionTabs.length }); + await yieldForLargeOperation(createdTabs.length, createBatchSize, Math.max(50, throttle.pauseMs)); } - groups.get(key).tabIds.push(tabId); - } + if (lazy) await chrome.storage.local.set({ lazySessionTabs: lazyMap }); - for (const { meta, tabIds } of groups.values()) { - const restoredGroupId = await chrome.tabs.group({ tabIds }); - await chrome.tabGroups.update(restoredGroupId, { - title: meta.title || "", - color: normalizeGroupColor(meta.color), - collapsed: Boolean(meta.collapsed), - }); - } + const groups = new Map(); + for (const { tabId, entry } of createdTabs) { + if (!entry.group) continue; + const key = entry.group.key || `${entry.group.title || "group"}:${groups.size}`; + if (!groups.has(key)) { + groups.set(key, { meta: entry.group, tabIds: [] }); + } + groups.get(key).tabIds.push(tabId); + } - return { name, tabs: sessionTabs.length }; + let restoredGroups = 0; + updateJobProgress(__job, { phase: "restoring groups", current: 0, total: groups.size }); + for (const { meta, tabIds } of groups.values()) { + throwIfJobCancelled(__job); + const restoredGroupId = await chrome.tabs.group({ tabIds }); + await chrome.tabGroups.update(restoredGroupId, { + title: meta.title || "", + color: normalizeGroupColor(meta.color), + collapsed: Boolean(meta.collapsed), + }); + restoredGroups++; + updateJobProgress(__job, { phase: "restoring groups", current: restoredGroups, total: groups.size }); + await yieldForLargeOperation(restoredGroups, 5, Math.max(50, throttle.pauseMs)); + } + + return { name, tabs: sessionTabs.length, gentle: throttle.gentle, audible: throttle.audible, discarded: Boolean(discardBackgroundTabs), lazy: Boolean(lazy), eagerTabs: eagerLimit }; + }); } export async function sessionList() { @@ -92,21 +153,86 @@ export async function sessionDiff({ nameA, nameB }) { }; } +let autoSaveTimer = null; +let autoSaveInFlight = false; +let autoSavePending = false; + export async function sessionAutoSave({ enabled }) { await chrome.storage.local.set({ autoSave: enabled }); - chrome.tabs.onUpdated.removeListener(autoSaveHandler); + chrome.tabs.onCreated.removeListener(autoSaveHandler); chrome.tabs.onRemoved.removeListener(autoSaveHandler); + chrome.tabs.onMoved.removeListener(autoSaveHandler); + chrome.tabs.onAttached.removeListener(autoSaveHandler); + chrome.tabs.onDetached.removeListener(autoSaveHandler); + chrome.tabs.onUpdated.removeListener(autoSaveUpdatedHandler); + if (chrome.tabGroups?.onUpdated) chrome.tabGroups.onUpdated.removeListener(autoSaveHandler); + if (autoSaveTimer) clearTimeout(autoSaveTimer); + autoSaveTimer = null; + autoSavePending = false; if (enabled) { - chrome.tabs.onUpdated.addListener(autoSaveHandler); + chrome.tabs.onCreated.addListener(autoSaveHandler); chrome.tabs.onRemoved.addListener(autoSaveHandler); + chrome.tabs.onMoved.addListener(autoSaveHandler); + chrome.tabs.onAttached.addListener(autoSaveHandler); + chrome.tabs.onDetached.addListener(autoSaveHandler); + chrome.tabs.onUpdated.addListener(autoSaveUpdatedHandler); + if (chrome.tabGroups?.onUpdated) chrome.tabGroups.onUpdated.addListener(autoSaveHandler); } return { enabled }; } -export async function autoSaveHandler() { +async function saveAutoSessionIfChanged() { + const tabs = await chrome.tabs.query({}); + const groups = await chrome.tabGroups.query({}); + const sessionTabs = buildSessionSnapshot(tabs, groups); + const signature = sessionSignature(sessionTabs); + const { autoSaveSignature } = await chrome.storage.local.get("autoSaveSignature"); + if (autoSaveSignature === signature) return { skipped: true, tabs: sessionTabs.length }; + + const sessions = await getSessions(); + sessions.__auto__ = { + tabs: sessionTabs, + urls: sessionTabs.map(tab => tab.url), + savedAt: Date.now(), + signature, + }; + await chrome.storage.local.set({ sessions, autoSaveSignature: signature }); + return { skipped: false, tabs: sessionTabs.length }; +} + +async function runAutoSave() { + if (autoSaveInFlight) { + autoSavePending = true; + return; + } + autoSaveInFlight = true; + try { + const { autoSave } = await chrome.storage.local.get("autoSave"); + if (autoSave) await runLargeOperation("session.auto_save", saveAutoSessionIfChanged); + } finally { + autoSaveInFlight = false; + if (autoSavePending) { + autoSavePending = false; + autoSaveTimer = setTimeout(runAutoSave, 1000); + } + } +} + +async function scheduleAutoSave(delayMs = 1000) { const { autoSave } = await chrome.storage.local.get("autoSave"); if (!autoSave) return; - await sessionSave({ name: "__auto__" }); + if (autoSaveTimer) clearTimeout(autoSaveTimer); + autoSaveTimer = setTimeout(runAutoSave, delayMs); +} + +export async function autoSaveHandler() { + await scheduleAutoSave(); +} + +export async function autoSaveUpdatedHandler(_tabId, changeInfo = {}) { + // Ignore noisy media/title/favicon/loading updates. Sessions only store URL and group/window structure. + if (!("url" in changeInfo)) return; + await scheduleAutoSave(); } // ── Misc ────────────────────────────────────────────────────────────────────── diff --git a/extension/src/commands/tabs.ts b/extension/src/commands/tabs.ts index 8e4692c..3d8be7e 100644 --- a/extension/src/commands/tabs.ts +++ b/extension/src/commands/tabs.ts @@ -1,5 +1,5 @@ // @ts-nocheck -import { executeScript, getActiveTab, getAliases, isBrowserErrorUrl, isErrorPageScriptError, isScriptableUrl, resolveTabForDirectAction, tabInfo } from '../core'; +import { executeScript, getActiveTab, getAliases, getLargeOperationThrottle, isBrowserErrorUrl, isErrorPageScriptError, isScriptableUrl, resolveTabForDirectAction, runLargeOperation, tabInfo, throwIfJobCancelled, updateJobProgress, yieldForLargeOperation } from '../core'; export async function tabsList() { const windows = await chrome.windows.getAll({ populate: true }); const aliases = await getAliases(); @@ -17,24 +17,33 @@ export async function tabsList() { return tabs; } -export async function tabsClose({ tabId, inactive, duplicates }) { - let toClose = []; - if (duplicates) { - const all = await chrome.tabs.query({}); - const seen = new Set(); - for (const t of all) { - if (!t.url) continue; - if (seen.has(t.url)) toClose.push(t.id); - else seen.add(t.url); +export async function tabsClose({ tabId, inactive, duplicates, gentleMode, __job } = {}) { + return runLargeOperation("tabs.close", async () => { + let toClose = []; + if (duplicates) { + const all = await chrome.tabs.query({}); + const seen = new Set(); + for (const t of all) { + if (!t.url) continue; + if (seen.has(t.url)) toClose.push(t.id); + else seen.add(t.url); + } + } else if (inactive) { + const all = await chrome.tabs.query({}); + toClose = all.filter(t => !t.active).map(t => t.id); + } else if (tabId) { + toClose = [tabId]; } - } else if (inactive) { - const all = await chrome.tabs.query({}); - toClose = all.filter(t => !t.active).map(t => t.id); - } else if (tabId) { - toClose = [tabId]; - } - if (toClose.length) await chrome.tabs.remove(toClose); - return { closed: toClose.length }; + const throttle = await getLargeOperationThrottle(toClose.length, gentleMode); + updateJobProgress(__job, { phase: "closing tabs", current: 0, total: toClose.length }); + for (let i = 0; i < toClose.length; i += throttle.batchSize) { + throwIfJobCancelled(__job); + await chrome.tabs.remove(toClose.slice(i, i + throttle.batchSize)); + updateJobProgress(__job, { phase: "closing tabs", current: Math.min(i + throttle.batchSize, toClose.length), total: toClose.length }); + await yieldForLargeOperation(i + throttle.batchSize, throttle.batchSize, throttle.pauseMs); + } + return { closed: toClose.length, gentle: throttle.gentle, audible: throttle.audible }; + }); } export async function tabsMove({ tabId, groupId, windowId, index, forward, backward }) { @@ -127,41 +136,62 @@ export async function tabsHtml({ tabId }) { } } -export async function tabsDedupe() { - return tabsClose({ duplicates: true }); +export async function tabsDedupe(args = {}) { + return tabsClose({ ...args, duplicates: true }); } -export async function tabsSort({ by }) { - const windows = await chrome.windows.getAll({ populate: true }); - let moved = 0; - for (const w of windows) { - const sorted = [...w.tabs].sort((a, b) => { - if (by === "title") return (a.title || "").localeCompare(b.title || ""); - if (by === "time") return a.id - b.id; // lower id = opened earlier - // domain (default) - const da = new URL(a.url || a.pendingUrl || "about:blank").hostname; - const db = new URL(b.url || b.pendingUrl || "about:blank").hostname; - return da.localeCompare(db); - }); - for (let i = 0; i < sorted.length; i++) { - await chrome.tabs.move(sorted[i].id, { index: i }); - moved++; +export async function tabsSort({ by, gentleMode, __job } = {}) { + return runLargeOperation("tabs.sort", async () => { + const windows = await chrome.windows.getAll({ populate: true }); + let moved = 0; + const totalTabs = windows.reduce((sum, w) => sum + (w.tabs?.length || 0), 0); + updateJobProgress(__job, { phase: "sorting tabs", current: 0, total: totalTabs }); + for (const w of windows) { + const sorted = [...w.tabs].sort((a, b) => { + if (by === "title") return (a.title || "").localeCompare(b.title || ""); + if (by === "time") return a.id - b.id; // lower id = opened earlier + // domain (default) + const da = new URL(a.url || a.pendingUrl || "about:blank").hostname; + const db = new URL(b.url || b.pendingUrl || "about:blank").hostname; + return da.localeCompare(db); + }); + if (w.tabs.every((tab, index) => tab.id === sorted[index]?.id)) continue; + const throttle = await getLargeOperationThrottle(sorted.length, gentleMode); + const moveBatchSize = Math.max(1, Math.min(10, throttle.batchSize)); + for (let i = 0; i < sorted.length; i++) { + throwIfJobCancelled(__job); + await chrome.tabs.move(sorted[i].id, { index: i }); + moved++; + updateJobProgress(__job, { phase: "sorting tabs", current: moved, total: totalTabs }); + await yieldForLargeOperation(moved, moveBatchSize, throttle.pauseMs); + } } - } - return { moved }; + return { moved }; + }); } -export async function tabsMergeWindows() { - const current = await chrome.windows.getCurrent(); - const all = await chrome.windows.getAll({ populate: true }); - let moved = 0; - for (const w of all) { - if (w.id === current.id) continue; - const ids = w.tabs.map(t => t.id); - await chrome.tabs.move(ids, { windowId: current.id, index: -1 }); - moved += ids.length; - } - return { moved }; +export async function tabsMergeWindows({ gentleMode, __job } = {}) { + return runLargeOperation("tabs.merge_windows", async () => { + const current = await chrome.windows.getCurrent(); + const all = await chrome.windows.getAll({ populate: true }); + let moved = 0; + const totalTabs = all.filter(w => w.id !== current.id).reduce((sum, w) => sum + (w.tabs?.length || 0), 0); + updateJobProgress(__job, { phase: "merging windows", current: 0, total: totalTabs }); + for (const w of all) { + if (w.id === current.id) continue; + const ids = w.tabs.map(t => t.id); + const throttle = await getLargeOperationThrottle(ids.length, gentleMode); + for (let i = 0; i < ids.length; i += throttle.batchSize) { + throwIfJobCancelled(__job); + const chunk = ids.slice(i, i + throttle.batchSize); + await chrome.tabs.move(chunk, { windowId: current.id, index: -1 }); + moved += chunk.length; + updateJobProgress(__job, { phase: "merging windows", current: moved, total: totalTabs }); + await yieldForLargeOperation(moved, throttle.batchSize, throttle.pauseMs); + } + } + return { moved }; + }); } export async function tabsPin({ tabId }) { diff --git a/extension/src/core.ts b/extension/src/core.ts index 8b21d53..9ec813c 100644 --- a/extension/src/core.ts +++ b/extension/src/core.ts @@ -32,13 +32,94 @@ export function isTransientScriptError(error) { return message.includes("Frame with ID") || message.includes("No tab with id") || isErrorPageScriptError(error); } +export function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export const LARGE_OPERATION_BATCH_SIZE = 25; +export const LARGE_OPERATION_PAUSE_MS = 25; +export const GENTLE_OPERATION_BATCH_SIZE = 8; +export const GENTLE_OPERATION_PAUSE_MS = 100; + +export async function hasAudibleTabs() { + const audibleTabs = await chrome.tabs.query({ audible: true }); + return audibleTabs.some(tab => !(tab.mutedInfo && tab.mutedInfo.muted)); +} + +let largeOperationQueue = Promise.resolve(); + +export async function runLargeOperation(name, fn) { + const run = largeOperationQueue.then(async () => { + console.log(`[browser-cli] large operation start: ${name}`); + try { + return await fn(); + } finally { + console.log(`[browser-cli] large operation done: ${name}`); + } + }); + largeOperationQueue = run.catch(() => {}); + return run; +} + +export async function getPerformanceProfile() { + const { performanceProfile } = await chrome.storage.local.get("performanceProfile"); + return performanceProfile || "auto"; +} + +export async function setPerformanceProfile(profile) { + const allowed = new Set(["auto", "normal", "gentle", "ultra"]); + const performanceProfile = allowed.has(profile) ? profile : "auto"; + await chrome.storage.local.set({ performanceProfile }); + return { performanceProfile }; +} + +export async function getLargeOperationThrottle(itemCount = 0, mode = "auto") { + const audible = await hasAudibleTabs(); + const storedProfile = await getPerformanceProfile(); + const configuredMode = mode && mode !== "auto" ? mode : storedProfile; + const gentle = configuredMode === "gentle" || configuredMode === "ultra" || (configuredMode === "auto" && audible); + let batchSize = gentle ? GENTLE_OPERATION_BATCH_SIZE : LARGE_OPERATION_BATCH_SIZE; + let pauseMs = gentle ? GENTLE_OPERATION_PAUSE_MS : LARGE_OPERATION_PAUSE_MS; + + if (configuredMode === "ultra" || itemCount >= 300) { + batchSize = Math.max(3, Math.floor(batchSize / 2)); + pauseMs *= 2; + } else if (itemCount >= 100) { + batchSize = Math.max(5, Math.floor(batchSize * 0.75)); + pauseMs = Math.max(pauseMs, 75); + } + + return { batchSize, pauseMs, gentle, audible, itemCount, mode: configuredMode }; +} + +export function updateJobProgress(job, { phase, current, total } = {}) { + if (!job) return; + if (phase) job.phase = phase; + if (total != null) job.total = total; + if (current != null) job.current = current; + if (job.total) job.percent = Math.min(100, Math.round((job.current || 0) * 100 / job.total)); + job.updatedAt = Date.now(); +} + +export function throwIfJobCancelled(job) { + if (job?.cancelRequested) { + throw new Error(`Job '${job.id}' cancelled`); + } +} + +export async function yieldForLargeOperation(processed, batchSize = LARGE_OPERATION_BATCH_SIZE, pauseMs = LARGE_OPERATION_PAUSE_MS) { + if (processed > 0 && processed % batchSize === 0) { + await sleep(pauseMs); + } +} + 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 && isTransientScriptError(e)) { - await new Promise(r => setTimeout(r, 300)); + await sleep(300); continue; } throw e; @@ -52,8 +133,9 @@ export function tabInfo(t) { windowId: t.windowId, active: t.active, muted: Boolean(t.mutedInfo && t.mutedInfo.muted), + groupId: t.groupId >= 0 ? t.groupId : null, title: t.title, - url: t.url, + url: t.url || t.pendingUrl || "", }; } diff --git a/extension/src/index.ts b/extension/src/index.ts index 5bc2654..904ffc8 100644 --- a/extension/src/index.ts +++ b/extension/src/index.ts @@ -5,7 +5,7 @@ * Connects to the native host (com.browsercli.host) via Native Messaging. */ -import { getProfileAlias } from './core'; +import { getLargeOperationThrottle, getPerformanceProfile, hasAudibleTabs, setPerformanceProfile, getProfileAlias } from './core'; import * as nav from './commands/navigation'; import * as tabs from './commands/tabs'; import * as groups from './commands/groups'; @@ -17,6 +17,15 @@ import * as session from './commands/session'; const NATIVE_HOST = "com.browsercli.host"; let port = null; let keepaliveEnabled = true; +const jobs = new Map(); +const BACKGROUND_COMMANDS = new Set([ + "session.load", + "tabs.close", + "tabs.dedupe", + "tabs.sort", + "tabs.merge_windows", + "group.close", +]); // ── Connection management ───────────────────────────────────────────────────── function sendControlMessage(targetPort, message) { @@ -88,6 +97,10 @@ chrome.alarms.onAlarm.addListener((alarm) => { } }); +chrome.tabs.onActivated.addListener(async ({ tabId }) => { + await session.activateLazyTab(tabId); +}); + // ── Message dispatcher ──────────────────────────────────────────────────────── async function onMessage(msg) { @@ -98,10 +111,14 @@ async function onMessage(msg) { let data, error; try { - const { __page, ...commandArgs } = args || {}; - data = await dispatch(command, commandArgs); - if (__page && Array.isArray(data)) { - data = makePagedData(data, __page); + const { __page, __background, ...commandArgs } = args || {}; + if (__background && BACKGROUND_COMMANDS.has(command)) { + data = await startBackgroundJob(command, commandArgs); + } else { + data = await dispatch(command, commandArgs); + if (__page && Array.isArray(data)) { + data = makePagedData(data, __page); + } } } catch (e) { error = e.message || String(e); @@ -121,6 +138,94 @@ async function onMessage(msg) { await connect(); } } +async function persistJobs() { + const recentJobs = [...jobs.values()].slice(-50).map(job => ({ ...job, __timer: undefined })); + await chrome.storage.local.set({ recentJobs }); +} + +async function startBackgroundJob(command, args) { + const jobId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; + const job = { + id: jobId, + command, + status: "running", + phase: "queued", + current: 0, + total: null, + percent: 0, + cancelRequested: false, + startedAt: Date.now(), + updatedAt: Date.now(), + finishedAt: null, + result: null, + error: null, + }; + jobs.set(jobId, job); + job.__timer = setInterval(persistJobs, 1000); + await persistJobs(); + dispatch(command, { ...args, __job: job }) + .then(async result => { + job.status = "done"; + job.phase = "done"; + job.result = result; + job.current = job.total || job.current; + job.percent = 100; + job.finishedAt = Date.now(); + job.updatedAt = Date.now(); + if (job.__timer) clearInterval(job.__timer); + await persistJobs(); + }) + .catch(async error => { + job.status = job.cancelRequested ? "cancelled" : "error"; + job.phase = job.status; + job.error = error?.message || String(error); + job.finishedAt = Date.now(); + job.updatedAt = Date.now(); + if (job.__timer) clearInterval(job.__timer); + await persistJobs(); + }); + return { jobId, command, status: job.status }; +} + +async function jobStatus({ jobId }) { + const job = jobs.get(jobId); + if (job) return { ...job }; + const { recentJobs } = await chrome.storage.local.get("recentJobs"); + const stored = (recentJobs || []).find(entry => entry.id === jobId); + if (!stored) throw new Error(`Job '${jobId}' not found`); + return stored; +} + +async function jobCancel({ jobId }) { + const job = jobs.get(jobId); + if (!job) throw new Error(`Job '${jobId}' not running`); + job.cancelRequested = true; + job.updatedAt = Date.now(); + await persistJobs(); + return { jobId, cancelled: true }; +} + +async function perfStatus() { + const profile = await getPerformanceProfile(); + const audible = await hasAudibleTabs(); + const throttle = await getLargeOperationThrottle(0, "auto"); + return { + performanceProfile: profile, + audible, + throttle, + jobs: [...jobs.values()].map(job => ({ + id: job.id, + command: job.command, + status: job.status, + phase: job.phase, + current: job.current, + total: job.total, + percent: job.percent, + cancelRequested: job.cancelRequested, + })), + }; +} + function makePagedData(items, page) { const total = items.length; const offset = Math.max(0, Number(page.offset) || 0); @@ -160,9 +265,9 @@ async function dispatch(command, args) { case "tabs.count": return tabs.tabsCount(args); case "tabs.query": return tabs.tabsQuery(args); case "tabs.html": return tabs.tabsHtml(args); - case "tabs.dedupe": return tabs.tabsDedupe(); + case "tabs.dedupe": return tabs.tabsDedupe(args); case "tabs.sort": return tabs.tabsSort(args); - case "tabs.merge_windows": return tabs.tabsMergeWindows(); + case "tabs.merge_windows": return tabs.tabsMergeWindows(args); case "tabs.mute": return tabs.tabsMute(args); case "tabs.unmute": return tabs.tabsUnmute(args); case "tabs.pin": return tabs.tabsPin(args); @@ -234,6 +339,20 @@ async function dispatch(command, args) { case "session.diff": return session.sessionDiff(args); case "session.auto_save": return session.sessionAutoSave(args); + // ── Jobs ────────────────────────────────────────────────────────────── + case "jobs.status": return jobStatus(args); + case "jobs.cancel": return jobCancel(args); + + // ── Performance ─────────────────────────────────────────────────────── + case "perf.status": return perfStatus(); + case "perf.set_profile": return setPerformanceProfile(args.profile); + + // ── Extension ───────────────────────────────────────────────────────── + case "extension.reload": { + setTimeout(() => chrome.runtime.reload(), 200); + return { reloading: true }; + } + // ── Misc ────────────────────────────────────────────────────────────── case "clients.list": return session.clientsList(); case "clients.rename_profile": return session.clientsRenameProfile(args); diff --git a/pyproject.toml b/pyproject.toml index 01ee8a0..b120ac1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "browser-cli" -version = "0.9.9" +version = "0.10.0" description = "Control your real running browser from the terminal or Python SDK" requires-python = ">=3.10" dependencies = [ @@ -13,7 +13,10 @@ dependencies = [ browser-cli = "browser_cli.cli:main" [dependency-groups] -dev = ["pytest>=8"] +dev = [ + "pytest>=8", + "pytest-cov>=7.1.0", +] [build-system] requires = ["hatchling"] diff --git a/tests/test_extension_error_page_handling.py b/tests/test_extension_error_page_handling.py index 1fbe258..f6f1ca2 100644 --- a/tests/test_extension_error_page_handling.py +++ b/tests/test_extension_error_page_handling.py @@ -35,3 +35,106 @@ def test_navigation_and_tabs_report_browser_error_pages(): assert "last URL:" in tabs assert "isBrowserErrorUrl" in navigation assert "showing an error page while waiting for load" in navigation + +def test_large_extension_operations_yield_between_batches(): + core = (ROOT / "extension" / "src" / "core.ts").read_text() + tabs = (ROOT / "extension" / "src" / "commands" / "tabs.ts").read_text() + groups = (ROOT / "extension" / "src" / "commands" / "groups.ts").read_text() + session = (ROOT / "extension" / "src" / "commands" / "session.ts").read_text() + + assert "yieldForLargeOperation" in core + assert "getLargeOperationThrottle" in core + assert "hasAudibleTabs" in core + assert "runLargeOperation" in core + assert "largeOperationQueue" in core + assert "updateJobProgress" in core + assert "throwIfJobCancelled" in core + assert "getPerformanceProfile" in core + assert "setPerformanceProfile" in core + assert "GENTLE_OPERATION_BATCH_SIZE" in core + assert "GENTLE_OPERATION_PAUSE_MS" in core + assert "itemCount >= 300" in core + assert "itemCount >= 100" in core + assert "chrome.tabs.query({ audible: true })" in core + assert "yieldForLargeOperation" in tabs + assert "toClose.slice" in tabs + assert "ids.slice" in tabs + assert "w.tabs.every" in tabs + assert "getLargeOperationThrottle" in tabs + assert "runLargeOperation(\"tabs.sort\"" in tabs + assert "yieldForLargeOperation" in groups + assert "tabIds.slice" in groups + assert "getLargeOperationThrottle" in groups + assert "runLargeOperation(\"group.close\"" in groups + assert "yieldForLargeOperation(createdTabs.length" in session + assert "getLargeOperationThrottle" in session + assert "runLargeOperation(\"session.load\"" in session + assert "chrome.tabs.discard" in session + assert "lazyPlaceholderUrl" in session + assert "activateLazyTab" in session + assert "lazySessionTabs" in session + assert "throwIfJobCancelled" in session + assert "updateJobProgress" in session + + index = (ROOT / "extension" / "src" / "index.ts").read_text() + assert "BACKGROUND_COMMANDS" in index + assert "startBackgroundJob" in index + assert "persistJobs" in index + assert "recentJobs" in index + assert "jobs.status" in index + assert "jobs.cancel" in index + assert "perf.status" in index + assert "perf.set_profile" in index + assert "__background" in index + + +def test_session_autosave_is_debounced_and_non_overlapping(): + session = (ROOT / "extension" / "src" / "commands" / "session.ts").read_text() + + assert "autoSaveTimer" in session + assert "autoSaveInFlight" in session + assert "autoSavePending" in session + assert "scheduleAutoSave" in session + assert "autoSaveUpdatedHandler" in session + assert "saveAutoSessionIfChanged" in session + assert "sessionSignature" in session + assert "autoSaveSignature" in session + assert "chrome.tabs.onUpdated.addListener(autoSaveUpdatedHandler)" in session + assert "chrome.tabs.onCreated.addListener(autoSaveHandler)" in session + assert "chrome.tabs.onMoved.addListener(autoSaveHandler)" in session + assert "if (!(\"url\" in changeInfo)) return;" in session + assert "setTimeout(runAutoSave, delayMs)" in session + assert "clearTimeout(autoSaveTimer)" in session + + +def test_cli_and_sdk_expose_gentle_restore_controls(): + session_cli = (ROOT / "browser_cli" / "commands" / "session.py").read_text() + tabs_cli = (ROOT / "browser_cli" / "commands" / "tabs.py").read_text() + groups_cli = (ROOT / "browser_cli" / "commands" / "groups.py").read_text() + sdk = (ROOT / "browser_cli" / "__init__.py").read_text() + + assert "--gentle-mode" in session_cli + assert "--discard-background-tabs" in session_cli + assert "--background" in session_cli + assert "--lazy" in session_cli + assert "--eager-tabs" in session_cli + assert "job-status" in session_cli + assert "job-cancel" in session_cli + assert "discardBackgroundTabs" in session_cli + assert "--gentle-mode" in tabs_cli + assert "gentleMode" in tabs_cli + assert "--gentle-mode" in groups_cli + assert "discard_background_tabs" in sdk + assert "discardBackgroundTabs" in sdk + assert "session_load_background" in sdk + assert "job_status" in sdk + assert "job_cancel" in sdk + assert "perf_status" in sdk + assert "set_performance_profile" in sdk + + perf_cli = (ROOT / "browser_cli" / "commands" / "perf.py").read_text() + root_cli = (ROOT / "browser_cli" / "cli.py").read_text() + assert "perf_group" in perf_cli + assert "perf.status" in perf_cli + assert "perf.set_profile" in perf_cli + assert "main.add_command(perf_group)" in root_cli diff --git a/tests/test_performance_integration.py b/tests/test_performance_integration.py new file mode 100644 index 0000000..cf6364b --- /dev/null +++ b/tests/test_performance_integration.py @@ -0,0 +1,157 @@ +"""Integration tests for browser performance controls and background jobs.""" +import time +import uuid + +import pytest + +def _wait_for_job(browser, job_id, timeout=10): + deadline = time.time() + timeout + last = None + while time.time() < deadline: + last = browser("jobs.status", {"jobId": job_id}) + if last.get("status") in {"done", "error", "cancelled"}: + return last + time.sleep(0.1) + return last or {} + +def _close_tabs(browser, tab_ids): + for tab_id in tab_ids: + try: + browser("tabs.close", {"tabId": tab_id}) + except Exception: + pass + +def _require_perf_features(browser): + try: + return browser("perf.status", {}) + except RuntimeError as exc: + if "Unknown command: perf.status" in str(exc): + pytest.skip("Running browser has not reloaded the v0.10.0 extension background worker yet") + raise + +def test_perf_status_and_profile_roundtrip_real_browser(browser): + initial = _require_perf_features(browser) + assert "performanceProfile" in initial + assert "audible" in initial + assert "throttle" in initial + assert "jobs" in initial + + original = initial.get("performanceProfile", "auto") + try: + changed = browser("perf.set_profile", {"profile": "gentle"}) + assert changed["performanceProfile"] == "gentle" + status = browser("perf.status", {}) + assert status["performanceProfile"] == "gentle" + assert status["throttle"]["mode"] == "gentle" + finally: + browser("perf.set_profile", {"profile": original}) + +def test_background_session_load_job_reports_progress_real_browser(browser): + _require_perf_features(browser) + name = f"_pytest_perf_job_{uuid.uuid4().hex}" + marker_url = f"https://example.com/?browser-cli-job={uuid.uuid4().hex}" + marker_tab = browser("navigate.open", {"url": marker_url, "background": True}) + loaded_ids = set() + + try: + browser("session.save", {"name": name}) + baseline_ids = {tab["id"] for tab in browser("tabs.list")} + + started = browser("session.load", { + "name": name, + "__background": True, + "lazy": True, + "eagerTabs": 0, + "gentleMode": "ultra", + }) + assert started["status"] == "running" + assert started["jobId"] + + status = _wait_for_job(browser, started["jobId"]) + assert status["status"] == "done" + assert status["command"] == "session.load" + assert status["percent"] == 100 + assert status["phase"] == "done" + assert status["total"] is None or status["total"] >= 0 + assert status.get("result", {}).get("lazy") is True + + loaded_ids = {tab["id"] for tab in browser("tabs.list")} - baseline_ids + assert loaded_ids, "Expected lazy session load to create tabs" + finally: + _close_tabs(browser, loaded_ids) + _close_tabs(browser, [marker_tab["id"]]) + try: + browser("session.remove", {"name": name}) + except Exception: + pass + +def test_lazy_session_load_creates_lightweight_placeholders_real_browser(browser): + _require_perf_features(browser) + name = f"_pytest_lazy_{uuid.uuid4().hex}" + marker_url = f"https://example.com/?browser-cli-lazy={uuid.uuid4().hex}" + marker_tab = browser("navigate.open", {"url": marker_url, "background": True}) + loaded_ids = set() + + try: + browser("session.save", {"name": name}) + baseline_ids = {tab["id"] for tab in browser("tabs.list")} + result = browser("session.load", {"name": name, "lazy": True, "eagerTabs": 0, "gentleMode": "ultra"}) + assert result["lazy"] is True + assert result["eagerTabs"] == 0 + + tabs_after = browser("tabs.list") + loaded_tabs = [tab for tab in tabs_after if tab["id"] not in baseline_ids] + loaded_ids = {tab["id"] for tab in loaded_tabs} + assert loaded_tabs + assert any((tab.get("url") or "").startswith("data:text/html") for tab in loaded_tabs) + finally: + _close_tabs(browser, loaded_ids) + _close_tabs(browser, [marker_tab["id"]]) + try: + browser("session.remove", {"name": name}) + except Exception: + pass + +def test_session_load_restores_pinned_tabs_real_browser(browser): + _require_perf_features(browser) + name = f"_pytest_pinned_{uuid.uuid4().hex}" + marker_url = f"https://example.com/?browser-cli-pinned={uuid.uuid4().hex}" + marker_tab = browser("navigate.open", {"url": marker_url, "background": True}) + loaded_ids = set() + + try: + browser("tabs.pin", {"tabId": marker_tab["id"]}) + browser("session.save", {"name": name}) + baseline_ids = {tab["id"] for tab in browser("tabs.list")} + + result = browser("session.load", {"name": name, "gentleMode": "ultra", "discardBackgroundTabs": True}) + assert result["tabs"] >= 1 + + loaded_tabs = [tab for tab in browser("tabs.list") if tab["id"] not in baseline_ids] + loaded_ids = {tab["id"] for tab in loaded_tabs} + matching = [tab for tab in loaded_tabs if tab.get("url") == marker_url] + assert matching, "Expected session load to restore marker tab" + assert matching[0].get("pinned") is True + finally: + _close_tabs(browser, loaded_ids) + _close_tabs(browser, [marker_tab["id"]]) + try: + browser("session.remove", {"name": name}) + except Exception: + pass + +def test_job_cancel_command_real_browser(browser): + _require_perf_features(browser) + started = browser("tabs.sort", {"by": "domain", "__background": True, "gentleMode": "ultra"}) + job_id = started["jobId"] + try: + cancelled = browser("jobs.cancel", {"jobId": job_id}) + assert cancelled["cancelled"] is True + except RuntimeError: + # Tiny real-browser sorts can finish before the cancel request arrives. + status = browser("jobs.status", {"jobId": job_id}) + assert status["status"] in {"done", "cancelled"} + return + + status = _wait_for_job(browser, job_id) + assert status["status"] in {"cancelled", "done"} diff --git a/tests/test_tabs.py b/tests/test_tabs.py index 70700a5..6e7959f 100644 --- a/tests/test_tabs.py +++ b/tests/test_tabs.py @@ -1,7 +1,6 @@ """Tests for tabs.* commands.""" import pytest - def test_tabs_list(browser): tabs = browser("tabs.list") assert isinstance(tabs, list) @@ -12,59 +11,51 @@ def test_tabs_list(browser): assert "url" in first assert "title" in first assert "muted" in first - + assert "groupId" in first def test_tabs_count(browser): count = browser("tabs.count", {}) tabs = browser("tabs.list") assert count == len(tabs) - def test_tabs_count_with_pattern(browser): count = browser("tabs.count", {"pattern": "http"}) assert isinstance(count, int) assert count >= 0 - def test_tabs_filter(browser): result = browser("tabs.filter", {"pattern": "http"}) assert isinstance(result, list) for tab in result: assert "http" in tab.get("url", "") - def test_tabs_query(browser): result = browser("tabs.query", {"search": "a"}) assert isinstance(result, list) - def test_tabs_active_exists(browser): tabs = browser("tabs.list") active = [t for t in tabs if t.get("active")] assert len(active) >= 1, "Expected at least one active tab" - def test_tabs_active_in_window(browser): active = next(t for t in browser("tabs.list") if t.get("active")) result = browser("tabs.active_in_window", {"windowId": active["windowId"]}) assert result["id"] == active["id"] assert result["windowId"] == active["windowId"] - def test_tabs_status(browser): result = browser("tabs.status", {}) assert isinstance(result, dict) assert "id" in result assert "muted" in result - def test_tabs_html(browser, http_tab): html = browser("tabs.html", {"tabId": http_tab["id"]}) assert isinstance(html, str) assert len(html) > 0 assert "