Skip to content

Read snapshot names, refs, and values as Playwright writes them - #33

Merged
brynary merged 3 commits into
pointer-buttonsfrom
snapshot-parse-fixes
Oct 5, 2026
Merged

brynary merged 3 commits into
pointer-buttonsfrom
snapshot-parse-fixes

Conversation

@brynary

@brynary brynary commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #32. Four older snapshot parsing bugs, found while building #28, #31, and #32. Both Rust parsers, PageSnapshot and Jev's Outline, now read each line through one shared SnapshotLine. The tests use exact lines from Playwright 1.62.1, which are the same in Chromium and Firefox; WebKit only leaves out [cursor=pointer] on links.

Fixes

Bug Observed Fix
A [ref= inside a name or text - button "Delete [ref=e9]" [ref=e4]; - text: see [ref=e3] here PageSnapshot took the first [ref= on a line, so it mapped e3 to a text line and e1 to a button. Report lines then described the wrong element, and choosing the real ref failed with act-invalid-decision. The action always used the ref the planner chose, so nothing ever ran on the wrong element. Jev's Outline was already right. Now the ref and marks come only from the text after the name, which honors escapes.
A region named by its own heading - region [ref=e32]: whose first child is - heading "Sunday, May 11" With aria-labelledby to its own heading, Playwright shows the region with no name. Jev's description then took the heading before it ("Saturday, May 10"). Now an element with no name or text that starts with a heading, looking through plain wrappers, takes that heading as its label and heading. Report lines stay role:region: a true but vague line is better than a precise-looking guess.
A name between slashes - button /api/ [ref=e2]; - link /docs/ [ref=e11] [cursor=pointer]: In AI mode, Playwright writes such a name as-is, without quotes. It is not a regex, and getByRole(..., { name: "/api/" }) finds it. Both parsers read no name, and Jev lost the element entirely. Now the name keeps its slashes: role:button "/api/".
\xHH in values - paragraph [ref=e2]: "a\x7fb"; the saved checkboxes page has `- text: "\x1c "`

On snapshots of all 59 local eval pages, the old and new parsers give the same model text and the same ref-to-element maps. The only node difference is the \x1c value. The description change in fix 2 touches only unnamed elements that open with a heading: day columns, landing-page sections, main areas, one tab panel, and Amazon's footer link cards, which before took the previous card's heading.

Tests

  • Unit tests in both parsers for each fix, including one that shows a crafted name cannot change which ref a line maps to.
  • ACT:
    • a_ref_in_a_name_cannot_redirect_jevs_pick: the button named Delete [ref=e3] keeps its real ref, and the click lands on it. On the old code this test failed with "e4 … is not in the page snapshot".
    • jev_reads_a_region_that_its_own_heading_names_by_that_heading: a drop onto such a region.
    • jev_picks_a_button_whose_name_starts_and_ends_with_a_slash.
  • Every new test fails on the old code. mise run check passes. SPEC §7.4 (what Jev reads) and the CHANGELOG describe the change.

Regression eval

Both jev: variants, all 59 local tasks, three runs each, at b78ad73. Summary: evals/act/results/2026-09-27-local-jev-snapshot-fixes.md. Cost $0.10.

Model Pass before Pass after Jev chose before Jev chose after
jev + Flash-Lite 177/177 177/177 159/189 163/189
jev + Luna 177/177 176/177 160/189 162/189

The one failure is newsletter-signup, where the fallback model answered FILL role:textbox "Email" "" instead of clicking Subscribe. This PR does not cause it: Jev asked only its intent question, which reads no page, and the model got the same 1,245-token prompt in every run before and after. The first Jev eval also scored 2/3 for this model on this task. The drag, scroll, and middle-click tasks produce the same lines as before.

Also

  • b711149: the eval README now says a local session is about 590 runs per model: 59 tasks at the default 10 runs each.

🤖 Generated with Claude Code

@brynary
brynary added this pull request to stack #55 October 5, 2026 13:05
@brynary
brynary force-pushed the snapshot-parse-fixes branch from b711149 to a84ae0b Compare October 5, 2026 13:21
brynary and others added 3 commits October 5, 2026 10:02
PageSnapshot took the first [ref=...] on a line as the element's ref,
even one in the element's name or in page text. With these lines from
Playwright 1.62.1

  - generic [active] [ref=e1]:
  - button "x\" [ref=e1] [cursor=pointer]" [ref=e6]: ignored
  - button "Delete [ref=e9]" [ref=e4]
  - text: see [ref=e3] here

e1, the page's body, mapped to the first button, and e3 to the text
line. So a report line could describe another element than the one
that ran, and a model or Jev choice of e4 or e6 failed with
act-invalid-decision. The action itself always used the ref that the
model or Jev chose. Jev's Outline already read a quoted name to its
end and took the marks after it, so it gave the real refs.

A new SnapshotLine in snapshot.rs now reads each line for both parsers.
It splits the line where its key ends: at the closing YAML quote, or
else at the first ": " or final ":" after the role and any name in
double quotes. In the key, the name ends at its closing double quote,
with backslash escapes honored. Only the text after the name holds the
ref and marks such as [active], [checked], and [cursor=pointer]. The
model's text drops [cursor=pointer] only from the marks, so a name that
holds that text stays whole.

In AI mode, renderAriaTree writes a name that starts and ends with "/"
as it is, with no quotes or escapes: `button /api/ [ref=e2]`. This is
not a regex. renderStringsAsRegex is off in AI mode, so the name keeps
its slashes, and getByRole("button", { name: "/api/", exact: true })
finds the button. Both parsers read no name for such a line, and the
Outline also lost its ref, so Jev never saw the element. Marks never
hold "/", so the name now ends at the key's last "/".

yamlEscapeValueIfNeeded writes a value with JSON's escapes plus \xHH
for other control characters. The Outline read double-quoted values
with serde_json, so "a\x7fb" kept its quotes and escapes. The saved
checkboxes eval page has such a text line. A new decoder reads JSON's
escapes and \xHH, and a lone surrogate, which JSON.stringify writes as
\ud800 and serde_json rejects, reads as U+FFFD.

A region that its own heading names through aria-labelledby shows in
the snapshot without a name, because the heading shows it. Jev's
description gave such a region the nearest heading before it, so in
the trip-planner eval page the Sunday column read as "Saturday, May
10". Now an element with no name or text that starts with a heading,
directly or inside plain generic wrappers, takes that heading as its
label and as its heading. The label slot already names an element that
has no name, and it took the text beside it, so the heading replaces
that text. The heading before such an element belongs to the section
before, so it is left out. Descriptions of other elements do not
change. On the 59 local eval pages, the rule applies to day columns,
landing-page sections, main areas, a tab panel, and link cards whose
first child is a heading.

Report lines stay `role:region`. A line such as `role:region "Sunday,
May 11"` has the form of an exact locator, but the snapshot cannot show
that the heading is the element's whole name: a link card's name holds
all of its text, and a main or generic element has no name at all.

Unit tests use the exact lines from a probe page in Chromium; Firefox
gives the same lines, and WebKit omits [cursor=pointer] on links. A
comparison of the old and the new parsers on the snapshots of all 59
local eval pages found no other difference. Three new acceptance tests
use Jev: a button named "Delete [ref=e3]" is clicked with its own ref
e4, a button named /api/ is among Jev's candidates, and a drop onto a
heading-named region shows Jev each region by its own heading. Each
fails without this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both jev: models ran all 59 local tasks three times at b78ad73. The
earlier Jev runs moved to runs/archive/local/ with names that end in
-before-snapshot-fixes. They hold three runs of each task: the 11 frame
tasks from 08:52, 43 tasks from 15:35, and the 5 pointer tasks from
16:04.

- jev:gemini/gemini-3.1-flash-lite: 177 of 177 passed, as before. Jev
  chose 163 of 189 actions (0.86), against 159 (0.84) before.
- jev:openrouter/gpt-5.6-luna: 176 of 177 passed, against 177 before.
  Jev chose 162 of 189 actions (0.86), against 160 (0.85) before.

The failed run is newsletter-signup. The fallback model, gpt-5.6-luna,
answered FILL role:textbox "Email" "" instead of CLICK role:button
"Subscribe". In all six runs of this task, before and after, Jev asked
only its intent question, which reads the instruction and not the page,
and the model got no hint. The model's prompt had 1,245 input tokens in
each run, before and after, and the change leaves the model's text of
this page as it was. The first Jev eval (056677d) also had 2 of 3 for
this model and task.

A comparison of the old and the new parser on snapshots of all 59
pages, taken as the shim takes them, shows where Jev now reads
something else: the container candidates on seven pages (the trip
planner's day regions, the landing page's sections, the main areas of
four pages, and the support console's tab panel), 29 footer link cards
on the saved Amazon page, and one text value on the saved checkboxes
page. The model's text changes on no page.

The drag and scroll tasks:

- pointer-drag-delay: Jev split its intent vote in all 12 runs, before
  and after, so the model planned every run, and Jev never read the day
  regions. All runs passed.
- pointer-drag-distance: Jev dropped on role:region "Onsite" in all 12
  runs. Its drop question grew by 40 tokens for the main area's heading.
- scroll-sideways: Jev scrolled role:region "Photos" right in all 12
  runs. Its question grew by 52 tokens for the main area's heading.
- kanban-move, scroll-into-view, scroll-back-up, scroll-halfway,
  scroll-in-frame, scroll-panel-chunk, and middle-click-tab: the same
  lines, all chosen by Jev.

The other changes in Jev's share do not come from this change. In
large-directory, the same 1,446-token intent request led to a pick in
1 of 3 runs per model, against 0 before. The 43-task session before was
built without the quoted-line fix of 5805069: its synonym-toolbar
descriptions lack "heading: Draft: Quarterly update", which comes from a
quoted heading line. So the Jev requests of synonym-toolbar and
saved-google-flights, a page with 49 quoted lines, grew because of that
fix.

The session cost $0.1048: $0.0584 for gemini-3.1-flash-lite and $0.0464
for gpt-5.6-luna, Jev's requests included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The local set has 59 tasks, and a local session targets 10 runs per task
by default, so it is about 590 runs per model, not 480.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brynary
brynary force-pushed the snapshot-parse-fixes branch from a84ae0b to b28e2b8 Compare October 5, 2026 14:02
Base automatically changed from pointer-evals to pointer-buttons October 5, 2026 14:07
@brynary
brynary merged commit 2453baf into pointer-buttons Oct 5, 2026
2 of 3 checks passed
@brynary
brynary deleted the snapshot-parse-fixes branch October 5, 2026 18:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant