fix: prevent browser target and focus surprises
- Respect the globally selected browser when renaming client aliases. - Pass the resolved local profile into sync and async local transports so BROWSER_CLI_PROFILE is honored consistently. - Stop tabs.active from explicitly focusing the OS browser window, avoiding virtual-desktop jumps during tab activation. - Make window merging skip audible, unmuted windows so video playback windows are not selected as merge targets. - Bump the Python package and extension manifest versions to 0.12.2. - Add regression coverage for browser selection and focus-stealing behavior.
This commit is contained in:
@@ -137,10 +137,10 @@ def send_command(
|
|||||||
response = (
|
response = (
|
||||||
_send_remote(remote_endpoint, msg, private_key)
|
_send_remote(remote_endpoint, msg, private_key)
|
||||||
if remote_endpoint
|
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):
|
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)
|
return messages.decode_response(response)
|
||||||
|
|
||||||
@@ -192,9 +192,9 @@ async def send_command_async(
|
|||||||
response = (
|
response = (
|
||||||
await _send_remote_async(remote_endpoint, msg, private_key)
|
await _send_remote_async(remote_endpoint, msg, private_key)
|
||||||
if remote_endpoint
|
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):
|
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)
|
return messages.decode_response(response)
|
||||||
|
|||||||
@@ -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.",
|
help="Browser profile alias to rename. Overrides the global --browser option for this command.",
|
||||||
)
|
)
|
||||||
@click.argument("alias")
|
@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."""
|
"""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:
|
try:
|
||||||
_ensure_unique_browser_alias(alias, target_browser)
|
_ensure_unique_browser_alias(alias, selected_browser)
|
||||||
send_command("clients.rename_profile", {"alias": alias}, profile=target_browser)
|
send_command("clients.rename_profile", {"alias": alias}, profile=selected_browser)
|
||||||
except BrowserNotConnected as e:
|
except BrowserNotConnected as e:
|
||||||
console.print(f"[red]Error:[/red] {e}")
|
console.print(f"[red]Error:[/red] {e}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "browser-cli",
|
"name": "browser-cli",
|
||||||
"version": "0.12.1",
|
"version": "0.12.2",
|
||||||
"description": "Control your browser from the terminal or Python SDK",
|
"description": "Control your browser from the terminal or Python SDK",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"tabs",
|
"tabs",
|
||||||
|
|||||||
@@ -70,8 +70,6 @@ export class TabsMutationCommands extends CommandGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async tabsActive({ tabId }: TabIdArgs) {
|
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 });
|
await chrome.tabs.update(tabId, { active: true });
|
||||||
return { tabId };
|
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 = {}) {
|
private async tabsMergeWindows({ gentleMode, __job }: TabsMergeWindowsArgs = {}) {
|
||||||
return runLargeOperation("tabs.merge_windows", async () => {
|
return runLargeOperation("tabs.merge_windows", async () => {
|
||||||
const current = await chrome.windows.getCurrent();
|
|
||||||
const all = await chrome.windows.getAll({ populate: true });
|
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;
|
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 });
|
updateJobProgress(__job, { phase: "merging windows", current: 0, total: totalTabs });
|
||||||
for (const w of all) {
|
for (const w of movableWindows) {
|
||||||
if (w.id === current.id) continue;
|
if (w.id === target.id) continue;
|
||||||
const ids = w.tabs.map(t => t.id);
|
const ids = w.tabs.map(t => t.id);
|
||||||
const throttle = await getLargeOperationThrottle(ids.length, gentleMode);
|
const throttle = await getLargeOperationThrottle(ids.length, gentleMode);
|
||||||
moved = await processInBatches(ids, throttle,
|
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 });
|
{ job: __job, phase: "merging windows", total: totalTabs, baseCurrent: moved });
|
||||||
}
|
}
|
||||||
return { moved };
|
return { moved, skippedAudibleWindows: all.length - movableWindows.length };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "browser-cli"
|
name = "browser-cli"
|
||||||
version = "0.12.1"
|
version = "0.12.2"
|
||||||
description = "Control your real running browser from the terminal or Python SDK"
|
description = "Control your real running browser from the terminal or Python SDK"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
+1
-1
Submodule servicelink updated: 094bdc8c56...7b9a51ee52
+11
-1
@@ -49,7 +49,7 @@ def test_clients_rename_uses_global_browser_target_when_set():
|
|||||||
result = CliRunner().invoke(main, ["--browser", "old-id", "clients", "rename", "work"])
|
result = CliRunner().invoke(main, ["--browser", "old-id", "clients", "rename", "work"])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
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
|
assert "Restart the browser" not in result.output
|
||||||
|
|
||||||
def test_clients_rename_rejects_duplicate_alias(tmp_path):
|
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
|
assert "Browser alias 'work' already exists" in result.output
|
||||||
send_command.assert_not_called()
|
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):
|
def test_clients_rename_allows_same_alias_for_same_target(tmp_path):
|
||||||
registry_path = tmp_path / "registry.json"
|
registry_path = tmp_path / "registry.json"
|
||||||
registry_path.write_text('{"work": "/tmp/work.sock"}', encoding="utf-8")
|
registry_path.write_text('{"work": "/tmp/work.sock"}', encoding="utf-8")
|
||||||
|
|||||||
@@ -132,6 +132,44 @@ def test_send_command_auto_routes_single_remote_target(monkeypatch):
|
|||||||
assert sent["_route"] == "work"
|
assert sent["_route"] == "work"
|
||||||
assert "token" not in sent
|
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):
|
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_REMOTE", raising=False)
|
||||||
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
||||||
|
|||||||
@@ -111,6 +111,15 @@ def test_large_extension_operations_yield_between_batches():
|
|||||||
assert "perf.set_profile" in perf
|
assert "perf.set_profile" in perf
|
||||||
assert "__background" in connection
|
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():
|
def test_session_autosave_is_debounced_and_non_overlapping():
|
||||||
# The autosave lifecycle moved out of session.ts into a dedicated
|
# The autosave lifecycle moved out of session.ts into a dedicated
|
||||||
# AutoSaveManager (autosave.ts) during the structure refactor; the shared
|
# AutoSaveManager (autosave.ts) during the structure refactor; the shared
|
||||||
|
|||||||
Reference in New Issue
Block a user