from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[1] def read_built_background() -> str: background_path = ROOT / "extension" / "background.js" if not background_path.exists(): pytest.skip("extension/background.js is a generated build artifact; run npm run build:extension") return background_path.read_text() def test_extension_retries_error_page_script_injection_before_failing(): # core.ts was split into a core/ subfolder during the structure refactor: # the URL/error classifiers live in core/errors.ts and the executeScript # retry wrapper (which calls isTransientScriptError) lives in core/scripting.ts. errors = (ROOT / "extension" / "src" / "core" / "errors.ts").read_text() scripting = (ROOT / "extension" / "src" / "core" / "scripting.ts").read_text() assert "isBrowserErrorUrl" in errors assert "isErrorPageScriptError" in errors assert "chrome-error://" in errors assert "edge-error://" in errors assert "brave-error://" in errors assert "about:neterror" in errors assert "about:certerror" in errors assert "isTransientScriptError(e)" in scripting 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 "isBrowserErrorUrl(tabUrl)" in dom assert "isErrorPageScriptError(e)" in dom def test_navigation_and_tabs_report_browser_error_pages(): # tabs.watch_url (which reports tab error pages) moved into the read-only # TabsQueryCommands class in tabs-query.ts during the structure refactor. tabs = (ROOT / "extension" / "src" / "commands" / "tabs-query.ts").read_text() navigation = (ROOT / "extension" / "src" / "commands" / "navigation.ts").read_text() assert "lastUrl" in tabs assert "lastStatus" in tabs assert "showing an error page" in tabs 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(): # The large-operation throttling/queue helpers moved from core.ts into # core/throttle.ts when core was split into a subfolder. The slice-based # batch loop (cancel-check -> batch call -> progress -> yield) was then # centralized into the processInBatches() helper in core/throttle.ts, so the # command modules call that instead of inlining the loop. core = (ROOT / "extension" / "src" / "core" / "throttle.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 # The centralized batch loop drives cancellation + progress + throttled yield. assert "processInBatches" in core assert "throwIfJobCancelled(progress.job)" in core assert "yieldForLargeOperation(done" in core # tabs.close / tabs.merge_windows now batch via processInBatches; tabs.sort # still runs its own per-item move loop with yieldForLargeOperation. assert "processInBatches(toClose" in tabs assert "processInBatches(ids" in tabs assert "yieldForLargeOperation" in tabs assert "w.tabs.every" in tabs assert "getLargeOperationThrottle" in tabs assert "runLargeOperation(\"tabs.sort\"" in tabs assert "processInBatches(tabIds" 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 # The background-job machinery moved out of index.ts into the JobManager # (classes/JobManager.ts), the message router (classes/NativeConnection.ts) # and the command registry/groups during the class-based refactor. The # behavior is identical; assert the defining patterns still exist in their # new homes. jobs_module = (ROOT / "extension" / "src" / "classes" / "JobManager.ts").read_text() connection = (ROOT / "extension" / "src" / "classes" / "NativeConnection.ts").read_text() perf = (ROOT / "extension" / "src" / "commands" / "perf.ts").read_text() tabs_module = (ROOT / "extension" / "src" / "commands" / "tabs.ts").read_text() assert "background: true" in tabs_module # background command flag (was BACKGROUND_COMMANDS) assert "persistJobs" in jobs_module assert "recentJobs" in jobs_module assert "jobs.status" in perf assert "jobs.cancel" in perf assert "perf.status" in perf assert "perf.set_profile" in perf assert "__background" in connection def test_tab_activation_open_and_merge_do_not_steal_audible_video_window(): tabs = (ROOT / "extension" / "src" / "commands" / "tabs.ts").read_text() navigation = (ROOT / "extension" / "src" / "commands" / "navigation.ts").read_text() assert "await chrome.windows.update(tab.windowId, { focused: true });" not in tabs assert "active: Boolean(focus) && !background" in navigation assert "windowHasAudibleTabs" in tabs assert "!this.windowHasAudibleTabs(w)" in tabs assert "skippedAudibleWindows" in tabs assert "const target = movableWindows.find(w => w.focused) || movableWindows[0];" in tabs def test_built_extension_avoids_static_firefox_unsupported_tab_group_api_refs(): background = read_built_background() assert "chrome.tabGroups" not in background assert "chrome.tabs.group" not in background assert "chrome.tabs.ungroup" not in background assert 'chrome["tabGroups"' in background assert 'chrome.tabs["group"' in background def test_built_extension_avoids_direct_eval_token_for_firefox_linter(): background = read_built_background() assert "(0, eval)(" not in background assert "eval(" not in background assert 'globalThis["eval"]' in background def test_session_autosave_is_debounced_and_non_overlapping(): # The autosave lifecycle moved out of session.ts into a dedicated # AutoSaveManager (autosave.ts) during the structure refactor; the shared # snapshot/signature helpers live in session-snapshot.ts. Behavior is # identical — the defining patterns just live in their new homes. autosave = (ROOT / "extension" / "src" / "commands" / "autosave.ts").read_text() snapshot = (ROOT / "extension" / "src" / "commands" / "session-snapshot.ts").read_text() assert "autoSaveTimer" in autosave assert "autoSaveInFlight" in autosave assert "autoSavePending" in autosave assert "scheduleAutoSave" in autosave assert "autoSaveUpdatedHandler" in autosave assert "saveAutoSessionIfChanged" in autosave assert "sessionSignature" in snapshot assert "autoSaveSignature" in autosave # AutoSaveManager binds the handlers as instance fields (this.*), so the # add/removeListener references stay identity-stable across enable/disable. assert "chrome.tabs.onUpdated.addListener(this.autoSaveUpdatedHandler)" in autosave assert "chrome.tabs.onCreated.addListener(this.autoSaveHandler)" in autosave assert "chrome.tabs.onMoved.addListener(this.autoSaveHandler)" in autosave assert "if (!(\"url\" in changeInfo)) return;" in autosave assert "setTimeout(() => this.runAutoSave(), delayMs)" in autosave assert "clearTimeout(this.autoSaveTimer)" in autosave 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() commands_init = (ROOT / "browser_cli" / "commands" / "__init__.py").read_text() sdk_session = (ROOT / "browser_cli" / "sdk" / "session.py").read_text() sdk_perf = (ROOT / "browser_cli" / "sdk" / "perf.py").read_text() # The --gentle-mode flag is defined once via the shared gentle_mode_option helper. assert '"--gentle-mode"' in commands_init assert "gentle_mode_option" 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 "discard_background_tabs" in session_cli assert "gentle_mode_option" in tabs_cli assert "gentle_mode" in tabs_cli assert "gentle_mode_option" in groups_cli assert "discard_background_tabs" in sdk_session assert "discardBackgroundTabs" in sdk_session assert "def load_background" in sdk_session assert "def job_status" in sdk_perf assert "def job_cancel" in sdk_perf assert "def status" in sdk_perf assert "def set_profile" in sdk_perf 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