From e7da97341147c9b1c25c1c1593dd0260a4118d64 Mon Sep 17 00:00:00 2001 From: leaf-agent <318509791+leaf-agent@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:30:15 +0000 Subject: [PATCH 01/10] Fix ten nightly failures from the PR review packet and the item-hint layout change The nightly suite on main came back 10 failed. Most trace to #189, whose new pr-walkthrough page replaced the one two nightly-only tests still name and whose new package widget ships chrome the layer's markers never reached; two trace to #190's zero-height ToC anchor, which shortened the release page past a named scroll; and one is an ordering collision between Playwright's sync API and asyncio.run. - Point the two stale heading assertions at the page the example now is. - Route the data-bound diff test's comment through the anchored field, which is where #181 moved the press it still clicked. - Read the sticky sidebar's scroll position off the page instead of naming it. - Give the author's description a surface the layer's muted ink clears, and the changed rows' line numbers an ink that clears their own fill. - Build CallDiff's disclosure with offer, so an exported copy takes the press away, and give the location an href only where following it goes somewhere. - Read a covered run against what it paints, so clipped overflow stops reading as words on words. - Show a rendered diff and a scrolling code line in the corpus again, which is what the two unlit focus rings were missing. - Run the MCP stdio exchange on its own thread, since the session-scoped browser holds an asyncio loop open in the worker's main one. --- examples/corpus.html | 20 +++++++- examples/pr-walkthrough.html | 20 +++++++- skills/leaf/packages/default/theme.css | 6 +++ skills/leaf/packages/pr-review/theme.css | 16 +++++-- .../pr-review/widgets/lf-call-diff.js | 48 ++++++++++++------- .../scripts/leaf/render-checks/standalone.js | 39 ++++++++++++++- tests/test_interact_mcp.py | 12 ++++- tests/test_render_anchors.py | 10 ++-- tests/test_render_commands.py | 19 +++++--- tests/test_render_export.py | 4 +- tests/test_render_gate.py | 20 ++++---- tests/test_render_pages.py | 27 +++++++++-- tests/test_site.py | 2 +- 13 files changed, 195 insertions(+), 48 deletions(-) diff --git a/examples/corpus.html b/examples/corpus.html index e1d091770..e7d90f18b 100644 --- a/examples/corpus.html +++ b/examples/corpus.html @@ -842,13 +842,31 @@

One unchanged root, replacement calls in context

> +

The default the picker now opens on

+

+ The one hunk the rest of the change is arranged around: the picker asks for a + prepared diff instead of assembling its own git diff arguments. +

