Skip to content

fix(web): read a sentence whole across its inline links and emphasis - #926

Merged
okwasniewski merged 5 commits into
mainfrom
oskar/inline-link-text
Oct 8, 2026
Merged

okwasniewski merged 5 commits into
mainfrom
oskar/inline-link-text

Conversation

@okwasniewski

@okwasniewski okwasniewski commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

The tree the agent reads dropped every word inside an inline element from its sentence. <p>Read our <a>privacy policy</a> for details.</p> reached the model as text="Read our for details.", so explore reported missing copy that the page actually shows (13 of 17 findings on one marketing site). Every agent step had the same gap.

Fixes ENG-919

What changed

  • Tree mode read an element's text with directTextOf, which counts only its own text nodes. It now uses lineTextOf (packages/web/src/in-page/read-semantics.ts): the element's text plus the text of each visible inline descendant (inline*, ruby, display: contents), in place. A block child, a line break, a form control, an image, or a frame splits words and contributes no text of its own.
  • The same rule applies to every inline element (a, strong, em, code, span). Only an element with text of its own reads through its children. A wrapper made only of elements lists them the same way it did before (<button><span>Save</span></button> does not change).
  • An inline element that is listed only for its text (strong, em, code, a plain span) is no longer listed separately once its parent's line includes that text (isReadInLine). A link, a button, or a node with a test id is still listed so the agent can act on it. The words of a link now appear twice (in the sentence and in the link's name), but the removed fragment lines save about as many tokens: a benchmark suite run used 76.4k tokens here vs 76.5k on main.
  • When a line is longer than the 512-char text bound, its inline descendants stay listed, so nothing after the cut is lost.
  • Node mode (locator reads, getByText) is unchanged.
main                                                      this branch
#n29 text="We ship every week. Read the for what…"        #n29 text="We ship every week. Read the release notes for what changed this week."
 #n30 link "release notes" href="/release-notes"            #n30 link "release notes" href="/release-notes"
#n31 text="Plans start at per seat, billed , and…"        #n31 text="Plans start at $12 per seat, billed annually, and include the e2e CLI."
 #n32 text="$12"
 #n33 text="annually"
 #n34 text="e2e"

Cache keys do not change: tree text is not part of the key. Recorded anchors can change, though. One web-benchmark entry (promo-storefront) recorded <strong>Jonas</strong> ★★★★★ Great bass… as two text anchors, and the branch failed it with REPLAY_STALE (end-mismatch). That entry is re-recorded here (same actions, the anchor is now one line). The re-recorded entry uses the @e2e-dev/web 0.13 key from #825. The other 29 entries in the suite are still under the 0.12 key, and the benchmark has been red on main since #825. Re-keying them belongs in its own re-record PR (like #833), so they are left alone here.

Verified

Ran it locally: yes

  • packages/web/tests/integration/line-text.test.ts is the new regression test. Against main's reader it fails 4 of 6 tests (the wrapper case passes there as intended), and all 6 pass on this branch. The web package suite passes (crosscheck and conformance included), and so does crosscheck against every web-benchmark scenario (CROSSCHECK_BENCHMARK_URL).
  • Testbed: a new /plans page with inline links, bold, italic, and code (not in the nav), plus tests-agent/judgments.e2e.ts "assert reads a sentence whole across its inline link and emphasis", paired with getByText checks. Run live with gateway/openai/gpt-6-luna-fast and --no-cache.
  • Web benchmark: the deterministic suite passes 91/91. tests-agent with CI=1 --strict-cache passed 33/33 against the 0.12 keys before the rebase and the review fixes, and the re-recorded promo-storefront entry replays with no model call under the 0.13 key.
  • Testbed deterministic suite: 71 passed, 1 skipped.
main this branch
e2e explore "Go to the /plans page and review its copy: report any sentence with missing or broken words" 2 findings: "Plans page sentence omits the release-notes link text" ("Read the for what changed this week."), "Plans pricing sentence omits price and billing interval" ("Plans start at per seat, billed , and include the CLI.") Findings: none
What agent.assert sees for /plans (unbox-ai) text="Plans start at per seat, billed , and include the CLI." plus loose "$12", "annually", "e2e" lines text="Plans start at $12 per seat, billed annually, and include the e2e CLI."
agent.assert on the two full sentences, 5 runs 5/5 pass (the judge fills in the gaps from the loose fragments) 5/5 pass, 1.1k vs 1.2k input tokens

