fix(gates): repair the WASM smoke gates - #180
Merged
Merged
Conversation
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>
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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>
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.
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.
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.innerTextassertion over them was structurallyunable to pass. They read the semantic tree now.
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.
including for rows that are not realized. Typing into one of those lands nowhere.
Matching prefers the laid-out node.
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 ownkeyboard pipeline).
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.
body text containing "New" — a word the navigation shell itself renders.
Product fixes included
fields hardcoded
AutomationProperties.Nameto their own automation id, so a screenreader announced
Diagnostics.GamepadStandardCountwhere the user reads0. Eachname is bound to the text its field renders. This is also what made the values
readable by the gate.
Two facts worth keeping
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: nonehands thepointer to the canvas). Assuming from a neighbour is how these went wrong.
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 sevensmokes, ending on
[gate] WASM smoke gates passed.🤖 Generated with Claude Code