diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index efe859349..b6722591f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/apps/obc-desktop/README.md b/apps/obc-desktop/README.md index 2f4a7e754..0740601b3 100644 --- a/apps/obc-desktop/README.md +++ b/apps/obc-desktop/README.md @@ -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 diff --git a/apps/obc-desktop/e2e/launch.py b/apps/obc-desktop/e2e/launch.py new file mode 100644 index 000000000..a0b3d5984 --- /dev/null +++ b/apps/obc-desktop/e2e/launch.py @@ -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() diff --git a/apps/obc-desktop/e2e/requirements.txt b/apps/obc-desktop/e2e/requirements.txt new file mode 100644 index 000000000..5f44b2104 --- /dev/null +++ b/apps/obc-desktop/e2e/requirements.txt @@ -0,0 +1 @@ +selenium==4.35.0 diff --git a/testing/suites.toml b/testing/suites.toml index 518f52295..04d0f97ef 100644 --- a/testing/suites.toml +++ b/testing/suites.toml @@ -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" }] diff --git a/tools/suite_registry.py b/tools/suite_registry.py index d7e79405e..794a32f0c 100644 --- a/tools/suite_registry.py +++ b/tools/suite_registry.py @@ -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: diff --git a/tools/tests/test_suite_registry.py b/tools/tests/test_suite_registry.py index c9847b738..64d2a04c7 100644 --- a/tools/tests/test_suite_registry.py +++ b/tools/tests/test_suite_registry.py @@ -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"], @@ -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.