From eacdc9ef7eae4787838a668df72a875f4d28d688 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Sun, 25 Oct 2026 12:16:45 +0100 Subject: [PATCH 1/3] fix(agent): pass --url to the agent prompt --- src/reverse_api/auto_engineer.py | 9 ++++ src/reverse_api/cli.py | 4 ++ src/reverse_api/copilot_engineer.py | 1 + src/reverse_api/cursor_engineer.py | 2 + src/reverse_api/prompts/auto/start_url.md | 5 ++ .../prompts/auto/user_agent_browser.md | 2 +- .../prompts/auto/user_chrome_mcp.md | 2 +- .../prompts/auto/user_playwright.md | 2 +- tests/test_auto_engineer.py | 49 +++++++++++++++++++ tests/test_cli_agent_json.py | 30 ++++++++++++ tests/test_prompts.py | 2 + 11 files changed, 105 insertions(+), 3 deletions(-) create mode 100644 src/reverse_api/prompts/auto/start_url.md diff --git a/src/reverse_api/auto_engineer.py b/src/reverse_api/auto_engineer.py index 183907e5..5211f236 100644 --- a/src/reverse_api/auto_engineer.py +++ b/src/reverse_api/auto_engineer.py @@ -56,6 +56,7 @@ class AutoPromptEngineer(Protocol): agent_provider: str mcp_run_id: str headless: bool + start_url: str | None prompt: str scripts_dir: Path har_path: Path @@ -82,6 +83,7 @@ def __init__( # `headless` is auto-engineer specific: for MCP providers it configures the MCP # server's browser launch; for `agent-browser` it only adjusts prompt wording. headless = kwargs.pop("headless", False) + start_url = kwargs.pop("start_url", None) har_dir = get_har_dir(run_id, output_dir) har_path = har_dir / "recording.har" @@ -96,6 +98,7 @@ def __init__( self.mcp_run_id = run_id self.agent_provider = agent_provider self.headless = headless + self.start_url = start_url def _build_auto_prompts(self: AutoPromptEngineer) -> tuple[str, str]: """Build (system_prompt, user_message) for auto mode. @@ -136,6 +139,7 @@ def _build_auto_prompts(self: AutoPromptEngineer) -> tuple[str, str]: template_kwargs = { "prompt": self.prompt, "scripts_dir": str(self.scripts_dir), + "start_url_block": "\n" + load("auto/start_url", start_url=self.start_url) if self.start_url else "", } if self.agent_provider != "chrome-mcp": template_kwargs["har_path"] = str(self.har_path) @@ -346,6 +350,7 @@ class OpenCodeAutoEngineer(OpenCodeEngineer): def __init__(self, run_id: str, prompt: str, output_dir: str | None = None, agent_provider: str = "auto", **kwargs: Any) -> None: """Initialize OpenCode-backed agent engineer.""" headless = kwargs.pop("headless", False) + start_url = kwargs.pop("start_url", None) har_dir = get_har_dir(run_id, output_dir) har_path = har_dir / "recording.har" @@ -360,6 +365,7 @@ def __init__(self, run_id: str, prompt: str, output_dir: str | None = None, agen self.agent_provider = agent_provider self.mcp_name: str | None = None self.headless = headless + self.start_url = start_url def _get_active_prompts(self) -> tuple[str, str]: return ClaudeAutoEngineer._build_auto_prompts(self) @@ -599,6 +605,7 @@ def __init__( from .copilot_engineer import CopilotEngineer headless = kwargs.pop("headless", False) + start_url = kwargs.pop("start_url", None) har_dir = get_har_dir(run_id, output_dir) har_path = har_dir / "recording.har" @@ -613,6 +620,7 @@ def __init__( self.mcp_run_id = run_id self.agent_provider = agent_provider self.headless = headless + self.start_url = start_url def start_sync(self) -> None: self._engineer.start_sync() @@ -637,6 +645,7 @@ async def analyze_and_generate(self) -> dict[str, Any] | None: eng.agent_provider = self.agent_provider eng.mcp_run_id = self.mcp_run_id eng.headless = self.headless + eng.start_url = self.start_url if self.agent_provider == "agent-browser": ab_setup = ensure_agent_browser_runtime() diff --git a/src/reverse_api/cli.py b/src/reverse_api/cli.py index 7de2eeb9..367efc61 100644 --- a/src/reverse_api/cli.py +++ b/src/reverse_api/cli.py @@ -2165,6 +2165,7 @@ def run_auto_capture( output_language=output_language, interactive=interactive, headless=headless, + start_url=url, ) elif sdk == "copilot": from .auto_engineer import CopilotAutoEngineer @@ -2180,6 +2181,7 @@ def run_auto_capture( output_language=output_language, interactive=interactive, headless=headless, + start_url=url, ) elif sdk == "cursor": from .cursor_engineer import CursorAutoEngineer @@ -2196,6 +2198,7 @@ def run_auto_capture( output_language=output_language, interactive=interactive, headless=headless, + start_url=url, cursor_model=model or config_manager.get("cursor_model", "composer-2.5"), cursor_web_search=bool(config_manager.get("cursor_web_search", True)), cursor_setting_sources=_cursor_src, @@ -2214,6 +2217,7 @@ def run_auto_capture( output_language=output_language, interactive=interactive, headless=headless, + start_url=url, ) if json_event_sink is not None: diff --git a/src/reverse_api/copilot_engineer.py b/src/reverse_api/copilot_engineer.py index 2b38edb2..91806021 100644 --- a/src/reverse_api/copilot_engineer.py +++ b/src/reverse_api/copilot_engineer.py @@ -16,6 +16,7 @@ class CopilotEngineer(BaseEngineer): agent_provider: str mcp_run_id: str headless: bool + start_url: str | None def __init__( self, diff --git a/src/reverse_api/cursor_engineer.py b/src/reverse_api/cursor_engineer.py index 6ac607fe..2c68a23b 100644 --- a/src/reverse_api/cursor_engineer.py +++ b/src/reverse_api/cursor_engineer.py @@ -491,6 +491,7 @@ def __init__( **kwargs: Any, ) -> None: headless = kwargs.pop("headless", False) + start_url = kwargs.pop("start_url", None) from .utils import get_har_dir har_dir = get_har_dir(run_id, output_dir) @@ -506,6 +507,7 @@ def __init__( self.mcp_run_id = run_id self.agent_provider = agent_provider self.headless = headless + self.start_url = start_url def _cursor_mcp_servers(self) -> dict[str, Any]: if self.agent_provider == "agent-browser": diff --git a/src/reverse_api/prompts/auto/start_url.md b/src/reverse_api/prompts/auto/start_url.md new file mode 100644 index 00000000..0a3c1c68 --- /dev/null +++ b/src/reverse_api/prompts/auto/start_url.md @@ -0,0 +1,5 @@ + +{start_url} + + +Start the mission by opening this URL. diff --git a/src/reverse_api/prompts/auto/user_agent_browser.md b/src/reverse_api/prompts/auto/user_agent_browser.md index dec2b9a7..c8c481f2 100644 --- a/src/reverse_api/prompts/auto/user_agent_browser.md +++ b/src/reverse_api/prompts/auto/user_agent_browser.md @@ -1,7 +1,7 @@ {prompt} - +{start_url_block} {scripts_dir} diff --git a/src/reverse_api/prompts/auto/user_chrome_mcp.md b/src/reverse_api/prompts/auto/user_chrome_mcp.md index f4928139..9f6bd23f 100644 --- a/src/reverse_api/prompts/auto/user_chrome_mcp.md +++ b/src/reverse_api/prompts/auto/user_chrome_mcp.md @@ -1,7 +1,7 @@ {prompt} - +{start_url_block} {scripts_dir} diff --git a/src/reverse_api/prompts/auto/user_playwright.md b/src/reverse_api/prompts/auto/user_playwright.md index 2eba8907..78b6b73f 100644 --- a/src/reverse_api/prompts/auto/user_playwright.md +++ b/src/reverse_api/prompts/auto/user_playwright.md @@ -1,7 +1,7 @@ {prompt} - +{start_url_block} {scripts_dir} diff --git a/tests/test_auto_engineer.py b/tests/test_auto_engineer.py index f9a00591..b392b720 100644 --- a/tests/test_auto_engineer.py +++ b/tests/test_auto_engineer.py @@ -312,6 +312,55 @@ def test_opencode_delegates_prompts(self, tmp_path): assert "npx" in user_p +class TestStartUrlPrompt: + """The agent --url value reaches the user message.""" + + URL = "https://jobs.ashbyhq.com/openai" + + def _user_prompt(self, tmp_path, agent_provider, **kwargs): + with patch("reverse_api.auto_engineer.get_har_dir", return_value=tmp_path / "har"): + with patch("reverse_api.base_engineer.get_scripts_dir", return_value=tmp_path / "scripts"): + with patch("reverse_api.base_engineer.MessageStore"): + with patch("reverse_api.agent_browser.agent_browser_npx_package", return_value="agent-browser@test"): + eng = ClaudeAutoEngineer( + run_id="test123", + prompt="fetch all open job postings", + model="claude-sonnet-4-6", + output_dir=str(tmp_path), + agent_provider=agent_provider, + **kwargs, + ) + return eng._build_auto_prompts()[1] + + @pytest.mark.parametrize("agent_provider", ["auto", "chrome-mcp", "agent-browser"]) + def test_start_url_follows_mission(self, tmp_path, agent_provider): + user_p = self._user_prompt(tmp_path, agent_provider, start_url=self.URL) + assert f"\n{self.URL}\n" in user_p + assert user_p.index("") < user_p.index("") < user_p.index("") + + @pytest.mark.parametrize("agent_provider", ["auto", "chrome-mcp", "agent-browser"]) + def test_no_start_url_block_without_url(self, tmp_path, agent_provider): + user_p = self._user_prompt(tmp_path, agent_provider) + assert "" not in user_p + assert "\n\n" in user_p + + def test_opencode_renders_start_url(self, tmp_path): + with patch("reverse_api.auto_engineer.get_har_dir", return_value=tmp_path / "har"): + with patch("reverse_api.base_engineer.get_scripts_dir", return_value=tmp_path / "scripts"): + with patch("reverse_api.base_engineer.MessageStore"): + with patch("reverse_api.opencode_engineer.OpenCodeUI"): + eng = OpenCodeAutoEngineer( + run_id="test123", + prompt="fetch all open job postings", + output_dir=str(tmp_path), + opencode_provider="anthropic", + opencode_model="claude-opus-4-6", + start_url=self.URL, + ) + _sys_p, user_p = eng._get_active_prompts() + assert f"\n{self.URL}\n" in user_p + + class TestChromeMcpConfig: """Test MCP configuration selection.""" diff --git a/tests/test_cli_agent_json.py b/tests/test_cli_agent_json.py index d8a07630..02121378 100644 --- a/tests/test_cli_agent_json.py +++ b/tests/test_cli_agent_json.py @@ -686,3 +686,33 @@ def test_direct_interrupt_after_claude_result_keeps_partial_run(tmp_path, output payload = json.loads(result.stdout.strip().splitlines()[-1]) assert payload["error"] == "interrupted" assert payload["script_path"] == "completed.py" + + +@pytest.mark.parametrize( + "sdk,engine_path", + [ + ("claude", "reverse_api.auto_engineer.ClaudeAutoEngineer"), + ("opencode", "reverse_api.auto_engineer.OpenCodeAutoEngineer"), + ("copilot", "reverse_api.auto_engineer.CopilotAutoEngineer"), + ("cursor", "reverse_api.cursor_engineer.CursorAutoEngineer"), + ], +) +def test_agent_url_reaches_engine(tmp_path, sdk, engine_path): + from unittest.mock import AsyncMock + + from reverse_api.config import ConfigManager + + config = ConfigManager(tmp_path / "config.json") + config.config.update({"sdk": sdk, "agent_provider": "auto", "real_time_sync": False}) + engine = MagicMock() + engine.analyze_and_generate = AsyncMock(return_value=None) + with ( + patch("reverse_api.cli.config_manager", config), + patch("reverse_api.cli.session_manager", SessionManager(tmp_path / "history.json")), + patch(engine_path, return_value=engine) as engine_cls, + ): + CliRunner().invoke( + main, + ["agent", "-p", "fetch job postings", "-u", "https://jobs.ashbyhq.com/openai", "--headless", "-o", str(tmp_path), "--json"], + ) + assert engine_cls.call_args.kwargs["start_url"] == "https://jobs.ashbyhq.com/openai" diff --git a/tests/test_prompts.py b/tests/test_prompts.py index efa9f225..0408bb7e 100644 --- a/tests/test_prompts.py +++ b/tests/test_prompts.py @@ -219,6 +219,7 @@ def test_auto_user_playwright_loads(self): prompt="browse example.com", scripts_dir="/tmp/scripts", har_path="/tmp/recording.har", + start_url_block="", ) assert "browse example.com" in text assert "browser_navigate" in text @@ -229,6 +230,7 @@ def test_auto_user_chrome_mcp_loads(self): "auto/user_chrome_mcp", prompt="browse example.com", scripts_dir="/tmp/scripts", + start_url_block="", ) assert "browse example.com" in text assert "navigate_page" in text From e1d611b636c3a789bb45c27fe231f1f465b9bb91 Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Sun, 25 Oct 2026 12:41:33 +0100 Subject: [PATCH 2/3] fix(agent): normalize bare-host --url and drop the example.com open step --- src/reverse_api/browser.py | 13 ++---------- src/reverse_api/cli.py | 4 ++++ .../prompts/auto/user_agent_browser.md | 2 +- src/reverse_api/utils.py | 9 ++++++++ tests/test_cli_agent_json.py | 21 +++++++++++++++++++ tests/test_manual_browser_startup_failure.py | 8 ++++--- 6 files changed, 42 insertions(+), 15 deletions(-) diff --git a/src/reverse_api/browser.py b/src/reverse_api/browser.py index 7df1d8ef..1249c065 100644 --- a/src/reverse_api/browser.py +++ b/src/reverse_api/browser.py @@ -15,7 +15,7 @@ from rich.console import Console from rich.status import Status -from .utils import get_har_dir, get_timestamp +from .utils import get_har_dir, get_timestamp, normalize_url console = Console() @@ -233,15 +233,6 @@ def _inject_stealth(self, page: Page) -> None: """Inject stealth scripts into page before any other scripts run.""" page.add_init_script(STEALTH_JS) - @staticmethod - def _normalize_url(url: str) -> str: - """Prepend https:// when the user typed a bare host (e.g. - ``jobs.example.com/x``); Playwright's goto rejects scheme-less URLs.""" - url = url.strip() - if url and "://" not in url: - return "https://" + url - return url - def _abort_playwright(self) -> None: """Quietly tear down the browser/Playwright after a failed start. @@ -440,7 +431,7 @@ def start(self, start_url: str | None = None) -> Path: console.print() if start_url: - start_url = self._normalize_url(start_url) + start_url = normalize_url(start_url) self._playwright = sync_playwright().start() diff --git a/src/reverse_api/cli.py b/src/reverse_api/cli.py index 367efc61..0e8d9bb7 100644 --- a/src/reverse_api/cli.py +++ b/src/reverse_api/cli.py @@ -52,6 +52,7 @@ get_history_path, get_messages_path, get_timestamp, + normalize_url, resolve_run, ) @@ -2084,6 +2085,9 @@ def run_auto_capture( url = options.get("url") model = options["model"] + if url: + url = normalize_url(url) + if agent_provider == "chrome-mcp" and not headless: console.print() console.print(" [dim]chrome devtools mcp (auto-connect)[/dim]") diff --git a/src/reverse_api/prompts/auto/user_agent_browser.md b/src/reverse_api/prompts/auto/user_agent_browser.md index c8c481f2..5e0ef01a 100644 --- a/src/reverse_api/prompts/auto/user_agent_browser.md +++ b/src/reverse_api/prompts/auto/user_agent_browser.md @@ -59,7 +59,7 @@ Start HAR recording **before** navigation and confirm it succeeded (abort if thi export AGENT_BROWSER_SESSION="{agent_browser_session}" {agent_browser_shell} network har start # verify exit code 0; if non-zero, diagnose with `{agent_browser_shell} doctor` and stop -{agent_browser_shell} open https://example.com +{agent_browser_shell} open {agent_browser_shell} snapshot -i --json # … iterate … ``` diff --git a/src/reverse_api/utils.py b/src/reverse_api/utils.py index c641a065..31890c5e 100644 --- a/src/reverse_api/utils.py +++ b/src/reverse_api/utils.py @@ -63,6 +63,15 @@ ) +def normalize_url(url: str) -> str: + """Prepend https:// when the user typed a bare host (e.g. + ``jobs.example.com/x``); Playwright's goto rejects scheme-less URLs.""" + url = url.strip() + if url and "://" not in url: + return "https://" + url + return url + + def build_sdk_env() -> dict[str, str]: """Environment overrides for Claude Agent SDK subprocesses. diff --git a/tests/test_cli_agent_json.py b/tests/test_cli_agent_json.py index 02121378..21088241 100644 --- a/tests/test_cli_agent_json.py +++ b/tests/test_cli_agent_json.py @@ -716,3 +716,24 @@ def test_agent_url_reaches_engine(tmp_path, sdk, engine_path): ["agent", "-p", "fetch job postings", "-u", "https://jobs.ashbyhq.com/openai", "--headless", "-o", str(tmp_path), "--json"], ) assert engine_cls.call_args.kwargs["start_url"] == "https://jobs.ashbyhq.com/openai" + + +def test_agent_bare_host_url_gets_https_scheme(tmp_path): + from unittest.mock import AsyncMock + + from reverse_api.config import ConfigManager + + config = ConfigManager(tmp_path / "config.json") + config.config.update({"sdk": "claude", "agent_provider": "auto", "real_time_sync": False}) + engine = MagicMock() + engine.analyze_and_generate = AsyncMock(return_value=None) + with ( + patch("reverse_api.cli.config_manager", config), + patch("reverse_api.cli.session_manager", SessionManager(tmp_path / "history.json")), + patch("reverse_api.auto_engineer.ClaudeAutoEngineer", return_value=engine) as engine_cls, + ): + CliRunner().invoke( + main, + ["agent", "-p", "fetch job postings", "-u", "jobs.ashbyhq.com/openai", "--headless", "-o", str(tmp_path), "--json"], + ) + assert engine_cls.call_args.kwargs["start_url"] == "https://jobs.ashbyhq.com/openai" diff --git a/tests/test_manual_browser_startup_failure.py b/tests/test_manual_browser_startup_failure.py index be267d51..52bf45c8 100644 --- a/tests/test_manual_browser_startup_failure.py +++ b/tests/test_manual_browser_startup_failure.py @@ -36,6 +36,8 @@ def test_playwright_stopped_when_start_raises(self, tmp_path): assert browser._playwright is None def test_scheme_added_to_bare_host(self): - assert ManualBrowser._normalize_url("jobs.example.com/x") == "https://jobs.example.com/x" - assert ManualBrowser._normalize_url("http://a.com") == "http://a.com" - assert ManualBrowser._normalize_url(" https://b.com ") == "https://b.com" + from reverse_api.utils import normalize_url + + assert normalize_url("jobs.example.com/x") == "https://jobs.example.com/x" + assert normalize_url("http://a.com") == "http://a.com" + assert normalize_url(" https://b.com ") == "https://b.com" From a4664d3bedb731db6970a8ee475a677e1bc5666a Mon Sep 17 00:00:00 2001 From: kalil0321 Date: Sun, 25 Oct 2026 12:50:34 +0100 Subject: [PATCH 3/3] fix(agent): normalize --url for JSON output and dry-run, quote the open placeholder --- src/reverse_api/cli.py | 3 +++ .../prompts/auto/user_agent_browser.md | 2 +- tests/test_cli_agent_json.py | 3 ++- tests/test_cli_followups.py | 17 +++++++++++++++++ tests/test_manual_browser_startup_failure.py | 7 ------- tests/test_utils.py | 9 +++++++++ 6 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/reverse_api/cli.py b/src/reverse_api/cli.py index 0e8d9bb7..15a84f62 100644 --- a/src/reverse_api/cli.py +++ b/src/reverse_api/cli.py @@ -1767,6 +1767,9 @@ def agent( Agent mode runs an integrated capture + reverse-engineering pipeline. """ + if url: + url = normalize_url(url) + if dry_run: # --dry-run is fundamentally about emitting machine-parseable validation # results, so it implies --json (and therefore --no-interactive). diff --git a/src/reverse_api/prompts/auto/user_agent_browser.md b/src/reverse_api/prompts/auto/user_agent_browser.md index 5e0ef01a..41395948 100644 --- a/src/reverse_api/prompts/auto/user_agent_browser.md +++ b/src/reverse_api/prompts/auto/user_agent_browser.md @@ -59,7 +59,7 @@ Start HAR recording **before** navigation and confirm it succeeded (abort if thi export AGENT_BROWSER_SESSION="{agent_browser_session}" {agent_browser_shell} network har start # verify exit code 0; if non-zero, diagnose with `{agent_browser_shell} doctor` and stop -{agent_browser_shell} open +{agent_browser_shell} open "" # the start URL, or the site the mission names {agent_browser_shell} snapshot -i --json # … iterate … ``` diff --git a/tests/test_cli_agent_json.py b/tests/test_cli_agent_json.py index 21088241..c5ebbd35 100644 --- a/tests/test_cli_agent_json.py +++ b/tests/test_cli_agent_json.py @@ -732,8 +732,9 @@ def test_agent_bare_host_url_gets_https_scheme(tmp_path): patch("reverse_api.cli.session_manager", SessionManager(tmp_path / "history.json")), patch("reverse_api.auto_engineer.ClaudeAutoEngineer", return_value=engine) as engine_cls, ): - CliRunner().invoke( + result = CliRunner().invoke( main, ["agent", "-p", "fetch job postings", "-u", "jobs.ashbyhq.com/openai", "--headless", "-o", str(tmp_path), "--json"], ) assert engine_cls.call_args.kwargs["start_url"] == "https://jobs.ashbyhq.com/openai" + assert json.loads(result.stdout.strip().splitlines()[-1])["url"] == "https://jobs.ashbyhq.com/openai" diff --git a/tests/test_cli_followups.py b/tests/test_cli_followups.py index 632390d2..269ab58b 100644 --- a/tests/test_cli_followups.py +++ b/tests/test_cli_followups.py @@ -423,6 +423,23 @@ def test_dry_run_bad_url_is_misuse(self, tmp_path): assert payload["error_kind"] == "misuse" assert any(c["name"] == "url" and c["status"] == "error" for c in payload["checks"]) + def test_dry_run_bare_host_gets_https_scheme(self, tmp_path): + """A bare host passes the url check, as it does on a real run.""" + from reverse_api.cli import agent as agent_cmd + + runner = CliRunner() + with patch("reverse_api.cli.config_manager") as cm: + cm.get.side_effect = lambda key, default=None: { + "agent_provider": "auto", + "sdk": "claude", + "output_dir": str(tmp_path), + }.get(key, default) + result = runner.invoke(agent_cmd, ["--dry-run", "-p", "x", "-u", "jobs.example.com/x"]) + + payload = json.loads(result.stdout.strip()) + assert payload["url"] == "https://jobs.example.com/x" + assert any(c["name"] == "url" and c["status"] == "ok" for c in payload["checks"]) + def test_dry_run_unwritable_output_dir_is_config_invalid(self, tmp_path): """Unwritable output_dir → error_kind=config_invalid, not misuse.""" from reverse_api.cli import agent as agent_cmd diff --git a/tests/test_manual_browser_startup_failure.py b/tests/test_manual_browser_startup_failure.py index 52bf45c8..48c5fe1c 100644 --- a/tests/test_manual_browser_startup_failure.py +++ b/tests/test_manual_browser_startup_failure.py @@ -34,10 +34,3 @@ def test_playwright_stopped_when_start_raises(self, tmp_path): fake_pw.stop.assert_called_once() assert browser._playwright is None - - def test_scheme_added_to_bare_host(self): - from reverse_api.utils import normalize_url - - assert normalize_url("jobs.example.com/x") == "https://jobs.example.com/x" - assert normalize_url("http://a.com") == "http://a.com" - assert normalize_url(" https://b.com ") == "https://b.com" diff --git a/tests/test_utils.py b/tests/test_utils.py index 32bf8217..a708cf5a 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -867,3 +867,12 @@ def test_unrelated_error(self): assert not is_context_overflow_error("Unknown error") assert not is_context_overflow_error("rate limit exceeded") + + +class TestNormalizeUrl: + def test_scheme_added_to_bare_host(self): + from reverse_api.utils import normalize_url + + assert normalize_url("jobs.example.com/x") == "https://jobs.example.com/x" + assert normalize_url("http://a.com") == "http://a.com" + assert normalize_url(" https://b.com ") == "https://b.com"