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
45 changes: 44 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1223,13 +1223,56 @@ jobs:
cargo build --release --locked --target x86_64-apple-darwin
file target/x86_64-apple-darwin/release/obc-desktop

# Launch the existing Linux release artifact with its embedded frontend.
desktop-launch:
needs: [selection, desktop]
if: contains(fromJSON(needs.selection.outputs.jobs), 'desktop-launch')
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/download-artifact@v4
with:
name: obc-desktop-linux-x86_64
path: apps/obc-desktop/target/release
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache: false
- name: Install Linux launch dependencies
run: |
sudo apt-get update
webkit_version=$(apt-cache policy libwebkit2gtk-4.1-0 | awk '/Candidate:/ {print $2}')
sudo apt-get install -y "libwebkit2gtk-4.1-0=$webkit_version" "webkit2gtk-driver=$webkit_version" xvfb imagemagick dbus-daemon
pip install -r apps/obc-desktop/e2e/requirements.txt
cargo install tauri-driver --version 2.0.6 --locked
chmod +x apps/obc-desktop/target/release/obc-desktop
dpkg-query -W libwebkit2gtk-4.1-0 webkit2gtk-driver xvfb
- name: Launch the release frontend and select a catalog region
id: launch
env:
OBC_DESKTOP_EVIDENCE: ${{ runner.temp }}/desktop-launch
run: xvfb-run -a dbus-run-session -- python3 apps/obc-desktop/e2e/launch.py
- name: Upload Linux launch evidence
if: ${{ !cancelled() && (steps.launch.outcome == 'success' || steps.launch.outcome == 'failure') }}
uses: actions/upload-artifact@v4
with:
name: desktop-launch-linux-${{ github.run_attempt }}
path: ${{ runner.temp }}/desktop-launch
if-no-files-found: error
retention-days: 7

# Single aggregate gate: make THIS the required status check on main/develop. It reports every
# planned suite against the jobs the plan routed it to, so a job the plan never selected is
# "not selected" and a selected job that was skipped is a failure. A failed `selection` job
# publishes no plan, which fails this gate rather than silently passing.
ci:
if: always()
needs: [selection, retired-map-stack, card-scheduler-guard, render-key-guard, catalog-ownership-guard, retention-ownership-guard, one-home-guard, screen-vocabulary-guard, fixture-registry, fmt, clippy, test, test-weather, embedded, boot, device, deny, wasm, wasm-bridges, docs, ios-unit, ios-app, web, desktop-frontend, desktop]
needs: [selection, retired-map-stack, card-scheduler-guard, render-key-guard, catalog-ownership-guard, retention-ownership-guard, one-home-guard, screen-vocabulary-guard, fixture-registry, fmt, clippy, test, test-weather, embedded, boot, device, deny, wasm, wasm-bridges, docs, ios-unit, ios-app, web, desktop-frontend, desktop, desktop-launch]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand Down
38 changes: 38 additions & 0 deletions apps/obc-desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,44 @@ npm test
npm run build:all
```

## Linux release-launch test

The `desktop-launch` CI job downloads the release executable from `desktop`. Its embedded
frontend is the existing `obc-desktop-frontend` build. The test opens the real window under
Xvfb, checks its `tauri://localhost` origin, searches for Switzerland, and adds it to the map.
A loopback catalog serves the producer's example fine-band cells with a 994 B price. The
Rust catalog commands must fetch the root and all pinned index and region documents before
the test can pass. No map download or device command is selected.

On a Linux test host with no OBC device attached, first build the release app as above. Then,
from the repository root:

```sh
sudo apt-get install webkit2gtk-driver xvfb imagemagick dbus-daemon
cargo install tauri-driver --version 2.0.6 --locked
python3 -m venv .venv
. .venv/bin/activate
pip install -r apps/obc-desktop/e2e/requirements.txt
xvfb-run -a dbus-run-session -- python3 apps/obc-desktop/e2e/launch.py
```

