Skip to content

fix(gates): repair the WASM smoke gates - #180

Merged
YoungSx merged 14 commits into
mainfrom
fix/wasm-hero-card-activation
Sep 5, 2026
Merged

fix(gates): repair the WASM smoke gates#180
YoungSx merged 14 commits into
mainfrom
fix/wasm-hero-card-activation

Conversation

@YoungSx

@YoungSx YoungSx commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Fixes the WASM smoke gates, which were red on main.

What was actually wrong

The chain was broken at the first gate, so gates two through seven had never been
reached — their assertions had not run in a long time, and most of them could not
pass on Skia at all. Each fix exposed the next one; that is why this is twelve
commits rather than one.

The recurring mistake: the checks read signals that exist in the DOM but do not
describe what a user experiences.

  • Text that only exists as an accessible name. Skia paints into a canvas and
    mirrors an accessibility view, not a DOM. Chat turns, list row titles, editor
    field labels, combo box items — all of them reach the tree as accessible names,
    never as text content. Every body.innerText assertion over them was structurally
    unable to pass. They read the semantic tree now.
  • "Present" mistaken for "on screen". A control inside a collapsed Expander is in
    the tree, unhidden, reporting a placeholder rect a few pixels wide at the origin.
    Checks treated that as ready, then drove a section the user cannot see — and a
    pointer aimed at that placeholder hit the title bar's back button and navigated
    away. Layout is now part of the readiness question.
  • Duplicate automation ids. Templated rows publish several nodes with the same id,
    including for rows that are not realized. Typing into one of those lands nowhere.
    Matching prefers the laid-out node.
  • Synthetic input that the ViewModel never sees. Assigning a mirrored input's
    value works for most fields but not all: the ACP profile editor's Server URL kept
    an empty value in the ViewModel while the DOM showed the typed URL, so saving
    failed validation on a field that was visibly filled. Text entry is real keystrokes
    now, committed with Tab (a DOM blur() does not commit — Uno is driven by its own
    keyboard pipeline).
  • A redundant navigation that undid the previous one. Reaching a settings section
    always activated the Settings entry first, even when the shell was already open,
    which starts an async navigation to the default section that can land after the
    section click and replace the page mid-use. The MCP editor "not opening" was this.
  • Assertions that could only ever be true. The MCP page's arrival was pinned to
    body text containing "New" — a word the navigation shell itself renders.

Product fixes included

  • Gamepad diagnostics were unreadable by assistive technology. All eight value
    fields hardcoded AutomationProperties.Name to their own automation id, so a screen
    reader announced Diagnostics.GamepadStandardCount where the user reads 0. Each
    name is bound to the text its field renders. This is also what made the values
    readable by the gate.

Two facts worth keeping

  • Which activation route reaches a control has to be measured per control. The
    report card needs a trusted pointer; the gamepad refresh button needs semantic
    activation and does nothing from a pointer; a locator click can never satisfy
    actionability against a semantic node at all (pointer-events: none hands the
    pointer to the canvas). Assuming from a neighbour is how these went wrong.
  • Some things are structurally unobservable here and should not be asserted. The
    composer's text colour is painted into the canvas, and a ContentDialog is not
    mirrored at all. Those assertions were removed rather than reworded: theme
    behaviour is covered by the persisted selection and the yaml snapshot, and the
    dialog by its modality (the page reports its controls disabled while it is up).

Verification

scripts/gates/run-wasm-smoke-gates.sh Debug — two consecutive green runs, all seven
smokes, ending on [gate] WASM smoke gates passed.

🤖 Generated with Claude Code

YoungSx and others added 12 commits September 5, 2026 01:22
The bootstrap keeps the .uno-loader splash mounted as #loading and unmounts it
from a MutationObserver on #uno-body's child list, so it can still be up after
openApp resolves: openApp returns once the semantic shell labels appear, and the
splash was measured still covering the viewport ~750ms past that point. While it
is up it takes pointer events at z-index 5000, so a trusted click aimed at the
canvas lands on the splash and is silently swallowed - the click reports success,
nothing behind it reacts, and the step later times out on a body-text wait with
no hint of what ate the gesture.

Waiting for #loading to detach makes every pointer-driven step start from a page
a user could actually reach. If it never detaches, the failure names the splash
and dumps its rect, which is itself a defect a user would see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A collapsed combo box on Skia mirrors no selection text at all - no value, no
accessible name, no template text - so the old comboBoxSelectionText read
whatever junk the node carried and matching items by accessible name never
found them. The selection is only observable while the dropdown is open, as the
highlighted option (aria-activedescendant) mapped onto the readable item labels.

Those labels are the second trap: they surface as fresh clean-label nodes
outside the popup subtree, in document order matching the option nodes, but only
on a dropdown's FIRST open. On reopen Uno reuses the very same option node ids
and never rebuilds that mirror, so any id-diffing scheme silently returns no
labels the second time. The first aligned open therefore seeds a
posinset-to-label cache per automation id, and later opens read labels back
through each option's aria-posinset. The cache lives in the page, so a shell
reload (language switch) clears it.

Opening is racy too: focus needs a settle beat before F4 registers, and the
popup has a half-open ghost state (aria-expanded=true with no option nodes yet).
The open loop therefore requires an aligned state - option count matching the
label count - and retries through Escape while its budget lasts. Committing a
selection stays on the keyboard path, since a semantic click on an item only
collapses the dropdown without selecting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e's copy

The previous version pinned the tip dialog's wording through body.innerText,
which cannot work on Skia: the ContentDialog is painted into the canvas and never
mirrored into the DOM or the semantic tree, so the copy is structurally
unobservable and asserting it was pinning a renderer detail rather than the
behaviour. What a user actually experiences - and what both renderers expose - is
modality: while the notice is up the page's controls report disabled, the
notice's OK button is present, and acknowledging it hands the page back. The
smoke now asserts that round-trip, plus a second activation to prove the card
survives its own use instead of going dead after one acknowledgement.

Two mechanics behind it. Activation goes through a trusted pointer at the center
Uno reports for the node: a raw locator can never pass Playwright's actionability
check against a semantic node (Uno bakes no `role` attribute and sets
pointer-events: none, so the hit test hands the pointer to the canvas), and
element.click() fires the peer's Invoke without always reaching the XAML command,
the same gap the expander and the gamepad refresh button documented. The helper
refuses to guess: it fails loudly when the reported center is outside the
viewport, and waits for that center to stop moving so a click is not aimed at
where the control used to be mid-animation.

Acknowledging is retried against its visible effect - the page becoming enabled
again - because the OK button's peer finishes wiring shortly after the node
surfaces and an invoke fired at first sight is swallowed while still reporting
success. waitForBodyText also no longer lets its own 5s poll timeout escape past
the caller's deadline, which used to surface a raw Playwright error instead of
the collected body text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The smoke used to switch the theme, then read the start composer's prompt text
color out of the DOM and assert it moved. On Skia that text is painted into the
canvas: the semantic tree mirrors structure, not styling, so there is no
DOM-observable color to read and the check was measuring whatever unrelated node
it happened to land on. Every step behind it (the whole cache-retention and MCP
half of the smoke) sat behind that dead assertion and never ran.

Theme behaviour keeps its coverage through the paths that are actually
observable: the theme combo reports the selection back after the change, and the
persisted "theme: Dark" line is asserted in the app.yaml snapshot later in this
same smoke - the outcome a user's next launch depends on. The contrast checker
for real text inputs stays: those are genuine <input> elements with computed
styles, so their readability is still verifiable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Skia exports NumberBox as two disconnected pieces: the spinbutton node carries
the automation id but only the two spin buttons as children - no aria-valuenow,
no text, no input - while the editable value lives in a separate real <input>
carrying Uno's template part id ("InputBox") directly under the application root,
outside the spinbutton's subtree and with no geometric relation to it (the
semantic node's rect is a virtual layout coordinate). So every read, focus and
write aimed at the spinbutton node failed: the read found no value to parse, the
focus check demanded an input inside a subtree that has none, and the write
reported "no editable field found".

The helpers now resolve the editor input, with the spinbutton node kept as the
anchor - it proves the right page is mounted, so a stale editor left by a
previously visited page can never be edited by mistake, and the helper fails
loudly if more than one NumberBox is mounted, since the part id is not unique on
its own. Reading is the input's live value; the mirrored aria-label is a stale
snapshot and is not trusted. Writing is the user's path - focus, select all, type
on the real keyboard, Tab to blur - because the TwoWay binding commits on blur:
assigning the value with a synthetic input event never commits (and corrupts the
mirrored label), and Enter steals focus without committing.

The focused-contrast check drops its control-specific plumbing with this: the
editor is a visible input like any other, so asserting that whichever input holds
focus is readable is both simpler and the actual requirement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…the way they do

Two gaps in how the semantic tree was being read, both of which let a check pass
against something the user never sees.

Matching took the first node carrying the wanted automation id. Templated rows put
several nodes with the SAME id into the tree - a ListView's item template, an
editor realized per row - and the ones belonging to unrealized rows are present,
unhidden, and report a placeholder rect a few pixels wide at the origin. Typing
into one of those lands in a control that is not on screen and never reaches the
ViewModel; the smoke then blames the save. Matching now prefers a laid-out
candidate, which is the one on screen.

That distinction needed a name, so isLaidOut/waitForLaidOutControl say it plainly:
present in the tree is not the same as on screen. The trusted-pointer helper uses
it to refuse a placeholder rect outright - its centre is a clickable point
belonging to whatever really occupies the top-left corner, so aiming there
navigates away and the failure surfaces somewhere else entirely.

waitForSemanticText is body text's equivalent for this renderer. Skia mirrors much
of what a user reads only as an accessible name - list item titles, field labels,
chat turns - so text plainly on screen can be absent from every node's
textContent. Matching both is what makes "the user can see this" checkable here.

countMatches exists because a duplicate row is only observable as a count, and
resolveEditableField reports which real input a control writes through (page
callbacks cannot hand back DOM nodes).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…odel

Assigning a mirrored <input>'s value and dispatching an input event looked like it
worked, and for most fields it did - but not all. The ACP profile editor's Server
URL sits inside a conditionally visible container and kept the ViewModel's empty
value while the DOM showed the typed URL: saving then failed validation with
"Server URL cannot be empty" while that URL was on screen, and the smoke reported
it as the save not persisting.

Typing is the user's own path and lands in both cases: focus the field, select all,
type, then Tab. Tab is what commits - measured, a DOM blur() does not, because the
managed side is driven by Uno's keyboard pipeline rather than by DOM focus events.
Focus is verified before typing and the value after, so a field that silently
refuses input fails where it happens instead of three steps later.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…opened

Reaching a settings section always activated the Settings entry first, even when
the shell was already open. That is not a wasted click: it starts a fresh
navigation to the shell's default section, which lands asynchronously and can
replace the section page a caller has already navigated to and started using. The
symptom is brutal to read - the section's own controls appear, the next step
activates one of them, and only its effect goes missing, because by then the shell
has swapped the page back. The MCP editor "not opening" was this.

The shell hop now happens only when the wanted section entry is not already
showing (those entries exist in the tree only while the shell is up, so seeing one
is proof enough), and arrival is confirmed, given a beat, and confirmed again - if
the shell did pull the page away, the section is simply activated again, which a
navigation item tolerates.

Combo boxes get the other half of the same fix: their helpers read the control once
and demanded it already exist, so a page still laying out its controls came back as
"the combo box does not exist". They wait for it now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three assertions in this smoke could not pass on Skia, and each was pinning
something other than the behaviour.

The New affordance was clicked through a locator keyed on its automation id as an
aria-label. Its accessible name is the localized button text, so that locator
matched nothing at all. It is matched by id in the semantic tree now, and retried
against the editor appearing: the page's nodes are published before its ViewModel
is ready, and an activation delivered too early is dropped by the command without
the node ever reporting itself disabled - the click "succeeds" and nothing opens.

Arrival on the page was pinned to body text that included "New", a word the
navigation shell itself renders, so the check passed while the page had not changed
and every later step ran against whichever section was still showing. It is pinned
to this page's own copy now, plus its own affordance.

The editor being open was pinned to its field labels appearing in body text. Those
labels reach the DOM only as the inputs' accessible names, never as text. The panel
becoming visible is the observable arrival, which is what a user sees.

The saved row's toggle is now polled rather than read once: rows refill
asynchronously after navigation, so a row can surface carrying its default state a
beat before the persisted one lands, and reading immediately fails on a value the
user never sees. A duplicate row is also checked for, since saving twice is only
observable as a count.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every step of the ACP fixture was reading a signal Skia does not publish.

Editors: both the profile editor and the remote directory editor were waited for by
their field labels appearing in body text. Those labels exist only as the inputs'
accessible names. Each editor's own field is the arrival proof now, and the
activation is retried against it, because an activation delivered before the row's
ViewModel is ready is dropped without a trace.