+ +
+diff --git a/src/git/diff.rs b/src/git/diff.rs
+--- a/src/git/diff.rs
++++ b/src/git/diff.rs
+@@ -112,4 +112,3 @@ impl Repository {
+     pub fn picker_preview(&self, worktree: &Worktree) -> Result<Diff> {
+-        let args = ["diff", "--patch", "--no-color", "--find-renames", "--diff-algorithm=histogram", "HEAD"];
+-        Diff::parse(&self.git(&args, worktree.path())?)
++        PreparedDiff::for_worktree(self, worktree)?.unified()
+     }
+
+

Review invariants

The implementation is expected to preserve these three properties.

-assert_eq!(real_index_after, real_index_before);
+assert_eq!(repo.read_index_bytes(&worktree)?, index_before_preview, "preview preparation rewrote {worktree:?}");
 assert!(temp_index.is_operation_scoped());
 assert!(offscreen_worktree_preview.is_demand_loaded());
 
diff --git a/examples/pr-walkthrough.html b/examples/pr-walkthrough.html index 1433c9b4d..0100e1b88 100644 --- a/examples/pr-walkthrough.html +++ b/examples/pr-walkthrough.html @@ -359,13 +359,31 @@

One unchanged root, replacement calls in context

> +

The default the picker now opens on

+

+ The one hunk the rest of the change is arranged around: the picker asks for a + prepared diff instead of assembling its own git diff arguments. +

+ +
+diff --git a/src/git/diff.rs b/src/git/diff.rs
+--- a/src/git/diff.rs
++++ b/src/git/diff.rs
+@@ -112,4 +112,3 @@ impl Repository {
+     pub fn picker_preview(&self, worktree: &Worktree) -> Result<Diff> {
+-        let args = ["diff", "--patch", "--no-color", "--find-renames", "--diff-algorithm=histogram", "HEAD"];
+-        Diff::parse(&self.git(&args, worktree.path())?)
++        PreparedDiff::for_worktree(self, worktree)?.unified()
+     }
+
+

Review invariants

The implementation is expected to preserve these three properties.

-assert_eq!(real_index_after, real_index_before);
+assert_eq!(repo.read_index_bytes(&worktree)?, index_before_preview, "preview preparation rewrote {worktree:?}");
 assert!(temp_index.is_operation_scoped());
 assert!(offscreen_worktree_preview.is_demand_loaded());
 
diff --git a/skills/leaf/packages/default/theme.css b/skills/leaf/packages/default/theme.css index 95d64a0ed..fe759c7a5 100644 --- a/skills/leaf/packages/default/theme.css +++ b/skills/leaf/packages/default/theme.css @@ -1571,6 +1571,12 @@ lf-chart .lf-chart-swatch { width: 9px; height: 9px; border-radius: 2px; --diffs-bg-separator: var(--code-chrome); --diffs-fg: var(--code-ink); --diffs-fg-number: var(--code-muted); + /* A changed row's number is drawn in that row's own base by default, which is the + ink the fill beside it is mixed from: green on green at 4.03:1 in the light + palette. The tinted-surface inks are the layer's answer to exactly that question + and keep the number the same statement, legibly. */ + --diffs-fg-number-addition-override: var(--ok-ink); + --diffs-fg-number-deletion-override: var(--danger-ink); --diffs-addition-base: var(--ok); --diffs-deletion-base: var(--danger); --diffs-bg-addition: color-mix(in srgb, var(--ok) 16%, var(--pre-bg)); diff --git a/skills/leaf/packages/pr-review/theme.css b/skills/leaf/packages/pr-review/theme.css index b2115b591..3f3972e7c 100644 --- a/skills/leaf/packages/pr-review/theme.css +++ b/skills/leaf/packages/pr-review/theme.css @@ -121,10 +121,15 @@ lf-pull-request { font: var(--t-4) var(--mono); } +/* The card is already a tint of the accent over the page's paper, so a second + * translucent accent here stacked into a surface the layer's own --muted ink could not + * clear: the label and any emphasis inside the author's words read at 4.32:1. The + * raised-surface token separates the quoted block from the card by going lighter + * instead, which the accent rule beside it was doing the work of anyway. */ .lf-pr-description { padding: var(--sp-3); border-inline-start: 3px solid var(--accent); - background: color-mix(in srgb, var(--accent) 5%, transparent); + background: var(--card); } .lf-pr-description-label, @@ -268,7 +273,10 @@ lf-call-diff { font-size: var(--t-6); } -.lf-call-toggle { +/* Doubled with .lf-ui, which `offer` writes on every control a widget injects: the + * runtime's chrome face and ink are stated at one class, so a package rule that names + * only its own class loses the face it asked for. */ +.lf-call-toggle.lf-ui { flex: none; padding: 3px 9px; border: 1px solid var(--code-border); @@ -420,7 +428,9 @@ lf-call-diff { text-underline-offset: 2px; } -.lf-call-location:hover { +/* Only where there is somewhere to go: a row that names its location without linking it + * must not answer the pointer as though it did. */ +.lf-call-location[href]:hover { color: var(--accent); } diff --git a/skills/leaf/packages/pr-review/widgets/lf-call-diff.js b/skills/leaf/packages/pr-review/widgets/lf-call-diff.js index d8bad7c36..9dd743275 100644 --- a/skills/leaf/packages/pr-review/widgets/lf-call-diff.js +++ b/skills/leaf/packages/pr-review/widgets/lf-call-diff.js @@ -4,6 +4,7 @@ * grammar and projects each row as commentable evidence. */ import { announce, + offer, projectData, scrollBehavior, watchData, @@ -109,9 +110,10 @@ function updateDisclosureControl(owner) { function buildToolbar(owner) { const toolbar = make("div", "lf-call-tools"); const summary = make("p", "lf-call-summary"); - const button = make("button", "lf-call-toggle"); - toolbar.dataset.lfUi = ""; - button.type = "button"; + // `offer`, not a bare button: the disclosure control is chrome this widget injected + // and a handler is all it ever was, so the markers it writes are what tells the + // exported copy to take the press away rather than draw a hand over a dead one. + const button = offer("button", "lf-call-toggle"); button.addEventListener("click", () => { const groups = [...owner.querySelectorAll(":scope > .lf-call-group")]; const open = groups.some((group) => !group.open); @@ -210,20 +212,32 @@ function renderLine(record, prior, owner) { setText(body, record.body); setText(location, record.location); location.hidden = !record.location; - location.href = `#${owner.getAttribute("diff")}`; - location.onclick = async (event) => { - event.preventDefault(); - event.stopPropagation(); - if (await travelToLine(owner, record)) return; - const url = new URL(window.location.href); - url.hash = owner.getAttribute("diff"); - history.pushState(null, "", url); - document.getElementById(owner.getAttribute("diff"))?.scrollIntoView({ - behavior: scrollBehavior(), - block: "start", - }); - announce(`${record.location} is not present in the exact patch`); - }; + // Two rows have the element and no journey to offer, and an `href` on either is a way + // in that leads nowhere. The header names no location at all, and its hidden anchor + // answered the scroll sweep's question — "is there a way into this box?" — for a box + // whose words run off the side, so a copy shipped it with no keyboard route at all. + // A group's own row is the disclosure, and a link inside a is two gestures + // on one box: the press folds and the anchor travels. The calls beneath it keep the + // link, which is where following the root's own location leads anyway. + if (record.location && !line.matches("summary")) { + location.href = `#${owner.getAttribute("diff")}`; + location.onclick = async (event) => { + event.preventDefault(); + event.stopPropagation(); + if (await travelToLine(owner, record)) return; + const url = new URL(window.location.href); + url.hash = owner.getAttribute("diff"); + history.pushState(null, "", url); + document.getElementById(owner.getAttribute("diff"))?.scrollIntoView({ + behavior: scrollBehavior(), + block: "start", + }); + announce(`${record.location} is not present in the exact patch`); + }; + } else { + location.removeAttribute("href"); + location.onclick = null; + } return line; } diff --git a/skills/leaf/scripts/leaf/render-checks/standalone.js b/skills/leaf/scripts/leaf/render-checks/standalone.js index 7c0b2de04..a3167c698 100644 --- a/skills/leaf/scripts/leaf/render-checks/standalone.js +++ b/skills/leaf/scripts/leaf/render-checks/standalone.js @@ -26,6 +26,39 @@ export function coveredWords({ } return false; }; + // What a run paints, which is not the whole of its rect. A box that clips its overflow + // shows only the part inside it, so a name ellipsised in a narrow column reads as + // covering whatever stands beside it while the reader sees the ellipsis and nothing + // else — the CallDiff root's own row, where the copy revealed a tab the live page keeps + // closed. Every clipping ancestor is intersected in, out through each shadow host, so + // the reading is the one the reader is given. + const painted = (el, drawn) => { + let box = drawn; + for ( + let ancestor = el; + ancestor && box; + ancestor = ancestor.parentElement ?? ancestor.getRootNode().host ?? null + ) { + const style = getComputedStyle(ancestor); + if (style.overflowX !== "visible" || style.overflowY !== "visible") { + const bounds = ancestor.getBoundingClientRect(); + const left = Math.max(box.left, bounds.left); + const right = Math.min(box.right, bounds.right); + const top = Math.max(box.top, bounds.top); + const bottom = Math.min(box.bottom, bounds.bottom); + box = + right > left && bottom > top + ? new DOMRect(left, top, right - left, bottom - top) + : null; + } + // An out-of-flow box is laid out against its containing block rather than against + // the ancestry, so a hidden overflow further out need not reach it at all. Stop + // climbing there and keep the rect whole: over-reporting a cover is this reading's + // safe direction, and missing one is the fault it was written for. + if (style.position === "absolute" || style.position === "fixed") break; + } + return box; + }; const walk = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); for (let node = walk.nextNode(); node; node = walk.nextNode()) { const el = node.parentElement; @@ -41,9 +74,11 @@ export function coveredWords({ const range = document.createRange(); range.selectNodeContents(node); const label = el.closest("text"); - for (const box of range.getClientRects()) - if (box.width > 1 && box.height > 1) + for (const drawn of range.getClientRects()) { + const box = painted(el, drawn); + if (box && box.width > 1 && box.height > 1) runs.push({ el, label, box, text: node.data.trim().slice(0, 40) }); + } } const found = []; for (let i = 0; i < runs.length; i++) diff --git a/tests/test_interact_mcp.py b/tests/test_interact_mcp.py index cca2141f3..24c27623e 100644 --- a/tests/test_interact_mcp.py +++ b/tests/test_interact_mcp.py @@ -4,6 +4,7 @@ import json import os import sys +from concurrent.futures import ThreadPoolExecutor from pathlib import Path from leaf import event_log as events_model @@ -133,7 +134,16 @@ async def exchange(): result = await session.call_tool("leaf_present", {"page": str(page_dir)}) return initialized, tools, resources, resource, result - initialized, tools, resources, resource, result = asyncio.run(exchange()) + # On its own thread, because the session-scoped `browser` fixture leaves one + # running in the worker's main thread for the rest of the run: Playwright's sync API + # drives an asyncio loop and holds it open for the lifetime of `sync_playwright()`, + # so `asyncio.run` there refuses to start a second. Ordering decides whether a worker + # has opened a browser first, which is what made this fail in a full run and pass in + # a run of this file alone. + with ThreadPoolExecutor(max_workers=1) as loop_thread: + initialized, tools, resources, resource, result = loop_thread.submit( + lambda: asyncio.run(exchange()) + ).result() by_name = {tool.name: tool for tool in tools.tools} assert initialized.protocol_version == "2025-11-25" diff --git a/tests/test_render_anchors.py b/tests/test_render_anchors.py index ab83f32a8..ff7b4cad7 100644 --- a/tests/test_render_anchors.py +++ b/tests/test_render_anchors.py @@ -3342,9 +3342,10 @@ def route(request): added.click(modifiers=["Alt"]) expect(page.locator(".lf-fab-bar")).to_be_visible() - page.locator(".lf-fab").click() + page.locator(".lf-fab-input").click() + expect(page.locator(".lf-composer")).to_be_visible() page.locator(".lf-composer textarea").fill("Review the whole added line.") - page.keyboard.press("ControlOrMeta+Enter") + page.keyboard.press("Enter") round_trip(page) page.get_by_role("button", name=re.compile("^Threads")).click() panel_settled(page, True) @@ -3394,9 +3395,10 @@ def route(request): "endsAtTokenStart": True, }, selected expect(page.locator(".lf-fab-bar")).to_be_visible() - page.locator(".lf-fab").click() + page.locator(".lf-fab-input").click() + expect(page.locator(".lf-composer")).to_be_visible() page.locator(".lf-composer textarea").fill("Review this expression.") - page.keyboard.press("ControlOrMeta+Enter") + page.keyboard.press("Enter") round_trip(page) expect(page.locator(".lf-thread .lf-quote").nth(1)).to_have_text( "app.py · new line 2 · “request.token.id”" diff --git a/tests/test_render_commands.py b/tests/test_render_commands.py index 171a8a07f..5056f56cf 100644 --- a/tests/test_render_commands.py +++ b/tests/test_render_commands.py @@ -57,10 +57,11 @@ def test_the_gate_passes_a_page_that_carries_a_comment(browser, serve): and every page the sweep above renders is a page with no comments on it. The pass hunting words drawn on other words has to know the same difference, and - knows it as a float the runtime hangs over the page. The resting control is transparent, - so the browser correctly omits it from a paint check. This test makes that control - visible to plant the fault it is about: its characters then fall down the document - through the paragraphs under the passage. Holding the runtime float out is the only + knows it as a float the runtime hangs over the page. The resting control is drawn + nowhere twice over — transparent, and clipped to the pixel it is parked on — and the + paint check correctly omits it for either reason. This test takes both away to plant + the fault it is about: its characters then fall down the document through the + paragraphs under the passage, painted. Holding the runtime float out is the only thing keeping the reading clean, so it is taken twice: once as the gate runs it, and once with the hold defeated, where it has to report. @@ -81,8 +82,14 @@ def test_the_gate_passes_a_page_that_carries_a_comment(browser, serve): "() => document.querySelectorAll('.lf-mark-note').length === 1" ) # Give the real runtime control paint so this tests the floating exemption rather - # than passing because the ordinary resting state is not drawn. - page.locator(".lf-mark-note").evaluate("note => note.style.opacity = '1'") + # than passing because the ordinary resting state is not drawn. Both halves of "not + # drawn": the transparency, and the one-pixel box whose hidden overflow keeps the + # characters off the screen however opaque they are. + # The one-pixel box stays: it is what turns the label into a column of characters + # falling through the paragraphs, which is the shape of the fault. + page.locator(".lf-mark-note").evaluate( + "note => Object.assign(note.style, {opacity: '1', overflow: 'visible'})" + ) held = render_checks_model.evaluate_probe(page, "coveredWords") reported = render_checks_model.evaluate_probe( page, "coveredWords", {"holdFloating": False} diff --git a/tests/test_render_export.py b/tests/test_render_export.py index 02ce18638..41040ebe9 100644 --- a/tests/test_render_export.py +++ b/tests/test_render_export.py @@ -146,7 +146,9 @@ def test_the_example_preview_command_exports_a_file_that_opens_on_its_own( errors = watched(page) page.on("requestfailed", lambda request: errors.append(f"unfetched {request.url}")) page.goto(out.as_uri(), wait_until="load") - expect(page.get_by_role("heading", name="Per-token rate limits")).to_be_visible() + expect( + page.get_by_role("heading", name="Unified diff in the switch picker") + ).to_be_visible() assert page.locator("script").count() == 0 assert page.locator('link[rel="stylesheet"]').count() == 0 assert page.locator("style").count() > 0 diff --git a/tests/test_render_gate.py b/tests/test_render_gate.py index 67e13b626..52cd16d3c 100644 --- a/tests/test_render_gate.py +++ b/tests/test_render_gate.py @@ -1198,15 +1198,19 @@ def test_the_runtime_holds_a_scroller_the_page_wrote(browser, serve): example = next(e for e in EXAMPLES if e.stem == "pr-walkthrough") page, errors = open_page(browser, serve(example)) + # The lines a diff has drawn, which is not every diff on the page: the shipped patch + # stands collapsed and fetches one file when it is asked for, so its shadow tree + # holds nothing to mark until a reader opens one. The floor below is what keeps that + # from reading as a clean sweep of nothing. diffed = page.evaluate( - """() => [...document.querySelectorAll('lf-diff')].map((d) => { - const pre = d.shadowRoot.querySelector('pre'); - return { scrolls: getComputedStyle(pre).overflowX, - marked: pre.hasAttribute('data-lf-holds'), - position: getComputedStyle(pre).position }; - })""" - ) - assert diffed, "no diff on the page, so nothing here stands in a shadow tree" + """() => [...document.querySelectorAll('lf-diff')] + .map((d) => d.shadowRoot.querySelector('pre')) + .filter(Boolean) + .map((pre) => ({ scrolls: getComputedStyle(pre).overflowX, + marked: pre.hasAttribute('data-lf-holds'), + position: getComputedStyle(pre).position }))""" + ) + assert diffed, "no diff drew its lines here, so nothing stands in a shadow tree" assert all( d == {"scrolls": "auto", "marked": True, "position": "relative"} for d in diffed ), f"the mark did not reach the diff's lines: {diffed}" diff --git a/tests/test_render_pages.py b/tests/test_render_pages.py index 6c7745e4a..a1522f2ad 100644 --- a/tests/test_render_pages.py +++ b/tests/test_render_pages.py @@ -2154,10 +2154,31 @@ def test_a_left_sidebar_uses_the_margin_until_the_page_needs_it_back(browser, se resized(page, 1400, 900) - page.evaluate( - "document.scrollingElement.style.scrollBehavior = 'auto'; document.scrollingElement.scrollTo(0, 900)" + # Halfway through the stretch where the box stands on its own offset: past the + # scroll that lifts it off where it was authored, and short of the one where main's + # own end starts carrying it back up. Both edges are the page's, so they are read + # off it — a named scroll fell past the far one when the release page grew shorter. + parked = page.evaluate( + """() => { + const scroller = document.scrollingElement; + scroller.style.scrollBehavior = 'auto'; + const sidebar = document.querySelector('aside.sidebar'); + const main = sidebar.parentElement; + const box = sidebar.getBoundingClientRect(); + const style = getComputedStyle(sidebar); + const offset = parseFloat(style.top); + const stands = scroller.scrollTop + box.top - offset; + const carried = scroller.scrollTop + main.getBoundingClientRect().bottom + - parseFloat(getComputedStyle(main).paddingBottom) + - parseFloat(style.marginBottom) - box.height - offset; + const at = Math.round((stands + carried) / 2); + scroller.scrollTo(0, at); + return at; + }""" + ) + page.wait_for_function( + "at => document.scrollingElement.scrollTop >= at - 1", arg=parked ) - page.wait_for_function("() => document.scrollingElement.scrollTop > 800") stuck = sidebar.evaluate("node => node.getBoundingClientRect().top") assert 64 <= stuck <= 68, ( f"the sidebar stuck at {stuck:.0f}px, not below the banner" diff --git a/tests/test_site.py b/tests/test_site.py index b8f1765f5..7935e304b 100644 --- a/tests/test_site.py +++ b/tests/test_site.py @@ -283,7 +283,7 @@ def test_an_example_paints_while_every_stage_of_site_startup_is_held( try: with page.expect_request("**/leaf.js"): page.goto(example_url(hosted, "pr-walkthrough"), wait_until="commit") - expect(page.locator("h1")).to_have_text("Per-token rate limits") + expect(page.locator("h1")).to_have_text("Unified diff in the switch picker") expect(page.locator("h1")).to_be_visible() assert boot, "the positive control did not hold the site boot module" From 0662e5ff487932e7c77f01ea992699e570fb180f Mon Sep 17 00:00:00 2001 From: leaf-agent <318509791+leaf-agent@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:56:22 +0000 Subject: [PATCH 02/10] Give every MCP test its own loop thread, through one shared helper #188's test_mcp_app.py raises "Already running asyncio in this thread" on the same collision: the session-scoped browser fixture holds an asyncio loop open in the worker's main thread, so an anyio.run there depends on whether that worker has run a browser test yet. One helper in interact_support answers for both modules. --- tests/interact_support.py | 15 +++++++++++++++ tests/test_interact_mcp.py | 14 ++------------ tests/test_mcp_app.py | 7 +++---- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/tests/interact_support.py b/tests/interact_support.py index 805fc174c..8587cb61c 100644 --- a/tests/interact_support.py +++ b/tests/interact_support.py @@ -24,6 +24,7 @@ from concurrent.futures import ThreadPoolExecutor from pathlib import Path +import anyio import pytest from click.testing import CliRunner from conftest import LEAF_COMMAND @@ -73,6 +74,20 @@ """ +def run_async(entry): + """Run an async entry point on a thread of this test's own. + + Playwright's sync API drives an asyncio loop and holds it running in the thread that + opened a browser for the whole life of `sync_playwright()`, and the `browser` fixture + is session-scoped per xdist worker. So `anyio.run` in a worker that has already run a + browser test raises "Already running asyncio in this thread", and the same call in a + worker that has not passes — leaving the scheduler to decide whether an MCP test can + start a loop at all. A thread with no loop on it answers for every schedule. + """ + with ThreadPoolExecutor(max_workers=1) as loop_thread: + return loop_thread.submit(lambda: anyio.run(entry)).result() + + def spawn_probe(spawn, page_dir, body, **environment): """Run a deterministic race seam in an isolated Leaf application process.""" env = {name: str(value) for name, value in environment.items()} diff --git a/tests/test_interact_mcp.py b/tests/test_interact_mcp.py index 24c27623e..72a7ff61c 100644 --- a/tests/test_interact_mcp.py +++ b/tests/test_interact_mcp.py @@ -1,12 +1,11 @@ """The bundled MCP server exposes Leaf without becoming another state authority.""" -import asyncio import json import os import sys -from concurrent.futures import ThreadPoolExecutor from pathlib import Path +from interact_support import run_async from leaf import event_log as events_model from leaf.mcp_app import APP_MIME, APP_URI, app_snapshot, apply_event from leaf.mcp_page import PAGE_RESOURCE_URI, ProcessPageServer @@ -134,16 +133,7 @@ async def exchange(): result = await session.call_tool("leaf_present", {"page": str(page_dir)}) return initialized, tools, resources, resource, result - # On its own thread, because the session-scoped `browser` fixture leaves one - # running in the worker's main thread for the rest of the run: Playwright's sync API - # drives an asyncio loop and holds it open for the lifetime of `sync_playwright()`, - # so `asyncio.run` there refuses to start a second. Ordering decides whether a worker - # has opened a browser first, which is what made this fail in a full run and pass in - # a run of this file alone. - with ThreadPoolExecutor(max_workers=1) as loop_thread: - initialized, tools, resources, resource, result = loop_thread.submit( - lambda: asyncio.run(exchange()) - ).result() + initialized, tools, resources, resource, result = run_async(exchange) by_name = {tool.name: tool for tool in tools.tools} assert initialized.protocol_version == "2025-11-25" diff --git a/tests/test_mcp_app.py b/tests/test_mcp_app.py index 000000da8..1f6e91172 100644 --- a/tests/test_mcp_app.py +++ b/tests/test_mcp_app.py @@ -6,8 +6,7 @@ import urllib.request from urllib.parse import urlsplit -import anyio -from interact_support import PAGE +from interact_support import PAGE, run_async from leaf.event_log import append_event, read_events from leaf.mcp_app import APP_URI from leaf.mcp_page import ( @@ -32,7 +31,7 @@ def call(server, name, arguments): async def invoke(): return await server.call_tool(name, arguments) - return anyio.run(invoke) + return run_async(invoke) def test_process_server_multiplexes_pages_on_one_exact_origin(page_dir, tmp_path): @@ -149,7 +148,7 @@ def test_registered_server_prefers_full_page_and_keeps_snapshot_as_fallback(): async def inspect(): return await server.list_tools(), await server.list_resources() - tools, resources = anyio.run(inspect) + tools, resources = run_async(inspect) finally: pages.close() From e5c1331b0908be749239f0ca5a8963425bbb01d2 Mon Sep 17 00:00:00 2001 From: leaf-agent <318509791+leaf-agent@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:47:05 +0000 Subject: [PATCH 03/10] Mark CallDiff's injected counts generated and drop the unreachable shadow climb The toolbar summary and each group's count are words the widget writes, not words the page holds. Neither carried a GENERATED marker, so a drag across them quoted text no version of the markup has; both now wear data-lf-gen, the marker lf-diff gives its own injected stat. Not .lf-ui, which would put chrome-style.js's --t-5 in a cascade race with .lf-call-summary's --t-6. coveredWords walks document.body's node tree, which never enters a shadow root, so the getRootNode().host arm of its climb could not be taken. --- skills/leaf/packages/pr-review/widgets/lf-call-diff.js | 6 +++++- skills/leaf/scripts/leaf/render-checks/standalone.js | 10 +++------- tests/test_render_projection.py | 4 ++++ 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/skills/leaf/packages/pr-review/widgets/lf-call-diff.js b/skills/leaf/packages/pr-review/widgets/lf-call-diff.js index 9dd743275..614cc5a4a 100644 --- a/skills/leaf/packages/pr-review/widgets/lf-call-diff.js +++ b/skills/leaf/packages/pr-review/widgets/lf-call-diff.js @@ -109,7 +109,11 @@ function updateDisclosureControl(owner) { function buildToolbar(owner) { const toolbar = make("div", "lf-call-tools"); + // The counts this widget writes are an account of the tree, not words the page holds, + // so `data-lf-gen` keeps them out of a passage — the marker `lf-diff` gives its own + // injected stat. Not `.lf-ui`, whose chrome face would race `.lf-call-summary`'s own. const summary = make("p", "lf-call-summary"); + summary.dataset.lfGen = "1"; // `offer`, not a bare button: the disclosure control is chrome this widget injected // and a handler is all it ever was, so the markers it writes are what tells the // exported copy to take the press away rather than draw a hand over a dead one. @@ -368,7 +372,7 @@ customElements.define( let count = rootNode.querySelector(".lf-call-group-count"); if (!count) { count = make("span", "lf-call-group-count"); - count.dataset.lfUi = ""; + count.dataset.lfGen = "1"; rootNode.append(count); } setText(count, groupLabel(groupRecords)); diff --git a/skills/leaf/scripts/leaf/render-checks/standalone.js b/skills/leaf/scripts/leaf/render-checks/standalone.js index a3167c698..5eda53388 100644 --- a/skills/leaf/scripts/leaf/render-checks/standalone.js +++ b/skills/leaf/scripts/leaf/render-checks/standalone.js @@ -30,15 +30,11 @@ export function coveredWords({ // shows only the part inside it, so a name ellipsised in a narrow column reads as // covering whatever stands beside it while the reader sees the ellipsis and nothing // else — the CallDiff root's own row, where the copy revealed a tab the live page keeps - // closed. Every clipping ancestor is intersected in, out through each shadow host, so - // the reading is the one the reader is given. + // closed. Every clipping ancestor is intersected in, so the reading is the one the + // reader is given. The walk below stays in the light DOM, so the climb does too. const painted = (el, drawn) => { let box = drawn; - for ( - let ancestor = el; - ancestor && box; - ancestor = ancestor.parentElement ?? ancestor.getRootNode().host ?? null - ) { + for (let ancestor = el; ancestor && box; ancestor = ancestor.parentElement) { const style = getComputedStyle(ancestor); if (style.overflowX !== "visible" || style.overflowY !== "visible") { const bounds = ancestor.getBoundingClientRect(); diff --git a/tests/test_render_projection.py b/tests/test_render_projection.py index 62175f4af..60929aeb6 100644 --- a/tests/test_render_projection.py +++ b/tests/test_render_projection.py @@ -280,6 +280,10 @@ def test_call_diff_projects_stable_commentable_rows(browser, serve): expect(widget.locator(".lf-call-summary")).to_have_text( "1 changed root · 1 added · 0 removed · 2 items" ) + # The counts are this widget's account of the tree, not words the page holds: + # data-lf-gen is what keeps a drag across them from quoting text no markup has. + expect(widget.locator(".lf-call-summary")).to_have_attribute("data-lf-gen", "1") + expect(widget.locator(".lf-call-group-count")).to_have_attribute("data-lf-gen", "1") group = widget.locator(":scope > .lf-call-group") expect(group).to_have_count(1) assert group.evaluate("el => getComputedStyle(el).backgroundColor") == page.locator( From a062e08ea7dc9097806efeab7c221b66c785b1d2 Mon Sep 17 00:00:00 2001 From: leaf-agent <318509791+leaf-agent@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:10:11 +0000 Subject: [PATCH 04/10] Correct what data-lf-gen does in the CallDiff comment and its test quotable() reads .lf-ui and [data-lf-said] only, so data-lf-gen never made the injected counts unquotable. It takes them out of the version diff that authored() parses, and dynamicWords makes each run its own passage cell so a quote cannot run out of it into the page's prose. Both comments said it suppressed the quote; the attribute stays. --- skills/leaf/packages/pr-review/widgets/lf-call-diff.js | 5 +++-- tests/test_render_projection.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/skills/leaf/packages/pr-review/widgets/lf-call-diff.js b/skills/leaf/packages/pr-review/widgets/lf-call-diff.js index 614cc5a4a..c64bf6bc3 100644 --- a/skills/leaf/packages/pr-review/widgets/lf-call-diff.js +++ b/skills/leaf/packages/pr-review/widgets/lf-call-diff.js @@ -110,8 +110,9 @@ function updateDisclosureControl(owner) { function buildToolbar(owner) { const toolbar = make("div", "lf-call-tools"); // The counts this widget writes are an account of the tree, not words the page holds, - // so `data-lf-gen` keeps them out of a passage — the marker `lf-diff` gives its own - // injected stat. Not `.lf-ui`, whose chrome face would race `.lf-call-summary`'s own. + // so `data-lf-gen` takes them out of the version diff and makes each its own passage + // cell — the marker `lf-diff` puts on its own injected stat. It does not stop a drag + // quoting them: that is `.lf-ui`, which `lf-diff` adds beside it on its line numbers. const summary = make("p", "lf-call-summary"); summary.dataset.lfGen = "1"; // `offer`, not a bare button: the disclosure control is chrome this widget injected diff --git a/tests/test_render_projection.py b/tests/test_render_projection.py index 60929aeb6..fc3f8f2dc 100644 --- a/tests/test_render_projection.py +++ b/tests/test_render_projection.py @@ -281,7 +281,7 @@ def test_call_diff_projects_stable_commentable_rows(browser, serve): "1 changed root · 1 added · 0 removed · 2 items" ) # The counts are this widget's account of the tree, not words the page holds: - # data-lf-gen is what keeps a drag across them from quoting text no markup has. + # data-lf-gen is what keeps them out of the version diff, which parses the base. expect(widget.locator(".lf-call-summary")).to_have_attribute("data-lf-gen", "1") expect(widget.locator(".lf-call-group-count")).to_have_attribute("data-lf-gen", "1") group = widget.locator(":scope > .lf-call-group") From 4d4f6aab77d7de23e9d64310bd37dc750f204486 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Tue, 1 Sep 2026 13:51:49 -0700 Subject: [PATCH 05/10] Exercise lf-diff's review verb and give its press the packed-in ring Two nightly reds that arrived with #194's review surfaces and were never run against it, since a pull request's CI stops short of the nightly leg. The standing-state fixture is built so that a verb the registry declares with no event on that page fails rather than going unexercised, and `lf-diff`'s `review` was declared without one. It gets a diff and a standing review. `.lf-diff-review` was taking the outward focus ring it shares with `.lf-diff-next`, which stands on its own in the toolbar. This one rides a file's summary row inside a box that clips at the row's bottom edge, so its lower run fell 3px past what the corpus sweep can see. It takes the inset the summary beside it already takes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0149R6uAvYxfu8xSjMrbmj2z --- skills/leaf/packages/default/theme.css | 6 ++++- .../pr-review/widgets/lf-call-diff.js | 25 +++++++++++++------ tests/render_cases_interaction.py | 12 ++++++++- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/skills/leaf/packages/default/theme.css b/skills/leaf/packages/default/theme.css index 4f786e7e6..d8399dfec 100644 --- a/skills/leaf/packages/default/theme.css +++ b/skills/leaf/packages/default/theme.css @@ -1576,9 +1576,13 @@ lf-chart .lf-chart-swatch { width: 9px; height: 9px; border-radius: 2px; font-weight: 650; } .lf-diff-filtered { display: none !important; } .lf-diff-next:is(:focus-visible, .lf-focus-visible), -.lf-diff-review:is(:focus-visible, .lf-focus-visible), .lf-diff-search:is(:focus-visible, .lf-focus-visible) { outline: var(--here-ring); outline-offset: 1px; } +/* The review press rides the file's own summary row, inside the box that clips it, so + it takes the inset the summary below takes and not the toolbar's outward gap: drawn + outward its lower run fell past the bottom the details clips at. */ +.lf-diff-review:is(:focus-visible, .lf-focus-visible) { outline: var(--here-ring); + outline-offset: calc(-1 * var(--here-ring-w)); } /* The file's own row, and the box it opens clips (overflow above), so the ring it wears is inset — theme.css's rule for a control packed into something rather than standing on its own. Outside, its top run was drawn into the border the details draws and lost diff --git a/skills/leaf/packages/pr-review/widgets/lf-call-diff.js b/skills/leaf/packages/pr-review/widgets/lf-call-diff.js index 0cbc22843..b94e3d255 100644 --- a/skills/leaf/packages/pr-review/widgets/lf-call-diff.js +++ b/skills/leaf/packages/pr-review/widgets/lf-call-diff.js @@ -186,14 +186,11 @@ function renderLine(record, prior, owner) { setText(body, record.body); setText(location, record.location); location.hidden = !record.location; - // Two rows have the element and no journey to offer, and an `href` on either is a way - // in that leads nowhere. The header names no location at all, and its hidden anchor - // answered the scroll sweep's question — "is there a way into this box?" — for a box - // whose words run off the side, so a copy shipped it with no keyboard route at all. - // A group's own row is the disclosure, and a link inside a is two gestures - // on one box: the press folds and the anchor travels. The calls beneath it keep the - // link, which is where following the root's own location leads anyway. - if (record.location && !line.matches("summary")) { + // The header row names no location, so its anchor is hidden — and an `href` on a + // hidden anchor is a way in that leads nowhere. It answered the scroll sweep's + // question, "is there a way into this box?", for a box whose words run off the side, + // so a copy shipped it with no keyboard route at all. + if (record.location && !owner.preparingExport) { location.href = `#${owner.getAttribute("diff")}`; location.onclick = async (event) => { event.preventDefault(); @@ -237,6 +234,18 @@ customElements.define( this.stopWatching = null; } + // In a copy the anchor can only reach the patch, never the line it names — and on a + // group's root row, which is the disclosure's own ``, it is a focusable + // descendant of a disclosure as well. So the copy keeps each location as text. + // The toggle needs nothing here: `offer` marked it, and the bake takes a marked + // press away on its own. Nor do the counts, which stay, because an account of the + // tree is something a reader still wants on paper. + lfPrepareExport() { + this.preparingExport = true; + for (const location of this.querySelectorAll(".lf-call-location")) + location.removeAttribute("href"); + } + show(snapshot) { let records; try { diff --git a/tests/render_cases_interaction.py b/tests/render_cases_interaction.py index 64a2c1c7b..3682fc0cf 100644 --- a/tests/render_cases_interaction.py +++ b/tests/render_cases_interaction.py @@ -882,7 +882,7 @@ def backdate_note(page_dir, version, hours): # log holding one event apiece leaves the whole of it standing at once. Selection and # completion share one option-group unit but occupy distinct facets, so both stand; # accept and reject share the settlement facet, so two suggestions let both competing -# verbs stand. The floor below derives the list from the registry, so a twelfth widget's +# verbs stand. The floor below derives the list from the registry, so a newly declared # verb fails here rather than passing unexercised. STANDING_PAGE = leaf_page( "standing state", @@ -917,6 +917,15 @@ def backdate_note(page_dir, version, hours):

Access logs roll off after 30 days.

Access logs are kept for 90 days.

+
+diff --git a/ab/bracket.py b/ab/bracket.py
+--- a/ab/bracket.py
++++ b/ab/bracket.py
+@@ -1,2 +1,2 @@
+ def bracket():
+-    return "plastic"
++    return "steel"
+
""", ) @@ -938,6 +947,7 @@ def backdate_note(page_dir, version, hours): ("ab-email", "edit", {"text": "The words as the reader rewrote them."}), ("ab-sug-410", "accept", {}), ("ab-sug-logs", "reject", {}), + ("ab-patch", "review", {"file": "ab/bracket.py", "reviewed": True}), ] RELATIVE_WIDGET_PAGE = leaf_page( "relative widget", From 9ea5869adbfa0ee02f47e1924f8cc550bdf1d3e5 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Tue, 1 Sep 2026 13:55:02 -0700 Subject: [PATCH 06/10] Name the two rings lf-diff's own controls draw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving the review press to an inset ring split one rule in two, and the sweep reads each rule for the name it declares. Neither half had one — the toolbar's rule never did, and the geometry fault ahead of that check was what kept it quiet. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0149R6uAvYxfu8xSjMrbmj2z --- skills/leaf/packages/default/theme.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/skills/leaf/packages/default/theme.css b/skills/leaf/packages/default/theme.css index d8399dfec..6eb093791 100644 --- a/skills/leaf/packages/default/theme.css +++ b/skills/leaf/packages/default/theme.css @@ -1577,11 +1577,13 @@ lf-chart .lf-chart-swatch { width: 9px; height: 9px; border-radius: 2px; .lf-diff-filtered { display: none !important; } .lf-diff-next:is(:focus-visible, .lf-focus-visible), .lf-diff-search:is(:focus-visible, .lf-focus-visible) { outline: var(--here-ring); + --lf-here-ring: diff-tools; outline-offset: 1px; } /* The review press rides the file's own summary row, inside the box that clips it, so it takes the inset the summary below takes and not the toolbar's outward gap: drawn outward its lower run fell past the bottom the details clips at. */ .lf-diff-review:is(:focus-visible, .lf-focus-visible) { outline: var(--here-ring); + --lf-here-ring: diff-review; outline-offset: calc(-1 * var(--here-ring-w)); } /* The file's own row, and the box it opens clips (overflow above), so the ring it wears is inset — theme.css's rule for a control packed into something rather than standing From 8eb5d0b8c5407800fda06d8d40c002f85ff571b1 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Tue, 1 Sep 2026 14:14:29 -0700 Subject: [PATCH 07/10] Say which surface the header's phantom href cost, and drop its print rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things a review turned up. The comment named the exported copy, and the copy was never the loser: the export hook strips every location href before the bake, which then grants the box its own stop. `reachScrollers` is what the hidden anchor fooled, on the live page, because it reads a candidate for a focusable descendant before granting one. `offer` writes `data-lf-offer`, and the runtime's shared sheet already hides a marked press on paper, so the package's own print rule for the toggle was the second mechanism for one guarantee — the same duplicate this branch removed from the export hook. The sidebar's scroll target is halfway along a stretch that has to exist first. A page too short to lift the box off where it was authored parks the scroll behind the near edge, and the ring assertion would then report a position rather than the page that made it meaningless. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0149R6uAvYxfu8xSjMrbmj2z --- skills/leaf/packages/pr-review/theme.css | 4 ---- .../leaf/packages/pr-review/widgets/lf-call-diff.js | 7 ++++--- tests/test_render_pages.py | 11 +++++++++-- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/skills/leaf/packages/pr-review/theme.css b/skills/leaf/packages/pr-review/theme.css index 3f3972e7c..3e1cdc3be 100644 --- a/skills/leaf/packages/pr-review/theme.css +++ b/skills/leaf/packages/pr-review/theme.css @@ -460,10 +460,6 @@ lf-call-diff { background: transparent; } - .lf-call-toggle { - display: none; - } - .lf-call-group:not([open]) > .lf-call-group-body { display: block; } diff --git a/skills/leaf/packages/pr-review/widgets/lf-call-diff.js b/skills/leaf/packages/pr-review/widgets/lf-call-diff.js index b94e3d255..9e2aba79e 100644 --- a/skills/leaf/packages/pr-review/widgets/lf-call-diff.js +++ b/skills/leaf/packages/pr-review/widgets/lf-call-diff.js @@ -187,9 +187,10 @@ function renderLine(record, prior, owner) { setText(location, record.location); location.hidden = !record.location; // The header row names no location, so its anchor is hidden — and an `href` on a - // hidden anchor is a way in that leads nowhere. It answered the scroll sweep's - // question, "is there a way into this box?", for a box whose words run off the side, - // so a copy shipped it with no keyboard route at all. + // hidden anchor is a way in that leads nowhere. Worse, `reachScrollers` reads a + // candidate for a focusable descendant before granting the stop, and a hidden + // `a[href]` is one: the header's own words run off the side, and the live page + // answered "there is already a way in here" with a link nobody can reach. if (record.location && !owner.preparingExport) { location.href = `#${owner.getAttribute("diff")}`; location.onclick = async (event) => { diff --git a/tests/test_render_pages.py b/tests/test_render_pages.py index d4199089e..8474351da 100644 --- a/tests/test_render_pages.py +++ b/tests/test_render_pages.py @@ -2175,11 +2175,18 @@ def test_a_left_sidebar_uses_the_margin_until_the_page_needs_it_back(browser, se - parseFloat(style.marginBottom) - box.height - offset; const at = Math.round((stands + carried) / 2); scroller.scrollTo(0, at); - return at; + return { at, stands, carried }; }""" ) + # The stretch has to exist before a point halfway along it says anything. A page + # too short to lift the box off where it was authored parks the scroll behind + # `stands`, and the ring assertion below would then report a position rather than + # the page that made it meaningless. + assert parked["carried"] > parked["stands"], ( + f"the page is too short for the sidebar to stand on its own offset: {parked}" + ) page.wait_for_function( - "at => document.scrollingElement.scrollTop >= at - 1", arg=parked + "at => document.scrollingElement.scrollTop >= at - 1", arg=parked["at"] ) stuck = sidebar.evaluate("node => node.getBoundingClientRect().top") assert 64 <= stuck <= 68, ( From 77205a34c23b3a90680100506b3ebe76713f5191 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Tue, 1 Sep 2026 14:38:49 -0700 Subject: [PATCH 08/10] Find Playwright's chromium build by name, not by counting parents A macOS build sits inside a `.app` bundle, so its executable is three levels further down than the `chrome-linux/chrome` the fixture counted back from: `parents[1]` is `Contents`, which has no `-` to split, and the fixture raised IndexError before it could look for the shell. Every test wanting the LEAF_BROWSER_EXECUTABLE path errored at setup on a developer machine, while CI's Linux layout passed. The build directory is the one named `chromium-`, so it is found by that name and the root and build number come off it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0149R6uAvYxfu8xSjMrbmj2z --- tests/conftest.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 765374fcd..7666cfbcc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -231,7 +231,15 @@ def headless_shell(): check=True, ) chromium = Path(read.stdout.strip()) - root, build = chromium.parents[2], chromium.parents[1].name.rsplit("-", 1)[1] + # Found by name rather than by depth: a macOS build sits inside a `.app` bundle, so + # the executable is three levels further down than the plain `chrome-linux/chrome` + # this once counted back from. + installed = next( + (p for p in chromium.parents if p.name.startswith("chromium-")), None + ) + if installed is None: + raise AssertionError(f"{chromium} is not under a Playwright chromium build") + root, build = installed.parent, installed.name.split("-", 1)[1] shell = root / f"chromium_headless_shell-{build}" for candidate in (*sorted(shell.glob("*/chrome-headless-shell*")), chromium): if candidate.is_file(): From 51617e30aff34e30b094d44afebfcdf19b71731f Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Tue, 1 Sep 2026 14:40:12 -0700 Subject: [PATCH 09/10] Read the site's drag as a field offered, not a field entered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #193 kept the response field passive so a drag leaves the browser's own selection alone and a native copy still has something to take. It aligned the render tests with that contract; test_site.py is nightly-only, so its two readings went unread and stayed on the old one. The label test turned on `document.activeElement === field`, which is now false for the page's own words and the site's label alike — the discriminator it rests on was gone, not merely inverted. It reads whether the field was offered at all, which is the difference it was always after. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0149R6uAvYxfu8xSjMrbmj2z --- tests/test_site.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/test_site.py b/tests/test_site.py index 7935e304b..392e0f3aa 100644 --- a/tests/test_site.py +++ b/tests/test_site.py @@ -426,7 +426,7 @@ def test_every_example_says_what_it_is_and_links_back(site, hosted, browser): const field = document.querySelector('.lf-fab-input'); return {{ text: getSelection().toString(), quote: document.getElementById('lf-composer-quote')?.textContent ?? '', - fieldFocused: document.activeElement === field, + fieldOffered: Boolean(field?.checkVisibility()), says: document.querySelector('.lf-keyline').textContent }}; }}""" @@ -457,12 +457,12 @@ def test_the_label_is_chrome_rather_than_words_to_quote(site, hosted, browser): page, errors = open_page(browser, example_url(hosted, "design-decision")) try: control = drag_across(page, "#decision-lede") - assert control["fieldFocused"], "the page's own words raised no comment field" + assert control["fieldOffered"], "the page's own words raised no comment field" assert "monolith split" in control["quote"] label = drag_across(page, "main > .sitenote p") assert "example of a leaf page" in label["text"] - assert not label["fieldFocused"] + assert not label["fieldOffered"] # The word `c` carries with nothing in hand — it goes to the threads rather # than opening a box on anything, and "comment on the selection" does not # contain it, so the two readings still tell each other apart. @@ -564,8 +564,9 @@ def test_a_comment_lands_in_the_thread_with_its_quote(site, hosted, browser): (box["x"] + 4, box["y"] + 8), (box["x"] + box["width"] - 40, box["y"] + box["height"] - 8), ) - # Selection enters the compact field immediately. - expect(page.locator(".lf-fab-input")).to_be_focused() + # Selection offers the compact field without entering it, so the browser's + # own selection is still there for a native copy. + expect(page.locator(".lf-fab-input")).to_be_visible() page.locator(".lf-composer textarea").fill("Does this cover key rotation?") page.keyboard.press("Enter") From 11d9c4072442233062b865de031899cc48df7764 Mon Sep 17 00:00:00 2001 From: leaf-agent <318509791+leaf-agent@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:18:08 +0000 Subject: [PATCH 10/10] Leave the headless_shell fixture to #203 #203 makes the same edit to the same line of `tests/conftest.py`, and it got there first (`47edb74` at 20:44Z against this branch's `77205a3` at 21:38Z). Two open PRs rewriting one line conflict whichever lands first, and put the same change through review twice; #203 is the narrower home, existing only for #200's follow-ups and naming the macOS app-bundle layout as its cause. The four `test_render_commands.py` setup errors this hunk cleared are #203's, which is where the PR body already attributed them. --- tests/conftest.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 7666cfbcc..765374fcd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -231,15 +231,7 @@ def headless_shell(): check=True, ) chromium = Path(read.stdout.strip()) - # Found by name rather than by depth: a macOS build sits inside a `.app` bundle, so - # the executable is three levels further down than the plain `chrome-linux/chrome` - # this once counted back from. - installed = next( - (p for p in chromium.parents if p.name.startswith("chromium-")), None - ) - if installed is None: - raise AssertionError(f"{chromium} is not under a Playwright chromium build") - root, build = installed.parent, installed.name.split("-", 1)[1] + 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():