Use a WebKit driver with the same version as the installed WebKitGTK runtime. CI installs an
exactly matched pair and records both package versions. Selenium is pinned in the requirements
file. The setup follows the [Tauri WebDriver CI guide](https://v2.tauri.app/develop/tests/webdriver/ci/).

`OBC_DESKTOP_BINARY` can name an existing release executable. Evidence goes to
`target/desktop-launch`, or `OBC_DESKTOP_EVIDENCE`: `result.json`, native catalog request logs,
application and driver logs, rendered HTML, and a screenshot. A failed journey also captures
`failure.png` when a webview session is available. CI uploads `desktop-launch-linux-ATTEMPT`
after an executed success or failure. ImageMagick captures the X11 display if the session fails
before WebDriver can take a screenshot. `dbus-run-session` gives the app and desktop portal
services a private session bus with the Xvfb display. The suite uses bounded state waits with no retries and
requires the app process to exit when its WebDriver session closes.

This suite covers Linux software launch and catalog integration. It does not establish USB
permissions, enumeration with a physical device, or route upload. Those checks remain in #994.
Windows launch is not automated here. Tauri's native WebDriver route does not support macOS.

## Files and storage

Each completed assembly is a uniquely named folder below
Expand Down
192 changes: 192 additions & 0 deletions apps/obc-desktop/e2e/launch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""Launch the embedded Linux frontend and select a local catalog region."""

from contextlib import suppress
import hashlib
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import os
from pathlib import Path
import shlex
import signal
import socket
import subprocess
import sys
from tempfile import TemporaryDirectory
from threading import Thread
import traceback
from urllib.parse import urlsplit

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.client_config import ClientConfig
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.webkitgtk.options import Options

ROOT = Path(__file__).resolve().parents[3]


def catalog_fixture():
"""Keep the producer's fine-band example; make the other bands empty."""
examples = ROOT / "host/obc-pack/schema"
catalog = json.loads((examples / "catalog.example.json").read_text())
fine = json.loads((examples / "cell-index.example.json").read_text())
region_cells = json.loads((examples / "region-cells.example.json").read_text())
objects = {}

def pin(name, document):
body = json.dumps(document).encode()
digest = hashlib.sha256(body).hexdigest()
path = f"/{name}.{digest}.json"
objects[path] = body
return {"url": path, "bytes": len(body), "sha256": digest}

catalog.pop("terrain", None)
catalog.pop("network_terrain_revision", None)
for ref in catalog["cell_index"]:
doc = fine if ref["band"] == "fine" else {
"schema_version": 2, "schema_revision": catalog["schema"]["revision"],
"band": ref["band"], "cells": [], "known_empty": [],
}
ref.update(pin(ref["band"], doc))
ref.update(cell_count=len(doc["cells"]), known_empty_count=len(doc["known_empty"]))
region_cells["cells"] = {"fine": region_cells["cells"]["fine"]}
region_cells.pop("terrain", None)
region = catalog["regions"][0]
region.pop("terrain", None)
region.update(bytes=sum(cell["bytes"] for cell in fine["cells"]),
bytes_by_band={"fine": 994}, cell_count={"fine": 3},
partial_cell_count_by_band={"fine": 0})
pinned = pin("region", region_cells)
region.update({f"cells_{key}": value for key, value in pinned.items()})
catalog["regions"] = [region]
objects["/catalog.json"] = json.dumps(catalog).encode()
return objects


def main():
if sys.platform != "linux":
raise SystemExit("The release-launch suite requires Linux and Xvfb.")
binary = Path(os.environ.get("OBC_DESKTOP_BINARY", ROOT / "apps/obc-desktop/target/release/obc-desktop")).resolve()
evidence = Path(os.environ.get("OBC_DESKTOP_EVIDENCE", ROOT / "target/desktop-launch")).resolve()
evidence.mkdir(parents=True, exist_ok=True)
for name in ("result.json", "failure.png", "ready.png", "page.html", "driver.log", "application.log", "catalog.jsonl", "processes.txt"):
(evidence / name).unlink(missing_ok=True)
result = {"passed": False, "binary": str(binary)}
browser = process = server = None
requests = []
objects = catalog_fixture()

class CatalogHandler(BaseHTTPRequestHandler):
def do_GET(self):
status = 200 if self.path in objects else 404
requests.append(self.path)
with (evidence / "catalog.jsonl").open("a") as log:
log.write(json.dumps({"path": self.path, "status": status}) + "\n")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(objects.get(self.path, b"not found"))

def log_message(self, *_args):
pass

with TemporaryDirectory(prefix="obc-desktop-launch-") as scratch, (evidence / "driver.log").open("w") as driver_log:
try:
if not binary.is_file() or not os.access(binary, os.X_OK):
raise RuntimeError(f"Missing executable release app: {binary}")
server = ThreadingHTTPServer(("127.0.0.1", 0), CatalogHandler)
Thread(target=server.serve_forever, daemon=True).start()
catalog_url = f"http://127.0.0.1:{server.server_port}/catalog.json"
# WebKit launches this wrapper once. exec preserves its PID and captures Rust output.
wrapper = Path(scratch) / "application.sh"
pidfile = Path(scratch) / "application.pid"
wrapper.write_text("#!/bin/sh\n" + f"echo $$ > {shlex.quote(str(pidfile))}\n" +
f"exec > {shlex.quote(str(evidence / 'application.log'))} 2>&1\n" +
'printf "automation=%s inspector=%s display=%s\\n" "$TAURI_WEBVIEW_AUTOMATION" "$WEBKIT_INSPECTOR_SERVER" "$DISPLAY"\n' +
f'exec {shlex.quote(str(binary))} "$@"\n')
wrapper.chmod(0o755)
environment = {**os.environ, "OBC_CATALOG_URL": catalog_url, "RUST_BACKTRACE": "1",
"XDG_DATA_HOME": scratch, "XDG_CONFIG_HOME": scratch, "XDG_CACHE_HOME": scratch}
process = subprocess.Popen(["tauri-driver"], env=environment, stdout=driver_log,
stderr=subprocess.STDOUT, start_new_session=True)

def listening(_):
if process.poll() is not None:
raise RuntimeError("tauri-driver exited before readiness")
try:
with socket.create_connection(("127.0.0.1", 4444), timeout=1):
return True
except OSError:
return False

WebDriverWait(None, 15).until(listening, "tauri-driver did not listen")
options = Options()
options.set_capability("browserName", "wry")
options.set_capability("tauri:options", {"application": str(wrapper)})
browser = webdriver.Remote(command_executor="http://127.0.0.1:4444", options=options,
client_config=ClientConfig(remote_server_addr="http://127.0.0.1:4444", timeout=30))
wait = WebDriverWait(browser, 30)
search = wait.until(EC.visibility_of_element_located((By.CSS_SELECTOR, '[aria-label="Search regions"]')))
result["url"] = browser.current_url
origin = urlsplit(result["url"])
if (origin.scheme, origin.netloc) != ("tauri", "localhost"):
raise AssertionError(f"Expected embedded custom-protocol frontend, got {result['url']}")
search.send_keys("Switzerland")
wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, '[aria-label="Add Switzerland (994 B)"]'))).click()
wait.until(EC.visibility_of_element_located((By.CSS_SELECTOR, '[aria-label="Switzerland is already in the map"]')))
wait.until(EC.text_to_be_present_in_element((By.CSS_SELECTOR, '.parts .price'), "994 B"))
if browser.find_elements(By.CSS_SELECTOR, '.catalog-error, .ledger .error, .parts .retry'):
raise AssertionError("Catalog or region resolution failed")
missing = set(objects) - set(requests)
if missing:
raise AssertionError(f"Native catalog requests missing: {sorted(missing)}")
result["region"] = "Switzerland"
result["price"] = browser.find_element(By.CSS_SELECTOR, '.parts .price').text
result["capabilities"] = browser.capabilities
browser.save_screenshot(str(evidence / "ready.png"))
(evidence / "page.html").write_text(browser.page_source)
browser.quit()
browser = None
pid = int(pidfile.read_text())
WebDriverWait(None, 10).until(lambda _: not Path(f"/proc/{pid}").exists(), "Application did not exit after session quit")
result["application_exited"] = True
if "panicked at" in (evidence / "application.log").read_text():
raise AssertionError("The release app panicked")
result["passed"] = True
except Exception:
result["error"] = traceback.format_exc()
with suppress(Exception):
# A session can fail before WebDriver can capture the still-open app window.
subprocess.run(["import", "-window", "root", str(evidence / "failure.png")],
check=True, timeout=5)
(evidence / "processes.txt").write_text(subprocess.check_output(
["ps", "-eo", "pid,ppid,stat,comm"], text=True, timeout=5))
if browser:
with suppress(Exception):
browser.save_screenshot(str(evidence / "failure.png"))
(evidence / "page.html").write_text(browser.page_source)
raise
finally:
if browser:
with suppress(Exception):
browser.quit()
if process:
with suppress(ProcessLookupError):
os.killpg(process.pid, signal.SIGTERM)
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
process.wait()
if server:
server.shutdown()
server.server_close()
result["requests"] = requests
(evidence / "result.json").write_text(json.dumps(result, indent=2) + "\n")
print(json.dumps(result, indent=2))


