feat(tabs): reuse an open tab instead of duplicating a page
- Add browser_cli/url_match.py with a canonical URL key for tab matching - Canonicalize scheme, host, default ports, www., trailing slash, query order - Support reuse modes none, exact, similar and origin via normalize_mode() - Pick the closest match when several tabs qualify, preferring identical URLs - Skip the reload when the reused tab already shows the requested page - Expose nav.reusable_tab() so callers can look up a tab without side effects - Accept reuse/reuse_domain/reuse_title in tabs.open(), not just nav.open() - Add --reuse-exact to nav open and nav open-wait; --reuse is now fuzzy - Default MCP tabs_open to reuse="similar" and return a reused flag - Tell MCP agents in the server instructions to reuse before opening - Document the reuse modes in README for MCP, CLI and Python API - Cover url_match, SDK reuse paths and MCP reuse behaviour with new tests - Bump package and extension version to 0.16.9 - Refresh uv.lock for the updated dependency set
This commit is contained in:
+36
-1
@@ -27,6 +27,7 @@ class FakeClient:
|
||||
open=self.tabs_open,
|
||||
close=self.tabs_close,
|
||||
active=self.tabs_active,
|
||||
activate=self.tabs_activate,
|
||||
status=self.tabs_status,
|
||||
screenshot=self.tabs_screenshot,
|
||||
)
|
||||
@@ -54,6 +55,9 @@ class FakeClient:
|
||||
self.calls.append(("active",))
|
||||
return SimpleNamespace(id=7)
|
||||
|
||||
def tabs_activate(self, tab_id):
|
||||
self.calls.append(("activate", tab_id))
|
||||
|
||||
def tabs_status(self, tab_id):
|
||||
self.calls.append(("status", tab_id))
|
||||
return {"id": tab_id, "title": "Navigated", "url": "https://example.com/next"}
|
||||
@@ -207,6 +211,7 @@ async def test_mutating_tools_use_sdk_and_return_fresh_state(client):
|
||||
"url": "https://example.com",
|
||||
"wait": True,
|
||||
"focus": True,
|
||||
"reuse": "none",
|
||||
})
|
||||
navigated = await client.call_tool("browser_navigate", {
|
||||
"tab_id": 8,
|
||||
@@ -216,7 +221,7 @@ async def test_mutating_tools_use_sdk_and_return_fresh_state(client):
|
||||
typed = await client.call_tool("browser_dom_type", {"selector": "#name", "text": "Daniel"})
|
||||
|
||||
assert opened.structured_content == {
|
||||
"id": 8, "title": "Opened", "url": "https://example.com"
|
||||
"id": 8, "title": "Opened", "url": "https://example.com", "reused": False
|
||||
}
|
||||
assert navigated.structured_content["id"] == 8
|
||||
assert clicked.structured_content["url"] == "https://example.com"
|
||||
@@ -230,6 +235,36 @@ async def test_mutating_tools_use_sdk_and_return_fresh_state(client):
|
||||
("navigate", 8, "https://example.com/next"), ("status", 8)
|
||||
]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tabs_open_reuses_a_tab_that_already_shows_the_page(client):
|
||||
opened = await client.call_tool("browser_tabs_open", {"url": "https://www.example.com/#top"})
|
||||
|
||||
assert opened.structured_content == {
|
||||
"id": 7, "title": "Navigated", "url": "https://example.com/next", "reused": True
|
||||
}
|
||||
assert FakeClient.instances[0].calls == [("status", 7)] # already there → no navigation
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tabs_open_reuses_a_site_tab_and_navigates_it(client):
|
||||
opened = await client.call_tool(
|
||||
"browser_tabs_open",
|
||||
{"url": "https://browser-host.example/app?view=logs", "focus": True},
|
||||
)
|
||||
|
||||
assert opened.structured_content["reused"] is True
|
||||
assert FakeClient.instances[0].calls == [
|
||||
("navigate", 9, "https://browser-host.example/app?view=logs"),
|
||||
("activate", 9),
|
||||
("status", 9),
|
||||
]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tabs_open_does_not_reuse_an_unrelated_tab(client):
|
||||
opened = await client.call_tool("browser_tabs_open", {"url": "https://other.example/docs"})
|
||||
|
||||
assert opened.structured_content["reused"] is False
|
||||
assert FakeClient.instances[0].calls[0][0] == "open"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tab_tools_default_to_the_active_tab(client):
|
||||
navigated = await client.call_tool("browser_navigate", {"url": "https://example.com/next"})
|
||||
|
||||
@@ -34,21 +34,60 @@ def test_session_export_cli_prints_json():
|
||||
assert result.exit_code == 0
|
||||
assert '"name": "work"' in result.output
|
||||
|
||||
def test_nav_open_reuse_navigates_existing_tab_instead_of_opening_new():
|
||||
calls = []
|
||||
def _reuse_sender(calls, tab_url="https://example.com"):
|
||||
tab = {"id": 7, "windowId": 1, "active": False, "muted": False, "title": "Example", "url": tab_url}
|
||||
|
||||
def sender(command, args=None, **kwargs):
|
||||
calls.append((command, args))
|
||||
if command == "tabs.list":
|
||||
return [{"id": 7, "windowId": 1, "active": False, "muted": False, "title": "Example", "url": "https://example.com"}]
|
||||
return [tab]
|
||||
if command in {"tabs.status", "navigate.open"}:
|
||||
return dict(tab)
|
||||
return {}
|
||||
return sender
|
||||
|
||||
BrowserCLI(browser="testing", _command_sender=sender).nav.open("https://example.com", reuse=True)
|
||||
def test_nav_open_reuse_navigates_existing_tab_instead_of_opening_new():
|
||||
calls = []
|
||||
|
||||
BrowserCLI(browser="testing", _command_sender=_reuse_sender(calls, "https://example.com/app?a=1")).nav.open(
|
||||
"https://example.com/app?a=2", reuse=True
|
||||
)
|
||||
assert calls == [
|
||||
("tabs.list", {}),
|
||||
("navigate.to", {"tabId": 7, "url": "https://example.com"}),
|
||||
("navigate.to", {"tabId": 7, "url": "https://example.com/app?a=2"}),
|
||||
]
|
||||
|
||||
def test_nav_open_reuse_matches_nearly_identical_urls():
|
||||
calls = []
|
||||
|
||||
BrowserCLI(browser="testing", _command_sender=_reuse_sender(calls, "https://example.com/app?a=1&b=2")).nav.open(
|
||||
"https://www.example.com/app/?b=2&a=1#top", reuse=True
|
||||
)
|
||||
assert calls == [("tabs.list", {})] # same page already open → no reload, no new tab
|
||||
|
||||
def test_nav_open_reuse_exact_opens_a_new_tab_for_a_different_url():
|
||||
calls = []
|
||||
|
||||
BrowserCLI(browser="testing", _command_sender=_reuse_sender(calls)).nav.open(
|
||||
"https://example.com/", reuse="exact"
|
||||
)
|
||||
assert [command for command, _ in calls] == ["tabs.list", "navigate.open"]
|
||||
|
||||
def test_tabs_open_reuse_returns_the_existing_tab():
|
||||
calls = []
|
||||
|
||||
tab = BrowserCLI(browser="testing", _command_sender=_reuse_sender(calls)).tabs.open(
|
||||
"https://example.com/deep/page", reuse="origin"
|
||||
)
|
||||
assert tab.id == 7
|
||||
assert [command for command, _ in calls] == ["tabs.list", "navigate.to", "tabs.status"]
|
||||
|
||||
def test_tabs_open_without_reuse_always_creates_a_tab():
|
||||
calls = []
|
||||
|
||||
BrowserCLI(browser="testing", _command_sender=_reuse_sender(calls)).tabs.open("https://example.com")
|
||||
assert [command for command, _ in calls] == ["navigate.open"]
|
||||
|
||||
def _tree_sender(tabs, groups):
|
||||
def sender(command, args=None, **kwargs):
|
||||
if command == "tabs.list":
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Matching a requested URL against already-open tabs."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from browser_cli.url_match import (
|
||||
find_reusable_tab,
|
||||
needs_navigation,
|
||||
normalize_mode,
|
||||
url_key,
|
||||
urls_match,
|
||||
)
|
||||
|
||||
def test_canonical_key_ignores_cosmetic_differences():
|
||||
assert url_key("https://Example.com:443/app/?b=2&a=1#top") == url_key("https://www.example.com/app?a=1&b=2")
|
||||
|
||||
def test_canonical_key_keeps_meaningful_differences():
|
||||
assert url_key("https://example.com/app") != url_key("http://example.com/app")
|
||||
assert url_key("https://example.com/app") != url_key("https://example.com:8080/app")
|
||||
assert url_key("https://example.com/a") != url_key("https://example.com/b")
|
||||
assert url_key("https://example.com/a?x=1") != url_key("https://example.com/a?x=2")
|
||||
|
||||
def test_non_urls_have_no_key():
|
||||
assert url_key(None) is None
|
||||
assert url_key("") is None
|
||||
assert url_key("not a url") is None
|
||||
|
||||
@pytest.mark.parametrize("reuse,reuse_domain,expected", [
|
||||
(False, False, "none"),
|
||||
(None, False, "none"),
|
||||
(True, False, "similar"),
|
||||
("exact", False, "exact"),
|
||||
("ORIGIN", False, "origin"),
|
||||
(False, True, "origin"),
|
||||
(True, True, "origin"),
|
||||
])
|
||||
def test_flags_fold_into_modes(reuse, reuse_domain, expected):
|
||||
assert normalize_mode(reuse, reuse_domain) == expected
|
||||
|
||||
def test_unknown_mode_is_rejected():
|
||||
with pytest.raises(ValueError, match="Unknown reuse mode"):
|
||||
normalize_mode("fuzzy")
|
||||
|
||||
def test_exact_mode_only_matches_identical_strings():
|
||||
assert urls_match("https://example.com/app", "https://example.com/app", "exact")
|
||||
assert not urls_match("https://example.com/app", "https://example.com/app/", "exact")
|
||||
|
||||
def test_similar_mode_matches_the_same_page():
|
||||
assert urls_match("https://example.com/app/", "https://example.com/app#section", "similar")
|
||||
assert not urls_match("https://example.com/app", "https://example.com/other", "similar")
|
||||
assert not urls_match("https://example.com/app", "https://other.example/app", "similar")
|
||||
|
||||
def test_similar_mode_treats_a_site_root_request_as_the_site():
|
||||
assert urls_match("https://example.com/", "https://example.com/web/streams", "similar")
|
||||
assert not urls_match("https://example.com/web", "https://example.com/web/streams", "similar")
|
||||
|
||||
def test_origin_mode_matches_any_page_of_the_site():
|
||||
assert urls_match("https://example.com/web", "https://example.com/other", "origin")
|
||||
assert not urls_match("https://example.com/web", "http://example.com/web", "origin")
|
||||
|
||||
def test_none_mode_never_matches():
|
||||
assert not urls_match("https://example.com", "https://example.com", "none")
|
||||
assert find_reusable_tab([{"id": 1, "url": "https://example.com"}], "https://example.com", "none") is None
|
||||
|
||||
def test_closest_tab_wins():
|
||||
tabs = [
|
||||
{"id": 1, "url": "https://example.com/web/streams/detail"},
|
||||
{"id": 2, "url": "https://example.com/web"},
|
||||
{"id": 3, "url": "https://example.com/web/?tab=1#x"},
|
||||
]
|
||||
assert find_reusable_tab(tabs, "https://example.com/web", "similar")["id"] == 2
|
||||
assert find_reusable_tab(tabs, "https://example.com/", "similar")["id"] == 2
|
||||
|
||||
def test_matching_works_on_sdk_tab_objects():
|
||||
from browser_cli.models import Tab
|
||||
|
||||
tabs = [Tab(id=4, window_id=1, active=False, url="https://example.com/app?a=1")]
|
||||
assert find_reusable_tab(tabs, "https://example.com/app/#top?", "none") is None
|
||||
assert find_reusable_tab(tabs, "https://example.com/app?a=1", "similar").id == 4
|
||||
|
||||
def test_navigation_is_skipped_only_for_the_same_page():
|
||||
assert not needs_navigation("https://example.com/app/", "https://example.com/app#top")
|
||||
assert needs_navigation("https://example.com/app", "https://example.com/app/next")
|
||||
assert needs_navigation(None, "https://example.com/app")
|
||||
Reference in New Issue
Block a user