Lists: a saved profile's name, a remote project's name and path all reach the DOM as
accessible names on their rows, so waiting for them in body text timed out on rows
the user can plainly see. They are read from the semantic tree.

Composer: its automation ids are applied through an x:Bind on
AutomationProperties.AutomationId and never reach the exported node - there is no id
in the accessibility view to match. The selectors are matched on the ComboBox's own
x:Name, which Uno exports when no id is set, and the prompt box by shape (the Start
shell has exactly one multi-line text box), which is stable in a way the localized
placeholder is not.

Modes and chat turns: a collapsed ComboBox mirrors no selection text at all, so the
session's modes are read by opening the dropdown; chat turns are read from the
semantic tree for the same reason as the lists.

Reload: openApp owns what "the app is up" means, including waiting out the splash.
The hand-rolled selector wait reported a bare 60s timeout on a blank page. The
diagnostic path that tells persistence apart from a UI hang no longer masks the real
error when it fails itself, which is exactly what it used to do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… internal ids

The eight gamepad diagnostic value fields each hardcoded
AutomationProperties.Name to their own automation id, so a screen reader announced
"Diagnostics.GamepadStandardCount" where the user reads "0". The value was
unreachable for assistive technology, and for the same reason unreadable by the
gate. Each name is bound to the text its field renders, which is what a TextBlock
would announce if the name had been left alone.

That makes the gamepad checks readable at the source, so they no longer hunt for a
laid-out leaf node containing the text - which could not work, because these value
nodes are not laid out.

Two mechanics behind the section they live in. Opening a collapsed Expander now
waits for its contents to be laid out, not merely present: a control inside a shut
Expander is in the semantic tree, unhidden, reporting a placeholder rect, so
"exists" was satisfied immediately and every later step ran against a section the
user could not see - and a pointer aimed at that placeholder hit the title bar's
back button and navigated away. And the thing to click is the Expander's own header
button (a real button with aria-expanded and a real rect), not the header content
inside it, which comes through as a 0x0 group that can be neither activated nor
pointed at. Both smokes shared a copy of this walk; it now lives in one helper.

The refresh affordance is activated semantically, measured against the effect: with
a gamepad injected, that moves the reported count from 0 to 1, while a trusted
pointer at the button's own centre leaves it at 0. Which route reaches a given
control is not something to assume from the others.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The acknowledgement loop pressed OK on every pass until the page reported itself
enabled again. The notice can be gone a beat before that happens, and pressing into
that gap fails on a control that has already done its job - reported as "OK never
appeared in the semantic DOM", which hides what actually took place. The press is
now conditional on the button still being present; the loop's exit condition is
unchanged, since the page coming back is still the proof.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@vercel

vercel Bot commented Sep 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
salmonegg Ready Ready Preview Sep 5, 2026 8:43am UTC

WasmSmokeScripts_AreSplitByBehaviorBoundary asserted that the settings persistence
smoke's source text contained specific expressions: which DOM predicate its focus
check used, the name of a parameter passed to the contrast check, which two fields a
projection compared, the helper names inside the colour maths. Rewriting those checks
to observe the same behaviour a different way turned this test red while the gate
itself was green - the test was guarding the implementation, not the behaviour, and
it failed for exactly the wrong reason.

What the test is for survives: which smoke covers which behaviour boundary, the
library modules they share, the retired scripts staying retired, and the two
observable outcomes that identify the persistence smoke (the persisted language and
the English page copy after a language reload).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI showed the notice never opening on one run in several, while the same commit
passed twice locally and on the previous CI run: the trusted pointer goes through
the canvas hit test, and that can miss - the card's reported centre is right, the
click is delivered, and the command still does not run. The click reporting success
told us nothing, so the failure surfaced 30s later as "OK never appeared".

The activation is retried against the notice appearing, which is the same
effect-driven shape the MCP editor, the ACP editors and the Expander reveal already
use. The card's own enabled state decides whether to press again: once the notice is
up the page reports its controls disabled, so a card that has gone disabled means
the notice is on its way and the only thing left is to wait for it - pressing then
would fail on a control that is behaving correctly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@YoungSx
YoungSx merged commit e9ed733 into main Sep 5, 2026
15 checks passed
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