if __name__ == "__main__":
main()
1 change: 1 addition & 0 deletions apps/obc-desktop/e2e/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
selenium==4.35.0
12 changes: 12 additions & 0 deletions testing/suites.toml
Original file line number Diff line number Diff line change
Expand Up @@ -943,3 +943,15 @@ extra_triggers = [
platforms = ["macos"]
coverage_component = "ios"
ownership = [{ kind = "workflow", pattern = "xcodebuild build -project OBCCompanion.xcodeproj -scheme OBCCompanion" }]

[[suite]]
id = "e2e.desktop-linux-launch"
surface = "desktop"
level = "end-to-end"
command = "xvfb-run -a dbus-run-session -- python3 apps/obc-desktop/e2e/launch.py"
fixtures = []
pull_request = "affected"
scheduled = "none"
platforms = ["linux"]
extra_triggers = ["apps/obc-desktop/**", "builder/app/**", "host/obc-pack/schema/*.example.json"]
ownership = [{ kind = "workflow", pattern = "xvfb-run -a dbus-run-session -- python3 apps/obc-desktop/e2e/launch.py" }]
2 changes: 1 addition & 1 deletion tools/suite_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,7 @@ def _validate_command(root: Path, suite: dict[str, Any], rust_packages: set[str]
if not words:
errors.append(f"{suite_id}: command is empty")
return
known_tools = {"bash", "cargo", "npm", "python3", "swift", "trunk", "xcodebuild"}
known_tools = {"bash", "cargo", "npm", "python3", "swift", "trunk", "xcodebuild", "xvfb-run"}
expect_executable = True
skip_cd_path = False
for word in words:
Expand Down
12 changes: 9 additions & 3 deletions tools/tests/test_suite_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,7 @@ def jobs_for(self, *paths):

def test_every_suite_routes_to_the_job_that_executes_it(self) -> None:
expected = {
"e2e.desktop-linux-launch": ["desktop-launch"],
"rust.obc-crc": ["clippy", "fmt", "test"],
"rust.obc-fw-nrf54l": ["embedded", "fmt"],
"rust.obc-boot": ["boot", "fmt"],
Expand Down Expand Up @@ -797,21 +798,26 @@ def test_selected_job_set_per_change_class(self) -> None:
["firmware/obc-weather/src/lib.rs"],
["clippy", "desktop", "desktop-frontend", "device", "embedded", "fmt", "test", "wasm", "wasm-bridges"],
),
(
"desktop launch harness",
["apps/obc-desktop/e2e/launch.py"],
["desktop", "desktop-frontend", "desktop-launch", "fmt", "wasm-bridges"],
),
("iOS application", ["companion-ios/OBCCompanion/App.swift"], ["ios-app"]),
(
"web only",
["builder/app/src/lib/panel.ts"],
["desktop", "desktop-frontend", "fmt", "wasm-bridges", "web"],
["desktop", "desktop-frontend", "desktop-launch", "fmt", "wasm-bridges", "web"],
),
(
"workflow",
[".github/workflows/ci.yml"],
["boot", "clippy", "deny", "desktop", "desktop-frontend", "device", "docs", "embedded", "fmt", "ios-app", "ios-unit", "test", "test-weather", "wasm", "wasm-bridges", "web"],
["boot", "clippy", "deny", "desktop", "desktop-frontend", "desktop-launch", "device", "docs", "embedded", "fmt", "ios-app", "ios-unit", "test", "test-weather", "wasm", "wasm-bridges", "web"],
),
(
"nextest configuration",
[".config/nextest.toml"],
["boot", "clippy", "deny", "desktop", "desktop-frontend", "device", "docs", "embedded", "fmt", "ios-app", "ios-unit", "test", "test-weather", "wasm", "wasm-bridges", "web"],
["boot", "clippy", "deny", "desktop", "desktop-frontend", "desktop-launch", "device", "docs", "embedded", "fmt", "ios-app", "ios-unit", "test", "test-weather", "wasm", "wasm-bridges", "web"],
),
# The web demo is built only by `trunk build`, the OBCKit package is compiled into the
# app only by `xcodebuild`, and tools/fixtures.py is run only by a workflow step.
Expand Down
Loading