Skip to content

fix(flow): fold invisible characters before comparing UI text - #720

Draft
hubgan wants to merge 5 commits into
fix/failed-nested-step-not-recordedfrom
fix/ui-text-invisible-characters
Draft

fix(flow): fold invisible characters before comparing UI text#720
hubgan wants to merge 5 commits into
fix/failed-nested-step-not-recordedfrom
fix/ui-text-invisible-characters

Conversation

@hubgan

@hubgan hubgan commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #719.

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:

element matched but its text was "Amount, PLN 42.00" (wanted to equal "Amount, PLN 42.00")
success: false, elapsed: 15001

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 a text check 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+202C pairs 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

  • Codepoint diff. When two strings differ only in invisible characters, the note prints both as codepoint sequences instead of quoting identical-looking text twice. It keys on Unicode category Cf, strictly wider than the fold's explicit classes, so it is the safety net for invisible characters not on that list.
  • Typographic variant. An author types ... 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.

@hubgan
hubgan force-pushed the fix/failed-nested-step-not-recorded branch from a2f1191 to aaa38a0 Compare August 5, 2026 10:15
@hubgan
hubgan force-pushed the fix/ui-text-invisible-characters branch from ab5a233 to a18b45b Compare August 5, 2026 10:15
@hubgan
hubgan force-pushed the fix/failed-nested-step-not-recorded branch from aaa38a0 to c530292 Compare August 5, 2026 10:38
@hubgan
hubgan force-pushed the fix/ui-text-invisible-characters branch from a18b45b to d15fad1 Compare August 5, 2026 10:38
hubgan added 5 commits August 6, 2026 11:48
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
hubgan force-pushed the fix/failed-nested-step-not-recorded branch from c530292 to e54968c Compare August 6, 2026 09:48
@hubgan
hubgan force-pushed the fix/ui-text-invisible-characters branch from d15fad1 to cd3e91a Compare August 6, 2026 09:51
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