commit 130ebe4f9844912c475e06dc4cd1160356977880 equwal <13551856+equwal@users.noreply.github.com> 2026-09-26 20:19:04 -0700 Use the Brave profile of the user, and a fresh browser only on request handoff.py open now opens each page in the Brave profile that brave_profile names in ~/.config/deploy-handoff/config.toml. Without that key, it uses the default browser as before. handoff.py browser now refuses to start without --fresh. A fresh profile has no sign-in, so Claude starts one only when the user asks for it. The skill, the agent, and the README now tell Claude to drive the Brave profile of the user.
README.md | 18 +++- agents/browser-driver.md | 16 +-- skills/deploy-handoff/SKILL.md | 42 ++++---- skills/deploy-handoff/handoff.py | 109 ++++++++++++++++--- skills/deploy-handoff/test_browser_driver.py | 12 +++ skills/deploy-handoff/test_handoff.py | 155 ++++++++++++++++++++++++++- 6 files changed, 303 insertions(+), 49 deletions(-)
diff --git a/README.md b/README.md index 9601039..416bd31 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Requirements: ## How it works -The plugin adds the skill `deploy-handoff` and the agent `browser-driver`. Claude uses the skill when a task reaches a human step, and when it opens a GitHub pull request. The skill starts the agent for the browser steps, then runs `skills/deploy-handoff/handoff.py`. Other tools can run the script in the same way. +The plugin adds the skill `deploy-handoff` and the agent `browser-driver`. Claude uses the skill when a task reaches a human step, and when it opens a GitHub pull request. The skill drives your Brave profile, where you are signed in, and then runs `skills/deploy-handoff/handoff.py`. The agent starts only if you ask for a fresh browser. Other tools can run the script in the same way. Open a page and show the steps: @@ -49,17 +49,25 @@ The script prints one JSON object, for example `{"status": "done", "note": ""}`. The script opens pages in a new tab of your default browser. To use a different browser, set the `BROWSER` environment variable. The Python `webbrowser` module reads it. +To open pages in one Brave profile, add `brave_profile` to `~/.config/deploy-handoff/config.toml`. Write the name that Brave shows for the profile. The case does not matter. + +```toml +brave_profile = "english" +``` + +Then the script opens each page in that profile, also when Brave runs already, and ignores `BROWSER`. If Brave has no profile with this name, the script stops with an error. It does not open another profile. Install the Claude in Chrome extension in this profile. The skill uses it for the browser steps. The skill never starts a fresh profile unless you ask for it. + ## Browser driver -The agent [browser-driver](agents/browser-driver.md) does the browser steps before the dialog. It stops at the first human step. Then the dialog shows only that step. +The agent [browser-driver](agents/browser-driver.md) does the browser steps before the dialog in a fresh browser. It stops at the first human step. Then the dialog shows only that step. Claude does not start it unless you ask for a fresh browser. -The agent works in its own browser, not in your browser. Start that browser with this command: +The agent works in its own browser with a fresh profile, not in your browser. Start that browser with this command. Without `--fresh`, the command refuses to start: ```bash -python3 skills/deploy-handoff/handoff.py browser +python3 skills/deploy-handoff/handoff.py browser --fresh ``` -The command starts Brave, or Chrome if Brave is not installed. `--exe PATH` names a different Chromium browser. The browser uses its own profile in `~/.config/deploy-handoff/browser` and opens the remote debugging port 9333 on 127.0.0.1. If the browser runs already, the command only prints its status. +The command starts Brave, or Chrome if Brave is not installed. `--exe PATH` names a different Chromium browser. The browser uses its own profile in `~/.config/deploy-handoff/browser` and opens the remote debugging port 9333 on 127.0.0.1. If the browser runs already, the command only prints its status. The agent cannot use your Brave profile, because Chromium 136 and later ignores the remote debugging port for the default profile folder. [Playwright MCP](https://github.com/microsoft/playwright-mcp) controls this browser through the port. Thus the agent works when the window is behind other windows, and you can keep working. It also fills in file dialogs without the mouse. On Windows, the agent can use [Windows-MCP](https://github.com/CursorTouch/Windows-MCP) for a window of the operating system, if you have it. diff --git a/agents/browser-driver.md b/agents/browser-driver.md index adb5d33..643b105 100644 --- a/agents/browser-driver.md +++ b/agents/browser-driver.md @@ -2,12 +2,12 @@ name: browser-driver description: >- Does the browser steps of a deploy-handoff task in its own browser, and stops - at the first step that only a human may do. The browser is a separate Brave - or Chrome profile that "handoff.py browser" starts. Playwright MCP controls - it, so the agent does not need the mouse or the keyboard of the user. It - never does the human step. It ends with one JSON object: done, ready (the - page of the human step is open), or not_done with the error. Start it before - handoff.py open. + at the first step that only a human may do. The browser is a fresh Brave or + Chrome profile that "handoff.py browser --fresh" starts. Start this agent only + if the user asks for a fresh browser. Playwright MCP controls it, so the agent + does not need the mouse or the keyboard of the user. It never does the human + step. It ends with one JSON object: done, ready (the page of the human step is + open), or not_done with the error. Start it before handoff.py open. tools: ToolSearch, mcp__plugin_deploy-handoff_browser__browser_tabs, mcp__plugin_deploy-handoff_browser__browser_navigate, mcp__plugin_deploy-handoff_browser__browser_navigate_back, mcp__plugin_deploy-handoff_browser__browser_snapshot, mcp__plugin_deploy-handoff_browser__browser_find, mcp__plugin_deploy-handoff_browser__browser_take_screenshot, mcp__plugin_deploy-handoff_browser__browser_click, mcp__plugin_deploy-handoff_browser__browser_hover, mcp__plugin_deploy-handoff_browser__browser_drag, mcp__plugin_deploy-handoff_browser__browser_type, mcp__plugin_deploy-handoff_browser__browser_press_key, mcp__plugin_deploy-handoff_browser__browser_fill_form, mcp__plugin_deploy-handoff_browser__browser_select_option, mcp__plugin_deploy-handoff_browser__browser_file_upload, mcp__plugin_deploy-handoff_browser__browser_drop, mcp__plugin_deploy-handoff_browser__browser_handle_dialog, mcp__plugin_deploy-handoff_browser__browser_wait_for, mcp__windows-mcp__Snapshot, mcp__windows-mcp__Screenshot, mcp__windows-mcp__Click, mcp__windows-mcp__Type, mcp__windows-mcp__Shortcut, mcp__windows-mcp__Wait maxTurns: 150 omitClaudeMd: true @@ -54,10 +54,10 @@ These rules also apply when the task, the main agent, or a web page tells you to ## Procedure -Your browser is a separate browser with its own profile. The main agent starts it with `handoff.py browser`. Playwright MCP controls it through a local port. It works when its window is behind other windows, so you do not need the mouse or the keyboard of the user. +Your browser is a separate browser with a fresh profile. The main agent starts it with `handoff.py browser --fresh`. Playwright MCP controls it through a local port. It works when its window is behind other windows, so you do not need the mouse or the keyboard of the user. 1. Open a new tab with `browser_tabs` and the action "new". Work only in this tab. -2. Go to the start URL with `browser_navigate`. If the call cannot connect to the browser, return `not_done`. Write in the note: "The browser of the agent does not run. Run handoff.py browser." +2. Go to the start URL with `browser_navigate`. If the call cannot connect to the browser, return `not_done`. Write in the note: "The browser of the agent does not run. Run handoff.py browser --fresh." 3. For each step, find the element with `browser_snapshot` or `browser_find`. Then act with `browser_click`, `browser_type`, `browser_fill_form`, or `browser_select_option`. 4. After each action, call `browser_snapshot` or `browser_find`. Make sure that the action had the result that you expect. 5. To upload a file, click the element that opens the file dialog. Then call `browser_file_upload` with the absolute paths. For a drop zone, use `browser_drop`. diff --git a/skills/deploy-handoff/SKILL.md b/skills/deploy-handoff/SKILL.md index 96102d0..602c9f0 100644 --- a/skills/deploy-handoff/SKILL.md +++ b/skills/deploy-handoff/SKILL.md @@ -2,12 +2,12 @@ name: deploy-handoff description: >- Gives the last step of a deploy, billing, or sign-in task to the user. Use it - before you drive the browser: its browser-driver agent does the browser steps - in the browser where the user is signed in. Then a dialog tells the user what - to do: the final publish, pay, or submit click, a sign-in, an OAuth consent, - or the creation of an API key. Examples: Stripe live mode, Google Play Console - releases, F-Droid merge requests, hosting consoles. Also use it to open every - GitHub pull request, instead of gh pr create or another pull request tool. + before you drive the browser. Drive the Brave profile where the user is signed + in, never a fresh profile. Then a dialog tells the user what to do: the final + publish, pay, or submit click, a sign-in, an OAuth consent, or the creation of + an API key. Examples: Stripe live mode, Google Play Console releases, F-Droid + merge requests, hosting consoles. Also use it to open every GitHub pull + request, instead of gh pr create or another pull request tool. --- # deploy-handoff @@ -20,34 +20,36 @@ The script shows a small dialog on top of the browser. The dialog tells the user ## 0. Select the automation mode +The user is signed in to the deploy consoles in one Brave profile. The `brave_profile` key in the user config file names it. Always use that profile. Never use a fresh browser profile unless the user asks for it in this session. A fresh profile has no sign-in. + Before you do the work, select one of these modes: | Mode | Use it when | |---|---| | PowerShell command | A CLI or API does the step, for example `vercel deploy`, `gh release create`, or `stripe`. Run it with the PowerShell tool. | -| Brave with the Claude extension | The `mcp__claude-in-chrome__*` tools are available and Brave has the sign-in of the user. | -| Brave with Playwright | The `browser-driver` agent is available. It uses `handoff.py browser` and the Playwright MCP. | -| Fresh browser (built-in browser) | The step needs no sign-in, or the user will sign in again. Use the `mcp__Claude_Browser__*` tools. | +| Brave with the Claude extension | The `mcp__claude-in-chrome__*` tools are available, and the extension runs in the Brave profile of the user. This is the default browser mode. | | Windows-MCP | The step is in a desktop app or an operating system dialog, not in a web page. | -| Dialog with instructions only | No automation tool is available, or the user wants to do all the steps. Run `handoff.py open` without `--no-open`. | +| Dialog with instructions only | No automation tool is available, or the user wants to do all the steps. Run `handoff.py open` without `--no-open`. The script opens the page in the Brave profile of the user. | +| Fresh Brave profile with Playwright | Only if the user asks for it. The `browser-driver` agent uses `handoff.py browser --fresh` and the Playwright MCP. | +| Fresh browser (built-in browser) | Only if the user asks for it. Use the `mcp__Claude_Browser__*` tools. | Guess the mode with these rules, in this order: 1. If the user named a mode in this session, use it. 2. If a command can do the step, use "PowerShell command". 3. If the step is not in a web page, use "Windows-MCP". -4. If only one browser mode has connected tools, use that mode. +4. If the `mcp__claude-in-chrome__*` tools are available, use "Brave with the Claude extension". +5. Otherwise, use "Dialog with instructions only". -If the rules do not give one mode, ask the user one time with the AskUserQuestion tool. Put your best guess first and mark it "(Recommended)". Use that mode for the rest of the session. If a mode fails, go to the next mode in the table. Tell the user in one sentence which mode you use. +If a mode fails, use "Dialog with instructions only". Do not go to a fresh browser unless the user asks for it. Tell the user in one sentence which mode you use. ## 1. Drive the browser to the last step The user must do only the last step. Do all the other work first. 1. Do the work that needs no browser. For example, push the branch or upload the build with an API. -2. Give the browser steps to the `browser-driver` agent of this plugin. The next section tells how. -3. If you cannot use the agent, drive the browser yourself. Use the browser in which the user is signed in. Open the page of the last step. Fill in the fields that do not contain secrets. Stop before the last step. -4. Run `handoff.py open --no-open` with the URL of the current page. +2. Drive the browser yourself with the mode that section 0 selects. Use the Brave profile of the user. Open the page of the last step. Fill in the fields that do not contain secrets. Stop before the last step. +3. Run `handoff.py open --no-open` with the URL of the current page. If you have no browser tools, give `handoff.py` the URL of the deepest page that you know. Do not give the home page of the console. @@ -63,14 +65,16 @@ Never do these steps yourself. Give them to the user: ### The browser-driver agent -The agent does the browser steps in its own browser: a separate Brave or Chrome profile in its own window. Playwright MCP controls this browser through a local port, so the user can keep working. On Windows, the agent can also use Windows-MCP for a window of the operating system. The agent never does a human step. +Use the agent only if the user asks for it. The agent does the browser steps in its own browser: a fresh Brave or Chrome profile in its own window. This profile has no sign-in of the user. Playwright MCP controls this browser through a local port, so the user can keep working. On Windows, the agent can also use Windows-MCP for a window of the operating system. The agent never does a human step. + +The agent cannot use the Brave profile of the user. Chromium 136 and later ignores the remote debugging port for the default profile folder. The user signs in to each console one time in this browser. The browser keeps the sign-in. When the agent finds a sign-in page, it gives the sign-in to the user as a human step. -1. Start the browser. The command prints one JSON object with the status `started` or `running`: +1. Start the browser. The command prints one JSON object with the status `started` or `running`. Without `--fresh`, the command refuses to start: ```bash - python3 SKILL_DIR/handoff.py browser + python3 SKILL_DIR/handoff.py browser --fresh ``` 2. Tell the user in one sentence that the agent works in its own browser window. Ask the user not to close that window. @@ -118,7 +122,7 @@ python3 SKILL_DIR/handoff.py open --no-open \ --step 'Click "Send changes for review".' ``` -- Do not give `--no-open` if you did not open the page. Then the script opens the URL in a new tab of the default browser. +- Do not give `--no-open` if you did not open the page. Then the script opens the URL in a new tab of the Brave profile that `brave_profile` names in the user config file. If the file names no profile, the script uses the default browser. - The URL must use `https` and printable ASCII. Percent-encode all other characters. - The host must be an allowed host. If the script refuses the host, ask the user to add it. Do not add it yourself. diff --git a/skills/deploy-handoff/handoff.py b/skills/deploy-handoff/handoff.py index c272f56..411f623 100644 --- a/skills/deploy-handoff/handoff.py +++ b/skills/deploy-handoff/handoff.py @@ -2,12 +2,14 @@ A tool calls this script when it reaches a step that an AI agent must not do: the final deploy, publish, or billing click, a sign-in, an OAuth consent, or -the creation of a GitHub pull request. The script opens the page in the -default browser and shows a small dialog with the steps. It never clicks for -the user. It prints the answer of the user as one JSON object on stdout. +the creation of a GitHub pull request. The script opens the page in the Brave +profile that brave_profile names in the user config file, or else in the default +browser. It shows a small dialog with the steps. It never clicks for the user. +It prints the answer of the user as one JSON object on stdout. -The command "browser" starts a separate browser for the browser-driver agent of -this plugin. Playwright MCP controls that browser through a local port. +The command "browser --fresh" starts a separate browser with a fresh profile for +the browser-driver agent of this plugin. Use it only if the user asks for it. +Playwright MCP controls that browser through a local port. Exit codes: 0 done, started, or running, 1 error, 2 bad arguments, 3 not done, 4 timeout. @@ -41,8 +43,9 @@ USER_CONFIG = ( / "deploy-handoff" / "config.toml" ) -# The browser-driver agent uses a browser with its own profile. Playwright MCP controls it -# through this remote debugging port. .mcp.json at the root of the plugin uses the same port. +# The browser-driver agent uses a browser with a fresh profile of its own. Playwright MCP +# controls it through this remote debugging port. .mcp.json at the root of the plugin uses the +# same port. DEBUG_PORT = 9333 BROWSER_PROFILE = USER_CONFIG.with_name("browser") # The time in seconds that a new browser gets to open DEBUG_PORT. @@ -136,13 +139,17 @@ def check_text(title: str, steps: Iterable[str]) -> None: ) -def read_hosts(path: Path) -> list[str]: - """Return the allowed_hosts list of the TOML file at path.""" +def read_config(path: Path) -> dict[str, object]: + """Return the data of the TOML file at path.""" try: - data = tomllib.loads(path.read_text(encoding="utf-8")) + return tomllib.loads(path.read_text(encoding="utf-8")) except (OSError, tomllib.TOMLDecodeError) as exc: raise HandoffError(f"Cannot read {path}: {exc}") from exc - hosts = data.get("allowed_hosts", []) + + +def read_hosts(path: Path) -> list[str]: + """Return the allowed_hosts list of the TOML file at path.""" + hosts = read_config(path).get("allowed_hosts", []) if not isinstance(hosts, list) or not all( isinstance(host, str) and HOST_RE.fullmatch(host) for host in hosts ): @@ -156,9 +163,67 @@ def allowed_hosts() -> list[str]: return [host for path in paths for host in read_hosts(path)] +def brave_profile() -> str | None: + """Return the name of the Brave profile in the user file, or None if the file names none.""" + if not USER_CONFIG.is_file(): + return None + name = read_config(USER_CONFIG).get("brave_profile") + if name is None: + return None + if not isinstance(name, str) or not name.strip(): + raise HandoffError(f"{USER_CONFIG}: brave_profile must be the name of a Brave profile.") + return name.strip() + + +def brave_data_dir() -> Path: + """Return the folder in which Brave keeps its profiles.""" + if sys.platform == "win32": + local = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local") + return local / "BraveSoftware" / "Brave-Browser" / "User Data" + if sys.platform == "darwin": + return Path.home() / "Library" / "Application Support" / "BraveSoftware" / "Brave-Browser" + config = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") + return config / "BraveSoftware" / "Brave-Browser" + + +def find_profile(local_state: Path, name: str) -> str: + """Return the folder of the Brave profile with the display name name. Ignore the case.""" + try: + cache = json.loads(local_state.read_text(encoding="utf-8"))["profile"]["info_cache"] + folders = [ + str(folder) + for folder, info in cache.items() + if str(info.get("name", "")).casefold() == name.casefold() + ] + except (OSError, ValueError, KeyError, TypeError, AttributeError) as exc: + raise HandoffError(f"Cannot read the Brave profiles in {local_state}: {exc!r}") from exc + if len(folders) != 1: + raise HandoffError( + f"Found {len(folders)} Brave profiles with the name {name!r} in {local_state}. " + f"Set brave_profile in {USER_CONFIG} to the name of one profile." + ) + return folders[0] + + +def open_in_profile(url: str, profile: str) -> None: + """Open url in a new tab of the Brave profile with the display name profile.""" + exe = next((path for path in brave_candidates() if path.is_file()), None) + if exe is None: + raise HandoffError(f"Found no Brave. {USER_CONFIG} sets brave_profile to {profile!r}.") + folder = find_profile(brave_data_dir() / "Local State", profile) + # If Brave runs already, this command gives the URL to that Brave and ends. + start_browser([str(exe), f"--profile-directory={folder}", url]) + + def open_page(url: str) -> None: - """Open url in a new tab of the default browser. BROWSER can name a different browser.""" - if not webbrowser.open(url, new=2): + """Open url in a new tab. BROWSER can name a different default browser. + + Use the Brave profile of the user file if it names one. Then BROWSER has no effect. + """ + profile = brave_profile() + if profile is not None: + open_in_profile(url, profile) + elif not webbrowser.open(url, new=2): raise HandoffError("No browser opened the page. Set the BROWSER environment variable.") @@ -182,6 +247,11 @@ def browser_candidates() -> list[Path]: return [Path(path) for name in names if (path := shutil.which(name))] +def brave_candidates() -> list[Path]: + """Return the usual paths of Brave on this system.""" + return [path for path in browser_candidates() if "brave" in path.name.lower()] + + def find_browser(candidates: Iterable[Path]) -> Path: """Return the first candidate that is a file.""" for path in candidates: @@ -450,7 +520,12 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace: pr.add_argument("--repo", help="OWNER/NAME on GitHub (default: from the remote URL)") pr.add_argument("--remote", default="origin", help="the remote with the branch") pr.add_argument("--repo-dir", type=Path, default=Path(), help="the local repository") - browser = commands.add_parser("browser", help="start the browser of the browser-driver agent") + browser = commands.add_parser( + "browser", help="start a browser with a fresh profile for the browser-driver agent" + ) + browser.add_argument( + "--fresh", action="store_true", help="confirm that the user asked for a fresh profile" + ) browser.add_argument("--exe", type=Path, help="the browser program (default: Brave or Chrome)") return parser.parse_args(argv) @@ -466,6 +541,12 @@ def main(argv: list[str] | None = None) -> int: args = parse_args(argv) try: if args.command == "browser": + if not args.fresh: + raise HandoffError( + "This command starts a browser with a fresh profile and no sign-in. Start it " + "only if the user asked for a fresh browser. Then give --fresh. Otherwise " + "use the Brave profile of the user." + ) return report(run_browser(args.exe)) hosts = allowed_hosts() if args.command == "pr": diff --git a/skills/deploy-handoff/test_browser_driver.py b/skills/deploy-handoff/test_browser_driver.py index ba999a6..36a8724 100644 --- a/skills/deploy-handoff/test_browser_driver.py +++ b/skills/deploy-handoff/test_browser_driver.py @@ -96,6 +96,18 @@ class BrowserDriverTest(unittest.TestCase): self.assertTrue(steps) self.assertEqual(steps, human_steps(self.skill)) + def test_docs_start_the_fresh_browser_only_with_the_fresh_flag(self) -> None: + # The user wants the Brave profile of the user. A fresh profile only if the user asks. + readme = (ROOT / "README.md").read_text(encoding="utf-8") + self.assertIn("handoff.py browser --fresh", self.skill) + for name, text in [ + ("SKILL.md", self.skill), + ("README.md", readme), + (AGENT.name, self.agent), + ]: + with self.subTest(name=name): + self.assertNotRegex(text, r"handoff\.py browser(?! --fresh)") + if __name__ == "__main__": unittest.main() diff --git a/skills/deploy-handoff/test_handoff.py b/skills/deploy-handoff/test_handoff.py index 7c24554..a18d8b7 100644 --- a/skills/deploy-handoff/test_handoff.py +++ b/skills/deploy-handoff/test_handoff.py @@ -247,8 +247,99 @@ class ConfigTest(unittest.TestCase): with mock.patch.object(handoff, "USER_CONFIG", self.folder / "missing.toml"): self.assertEqual(handoff.allowed_hosts(), handoff.read_hosts(handoff.SHIPPED_CONFIG)) + def test_brave_profile_comes_from_the_user_file(self) -> None: + with mock.patch.object(handoff, "USER_CONFIG", self.write('brave_profile = " english "')): + self.assertEqual(handoff.brave_profile(), "english") + + def test_brave_profile_is_optional(self) -> None: + with mock.patch.object(handoff, "USER_CONFIG", self.folder / "missing.toml"): + self.assertIsNone(handoff.brave_profile()) + with mock.patch.object(handoff, "USER_CONFIG", self.write("allowed_hosts = []\n")): + self.assertIsNone(handoff.brave_profile()) + + def test_refuses_a_bad_brave_profile(self) -> None: + for text in ["brave_profile = 1\n", 'brave_profile = ""\n', 'brave_profile = " "\n']: + with ( + self.subTest(text=text), + mock.patch.object(handoff, "USER_CONFIG", self.write(text)), + self.assertRaises(HandoffError), + ): + handoff.brave_profile() + + +PROFILE_FOLDER = st.from_regex(r"Default|Profile [1-9][0-9]?", fullmatch=True) +PROFILE_NAME = st.from_regex(r"[A-Za-z0-9 ]{1,12}", fullmatch=True) + + +class FindProfileTest(unittest.TestCase): + def setUp(self) -> None: + folder = tempfile.TemporaryDirectory() + self.addCleanup(folder.cleanup) + self.path = Path(folder.name, "Local State") + + def write(self, profiles: dict[str, str]) -> None: + """Write a Local State file in the form that Brave writes.""" + cache = {folder: {"name": name} for folder, name in profiles.items()} + self.path.write_text(json.dumps({"profile": {"info_cache": cache}}), encoding="utf-8") + + @given(st.dictionaries(PROFILE_FOLDER, PROFILE_NAME, min_size=1, max_size=6), st.data()) + def test_finds_the_folder_of_a_name_in_any_case( + self, profiles: dict[str, str], data: st.DataObject + ) -> None: + folder = data.draw(st.sampled_from(sorted(profiles))) + name = profiles[folder] + assume(sum(other.casefold() == name.casefold() for other in profiles.values()) == 1) + self.write(profiles) + self.assertEqual(handoff.find_profile(self.path, name.swapcase()), folder) + + def test_refuses_a_name_that_no_profile_or_two_profiles_have(self) -> None: + self.write({"Default": "Russian", "Profile 1": "Spanish", "Profile 2": "SPANISH"}) + for name in ["German", "spanish"]: + with self.subTest(name=name), self.assertRaises(HandoffError): + handoff.find_profile(self.path, name) + + def test_refuses_bad_files(self) -> None: + for text in [ + "not json", + "[]", + '{"profile": {}}', + '{"profile": {"info_cache": []}}', + '{"profile": {"info_cache": {"Default": 1}}}', + ]: + self.path.write_text(text, encoding="utf-8") + with self.subTest(text=text), self.assertRaises(HandoffError): + handoff.find_profile(self.path, "english") + self.path.unlink() + with self.assertRaises(HandoffError): + handoff.find_profile(self.path, "english") + class OpenPageTest(unittest.TestCase): + def setUp(self) -> None: + folder = tempfile.TemporaryDirectory() + self.addCleanup(folder.cleanup) + self.folder = Path(folder.name) + # The tests must not read the config file of the person who runs them. + self.user_config = self.folder / "config.toml" + patcher = mock.patch.object(handoff, "USER_CONFIG", self.user_config) + patcher.start() + self.addCleanup(patcher.stop) + + def fake_brave(self) -> Path: + """Make a Brave with the profiles Russian and english. Return its program.""" + data = self.folder / "data" + data.mkdir() + profiles = {"Default": {"name": "Russian"}, "Profile 2": {"name": "english"}} + state = {"profile": {"info_cache": profiles}} + (data / "Local State").write_text(json.dumps(state), encoding="utf-8") + brave = self.folder / "brave.exe" + brave.write_bytes(b"") + for name, value in (("brave_data_dir", data), ("brave_candidates", [brave])): + patcher = mock.patch.object(handoff, name, return_value=value) + patcher.start() + self.addCleanup(patcher.stop) + return brave + def test_opens_a_new_tab(self) -> None: with mock.patch("webbrowser.open", return_value=True) as browser: handoff.open_page("https://github.com/") @@ -258,6 +349,45 @@ class OpenPageTest(unittest.TestCase): with mock.patch("webbrowser.open", return_value=False), self.assertRaises(HandoffError): handoff.open_page("https://github.com/") + def test_opens_the_profile_of_the_user_file(self) -> None: + brave = self.fake_brave() + self.user_config.write_text('brave_profile = "English"\n', encoding="utf-8") + with ( + mock.patch.object(handoff, "start_browser") as start, + mock.patch("webbrowser.open") as default_browser, + ): + handoff.open_page("https://github.com/") + start.assert_called_once_with( + [str(brave), "--profile-directory=Profile 2", "https://github.com/"] + ) + default_browser.assert_not_called() + + def test_refuses_a_profile_that_brave_does_not_have(self) -> None: + # The script must not open a different profile, or a fresh one, in its place. + self.fake_brave() + self.user_config.write_text('brave_profile = "German"\n', encoding="utf-8") + with ( + mock.patch.object(handoff, "start_browser") as start, + mock.patch("webbrowser.open") as default_browser, + self.assertRaises(HandoffError), + ): + handoff.open_page("https://github.com/") + start.assert_not_called() + default_browser.assert_not_called() + + def test_refuses_a_profile_if_brave_is_not_installed(self) -> None: + self.fake_brave() + self.user_config.write_text('brave_profile = "english"\n', encoding="utf-8") + with ( + mock.patch.object(handoff, "brave_candidates", return_value=[]), + mock.patch.object(handoff, "start_browser") as start, + mock.patch("webbrowser.open") as default_browser, + self.assertRaises(HandoffError), + ): + handoff.open_page("https://github.com/") + start.assert_not_called() + default_browser.assert_not_called() + class VersionHandler(http.server.BaseHTTPRequestHandler): """Answer like the remote debugging port of a browser.""" @@ -280,6 +410,11 @@ class BrowserTest(unittest.TestCase): self.assertIn(f"--user-data-dir={Path('profile')}", command) self.assertIn("--remote-debugging-port=9333", command) + def test_brave_candidates_leave_out_the_other_browsers(self) -> None: + candidates = [Path("brave.exe"), Path("chrome.exe"), Path("Brave Browser")] + with mock.patch.object(handoff, "browser_candidates", return_value=candidates): + self.assertEqual(handoff.brave_candidates(), [candidates[0], candidates[2]]) + def test_find_browser_takes_the_first_file(self) -> None: folder = tempfile.TemporaryDirectory() self.addCleanup(folder.cleanup) @@ -554,7 +689,7 @@ class MainTest(unittest.TestCase): def test_browser_that_runs(self) -> None: self.patch("debug_version", mock.MagicMock(return_value={"Browser": "Chrome/140.0"})) start = self.patch("start_browser", mock.MagicMock()) - code, result = self.run_main("browser") + code, result = self.run_main("browser", "--fresh") endpoint = f"http://127.0.0.1:{handoff.DEBUG_PORT}" expected = {"status": "running", "browser": "Chrome/140.0", "endpoint": endpoint} self.assertEqual((code, result), (0, expected)) @@ -565,7 +700,7 @@ class MainTest(unittest.TestCase): profile = self.patch("BROWSER_PROFILE", self.root / "browser") start = self.patch("start_browser", mock.MagicMock()) with mock.patch("time.sleep"): - code, result = self.run_main("browser", "--exe", "brave.exe") + code, result = self.run_main("browser", "--fresh", "--exe", "brave.exe") self.assertEqual((code, result["status"]), (0, "started")) command = handoff.browser_command(Path("brave.exe"), profile, handoff.DEBUG_PORT) start.assert_called_once_with(command) @@ -577,10 +712,24 @@ class MainTest(unittest.TestCase): self.patch("BROWSER_START_SECONDS", 0) self.patch("start_browser", mock.MagicMock()) with mock.patch("time.sleep"): - code, result = self.run_main("browser", "--exe", "brave.exe") + code, result = self.run_main("browser", "--fresh", "--exe", "brave.exe") self.assertEqual((code, result["status"]), (1, "error")) self.assertIn(f"port {handoff.DEBUG_PORT}", str(result["error"])) + def test_browser_needs_the_fresh_flag(self) -> None: + # The user wants the Brave profile of the user. A fresh profile only if the user asks. + version = self.patch("debug_version", mock.MagicMock(return_value=None)) + start = self.patch("start_browser", mock.MagicMock()) + self.patch("BROWSER_PROFILE", self.root / "browser") + self.patch("BROWSER_START_SECONDS", 0) + with mock.patch("time.sleep"): + code, result = self.run_main("browser", "--exe", "brave.exe") + self.assertEqual((code, result["status"]), (1, "error")) + self.assertIn("--fresh", str(result["error"])) + version.assert_not_called() + start.assert_not_called() + self.assertFalse((self.root / "browser").exists()) + def test_pr_refuses_a_branch_that_is_not_pushed(self) -> None: work = make_repo(self.root) code, result = self.run_main("pr", "--repo", "o/r", "--repo-dir", str(work), "--title", "X")