Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions .github/workflows/native-ui.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
name: Native UI End-to-End

on:
pull_request:
workflow_dispatch:

permissions:
contents: read

concurrency:
group: native-ui-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true

jobs:
linux:
name: GTK4 accessibility and interaction
runs-on: ubuntu-24.04
timeout-minutes: 25
steps:
- uses: actions/checkout@v7
- name: Set up embeddable Racket CS
uses: ./.github/actions/setup-embed-racket
- name: Install Rivet and native UI test dependencies
shell: bash
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install --yes \
at-spi2-core dbus-x11 libgtk-4-dev libsecret-1-dev \
python3-pyatspi weston
raco pkg install --auto --no-docs --name rivet --link "$PWD"
- name: Package and exercise the GTK4 Taskboard on native Wayland
shell: bash
run: |
set -euo pipefail
cd examples/taskboard
raco rivet package --skip-launch-smoke
dbus-run-session -- \
"$GITHUB_WORKSPACE/tests/native-ui/linux-session.sh" \
"$PWD/dist/rivet-taskboard-linux-x64/RivetHost" \
"$RUNNER_TEMP/rivet-native-ui-linux"
- name: Upload Linux native UI evidence
if: always()
uses: actions/upload-artifact@v6
with:
name: native-ui-linux
path: ${{ runner.temp }}/rivet-native-ui-linux
if-no-files-found: warn

windows:
name: WinUI 3 accessibility and interaction
runs-on: windows-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v7
- name: Set up Racket CS
uses: Bogdanp/setup-racket@v1.15
with:
architecture: x64
distribution: full
variant: CS
version: '9.3'
- name: Install Rivet
shell: pwsh
run: raco pkg install --auto --no-docs --name rivet --link "$PWD"
- name: Package and exercise the WinUI Taskboard
shell: pwsh
run: |
Set-Location (Join-Path $env:GITHUB_WORKSPACE 'examples\taskboard')
raco rivet package --skip-launch-smoke
& (Join-Path $env:GITHUB_WORKSPACE 'tests\native-ui\windows-uia.ps1') `
-Executable (Join-Path $PWD 'dist\rivet-taskboard-windows-x64\RivetHost.exe') `
-OutputDirectory (Join-Path $env:RUNNER_TEMP 'rivet-native-ui-windows')
- name: Upload Windows native UI evidence
if: always()
uses: actions/upload-artifact@v6
with:
name: native-ui-windows
path: ${{ runner.temp }}/rivet-native-ui-windows
if-no-files-found: warn

macos:
name: SwiftUI accessibility and interaction
runs-on: macos-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v7
- name: Set up Racket CS
uses: Bogdanp/setup-racket@v1.15
with:
architecture: ${{ runner.arch == 'ARM64' && 'arm64' || 'x64' }}
distribution: full
variant: CS
version: '9.3'
- name: Install Rivet
run: raco pkg install --auto --no-docs --name rivet --link "$PWD"
- name: Package and exercise the SwiftUI Taskboard
shell: bash
run: |
set -euo pipefail
output="$RUNNER_TEMP/rivet-native-ui-macos"
driver="$RUNNER_TEMP/rivet-macos-ax"
mkdir -p "$output"
swiftc tests/native-ui/macos-ax.swift -o "$driver" \
-framework AppKit -framework ApplicationServices
cd examples/taskboard
raco rivet package --skip-launch-smoke
app="$PWD/dist/rivet-taskboard.app/Contents/MacOS/rivet-taskboard"
"$app" >"$output/application.log" 2>&1 &
app_pid=$!
stop_bounded() {
local pid="$1"
kill -TERM "$pid" 2>/dev/null || true
for _ in $(seq 1 200); do
kill -0 "$pid" 2>/dev/null || return 0
sleep 0.05
done
kill -KILL "$pid" 2>/dev/null || true
return 1
}
cleanup() {
local status=$?
trap - EXIT
if kill -0 "$app_pid" 2>/dev/null; then
stop_bounded "$app_pid" || true
wait "$app_pid" 2>/dev/null || true
fi
exit "$status"
}
trap cleanup EXIT
"$driver" --pid "$app_pid" --output "$output"
for _ in $(seq 1 200); do
kill -0 "$app_pid" 2>/dev/null || break
sleep 0.05
done
if kill -0 "$app_pid" 2>/dev/null; then
echo "Taskboard did not exit within ten seconds of native window close" >&2
exit 1
fi
wait "$app_pid"
printf '{"nativeWindowClosed":true,"backendStopped":true,"processExited":true}\n' \
>"$output/shutdown-trace.json"
trap - EXIT
- name: Upload macOS native UI evidence
if: always()
uses: actions/upload-artifact@v6
with:
name: native-ui-macos
path: ${{ runner.temp }}/rivet-native-ui-macos
if-no-files-found: warn
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ dist/
.swiftpm/
DerivedData/

# Python test tooling
__pycache__/
*.py[cod]

