From 4c47dde75ebaa357d2fe7a735668ea89f51911de Mon Sep 17 00:00:00 2001 From: leaf-agent <318509791+leaf-agent@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:23:14 +0000 Subject: [PATCH 1/2] Launch the browser LEAF_BROWSER_EXECUTABLE names, else the Chrome channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both user-path launches — `version check --render` and `version export` — hardcoded `p.chromium.launch(channel="chrome")`, which finds a Google Chrome release-channel install at a fixed OS path and nothing else. A host with a Chrome for Testing, a distro or Homebrew Chromium, or a self-hosted build could neither render-check nor export, and serving-pages.md names export as the fallback for when no network route reaches the page. One helper in `render_gate/browser.py` now owns the launch and the hint each gate appends on failure, so the two call sites move together. With the variable unset the launch is unchanged. Closes #197 --- CLAUDE.md | 3 +- README.md | 4 +- .../leaf/references/internals/validation.md | 11 +- skills/leaf/scripts/CLAUDE.md | 3 +- skills/leaf/scripts/leaf/exporting.py | 12 +- .../leaf/scripts/leaf/render_gate/browser.py | 42 +++++ .../leaf/scripts/leaf/render_gate/command.py | 13 +- tests/conftest.py | 39 +++++ tests/test_render_commands.py | 157 +++++++++++++----- 9 files changed, 227 insertions(+), 57 deletions(-) create mode 100644 skills/leaf/scripts/leaf/render_gate/browser.py diff --git a/CLAUDE.md b/CLAUDE.md index 320385a82..811f5e47d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,7 +66,8 @@ in the README. The dev group — pytest, the accessibility checks, the demo recorder's frames — is recorded in that same lock but never installed on a host: `bin/leaf` passes `--no-dev`. Playwright is a runtime dependency, since the skill's own flow renders pages; browser checks launch the host's installed -Chrome, and leaf does not download a browser. +Chrome, or the executable `LEAF_BROWSER_EXECUTABLE` names where the host's browser +is some other Chromium, and leaf does not download a browser. `uv` owns `.venv/`; a `.venv` inside an installed copy is uv doing its job, not stray state to clean up. Nothing else is written back: no cache leaf keeps for diff --git a/README.md b/README.md index 09658dc68..4ebab464a 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,9 @@ No config or account is required. It needs [`uv`](https://docs.astral.sh/uv/) and [`jq`](https://jqlang.github.io/jq/download/) 1.6 or newer on `PATH` (the plugin is a uv project, and the first run syncs its environment through whatever index you have -already configured), plus a browser on the same machine as the session. +already configured), plus a browser on the same machine as the session. Render checks +and export launch Google Chrome by default; on a host that has another Chromium +instead, set `LEAF_BROWSER_EXECUTABLE` to that executable's path and both use it. Then ask the agent for a page. The explicit skill is `/leaf [topic]` in Claude Code and `$leaf [topic]` in Codex; with no argument it presents whatever the session is diff --git a/skills/leaf/references/internals/validation.md b/skills/leaf/references/internals/validation.md index 9dcf1d84c..2f66170fa 100644 --- a/skills/leaf/references/internals/validation.md +++ b/skills/leaf/references/internals/validation.md @@ -24,15 +24,18 @@ check that way; anything needing a browser belongs in `--render`. ## Browser validation `version check --render` adds the browser half, run once before a page's URL is first -handed over: the exact current source loads in the machine's installed Chrome (Playwright -`channel="chrome"` — the caller supplies playwright, which `bin/leaf` does -on seeing `--render`) and the render invariants the static lint cannot reach run +handed over: the exact current source loads in the host's browser (Playwright +`channel="chrome"`, or the executable `LEAF_BROWSER_EXECUTABLE` names where the host +has a Chromium rather than an installed Chrome — the caller supplies playwright, which +`bin/leaf` does on seeing `--render`) and the render invariants the static lint cannot reach run against it — no console or page errors, no fail-soft error box, every visible widget occupies real space, code that reads against the block it is set on, no sideways scroll, in both color schemes. The invariants live in render_version, which the tests/test_render_*.py modules drive over the shipped examples. The suite uses Chromium's headless shell, while its -end-to-end render-check tests cover the installed Chrome launch used here. +end-to-end render-check tests run both launches used here — the installed Chrome +channel, and the headless shell handed over as a named executable. `version export` +launches through the same helper, so the two move together. ## Passages diff --git a/skills/leaf/scripts/CLAUDE.md b/skills/leaf/scripts/CLAUDE.md index 4b0c2ec23..b99343c36 100644 --- a/skills/leaf/scripts/CLAUDE.md +++ b/skills/leaf/scripts/CLAUDE.md @@ -107,7 +107,8 @@ initializer is only a marker. Within `render_gate/`, `models` owns the values passed between phases, `scheme` owns one browser/color lifecycle, `readings` owns raw probe results, `reporting` owns human findings, `version` owns retry policy, `preview` owns ephemeral -servers, and `command` owns the CLI boundary. Import the owner directly; the +servers, `browser` owns the launch the two user-path gates share — `exporting` is +its other caller — and `command` owns the CLI boundary. Import the owner directly; the package initializer is only a marker. Within `validation/`, `markup` owns shared document structure rules, `instances` diff --git a/skills/leaf/scripts/leaf/exporting.py b/skills/leaf/scripts/leaf/exporting.py index 85a66c25c..447fe1bf1 100644 --- a/skills/leaf/scripts/leaf/exporting.py +++ b/skills/leaf/scripts/leaf/exporting.py @@ -14,6 +14,7 @@ version_revisions, ) from leaf.render_checks import RENDER_VIEWPORT, evaluate_probe, wait_for_probe +from leaf.render_gate.browser import browser_hint, launch_browser from leaf.render_gate.preview import preview_server from leaf.schema import _DIR_FILES, MEDIA_DIR, MEDIA_TYPES from leaf.structure import parse_structure @@ -134,8 +135,8 @@ def cmd_export(page_dir: Path, out: Path, version, *, preview=None) -> int: get one: half the document is written by the widget layer at runtime, a mermaid diagram becomes an SVG only once mermaid has drawn it, and a code block is colored by the vendored tokenizer in the page rather than by anything that can read the - file. So Chrome is not an optimisation here and no `x-` key exempts a widget from - it; without a browser there is nothing to copy at all.""" + file. So a browser is not an optimisation here and no `x-` key exempts a widget + from it; without one there is nothing to copy at all.""" preview = preview_server if preview is None else preview try: from playwright.sync_api import Error as PlaywrightError @@ -169,11 +170,12 @@ def cmd_export(page_dir: Path, out: Path, version, *, preview=None) -> int: sync_playwright() as p, ): try: - browser = p.chromium.launch(channel="chrome") + browser = launch_browser(p) except PlaywrightError as e: sys.exit( - f"export needs Chrome, and it didn't launch ({str(e).strip().splitlines()[0]}). " - "A copy is the drawn page, so there is nothing to write without one." + "export needs a browser, and none launched " + f"({str(e).strip().splitlines()[0]}). A copy is the drawn page, so " + f"there is nothing to write without one. {browser_hint()}" ) try: html = export_page(browser, url, page_dir, name) diff --git a/skills/leaf/scripts/leaf/render_gate/browser.py b/skills/leaf/scripts/leaf/render_gate/browser.py new file mode 100644 index 000000000..ad21532b8 --- /dev/null +++ b/skills/leaf/scripts/leaf/render_gate/browser.py @@ -0,0 +1,42 @@ +"""The browser the user-path gates launch. + +`version check --render` and `version export` both draw the page in a real +browser, and both should reach whichever one the host has. Playwright's +`channel="chrome"` finds a Google Chrome release-channel install at a fixed OS +path and nothing else, so a Chrome for Testing, a distro or Homebrew Chromium, +or a self-hosted build is invisible to it — even though every render invariant +passes on one, which is why the suite's own fixture drives the headless shell. +With no outbound network Playwright's usual answer of fetching its own browser +is gone too, and leaf does not download one. + +So the host names its browser, in the namespace `host.py` already uses for +LEAF_AGENT and LEAF_SESSION_ID. The variable carries a path and nothing else: +the launch takes no other argument from the host, and the default is unchanged +where it names none. +""" + +import os + +VARIABLE = "LEAF_BROWSER_EXECUTABLE" + + +def named_executable() -> str | None: + """The browser this host named, or None where it named none. Empty is none, + so a caller handing a child its own environment can unname one.""" + return os.environ.get(VARIABLE) or None + + +def launch_browser(p): + """The host's browser: whichever executable LEAF_BROWSER_EXECUTABLE names, + else the installed Chrome release channel. Raises PlaywrightError, which + each gate reports in its own words.""" + if executable := named_executable(): + return p.chromium.launch(executable_path=executable) + return p.chromium.launch(channel="chrome") + + +def browser_hint() -> str: + """The line each gate appends when the launch failed.""" + if executable := named_executable(): + return f"{VARIABLE} named {executable}." + return f"{VARIABLE} names one if this host has no installed Chrome." diff --git a/skills/leaf/scripts/leaf/render_gate/command.py b/skills/leaf/scripts/leaf/render_gate/command.py index 21628ffd1..dc9d7946b 100644 --- a/skills/leaf/scripts/leaf/render_gate/command.py +++ b/skills/leaf/scripts/leaf/render_gate/command.py @@ -3,6 +3,7 @@ import sys from pathlib import Path +from .browser import browser_hint, launch_browser from .preview import preview_server from .version import render_version @@ -16,13 +17,13 @@ def render_check( render=None, transition_held: bool = False, ) -> int: - """Serve candidate source to the machine's installed Chrome and run the - render invariants on it. + """Serve candidate source to the host's browser and run the render + invariants on it. Playwright is the gate's own extra, not the payload's: declaring it in `pyproject.toml` would put its wheel in every `server run`, `leaf wait`, and `version stamp`, so the import happens here and its absence names the - invocation that supplies it. Chrome is part of this gate: if it cannot + invocation that supplies it. A browser is part of this gate: if it cannot launch, the gate fails.""" preview = preview_server if preview is None else preview render = render_version if render is None else render @@ -43,11 +44,11 @@ def render_check( sync_playwright() as p, ): try: - browser = p.chromium.launch(channel="chrome") + browser = launch_browser(p) except PlaywrightError as error: print( - "✗ render check failed — Chrome did not launch: " - + str(error).strip().splitlines()[0], + "✗ render check failed — no browser launched: " + f"{str(error).strip().splitlines()[0]}. {browser_hint()}", file=sys.stderr, ) return 1 diff --git a/tests/conftest.py b/tests/conftest.py index 603436e7e..d98273266 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -199,3 +199,42 @@ def browser(): b = p.chromium.launch() yield b b.close() + + +@pytest.fixture(scope="session") +def headless_shell(): + """The path of a browser that is not installed Chrome, for the tests that hand + one to a leaf process through LEAF_BROWSER_EXECUTABLE. + + Playwright reports where its full Chromium build would be whether or not that + build is installed, and the documented setup installs the shell alone + (tests/CLAUDE.md, "Run the narrowest useful surface"). Both sit under one + registry root at one build number, so the shell's path follows from Chromium's; + where a developer installed the full build instead, that is the browser to hand + over and the same tests hold on it. + + Asked in a subprocess because the answer is a path and the question is not free + here: a second `sync_playwright()` inside this process raises where the + session's `browser` fixture already holds one open, so which tests had run + first would decide whether the fixture worked.""" + read = subprocess.run( + [ + sys.executable, + "-c", + "from playwright.sync_api import sync_playwright\n" + "with sync_playwright() as p: print(p.chromium.executable_path)", + ], + capture_output=True, + text=True, + check=True, + ) + chromium = Path(read.stdout.strip()) + root, build = chromium.parents[2], chromium.parents[1].name.rsplit("-", 1)[1] + shell = root / f"chromium_headless_shell-{build}" + for candidate in (*sorted(shell.glob("*/chrome-headless-shell*")), chromium): + if candidate.is_file(): + return str(candidate) + raise AssertionError( + f"no Playwright Chromium under {root}; run `uv run playwright install " + "chromium --only-shell` (tests/CLAUDE.md)" + ) diff --git a/tests/test_render_commands.py b/tests/test_render_commands.py index 171a8a07f..0b09fb380 100644 --- a/tests/test_render_commands.py +++ b/tests/test_render_commands.py @@ -2,6 +2,7 @@ import hashlib import json +import os import shutil import subprocess from pathlib import Path @@ -188,15 +189,22 @@ def test_the_gate_measures_an_inline_widget_by_its_words(browser, serve): assert render_gate_model.render_version(browser, url) == [] -def test_check_render_refuses_what_only_a_browser_can_see(serve): +def test_check_render_refuses_what_only_a_browser_can_see(serve, headless_shell): """`version check --render` end to end, as the agent runs it: the static lint passes both sources, and only one renders clean. The broken source is deliberately unstamped — refusing it before `version stamp` names it is the gate's whole job, - so the preview server has to expose the exact candidate without activating it.""" + so the preview server has to expose the exact candidate without activating it. + + Twice over the clean source, once through each browser a host can supply: the + installed Chrome the default channel finds, and the executable + LEAF_BROWSER_EXECUTABLE names. The default arm states the empty value rather + than inheriting whatever the developer or the job exported, since a set variable + would otherwise turn the channel this arm exists to cover into a second run of + the other one.""" serve(LONG_PAGE) d = serve.page_dir - def gate(*args): + def gate(*args, executable=""): return subprocess.run( [ *LEAF_COMMAND, @@ -209,12 +217,17 @@ def gate(*args): capture_output=True, text=True, check=False, # both exit codes are the subject + env=os.environ | {"LEAF_BROWSER_EXECUTABLE": executable}, ) ok = gate() assert ok.returncode == 0, ok.stderr assert "renders clean" in ok.stdout + named = gate(executable=headless_shell) + assert named.returncode == 0, named.stderr + assert "renders clean" in named.stdout + # A vw width slips the static lint (which counts only px) and overflows only # in a layout engine. (d / "index.html").write_text( @@ -225,8 +238,57 @@ def gate(*args): assert "scrolls sideways" in broken.stderr -def test_an_installed_payload_passes_its_real_browser_gate(tmp_path): - """Exercise the copied artifact a host installs, never an import from this checkout.""" +def test_a_named_browser_that_is_not_one_names_the_variable(serve, tmp_path): + """LEAF_BROWSER_EXECUTABLE is the whole of what a host says about its browser, so + a value naming no browser has to come back as that variable and that value rather + than as Chrome, which the host never asked for. Both user-path launches answer for + it, and they have to move together: `serving-pages.md` names export as the fallback + for when no network route reaches the page, so a host whose browser cannot launch + loses the page twice over.""" + serve(LONG_PAGE) + d = serve.page_dir + missing = tmp_path / "not-a-browser" + named = os.environ | {"LEAF_BROWSER_EXECUTABLE": str(missing)} + + checked = subprocess.run( + [*LEAF_COMMAND, "version", "check", str(d), "--render"], + capture_output=True, + text=True, + check=False, + env=named, + ) + assert checked.returncode == 1, checked.stdout + checked.stderr + assert ( + "LEAF_BROWSER_EXECUTABLE" in checked.stderr and str(missing) in checked.stderr + ) + assert "Chrome did not launch" not in checked.stderr + + exported = subprocess.run( + [ + *LEAF_COMMAND, + "version", + "export", + str(d), + "--out", + str(tmp_path / "standalone.html"), + ], + capture_output=True, + text=True, + check=False, + env=named, + ) + assert exported.returncode == 1, exported.stdout + exported.stderr + assert ( + "LEAF_BROWSER_EXECUTABLE" in exported.stderr and str(missing) in exported.stderr + ) + assert "export needs Chrome" not in exported.stderr + + +def test_an_installed_payload_passes_its_real_browser_gate(tmp_path, headless_shell): + """Exercise the copied artifact a host installs, never an import from this checkout. + + Its browser gate runs on both of the browsers a host can supply, since the install + is where a host with a Chromium and no Chrome meets it.""" root = Path(__file__).parent.parent installed = install_payload(tmp_path / "host" / "leaf") launcher = installed / "bin" / "leaf" @@ -265,15 +327,17 @@ def test_an_installed_payload_passes_its_real_browser_gate(tmp_path): ) assert stamp.returncode == 0, stamp.stderr - rendered = subprocess.run( - [launcher, "version", "check", page_dir, "--render"], - cwd=elsewhere, - capture_output=True, - text=True, - check=False, - ) - assert rendered.returncode == 0, rendered.stderr - assert "renders clean" in rendered.stdout + for executable in ("", headless_shell): + rendered = subprocess.run( + [launcher, "version", "check", page_dir, "--render"], + cwd=elsewhere, + capture_output=True, + text=True, + check=False, + env=os.environ | {"LEAF_BROWSER_EXECUTABLE": executable}, + ) + assert rendered.returncode == 0, rendered.stderr + assert "renders clean" in rendered.stdout def test_render_reports_a_word_the_printed_page_loses(browser, serve): @@ -443,7 +507,9 @@ def test_a_tall_shot_flips_where_it_was_clicked_without_moving_the_page(browser, page.close() -def test_a_shot_still_flips_with_every_script_removed(browser, serve, tmp_path): +def test_a_shot_still_flips_with_every_script_removed( + browser, serve, tmp_path, headless_shell +): """Which is the whole reason the target is a native checkbox. A copy is the rendered DOM with the scripts dropped and every press a handler answered taken out with them — the upgrade has already run, so the frames are there, and this switch @@ -470,21 +536,32 @@ def test_a_shot_still_flips_with_every_script_removed(browser, serve, tmp_path): media={SHOT_SRC[name]: data for name, data in SHOTS.items()}, ) + def export(out, executable=""): + return subprocess.run( + [ + *LEAF_COMMAND, + "version", + "export", + str(serve.page_dir), + "--out", + str(out), + ], + capture_output=True, + text=True, + check=False, + env=os.environ | {"LEAF_BROWSER_EXECUTABLE": executable}, + ) + standalone = tmp_path / "standalone.html" - exported = subprocess.run( - [ - *LEAF_COMMAND, - "version", - "export", - str(serve.page_dir), - "--out", - str(standalone), - ], - capture_output=True, - text=True, - check=False, - ) + exported = export(standalone) assert exported.returncode == 0, exported.stdout + exported.stderr + + # The same copy through the browser a host names instead. A file is all this arm + # needs from it: what a copy has to keep is the subject below, on the Chrome arm. + named = tmp_path / "named.html" + from_named = export(named, executable=headless_shell) + assert from_named.returncode == 0, from_named.stdout + from_named.stderr + assert named.stat().st_size > 0 loose = browser.new_page(viewport={"width": 1200, "height": 900}) loose.goto(standalone.as_uri(), wait_until="load") assert loose.evaluate("document.querySelectorAll('script').length") == 0 @@ -725,7 +802,7 @@ def test_render_reads_a_reply_widgets_own_chrome_and_not_the_panel_around_it( ], found -def test_the_shim_runs_the_gate_from_anywhere(serve, tmp_path): +def test_the_shim_runs_the_gate_from_anywhere(serve, tmp_path, headless_shell): """`leaf` is what the skill hands an agent, so the shim's own resolution is load-bearing: it names the payload project from its own location rather than letting uv find whatever project the cwd sits in. Running it from an @@ -742,13 +819,15 @@ def test_the_shim_runs_the_gate_from_anywhere(serve, tmp_path): ) shim = Path(__file__).parent.parent / "bin" / "leaf" - run = subprocess.run( - [str(shim), "version", "check", str(d), "--render"], - cwd=tmp_path, - capture_output=True, - text=True, - check=False, - ) - assert run.returncode == 1, run.stdout + run.stderr - # "needs Playwright" here would mean the shim dispatched the plain `uv run`. - assert "failed soft" in run.stderr and "Parse error" in run.stderr + for executable in ("", headless_shell): + run = subprocess.run( + [str(shim), "version", "check", str(d), "--render"], + cwd=tmp_path, + capture_output=True, + text=True, + check=False, + env=os.environ | {"LEAF_BROWSER_EXECUTABLE": executable}, + ) + assert run.returncode == 1, run.stdout + run.stderr + # "needs Playwright" here would mean the shim dispatched the plain `uv run`. + assert "failed soft" in run.stderr and "Parse error" in run.stderr From a5acaf2110185595279caac4413ae66a4e40ce08 Mon Sep 17 00:00:00 2001 From: leaf-agent <318509791+leaf-agent@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:31:45 +0000 Subject: [PATCH 2/2] Parenthesize the fixture's implicit string concatenation for ISC004 --- tests/conftest.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index d98273266..765374fcd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -221,8 +221,10 @@ def headless_shell(): [ sys.executable, "-c", - "from playwright.sync_api import sync_playwright\n" - "with sync_playwright() as p: print(p.chromium.executable_path)", + ( + "from playwright.sync_api import sync_playwright\n" + "with sync_playwright() as p: print(p.chromium.executable_path)" + ), ], capture_output=True, text=True,