commit 43d1af77a2b9ae0b9330a1efe90ba4a1abdc2f65 equwal <13551856+equwal@users.noreply.github.com> 2026-09-21 13:27:16 -0700 Add deploy-handoff plugin deploy-handoff shows a dialog that tells the user the last step of a deploy, billing, or sign-in task. The agent drives the browser to that step first. The user makes the final click, and the agent gets the answer as JSON. - handoff.py open: opens an https page on an allowed host in the default browser, or shows only the dialog with --no-open. - handoff.py pr: opens the GitHub pull request form with the title and the description filled in. It refuses a branch that is not pushed. - The script refuses a title or a step that is not short Simplified Technical English.
.claude-plugin/marketplace.json | 22 ++ .claude-plugin/plugin.json | 13 + .gitattributes | 1 + .gitignore | 5 + LICENSE | 21 ++ README.md | 78 ++++++ pyproject.toml | 18 ++ skills/deploy-handoff/SKILL.md | 119 +++++++++ skills/deploy-handoff/config.toml | 24 ++ skills/deploy-handoff/handoff.py | 369 +++++++++++++++++++++++++++ skills/deploy-handoff/test_handoff.py | 460 ++++++++++++++++++++++++++++++++++ 11 files changed, 1130 insertions(+)
diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..0f10a01 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", + "name": "deploy-handoff", + "description": "deploy-handoff: the human makes the final deploy, billing, and pull request click", + "owner": { + "name": "equwal", + "url": "https://github.com/equwal" + }, + "plugins": [ + { + "name": "deploy-handoff", + "description": "Opens deploy, billing, sign-in, and pull request pages in your browser, and waits while you make the final click. Claude never makes it for you.", + "version": "0.1.0", + "author": { + "name": "equwal" + }, + "source": "./", + "category": "deployment", + "homepage": "https://github.com/equwal/deploy-handoff" + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..9625346 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,13 @@ +{ + "name": "deploy-handoff", + "version": "0.1.0", + "description": "Opens deploy, billing, sign-in, and pull request pages in your browser, and waits while you make the final click. Claude never makes it for you.", + "author": { + "name": "equwal", + "url": "https://github.com/equwal" + }, + "homepage": "https://github.com/equwal/deploy-handoff", + "repository": "https://github.com/equwal/deploy-handoff", + "license": "MIT", + "keywords": ["deploy", "billing", "human-in-the-loop", "pull-request", "browser", "stripe", "google-play", "f-droid"] +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2d38043 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.venv/ +__pycache__/ +.hypothesis/ +.mypy_cache/ +.ruff_cache/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..160e1db --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 equwal + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..34c4281 --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# deploy-handoff + +A Claude Code plugin for the steps that an AI agent must not do alone. + +An agent can build, test, and upload a release. Some steps must come from a person: publish the release, turn on live payments, accept an OAuth consent, or create a pull request. The agent drives the browser as far as it can. Then deploy-handoff shows a small dialog on top of the browser. The dialog tells you what to do, in short steps in [Simplified Technical English](https://asd-ste100.org). You do the final click. Then you click **Done** or **Not done**, and the agent continues. + +deploy-handoff never clicks for you. It never asks for a password or a key. + +## Install + +In Claude Code: + +``` +/plugin marketplace add equwal/deploy-handoff +/plugin install deploy-handoff@deploy-handoff +``` + +Requirements: + +- Python 3.11 or later, with Tk. The python.org installers for Windows and macOS include Tk. On Debian and Ubuntu, install `python3-tk`. +- `git` for the `pr` command. `gh` is optional. If `gh` is available, the `pr` command gives the URL of the new pull request. + +## How it works + +The plugin adds the skill `deploy-handoff`. Claude uses the skill when a task reaches a human step, and when it opens a GitHub pull request. The skill runs `skills/deploy-handoff/handoff.py`. Other tools can run the script in the same way. + +Open a page and show the steps: + +```bash +python3 skills/deploy-handoff/handoff.py open \ + --url https://play.google.com/console \ + --title "Send version 1.4.0 for review" \ + --step 'Open "Publishing overview".' \ + --step 'Click "Send changes for review".' +``` + +If the agent already drove a browser to the page, it adds `--no-open`. Then the script shows only the dialog. The script refuses a title or a step with more than 20 words or with a semicolon. + +Open the pull request form for the current branch: + +```bash +python3 skills/deploy-handoff/handoff.py pr --title "Add CSV export" --body-file body.md +``` + +The script prints one JSON object, for example `{"status": "done", "note": ""}`. [SKILL.md](skills/deploy-handoff/SKILL.md) lists all statuses and exit codes. + +## Browser + +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. + +## Allowed hosts + +The script opens only `https` pages on allowed hosts. This stops an agent that follows bad instructions from sending you to a fake sign-in page. The dialog also shows the host of the page. + +[config.toml](skills/deploy-handoff/config.toml) has the default list: GitHub, GitLab, Stripe, Google Play, F-Droid, App Store Connect, and some cloud and hosting consoles. An entry also allows its subdomains. + +To add hosts, make the file `~/.config/deploy-handoff/config.toml`. If `XDG_CONFIG_HOME` is set, the file is `$XDG_CONFIG_HOME/deploy-handoff/config.toml`. + +```toml +allowed_hosts = ["dashboard.example.com"] +``` + +## Development + +```bash +python3 -m venv .venv +.venv/bin/python -m pip install --group dev +.venv/bin/python -m ruff format --check +.venv/bin/python -m ruff check +.venv/bin/python -m mypy skills/deploy-handoff +.venv/bin/python -m unittest discover -s skills/deploy-handoff +``` + +On Windows, use `.venv\Scripts\python`. `pip install --group` needs pip 25.1 or later. The dialog tests show small windows for a short time. + +## License + +MIT diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..97cb5da --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +# handoff.py uses only the Python standard library. +# The group below holds the tools that check it. README.md tells how to run them. + +[dependency-groups] +dev = ["hypothesis", "mypy", "ruff"] + +[tool.ruff] +target-version = "py311" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF"] + +[tool.ruff.lint.isort] +known-first-party = ["handoff"] + +[tool.mypy] +strict = true diff --git a/skills/deploy-handoff/SKILL.md b/skills/deploy-handoff/SKILL.md new file mode 100644 index 0000000..9d6e0da --- /dev/null +++ b/skills/deploy-handoff/SKILL.md @@ -0,0 +1,119 @@ +--- +name: deploy-handoff +description: >- + Gives the last step of a deploy, billing, or sign-in task to the user. Drive + the browser as far as you can, then use this skill. It shows the user a + dialog that tells 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 + +`handoff.py` is in the base directory of this skill. In the commands below, replace `SKILL_DIR` with that directory. Run the script with Python 3.11 or later: `py -3` on Windows, `python3` on other systems. + +Run the commands in a POSIX shell. On Windows, use Git Bash. Windows PowerShell 5.1 removes the double quotation marks inside the arguments. + +The script shows a small dialog on top of the browser. The dialog tells the user what to do. The user does the last step and clicks **Done** or **Not done**. The script never clicks for the user. + +## 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. If you have browser tools, go to the page of the last step. Examples of browser tools are Claude in Chrome and a Playwright MCP server. Use the browser in which the user is signed in, if you can. +3. Open the correct app, release, or settings page. Fill in the fields that do not contain secrets. +4. Stop before the last step. Then 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. + +Never do these steps yourself. Give them to the user: + +- Type a password, a one-time code, an API key, or a card number. +- Solve a CAPTCHA. +- Accept terms or an OAuth consent screen. +- Click the final button that publishes, pays, submits, merges, or deletes. + +## 2. Write the steps in Simplified Technical English + +The user reads the title and the steps in the dialog. Write them in ASD-STE100 Simplified Technical English: + +- Start each step with a verb in the imperative, for example Click, Open, Select, or Make sure. +- Give one action in each step. Use 20 words or fewer. Do not use semicolons. +- Write the exact name of each button or menu item in quotation marks. +- Put a condition before its action: 'If the page asks for a code, type the code from your phone.' +- Make the title the result of the steps, for example 'Send version 1.4.0 for review'. + +The script refuses a title or a step that has more than 20 words or a semicolon. + +- Good: `Click "Send changes for review".` +- Bad: `Review everything, then submit it and tell me when it is done; check the release notes too.` + +## 3. Run the handoff + +```bash +python3 SKILL_DIR/handoff.py open --no-open \ + --url "https://play.google.com/console/u/0/developers/123/app/456/publishing" \ + --title "Send version 1.4.0 for review" \ + --step 'Make sure that the page shows version 1.4.0.' \ + --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. +- 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. + +Useful start pages: + +| Service | Page | +|---|---| +| Stripe API keys | `https://dashboard.stripe.com/apikeys` (test mode: `https://dashboard.stripe.com/test/apikeys`) | +| Google Play Console | `https://play.google.com/console` | +| F-Droid merge requests | `https://gitlab.com/fdroid/fdroiddata/-/merge_requests` | +| GitHub device sign-in | `https://github.com/login/device` | + +## Open a pull request + +Push the branch first. Then run this command in the repository: + +```bash +python3 SKILL_DIR/handoff.py pr --title "Add CSV export" --body-file pr-body.md +``` + +The script opens the GitHub form with the title and the description filled in. The user only clicks "Create pull request". + +- The head is the current branch. The base is the default branch on GitHub. Use `--head` and `--base` to change them. +- The script refuses the branch if the remote does not have the local commit. +- The script gets OWNER/NAME from the URL of `origin`. Use `--remote` or `--repo OWNER/NAME` to change it. +- Do not create a pull request with `gh pr create`, the GitHub API, or another tool. + +## 4. Wait for the answer + +The user can take many minutes. Run the command in the background (in Claude Code: `run_in_background: true`). Tell the user in one sentence that the dialog is open. The default time limit is 30 minutes. `--timeout MINUTES` changes it. + +The script prints one JSON object: + +| `status` | Exit code | Meaning | +|---|---|---| +| `done` | 0 | The user did the steps. | +| `not_done` | 3 | The user did not do the steps. `note` can give the reason. | +| `timeout` | 4 | The user did not answer in time. | +| `error` | 1 | The script did not show the dialog. `error` gives the reason. | + +Exit code 2 means bad arguments. Then the script writes the usage to stderr. + +After `pr`, a `done` answer also has `pr_url`. It is the open pull request that `gh` found for the branch, or `null` if `gh` is not available or found none. + +## 5. After the answer + +- `done`: Check the result with the API or CLI of the service when you can, for example `gh pr view`. Then continue. +- `not_done`: Read the note. Do not show the same steps again without a change. Ask the user what to do next. +- `timeout`: Ask the user before you try again. +- `error`: Fix the cause. If the host is not allowed, ask the user. + +## Safety + +- Never ask the user to type or paste a password, key, token, or card number into the chat or the note. Tell the user where to put a secret, for example in the secret store of the host. +- Never click the final button yourself with a browser tool. diff --git a/skills/deploy-handoff/config.toml b/skills/deploy-handoff/config.toml new file mode 100644 index 0000000..032c603 --- /dev/null +++ b/skills/deploy-handoff/config.toml @@ -0,0 +1,24 @@ +# The hosts that handoff.py can open. An entry also allows its subdomains. +# Do not edit this file to add hosts, because an update replaces it. +# Put your hosts in allowed_hosts in ~/.config/deploy-handoff/config.toml. +allowed_hosts = [ + # Code and pull requests + "github.com", + "gitlab.com", + # Payments + "stripe.com", + # Android and iOS stores + "play.google.com", + "payments.google.com", + "f-droid.org", + "appstoreconnect.apple.com", + "developer.apple.com", + # Sign-in and cloud consoles + "accounts.google.com", + "console.cloud.google.com", + # Hosting + "vercel.com", + "app.netlify.com", + "dash.cloudflare.com", + "fly.io", +] diff --git a/skills/deploy-handoff/handoff.py b/skills/deploy-handoff/handoff.py new file mode 100644 index 0000000..5f537d8 --- /dev/null +++ b/skills/deploy-handoff/handoff.py @@ -0,0 +1,369 @@ +"""Open a web page and wait while the user does a step that only a human may do. + +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. + +Exit codes: 0 done, 1 error, 2 bad arguments, 3 not done, 4 timeout. +""" + +import argparse +import contextlib +import ctypes +import json +import os +import re +import subprocess +import sys +import tkinter as tk +import tomllib +import webbrowser +from collections.abc import Callable, Iterable +from dataclasses import dataclass +from pathlib import Path +from tkinter import font as tkfont +from tkinter import ttk +from urllib.parse import quote, urlencode, urlsplit + +SHIPPED_CONFIG = Path(__file__).with_name("config.toml") +USER_CONFIG = ( + Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") + / "deploy-handoff" + / "config.toml" +) +# GitHub refuses very long request lines. This limit keeps a margin. +MAX_PR_URL_LENGTH = 8000 +EXIT_CODES = {"done": 0, "error": 1, "not_done": 3, "timeout": 4} +# ASD-STE100 Simplified Technical English allows 20 words in one instruction. +MAX_STEP_WORDS = 20 +PR_STEPS = ( + "Make sure that the branches, the title, and the description are correct.", + 'Click "Create pull request".', +) +HEADING = "Do these steps in your browser:" +FINISH = ( + 'When you finish, click "Done".\n' + 'If you cannot finish, write the reason in the note. Then click "Not done".' +) +WARNING = ( + "Claude does not click for you. Before you sign in, make sure that the address bar " + "shows the site above. Do not type a password, key, or card number in this window." +) + +# A plain DNS name in lowercase ASCII. The script refuses all other host forms, +# so the browser and this script always read the same host from a URL. +HOST_RE = re.compile(r"[a-z0-9-]+(?:\.[a-z0-9-]+)+") +# Printable ASCII without a space. Callers percent-encode all other characters. +URL_CHARS_RE = re.compile(r"[!-~]+") +REPO_RE = re.compile(r"[A-Za-z0-9-]+/[A-Za-z0-9._-]+") +GITHUB_REMOTE_RE = re.compile( + r"(?:https://(?:[^@/]+@)?github\.com/|git@github\.com:|ssh://git@github\.com/)" + r"(?P<repo>[A-Za-z0-9-]+/[A-Za-z0-9._-]+?)(?:\.git)?/?" +) + + +class HandoffError(Exception): + """The script cannot start the handoff. The message tells the caller why.""" + + +@dataclass(frozen=True) +class Request: + """A page to open, and the steps that the user must do on it.""" + + title: str + url: str + steps: tuple[str, ...] + # For a pull request: the repository as "owner/name", and the head branch. + pr: tuple[str, str] | None = None + + +def match_domain(host: str, domains: Iterable[str]) -> str | None: + """Return the most specific domain that is host or a parent domain of host.""" + matches = [domain for domain in domains if host == domain or host.endswith("." + domain)] + return max(matches, key=len, default=None) + + +def check_url(url: str, domains: Iterable[str]) -> str: + """Return the domain that allows url. Raise HandoffError if the script must not open url.""" + if "\\" in url or not URL_CHARS_RE.fullmatch(url): + raise HandoffError( + f"The URL must be printable ASCII with no spaces or backslashes: {url!r}" + ) + try: + parts = urlsplit(url) + except ValueError as exc: + raise HandoffError(f"The URL is not valid: {url}") from exc + host = parts.hostname or "" + # The netloc must be the host only: no user name, no password, and no port. + if parts.scheme != "https" or parts.netloc.lower() != host or not HOST_RE.fullmatch(host): + raise HandoffError(f"The URL must use https and a plain host name: {url}") + domain = match_domain(host, domains) + if domain is None: + raise HandoffError( + f"{host} is not an allowed host. Ask the user to add it to allowed_hosts in " + f"{USER_CONFIG}. Do not add it yourself." + ) + return domain + + +def check_text(title: str, steps: Iterable[str]) -> None: + """Raise HandoffError if the title or a step is not short Simplified Technical English.""" + named = [("The title", title), *((f"Step {n}", step) for n, step in enumerate(steps, 1))] + for name, text in named: + words = len(text.split()) + if not 0 < words <= MAX_STEP_WORDS or ";" in text: + raise HandoffError( + f"{name} is not short Simplified Technical English ({words} words). " + f"Give one action in 1 to {MAX_STEP_WORDS} words, with no semicolons." + ) + + +def read_hosts(path: Path) -> list[str]: + """Return the allowed_hosts list of the TOML file at path.""" + try: + data = 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", []) + if not isinstance(hosts, list) or not all( + isinstance(host, str) and HOST_RE.fullmatch(host) for host in hosts + ): + raise HandoffError(f"{path}: allowed_hosts must be a list of lowercase host names.") + return hosts + + +def allowed_hosts() -> list[str]: + """Return the hosts in the shipped file, and in the user file if it exists.""" + paths = [SHIPPED_CONFIG, USER_CONFIG] if USER_CONFIG.is_file() else [SHIPPED_CONFIG] + return [host for path in paths for host in read_hosts(path)] + + +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): + raise HandoffError("No browser opened the page. Set the BROWSER environment variable.") + + +def github_repo(remote_url: str) -> str: + """Return "owner/name" for the URL of a github.com remote.""" + match = GITHUB_REMOTE_RE.fullmatch(remote_url.strip()) + if match is None: + raise HandoffError(f"{remote_url} is not a github.com remote. Give --repo OWNER/NAME.") + return match["repo"] + + +def compare_url(repo: str, base: str | None, head: str, title: str, body: str) -> str: + """Return the GitHub page that shows a new pull request form with title and body filled in.""" + # Without a base, GitHub compares head with the default branch. + refs = quote(head) if base is None else f"{quote(base)}...{quote(head)}" + query = urlencode({"expand": "1", "title": title, "body": body}, quote_via=quote) + url = f"https://github.com/{repo}/compare/{refs}?{query}" + if len(url) > MAX_PR_URL_LENGTH: + raise HandoffError( + f"The pull request URL has {len(url)} characters. The limit is {MAX_PR_URL_LENGTH}. " + "Make the body shorter." + ) + return url + + +def git(repo_dir: Path, *args: str) -> str: + """Run git in repo_dir and return its output. Raise HandoffError if git fails.""" + command = ["git", "-C", str(repo_dir), *args] + try: + result = subprocess.run( + command, capture_output=True, encoding="utf-8", errors="replace", timeout=60 + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise HandoffError(f"{' '.join(command)} failed: {exc}") from exc + if result.returncode != 0: + raise HandoffError(f"{' '.join(command)} failed: {result.stderr.strip()}") + return result.stdout.strip() + + +def current_branch(repo_dir: Path) -> str: + """Return the branch that HEAD is on.""" + branch = git(repo_dir, "branch", "--show-current") + if not branch: + raise HandoffError("HEAD is not on a branch. Give --head BRANCH.") + return branch + + +def check_pushed(repo_dir: Path, remote: str, branch: str) -> None: + """Raise HandoffError if remote does not have the commit of the local branch.""" + ref = f"refs/heads/{branch}" + local = git(repo_dir, "rev-parse", "--verify", ref) + lines = git(repo_dir, "ls-remote", remote, ref).splitlines() + remote_commits = [line.split()[0] for line in lines if line.split()[1:] == [ref]] + if remote_commits != [local]: + raise HandoffError( + f"{remote} does not have the local commit of {branch}. Push {branch} first." + ) + + +def find_open_pr(repo: str, head: str) -> str | None: + """Return the URL of the open pull request from head, or None if gh cannot find one.""" + command = ["gh", "pr", "list", "--repo", repo, "--head", head, "--state", "open"] + command += ["--json", "url", "--jq", ".[0].url // empty"] + try: + result = subprocess.run(command, capture_output=True, encoding="utf-8", timeout=60) + except (OSError, subprocess.TimeoutExpired): + return None + url = result.stdout.strip() + return url if result.returncode == 0 and url else None + + +def pr_request(args: argparse.Namespace) -> Request: + """Make the request that opens the GitHub form for a new pull request.""" + repo_dir: Path = args.repo_dir + repo: str = args.repo or github_repo(git(repo_dir, "remote", "get-url", args.remote)) + if not REPO_RE.fullmatch(repo): + raise HandoffError(f"{repo!r} is not a repository name of the form OWNER/NAME.") + head: str = args.head or current_branch(repo_dir) + check_pushed(repo_dir, args.remote, head) + try: + body: str = args.body_file.read_text(encoding="utf-8") if args.body_file else args.body + except OSError as exc: + raise HandoffError(f"Cannot read {args.body_file}: {exc}") from exc + url = compare_url(repo, args.base, head, args.title, body) + return Request(f"Create pull request: {args.title}", url, PR_STEPS, pr=(repo, head)) + + +class Dialog: + """A small window on top of the browser. It shows the steps and records the answer.""" + + def __init__(self, request: Request, timeout_minutes: float, reopen: Callable[[], None]): + self.status = "not_done" + self.note_text = "" + if sys.platform == "win32": + # Draw sharp text on high-DPI screens, as IDLE does. The call fails if the + # process has already set the DPI awareness. That is not a problem. + with contextlib.suppress(OSError): + ctypes.OleDLL("shcore").SetProcessDpiAwareness(1) + self.root = tk.Tk() + scale = self.root.winfo_fpixels("1i") / 96 + wrap = round(420 * scale) + self.root.title(f"Claude handoff: {request.title}") + self.root.attributes("-topmost", True) + self.root.resizable(False, False) + # Put the window in the bottom-left corner, above the taskbar. Deploy pages + # usually put the final button on the right side. + self.root.geometry(f"+{round(24 * scale)}-{round(80 * scale)}") + self.root.protocol("WM_DELETE_WINDOW", lambda: self.finish("not_done")) + + parts = urlsplit(request.url) + shown = f"https://{parts.netloc}{parts.path}" + if len(shown) > 80: + shown = shown[:77] + "..." + steps = "\n".join(f"{number}. {step}" for number, step in enumerate(request.steps, 1)) + # Tk deletes a font when its Python object goes away, so the dialog keeps it. + self.bold = tkfont.nametofont("TkDefaultFont").copy() + self.bold.configure(weight="bold") + frame = ttk.Frame(self.root, padding=round(12 * scale)) + frame.grid() + # The user must see first what to do, and then where to do it. + ttk.Label(frame, text=request.title, font=self.bold, wraplength=wrap).grid(sticky="w") + ttk.Label(frame, text=HEADING, font=self.bold).grid(sticky="w", pady=(8, 0)) + ttk.Label(frame, text=steps, wraplength=wrap, justify="left").grid(sticky="w") + site = ttk.Label(frame, text=f"Site: {parts.hostname}", font=self.bold) + site.grid(sticky="w", pady=(8, 0)) + link = ttk.Label(frame, text=shown, foreground="blue", cursor="hand2", wraplength=wrap) + link.grid(sticky="w") + link.bind("<Button-1>", lambda _event: reopen()) + for text, color in ((FINISH, ""), (WARNING, "#b00020")): + label = ttk.Label(frame, text=text, foreground=color, wraplength=wrap, justify="left") + label.grid(sticky="w", pady=(8, 0)) + ttk.Label(frame, text="Note for Claude (optional):").grid(sticky="w", pady=(8, 0)) + self.note = ttk.Entry(frame) + self.note.grid(sticky="ew") + buttons = ttk.Frame(frame) + buttons.grid(sticky="e", pady=(12, 0)) + self.done_button = ttk.Button(buttons, text="Done", command=lambda: self.finish("done")) + self.done_button.grid(row=0, column=0, padx=(0, 8)) + self.not_done_button = ttk.Button( + buttons, text="Not done", command=lambda: self.finish("not_done") + ) + self.not_done_button.grid(row=0, column=1) + self.timer = self.root.after(round(timeout_minutes * 60_000), self.finish, "timeout") + + def finish(self, status: str) -> None: + """Record the answer and close the window.""" + self.root.after_cancel(self.timer) + self.status = status + self.note_text = self.note.get().strip() + self.root.destroy() + + def run(self) -> tuple[str, str]: + """Show the window until the user answers or the time ends.""" + self.root.mainloop() + return self.status, self.note_text + + +def parse_args(argv: list[str] | None) -> argparse.Namespace: + """Read the command line.""" + common = argparse.ArgumentParser(add_help=False) + common.add_argument( + "--timeout", + type=float, + default=30, + metavar="MINUTES", + help="the time to wait for the user (default: 30)", + ) + parser = argparse.ArgumentParser( + description="Open a web page and wait while the user does the final step." + ) + commands = parser.add_subparsers(dest="command", required=True) + page = commands.add_parser("open", parents=[common], help="open a page and show the steps") + page.add_argument("--url", required=True, help="the https URL of the page") + page.add_argument("--title", required=True, help="the action, for example: Publish 1.2") + page.add_argument( + "--step", action="append", required=True, help="one step for the user (give it again)" + ) + page.add_argument( + "--no-open", action="store_true", help="show only the dialog: the page is already open" + ) + pr = commands.add_parser("pr", parents=[common], help="open the GitHub pull request form") + pr.add_argument("--title", required=True, help="the title of the pull request") + body = pr.add_mutually_exclusive_group() + body.add_argument("--body", default="", help="the description of the pull request") + body.add_argument("--body-file", type=Path, help="a file with the description") + pr.add_argument("--base", help="the branch to merge into (default: the default branch)") + pr.add_argument("--head", help="the branch to merge (default: the current branch)") + 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") + return parser.parse_args(argv) + + +def report(result: dict[str, str | None]) -> int: + """Print result as JSON. Return the exit code for its status.""" + print(json.dumps(result)) + return EXIT_CODES[str(result["status"])] + + +def main(argv: list[str] | None = None) -> int: + """Do the handoff. Return the exit code.""" + args = parse_args(argv) + try: + hosts = allowed_hosts() + if args.command == "pr": + request = pr_request(args) + else: + check_text(args.title, args.step) + request = Request(args.title, args.url, tuple(args.step)) + check_url(request.url, hosts) + # With --no-open, the caller drove a browser to the page already. + if args.command == "pr" or not args.no_open: + open_page(request.url) + except HandoffError as exc: + return report({"status": "error", "error": str(exc)}) + status, note = Dialog(request, args.timeout, lambda: open_page(request.url)).run() + result: dict[str, str | None] = {"status": status, "note": note} + if request.pr is not None and status == "done": + result["pr_url"] = find_open_pr(*request.pr) + return report(result) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/deploy-handoff/test_handoff.py b/skills/deploy-handoff/test_handoff.py new file mode 100644 index 0000000..e4b7471 --- /dev/null +++ b/skills/deploy-handoff/test_handoff.py @@ -0,0 +1,460 @@ +"""Tests for handoff.py. README.md tells how to run them. + +The dialog tests show small windows for a short time. +""" + +import json +import subprocess +import tempfile +import tkinter as tk +import unittest +from contextlib import redirect_stdout +from io import StringIO +from pathlib import Path +from tkinter import ttk +from typing import TypeVar +from unittest import mock +from urllib.parse import parse_qs, unquote, urlsplit + +from hypothesis import assume, given +from hypothesis import strategies as st + +import handoff +from handoff import HandoffError, Request + +T = TypeVar("T") + +DOMAINS = ["github.com", "stripe.com", "google.com", "play.google.com"] +LABEL = st.from_regex(r"[a-z0-9-]{1,12}", fullmatch=True) +DOMAIN = st.lists(LABEL, min_size=2, max_size=3).map(".".join) +ALLOWED_HOST = st.builds( + lambda prefix, domain: ".".join([*prefix, domain]), + st.lists(LABEL, max_size=3), + st.sampled_from(DOMAINS), +) +# A path and a query in printable ASCII. They contain "@" and ":", which must not change the host. +REST = st.from_regex( + r"(/[A-Za-z0-9._~%!$&'()*+,;=:@-]*)*(\?[A-Za-z0-9._~%!$&'()*+,;=:@/?-]*)?", fullmatch=True +) +ALLOWED_URL = st.builds(lambda host, rest: f"https://{host}{rest}", ALLOWED_HOST, REST) +TEXT = st.text(st.characters(codec="utf-8"), max_size=100) +REPO = st.from_regex(r"[A-Za-z0-9-]{1,10}/[A-Za-z0-9_-]{1,10}", fullmatch=True) +OWNER = st.from_regex(r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,10}[A-Za-z0-9])?", fullmatch=True) +NAME = st.from_regex(r"[A-Za-z0-9._-]{1,12}", fullmatch=True).filter( + lambda name: name not in {".", ".."} and not name.endswith(".git") +) +REMOTE_FORMS = [ + "https://github.com/{}.git", + "https://github.com/{}", + "https://github.com/{}/", + "https://user:token@github.com/{}.git", + "git@github.com:{}.git", + "git@github.com:{}", + "ssh://git@github.com/{}.git", +] +WORD = st.from_regex(r"[A-Za-z0-9\"'.,:()-]{1,10}", fullmatch=True) +# Characters that git allows in a branch name, other than "/". See git check-ref-format. +REF_CHAR = st.characters(codec="utf-8", exclude_categories=["Cc"], exclude_characters=" ~^:?*[\\/") + + +@st.composite +def branches(draw: st.DrawFn) -> str: + """Make a branch name that git accepts.""" + parts = draw(st.lists(st.text(REF_CHAR, min_size=1, max_size=6), min_size=1, max_size=3)) + name = "/".join(parts) + assume(".." not in name and "@{" not in name and name != "@" and not name.endswith(".lock")) + assume(not any(part.startswith(".") or part.endswith(".") for part in parts)) + return name + + +@st.composite +def host_and_domains(draw: st.DrawFn) -> tuple[str, list[str]]: + """Make a host near an allowed domain: the domain, a subdomain, or a look-alike.""" + domains = draw(st.lists(DOMAIN, min_size=1, max_size=4)) + domain = draw(st.sampled_from(domains)) + label = draw(LABEL) + glue = draw(st.sampled_from([".", "", "-"])) + host = draw(st.sampled_from([domain, label + glue + domain, domain + glue + label])) + return host, domains + + +def reference_match(host: str, domains: list[str]) -> str | None: + """Do the work of match_domain slowly: compare the labels from the right.""" + labels = host.split(".") + found = [d for d in domains if labels[-len(d.split(".")) :] == d.split(".")] + return max(found, key=len, default=None) + + +def label_texts(widget: tk.Misc) -> list[str]: + """Return the text of each label in widget, in the order in which the dialog made them.""" + texts: list[str] = [] + for child in widget.winfo_children(): + if isinstance(child, ttk.Label): + texts.append(str(child.cget("text"))) + texts.extend(label_texts(child)) + return texts + + +def run_git(*args: str) -> None: + identity = ["-c", "user.name=test", "-c", "user.email=test@example.com"] + subprocess.run(["git", *identity, *args], check=True, capture_output=True) + + +def make_repo(root: Path) -> Path: + """Make a repository with one commit on main, and a bare remote with the name origin.""" + remote, work = root / "remote.git", root / "work" + run_git("init", "--bare", str(remote)) + run_git("init", "-b", "main", str(work)) + run_git("-C", str(work), "commit", "--allow-empty", "-m", "first") + run_git("-C", str(work), "remote", "add", "origin", str(remote)) + return work + + +class MatchDomainTest(unittest.TestCase): + @given(host_and_domains()) + def test_same_as_reference(self, case: tuple[str, list[str]]) -> None: + host, domains = case + self.assertEqual(handoff.match_domain(host, domains), reference_match(host, domains)) + + @given(st.lists(LABEL, max_size=3), DOMAIN) + def test_allows_subdomains(self, prefix: list[str], domain: str) -> None: + self.assertEqual(handoff.match_domain(".".join([*prefix, domain]), [domain]), domain) + + @given(LABEL, DOMAIN) + def test_refuses_look_alikes(self, label: str, domain: str) -> None: + self.assertIsNone(handoff.match_domain(label + domain, [domain])) + + def test_most_specific_domain_wins(self) -> None: + self.assertEqual(handoff.match_domain("a.play.google.com", DOMAINS), "play.google.com") + + +class CheckUrlTest(unittest.TestCase): + @given(ALLOWED_HOST, REST) + def test_accepts_allowed_hosts(self, host: str, rest: str) -> None: + domain = handoff.match_domain(host, DOMAINS) + self.assertEqual(handoff.check_url(f"https://{host}{rest}", DOMAINS), domain) + + @given( + ALLOWED_URL, + st.integers(min_value=0), + st.sampled_from(["\\", " ", "\t", "\n", "\x00", "\x7f", "\u00e9", "\u3002", "\u200b"]), + ) + def test_refuses_unsafe_characters(self, url: str, index: int, char: str) -> None: + index %= len(url) + 1 + with self.assertRaises(HandoffError): + handoff.check_url(url[:index] + char + url[index:], DOMAINS) + + @given(ALLOWED_HOST, st.from_regex(r"[A-Za-z0-9._~!$&'()*+,;=:-]{0,12}", fullmatch=True)) + def test_refuses_user_info(self, host: str, user: str) -> None: + with self.assertRaises(HandoffError): + handoff.check_url(f"https://{user}@{host}/", DOMAINS) + + @given(ALLOWED_HOST, st.integers(min_value=0, max_value=65535)) + def test_refuses_ports(self, host: str, port: int) -> None: + with self.assertRaises(HandoffError): + handoff.check_url(f"https://{host}:{port}/", DOMAINS) + + def test_refuses_known_attacks(self) -> None: + for url in [ + "", + "http://github.com/", + "https://evil.com/", + "https://evilgithub.com/", + "https://github.com.evil.com/", + "https://github.com@evil.com/", + "https://evil.com\\@github.com/", + "https://evil.com\\.github.com/", + "https://evil.com%2F.github.com/", + "https://github.com./", + "https://gith\u0443b.com/", + "https:github.com/", + "https:///github.com/", + "https://[::1]/", + "https://[github.com/", + "//github.com/", + "javascript:alert(1)//github.com/", + ]: + with self.subTest(url=url), self.assertRaises(HandoffError): + handoff.check_url(url, DOMAINS) + + def test_ignores_the_case_of_the_host(self) -> None: + self.assertEqual(handoff.check_url("https://GitHub.COM/equwal", DOMAINS), "github.com") + + +class CheckTextTest(unittest.TestCase): + @given(st.lists(WORD, min_size=1, max_size=20)) + def test_accepts_short_text(self, words: list[str]) -> None: + handoff.check_text(" ".join(words), [" ".join(words)]) + + @given(st.lists(WORD, min_size=21, max_size=40)) + def test_refuses_long_steps(self, words: list[str]) -> None: + with self.assertRaises(HandoffError): + handoff.check_text("Publish 1.4.0", ["Click it.", " ".join(words)]) + + def test_refuses_long_titles_semicolons_and_empty_text(self) -> None: + for title, steps in [ + (" ".join(["word"] * 21), ["Click it."]), + ("Publish 1.4.0", ["Open the page; click it."]), + ("Publish 1.4.0", [" "]), + ("", ["Click it."]), + ]: + with self.subTest(title=title, steps=steps), self.assertRaises(HandoffError): + handoff.check_text(title, steps) + + +class ConfigTest(unittest.TestCase): + def setUp(self) -> None: + folder = tempfile.TemporaryDirectory() + self.addCleanup(folder.cleanup) + self.folder = Path(folder.name) + + def write(self, text: str) -> Path: + path = self.folder / "config.toml" + path.write_text(text, encoding="utf-8") + return path + + def test_shipped_file_allows_github(self) -> None: + self.assertIn("github.com", handoff.read_hosts(handoff.SHIPPED_CONFIG)) + + def test_refuses_bad_files(self) -> None: + for text in [ + 'allowed_hosts = ["https://github.com"]\n', + 'allowed_hosts = ["GitHub.com"]\n', + "allowed_hosts = [1]\n", + 'allowed_hosts = "github.com"\n', + "not toml\n", + ]: + with self.subTest(text=text), self.assertRaises(HandoffError): + handoff.read_hosts(self.write(text)) + + def test_refuses_a_missing_file(self) -> None: + with self.assertRaises(HandoffError): + handoff.read_hosts(self.folder / "missing.toml") + + def test_user_file_adds_hosts(self) -> None: + user = self.write('allowed_hosts = ["example.com"]\n') + with mock.patch.object(handoff, "USER_CONFIG", user): + hosts = handoff.allowed_hosts() + self.assertIn("example.com", hosts) + self.assertIn("github.com", hosts) + + def test_user_file_is_optional(self) -> None: + with mock.patch.object(handoff, "USER_CONFIG", self.folder / "missing.toml"): + self.assertEqual(handoff.allowed_hosts(), handoff.read_hosts(handoff.SHIPPED_CONFIG)) + + +class OpenPageTest(unittest.TestCase): + def test_opens_a_new_tab(self) -> None: + with mock.patch("webbrowser.open", return_value=True) as browser: + handoff.open_page("https://github.com/") + browser.assert_called_once_with("https://github.com/", new=2) + + def test_error_if_no_browser_opens(self) -> None: + with mock.patch("webbrowser.open", return_value=False), self.assertRaises(HandoffError): + handoff.open_page("https://github.com/") + + +class GithubRepoTest(unittest.TestCase): + @given(OWNER, NAME, st.sampled_from(REMOTE_FORMS)) + def test_round_trip(self, owner: str, name: str, form: str) -> None: + self.assertEqual(handoff.github_repo(form.format(f"{owner}/{name}")), f"{owner}/{name}") + + def test_refuses_other_remotes(self) -> None: + for remote in [ + "https://gitlab.com/o/r.git", + "git@github-work:o/r.git", + "https://github.com.evil.com/o/r", + "https://github.com/o", + "https://github.com/o/r/extra", + "C:/repos/r.git", + ]: + with self.subTest(remote=remote), self.assertRaises(HandoffError): + handoff.github_repo(remote) + + +class CompareUrlTest(unittest.TestCase): + @given(REPO, st.none() | branches(), branches(), TEXT, TEXT) + def test_round_trip( + self, repo: str, base: str | None, head: str, title: str, body: str + ) -> None: + url = handoff.compare_url(repo, base, head, title, body) + self.assertEqual(handoff.check_url(url, ["github.com"]), "github.com") + parts = urlsplit(url) + prefix = f"/{repo}/compare/" + self.assertTrue(parts.path.startswith(prefix)) + refs = [unquote(ref) for ref in parts.path.removeprefix(prefix).split("...")] + self.assertEqual(refs, [head] if base is None else [base, head]) + query = parse_qs(parts.query, keep_blank_values=True, strict_parsing=True) + self.assertEqual(query, {"expand": ["1"], "title": [title], "body": [body]}) + + def test_refuses_long_urls(self) -> None: + with self.assertRaises(HandoffError): + handoff.compare_url("o/r", None, "b", "t", "x" * handoff.MAX_PR_URL_LENGTH) + + +class GitTest(unittest.TestCase): + def setUp(self) -> None: + folder = tempfile.TemporaryDirectory(ignore_cleanup_errors=True) + self.addCleanup(folder.cleanup) + self.work = make_repo(Path(folder.name)) + + def test_current_branch(self) -> None: + self.assertEqual(handoff.current_branch(self.work), "main") + run_git("-C", str(self.work), "checkout", "--detach") + with self.assertRaises(HandoffError): + handoff.current_branch(self.work) + + def test_check_pushed(self) -> None: + with self.assertRaises(HandoffError): + handoff.check_pushed(self.work, "origin", "main") + run_git("-C", str(self.work), "push", "origin", "main") + handoff.check_pushed(self.work, "origin", "main") + run_git("-C", str(self.work), "commit", "--allow-empty", "-m", "second") + with self.assertRaises(HandoffError): + handoff.check_pushed(self.work, "origin", "main") + + +class DialogTest(unittest.TestCase): + def make_dialog(self, timeout_minutes: float = 1) -> handoff.Dialog: + request = Request("Test handoff", "https://github.com/equwal", ("Look at the page.",)) + return handoff.Dialog(request, timeout_minutes, lambda: None) + + def test_done_returns_the_note(self) -> None: + dialog = self.make_dialog() + + def answer() -> None: + dialog.note.insert(0, " shipped ") + dialog.done_button.invoke() + + dialog.root.after(50, answer) + self.assertEqual(dialog.run(), ("done", "shipped")) + + def test_not_done(self) -> None: + dialog = self.make_dialog() + dialog.root.after(50, dialog.not_done_button.invoke) + self.assertEqual(dialog.run(), ("not_done", "")) + + def test_closing_the_window_means_not_done(self) -> None: + dialog = self.make_dialog() + close = dialog.root.protocol("WM_DELETE_WINDOW") + dialog.root.after(50, dialog.root.tk.call, close) + self.assertEqual(dialog.run(), ("not_done", "")) + + def test_timeout(self) -> None: + self.assertEqual(self.make_dialog(timeout_minutes=0.001).run(), ("timeout", "")) + + def test_shows_the_steps_under_a_heading(self) -> None: + # The smoke test on 2026-09-21 showed this request. The user could not see what to do. + request = Request( + "Smoke test: deploy-handoff", + "https://github.com/equwal", + ("No action needed. This window closes itself.",), + ) + dialog = handoff.Dialog(request, 1, lambda: None) + texts = label_texts(dialog.root) + dialog.finish("not_done") + expected = [ + "Smoke test: deploy-handoff", + "Do these steps in your browser:", + "1. No action needed. This window closes itself.", + ] + self.assertEqual(texts[:3], expected) + self.assertIn("Site: github.com", texts) + + +class MainTest(unittest.TestCase): + """Test main with a fake browser and a fake dialog.""" + + def setUp(self) -> None: + folder = tempfile.TemporaryDirectory(ignore_cleanup_errors=True) + self.addCleanup(folder.cleanup) + self.root = Path(folder.name) + config = self.root / "config.toml" + config.write_text('allowed_hosts = ["github.com"]\n', encoding="utf-8") + self.patch("SHIPPED_CONFIG", config) + self.patch("USER_CONFIG", self.root / "missing.toml") + self.open_page = self.patch("open_page", mock.MagicMock()) + self.dialog = self.patch("Dialog", mock.MagicMock()) + self.dialog.return_value.run.return_value = ("done", "ok") + + def patch(self, name: str, value: T) -> T: + patcher = mock.patch.object(handoff, name, value) + patcher.start() + self.addCleanup(patcher.stop) + return value + + def run_main(self, *argv: str) -> tuple[int, dict[str, object]]: + output = StringIO() + with redirect_stdout(output): + code = handoff.main(list(argv)) + result: dict[str, object] = json.loads(output.getvalue()) + return code, result + + def open_url(self, url: str) -> tuple[int, dict[str, object]]: + return self.run_main("open", "--url", url, "--title", "Do it", "--step", "Click it.") + + def test_open(self) -> None: + self.assertEqual( + self.open_url("https://github.com/o"), (0, {"status": "done", "note": "ok"}) + ) + self.open_page.assert_called_once_with("https://github.com/o") + + def test_exit_codes_for_other_answers(self) -> None: + for status, expected in [("not_done", 3), ("timeout", 4)]: + self.dialog.return_value.run.return_value = (status, "") + code, result = self.open_url("https://github.com/o") + self.assertEqual((code, result["status"]), (expected, status)) + + def test_no_open_shows_only_the_dialog(self) -> None: + code, result = self.run_main( + "open", + "--no-open", + "--url", + "https://github.com/o", + "--title", + "Do it", + "--step", + "Go.", + ) + self.assertEqual((code, result["status"]), (0, "done")) + self.open_page.assert_not_called() + self.dialog.assert_called_once() + + def test_open_refuses_long_steps(self) -> None: + code, result = self.run_main( + "open", "--url", "https://github.com/o", "--title", "Do it", "--step", "word " * 21 + ) + self.assertEqual((code, result["status"]), (1, "error")) + self.open_page.assert_not_called() + + def test_open_refuses_hosts_that_are_not_allowed(self) -> None: + code, result = self.open_url("https://evil.example/") + self.assertEqual((code, result["status"]), (1, "error")) + self.open_page.assert_not_called() + self.dialog.assert_not_called() + + def test_pr(self) -> None: + work = make_repo(self.root) + run_git("-C", str(work), "push", "origin", "main") + find_open_pr = self.patch("find_open_pr", mock.MagicMock()) + find_open_pr.return_value = "https://github.com/o/r/pull/1" + code, result = self.run_main( + "pr", "--repo", "o/r", "--repo-dir", str(work), "--title", "Add X", "--body", "Why" + ) + expected = {"status": "done", "note": "ok", "pr_url": "https://github.com/o/r/pull/1"} + self.assertEqual((code, result), (0, expected)) + self.open_page.assert_called_once_with( + "https://github.com/o/r/compare/main?expand=1&title=Add%20X&body=Why" + ) + find_open_pr.assert_called_once_with("o/r", "main") + + 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") + self.assertEqual((code, result["status"]), (1, "error")) + self.assertIn("Push main first", str(result["error"])) + self.open_page.assert_not_called() + + +if __name__ == "__main__": + unittest.main()