Skip to content

test(decorated-window-tao): tiered test infra — offscreen scene harness + headful window suite#373

Open
kdroidFilter wants to merge 30 commits into
refactor/tao-jetbrains-layoutfrom
test/tao-scene-harness
Open

test(decorated-window-tao): tiered test infra — offscreen scene harness + headful window suite#373
kdroidFilter wants to merge 30 commits into
refactor/tao-jetbrains-layoutfrom
test/tao-scene-harness

Conversation

@kdroidFilter

@kdroidFilter kdroidFilter commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

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): same CanvasLayersComposeScene config as the host, production TaoWindowInfo, thread-safe manually-pumped dispatcher, owned frame clock, frames recorded via the production recordSceneToPicture CPU 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 (runApplicationTest model), drivers at application level. Found and fixed a real native bug: macOS setMaximized(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-tests job 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-a11y job): 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 proven dump-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 on refactor/* (fixed to '**' in pre-merge, test-graalvm, test-packaging); decorated-window-tao:check joins preMerge; harness dispatcher made thread-safe (root cause of all observed flakes).

CI (this PR, all green)

  • gradle (preMerge) · tao-tests ×3 OS · tao-headful ×3 OS · tao-a11y ×3 OS · tao-native-tests ×3 OS

…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.
@kdroidFilter kdroidFilter changed the title test(decorated-window-tao): stage-1 offscreen scene test harness test(decorated-window-tao): tiered test infra — offscreen scene harness + headful window suite Jul 19, 2026
…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.
@kdroidFilter
kdroidFilter marked this pull request as ready for review July 19, 2026 06:06
…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.
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.
… 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.
@kdroidFilter

Copy link
Copy Markdown
Collaborator Author

Windows UIA protocol validated on real hardware — and the hosted-runner mystery solved

Ran the full stack on a physical Windows 11 machine with a real GPU:

Suite Result
:decorated-window-tao:test (offscreen battery) 60/60
:decorated-window-tao:taoHeadfulTest 6/6
GraalVM native runner (tao-native-test.exe battery / headful, packaged binary) 60/60 + 6/6
scripts/ci/verify-uia.ps1 against tao-demo 10/10 PASS, 0 SKIPPED (after the fixes below)

The empty-scene hypothesis was wrong

The scene was never empty. On real hardware the window renders perfectly (verified by PrintWindow capture — the A11y tab is fully composed), and the Windows host renders through ANGLE/D3D11 (WARP-capable on VMs), never through WGL/opengl32 — so the Mesa/llvmpipe install on the runners was a no-op by construction. first pushSnapshot: nodes=1 is normal everywhere: it's the forced seed push that fires before the first composition produces semantics. The debug trace now also logs the first push carrying a real tree (73 nodes on tao-demo).

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 WM_GETOBJECT sets the native active+resync flags, but the debounced semantics walk had already fired and been gated off (shouldRunSync false), and nothing re-scheduled it. On a static scene no further semantics change ever comes, so the provider serves the 1-node seed tree forever.

Fix (5cbbf1eb): the a11y sync scheduler now parks the gated walk and polls the activation gate at 250 ms (one JNI flag read per tick, no render-loop wakeups). The tree is projected within ~250 ms of the first AT query. Applied to all three hosts (the same race exists on macOS/Linux in principle).

Verified: launch → 75 s idle → probe = 10/10 PASS.

Bonus fix found by the probe

RangeValue.SetValue(0.7) from 0.5 landed on 0.6: the C provider translated SetValue into a single ±10% Increment/Decrement step. a670ffa9 adds a proper absolute set-value channel (C export + Rust trampoline → dispatchA11ySetValue → SetProgress with clamping, same path as Linux AT-SPI). Now lands on 0.7 exactly.

CI changes

b05152f6 removes the Mesa step from the tao-a11y Windows leg and rewrites the job comments. With the activation fix the Windows probe should now run its assertions on hosted runners instead of self-skipping — the self-skip stays as a safety valve.

check + apiCheck green locally (JBR 21, natives rebuilt via build.bat, loader cache purged).

…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.
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