# NuGet / Windows
packages/
AppPackages/
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,8 @@ platform/android/gradlew -p platform/android test

CI runs the protocol implementation across Racket, C++, Swift, and Kotlin; exercises real embedded Racket round trips on all three desktop platforms; opens the packaged GTK4 host through a headless native Wayland compositor; cross-compiles the portable Swift layers for iOS/watchOS; compiles the generated Kotlin client; smoke-builds, packages, and verifies generated desktop applications; and covers Windows ARM64, macOS Intel, and Linux ARM64 on dedicated clean-runner architecture gates.

The maintained Taskboard also has platform-native [UI end-to-end gates](docs/native-ui-testing.md): UI Automation on WinUI 3, AXUIElement on SwiftUI/AppKit, and AT-SPI on GTK4/Wayland assert semantic roles, RPC/State/Event behavior, screenshots, and orderly close without introducing a shared UI abstraction.

## Honest gaps

- **Linux desktop qualification is intentionally bounded** — the release matrix proves native Wayland, package-manager lifecycle, AppImage launch, and system-adapter behavior on maintained distributions. Product-specific GPU, portal, scaling, input, GNOME, and KDE behavior still belongs in each application's release evidence. X11/XWayland is best-effort compatibility, not a release gate.
Expand Down
7 changes: 7 additions & 0 deletions docs/ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,13 @@ Wayland gate: `DISPLAY` is unset, so the application cannot silently pass by
using X11 or XWayland. Products supporting Linux should keep an equivalent
Wayland launch in their release matrix; see [Linux display policy](linux-display.md).

The separate `Native UI End-to-End` workflow packages the maintained Taskboard
and drives its real WinUI 3, SwiftUI/AppKit, and GTK4 accessibility surfaces.
It covers activation, an RPC mutation, an Event, State rendering, and orderly
close while retaining bounded failure evidence. See
[native UI end-to-end testing](native-ui-testing.md) for the application-level
extension contract.

For a security-sensitive product, replace `@main` with the Rivet tag or full
commit SHA used by the application. The action downloads the official minimal
Racket CS source archive, verifies its SHA-256, builds and caches the static
Expand Down
6 changes: 6 additions & 0 deletions docs/ci.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,12 @@ SHA。该 action 会下载官方 minimal Racket CS 源码归档、校验 SHA-256
action 固定到同一 revision。用一个 Rivet revision 编译宿主、再用另一个
revision 发布,不构成有效门禁。

仓库中的 `Native UI End-to-End` 工作流还会打包维护中的 Taskboard,并分别
通过 WinUI 3 的 UI Automation、SwiftUI/AppKit 的 AXUIElement 和 GTK4 的
AT-SPI 检查真实原生界面。门禁覆盖窗口激活、一次 RPC 变更、一次 Event、
State 渲染与有序关闭;失败时保留有界的无障碍快照、交互轨迹、截图和日志。
应用如何复用这套模式,见[原生 UI 端到端测试](native-ui-testing.md)。

## 发布门禁

推送标签前,应要求上述所有原生宿主任务和产品自己的 Racket 测试通过。
Expand Down
47 changes: 47 additions & 0 deletions docs/native-ui-testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Native UI end-to-end testing

Rivet keeps UI tests native for the same reason it keeps production UI native:
the platform accessibility and interaction stacks are part of the product.
The reference Taskboard is exercised through UI Automation on Windows,
AXUIElement on macOS, and AT-SPI on Linux. No shared widget DSL or synthetic
Rivet view tree sits between the test and WinUI 3, SwiftUI/AppKit, or GTK4.

## Behavioral contract

The `Native UI End-to-End` workflow packages the application before testing it
and requires each platform driver to prove all of the following:

1. a real native window can be discovered and activated;
2. stable semantic names/identifiers and roles are exposed;
3. invoking `new-task` crosses the generated client and RVT1 boundary and the
resulting State reaches the native list;
4. invoking `generate-demo` makes an `operation-progress` Event visible through
the native accessibility surface;
5. the final 1,000-row State reaches the UI; and
6. native window close or `SIGTERM` completes the orderly backend shutdown.

Every run uploads the bounded interaction trace and accessibility snapshots.
It also captures a screen image when the runner supports the platform capture
API, plus application/compositor logs on Linux. The evidence is uploaded with
`if: always()` so a failed assertion remains diagnosable.

## Application extension point

Product applications own their views, so they also own the identifiers and
scenario vocabulary. Keep those identifiers in native source:

- WinUI: `AutomationProperties.AutomationId` and
`AutomationProperties.Name`;
- SwiftUI/AppKit: `.accessibilityIdentifier`, labels, and native roles; and
- GTK4: `GtkAccessible` properties and meaningful widget roles.

Copy the matching driver under `tests/native-ui/`, replace the reference
identifiers and actions with one product-critical scenario, and add the driver
to a packaging job. A useful minimum scenario has one RPC-driven mutation, one
Event, a stable accessibility-tree assertion, and orderly close. Test-only
protocol messages and a cross-platform selector language are deliberately not
extension points.

