#!/usr/bin/env python3 """Real Calc through the production reader, in a private X11/AT-SPI session.""" import os from contextlib import ExitStack from pathlib import Path import shutil import signal import subprocess import sys import tempfile import time from native_test_session import run_private_session def wait_for(check, message, timeout=15): deadline = time.monotonic() + timeout while time.monotonic() < deadline: result = check() if result: return result time.sleep(0.05) raise RuntimeError(message) def run_inside(): import gi gi.require_version("Atspi", "2.0") from gi.repository import Atspi with tempfile.TemporaryDirectory(prefix="cthulhu-libreoffice-") as temporary, ExitStack() as cleanup: root = Path(temporary) # Workbook and modal-dialog activation need a window manager's focus # protocol, rather than bare XSetInputFocus on a virtual display. wmConfig = root / "i3.config" wmConfig.write_text( 'font pango:monospace 10\nfocus_follows_mouse no\n' f'ipc-socket {root / "i3.sock"}\n', encoding="utf-8", ) windowManager = subprocess.Popen( ["i3", "-a", "-c", str(wmConfig)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) def stop_window_manager(): if windowManager.poll() is None: windowManager.terminate() windowManager.wait(timeout=5) cleanup.callback(stop_window_manager) wait_for(lambda: "0x" in subprocess.check_output( ["xprop", "-root", "_NET_SUPPORTING_WM_CHECK"], text=True), "Private i3 window manager missing") profile = root / "profile" / "user" profile.mkdir(parents=True) (profile / "registrymodifications.xcu").write_text( '' '' 'false' 'false' '', encoding="utf-8", ) document = root / "Example.fods" shutil.copyfile(Path(__file__).with_name("libreoffice.fods"), document) secondDocument = root / "Other.fods" shutil.copyfile(document, secondDocument) environment = dict(os.environ, SAL_USE_VCLPLUGIN="gtk3", LC_ALL="C.UTF-8", GTK_MODULES="gail:atk-bridge", NO_AT_BRIDGE="0", XDG_CONFIG_HOME=str(root / "config"), XDG_CACHE_HOME=str(root / "cache"), XDG_DATA_HOME=str(root / "data")) application = subprocess.Popen( ["libreoffice", "-env:UserInstallation=" + (root / "profile").as_uri(), "--norestore", "--nodefault", "--nofirststartwizard", "--calc", str(document)], env=environment, start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) try: def find_window(): result = subprocess.run( ["xdotool", "search", "--onlyvisible", "--class", "libreoffice-calc"], capture_output=True, text=True, check=False, ) return result.stdout.splitlines()[-1] if result.stdout.strip() else None window = wait_for(find_window, "Calc window missing") # Wait for the actual accessible sheet, without enumerating its # millions of cells. Dismiss disposable first-run UI if present. def ready(): pending = [Atspi.get_desktop(0)] remaining = 2500 found = False while pending and remaining: node = pending.pop() remaining -= 1 role = node.get_role_name() if role == "table": parent = node.get_parent() found |= parent is not None and parent.get_role_name() == "document spreadsheet" continue if role == "dialog" and node.get_name() == "Welcome to LibreOffice!": subprocess.run(["xdotool", "key", "Escape"], check=True) return False if role == "table cell": continue for index in range(min(node.get_child_count(), 200)): child = node.get_child_at_index(index) if child is not None: pending.append(child) return found wait_for(ready, "Calc accessible sheet missing") environment.update(CTHULHU_NATIVE_LIBREOFFICE="1", CTHULHU_CALC_WINDOW=window, CTHULHU_CALC_PROFILE=(root / "profile").as_uri(), CTHULHU_CALC_SECOND_DOCUMENT=str(secondDocument)) environment.setdefault("GOCACHE", "/tmp/cthulhu-go-cache") subprocess.run( ["go", "test", "./internal/app", "-run", "^TestNativeLibreOfficeCalc$", "-count=1", "-timeout=80s", "-v"], cwd=Path(__file__).resolve().parents[1], env=environment, check=True, timeout=90, ) finally: if application.poll() is None: os.killpg(application.pid, signal.SIGTERM) application.wait(timeout=5) def select_disjoint(): """Mutate only the disposable fixture, without walking sheet cells.""" if os.environ.get("CTHULHU_NATIVE_LIBREOFFICE") != "1": raise RuntimeError("Selection helper requires the private Calc harness") import gi gi.require_version("Atspi", "2.0") from gi.repository import Atspi pending = [Atspi.get_desktop(0)] remaining = 2500 while pending and remaining: node = pending.pop() remaining -= 1 if node.get_role_name() == "table": if node.get_name() == "Sheet Second": node.clear_selection() node.select_child(0) node.select_child(2) return continue for index in range(min(node.get_child_count(), 200)): child = node.get_child_at_index(index) if child is not None: pending.append(child) raise RuntimeError("Disposable selection fixture missing") def main(): if sys.argv[1:] == ["--select-disjoint"]: select_disjoint() return if sys.argv[1:] == ["--inside"]: run_inside() return with tempfile.TemporaryDirectory(prefix="cthulhu-libreoffice-session-") as temporary: environment = dict(os.environ, XDG_SESSION_TYPE="x11", XDG_CONFIG_HOME=temporary, XDG_CACHE_HOME=temporary, XDG_DATA_HOME=temporary) for name in ("DBUS_SESSION_BUS_ADDRESS", "AT_SPI_BUS_ADDRESS", "WAYLAND_DISPLAY", "I3SOCK"): environment.pop(name, None) run_private_session( ["xvfb-run", "-a", "dbus-run-session", "--", sys.executable, str(Path(__file__).resolve()), "--inside"], env=environment, check=True, timeout=120, ) if __name__ == "__main__": main()