diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 53693fac2..976b7ebc2 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -5,6 +5,60 @@ on: pull_request: jobs: + gui-tests: + runs-on: self-hosted + timeout-minutes: 30 + + env: + VERBOSE: 0 + LOG_LEVEL: debug + TIMEOUT: 120 + USE_TAPROOT: 0 + BITCOIN_BACKEND_TYPE: bitcoind + + steps: + - uses: actions/checkout@v4 + + - name: Install Nix + uses: cachix/install-nix-action@v31 + with: + extra_nix_config: | + experimental-features = nix-command flakes + + - name: cleanup /tmp + run: | + find /tmp -maxdepth 1 -type d -name 'lianad*' -mtime +0 -exec rm -rf {} + + + - name: Build liana-gui + run: | + nix --extra-experimental-features "nix-command flakes" \ + develop .#default \ + -c cargo build -p liana-gui + + - name: Run GUI tests + run: | + nix --extra-experimental-features "nix-command flakes" \ + develop .#gui-tests \ + -c env LIANA_GUI_PATH="$PWD/target/debug/liana-gui" \ + pytest tests/gui -vv -s --tb=short + + - name: Collect GUI test artifacts + if: failure() + run: | + mkdir -p gui-test-artifacts + shopt -s nullglob + for dir in /tmp/lianad-tests-*/*/x11/gui-artifacts; do + find "$dir" -type f -exec cp --parents {} gui-test-artifacts/ \; + done + + - name: Upload GUI test artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: gui-test-artifacts + path: gui-test-artifacts + if-no-files-found: ignore + functional-tests: runs-on: self-hosted timeout-minutes: 90 @@ -135,4 +189,5 @@ jobs: rm -rf ~/.cargo/registry/index # Run the functional tests - LIANAD_PATH=$PWD/target/release/lianad pytest tests/ -vvv -n 8 + LIANAD_PATH=$PWD/target/release/lianad \ + pytest tests/ --ignore=tests/gui -vvv -n 8 diff --git a/flake.nix b/flake.nix index 1d7525a91..ae90807dd 100644 --- a/flake.nix +++ b/flake.nix @@ -71,6 +71,87 @@ builtins.foldl' (a: b: "${a}:${b}/lib") "${pkgs.vulkan-loader}/lib" buildInputs; }; + guiRuntimeInputs = commonBuildInputs ++ (with pkgs; [ + expat + mesa + vulkan-loader + ]); + + guiTestPython = pkgs.python3.withPackages (ps: + let + bip380 = ps.buildPythonPackage rec { + pname = "bip380"; + version = "0.2.0-fb61971"; + pyproject = true; + + src = pkgs.fetchzip { + url = "https://github.com/darosior/python-bip380/archive/fb61971d9128e663f110ea2734c1d023e7e0266b.zip"; + sha256 = "0qhnczv7ndvgw18s6mds892l4kmgj3grvk5zj7xbpmq1p264f9mi"; + }; + + pythonRelaxDeps = [ + "bip32" + "coincurve" + ]; + + nativeBuildInputs = with ps; [ + setuptools + ]; + + propagatedBuildInputs = with ps; [ + bip32 + coincurve + ]; + + doCheck = false; + pythonImportsCheck = [ "bip380" ]; + }; + in + with ps; [ + bip32 + bip380 + ephemeral-port-reserve + numpy + opencv4 + pillow + pytest + pytest-timeout + pytest-xdist + ]); + + guiTestShell = pkgs.mkShell { + packages = guiRuntimeInputs ++ (with pkgs; [ + bitcoin + dbus + electrs + imagemagick + openbox + tesseract + tigervnc + x11vnc + xdg-desktop-portal + xdg-desktop-portal-gtk + xdotool + xauth + xdpyinfo + xev + xvfb + xwininfo + zenity + ]) ++ [ + guiTestPython + ]; + + BITCOIND_PATH = "${pkgs.bitcoin}/bin/bitcoind"; + ELECTRS_PATH = "${pkgs.electrs}/bin/electrs"; + GUI_TEST_RUNTIME_LIBRARY_PATH = lib.makeLibraryPath guiRuntimeInputs; + + shellHook = '' + export LD_LIBRARY_PATH="$GUI_TEST_RUNTIME_LIBRARY_PATH''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + export WINIT_X11_SCALE_FACTOR=1 + ''; + }; + releaseShell = pkgs.mkShell { buildInputs = [ pkgs.zip @@ -88,6 +169,7 @@ }; devShells = { + gui-tests = guiTestShell; minimal = minimalShell; release = releaseShell; default = devShell; diff --git a/tests/gui/__init__.py b/tests/gui/__init__.py new file mode 100644 index 000000000..b8be73c61 --- /dev/null +++ b/tests/gui/__init__.py @@ -0,0 +1,2 @@ +"""GUI end-to-end test helpers.""" + diff --git a/tests/gui/bin/launch-liana-gui b/tests/gui/bin/launch-liana-gui new file mode 100755 index 000000000..eccaf61fa --- /dev/null +++ b/tests/gui/bin/launch-liana-gui @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +export WINIT_UNIX_BACKEND="${WINIT_UNIX_BACKEND:-x11}" +export WINIT_X11_SCALE_FACTOR="${WINIT_X11_SCALE_FACTOR:-1}" +export ICED_BACKEND="${ICED_BACKEND:-tiny-skia}" +export NO_AT_BRIDGE="${NO_AT_BRIDGE:-1}" +export GTK_USE_PORTAL="${GTK_USE_PORTAL:-0}" +export LC_ALL="${LC_ALL:-C}" +export TZ="${TZ:-UTC}" +unset WAYLAND_DISPLAY + +if [ -n "${GUI_TEST_RUNTIME_LIBRARY_PATH:-}" ]; then + export LD_LIBRARY_PATH="${GUI_TEST_RUNTIME_LIBRARY_PATH}${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}" +fi + +exec "${LIANA_GUI_PATH:-liana-gui}" "$@" diff --git a/tests/gui/conftest.py b/tests/gui/conftest.py new file mode 100644 index 000000000..9b411d019 --- /dev/null +++ b/tests/gui/conftest.py @@ -0,0 +1,70 @@ +import shutil +import tempfile +from pathlib import Path + +import pytest + +# Re-export the daemon test fixtures so GUI tests can compose with the existing +# regtest harness from this nested conftest. +from fixtures import * # noqa: F401,F403 + +from .datadir import GuiWallet +from .driver import GuiApp +from .x11 import X11Session + + +def pytest_configure(config): + for marker in ( + "gui_smoke: quick GUI launch and navigation tests", + "gui_core: core GUI wallet workflows", + "gui_filepicker: GUI workflows that use native file pickers", + "gui_slow: slow GUI workflows such as rescans and reorgs", + "gui_electrs: GUI workflows using an Electrum backend", + "gui_taproot: GUI workflows using Taproot descriptors", + ): + config.addinivalue_line("markers", marker) + + +@pytest.fixture +def x11_session(directory): + session = X11Session(Path(directory) / "x11").start() + try: + yield session + finally: + session.close() + + +@pytest.fixture +def gui_wallet(request, test_base_dir, bitcoind): + datadir = Path(tempfile.mkdtemp(prefix="lg-", dir=test_base_dir)) + wallet = GuiWallet.single_sig(datadir, bitcoind) + try: + yield wallet + finally: + rep_call = getattr(request.node, "rep_call", None) + if rep_call is not None and not rep_call.failed: + shutil.rmtree(datadir) + else: + print(f"Test failed, leaving GUI datadir '{datadir}' intact") + + +@pytest.fixture +def liana_gui(request, x11_session, gui_wallet): + app = GuiApp(x11_session, gui_wallet.datadir).start() + try: + yield app + finally: + if getattr(request.node, "rep_call", None) and request.node.rep_call.failed: + app.save_debug_artifacts("failure") + app.stop() + + +@pytest.fixture +def opened_liana_gui(liana_gui): + try: + liana_gui.click_text("GUI regtest wallet", timeout=30) + liana_gui.assert_text("Balance", timeout=60) + except Exception: + liana_gui.save_debug_artifacts("open-wallet-failure") + raise + return liana_gui diff --git a/tests/gui/datadir.py b/tests/gui/datadir.py new file mode 100644 index 000000000..7a62ffbc6 --- /dev/null +++ b/tests/gui/datadir.py @@ -0,0 +1,207 @@ +import json +import sqlite3 +from dataclasses import dataclass +from pathlib import Path + +from bip380.descriptors import Descriptor + +from fixtures import single_key_desc, xpub_fingerprint +from test_framework.signer import SingleSigner +from test_framework.utils import USE_TAPROOT, wait_for + + +@dataclass +class GuiWallet: + datadir: Path + network_dir: Path + data_dir: Path + wallet_id: str + descriptor: Descriptor + signer: SingleSigner + + @classmethod + def single_sig(cls, datadir, bitcoind, timestamp=1_700_000_000): + datadir = Path(datadir) + network_dir = datadir / "regtest" + network_dir.mkdir(parents=True, exist_ok=True) + + signer = SingleSigner(is_taproot=USE_TAPROOT) + (primary_fingerprint, primary_xpub), (recovery_fingerprint, recovery_xpub) = ( + (xpub_fingerprint(signer.primary_hd), signer.primary_hd.get_xpub()), + (xpub_fingerprint(signer.recovery_hd), signer.recovery_hd.get_xpub()), + ) + descriptor = Descriptor.from_str( + single_key_desc( + primary_fingerprint, + primary_xpub, + recovery_fingerprint, + recovery_xpub, + 10, + is_taproot=USE_TAPROOT, + ) + ) + + descriptor_checksum = str(descriptor).split("#", maxsplit=1)[1] + wallet_id = f"{descriptor_checksum}-{timestamp}" + data_dir = network_dir / "data" / wallet_id + data_dir.mkdir(parents=True, exist_ok=True) + + _write_global_settings(datadir) + _write_gui_config(network_dir) + _write_gui_settings( + network_dir, + descriptor_checksum, + timestamp, + primary_fingerprint, + recovery_fingerprint, + ) + _write_daemon_config(data_dir, descriptor, bitcoind) + + return cls( + datadir=datadir, + network_dir=network_dir, + data_dir=data_dir, + wallet_id=wallet_id, + descriptor=descriptor, + signer=signer, + ) + + @property + def db_path(self): + return self.data_dir / "lianad.sqlite3" + + def wait_for_db(self): + self._wait_for_db(lambda: self._db_value("SELECT COUNT(*) FROM wallets") == 1) + + def receive_index(self): + return self._db_value("SELECT deposit_derivation_index FROM wallets LIMIT 1") + + def wait_for_receive_index(self, minimum): + self._wait_for_db(lambda: self.receive_index() >= minimum) + + def receive_address(self, index): + return self._db_value( + "SELECT receive_address FROM addresses WHERE derivation_index = ?", + (index,), + ) + + def chain_height(self): + return self._db_value("SELECT blockheight FROM tip LIMIT 1") + + def confirmed_coin_count(self): + return self._db_value( + """ + SELECT COUNT(*) + FROM coins + WHERE blockheight IS NOT NULL + AND spend_txid IS NULL + AND is_immature = 0 + """ + ) + + def wait_for_confirmed_coin_count(self, minimum): + self._wait_for_db(lambda: self.confirmed_coin_count() >= minimum) + + def wait_for_sync(self, bitcoind): + self._wait_for_db(lambda: self.chain_height() == bitcoind.rpc.getblockcount()) + + def sign_psbt_base64(self, psbt_base64, recovery=False): + from test_framework.serializations import PSBT + + return self.signer.sign_psbt(PSBT.from_base64(psbt_base64), recovery).to_base64() + + def _db_value(self, query, params=()): + if not self.db_path.exists(): + raise FileNotFoundError(self.db_path) + uri = f"file:{self.db_path}?mode=ro" + with sqlite3.connect(uri, uri=True, timeout=1) as connection: + row = connection.execute(query, params).fetchone() + if row is None: + raise LookupError(query) + return row[0] + + def _wait_for_db(self, predicate): + def ready(): + try: + return predicate() + except (FileNotFoundError, LookupError, sqlite3.Error): + return False + + wait_for(ready) + + +def _write_global_settings(datadir): + (datadir / "global_settings.json").write_text( + json.dumps({"window_config": {"width": 1280.0, "height": 960.0}}, indent=2) + ) + + +def _write_gui_config(network_dir): + (network_dir / "gui.toml").write_text( + "\n".join( + [ + 'log_level = "debug"', + "debug = false", + "start_internal_bitcoind = false", + "", + ] + ) + ) + + +def _write_gui_settings( + network_dir, + descriptor_checksum, + timestamp, + primary_fingerprint, + recovery_fingerprint, +): + settings = { + "wallets": [ + { + "name": f"Liana-{descriptor_checksum}", + "alias": "GUI regtest wallet", + "descriptor_checksum": descriptor_checksum, + "pinned_at": timestamp, + "keys": [ + { + "name": "primary", + "master_fingerprint": primary_fingerprint, + "provider_key": None, + }, + { + "name": "recovery", + "master_fingerprint": recovery_fingerprint, + "provider_key": None, + }, + ], + "hardware_wallets": [], + "remote_backend_auth": None, + "start_internal_bitcoind": False, + "fiat_price": None, + } + ] + } + (network_dir / "settings.json").write_text(json.dumps(settings, indent=2)) + + +def _write_daemon_config(data_dir, descriptor, bitcoind): + cookie_path = Path(bitcoind.bitcoin_dir) / "regtest" / ".cookie" + (data_dir / "daemon.toml").write_text( + "\n".join( + [ + f"data_directory = '{data_dir}'", + "log_level = 'debug'", + f'main_descriptor = "{descriptor}"', + "", + "[bitcoin_config]", + "network = 'regtest'", + "poll_interval_secs = 1", + "", + "[bitcoind_config]", + f"cookie_path = '{cookie_path}'", + f"addr = '127.0.0.1:{bitcoind.rpcport}'", + "", + ] + ) + ) diff --git a/tests/gui/driver.py b/tests/gui/driver.py new file mode 100644 index 000000000..1284f9024 --- /dev/null +++ b/tests/gui/driver.py @@ -0,0 +1,247 @@ +import csv +import re +import subprocess +import time +from dataclasses import dataclass +from pathlib import Path + +from .x11 import terminate_process + + +class GuiDriverError(RuntimeError): + pass + + +@dataclass +class TextBox: + text: str + left: int + top: int + width: int + height: int + confidence: float + + @property + def center(self): + return (self.left + self.width // 2, self.top + self.height // 2) + + +class GuiApp: + def __init__(self, session, datadir, network="regtest", width=1280, height=960): + self.session = session + self.datadir = Path(datadir) + self.network = network + self.width = width + self.height = height + self.window_id = None + self.proc = None + self.log = None + self.screenshot_count = 0 + self.launcher = Path(__file__).parent / "bin" / "launch-liana-gui" + + def start(self, *extra_args): + if self.proc is not None: + raise GuiDriverError("GUI process already started") + + args = [ + str(self.launcher), + "--datadir", + str(self.datadir), + f"--{self.network}", + *extra_args, + ] + log_path = self.session.artifacts_dir / "liana-gui.log" + self.log = log_path.open("wb") + self.proc = subprocess.Popen( + args, + env=self.session.env, + stdout=self.log, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + self.wait_for_window() + self.resize(self.width, self.height) + self.activate() + return self + + def stop(self): + if self.proc is not None: + terminate_process(self.proc) + self.proc = None + if self.log is not None: + self.log.close() + self.log = None + + def wait_for_window(self, title_pattern="Liana", timeout=30): + deadline = time.time() + timeout + while time.time() < deadline: + if self.proc and self.proc.poll() is not None: + raise GuiDriverError("liana-gui exited before creating a window") + res = self.session.run( + ["xdotool", "search", "--name", title_pattern], + check=False, + timeout=2, + ) + ids = [line.strip() for line in res.stdout.splitlines() if line.strip()] + if ids: + self.window_id = ids[-1] + return self.window_id + time.sleep(0.25) + raise GuiDriverError(f"Timed out waiting for GUI window matching {title_pattern!r}") + + def activate(self): + self._require_window() + for command in ("windowactivate", "windowfocus"): + for _ in range(3): + res = self.session.run( + ["xdotool", command, self.window_id], + check=False, + timeout=5, + ) + if res.returncode == 0: + time.sleep(0.1) + return + time.sleep(0.25) + time.sleep(0.1) + + def resize(self, width, height): + self._require_window() + self.session.run(["xdotool", "windowsize", self.window_id, str(width), str(height)]) + time.sleep(0.25) + + def screenshot(self, label="screen"): + self.screenshot_count += 1 + safe = re.sub(r"[^A-Za-z0-9_.-]+", "-", label).strip("-") or "screen" + path = self.session.artifacts_dir / f"{self.screenshot_count:04d}-{safe}.png" + try: + self.session.run(["import", "-window", "root", str(path)], timeout=10) + except (FileNotFoundError, subprocess.CalledProcessError): + self.session.run(["magick", "import", "-window", "root", str(path)], timeout=10) + return path + + def save_debug_artifacts(self, label="failure"): + self.screenshot(label) + if self.window_id: + res = self.session.run( + ["xwininfo", "-id", self.window_id], + check=False, + timeout=5, + ) + (self.session.artifacts_dir / f"{label}-xwininfo.txt").write_text(res.stdout) + + def click_text(self, needle, timeout=15, button=1): + box = self.wait_for_text(needle, timeout=timeout) + self.click_at(*box.center, button=button) + return box + + def wait_for_text(self, needle, timeout=15): + deadline = time.time() + timeout + last_text = "" + while time.time() < deadline: + image = self.screenshot(f"ocr-{needle}") + boxes = self.ocr(image) + last_text = "\n".join(box.text for box in boxes) + box = self._find_text_box(boxes, needle) + if box: + return box + time.sleep(0.5) + raise GuiDriverError(f"Timed out waiting for text {needle!r}. Last OCR text:\n{last_text}") + + def assert_text(self, needle, timeout=10): + self.wait_for_text(needle, timeout=timeout) + + def click_at(self, x, y, button=1): + self.session.run( + [ + "xdotool", + "mousemove", + str(int(x)), + str(int(y)), + "click", + str(button), + ], + timeout=5, + ) + time.sleep(0.2) + + def type_text(self, value, delay=1): + self.session.run( + ["xdotool", "type", "--clearmodifiers", "--delay", str(delay), str(value)], + timeout=max(10, len(str(value)) // 10), + ) + + def key(self, *keys): + self.session.run(["xdotool", "key", "--clearmodifiers", *keys], timeout=5) + time.sleep(0.1) + + def ocr(self, image_path): + res = self.session.run( + ["tesseract", str(image_path), "stdout", "--psm", "11", "tsv"], + check=False, + timeout=20, + ) + if res.returncode not in (0, 1): + raise GuiDriverError(f"OCR failed: {res.stdout}") + return _parse_tesseract_tsv(res.stdout) + + def _find_text_box(self, boxes, needle): + normalized_needle = _normalize(needle) + if not normalized_needle: + return None + for box in boxes: + if normalized_needle == _normalize(box.text): + return box + for box in boxes: + if normalized_needle in _normalize(box.text): + return box + return None + + def _require_window(self): + if not self.window_id: + raise GuiDriverError("GUI window was not discovered yet") + + +def _parse_tesseract_tsv(output): + reader = csv.DictReader(output.splitlines(), delimiter="\t") + words_by_line = {} + for row in reader: + text = (row.get("text") or "").strip() + if not text: + continue + try: + confidence = float(row.get("conf", "-1")) + except ValueError: + confidence = -1 + if confidence < 0: + continue + key = ( + row.get("page_num"), + row.get("block_num"), + row.get("par_num"), + row.get("line_num"), + ) + words_by_line.setdefault(key, []).append( + ( + text, + int(row["left"]), + int(row["top"]), + int(row["width"]), + int(row["height"]), + confidence, + ) + ) + + boxes = [] + for words in words_by_line.values(): + left = min(w[1] for w in words) + top = min(w[2] for w in words) + right = max(w[1] + w[3] for w in words) + bottom = max(w[2] + w[4] for w in words) + text = " ".join(w[0] for w in words) + confidence = sum(w[5] for w in words) / len(words) + boxes.append(TextBox(text, left, top, right - left, bottom - top, confidence)) + return boxes + + +def _normalize(value): + return re.sub(r"\s+", " ", value).strip().lower() diff --git a/tests/gui/test_launch_and_menu.py b/tests/gui/test_launch_and_menu.py new file mode 100644 index 000000000..e8869fc45 --- /dev/null +++ b/tests/gui/test_launch_and_menu.py @@ -0,0 +1,40 @@ +import pytest + + +@pytest.mark.gui_smoke +def test_open_wallet_and_visit_all_menus(opened_liana_gui): + app = opened_liana_gui + + app.assert_text("Balance", timeout=20) + + top_level_menus = [ + ("Receive", "Always generate"), + ("Send", "Feerate"), + ("Drafts", "Import"), + ("Transactions", "Transactions"), + ("Coins", "Coins"), + ("Recovery", "Recovery"), + ("Settings", "General"), + ] + + for menu_label, expected_text in top_level_menus: + app.click_text(menu_label, timeout=20) + app.assert_text(expected_text, timeout=20) + + settings_sections = [ + ("General", "Fiat price"), + ("Node", "Bitcoin Core"), + ("Wallet", "Wallet descriptor"), + ("Import", "Encrypted descriptor"), + ("About", "Version"), + ] + + app.click_text("Settings", timeout=20) + app.assert_text("General", timeout=20) + for index, (section_label, expected_text) in enumerate(settings_sections): + app.assert_text("General", timeout=20) + app.click_text(section_label, timeout=20) + app.assert_text(expected_text, timeout=20) + if index + 1 < len(settings_sections): + app.click_at(540, 280) + app.assert_text("General", timeout=20) diff --git a/tests/gui/test_receive.py b/tests/gui/test_receive.py new file mode 100644 index 000000000..fd9fe7e1b --- /dev/null +++ b/tests/gui/test_receive.py @@ -0,0 +1,38 @@ +import pytest + + +@pytest.mark.gui_core +def test_generate_receive_address_from_gui(opened_liana_gui, gui_wallet): + app = opened_liana_gui + + address = generate_receive_address(app, gui_wallet) + + assert gui_wallet.receive_index() == 1 + assert address.startswith("bcrt1") + + +@pytest.mark.gui_core +def test_gui_generated_address_receives_confirmed_deposit( + opened_liana_gui, gui_wallet, bitcoind +): + app = opened_liana_gui + + address = generate_receive_address(app, gui_wallet) + + txid = bitcoind.rpc.sendtoaddress(address, 0.01) + bitcoind.generate_block(1, wait_for_mempool=txid) + gui_wallet.wait_for_sync(bitcoind) + gui_wallet.wait_for_confirmed_coin_count(1) + + app.click_text("Coins", timeout=20) + app.assert_text("Coins", timeout=20) + app.click_text("Transactions", timeout=20) + app.assert_text("Transactions", timeout=20) + + +def generate_receive_address(app, gui_wallet): + app.click_text("Receive", timeout=20) + app.assert_text("Always generate", timeout=20) + app.click_at(1100, 278) + gui_wallet.wait_for_receive_index(1) + return gui_wallet.receive_address(1) diff --git a/tests/gui/x11.py b/tests/gui/x11.py new file mode 100644 index 000000000..85ca253ac --- /dev/null +++ b/tests/gui/x11.py @@ -0,0 +1,186 @@ +import os +import select +import shutil +import signal +import subprocess +import time +from pathlib import Path + +from ephemeral_port_reserve import reserve + + +class X11SessionError(RuntimeError): + pass + + +class X11Session: + """Own a small X11 desktop suitable for native GUI automation.""" + + def __init__(self, directory, width=1280, height=960, depth=24): + self.directory = Path(directory) + self.artifacts_dir = self.directory / "gui-artifacts" + self.runtime_dir = self.directory / "xdg-runtime" + self.width = width + self.height = height + self.depth = depth + self.display = None + self.processes = [] + self.env = os.environ.copy() + + def start(self): + self.artifacts_dir.mkdir(parents=True, exist_ok=True) + self.runtime_dir.mkdir(parents=True, exist_ok=True) + self.runtime_dir.chmod(0o700) + + self._start_xvfb() + self.env.update( + { + "DISPLAY": self.display, + "XDG_RUNTIME_DIR": str(self.runtime_dir), + "WINIT_UNIX_BACKEND": "x11", + "WINIT_X11_SCALE_FACTOR": "1", + "NO_AT_BRIDGE": "1", + "GTK_USE_PORTAL": os.getenv("GTK_USE_PORTAL", "0"), + "LC_ALL": "C", + "TZ": "UTC", + } + ) + self.env.pop("WAYLAND_DISPLAY", None) + + self._wait_for_x() + self._start_process("openbox", ["openbox"]) + self._wait_for_window_manager() + + if os.getenv("GUI_TEST_VNC") == "1": + port = str(reserve()) + self.env["GUI_TEST_VNC_PORT"] = port + self._start_process( + "x11vnc", + [ + "x11vnc", + "-display", + self.display, + "-forever", + "-shared", + "-nopw", + "-rfbport", + port, + ], + ) + + return self + + def close(self): + for _, proc, _ in reversed(self.processes): + if proc.poll() is None: + proc.terminate() + + deadline = time.time() + 5 + for _, proc, _ in reversed(self.processes): + if proc.poll() is not None: + continue + timeout = max(0.1, deadline - time.time()) + try: + proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=5) + + def run(self, args, check=True, capture_output=True, timeout=10, **kwargs): + stdout = subprocess.PIPE if capture_output else None + stderr = subprocess.STDOUT if capture_output else None + return subprocess.run( + args, + check=check, + env=self.env, + text=True, + stdout=stdout, + stderr=stderr, + timeout=timeout, + **kwargs, + ) + + def _start_xvfb(self): + if shutil.which("Xvfb") is None: + raise X11SessionError("Xvfb is not available in PATH") + + read_fd, write_fd = os.pipe() + log_path = self.artifacts_dir / "Xvfb.log" + log = log_path.open("wb") + proc = subprocess.Popen( + [ + "Xvfb", + "-displayfd", + str(write_fd), + "-screen", + "0", + f"{self.width}x{self.height}x{self.depth}", + "-nolisten", + "tcp", + ], + stdout=log, + stderr=subprocess.STDOUT, + pass_fds=(write_fd,), + close_fds=True, + ) + os.close(write_fd) + + ready, _, _ = select.select([read_fd], [], [], 10) + if not ready: + proc.terminate() + raise X11SessionError("Timed out waiting for Xvfb display allocation") + + display_num = os.read(read_fd, 32).decode().strip() + os.close(read_fd) + if proc.poll() is not None or not display_num: + raise X11SessionError(f"Xvfb exited before reporting a display; see {log_path}") + + self.display = f":{display_num}" + self.processes.append(("Xvfb", proc, log)) + + def _start_process(self, name, args): + log = (self.artifacts_dir / f"{name}.log").open("wb") + proc = subprocess.Popen( + args, + stdout=log, + stderr=subprocess.STDOUT, + env=self.env, + start_new_session=True, + ) + self.processes.append((name, proc, log)) + return proc + + def _wait_for_x(self): + deadline = time.time() + 10 + while time.time() < deadline: + try: + self.run(["xdpyinfo"], timeout=2) + return + except (subprocess.CalledProcessError, subprocess.TimeoutExpired): + time.sleep(0.1) + raise X11SessionError("Timed out waiting for X11 to accept clients") + + def _wait_for_window_manager(self): + deadline = time.time() + 10 + while time.time() < deadline: + try: + self.run(["xdotool", "getdisplaygeometry"], timeout=2) + return + except (subprocess.CalledProcessError, subprocess.TimeoutExpired): + time.sleep(0.1) + raise X11SessionError("Timed out waiting for xdotool to access display") + + +def terminate_process(proc, timeout=10): + if proc.poll() is not None: + return + proc.terminate() + try: + proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + try: + os.killpg(proc.pid, signal.SIGKILL) + except ProcessLookupError: + proc.kill() + proc.wait(timeout=5) +