Keep snapshots small: cap recursion depth, record semantic fields rather than
platform object dumps, and never include user data or secret values. Failure
artifacts should be retained only as long as the repository's normal CI logs.
4 changes: 3 additions & 1 deletion examples/taskboard/linux/src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,8 @@ int on_tasks_delivered(gpointer user_data) {
g_state.set_status("Backend error: " + delivered->error);
} else {
apply_tasks(std::move(delivered->tasks), delivered->preferred);
g_state.set_status("Saved by the Racket backend");
g_state.set_status("Loaded " + std::to_string(g_state.tasks.size()) +
" tasks");
}
return G_SOURCE_REMOVE;
}
Expand Down Expand Up @@ -548,6 +549,7 @@ void perform_orderly_shutdown() { on_shutdown(nullptr, nullptr); }
} // namespace

int main(int argc, char** argv) {
g_set_application_name("Rivet Taskboard");
auto* app = gtk_application_new("dev.rivet.taskboard", G_APPLICATION_DEFAULT_FLAGS);
g_signal_connect(app, "activate", G_CALLBACK(on_activate), nullptr);
g_signal_connect(app, "shutdown", G_CALLBACK(on_shutdown), nullptr);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,12 +95,12 @@ struct ContentView: View {
Text(model.status)
.font(.callout)
.lineLimit(2)
.accessibilityIdentifier("application-status")
Spacer()
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(.bar)
.accessibilityIdentifier("application-status")
}
.onChange(of: model.selectedID) { _, id in model.persistSelection(id) }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import RivetSystem

@main
struct RivetHostApp: App {
@NSApplicationDelegateAdaptor(ApplicationDelegate.self)
private var applicationDelegate
@StateObject private var model = AppModel()
private let activationRouter = RivetActivationRouter()

Expand All @@ -19,6 +21,24 @@ struct RivetHostApp: App {
}
}

@MainActor
final class ApplicationDelegate: NSObject, NSApplicationDelegate {
static var orderlyShutdown: (() -> Void)?

nonisolated func applicationShouldTerminateAfterLastWindowClosed(
_ sender: NSApplication
) -> Bool {
true
}

nonisolated func applicationShouldTerminate(
_ sender: NSApplication
) -> NSApplication.TerminateReply {
MainActor.assumeIsolated { Self.orderlyShutdown?() }
return .terminateNow
}
}

@MainActor
final class AppModel: ObservableObject {
@MainActor
Expand Down Expand Up @@ -72,6 +92,7 @@ final class AppModel: ObservableObject {
let backend = EmbeddedRacketBackend(configuration: config)
let relay = EventRelay(self)
self.backend = backend
ApplicationDelegate.orderlyShutdown = { [weak self] in self?.stop() }

Task.detached { [backend, relay] in
do {
Expand Down Expand Up @@ -161,7 +182,7 @@ final class AppModel: ObservableObject {
guard let self else { return }
self.tasks = items
self.selectedID = items.first?.id
self.status = "Generated 1,000 tasks"
self.status = "Generated \(items.count) tasks"
} catch is CancellationError {
self?.status = "Generation cancelled"
} catch {
Expand All @@ -176,6 +197,14 @@ final class AppModel: ObservableObject {
generationTask?.cancel()
}

func stop() {
generationTask?.cancel()
generationTask = nil
backend?.stop()
backend = nil
ready = false
}

func persistSelection(_ id: Int64?) {
guard let id, let backend, ready else { return }
Task {
Expand Down
9 changes: 9 additions & 0 deletions examples/taskboard/windows/App.xaml.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ App::App() {

void App::OnLaunched(Microsoft::UI::Xaml::LaunchActivatedEventArgs const&) {
window_ = winrt::make<MainWindow>();
window_.Closed([this](auto const&, auto const&) {
// A WinUI 3 desktop Application owns its dispatcher independently of its
// windows. Releasing the final Window reference is necessary, but it does
// not consistently end the dispatcher on hosted CI desktops. The window's
// Closed handler was registered first and has already stopped the backend,
// so explicitly terminate the application lifecycle here.
window_ = nullptr;
Microsoft::UI::Xaml::Application::Current().Exit();
});
window_.Activate();
}

Expand Down
9 changes: 8 additions & 1 deletion examples/taskboard/windows/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,15 @@
SelectionMode="Single"
IsEnabled="False"
SelectionChanged="TaskList_SelectionChanged"
ContainerContentChanging="TaskList_ContainerContentChanging"
AutomationProperties.Name="Tasks"
AutomationProperties.AutomationId="task-list" />
AutomationProperties.AutomationId="task-list">
<ListView.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding [Title]}" />
</DataTemplate>
</ListView.ItemTemplate>
</ListView>

<Border Grid.Column="1" Padding="28" BorderThickness="1" CornerRadius="8">
<StackPanel Spacing="14">
Expand Down
Loading
Loading