diff --git a/browser_cli/client/core.py b/browser_cli/client/core.py index 6e405d2..8d280c5 100644 --- a/browser_cli/client/core.py +++ b/browser_cli/client/core.py @@ -137,10 +137,10 @@ def send_command( response = ( _send_remote(remote_endpoint, msg, private_key) if remote_endpoint - else local_transport.send_local_sync(profile, payload, target_discovery.resolve_socket) + else local_transport.send_local_sync(requested_profile, payload, target_discovery.resolve_socket) ) except (FileNotFoundError, ConnectionRefusedError, OSError): - raise messages.remote_connection_error(remote_endpoint) if remote_endpoint else messages.local_connection_error(profile) + raise messages.remote_connection_error(remote_endpoint) if remote_endpoint else messages.local_connection_error(requested_profile) return messages.decode_response(response) @@ -192,9 +192,9 @@ async def send_command_async( response = ( await _send_remote_async(remote_endpoint, msg, private_key) if remote_endpoint - else await local_transport.send_local_async(profile, payload, target_discovery.resolve_socket) + else await local_transport.send_local_async(requested_profile, payload, target_discovery.resolve_socket) ) except (FileNotFoundError, ConnectionRefusedError, OSError): - raise messages.remote_connection_error(remote_endpoint) if remote_endpoint else messages.local_connection_error(profile) + raise messages.remote_connection_error(remote_endpoint) if remote_endpoint else messages.local_connection_error(requested_profile) return messages.decode_response(response) diff --git a/browser_cli/commands/clients.py b/browser_cli/commands/clients.py index edba3ba..20235a7 100644 --- a/browser_cli/commands/clients.py +++ b/browser_cli/commands/clients.py @@ -159,11 +159,14 @@ def _print_clients(all_clients: list) -> None: help="Browser profile alias to rename. Overrides the global --browser option for this command.", ) @click.argument("alias") -def cmd_clients_rename(target_browser, alias): +@click.pass_context +def cmd_clients_rename(ctx, target_browser, alias): """Set the profile alias used to identify this browser instance.""" + root_obj = ctx.find_root().obj or {} + selected_browser = target_browser or root_obj.get("browser") try: - _ensure_unique_browser_alias(alias, target_browser) - send_command("clients.rename_profile", {"alias": alias}, profile=target_browser) + _ensure_unique_browser_alias(alias, selected_browser) + send_command("clients.rename_profile", {"alias": alias}, profile=selected_browser) except BrowserNotConnected as e: console.print(f"[red]Error:[/red] {e}") sys.exit(1) diff --git a/extension/manifest.json b/extension/manifest.json index ea6a040..e0bee18 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "browser-cli", - "version": "0.12.1", + "version": "0.12.2", "description": "Control your browser from the terminal or Python SDK", "permissions": [ "tabs", diff --git a/extension/src/commands/tabs.ts b/extension/src/commands/tabs.ts index fe9391b..620d7f7 100644 --- a/extension/src/commands/tabs.ts +++ b/extension/src/commands/tabs.ts @@ -70,8 +70,6 @@ export class TabsMutationCommands extends CommandGroup { } private async tabsActive({ tabId }: TabIdArgs) { - const tab = await chrome.tabs.get(tabId); - await chrome.windows.update(tab.windowId, { focused: true }); await chrome.tabs.update(tabId, { active: true }); return { tabId }; } @@ -110,22 +108,29 @@ export class TabsMutationCommands extends CommandGroup { }); } + private windowHasAudibleTabs(window: chrome.windows.Window): boolean { + return Boolean(window.tabs?.some(tab => tab.audible && !tab.mutedInfo?.muted)); + } + private async tabsMergeWindows({ gentleMode, __job }: TabsMergeWindowsArgs = {}) { return runLargeOperation("tabs.merge_windows", async () => { - const current = await chrome.windows.getCurrent(); const all = await chrome.windows.getAll({ populate: true }); + const movableWindows = all.filter(w => !this.windowHasAudibleTabs(w)); + const target = movableWindows.find(w => w.focused) || movableWindows[0]; + if (!target) return { moved: 0, skippedAudibleWindows: all.length }; + let moved = 0; - const totalTabs = all.filter(w => w.id !== current.id).reduce((sum, w) => sum + (w.tabs?.length || 0), 0); + const totalTabs = movableWindows.filter(w => w.id !== target.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; + for (const w of movableWindows) { + if (w.id === target.id) continue; const ids = w.tabs.map(t => t.id); const throttle = await getLargeOperationThrottle(ids.length, gentleMode); moved = await processInBatches(ids, throttle, - batch => chrome.tabs.move(batch, { windowId: current.id, index: -1 }), + batch => chrome.tabs.move(batch, { windowId: target.id, index: -1 }), { job: __job, phase: "merging windows", total: totalTabs, baseCurrent: moved }); } - return { moved }; + return { moved, skippedAudibleWindows: all.length - movableWindows.length }; }); } diff --git a/pyproject.toml b/pyproject.toml index 154442c..d466554 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "browser-cli" -version = "0.12.1" +version = "0.12.2" description = "Control your real running browser from the terminal or Python SDK" requires-python = ">=3.10" dependencies = [ diff --git a/servicelink b/servicelink index 094bdc8..7b9a51e 160000 --- a/servicelink +++ b/servicelink @@ -1 +1 @@ -Subproject commit 094bdc8c569d2980d00a42b55039abd62254898f +Subproject commit 7b9a51ee525862a6e5eb99732a20aa1927d3ae62 diff --git a/tests/test_cli.py b/tests/test_cli.py index 4343c9f..76dc2eb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -49,7 +49,7 @@ def test_clients_rename_uses_global_browser_target_when_set(): result = CliRunner().invoke(main, ["--browser", "old-id", "clients", "rename", "work"]) assert result.exit_code == 0 - send_command.assert_called_once_with("clients.rename_profile", {"alias": "work"}, profile=None) + send_command.assert_called_once_with("clients.rename_profile", {"alias": "work"}, profile="old-id") assert "Restart the browser" not in result.output def test_clients_rename_rejects_duplicate_alias(tmp_path): @@ -63,6 +63,16 @@ def test_clients_rename_rejects_duplicate_alias(tmp_path): assert "Browser alias 'work' already exists" in result.output send_command.assert_not_called() +def test_clients_rename_duplicate_check_uses_global_browser_target(tmp_path): + registry_path = tmp_path / "registry.json" + registry_path.write_text('{"work": "/tmp/work.sock"}', encoding="utf-8") + + with patch("browser_cli.commands.clients.REGISTRY_PATH", registry_path), patch("browser_cli.commands.clients.send_command") as send_command: + result = CliRunner().invoke(main, ["--browser", "work", "clients", "rename", "work"]) + + assert result.exit_code == 0 + send_command.assert_called_once_with("clients.rename_profile", {"alias": "work"}, profile="work") + def test_clients_rename_allows_same_alias_for_same_target(tmp_path): registry_path = tmp_path / "registry.json" registry_path.write_text('{"work": "/tmp/work.sock"}', encoding="utf-8") diff --git a/tests/test_client.py b/tests/test_client.py index b931bbd..64149f9 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -132,6 +132,44 @@ def test_send_command_auto_routes_single_remote_target(monkeypatch): assert sent["_route"] == "work" assert "token" not in sent +def test_send_command_uses_env_profile_for_local_transport(monkeypatch): + monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False) + monkeypatch.setenv("BROWSER_CLI_PROFILE", "work") + seen = {} + + def fake_send_local(profile, payload, resolve_socket): + seen["profile"] = profile + seen["payload"] = json.loads(payload) + return json.dumps({"success": True, "data": "ok"}).encode("utf-8") + + monkeypatch.setattr("browser_cli.client.targets.is_active_local_profile", lambda profile: True) + monkeypatch.setattr("browser_cli.client.core.local_transport.send_local_sync", fake_send_local) + + assert send_command("tabs.list") == "ok" + assert seen["profile"] == "work" + assert seen["payload"]["command"] == "tabs.list" + +async def _async_local_profile_result(monkeypatch): + seen = {} + + async def fake_send_local(profile, payload, resolve_socket): + seen["profile"] = profile + return json.dumps({"success": True, "data": "ok"}).encode("utf-8") + + monkeypatch.setattr("browser_cli.client.targets.is_active_local_profile", lambda profile: True) + monkeypatch.setattr("browser_cli.client.core.local_transport.send_local_async", fake_send_local) + result = await send_command_async("tabs.list") + return result, seen + +def test_send_command_async_uses_env_profile_for_local_transport(monkeypatch): + monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False) + monkeypatch.setenv("BROWSER_CLI_PROFILE", "work") + + result, seen = asyncio.run(_async_local_profile_result(monkeypatch)) + + assert result == "ok" + assert seen["profile"] == "work" + def test_send_command_prefers_active_local_profile_over_saved_remote_alias(monkeypatch, tmp_path): monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False) monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False) diff --git a/tests/test_extension_error_page_handling.py b/tests/test_extension_error_page_handling.py index dfbf9b1..edbc7d4 100644 --- a/tests/test_extension_error_page_handling.py +++ b/tests/test_extension_error_page_handling.py @@ -111,6 +111,15 @@ def test_large_extension_operations_yield_between_batches(): assert "perf.set_profile" in perf assert "__background" in connection +def test_tab_activation_and_merge_do_not_steal_audible_video_window(): + tabs = (ROOT / "extension" / "src" / "commands" / "tabs.ts").read_text() + + assert "await chrome.windows.update(tab.windowId, { focused: true });" not in tabs + 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_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 diff --git a/uv.lock b/uv.lock index d47a973..fd189f9 100644 --- a/uv.lock +++ b/uv.lock @@ -18,7 +18,7 @@ wheels = [ [[package]] name = "browser-cli" -version = "0.12.1" +version = "0.12.2" source = { editable = "." } dependencies = [ { name = "click" },