Before the PR, a fresh-context review found two problems, and both are fixed with tests. Zero-size and font-size: 0 inline text had leaked into the line, and inline text after the 512-char cut was lost.

Overlap with open reader PRs (#871, #895, #898, #900): this change touches none of their hunks in read-semantics.ts. It adds a new test file instead of editing crosscheck/fixtures.ts.

Review in cubic Turn on auto-fix

@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

🟢 e2e web agent: 33 passed

34 agent steps · 26 replayed from cache · 21 model calls · 83.4k tokens (90% cached) · $0.0041

All 33 tests in 5 files
Test Agent Time
🟢 tests-agent/control-inventory.e2e.ts · 7 passed 7 steps 7.9s
🟢 control inventory › check sets the checkbox and the radio 1 step 725ms
🟢 control inventory › check picks a radio the pick replaces with its summary 1 step 341ms
🟢 control inventory › a long press and a double click are chosen over a plain tap 1 step 1.0s
🟢 control inventory › a right-click opens the file menu and Rename renames the file 1 step 2.6s
🟢 control inventory › upload attaches the named project files 1 step 2.5s
🟢 control inventory › back returns from the details view to the inventory 1 step 301ms
🟢 control inventory › scroll_to brings the footnote into view 1 step 393ms
🟢 tests-agent/login-form.e2e.ts · 1 passed 1 step 364ms
🟢 act signs in with the benchmark account 1 step 364ms
🟢 tests-agent/planted-bugs.e2e.ts · 3 passed 4 steps · 8 calls 12.4s
🟢 cart totals: a judgment catches the stale order summary 2 steps · 4 calls 5.6s
🟢 checkout review: a judgment catches the broken copy 1 step · 1 call 2.5s
🟢 wishlist: a judgment catches the lost saved items 1 step · 3 calls 4.2s
🟢 tests-agent/scenarios.e2e.ts · 21 passed 21 steps · 13 calls 2m 34s
🟢 act completes shadow-dom-form 1 step · 3 calls 4.2s
🟢 act completes lying-labels 1 step · 4 calls 7.2s
🟢 act completes iframe-form 1 step · 3 calls 4.0s
🟢 act completes infinite-scroll 1 step 5.3s
🟢 act completes virtualized-table 1 step 1m 41s
🟢 act completes overlay-trap 1 step 893ms
🟢 act completes sticky-chrome 1 step 896ms
🟢 act completes async-states 1 step · 3 calls 6.7s
🟢 act completes debounced-search 1 step 2.3s
🟢 act completes hover-menu 1 step 531ms
🟢 act completes drag-and-drop 1 step 483ms
🟢 act completes kanban-board 1 step 684ms
🟢 act completes context-menu-trap 1 step 2.7s
🟢 act completes stale-dom 1 step 480ms
🟢 act completes otp-auto-advance 1 step 807ms
🟢 act completes date-picker 1 step 941ms
🟢 act completes rich-text-editor 1 step 10.2s
🟢 act completes playbook-cleanup 1 step 586ms
🟢 act completes promo-storefront 1 step 2.3s
🟢 act completes gift-card-purchase 1 step 2.4s
🟢 act filters the list and the URL carries the filter 1 step 223ms
🟢 tests-agent/unique-cache.e2e.ts · 1 passed 1 step 298ms
🟢 act adds an address with a run-unique label 1 step 298ms

e2e 0.18.0 · 2m 56s · web · run artifacts

@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

🟢 e2e web: 91 passed

All 91 tests in 41 files
Test Time
🟢 tests/aria-hidden-flow.e2e.ts · 2 passed 551ms
🟢 aria hidden flow › picks the Pro plan by its visible text and confirms it 384ms
🟢 aria hidden flow › another plan shows the error and keeps the picker open 167ms
🟢 tests/async-states.e2e.ts · 2 passed 7.1s
🟢 async states › reads the code off the transient toast and verifies it once the button enables 2.2s
🟢 async states › a wrong code is rejected after the toast has gone 4.9s
🟢 tests/canvas-only.e2e.ts · 2 passed 316ms
🟢 canvas only › taps square, circle, then diamond at their pixel coordinates 176ms
🟢 canvas only › a shape out of order paints the error and resets progress 140ms
🟢 tests/canvas-whiteboard.e2e.ts · 2 passed 477ms
🟢 canvas whiteboard › drags both shapes into their slots and saves 270ms
🟢 canvas whiteboard › bounces a shape off the wrong slot and refuses to save early 207ms
🟢 tests/cart-totals.e2e.ts · 2 passed 451ms
🟢 cart totals › increasing a quantity updates the line total but the order summary stays stale (planted bug) 271ms
🟢 cart totals › a quantity never drops below one 180ms
🟢 tests/checkout-review.e2e.ts · 1 passed 161ms
🟢 shows a raw greeting token and an undefined delivery date (planted bug), yet places the order 161ms
🟢 tests/context-menu-trap.e2e.ts · 2 passed 662ms
🟢 context menu trap › right-click opens nothing; the row menu renames the file 341ms
🟢 context menu trap › the decoy menu items refuse and keep the file 321ms
🟢 tests/control-inventory.e2e.ts · 8 passed 3.9s
🟢 control inventory › check sets the checkbox and the radio without flipping a set one 343ms
🟢 control inventory › check picks a radio the pick replaces with its summary 214ms
🟢 control inventory › a long press and a double click are told apart from a tap 1.0s
🟢 control inventory › the file menu opens on right-click only and Rename renames the file 304ms
🟢 control inventory › setInputFiles attaches files named from the project root 183ms
🟢 control inventory › the details view is left through the browser history 185ms
🟢 control inventory › scrollIntoView brings the footnote into view 193ms
🟢 control inventory › every exercise done shows the success message 1.4s
🟢 tests/css-content-ui.e2e.ts · 2 passed 358ms
🟢 css content ui › the tile labelled Ship it advances and confirming ships 200ms
🟢 css content ui › a wrong tile shows the CSS-rendered error and keeps the picker 158ms
🟢 tests/date-picker.e2e.ts · 2 passed 672ms
🟢 date picker › navigates three months ahead and books the required date 394ms
🟢 date picker › confirming without the exact date shows the error 278ms
🟢 tests/debounced-search.e2e.ts · 2 passed 2.9s
🟢 debounced search › a decoy one character off is refused 1.4s
🟢 debounced search › picking the exact item adds it to the cart 1.4s
🟢 tests/deferred-dialog.e2e.ts · 1 passed 1.1s
🟢 one submit opens one confirm, even when double-clicked 1.1s
🟢 tests/div-soup.e2e.ts · 2 passed 792ms
🟢 div soup › a wrong code is rejected 346ms
🟢 div soup › the right code grants access after the delete tile fixes a typo 446ms
🟢 tests/drag-and-drop.e2e.ts · 2 passed 718ms
🟢 drag and drop › an out-of-order item resets the dropzone 339ms
🟢 drag and drop › dropping Banana then Cherry passes 379ms
🟢 tests/filter-deep-link.e2e.ts · 2 passed 318ms
🟢 filter deep link › filtering writes the query to the URL 178ms
🟢 filter deep link › opening a shared link restores the filter 140ms
🟢 tests/gift-card-purchase.e2e.ts · 3 passed 748ms
🟢 gift card purchase › refuses to buy without an amount 204ms
🟢 gift card purchase › refuses a recipient without an at sign 274ms
🟢 gift card purchase › sends the gift card 270ms
🟢 tests/hover-menu.e2e.ts · 2 passed 644ms
🟢 hover menu › a decoy leaf is refused 298ms
🟢 hover menu › hovering Account, then Billing, reaches Redeem voucher 346ms
🟢 tests/iframe-form.e2e.ts · 2 passed 722ms
🟢 iframe form › a wrong code is refused by the host page 372ms
🟢 iframe form › the code read from one frame applies through the other 350ms
🟢 tests/image-only-ui.e2e.ts · 3 passed 667ms
🟢 image only ui › the icons have no names, so the bare svg locator is ambiguous 155ms
🟢 image only ui › a wrong icon shows the error image and keeps the icons 250ms
🟢 image only ui › tapping the star, then the heart, verifies the icons 262ms
🟢 tests/infinite-scroll.e2e.ts · 1 passed 4.8s
🟢 scrolls the feed until the Golden Ticket loads, then claims it 4.8s
🟢 tests/kanban-board.e2e.ts · 3 passed 829ms
🟢 kanban board › pointer drags move both cards into the goal layout 405ms
🟢 kanban board › a swipe along a path drags a card as well 193ms
🟢 kanban board › a plain tap moves nothing and the untouched board is rejected 231ms
🟢 tests/login-form.e2e.ts · 4 passed 957ms
🟢 login form › rejects a malformed email 245ms
🟢 login form › rejects a short password 211ms
🟢 login form › rejects an unknown account 207ms
🟢 login form › signs in and logs out 294ms
🟢 tests/lying-labels.e2e.ts · 4 passed 793ms
🟢 lying labels › the button whose accessible name says Pay now is the wrong one 209ms
🟢 lying labels › a text match for Pay now catches the decoy phrase too 149ms
🟢 lying labels › the field whose accessible name says Amount is the wrong one 221ms
🟢 lying labels › visible text drives the payment through 214ms
🟢 tests/native-dialogs.e2e.ts · 3 passed 602ms
🟢 native dialogs › answers the confirm, prompt, and alert chain 189ms
🟢 native dialogs › dismissing the confirm cancels the order 203ms
🟢 native dialogs › a wrong gift code is rejected 210ms
🟢 tests/newsletter-signup.e2e.ts · 1 passed 2.3s
🟢 subscribe renders no feedback and logs a TypeError (planted bug) 2.3s
🟢 tests/onboarding-wizard.e2e.ts · 5 passed 6.1s
🟢 onboarding wizard › back keeps what was typed and the brief comes back with it 341ms
🟢 onboarding wizard › the address step takes the brief through its bare-div dropdown 468ms
🟢 onboarding wizard › the profile step steers the calendar three decades back 1.6s
🟢 onboarding wizard › the preferences step flips the toggles the brief names and leaves the trap off 1.8s
🟢 onboarding wizard › the review step repeats the brief and finishes with the PIN 1.9s
🟢 tests/order-history.e2e.ts · 2 passed 1.5s
🟢 order history › overview and settings render their panels 215ms
🟢 order history › the orders tab never leaves its loading state (planted bug) 1.3s
🟢 tests/otp-auto-advance.e2e.ts · 3 passed 1.5s
🟢 otp auto-advance › focus advances with each digit and the full code verifies 518ms
🟢 otp auto-advance › backspace on an empty box clears and refocuses the previous one 169ms
🟢 otp auto-advance › a wrong code clears every box and starts over 811ms
🟢 tests/overlay-trap.e2e.ts · 1 passed 759ms
🟢 continues only after the banner and the overlay are gone 759ms
🟢 tests/playbook-cleanup.e2e.ts · 2 passed 607ms
🟢 playbook cleanup › deleting the draft removes it from an exact-name search 309ms
🟢 playbook cleanup › cancelling the dialog keeps the draft searchable 298ms
🟢 tests/price-sorting.e2e.ts · 3 passed 453ms
🟢 price sorting › opens in the featured order with sale prices struck through 124ms
🟢 price sorting › low to high sorts by the original price, not the paid one (planted bug) 153ms
🟢 price sorting › high to low is the same wrong order reversed (planted bug) 176ms
🟢 tests/product-reviews.e2e.ts · 1 passed 1.2s
🟢 the running-shoe page lists coffee-grinder reviews (planted bug) 1.2s
🟢 tests/promo-storefront.e2e.ts · 1 passed 1.4s
🟢 buys a deal once the promo chrome is out of the way 1.4s
🟢 tests/rich-text-editor.e2e.ts · 2 passed 563ms
🟢 rich text editor › bolding exactly the required word passes the check 265ms
🟢 rich text editor › bold that spills into the previous word fails the check 298ms
🟢 tests/saved-addresses.e2e.ts · 3 passed 666ms
🟢 saved addresses › adds an address and clears the form 246ms
🟢 saved addresses › ignores an add with an empty field 205ms
🟢 saved addresses › delete shows its toast but keeps the address, on retry too (planted bug) 215ms
🟢 tests/shadow-dom-form.e2e.ts · 2 passed 594ms
🟢 shadow DOM form › rejects a wrong access code 298ms
🟢 shadow DOM form › grants access with the code from the hint inside the root 296ms
🟢 tests/smoke.e2e.ts · 1 passed 360ms
🟢 home lists the scenarios and opens one 360ms
🟢 tests/stale-dom.e2e.ts · 1 passed 2.1s
🟢 taps the moving target three times across rebuilds of the list 2.1s
🟢 tests/sticky-chrome.e2e.ts · 1 passed 252ms
🟢 accepts the terms once the button clears the sticky footer 252ms
🟢 tests/virtualized-table.e2e.ts · 1 passed 1m 12s
🟢 scrolls the windowed table to the Golden Row and claims it 1m 12s
🟢 tests/wishlist.e2e.ts · 2 passed 1.6s
🟢 wishlist › saving toggles the button and the tab badge 257ms
🟢 wishlist › the wishlist tab renders the empty state despite the badge count (planted bug) 1.3s

e2e 0.18.0 · 2m 5s · web · run artifacts

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 7 files

Heads up: you’re close to your included review allowance. Set a flex budget so reviews don’t pause.

Turn on auto-fix | Re-trigger cubic

Comment thread docs/web.mdx Outdated
Comment thread packages/web/src/in-page/read-semantics.ts Outdated
Comment thread packages/web/src/in-page/read-semantics.ts Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 3 files (changes from recent commits).

Heads up: you’re close to your included review allowance. Set a flex budget so reviews don’t pause.

Reply with feedback, questions, or to request a fix.

Turn on auto-fix | Re-trigger cubic

Comment thread packages/web/src/in-page/read-semantics.ts Outdated

@okwasniewski okwasniewski left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

On c589b4e: one question about inline clickables that have no role (inline comment on read-semantics.ts:1419).

With directTextOf(el) !== '' && !isReadInLine(el), an inline element whose only reason to be listed was its own text now drops out once its parent reads the line. That covers <span onclick>, <a> without href and <span tabindex=0>, since nothing in read-semantics detects clickability. Is it intended that the agent loses the ref for something like <p>Email: a@b.com <span onclick=...>change</span></p>? Tapping the <p> hits its center and can miss the span, and cached steps that targeted such a span would no longer find it.

Small note: REPLAY_POLICY_VERSION stays at conservative/6 while paragraph text changes and inline nodes disappear, so older cache entries will diverge and hand off to the agent.

reviewed c589b4e by Hermes on behalf of @okwasniewski

if (implicitRole(el) !== null && !isPresentational(el)) return true;
if (accessibleName(el) !== null) return true;
return directTextOf(el) !== '';
return directTextOf(el) !== '' && !isReadInLine(el);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Should inline elements that are plausibly interactive (a tabindex, an onclick, or an <a> without href) stay listed here? Right now <p>Text <span onclick="" tabindex="0">change</span></p> lists only the <p>, so the span has no ref to act on. Keeping those listed, or limiting the drop to presentational tags like strong, em and code, would cover it. A test with that markup would pin it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yes, real regression vs main. Fixed in 90277f2: an inline element that offers an action (numeric tabindex, onclick attribute or script-set property, <a> without href) stays listed with its own words while the sentence still reads whole. Test pins your markup plus those cases. Went with that over a strong/em/code tag list so plain spans are not listed twice. Gap left: a span wired only via addEventListener (incl. React delegated onClick) with no tabindex/role exposes nothing readable, so it still folds into the sentence; closing that needs a cursor-style guess.

@okwasniewski
okwasniewski merged commit 5fb69de into main Oct 8, 2026
26 checks passed
@okwasniewski
okwasniewski deleted the oskar/inline-link-text branch October 8, 2026 11:20
@github-actions github-actions Bot mentioned this pull request Oct 8, 2026
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