diff --git a/optional-skills/web-development/har-derived-api-client/SKILL.md b/optional-skills/web-development/har-derived-api-client/SKILL.md index 81ac6e4a82016..f205e846ca18f 100644 --- a/optional-skills/web-development/har-derived-api-client/SKILL.md +++ b/optional-skills/web-development/har-derived-api-client/SKILL.md @@ -65,8 +65,9 @@ Rule of thumb: **if Hermes *launched* the browser, use `har_capture.py`; if it *connected to* one over CDP, use `har_capture_cdp.py`.** `har_capture.py` uses Playwright's `record_har_path`, which only works on a locally-owned context. `har_capture_cdp.py` attaches with `connect_over_cdp()` and assembles the HAR -from `page.on("request"/"response")` events, because `record_har_path` is -unavailable on a connected browser. +from context-level request/response events, because `record_har_path` is +unavailable on a connected browser. `--goto` opens a **new tab** so it does +not navigate a page Hermes is already using. Then, for either path: @@ -101,6 +102,7 @@ har_capture.py [--wait S] [--headed] [--action SPEC ...] har_capture_cdp.py [--goto URL] [--wait S] [--action SPEC ...] same action SPEC; attaches to an existing CDP browser and does NOT close it + --goto opens a new tab (does not reuse Hermes's current page) use for cloud backends (Browserbase/Browser-Use/Firecrawl) & /browser connect har_to_client.py [--host SUBSTR] [--include-static] [--max-body N] @@ -138,7 +140,7 @@ for p in r.json()["pages"]: ## Pitfalls - **Default library User-Agent gets 403.** Many sites (Wikipedia, Cloudflare-fronted APIs) reject `python-requests/x.y`. Always send the browser UA from the replay hints. This is the #1 reason a derived client fails when the browser succeeded. -- **A failed `--action` aborts before the HAR flushes** — you get no file. If capture errors on a selector, the run produced nothing; fix the selector (use `--headed` to watch) and rerun. Don't debug a missing HAR. +- **A failed `--action` writes a partial HAR.** Malformed specs (`fill` without text, `click` without a selector) fail with a clear error, and everything captured before the failure is still flushed to the file. Fix the selector (use `--headed` to watch) and rerun if the file is missing the request you wanted. - **Server-rendered pages have no XHR** to derive — `har_to_client.py` prints "No API-looking entries". The data came in the HTML; scrape it or find the interaction that does fetch JSON. - **Debounced/typeahead XHRs need a real pause.** Add `--action "sleep:3"` after `fill`; typing alone won't have fired the request when the HAR closes. - **Auth/session endpoints** need the captured `Cookie`/`Authorization` header, and those expire. The derived client is only as durable as the credential; re-capture when it 401s. HARs contain live secrets — treat `out.har` as sensitive and delete it after deriving. @@ -146,7 +148,7 @@ for p in r.json()["pages"]: - **Endpoints shift.** Sites change private APIs without notice. Re-run the capture→derive loop when a client breaks rather than patching URLs by hand. - **Wrong capturer = empty/no HAR.** `har_capture.py` on a cloud/CDP backend records nothing (it launches its own local browser instead of the one you meant). `har_capture_cdp.py` needs the endpoint; on Hermes get it from `/browser connect` or `BROWSER_CDP_URL`. Match the capturer to the pathway (How to Run table). - **Headless-Chrome UA is a weak tell.** Local/agent-browser capture yields a `HeadlessChrome/...` User-Agent; some sites sniff the "Headless" token. Cloud backends (Browserbase/Browser-Use) send a real desktop-Chrome UA, so a client derived from a cloud capture replays more reliably. If a headless-derived client 403s where the browser didn't, swap the "Headless" UA for a normal Chrome UA string before assuming the endpoint changed. -- **CDP capture doesn't close the browser.** `har_capture_cdp.py` attaches to a browser it doesn't own and leaves it running — correct for cloud/remote sessions Hermes manages. Don't add a close; let the owning backend tear it down. +- **CDP capture doesn't close the browser.** `har_capture_cdp.py` attaches to a browser it doesn't own and leaves it running, which is correct for cloud/remote sessions Hermes manages. Don't add a close. Let the owning backend tear it down. `--goto` opens a new tab on purpose so it cannot wipe the tab Hermes is driving. ## Verification diff --git a/optional-skills/web-development/har-derived-api-client/scripts/har_actions.py b/optional-skills/web-development/har-derived-api-client/scripts/har_actions.py new file mode 100644 index 0000000000000..52202e79787f3 --- /dev/null +++ b/optional-skills/web-development/har-derived-api-client/scripts/har_actions.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Shared --action parser for the HAR capture scripts. + +Specs: + fill:SELECTOR:TEXT | press:SELECTOR:KEY | click:SELECTOR + goto:URL | sleep:SECONDS +""" +from __future__ import annotations + +import time + + +def parse_action(spec: str) -> tuple[str, list[str]]: + """Return (kind, args). Raise ValueError on a malformed spec.""" + if not spec: + raise ValueError("empty action spec") + parts = spec.split(":", 2) + kind = parts[0] + if kind == "fill": + if len(parts) < 3 or parts[1] == "": + raise ValueError(f"fill needs fill:SELECTOR:TEXT, got {spec!r}") + return kind, [parts[1], parts[2]] + if kind == "press": + if len(parts) < 3 or parts[1] == "": + raise ValueError(f"press needs press:SELECTOR:KEY, got {spec!r}") + return kind, [parts[1], parts[2]] + if kind == "click": + if len(parts) < 2 or parts[1] == "": + raise ValueError(f"click needs click:SELECTOR, got {spec!r}") + return kind, [parts[1]] + if kind == "goto": + if len(parts) < 2 or parts[1] == "": + raise ValueError(f"goto needs goto:URL, got {spec!r}") + url = parts[1] + (":" + parts[2] if len(parts) > 2 else "") + return kind, [url] + if kind == "sleep": + if len(parts) != 2 or parts[1] == "": + raise ValueError(f"sleep needs sleep:SECONDS, got {spec!r}") + try: + seconds = float(parts[1]) + except ValueError as exc: + raise ValueError(f"sleep needs a number of seconds, got {spec!r}") from exc + return kind, [seconds] + raise ValueError(f"unknown action: {spec!r}") + + +def run_action(page, spec: str) -> None: + kind, args = parse_action(spec) + if kind == "fill": + page.fill(args[0], args[1]) + elif kind == "press": + page.press(args[0], args[1]) + elif kind == "click": + page.click(args[0]) + elif kind == "goto": + page.goto(args[0]) + elif kind == "sleep": + time.sleep(args[0]) + + +def choose_drive_page(context, *, new_page: bool): + """Pick a page to drive. + + ``new_page=True`` (CDP ``--goto``) always opens a tab so we do not + navigate a tab Hermes is already using. + """ + if new_page or not getattr(context, "pages", None): + return context.new_page() + return context.pages[-1] + + +def flush_pending(pending: dict, entries: list, make_entry) -> None: + """Turn in-flight requests into incomplete HAR entries, then clear ``pending``. + + Leftovers have no completed response. Playwright's ``request.response()`` + blocks until one arrives (no timeout), so it must never be called here. + An entry with a null response is the correct partial-capture outcome. + """ + for req in list(pending.values()): + entries.append(make_entry(req, None)) + pending.clear() diff --git a/optional-skills/web-development/har-derived-api-client/scripts/har_capture.py b/optional-skills/web-development/har-derived-api-client/scripts/har_capture.py index 445a01ecb1f4c..98444f531ae5c 100644 --- a/optional-skills/web-development/har-derived-api-client/scripts/har_capture.py +++ b/optional-skills/web-development/har-derived-api-client/scripts/har_capture.py @@ -9,31 +9,20 @@ Actions run in order after page load. The HAR embeds request/response bodies (record_har_content='embed') so derived clients can see payload shapes. -NOTE: a failing action raises before the HAR is flushed -- you get no file. -Fix the selector (try --headed to watch) and rerun. +If an action fails, everything captured up to that point is flushed to the +HAR anyway. Fix the selector (try --headed to watch) and rerun. """ +from __future__ import annotations + import argparse import sys import time +from pathlib import Path from playwright.sync_api import sync_playwright - -def run_action(page, spec: str) -> None: - parts = spec.split(":", 2) - kind = parts[0] - if kind == "fill": - page.fill(parts[1], parts[2]) - elif kind == "press": - page.press(parts[1], parts[2]) - elif kind == "click": - page.click(parts[1]) - elif kind == "goto": - page.goto(parts[1] + (":" + parts[2] if len(parts) > 2 else "")) - elif kind == "sleep": - time.sleep(float(parts[1])) - else: - raise ValueError(f"unknown action: {spec}") +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from har_actions import run_action # noqa: E402 def main() -> int: @@ -49,21 +38,25 @@ def main() -> int: with sync_playwright() as p: browser = p.chromium.launch(channel="chromium", headless=not args.headed) - context = browser.new_context( - record_har_path=args.har_path, - record_har_content="embed", # keep response bodies in the HAR - ) - page = context.new_page() - page.goto(args.url, wait_until="domcontentloaded") - for spec in args.action: - run_action(page, spec) - try: - page.wait_for_load_state("networkidle", timeout=15000) - except Exception: - pass # some pages never fully idle; the trailing --wait covers it - time.sleep(args.wait) - context.close() # flushes the HAR - browser.close() + context = None + try: + context = browser.new_context( + record_har_path=args.har_path, + record_har_content="embed", # keep response bodies in the HAR + ) + page = context.new_page() + page.goto(args.url, wait_until="domcontentloaded") + for spec in args.action: + run_action(page, spec) + try: + page.wait_for_load_state("networkidle", timeout=15000) + except Exception: + pass # some pages never fully idle; the trailing --wait covers it + time.sleep(args.wait) + finally: + if context is not None: + context.close() # flushes the HAR even when an action failed + browser.close() print(f"HAR written: {args.har_path}") return 0 diff --git a/optional-skills/web-development/har-derived-api-client/scripts/har_capture_cdp.py b/optional-skills/web-development/har-derived-api-client/scripts/har_capture_cdp.py index cc6facc1ce7e2..e60c1fca55239 100644 --- a/optional-skills/web-development/har-derived-api-client/scripts/har_capture_cdp.py +++ b/optional-skills/web-development/har-derived-api-client/scripts/har_capture_cdp.py @@ -8,8 +8,8 @@ Why this exists: Playwright's record_har_path only works on a context you launched locally. connect_over_cdp() attaches to an existing browser, so -record_har is unavailable — we assemble the HAR from CDP Network.* events -ourselves via page.on("request"/"response"). +record_har is unavailable, so we assemble the HAR from context +request/response events instead. Usage: python3 har_capture_cdp.py [--wait S] \ @@ -17,31 +17,23 @@ is the ws:// or http:// CDP endpoint. For Hermes: run `/browser connect` to see the active endpoint, or read BROWSER_CDP_URL. + +--goto opens a NEW tab so it does not navigate a page Hermes is already using. +Listeners are attached to every existing context (not just pages[0]). """ +from __future__ import annotations + import argparse import base64 import json import sys import time +from pathlib import Path from playwright.sync_api import sync_playwright - -def run_action(page, spec: str) -> None: - parts = spec.split(":", 2) - kind = parts[0] - if kind == "fill": - page.fill(parts[1], parts[2]) - elif kind == "press": - page.press(parts[1], parts[2]) - elif kind == "click": - page.click(parts[1]) - elif kind == "goto": - page.goto(parts[1] + (":" + parts[2] if len(parts) > 2 else "")) - elif kind == "sleep": - time.sleep(float(parts[1])) - else: - raise ValueError(f"unknown action: {spec}") +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from har_actions import choose_drive_page, flush_pending, run_action # noqa: E402 def _har_entry(req, resp): @@ -80,22 +72,40 @@ def _har_entry(req, resp): } +def _attach_network(contexts, on_request, on_response) -> list: + attached = [] + for ctx in contexts: + ctx.on("request", on_request) + ctx.on("response", on_response) + attached.append(ctx) + return attached + + +def _detach_network(contexts, on_request, on_response) -> None: + for ctx in contexts: + try: + ctx.remove_listener("request", on_request) + ctx.remove_listener("response", on_response) + except Exception: + pass + + def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("cdp_url") ap.add_argument("har_path") - ap.add_argument("--goto", default=None, help="URL to navigate to after attaching") + ap.add_argument("--goto", default=None, help="URL to open in a new tab after attaching") ap.add_argument("--wait", type=float, default=3.0) ap.add_argument("--action", action="append", default=[]) args = ap.parse_args() entries = [] pending = {} # id(request) -> request + drive_error = None with sync_playwright() as p: browser = p.chromium.connect_over_cdp(args.cdp_url) - context = browser.contexts[0] if browser.contexts else browser.new_context() - page = context.pages[0] if context.pages else context.new_page() + contexts = list(browser.contexts) or [browser.new_context()] def on_request(req): pending[id(req)] = req @@ -105,29 +115,39 @@ def on_response(resp): pending.pop(id(req), None) entries.append(_har_entry(req, resp)) - page.on("request", on_request) - page.on("response", on_response) - - if args.goto: - page.goto(args.goto, wait_until="domcontentloaded") - for spec in args.action: - run_action(page, spec) - try: - page.wait_for_load_state("networkidle", timeout=15000) - except Exception: - pass - time.sleep(args.wait) - - page.remove_listener("request", on_request) - page.remove_listener("response", on_response) + attached = _attach_network(contexts, on_request, on_response) + try: + page = choose_drive_page(contexts[0], new_page=bool(args.goto)) + if args.goto: + page.goto(args.goto, wait_until="domcontentloaded") + for spec in args.action: + run_action(page, spec) + try: + page.wait_for_load_state("networkidle", timeout=15000) + except Exception: + pass + time.sleep(args.wait) + except Exception as exc: + drive_error = exc + finally: + # Detach before flushing so a late response event can't append a + # duplicate of an entry the flush is about to write. + _detach_network(attached, on_request, on_response) + flush_pending(pending, entries, _har_entry) # Do NOT close: we connected to someone else's browser. - har = {"log": {"version": "1.2", - "creator": {"name": "har_capture_cdp", "version": "0.1"}, - "entries": entries}} + har = { + "log": { + "version": "1.2", + "creator": {"name": "har_capture_cdp", "version": "0.1"}, + "entries": entries, + } + } with open(args.har_path, "w", encoding="utf-8") as f: json.dump(har, f) print(f"HAR written: {args.har_path} ({len(entries)} entries)") + if drive_error is not None: + raise drive_error return 0 diff --git a/optional-skills/web-development/har-derived-api-client/scripts/har_to_client.py b/optional-skills/web-development/har-derived-api-client/scripts/har_to_client.py index 27eb2821a473a..ce83a2b8257ad 100644 --- a/optional-skills/web-development/har-derived-api-client/scripts/har_to_client.py +++ b/optional-skills/web-development/har-derived-api-client/scripts/har_to_client.py @@ -13,6 +13,7 @@ get a 403/401. """ import argparse +import base64 import json import re import sys @@ -54,6 +55,48 @@ def trunc(text, n: int) -> str: return text if len(text) <= n else text[:n] + f"... [{len(text)} chars total]" +def request_body_sample(post, max_body: int): + """Return (mimeType, text) from a HAR postData object, or None. + + Chrome/Playwright urlencoded forms often ship ``params`` and no ``text``. + ``postData: null`` (common on GET) is treated as empty, not a crash. + """ + if not isinstance(post, dict): + return None + mime = post.get("mimeType") or "" + text = post.get("text") + if text: + return mime, trunc(text, max_body) + params = post.get("params") or [] + pieces = [] + for item in params: + if not isinstance(item, dict) or item.get("name") is None: + continue + pieces.append(f"{item['name']}={item.get('value', '')}") + if not pieces: + return None + return mime, trunc("&".join(pieces), max_body) + + +def response_body_text(content) -> str: + """Return response text, decoding HAR base64 when needed.""" + if not isinstance(content, dict): + return "" + text = content.get("text") or "" + if not text: + return "" + if str(content.get("encoding") or "").lower() != "base64": + return text + try: + raw = base64.b64decode(text) + except Exception: + return text + try: + return raw.decode("utf-8") + except UnicodeDecodeError: + return f"[binary base64, {len(raw)} bytes]" + + def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("har") @@ -87,14 +130,15 @@ def main() -> int: if name in BORING_HEADERS or name in ("method", "path", "scheme", "authority"): continue g["headers"][name] = trunc(h["value"], 120) - post = req.get("postData", {}) - if post.get("text") and g["req_body"] is None: - g["req_body"] = (post.get("mimeType", ""), trunc(post["text"], args.max_body)) + if g["req_body"] is None: + sample = request_body_sample(req.get("postData"), args.max_body) + if sample is not None: + g["req_body"] = sample resp = entry.get("response", {}) if g["resp"] is None and resp: content = resp.get("content", {}) g["resp"] = (resp.get("status"), content.get("mimeType", ""), - trunc(content.get("text") or "", args.max_body)) + trunc(response_body_text(content), args.max_body)) if not groups: print("No API-looking entries found. Re-run with --include-static to see everything.") diff --git a/tests/skills/test_har_derived_api_client_skill.py b/tests/skills/test_har_derived_api_client_skill.py index 3bb10c231af33..f132bda970254 100644 --- a/tests/skills/test_har_derived_api_client_skill.py +++ b/tests/skills/test_har_derived_api_client_skill.py @@ -1,21 +1,39 @@ """Tests for the har-derived-api-client optional skill. -Runs the real har_to_client.py logic against a synthetic HAR fixture and -asserts it derives the endpoint, collapses id path segments, filters static -assets, and surfaces the User-Agent replay hint. Stdlib + pytest, no network. +Two layers, both stdlib + pytest, no network: + 1. Structural / frontmatter contract on SKILL.md (matches the maintainer + review checklist for optional skills). + 2. Behavioral: run the real har_to_client.py logic against a synthetic HAR + fixture and assert it derives the endpoint, collapses id path segments, + filters static assets, and surfaces the User-Agent replay hint. """ import importlib.util import json +import re +import sys from pathlib import Path +import pytest + SKILL_DIR = ( Path(__file__).resolve().parents[2] / "optional-skills" / "web-development" / "har-derived-api-client" ) -DERIVE = SKILL_DIR / "scripts" / "har_to_client.py" +SKILL_MD = SKILL_DIR / "SKILL.md" +SCRIPTS = SKILL_DIR / "scripts" +CAPTURE = SCRIPTS / "har_capture.py" +CAPTURE_CDP = SCRIPTS / "har_capture_cdp.py" +ACTIONS = SCRIPTS / "har_actions.py" +DERIVE = SCRIPTS / "har_to_client.py" + + +@pytest.fixture(scope="module") +def skill_text() -> str: + return SKILL_MD.read_text(encoding="utf-8") + def _load_module(path: Path, name: str): spec = importlib.util.spec_from_file_location(name, path) @@ -24,8 +42,47 @@ def _load_module(path: Path, name: str): spec.loader.exec_module(mod) return mod + +# --- structural contract --------------------------------------------------- + + +def test_skill_files_exist(): + assert SKILL_MD.is_file() + assert CAPTURE.is_file() + assert CAPTURE_CDP.is_file() + assert ACTIONS.is_file() + assert DERIVE.is_file() + + +def test_frontmatter_present(skill_text: str): + assert skill_text.startswith("---\n") + assert skill_text.count("---") >= 2 + + +def test_description_under_sixty_chars(skill_text: str): + m = re.search(r"^description: (.*)$", skill_text, re.MULTILINE) + assert m, "no description field" + desc = m.group(1).strip() + assert len(desc) <= 60, f"description is {len(desc)} chars (>60): {desc!r}" + assert desc.endswith("."), "description should end with a period" + + +def test_required_sections_present(skill_text: str): + for heading in ( + "## When to Use", + "## Prerequisites", + "## How to Run", + "## Quick Reference", + "## Procedure", + "## Pitfalls", + "## Verification", + ): + assert heading in skill_text, f"missing section: {heading}" + + # --- behavioral: derivation logic ----------------------------------------- + def _make_har() -> dict: return { "log": { @@ -64,6 +121,7 @@ def _make_har() -> dict: } } + def test_derives_endpoint_and_filters_static(tmp_path, capsys): mod = _load_module(DERIVE, "har_to_client_undertest") har = tmp_path / "t.har" @@ -91,7 +149,161 @@ def test_derives_endpoint_and_filters_static(tmp_path, capsys): # replay hint carries the browser UA assert "User-Agent (send this): Mozilla/5.0 TestBrowser/1.0" in out + +def _run_derive(mod, har_dict, tmp_path, capsys, *extra): + har = tmp_path / "t.har" + har.write_text(json.dumps(har_dict), encoding="utf-8") + argv = sys.argv + try: + sys.argv = ["har_to_client.py", str(har), *extra] + rc = mod.main() + finally: + sys.argv = argv + return rc, capsys.readouterr().out + + def test_path_template_collapses_ids(): mod = _load_module(DERIVE, "har_to_client_undertest2") assert mod.path_template("/v1/items/12345/x") == "/v1/items/{id}/x" assert mod.path_template("/v1/items/abc/x") == "/v1/items/abc/x" + + +def test_form_params_and_null_postdata(tmp_path, capsys): + mod = _load_module(DERIVE, "har_to_client_postdata") + fixture = { + "log": { + "entries": [ + { + "_resourceType": "xhr", + "request": { + "method": "POST", + "url": "https://api.example.com/login", + "queryString": [], + "headers": [{"name": "content-type", "value": "application/x-www-form-urlencoded"}], + "postData": { + "mimeType": "application/x-www-form-urlencoded", + "params": [ + {"name": "user", "value": "ada"}, + {"name": "pass", "value": "secret"}, + ], + }, + }, + "response": { + "status": 200, + "content": {"mimeType": "application/json", "text": '{"ok":true}'}, + }, + }, + { + "_resourceType": "xhr", + "request": { + "method": "GET", + "url": "https://api.example.com/v1/search?q=hi", + "queryString": [{"name": "q", "value": "hi"}], + "headers": [{"name": "User-Agent", "value": "Mozilla/5.0 TestBrowser/1.0"}], + "postData": None, + }, + "response": { + "status": 200, + "content": {"mimeType": "application/json", "text": "{}"}, + }, + }, + ] + } + } + rc, out = _run_derive(mod, fixture, tmp_path, capsys) + assert rc == 0 + assert "user=ada" in out + assert "pass=secret" in out + assert "GET https://api.example.com/v1/search" in out + + +def test_decodes_base64_response_body(tmp_path, capsys): + mod = _load_module(DERIVE, "har_to_client_b64") + fixture = _make_har() + fixture["log"]["entries"][0]["response"]["content"] = { + "mimeType": "application/json", + "encoding": "base64", + "text": "eyJvayI6dHJ1ZX0=", # {"ok":true} + } + rc, out = _run_derive(mod, fixture, tmp_path, capsys, "--host", "example.com") + assert rc == 0 + assert '{"ok":true}' in out + assert "eyJvayI6dHJ1ZX0=" not in out + + +def test_run_action_validates_specs_and_drives_page(): + mod = _load_module(ACTIONS, "har_actions_undertest") + + class Page: + def __init__(self): + self.calls = [] + + def fill(self, sel, text): + self.calls.append(("fill", sel, text)) + + def press(self, sel, key): + self.calls.append(("press", sel, key)) + + def click(self, sel): + self.calls.append(("click", sel)) + + def goto(self, url): + self.calls.append(("goto", url)) + + page = Page() + mod.run_action(page, "fill:#q:hello:world") + mod.run_action(page, "goto:https://ex.com:8080/a") + mod.run_action(page, "click:button.submit") + assert page.calls == [ + ("fill", "#q", "hello:world"), + ("goto", "https://ex.com:8080/a"), + ("click", "button.submit"), + ] + for spec in ("fill:onlysel", "fill::text", "click", "sleep:", "sleep:1:30", + "press:sel", "press::Enter", "goto", "nope:x"): + with pytest.raises(ValueError): + mod.run_action(page, spec) + + +def test_cdp_goto_opens_new_page_and_flushes_pending(): + mod = _load_module(ACTIONS, "har_actions_cdp") + + class Context: + def __init__(self, pages): + self.pages = list(pages) + self.created = [] + + def new_page(self): + page = object() + self.created.append(page) + self.pages.append(page) + return page + + existing = object() + ctx = Context([existing]) + driven = mod.choose_drive_page(ctx, new_page=True) + assert driven is not existing + assert ctx.created == [driven] + assert mod.choose_drive_page(ctx, new_page=False) is driven + + class Req: + # Playwright's request.response() blocks until the response arrives. + # A leftover request after --wait may never get one, so the flush + # must not call it at all. + def response(self): + raise AssertionError("flush_pending must not call the waiting response()") + + reqs = [Req(), Req()] + pending = {1: reqs[0], 2: reqs[1]} + entries = [] + mod.flush_pending(pending, entries, lambda req, resp: (req, resp)) + assert pending == {} + assert [req for req, _ in entries] == reqs + assert all(resp is None for _, resp in entries) + + +def test_skill_documents_all_browser_pathways(skill_text: str): + # The skill must route every Hermes browser backend to the right capturer. + for token in ("Browserbase", "Browser-Use", "Firecrawl", "browser connect", + "har_capture_cdp.py", "connect_over_cdp"): + assert token in skill_text, f"pathway coverage missing: {token}"