Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 7 additions & 4 deletions skills/leaf/references/internals/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion skills/leaf/scripts/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
12 changes: 7 additions & 5 deletions skills/leaf/scripts/leaf/exporting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
42 changes: 42 additions & 0 deletions skills/leaf/scripts/leaf/render_gate/browser.py
Original file line number Diff line number Diff line change
@@ -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."
13 changes: 7 additions & 6 deletions skills/leaf/scripts/leaf/render_gate/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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
Expand Down
41 changes: 41 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,3 +199,44 @@ 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]

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
root, build = chromium.parents[2], chromium.parents[1].name.rsplit("-", 1)[1]
versioned = next(p for p in chromium.parents if p.name.startswith("chromium-"))
root, build = versioned.parent, versioned.name.rsplit("-", 1)[1]

Counting two levels up finds the registry root only on Linux. On macOS the executable is chromium-<build>/chrome-mac/Chromium.app/Contents/MacOS/Chromium, so parents[1].name is Contents and the rsplit raises IndexError — the fixture dies before it can raise its own AssertionError, and every test that asks for it errors. Naming the chromium-<build> directory rather than its depth holds on both.

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)"
)
Loading
Loading