From f21039d47e5d86f6fd91e6e75b99f1778ac4c9a1 Mon Sep 17 00:00:00 2001 From: Shangxin Date: Sat, 5 Sep 2026 01:22:33 +0000 Subject: [PATCH 01/14] test(gates): wait out the splash loader before pointer-driven steps 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) --- scripts/gates/wasm-smoke-lib/browser-app.mjs | 39 ++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/scripts/gates/wasm-smoke-lib/browser-app.mjs b/scripts/gates/wasm-smoke-lib/browser-app.mjs index 14925674..a0596cfb 100644 --- a/scripts/gates/wasm-smoke-lib/browser-app.mjs +++ b/scripts/gates/wasm-smoke-lib/browser-app.mjs @@ -547,6 +547,45 @@ export async function openApp(page, baseUrl) { } catch (error) { throw new Error(`${error.message}\n${await describeUnrenderedPage(page)}`, { cause: error }); } + await waitForSplashLoaderGone(page); +} + +// The bootstrap keeps the `.uno-loader` splash mounted as `#loading` and unmounts it only from a +// MutationObserver on #uno-body's child list (uno-bootstrap.js `initProgress`). The canvas, the +// aria-live regions and the semantics root all land in #uno-body during boot, so the observer fires +// within about a second of first paint - but openApp resolves the moment the semantic shell labels +// appear, which can still be inside that window (measured: the splash was up for ~750ms after the +// start cards were already queryable). While it is up it covers the whole viewport with +// `pointer-events: auto` at z-index 5000, so a real pointer click aimed at the canvas lands on the +// splash and is silently swallowed - the click reports success, nothing behind it reacts, and the +// step times out on a body-text wait with no signal of what ate the click. Wait for the splash to +// detach so pointer-driven steps start from a page a user could actually reach. +// +// If it is still mounted this deep into boot the observer never fired, which is itself a defect a +// real user would see as a splash that never leaves; surface that instead of tearing it out here. +async function waitForSplashLoaderGone(page) { + try { + await page.waitForFunction( + () => !document.getElementById("loading"), + undefined, + { timeout: 15_000, polling: 250 }); + } catch (error) { + const splash = await page.evaluate(() => { + const element = document.getElementById("loading"); + if (!element) { + return null; + } + const rect = element.getBoundingClientRect(); + return { + rect: `${rect.width}x${rect.height}@${rect.left},${rect.top}`, + pointerEvents: getComputedStyle(element).pointerEvents, + zIndex: getComputedStyle(element).zIndex + }; + }); + throw new Error( + `The bootstrap splash loader (#loading) never unmounted, so every real pointer click ` + + `lands on it instead of the app. Splash state=${JSON.stringify(splash)}`, { cause: error }); + } } // Skia paints into a , so the accessibility tree is the only DOM Uno mirrors - and it builds From df027455eba5c09c52fa1db4551759bdb742082e Mon Sep 17 00:00:00 2001 From: Shangxin Date: Sat, 5 Sep 2026 01:23:34 +0000 Subject: [PATCH 02/14] test(gates): read combo box selection through the open dropdown 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) --- scripts/gates/wasm-smoke-lib/browser-app.mjs | 112 +++++++----- .../gates/wasm-smoke-lib/ui-affordances.mjs | 162 ++++++++++++++---- 2 files changed, 195 insertions(+), 79 deletions(-) diff --git a/scripts/gates/wasm-smoke-lib/browser-app.mjs b/scripts/gates/wasm-smoke-lib/browser-app.mjs index a0596cfb..a3635bf0 100644 --- a/scripts/gates/wasm-smoke-lib/browser-app.mjs +++ b/scripts/gates/wasm-smoke-lib/browser-app.mjs @@ -133,54 +133,66 @@ const semanticRuntimeScript = ` return { matched: true, editable: true, disabled: false, state }; }; - const matchComboBoxItem = expectedNames => { - // An open ComboBox popup contributes option nodes to the semantic DOM. Options carry no - // automation id of their own, so match on the accessible name, falling back to contained - // text so item templates with secondary copy still resolve. - const names = (expectedNames ?? []).map(normalize).filter(Boolean); - const nodes = semanticRoot()?.querySelectorAll("[id^='uno-semantics-'][role='option']"); - if (!nodes) { + // Skia collapsed combo boxes mirror no selection text at all: the value only exists as the + // popup's highlighted option while the dropdown is open. The readable item names surface as + // fresh clean-label nodes outside the popup subtree, in document order matching the popup's + // option nodes - but only on the FIRST open of a dropdown: on reopen Uno reuses the same + // option nodes and never rebuilds the clean-label mirror. So the first aligned open seeds a + // posinset-to-label cache per automation id, and every later open reads labels back through + // the option nodes' aria-posinset. The count alignment is the ordering proof; a mismatch + // means the mirror has not caught up (or the popup is a half-open ghost) and the caller must + // retry. The cache lives in the page, so a shell reload (language switch) clears it. + const comboBoxLabelCache = new Map(); + const comboBoxOpenState = (automationId, beforeIds) => { + const combo = matchNode({ automationIds: [automationId], labels: [] }); + if (!combo) { return null; } - for (const element of nodes) { - if (element.hidden) { - continue; - } - - const aria = normalize(element.getAttribute("aria-label")); - const text = normalize(element.textContent); - if (names.includes(aria) || names.includes(text)) { - return element; - } + const expanded = combo.getAttribute("aria-expanded") === "true"; + const popupId = combo.getAttribute("aria-controls"); + const popup = (popupId && document.getElementById(popupId)) + ?? semanticRoot().querySelector("[role='listbox']") + ?? null; + const optionNodes = popup ? Array.from(popup.children) : []; + const activeId = combo.getAttribute("aria-activedescendant"); + const activeIndex = activeId ? optionNodes.findIndex(node => node.id === activeId) : -1; + + const freshLabels = Array.from(semanticRoot().querySelectorAll("[aria-label]")) + .filter(node => !beforeIds.includes(node.id) + && !node.hidden + && node.getAttribute("aria-label") !== "Popup" + && !(popup && (popup === node || popup.contains(node)))) + .map(node => node.getAttribute("aria-label")); + + if (optionNodes.length > 0 && freshLabels.length === optionNodes.length) { + const byPos = new Map(); + optionNodes.forEach((node, index) => { + byPos.set(node.getAttribute("aria-posinset") ?? String(index + 1), freshLabels[index]); + }); + comboBoxLabelCache.set(automationId, byPos); } - for (const element of nodes) { - if (element.hidden) { - continue; - } + const cached = comboBoxLabelCache.get(automationId); + const cacheUsable = cached !== undefined && cached.size === optionNodes.length; + const itemLabels = freshLabels.length === optionNodes.length + ? freshLabels + : optionNodes.map(node => cached?.get(node.getAttribute("aria-posinset")) ?? null); - const text = normalize(element.textContent); - if (names.some(name => text.includes(name))) { - return element; - } - } - - return null; + return { + expanded, + optionCount: optionNodes.length, + activeIndex, + itemLabels, + aligned: expanded + && optionNodes.length > 0 + && (freshLabels.length === optionNodes.length || cacheUsable) + && itemLabels.every(label => typeof label === "string") + }; }; - const comboBoxSelectionText = automationId => { - const element = matchNode({ automationIds: [automationId], labels: [] }); - if (!element) { - return null; - } - - // Prefer an explicit value mirror (inputs and sliders carry one) over template text, which - // can include the caret. - return element.value - ?? element.getAttribute("aria-label") - ?? ((element.textContent ?? "").trim() || null); - }; + const comboBoxLabeledIds = () => Array.from(semanticRoot().querySelectorAll("[aria-label]")) + .map(node => node.id); const focusedSnapshot = () => { const element = document.activeElement; @@ -245,6 +257,18 @@ const semanticRuntimeScript = ` }; }; + // Real DOM focus on the semantic node. Uno forwards focus into the managed visual tree, which + // is the precondition for keyboard choreography (F4, arrows, Enter) on combos and lists. + const focusControl = input => { + const element = matchNode(input); + if (!element) { + return false; + } + + element.focus(); + return document.activeElement === element; + }; + const stateWithLegacyPointers = element => { const state = describeNode(element); return { @@ -265,11 +289,9 @@ const semanticRuntimeScript = ` }, activate, setInput, - comboBoxItem: expectedNames => { - const element = matchComboBoxItem(expectedNames); - return element ? { activate: () => element.click(), state: describeNode(element) } : null; - }, - comboBoxSelectionText, + comboBoxOpenState, + comboBoxLabeledIds, + focusControl, focusedSnapshot, readLocalTextFile, persistenceDebug, diff --git a/scripts/gates/wasm-smoke-lib/ui-affordances.mjs b/scripts/gates/wasm-smoke-lib/ui-affordances.mjs index 10c7bcb0..22edcb0c 100644 --- a/scripts/gates/wasm-smoke-lib/ui-affordances.mjs +++ b/scripts/gates/wasm-smoke-lib/ui-affordances.mjs @@ -310,72 +310,166 @@ export async function expectToggleSwitchValue(page, options, expectedValue, labe // ---- combo boxes ----------------------------------------------------------------------------- -// Expand-collapse and selection are both semantic clicks; the selector's accessible name tracks -// the selected value, which is the verification. No keyboard choreography: the semantic layer -// already programs Enter/Space/Escape onto the nodes. -export async function selectComboBoxItem(page, selectorAutomationId, expectedVisibleName, options = {}) { - const expectedNames = Array.isArray(expectedVisibleName) - ? expectedVisibleName - : [expectedVisibleName]; - const label = `combo box '${selectorAutomationId}'`; +// Skia renders the app into a canvas and collapsed combo boxes mirror no selection text, so the +// selection is only observable by opening the dropdown and reading which option is highlighted +// (aria-activedescendant), mapped onto the clean item labels. Both helpers below share the +// keyboard path because it is the only one that actually commits a selection: a semantic click +// on an item merely collapses the dropdown. +// +// F4 is also racy on Skia: focus needs a settle beat before the key opens the popup, and the +// popup has a half-open ghost state (expanded=true, no option nodes yet). So opening loops: +// focus -> settle -> F4 -> poll for an aligned open state (option count matching the fresh +// labeled nodes) -> Escape and retry while budget remains. - await activateWhenReady(page, { automationIds: [selectorAutomationId], labels: [] }, label, defaultTimeoutMs); +const COMBO_SETTLE_MS = 300; +const COMBO_OPEN_TIMEOUT_MS = 20_000; + +const findComboBox = (page, selectorAutomationId) => page.evaluate( + automationId => window.__salmoneggSmoke.semantic.describe({ + automationIds: [automationId], labels: [] + }), + selectorAutomationId); + +async function openComboBoxAligned(page, selectorAutomationId, label) { + const deadline = Date.now() + COMBO_OPEN_TIMEOUT_MS; + let beforeIds = null; + let attempt = 0; - const deadline = Date.now() + 10_000; - let itemState = null; while (Date.now() < deadline) { - itemState = await page.evaluate( - input => window.__salmoneggSmoke.semantic.comboBoxItem(input.expectedNames), - { expectedNames }); - if (itemState) { - break; + beforeIds = await page.evaluate(() => window.__salmoneggSmoke.semantic.comboBoxLabeledIds()); + attempt += 1; + + await waitForControlState( + page, + { automationIds: [selectorAutomationId], labels: [] }, + label, + 5_000); + await page.evaluate( + automationId => window.__salmoneggSmoke.semantic.focusControl({ + automationIds: [automationId], labels: [] + }), + selectorAutomationId); + await page.waitForTimeout(COMBO_SETTLE_MS); + await page.keyboard.press("F4"); + + const openDeadline = Date.now() + 2_000; + while (Date.now() < openDeadline && Date.now() < deadline) { + const state = await page.evaluate( + input => window.__salmoneggSmoke.semantic.comboBoxOpenState(input.automationId, input.beforeIds), + { automationId: selectorAutomationId, beforeIds }); + if (state?.aligned) { + return state; + } + + await page.waitForTimeout(200); } + await page.keyboard.press("Escape"); await page.waitForTimeout(200); } - if (!itemState) { + throw new Error( + `${label} never produced an aligned open state after ${attempt} attempts. ` + + `Semantic DOM=${JSON.stringify(await collectSemanticDebug(page))}`); +} + +async function closeComboBox(page) { + await page.keyboard.press("Escape"); + await page.waitForTimeout(200); +} + +// Opens the dropdown (retrying through the F4 race), reads the highlighted option's label, then +// closes it. The dropdown must end closed: a lingering popup would swallow the next gate's keys. +async function readComboBoxSelectionLabel(page, selectorAutomationId) { + const combo = await findComboBox(page, selectorAutomationId); + if (!combo?.found) { + throw new Error(`combo box '${selectorAutomationId}' was not found in the semantic DOM.`); + } + + const state = await openComboBoxAligned(page, selectorAutomationId, `combo box '${selectorAutomationId}'`); + const activeIndex = state.activeIndex; + if (activeIndex < 0 || activeIndex >= state.itemLabels.length) { + await closeComboBox(page); throw new Error( - `${label} did not expose any item from ${JSON.stringify(expectedNames)} after expanding. ` - + `Semantic DOM=${JSON.stringify(await collectSemanticDebug(page))}`); + `combo box '${selectorAutomationId}' open state has no highlighted option. ` + + `State=${JSON.stringify(state)}`); } - await page.evaluate( - input => window.__salmoneggSmoke.semantic.comboBoxItem(input.expectedNames)?.activate(), - { expectedNames }); + const selected = state.itemLabels[activeIndex]; + await closeComboBox(page); + return selected; +} + +// Keyboard-only selection: open (aligned), Home to reset to the first option, ArrowDown to the +// target, Enter to commit. Skia's item click only collapses the popup, so keys are the only +// commit path. The target name must match an item label exactly (case-insensitive) - a miss is +// an error, never a silent no-op. +export async function selectComboBoxItem(page, selectorAutomationId, expectedVisibleName, options = {}) { + const expectedNames = Array.isArray(expectedVisibleName) + ? expectedVisibleName + : [expectedVisibleName]; + const label = `combo box '${selectorAutomationId}'`; + + const state = await openComboBoxAligned(page, selectorAutomationId, label); + const targetIndex = state.itemLabels.findIndex( + item => expectedNames.some(name => name.toLowerCase() === item.toLowerCase())); + if (targetIndex < 0) { + await closeComboBox(page); + throw new Error( + `${label} open state exposed ${JSON.stringify(state.itemLabels)}; ` + + `none matched ${JSON.stringify(expectedNames)}.`); + } + + await page.keyboard.press("Home"); + await page.waitForTimeout(COMBO_SETTLE_MS); + for (let i = 0; i < targetIndex; i += 1) { + await page.keyboard.press("ArrowDown"); + await page.waitForTimeout(100); + } + await page.keyboard.press("Enter"); + await closeComboBox(page); if (options.verifySelectionText !== false) { - await expectComboBoxSelectionText(page, selectorAutomationId, expectedNames, label); + const observed = await readComboBoxSelectionLabel(page, selectorAutomationId); + if (!expectedNames.some(name => name.toLowerCase() === observed.toLowerCase())) { + throw new Error( + `${label} selection read back as ${JSON.stringify(observed)}, expected ${JSON.stringify(expectedNames)}.`); + } } } +// Reads the selection by reopening the dropdown and checking the highlighted option's label. +// Despite the legacy name, no collapsed-state text is read - Skia mirrors none. export async function expectComboBoxSelectionText(page, selectorAutomationId, expectedVisibleNames, label) { const expectedNames = Array.isArray(expectedVisibleNames) ? expectedVisibleNames : [expectedVisibleNames]; - const deadline = Date.now() + 10_000; - let observedText = null; + const deadline = Date.now() + defaultTimeoutMs; + let observed = null; + let lastError = null; while (Date.now() < deadline) { - observedText = await readComboBoxSelectionText(page, selectorAutomationId); - if (observedText !== null - && expectedNames.some(name => observedText.toLowerCase().includes(name.toLowerCase()))) { - return; + try { + observed = await readComboBoxSelectionLabel(page, selectorAutomationId); + if (expectedNames.some(name => name.toLowerCase() === observed.toLowerCase())) { + return; + } + } catch (error) { + lastError = error; } await page.waitForTimeout(200); } throw new Error( - `${label ?? `combo box '${selectorAutomationId}'`} did not show one of ${JSON.stringify(expectedNames)}. ` - + `Observed=${JSON.stringify(observedText)} ` + `${label ?? `combo box '${selectorAutomationId}'`} selection never read back as ` + + `${JSON.stringify(expectedNames)}. Last observed=${JSON.stringify(observed)} ` + + `Last error=${String(lastError)} ` + `Semantic DOM=${JSON.stringify(await collectSemanticDebug(page))}`); } export async function readComboBoxSelectionText(page, selectorAutomationId) { - return await page.evaluate( - automationId => window.__salmoneggSmoke.semantic.comboBoxSelectionText(automationId), - selectorAutomationId); + return await readComboBoxSelectionLabel(page, selectorAutomationId); } // ---- focus ----------------------------------------------------------------------------------- From a8b23f2d8e12ff91db34977604aa192b9b923942 Mon Sep 17 00:00:00 2001 From: Shangxin Date: Sat, 5 Sep 2026 01:25:27 +0000 Subject: [PATCH 03/14] test(gates): assert the report card's modal round-trip, not the notice'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) --- .../gates/wasm-smoke-lib/ui-affordances.mjs | 112 +++++++++++++++++- scripts/gates/wasm-start-visibility-smoke.mjs | 56 +++++++-- 2 files changed, 150 insertions(+), 18 deletions(-) diff --git a/scripts/gates/wasm-smoke-lib/ui-affordances.mjs b/scripts/gates/wasm-smoke-lib/ui-affordances.mjs index 22edcb0c..70a67470 100644 --- a/scripts/gates/wasm-smoke-lib/ui-affordances.mjs +++ b/scripts/gates/wasm-smoke-lib/ui-affordances.mjs @@ -65,6 +65,29 @@ export async function expectControlEnabledState(page, options, expectedEnabled, } } +// Polls until the control's enabled state flips to the expected value. Skia renders the app into a +// canvas, so "a dialog opened" is not observable as text or DOM - but modality is observable as +// state: the semantic tree marks the page's controls disabled while the dialog is up and re-enables +// them once it is dismissed. Waiting on that flip is how a smoke asserts dialog round-trips without +// depending on how (or whether) the dialog itself is rendered. +export async function waitForControlEnabledState(page, options, expectedEnabled, label, timeoutMs = defaultTimeoutMs) { + const deadline = Date.now() + timeoutMs; + let lastState = notFoundState; + + while (Date.now() < deadline) { + lastState = await readControlState(page, options); + if (lastState.found && lastState.enabled === expectedEnabled) { + return lastState; + } + + await page.waitForTimeout(200); + } + + throw new Error( + `Timed out waiting for ${label} to become enabled=${expectedEnabled}. Last state=${JSON.stringify(lastState)} ` + + `Semantic DOM=${JSON.stringify(await collectSemanticDebug(page))}`); +} + // Naming note: this used to scroll - activation needed a hit-testable point, so out-of-viewport // controls had to be dragged into one first, and callers distinguished "found" from "scrolled" by // return value. Semantic activation has no such requirement, so what remains is waiting for the @@ -128,6 +151,74 @@ export async function clickVisibleNavigationTarget(page, options) { return await activateWhenReady(page, options, describeTarget(options), defaultTimeoutMs); } +// A real Playwright mouse click at the semantic node's center. The two synthetic routes both fail +// here and need different medicine: +// - A raw locator click can never pass Playwright's actionability check: Uno bakes no `role` +// attribute into semantic elements (the `