fix(flow): fold invisible characters before comparing UI text - #720
Draft
hubgan wants to merge 5 commits into
Draft
fix(flow): fold invisible characters before comparing UI text#720hubgan wants to merge 5 commits into
hubgan wants to merge 5 commits into
Conversation
hubgan
force-pushed
the
fix/failed-nested-step-not-recorded
branch
from
August 5, 2026 10:15
a2f1191 to
aaa38a0
Compare
hubgan
force-pushed
the
fix/ui-text-invisible-characters
branch
from
August 5, 2026 10:15
ab5a233 to
a18b45b
Compare
hubgan
force-pushed
the
fix/failed-nested-step-not-recorded
branch
from
August 5, 2026 10:38
aaa38a0 to
c530292
Compare
hubgan
force-pushed
the
fix/ui-text-invisible-characters
branch
from
August 5, 2026 10:38
a18b45b to
d15fad1
Compare
UI text is not the text an author types. A currency label renders with a
non-breaking space, a layout wraps a user-supplied name in bidi isolates, a soft
hyphen survives a copy-paste. All of them survived `toLowerCase()`, so a check
failed against two strings that are character-for-character identical on screen
AND in the failure message:
element matched but its text was "Amount, PLN 42.00"
(wanted to equal "Amount, PLN 42.00")
Two sessions paid a full 15-second timeout per attempt to that. In CI it is an
unexplainable red build.
Every literal comparison now folds both sides first: NFC, invisible formatting
stripped, space-like codepoints reduced to a plain space, whitespace collapsed.
`matches` (regex) is exempt — a pattern carries its own precision.
The invariant is that folding only ever removes distinctions the EYE CANNOT SEE,
and two consequences follow from it:
- NFC, not NFKC. Compatibility normalization folds away differences that read
perfectly well — a blackletter display name compared EQUAL to its plain-ASCII
spelling, so a check could not tell an impersonating account from the real one.
- ZWJ and variation selectors are NOT stripped. They build emoji sequences: the
transgender flag is ONE glyph made of four codepoints, and stripping them
folded it onto two different glyphs — hiding a real rendering regression.
ZWNJ is a spelling difference in Arabic, Persian and Indic scripts.
Bidi wrappers ARE folded, and are not theoretical: a census of four Bluesky web
screens found 367 U+202A/U+202C pairs and not one NBSP.
Two new failure notes cover what folding deliberately does not fix: a codepoint
diff when two strings differ only in invisible characters, and a note naming the
on-screen text when a selector missed only by a typographic variant (a rendered
"…" against three typed dots).
Separately, a needle that folds away to nothing is now no constraint rather than
a weak one: `{ role: " " }` matched every element on the screen, so the check
could never fail.
Follow-ups on the invisible-character folding, all reachable from a flow or
await-ui-element:
- equalsCI gained the empty-needle guard its siblings (includesCI,
identifierMatches) already carry. Folding trims and strips, so
`equalsCI("", " ")` was true — a `text`/`equals` check whose expected is
whitespace- or invisible-only passed against every textless element, the
silently-wrong green this module rates worse than a flake. It now refuses an
expected that folds to nothing.
- compatibilityMissNote is scoped to an actual MISS. It was appended to every
failed wait, so on a `hidden` failure (an element the selector found is still
on screen) and on a regex `matches` failure (the "wanted" is a pattern, not
text) it gave backwards "copy the characters the app renders" advice. Those
two conditions are now exempt — the same exemption confusableTextNote already
draws — and for a `text` miss it keys on the expected text, not the locator.
- lastTree is assigned only on a trusted read, matching its comment, so a
trailing blind read no longer overwrites it and drops the note.
- Dropped the redundant NFKD in compatibilityVariantOf (NFKC already composes
after NFKD).
Tests: the equalsCI guard across the blank-needle set, and three
compatibilityMissNote cases (no note on hidden or on matches, note still fires
on a genuine miss).
Wrap the compatibilityMissNote call and the visible-compat step array the formatter wanted.
compatibilityMissNote walked the WHOLE tree for a `text` condition, so on a genuine mismatch an unrelated node that merely rendered a compatibility variant of the expected string hijacked the failure note — telling an author whose element is simply in the wrong state to "copy the rendered characters" of a look-alike elsewhere on screen, a dead end that masks the real failure. It is now scoped to the located element's own text for `text` conditions; exists and visible keep the whole-tree search, because nothing matched and there is no element to scope to. Pinned by two flow-run tests: no hijack on a real miss, and the note still fires when the variant IS the located element's own text. Three comments corrected to match the code they describe: - The INVISIBLE class doc listed ZWSP/ZWNJ/ZWJ among the stripped characters, but the regex jumps U+200B to U+200E and the block right below deliberately KEEPS ZWNJ/ZWJ (load-bearing in sequence). Listing them invited a maintainer to "fix" the regex into a real rendering regression. - The assertReason confusable-note comment cited an NBSP and a variation selector as its cases; neither reaches it — an NBSP folds to a plain space so the check already passes, and a variation selector is category Mn, not Cf. Replaced with what it actually explains: Cf format characters (ZWSP, bidi marks) that survive a copy-paste. - A confusableTextNote test asserted only equalsCI and never called the function; it now calls it. Added the end-to-end coverage the suite lacked: a plain selector matching a bidi-wrapped label through findAll/evaluateCondition (the user-facing invariant, where only the comparators-in-isolation were pinned before), and the confusableTextNote path through a real flow run (reached only via assertReason, which nothing exercised end-to-end).
…up miss notes Three gaps the text-folding work left open, surfaced reviewing this PR: - INVISIBLE now strips U+061C (Arabic letter mark) and U+180E (Mongolian vowel separator). U+061C is the zero-width RTL analog of the LRM/RLM the fold already removes, so an Arabic display name wrapped with it stayed as unmatchable as the LTR case the fix targets; U+180E has rendered as nothing since Unicode 6.3. - foldText strips invisibles BEFORE NFC. An invisible wedged between a base letter and its combining mark was blocking composition, leaving a decomposed grapheme that no longer equalled its precomposed, identically-rendered twin. - the compatibility miss note stands down when the confusable note already fires. A node whose own text and hoisted subtree text missed the expected string in different ways (one an invisible-only difference, the other a typographic variant) printed both notes at once - two conflicting explanations of one failure. Tests cover the two new folded code points, the composition case, the confusable note's own-text fallback and its `matches` exemption, and the single-note rule.
hubgan
force-pushed
the
fix/failed-nested-step-not-recorded
branch
from
August 6, 2026 09:48
c530292 to
e54968c
Compare
hubgan
force-pushed
the
fix/ui-text-invisible-characters
branch
from
August 6, 2026 09:51
d15fad1 to
cd3e91a
Compare
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.
The problem
UI text is not the text an author types. A currency label renders with a non-breaking space; a layout wraps a user-supplied name in bidi isolates; a soft hyphen or ZWSP survives a copy-paste. All of them survived
toLowerCase(), so a check failed against two strings that are character-for-character identical on screen and in the failure message:Two sessions paid a full 15-second timeout per attempt to that, plus extra probe cycles. In CI it is an unexplainable red build.
What changes
Every literal comparison (
equalsCI,includesCI,identifierMatches) folds both sides first: NFC, invisible formatting stripped, space-like codepoints reduced to a plain space, whitespace runs collapsed, trimmed, lowercased.matches(regex) is deliberately exempt — a pattern carries its own precision.The invariant, and what it rules out
Folding only ever removes distinctions the eye cannot see. Two consequences follow, and both are load-bearing:
NFC, not NFKC. Compatibility normalization folds away differences that read perfectly well — mathematical alphanumerics, ligatures, fullwidth forms. Under NFKC a blackletter display name compared equal to its plain-ASCII spelling, so a check could not tell an impersonating account from the real one. A silently-wrong green is worse than a flake.
ZWJ and variation selectors are not stripped. They are invisible alone but load-bearing in sequence: the transgender flag is
U+1F3F3 VS16 ZWJ U+26A7 VS16— one glyph. Stripping them folded it onto two separate glyphs, so atextcheck passed against a visibly different display name, and a broken sequence — a real rendering regression — became invisible to every check. ZWNJ is a spelling difference in Arabic, Persian and Indic scripts.Bidi wrappers are folded, and are not a theoretical case: a census of four Bluesky web screens found 367
U+202A/U+202Cpairs and not a single NBSP. Omitting them would have left the most common real instance of this bug unfixed.Two failure notes for what folding deliberately does not fix
Cf, strictly wider than the fold's explicit classes, so it is the safety net for invisible characters not on that list....for a label the app renders with…and gets "no element matched", which points at nothing. The failure now names the text the screen actually shows and says why the two are not equated.Unrelated hole closed in passing
A needle that folds away to nothing is now no constraint rather than a weak one.
"".includes()is true of every string, so{ role: " " }matched every element on the screen and the check could never fail.