Repository navigation
fix(pixel): capsule role/name locators match Playwright's source-text names as well as Chromium's text-transformed names - #6750
Merged
Conversation
…s Chromium's transformed names Chromium's accessibility names apply CSS text-transform. A button whose source text is "Show sold out", styled uppercase, is named "SHOW SOLD OUT" in Accessibility.queryAXTree, so an exact click or assert-visible on the owner's name returned no_match. The capsule's own click (get_by_role exact) and the owner's getByRole check both use Playwright's source-text names and see the button. Fleet laptop round 107 (main d4a61f3) lost website-create on this: the recorded plan clicked button "Show sold out" on site-1f5f2cf8 (no_match), then the run hit the no-progress stop. Round 101 hit the same no_match on site-458e7785 and recovered only by switching to '#revealBtn'. assert-visible and click now unite Chromium's rendered matches with the same Playwright-rule matcher assert-hidden already uses, in its rendered-only form (ROLE_NAME_RENDERED): only elements not hidden for ARIA (Playwright's isElementHiddenForAria), and hidden descendants left out of a name. Union by identity, same MAX_RENDERED_MATCHES bound and uniqueness rules; assert-hidden and the click dispatch are unchanged. Names stay exact. Matching stays in the isolated world and read-only. Tests: fake-CDP unit tests for the union, exclusion and uniqueness; opt-in real Chromium tests for text-transform, hidden-for-ARIA exclusion, uniqueness, spoofing and parity with Playwright's default getByRole; and replays of the two recorded laptop pages (byte-exact fixtures). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…den element by an empty name A whitespace-only locator name normalizes to the empty name. The walk filter keeps hidden elements out even then, as Playwright's default getByRole does; the parity fixture now checks it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every role/name assert-visible and click now runs the isolated-world matcher once per stability sample, inside the 1.5 s observe window. Two paths in the shared rules were superlinear: - The rendered name asks hiddenForAria for every descendant, and a display:contents element re-walked its children each time, so a nested display:contents chain cost depth squared style reads. Results are now kept per element per call, like Playwright's cacheIsHidden. - element.labels makes Chromium scan the element's whole tree on each element's first read, so naming N buttons cost N x elements. Each tree's labels are now indexed by label.control once per call; the result is the same list, in tree order, that element.labels returns. Both also apply to the assert-hidden matcher. Names and matches are unchanged: both Playwright parity tests pass with new label cases (several labels per control, wrapping, a button's label, a label for a non-labelable element, and a shadow-tree label), and a new browser test counts style and label reads in the matcher's world: a 200-deep chain read 22,717 styles before (bound 8 x elements = 4,088; now about 2,500), and 303 label lists for 303 buttons before (bound 3, the label count). Measured locally (HeadlessChrome 149): a 3000-card page (18k elements) first matcher call 579 ms -> 42 ms; the reviewer's 3000 buttons x 20-deep display:contents page went from always 'unstable' to passing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The capsule's matcher and observation scripts are JavaScript inside Python strings, and only the opt-in real-browser suite ever executes them; CI runs neither ods/tests/test_preview_inspection.py nor ods/tests/test-preview-inspection.mjs. A textual merge that declares a matcher helper twice (open PR #6741 declares the same consts in the same block) would make every callFunctionOn throw, so every role/name step would report 'unavailable' while CI stayed green. The new pixel-agent test, picked up by the existing tests/*.test.mjs steps in test-linux and portal-runtime, imports the capsule, compiles every module-level function script, and runs both role/name matchers on an empty document to check they keep the passed Chromium matches, de-duplicated by identity. A duplicated 'const parentOf' fails both tests with "Identifier 'parentOf' has already been declared". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ce-text #6741 moved the role and name rules into ACCESSIBLE_NAME_RULES, shared with its load-time CONTROL_NAMES, and ported the same hidden-for-ARIA rule with its own helper names. The resolution keeps one set of rules: - main's ACCESSIBLE_NAME_RULES, excluded/outsideTree, reference(o, e, kind) with hiddenLabel/hiddenLabelledBy, and SVG <title> names - this branch's per-call hiddenForAria cache and label index, which now also serve CONTROL_NAMES - ROLE_NAME_MATCH (the exact-name walk) appended to the shared rules with renderedOnly false (ROLE_NAME_INCLUDING_HIDDEN) or true (ROLE_NAME_RENDERED); git's auto-merge had instead left main's single definition carrying this branch's walk, followed by this branch's two definitions of a now-missing ROLE_NAME_RULES - both parity pages keep both sides' cases; the protocol doc's test paragraph lists both sides' fixtures - the compile guard also requires and runs CONTROL_NAMES, and the shared-rules test also checks ROLE_NAME_RENDERED Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#6741 recorded, "so a change is noticed", that an exact-name click on the repaired round-100 page with an aria-hidden icon returned no_match: Chromium's accessibility name keeps the space after the icon (" Show sold out") and role/name steps matched only that name. On the composed tree the rendered matcher also uses Playwright's getByRole name, which normalizes the whitespace like the load-time control names, so the click matches the one rendered button and every variant passes. Found by running ODS_PREVIEW_BROWSER_TESTS=1 on the composed tree (1 failed, 90 passed before this change, twice). COMPLETION-RELIABILITY.md now says the load-time "name is on the page" feedback was written for Chromium-only matching and remains for a page whose control changed between load and the step. The plugin and its recorded replays are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Sep 26, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is, and what it is not
This fixes one false negative in the preview inspector. An exact role/name locator for
assert-visibleorclickreturnedno_matchwhen the button was styled with CSStext-transform, even though the owner's exact name is in the source and Playwright's owngetByRolefinds the button.It is not the round-107 regression fix, and it should not be counted as one. The two adversarial reviews of this PR replayed the recorded sessions: real capsules of main and this branch in real Chromium, pages rebuilt from each session's write/edit calls and digest-verified, and receipts fed through the real inspect tool and tool-loop-guard hooks. I re-read their receipt and guard outputs. They found:
no_matchbecomes step 3 click passed, then step 4.sold-out-cardvisibility_mismatch. That is still a failure: the page's CSS hides the card and its script removes a class the card never has. The guard's failure count is identical and the no-progress stop lands on the same call (feat: standalone Windows installer (NVIDIA + AMD Strix Halo) #40, the 12th total failure) on both capsules.tool_callwith idtool_describe.#revealBtn, and the journey passed.The laptop r107 case now ends on a measured
visibility_mismatch, and on main that gets the generic line "Requested behavior remains unverified; a failed inspection does not establish a visibility transition." Open PR #6748 replaces that line with a measured diagnosis of the step (locator, display, visibility). That message change belongs there, not here.What was wrong
The capsule matched exact role/name locators for
assert-visibleandclickonly through Chromium'sAccessibility.queryAXTree(preview_inspection_capsule.py,measure()). Chromium's accessibility names apply CSStext-transform.So a button whose source text is exactly
Show sold out, styledtext-transform: uppercase, is namedSHOW SOLD OUTthere. The owner's exact name returnsno_match. The feedback then tells the model to copy the exact name from its source, which it already did.The capsule's own click (
frame.get_by_role(role, name=..., exact=True)) and the owner harness (preview-check.mjs,getByRoleexact) both use Playwright's names, which are the source text. Both see the button.The same gap shows up with whitespace. Chromium keeps the space after an
aria-hiddenicon, so the button is named" Show sold out", whilegetByRolenormalizes it. #6741 recorded that case on its round-100 icon variant as a knownno_match, "so a change is noticed". On this branch that click passes (see the last commit).Checked firsthand:
queryAXTree(accessibleName: "Show sold out", role: "button")returns[]for an uppercase-styled button."SHOW SOLD OUT"matches, and the full AX tree listsSHOW SOLD OUT.getByRole('button', {name: 'Show sold out', exact: true})finds that button. Chromium's AX query does not.Fleet evidence (round directories under
ods-main-qualification-20260924-r049, read-only). These are all the role/nameno_matchsteps in rounds 100–107:.show-sold-out-btnassert-visible passed. Step 3, click button "Show sold out", returnedno_match. With this branch, that step runs and step 4 reports the page's real defect instead. As above, this does not change how that run ended.no_match. The model recovered only by switching to#revealBtn, and the same page then passed.aria-labeloverride that fix(pixel): verify owner-requested exact control names after the page scripts run #6741 addresses. Neither r100 source hastext-transform. The other tower2 sources, both laptop sources and the r102 assert-visible case all style the buttontext-transform: uppercase, and the exact name is in the source every time (from the fix survey).soldOutRevealed: trueon these uppercase buttons in r101 and r105.no_matchsteps are real name mismatches and stayno_match. In tower2 r105/r107, heading "Sum of squares calculation" is written as "Sum of Squares Calculation" in the source, "Expense report cli" as "Expense Report CLI", and "Midnight sold-out concert" as "Midnight Sold-Out Concert".The same false negative would also defeat ready plans built from the owner's quoted control name (#6740's
transition_untestedfallback) on an uppercase-styled control. No recorded session shows that case yet: in strixy r105 the ready plan's button had notext-transform.What changed
Only
host/preview_inspection_capsule.pychanges behavior.Shared rules, two exact-name matchers. #6741, now on main, moved the role and name rules into
ACCESSIBLE_NAME_RULES, shared with its load-timeCONTROL_NAMES. That includes a rendered (includeHidden: false) name mode and a port of Playwright 1.62'sisElementHiddenForAria. This branch had ported the same rule; the merge keeps main's copy. This branch appends one exact-name walk,ROLE_NAME_MATCH, to those rules twice:ROLE_NAME_INCLUDING_HIDDEN(renderedOnly = false): unchangedincludeHidden: truebehavior, still used only byassert-hidden.ROLE_NAME_RENDERED(renderedOnly = true): Playwright's defaultgetByRole(role, {name, exact: true}). It is used forassert-visibleandclick, including the after-click observation.The two differ only in
const renderedOnly, and a test pins that.Rendered-only matching skips every element hidden for ARIA:
display: noneoraria-hidden="true"on the element or on an ancestor across shadow hostsvisibilityis notvisible, or whosecheckVisibility()fails (content-visibility, closed<details>)display: contentselement with no rendered childOpacity does not matter, as in the AX tree.
The rendered name also leaves hidden descendants out, as Playwright's
includeHidden: falsename does. The exception: a hidden descendant still counts when it is reached through anaria-labelledby,<label>or SVG<title>reference that is itself hidden.measure(): a role/name locator now always goes through the existingincluding_hidden(...)union, withrendered_only=not include_hidden:MAX_RENDERED_MATCHESbound, the unresolved-node handling and the count/uniqueness rules are the same.Linear cost (third commit). The matcher now runs on every role/name
assert-visibleandclicksample, inside the 1.5 s observe window, so two superlinear paths in the shared rules were removed. They also speed up the existingassert-hiddenmatcher andCONTROL_NAMES; names and matches are unchanged.hiddenForAriaresults are kept per element per call, like Playwright'scacheIsHidden. Before, a nesteddisplay: contentschain was re-walked from each of its levels, so its cost was quadratic in its depth.label.control, which gives the same list, in tree order, thatelement.labelsreturns. Before,element.labelsmade Chromium scan the whole tree on each element's first read, so naming N buttons cost N x elements.Unchanged: the
assert-hiddensemantics, the click dispatch, receipt schema, plan hash, capsule deadline, and every model-facing string. The protocol doc's locator-matching section is updated.Safety invariants and behavior changes
Names stay exact. No case folding and no normalization beyond the matcher's existing whitespace and zero-width handling. The tests cover
show sold out,Show Sold OutandShow sold out!returningno_match.Superset of Chromium. Chromium's own matches are still passed in and kept, so every element Chromium matched still matches. The tests cover
SHOW SOLD OUT,Show All EventsandRiver Lantern Walkstill passing, and an AX-onlydisplay: contents+aria-labelcase.Uniqueness counts the union, so a plan that passed on main can now fail with
selector_not_unique. This happens when Playwright's rules find an element with the exact name that Chromium named differently, next to another match:<button class=cap>save</button>(capitalize; Chromium names it "Save") next to<button class=up>Save</button>(uppercase; Playwright names it "Save"). Main passed bothassert-visibleandclickon "Save" with count 1. It measured the first button, while Playwright's click hit the second. This branch reports count 2 for both.assert-visiblewith count 1, and itsclickfailed asclick_failedbecause Playwright's strict click saw two elements. This branch reports count 2 for both.In each case main was measuring one element while the name addressed another, and the owner's
getByRoleexact check sees the same ambiguity. None of the 40+ recorded fleet pages the reviewers rebuilt and replayed hits it.Still rendered-only for
assert-visibleandclick.test_role_locator_semantics_outside_assert_hidden_are_unchangedis unchanged and still passes (count 0 for a heading in<article hidden>and for<button hidden>). New tests coverdisplay: noneancestors,aria-hidden="true",visibility: hiddenandcontent-visibility: hidden.Isolated world, read-only. No DOM mutation and no page-world evaluation.
test_matcher_source_is_bounded_and_read_onlynow checks both matchers. A new spoofing test overridescheckVisibility,getComputedStyle,getAttribute,hasAttribute,querySelectorAllandtextContentin the page world, and matching is unaffected.Bounds unchanged.
MAX_RENDERED_MATCHES, receipt schema,planSha256, the 45 s capsule deadline, and the loop and progress bounds (RUN_PROGRESS_LIMITSuntouched). No plugin change, and no prompt or tool-description byte changes.A failing page still fails. The laptop r107 replay now runs the click and still reports the page's real defect as
visibility_mismatch. Nothing here can turn an unverified site into a verified one.Cost
Measured locally (HeadlessChrome 149, no CPU limit), in one session, on this branch before and after the third commit. "First" is the first matcher call on a fresh page, which is what the first stability sample pays.
04b78d7d42493077display: contentschaindisplay: contentschainThe existing
assert-hiddenmatcher gains the same way: 796 ms to 49 ms first call on the 3000-card page.End to end through
run_browser, a role/nameassert-visibleplan:unstablebefore, passing after.unstablebefore; after, it passed in 1 of 3 runs.The merged head (
2dc6b0b4, on top of #6741) measures the same: 3000 cards 76 ms first call, the 63k-element chain page 670 ms first call, and 3000 and 5000 cards pass end to end.Remaining limit, disclosed. The cost is now linear, but it adds to Chromium's own AX query in every sample, and that query alone takes 150–450 ms on its first call at these sizes. On the reviewers' synthetic page (3000 buttons, each wrapping a 20-deep
display: contentschain, 63k elements), a 12-step role/name plan was stillunstableat step 1 in 4 of 4 local runs; a 3-step plan passed in 1 of 1. Main passed that page, because it ran the AX query only.unstable, never a false pass.assert-hiddenrole/name path isunstableon the same page (first sample 3.1 s).Deployment
The inspection image picks up the new capsule through the existing source copy:
installers/lib/pixel-preview-inspection.pyincludespreview_inspection_capsule.pyinBUILD_FILES.Dockerfile.inspectionCOPYs it.build_config.pixel-host-install.shalso includes it in the managed file list used for uninstall.Every Pixel host install runs
buildfrom the current sources and binds the resulting image ID in/etc/ods-pixel-inspection.json. Docker's cache is keyed on file content, so changed capsule bytes produce a new image.Checked read-only on tower2: the config written at the round-107 install (19:21 -04:00) binds
sha256:ec4868a9…, labelled Playwright 1.62.0. The image itself was built at 10:05 the same day, which is consistent with a cache hit for unchanged capsule bytes.Fleet note: a round that installs this candidate picks the change up. A journeys-only rerun without reinstall, like r101's "no reinstall" note, keeps the old image.
Merge order
#6741 landed first (main
6cba572f). This branch merged main and resolved the conflict onto #6741's shared rules, keeping one set of helpers (merge commit2dc6b0b4):ACCESSIBLE_NAME_RULES, hidden-for-ARIA helpers,referencekinds and SVG<title>nameshiddenForAriacache and label index, which now also serveCONTROL_NAMESROLE_NAME_MATCHappended withrenderedOnlyfalse or trueGit's auto-merge had silently kept main's single matcher definition carrying this branch's walk, followed by this branch's two definitions of a
ROLE_NAME_RULESthat no longer existed. There was no conflict marker for that part; it was fixed by hand.Guarding the next composed tree:
tests/preview_capsule_scripts.test.mjsto the pixel-agent JS suite, which CI already runs in test-linux and portal-runtime.CONTROL_NAMES.CONTROL_NAMESon an empty document.const parentOffails it with "Identifier 'parentOf' has already been declared".ods/tests/test_preview_inspection.py. TheODS_PREVIEW_BROWSER_TESTS=1suite was run on the composed tree here, and it caught one expectation that the merge made stale: fix(pixel): verify owner-requested exact control names after the page scripts run #6741's round-100 icon-variant click, which now passes. The last commit updates that test (details under Tests). The next PR that touches the capsule should run this suite on its composed tree too.Follow-up for the plugin side (not changed here): #6741's load-time "that name is on the page" feedback (
presentNameinworkspace-preview-inspect.mjs) tells the model that role/name matching "compares the browser's own name verbatim, which can keep extra spacing (for example beside an aria-hidden icon)". With this branch the icon page matches, so that feedback can now appear only for a control that changed between load and the step, and its explanation no longer applies. The JS capsule models incontrol_names_transition.test.mjsandrequested_control_names.test.mjsstill model the older capsule for the icon variant; they test the plugin's handling of that receipt and still pass. #6748 is rewriting that message, so the wording is left to it.COMPLETION-RELIABILITY.mdis updated to say where the feedback still applies.Other open PRs (checked with
git merge-treeagainst this head):tool-loop-guard.mjs,workspace-preview-inspect.mjsandods/tests/test_preview_inspection.py. This branch adds no conflict beyond those.ods/tests/test_preview_inspection.pyto that list.Tests
Unit, fake CDP (
SourceTextRoleNameTests):[]for the source name and the rendered matcher finds the button: count 1, that node is observed, and the click is dispatched.no_matchforassert-visibleandclick, and the hidden-inclusive matcher is never called.assert-hiddenstill matches it.selector_not_unique.siteIdandsha256.HiddenRoleLocatorTestsis updated to record which matcher each step used.CI-run compile guard (
pixel-agent/tests/preview_capsule_scripts.test.mjs): every module-level capsule function script compiles. Both role/name matchers andCONTROL_NAMESrun on an empty document, and the matchers keep the passed Chromium matches, de-duplicated by identity.test_names_share_the_matcher_rules_and_are_read_only(from #6741) now also checksROLE_NAME_RENDERED.Opt-in real Chromium (
ODS_PREVIEW_BROWSER_TESTS=1, Playwright 1.62.0):test_rendered_role_matcher_agrees_with_playwright_get_by_role: 47 role/name queries on one page, compared with Playwright's own engine. It covers text-transform, hidden andaria-hiddenancestors,visibility, opacity, hidden descendants, hiddenaria-labelledbyand<label>references,display: contents,content-visibility, closed<details>,::beforecontent, duplicates, shadow DOM (slotted, unslotted, shadow children), whitespace, and labels: several for one control, wrapping, a button's label, a label for a non-labelable element, and a shadow-tree label. The label cases are also added to theincludeHiddenparity test, which now has 40 queries with fix(pixel): verify owner-requested exact control names after the page scripts run #6741's SVG<title>and hidden-icon cases.test_load_time_control_names_replay_fleet_round100: the exact-name click on every repaired variant, including thearia-hiddenicon, now passes with count 1. It was an expectedno_matchfor the icon variant.test_role_name_matchers_do_linear_work: counts style and label reads in the matcher's own isolated world, on a page with a 200-deepdisplay: contentschain, 300 buttons and 3 labels. Both matchers stay within 8 style reads per element and 3 label reads per call.Replays of recorded bytes (new fixtures;
.gitattributeskeeps them byte-exact):preview-inspection-laptop-round101.html(site-458e7785, session 119e5fd5) with the recorded Add PHILOSOPHY.md and reframe README as methodology #10 plan: passed. On main: step 5no_match, as recorded.preview-inspection-laptop-round107-site-1f5f2cf8.htmlwith the recorded fix(windows): use GGUF filename as OpenCode model ID #51 plan: step 3 click passed, step 4visibility_mismatch(the page's real defect, the same result the recorded Wave 1: All 10 extensions + ChromaDB bump #56 selector plan got). On main: step 3no_match, as recorded.writeandeditcalls. At each of the four publications its digest matches the recorded siteId, ending at1f5f2cf8…2018(11509 bytes).Results:
All runs are on tower2 (Linux) from
git archiveof this branch into a scratch directory, using the existing Playwright 1.62.0 test venv andchromium_headless_shell-1234. Nothing was installed. No Pixel services or fleet hosts were touched.Final head
ada56f51(this branch merged with main6cba572f, which includes #6741):remote-js-test.sh)node --test tests/test-preview-inspection.mjstest_preview_inspection.py, no browsertest_preview_inspection_distribution.py+test_pixel_inspection_upgrade_dispatch.pyODS_PREVIEW_BROWSER_TESTS=1 test_preview_inspection.py, real Chromium, run twiceOn the merge commit
2dc6b0b4before the last commit, the browser suite had 1 failed, 90 passed in both runs: #6741's icon-variant expectation described above. GitHub CI on2dc6b0b4is green, and its logs show the new compile guard running in both Portal runtime contracts and Test Linux (ok 1247,ok 1248).Pre-merge head
42493077: JS suite tests 3140, pass 3102, fail 0; browser suite 80 passed, 7 skipped, 137 subtests, 0 failed, twice; no-browser unittest 87 OK; distribution + upgrade dispatch 79 passed.Branch commits:
448a4bb8fix,04b78d7dwhitespace parity query,1216a92dlinear cost,42493077CI compile guard,2dc6b0b4merge of main (#6741),ada56f51icon-variant expectation and reliability doc.Red checks. These are the same tests with the capsule swapped out:
d4a61f33)no_match. Also failing: text-transform, uniqueness, hidden descendants, spoofing, parity, and the fake-CDP tests (noROLE_NAME_RENDERED).measure()reverted to the AX-only pathtest_source_text_matching_stays_rendered_onlyfail.04b78d7d)test_role_name_matchers_do_linear_workfails: 22,717 style reads against a bound of 4,088, and 303 label reads against 3. Both parity tests pass, so names are unchanged.hiddenForAriacacheconst parentOfin the rules2dc6b0b4): nohiddenForAriacache; no label index; duplicatedconst parentOfNotes on the rows above:
test_navigationsubprocess timeout under load. It did not reproduce: 3/3 passes each on branch and main, about 1.4 s each.Also run locally on Windows (HeadlessChrome 149, Playwright 1.62.0), on the merged head: both replays give the same results (main
no_match, branch passed andvisibility_mismatch). The fake-CDP tests (including #6741'sControlNamesTests), both parity tests, the cost test and the compile guard pass there too. Four tests need POSIX APIs (os.getuid, Unix sockets) and ran only on tower2.Not run:
DockerCapsuleTests. They need an image built from this capsule, and none was built on a fleet host.🤖 Generated with Claude Code