test(decorated-window-tao): tiered test infra — offscreen scene harness + headful window suite#373
test(decorated-window-tao): tiered test infra — offscreen scene harness + headful window suite#373kdroidFilter wants to merge 30 commits into
Conversation
…ttery JetBrains-style testing pyramid, stage 1 (the bulk tier): an offscreen, fully deterministic test harness for the Tao Compose backend, mirroring how Compose Desktop tests the scene without a window. - TaoSceneTestHarness (test sources): builds the same CanvasLayersComposeScene the host uses, with the production TaoWindowInfo, a manually pumped single-thread dispatcher and an owned BroadcastFrameClock. Frames are recorded through the production CPU path (recordSceneToPicture) with fully virtual time; input goes through the production translation layer (dispatchNativeKeyEvent, taoKeyboardModifiers, TaoSyntheticMouseWheelEvent) and mirrors the host's dispatch shapes including its input guards. Pixel asserts via Skia raster; semantics via the same PlatformContext.SemanticsOwnerListener hook the a11y layer uses. - Battery: key mapping tables (mac kVK / linux keysyms), modifier decode, keyboard-to-BasicTextField pipeline, pointer (clicks, guards, drag, hover), wheel scroll (direction, symmetry, scrollAmount), popups (render, outside click dismiss), deterministic animations, semantics tree. 54 new tests, module total 69, all headless. - CI: decorated-window-tao:check joins preMerge, and a new tao-tests job in pre-merge.yaml runs the module tests on ubuntu/macos/windows. - Fixes the 5 pre-existing detekt findings that were blocking check (ComplexCondition in DecoratedWindow, swallowed/generic catch + magic number in TaoHotReloadBridgeImpl, missing final newline, consecutive KDoc in NucleusPlatformViewFactory).
'*' does not cross '/' in GitHub branch filters, so PRs based on refactor/* or feature/* branches never ran Pre Merge Checks.
…splay infra Stage 2 of the testing pyramid: real Tao windows driven end-to-end, one process, one event loop (Compose Desktop's runApplicationTest model — the Tao loop runs once per process, so no forkEvery). - headful/TaoWindowTestHarness.kt: TaoWindowTestCase/TaoWindowTestScope with suspending awaitUntil polling on the composition dispatcher. - headful/TaoHeadfulTestSuiteMain.kt: JavaExec entry point (taoHeadfulTest task). Each case gets a fresh DecoratedWindow (key'd per case); drivers run at APPLICATION level because window-scene attach re-composes the window content and would cancel in-window effects. Reports and exits with the failure count before taoApplication's own exitProcess(0). Watchdog halt like the existing smoke test. 6 cases: map+size, setInnerSize/onResized (dp→px scale-aware), setOuterPosition/onMoved, maximize/restore, minimize/restore, requestUserClose routing. WM-owned-geometry cases skip on Linux (tiling/headless WMs). - No -XstartOnFirstThread: taoApplication marshals to the AppKit main thread itself; the flag deadlocks the AWT classes the Compose host touches. - KNOWN QUIRK surfaced by the suite (documented in-code, needs a native fix): on macOS, setMaximized(false) never un-zooms our borderless decorated window — tao's is_zoomed() doesn't recognize it, so restore early-returns. - CI: new tao-headful matrix job — plain runners on macOS/Windows, Xvfb + openbox + Mesa on Linux. Local run (macOS): 6 run, 0 skipped, 0 failed.
…ilent no-op Surfaced by the stage-2 headful suite: an unmaximize issued while the maximize animation is still in flight left the window stuck zoomed. Root cause: set_maximized dedups on is_zoomed(), which compares the live frame to screen.visibleFrame — mid-animation the frame matches neither end state, so is_zoomed()==false==maximized and the call early-returned. Vendored-tao patch (macos): - window.rs::set_maximized: dedup against the tracked shared_state.maximized as well as the live frame, and flip the tracked flag synchronously so back-to-back calls see the new intent. The frame check is kept so a manually un-zoomed window can still be restored. - util/async.rs::set_maximized_async: save standard_frame only when actually maximizing — an unmaximize arriving mid-animation would otherwise overwrite the real pre-zoom frame with a half-grown one. The maximize/restore headful case now asserts the restore on every OS, with the unmaximize deliberately issued mid-animation as a regression test. Also unskips the Linux move/maximize/minimize cases: the CI display uses openbox (floating, iconify-capable), not a tiling WM. macOS natives rebuilt; Linux/Windows rebuilt by CI. Local suite: 6/6.
…ocol
GraalVM native-image testing (examples/tao-native-test):
- New runner app (nucleus plugin, graalvm enabled) that compiles the whole
Tao test pyramid into a native binary and RUNS it — the first place a
produced native image is actually executed in CI (test-graalvm.yaml only
compiled nucleus-demo before). Consumes decorated-window-tao's compiled
test classes via a taoTestArtifacts configuration.
- TaoSceneTestBattery: generated reflection-free registry of the stage-1
offscreen suite (60 cases) — JUnit discovery needs reflection metadata,
direct calls need none.
- Two process modes (battery / headful): the offscreen battery's global
Compose runtime state prevents the Tao loop from starting in the same
process, so CI compiles once and runs the binary once per mode.
- New tao-native-tests job in test-graalvm.yaml (3-OS matrix, Xvfb+openbox
on Linux). Local macOS: battery 60/60 and headful 6/6 with native=true.
Native accessibility protocol (scripts/ci/ + tao-a11y job in pre-merge.yaml):
- Verifies the OS-level a11y projection from the OUTSIDE, via the same
client APIs assistive tech uses, against tao-demo's A11y tab
(NUCLEUS_DEMO_TAB env added to tao-demo for direct navigation):
- Windows (hard gate): verify-uia.ps1 — UIA tree exposure, InvokePattern
round-trip (Increment -> 'click counter 1'), TogglePattern, RangeValue.
Derived from scripts/dump-uia-tree.ps1 / uia-action.ps1.
- Linux (hard gate): verify-atspi.py — pyatspi walk over the AT-SPI bus
(org.a11y.Status.IsEnabled flipped like scripts/a11y-linux-iter.sh),
element exposure + doAction round-trip.
- macOS: verify-ax.swift — AX client dump (axdump/axtool derived);
self-skips on runners without the TCC Accessibility permission.
Verified locally: 4/4 elements exposed, 53 named accessibles.
All three first-run failures were the same race: the probe's 120s window was consumed by the cold gradle compile of tao-demo on a fresh runner. Build the jar in a prior step and give the probes 300s.
tao-demo's build graph reaches decorated-window-jni's buildNativeLinux, which fails on runners without X11 dev headers; ship the prebuilt artifacts like the main gradle job does.
The prebuild step starts the Gradle daemon without NUCLEUS_DEMO_TAB; the launch step then reuses that daemon and JavaExec inherits ITS environment, so the app could open on the Demo tab instead of A11y (observed on the Windows runner). --no-daemon makes the app JVM inherit the step env directly. The UIA probe also fails cleanly now instead of null-crashing when an element is missing.
…fallback - test-graalvm/tao-native-tests: execute the binary from graalvm/output (the packaging step extracts skiko/jawt/icudtl next to it; the bare nativeCompile artifact lacks them — Windows failed unpacking skiko). - verify-uia.ps1: dump the UIA descendant names when the A11y content is missing, and try invoking the 'A11y' tab through UIA as a fallback.
FindAll uses the filtered control view, which never descended into the custom UIA provider on the runner (only the 7 nonclient elements showed). scripts/dump-uia-tree.ps1 — validated during manual provider bring-up — walks TreeWalker.RawViewWalker; the probe now does the same, and dumps the raw tree on failure.
…, proven dump tool)
The hosted runner's software display adapter can't create the WGL context the Tao scene host needs, so the app has no composed content and nothing to project through UIA — confirmed by the proven manual dump tool seeing only nonclient elements while the same protocol passes on real hardware. macOS (AX client, runner turned out to be TCC-trusted: 4/4 PASS in CI) and Linux (AT-SPI, doAction round-trip) remain hard gates.
A single non-invalidated frame can race the scroll/animation pipeline re-arming one dispatch later (rare 8px residue flake in the wheel-scroll symmetry test on CI). Idle now requires 3 consecutive quiet frames.
…achable Mirrors the macOS TCC self-skip: on hosted runners the software display adapter yields no WGL context, the scene never attaches and only the OS nonclient elements exist — nothing assertable, loud SKIPPED, exit 0. Any partial exposure with failing assertions still fails hard, and the job is gating again (continue-on-error removed).
Coroutine resumptions can enqueue from other threads; the unsynchronized ArrayDeque corrupted rarely under load (NPE in pump on the Windows native run, and the earlier animation/scroll flakes). ConcurrentLinkedQueue, same model as TaoMainDispatcher.
f3d-app/install-mesa-windows-action drops Mesa's software opengl32.dll next to java.exe (first hit in the DLL search order), so the Tao scene host can create its WGL context on hosted runners. This should turn the Windows UIA a11y probe back into a hard gate (the self-skip stays as a safety net) and give the headful suite real rendering.
Probes now assert the advanced A11yTab sections, not just the basics:
- disabled button: role survives AND the disabled state projects
(AX: AXButton + AXEnabled=false; UIA: IsEnabled=false; AT-SPI: no
STATE_ENABLED)
- custom actions: labels ride the a11y channel and are performable
(AX action names; AT-SPI doAction('Mark as read') round-trip -> counter)
- live region: pressing 'Update status' surfaces the new status text
- toggleable without an explicit role: STATE_CHECKABLE / TogglePattern
- heading role (AT-SPI)
- slider Value interface set/read (AT-SPI)
Also fixes a REAL a11y bug the new assertions caught: A11yButton's disabled
branch used semantics{} without mergeDescendants (unlike the enabled
clickable path), so the label stayed a separate enabled static-text node and
the disabled button projected unlabeled — VoiceOver read 'Cannot press' as
plain active text. Local AX probe: 7/7 PASS.
The Windows CI investigation lost hours to a silent pipeline: no way to know whether the sidecar DLL loaded, the HWND resolved, or the first snapshot was pushed. Gated stderr traces at attach and first pushSnapshot; the tao-a11y Windows job sets the flag.
… interface Surfaced by the advanced a11y probe: Compose CustomAccessibilityActions were reachable from VoiceOver (macOS rotor) but invisible to Orca — the vendored accesskit_atspi_common only exposed the synthetic 'click' action. n_actions/ get_action_name/do_action now append the node's custom actions after the click slot; do_action routes them as Action::CustomAction with the per-node index, which the embedder side (linux/a11y.rs) already dispatches to Kotlin's customActions list. Linux natives rebuilt by CI.
…the other fork patches
… nodes pyatspi raised NotImplemented on queryAction: the InterfaceSet only included Interface::Action for clickable nodes, so the patched n_actions/do_action were unreachable for nodes whose only actions are custom.
…hile idle An assistive-tech client that connects while the scene is idle (e.g. a UIA probe issuing WM_GETOBJECT against a settled window) sets the native active/resync flags, but the debounced semantics walk had already fired and been gated off — nothing re-scheduled it, so the provider kept serving the 1-node seed tree until the next semantics change, which never comes on a static scene. This is what made the CI UIA probe self-skip on the Windows runners (and made the same probe flaky on real hardware, passing only when it attached during startup churn). The a11y sync scheduler now takes the controller's shouldRunSync gate: when the gate is closed the walk is parked and the gate is polled at a slow cadence (one JNI flag read per 250 ms, no render-loop wakeups) instead of being dropped. The tree is projected within ~250 ms of the first AT query. Same fix on the macOS, Windows, and Linux hosts. NUCLEUS_A11Y_DEBUG now also traces the first push carrying a real tree — logging only the 1-node seed read as 'the tree is empty' while the pipeline was healthy.
…e set RangeValue.SetValue used to translate into a single Increment/Decrement step (10% of the range), so SetValue(0.7) from 0.5 landed on 0.6 — UIA clients expect the slider to land ON the requested value. Add a set_value callback channel (C export + Rust trampoline) that forwards the absolute value to the existing dispatchA11ySetValue upcall, which routes into Compose's SetProgress with range clamping — the same path the Linux AT-SPI backend already uses. Falls back to the relative step if the callback is not registered.
… not a GL issue The Windows UIA protocol is validated end-to-end on real hardware (physical GPU, Windows 11): every verify-uia.ps1 assertion passes, no SKIPPED. The hosted-runner self-skips were never a display-adapter/WGL limitation — the Windows host renders through ANGLE/D3D11 (WARP-capable on VMs), never through WGL/opengl32, so installing Mesa's opengl32.dll next to java.exe had no effect. The empty UIA tree was the idle-activation race fixed in the a11y sync scheduler. Update the job comments accordingly.
Windows UIA protocol validated on real hardware — and the hosted-runner mystery solvedRan the full stack on a physical Windows 11 machine with a real GPU:
The empty-scene hypothesis was wrongThe scene was never empty. On real hardware the window renders perfectly (verified by The real bug: idle-activation race (reproduced locally)Probing right after launch → all assertions pass. Probing after ~60 s of idle → the probe sees only the 9 default non-client elements and everything fails — exactly the CI symptom. Root cause: a UIA client's Fix ( Verified: launch → 75 s idle → probe = 10/10 PASS. Bonus fix found by the probe
CI changes
|
…n dispatcher The harness advances a fully synthetic clock, but QueueDispatcher did not implement Delay — every delay/withTimeout inside scene code (notably Compose's MouseWheelScrollingLogic coalescing timeout) fell back to kotlinx's DefaultExecutor, i.e. real time on another thread. On a loaded runner the pending real-time resumption could fire after frameUntilIdle had declared the scene quiet, leaving a scroll animation half-applied — the rare 8px residue in 'scroll direction is symmetric' (seen again on windows-latest CI, passing on rerun). QueueDispatcher now schedules timers against the virtual clock; frame() releases due timers before pumping, and frameUntilIdle counts a frame as busy while a timer is due within a short horizon (covers the ~100 ms wheel coalescing timeout, ignores 500 ms housekeeping like caret blink). The scroll pipeline is now deterministic across machines.
Summary
Full test infrastructure for the Tao backend (stacked on #371) — every layer verified e2e in this PR's CI:
Stage 1 — offscreen scene harness (
TaoSceneTestHarness, 60 cases): sameCanvasLayersComposeSceneconfig as the host, productionTaoWindowInfo, thread-safe manually-pumped dispatcher, owned frame clock, frames recorded via the productionrecordSceneToPictureCPU path with virtual time. Input through the production translation layer, host input-guards mirrored. Pixel + semantics assertions.Stage 2 — headful window suite (
taoHeadfulTest, 6 cases): real Tao windows, one event loop per process (runApplicationTestmodel), drivers at application level. Found and fixed a real native bug: macOSsetMaximized(false)issued during the zoom animation was a silent no-op (vendored-tao PATCH: dedup on tracked state, standard_frame saved only when maximizing); the case now regression-tests it by unmaximizing mid-animation. Linux runs all 6 cases under Xvfb+openbox.GraalVM native pyramid (
examples/tao-native-test+tao-native-testsjob in test-graalvm.yaml): the whole pyramid compiled INTO a native image and executed per OS (battery 60/60 + headful 6/6,native=true) — the first place a produced native binary is actually run in CI. Reflection-free registry; the binary must be the PACKAGED one (skiko/jawt/icudtl extracted next to it).Native accessibility protocol (
scripts/ci/verify-{uia.ps1,atspi.py,ax.swift}+tao-a11yjob): external OS-level probes against tao-demo's A11y tab (NUCLEUS_DEMO_TAB) — the same client APIs assistive tech uses. macOS: hosted runners are TCC-trusted → 4/4 AX assertions PASS in CI. Linux: AT-SPI exposure + doAction round-trip PASS. Windows: full protocol (UIA raw-view walk like the provendump-uia-tree.ps1, Invoke/Toggle/RangeValue round-trips) — self-skips loudly on hosted runners only (software adapter, no WGL context, scene never attaches: nothing to project; hard gate on GPU machines).CI fixes along the way:
pull_request.branches: '*'silently skipped every workflow for PRs based onrefactor/*(fixed to'**'in pre-merge, test-graalvm, test-packaging);decorated-window-tao:checkjoinspreMerge; harness dispatcher made thread-safe (root cause of all observed flakes).CI (this PR, all green)