Problem
innerText excludes hidden subtrees and trims the ends, but does not collapse
interior runs of whitespace the way a browser does.
const w = new Window()
w.document.body.innerHTML = '<p> Save changes \n more </p>'
const p = w.document.querySelector('p')
p.textContent // ' Save changes \n more ' correct
p.innerText // 'Save changes \n more' browser: 'Save changes more'VirtualElement.innerText (:196) walks the tree, skips display: none and
visibility: hidden subtrees, turns <br> into \n, and concatenates the text
nodes as written. The two hard parts are done; the collapsing step is missing.
Why it matters
innerText exists to answer "what does the user see", and a user does not see
three spaces. The distinction from textContent is the whole reason to reach for
it, so an innerText that only differs by trimming and hidden subtrees is doing
two thirds of a job while carrying a name that promises all three.
It bites hardest where the markup is indented, which is to say all templated markup:
<button>
Save changes
</button>innerText there is 'Save changes' only because the ends are trimmed. Put two
elements on separate lines inside one container and the container's innerText
carries the indentation between them, so a comparison against 'Save changes'
fails for a reason that is invisible in the source.
Playwright's toHaveText normalises before comparing, so an assertion routed
through a matcher is insulated; a direct innerText read is not. That split is
its own trap — the same markup passes one and fails the other.
Proposed scope
Apply CSS whitespace processing to the collected text:
- Collapse runs of spaces, tabs and newlines to a single space within a text run.
- Drop whitespace at the start and end of each block-level line, not only at the ends of the whole result.
- Keep the
<br>→\nbehaviour, and keep block boundaries as line breaks (a<div>after a<div>should not run its text together with the previous one, which is worth checking separately — it may already be wrong). - Honour
white-space: pre,pre-wrapandpre-linewhere a rule sets it.getComputedStyleresolves stylesheet rules now (#1597), so the value is reachable; before that it could only have read an inline style.
Note that innerText currently reads el.style.getPropertyValue('display')
directly rather than going through getComputedStyle, so a display: none from
a stylesheet is not excluded — the visibility half has the same gap #1600 had.
Worth fixing in the same pass, and isRendered() in src/aria/visibility.ts
already does this walk correctly and could be shared.
Acceptance
- The reproduction above reports
'Save changes more'. - Indented markup gives the same
innerTextas the equivalent single-line markup, asserted side by side. - A
display: noneset by a stylesheet rule is excluded, not only an inline one. white-space: prepreserves runs.textContentis unchanged, asserted, since it is correct as it stands.locator.allInnerTexts()andlocator.innerText()keep agreeing with each other afterwards — they agree today and a test pins it (test/locator-evaluate.test.ts).
Found while adding allInnerTexts() (#1607). Normalising there was the obvious
shortcut and the wrong one: it would have made allInnerTexts() disagree with
innerText() about the same element, which is a worse bug than the imprecision.
The fix belongs here instead.