diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index 48e9ba0828..aff53b57e6 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -762,7 +762,9 @@ jobs: # checked from file bytes); qualify_linux.py records a write workflow on a # GTK3 chart app with SQLite, compiles it, runs it governed with the record # check (clean, another patient, a look-alike) and repairs an update notice - # on its AT-SPI element identity. Both results are uploaded. + # on its AT-SPI element identity; qualify_linux_window_record.py records + # that app through Capture scoped to its window, with a second window open. + # All results are uploaded. flow-linux-atspi: name: flow native Linux (AT-SPI, Xvfb) needs: scope @@ -801,19 +803,24 @@ jobs: set -e sudo apt-get install -y \ at-spi2-core dbus-x11 gir1.2-atspi-2.0 gir1.2-gtk-3.0 libatk-adaptor \ - libcairo2-dev libgirepository-2.0-dev openbox pkg-config xauth xvfb + libcairo2-dev libgirepository-2.0-dev openbox pkg-config xauth xdotool xvfb - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: python-version: "3.12" - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 with: enable-cache: true - cache-dependency-glob: packages/flow/uv.lock + cache-dependency-glob: | + packages/flow/uv.lock + packages/capture/uv.lock cache-suffix: flow-linux-atspi - - name: Install from the lock + - name: Install from the locks + # Flow's lock, then Capture's: the in-tree Capture (X11 window capture) + # can need newer dependencies than the published one Flow's lock names. run: >- - python ../../scripts/ci/locked_install.py --lock 'packages/flow[dev,linux]' - --editable packages/types --editable packages/flow + python ../../scripts/ci/locked_install.py --lock 'packages/flow[dev,linux,capture]' + --lock packages/capture + --editable packages/types --editable packages/capture --editable packages/flow - name: Qualify LinuxBackend on the GTK3 fixture timeout-minutes: 10 run: | @@ -833,6 +840,17 @@ jobs: env XDG_SESSION_TYPE=x11 NO_AT_BRIDGE=0 GTK_MODULES=atk-bridge \ python benchmark/repair_every_backend/qualify_linux.py \ --output runs/linux-atspi/record-compile-run.json + - name: Record one X11 window through Capture with a second window open + timeout-minutes: 10 + run: | + # --skip-other-clicks: Capture's verifier rejects a recording whose + # dropped input left a hole in the source ordinals, so clicks in the + # second window fail the recording. The second window stays open. + xvfb-run -a -s "-screen 0 1280x800x24" dbus-run-session -- \ + bash -c 'openbox >/dev/null 2>&1 & sleep 1; exec "$@"' bash \ + env XDG_SESSION_TYPE=x11 GDK_BACKEND=x11 \ + python scripts/qualify_linux_window_record.py --skip-other-clicks \ + --output runs/linux-atspi/window-record.json - name: Upload Linux AT-SPI qualification evidence if: always() timeout-minutes: 2 diff --git a/packages/flow/CHANGELOG.md b/packages/flow/CHANGELOG.md index 75a582a84b..7fb86c3f1e 100644 --- a/packages/flow/CHANGELOG.md +++ b/packages/flow/CHANGELOG.md @@ -19,6 +19,13 @@ ### Changed +- `record --backend linux` can scope the recording to one X11 window. + `--window`/`--window-title` and `--linux-app`/`--linux-window-title` select + it; before, they were refused. Wayland sessions, an unset `DISPLAY` and + openadapt-capture releases before 1.3.0 are refused before recording starts. + `scripts/qualify_linux_window_record.py` checks it under Xvfb with a second + window open: the recording holds only the target's clicks, in its pixel + space, and only its frames. - No command falls back to the demo app on its own. `replay` needs a target: `--url`, `backend.url` in `--config`, or `--demo-app` to serve the bundled MockMed (the tutorial and quickstart commands pass it; `--drift` needs it). The variant router's first-screen probe captures nothing without a URL. `qualify accept`'s break-it check no longer runs every workflow's oracle against MockMed: it builds the lie from the workflow itself (a store that did not change, and a screen showing the workflow's own success text and every value the run typed). The reference Execute worker refuses, before running anything, an admission whose `target_url` it cannot drive, instead of resetting and reading that app as if it were the demo. ### Fixes diff --git a/packages/flow/docs/SURFACES.md b/packages/flow/docs/SURFACES.md index 1534cc6d55..d8503b937d 100644 --- a/packages/flow/docs/SURFACES.md +++ b/packages/flow/docs/SURFACES.md @@ -147,8 +147,10 @@ openadapt-flow compile rec --out bundle --name my-task openadapt-flow replay bundle --macos-app TextEdit \ --macos-window-title notes.txt -# Linux: Capture records the local desktop; AT-SPI selects the replay target. -openadapt-flow record --backend linux --out rec +# Linux: the title scopes the local Capture window on X11; AT-SPI selects +# the replay target. +openadapt-flow record --backend linux \ + --linux-window-title "Untitled Document 1" --out rec openadapt-flow compile rec --out bundle --name my-task openadapt-flow replay bundle --linux-app gedit \ --linux-window-title "Untitled Document 1" diff --git a/packages/flow/docs/desktop/RECORDING.md b/packages/flow/docs/desktop/RECORDING.md index 2fb0168e4c..36fba457e8 100644 --- a/packages/flow/docs/desktop/RECORDING.md +++ b/packages/flow/docs/desktop/RECORDING.md @@ -165,10 +165,18 @@ with `--rdp-readiness-text`, set `backend.rdp_readiness_text` in deployment config, or pass the flag directly. If it is absent, `run` refuses before any action; ordinary record/replay remains available. -Window-scoped capture is implemented on **macOS and Windows** hosts -(`CGWindowListCreateImage` / Win32 region grab); on any other host `--window` -is refused up front rather than silently falling back to full-screen (which -would record coordinates in the wrong pixel space). `--window` applies only to +Window-scoped capture is implemented on **macOS, Windows and X11 Linux** +hosts (`CGWindowListCreateImage` / Win32 region grab / X11 EWMH window list +and XComposite). On Linux, `--linux-app` and `--linux-window-title` are the +same selectors as `--window` and `--window-title`: the owner is matched +against the window's process name and the title against its `_NET_WM_NAME`, +both as substrings, and more than one match is refused. The process name of a +Python or Java app is the interpreter (`python3`, `java`), not the AT-SPI +application name that `replay --linux-app` matches, so scope such an app by +title. A native Wayland session, an unset `DISPLAY`, or an openadapt-capture +older than 1.3.0 is refused before recording starts. On any other host +`--window` is refused up front rather than silently falling back to +full-screen (which would record coordinates in the wrong pixel space). `--window` applies only to the desktop backends — `--backend web` records the Playwright page and refuses it. diff --git a/packages/flow/openadapt_flow/__main__.py b/packages/flow/openadapt_flow/__main__.py index bd65924ff0..f1626c11ce 100644 --- a/packages/flow/openadapt_flow/__main__.py +++ b/packages/flow/openadapt_flow/__main__.py @@ -185,7 +185,7 @@ def _reject_unbound_record_target_flags(args: argparse.Namespace, backend: str) "web": set(), "windows": set(), "macos": {"macos_app", "macos_window_title"}, - "linux": set(), + "linux": {"linux_app", "linux_window_title"}, "rdp": {"rdp_window", "rdp_window_title", "rdp_readiness_text"}, "citrix": {"rdp_window", "rdp_window_title", "rdp_readiness_text"}, } @@ -198,15 +198,12 @@ def _reject_unbound_record_target_flags(args: argparse.Namespace, backend: str) "Scope the local recording with --window/--window-title, then " "pass --agent-url to replay or run" ) - elif backend == "linux" and attr in { - "linux_app", - "linux_window_title", - "linux_allow_physical_input", - }: + elif backend == "linux" and attr == "linux_allow_physical_input": reason = ( - "the current Capture component has no Linux window-scoping " - "primitive. Record the local Linux desktop without this flag, " - "then pass it to replay or run" + "it permits replay and run to send physical input; recording " + "sends none. Scope the recording with --window/--window-title " + "(or --linux-app/--linux-window-title), then pass it to " + "replay or run" ) elif backend == "rdp" and attr == "rdp_host": reason = ( @@ -252,26 +249,22 @@ def _resolve_record_capture_window( owner = getattr(args, "window", None) title = getattr(args, "window_title", None) - if backend == "linux" and (owner is not None or title is not None): - raise SystemExit( - "record --backend linux: --window/--window-title cannot be applied " - "because the current Capture component has no Linux " - "window-scoping primitive. Record the local Linux desktop without " - "these flags. Nothing was recorded." - ) - if backend == "macos": + if backend in ("macos", "linux"): + # On Linux, Capture resolves one X11 (EWMH) client window: the owner + # is matched against the window's process name and the title against + # its _NET_WM_NAME. No match, or more than one, stops the recording. owner = _merge_record_window_selector( owner, - getattr(args, "macos_app", None), + getattr(args, f"{backend}_app", None), generic_flag="--window", - target_flag="--macos-app", + target_flag=f"--{backend}-app", backend=backend, ) title = _merge_record_window_selector( title, - getattr(args, "macos_window_title", None), + getattr(args, f"{backend}_window_title", None), generic_flag="--window-title", - target_flag="--macos-window-title", + target_flag=f"--{backend}-window-title", backend=backend, ) elif backend in ("rdp", "citrix"): @@ -8515,8 +8508,9 @@ def _add_backend_flags(p: argparse.ArgumentParser) -> None: metavar="APP", help=( "Exact AT-SPI application name for --backend linux (e.g. gedit). " - "Replay/run only: the current Capture path records the local Linux " - "desktop and refuses this flag. Overrides backend.linux_app." + "During record it is the same selector as --window and scopes " + "Capture to one X11 window whose process name contains APP. " + "During replay/run it overrides backend.linux_app." ), ) p.add_argument( @@ -8525,9 +8519,9 @@ def _add_backend_flags(p: argparse.ArgumentParser) -> None: metavar="TITLE", help=( "Exact top-level window title for --backend linux. Zero or " - "multiple matches are refused. Replay/run only: the current " - "Capture path records the local Linux desktop and refuses this " - "flag. Overrides backend.linux_window_title." + "multiple matches are refused. During record it is the same " + "selector as --window-title (a title substring) and scopes " + "Capture. During replay/run it overrides backend.linux_window_title." ), ) p.add_argument( @@ -9198,8 +9192,9 @@ def build_parser() -> argparse.ArgumentParser: "substring, e.g. --window Parallels / --window 'Citrix Workspace'). " "Closes the coordinate-space gap with the pixel (rdp) replay " "surface. Combine with --window-title to disambiguate. " - "Supported on macOS and Windows hosts; refused elsewhere. " - "Ignored (rejected) for --backend web." + "Supported on macOS, Windows and X11 Linux hosts (on Linux the " + "owner is the window's process name); refused elsewhere, " + "including native Wayland. Ignored (rejected) for --backend web." ), ) p.add_argument( diff --git a/packages/flow/openadapt_flow/desktop_record.py b/packages/flow/openadapt_flow/desktop_record.py index 5af412840d..c933da2359 100644 --- a/packages/flow/openadapt_flow/desktop_record.py +++ b/packages/flow/openadapt_flow/desktop_record.py @@ -56,9 +56,50 @@ # (openadapt_capture.window_capture.resolve_window / capture_window). Kept in # lock-step with that module: recording ONE window in its own pixel space needs # a per-window capture primitive (macOS CGWindowListCreateImage / Windows -# Win32 + region grab). Elsewhere we refuse UP FRONT rather than start a -# full-screen capture that silently ignores the requested --window scope. -WINDOW_CAPTURE_PLATFORMS = ("darwin", "win32") +# Win32 + region grab / Linux X11 EWMH + XComposite). Elsewhere we refuse UP +# FRONT rather than start a full-screen capture that silently ignores the +# requested --window scope. +WINDOW_CAPTURE_PLATFORMS = ("darwin", "win32", "linux") + + +def _linux_window_session_refusal(environ: Any = None) -> Optional[str]: + """Why a Linux session cannot record one window, or None for X11. + + Mirrors ``openadapt_capture.window_capture_linux._require_x11_session`` so + the refusal comes before the operator is told to start demonstrating. + """ + import os + + values = os.environ if environ is None else environ + session_type = values.get("XDG_SESSION_TYPE", "").strip().casefold() + if session_type == "wayland" or values.get("WAYLAND_DISPLAY", "").strip(): + return ( + "this is a Wayland session; Capture can scope a recording to one " + "window only under X11" + ) + if not values.get("DISPLAY", "").strip(): + return "DISPLAY is not set; Capture needs an X11 desktop" + return None + + +def _installed_capture_lacks_linux_windows() -> bool: + """Whether the installed openadapt-capture predates Linux window capture. + + openadapt-capture added X11 window capture in 1.3.0 + (``openadapt_capture.window_capture_linux``). An older release would + refuse only after the operator was told to start. Returns False when + openadapt-capture is not installed: the recorder reports that itself. + """ + import importlib.util + + try: + if importlib.util.find_spec("openadapt_capture") is None: + return False + return ( + importlib.util.find_spec("openadapt_capture.window_capture_linux") is None + ) + except (ImportError, ValueError): + return False class _CaptureRecorder(Protocol): @@ -315,9 +356,22 @@ def record_desktop_capture( "window-scoped recording (--window) is not supported on this host " f"(platform {sys.platform!r}); openadapt-capture implements " "per-window capture only on " - f"{' and '.join(WINDOW_CAPTURE_PLATFORMS)}. Record on a supported " + f"{', '.join(WINDOW_CAPTURE_PLATFORMS)}. Record on a supported " "host, or omit --window to capture the full screen." ) + if window is not None and sys.platform.startswith("linux"): + refusal = _linux_window_session_refusal() + if refusal is not None: + raise SystemExit( + f"window-scoped recording (--window) refused: {refusal}. " + "Nothing was recorded." + ) + if recorder_factory is None and _installed_capture_lacks_linux_windows(): + raise SystemExit( + "window-scoped recording (--window) refused: the installed " + "openadapt-capture has no Linux window capture; install " + "openadapt-capture 1.3.0 or later. Nothing was recorded." + ) if backend_kind is not None and backend_kind not in ("rdp", "citrix"): raise ValueError( "backend_kind execution hints are supported only for rdp or citrix" diff --git a/packages/flow/scripts/qualify_linux_window_record.py b/packages/flow/scripts/qualify_linux_window_record.py new file mode 100644 index 0000000000..bb44245eaa --- /dev/null +++ b/packages/flow/scripts/qualify_linux_window_record.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +"""Qualify window-scoped ``record --backend linux`` on a real X11 desktop. + +Run under Xvfb with a window manager (openbox) and xdotool, as the +flow-linux-atspi CI job does. The check opens two windows of the GTK3 chart +app (tests/native_apps/chart_gtk.py): the target, titled "Chart", and a +second one titled "Other". Both are python3 processes, so only the title +tells them apart. It then runs the real CLI, +``record --backend linux --linux-window-title Chart``, clicks twice in the +target, twice in the other window and once more in the target with xdotool, +and stops the recording with ``--until-cmd``. + +It passes only when the recording holds exactly the three target clicks, each +at the point clicked inside the target; every frame has the target +window's size; and meta.json names the target window. It prints one JSON +line of observations and exits non-zero on a mismatch. +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +FLOW = Path(__file__).resolve().parents[1] +APP_SOURCE = FLOW / "tests" / "native_apps" / "chart_gtk.py" +TARGET = ("Chart", "rc79284", "MRN RC79284 Okafor, Philip born 1966-02-17") +OTHER = ("Other", "rc48572", "MRN RC48572 Adeyemi, Grace born 1971-03-29") +# Window-relative click points; both windows are moved apart first. +TARGET_ORIGIN = (40, 40) +OTHER_ORIGIN = (620, 40) +TARGET_CLICKS = ((30, 20), (60, 30)) +OTHER_CLICKS = ((30, 20), (80, 40)) +LAST_TARGET_CLICK = (90, 25) +TOLERANCE_PX = 2 + + +def _xdotool(*args: str) -> str: + done = subprocess.run( + ["xdotool", *args], check=True, capture_output=True, text=True, timeout=20 + ) + return done.stdout.strip() + + +def _window_id(title: str) -> int: + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + try: + ids = _xdotool("search", "--onlyvisible", "--name", f"^{title}$").split() + except subprocess.CalledProcessError: + ids = [] + if len(ids) == 1: + return int(ids[0]) + time.sleep(0.3) + raise SystemExit(f"no single visible window titled {title!r}") + + +def _size(window: int) -> tuple[int, int]: + values: dict[str, int] = {} + for line in _xdotool("getwindowgeometry", "--shell", str(window)).splitlines(): + key, _, value = line.partition("=") + values[key] = int(value) + return values["WIDTH"], values["HEIGHT"] + + +def _click(window: int, rel: tuple[int, int]) -> tuple[int, int]: + """Activate ``window`` and click at ``rel`` inside it; return the screen point.""" + _xdotool("windowactivate", "--sync", str(window)) + time.sleep(0.4) + # Relative to the window itself (XTranslateCoordinates): the X/Y that + # getwindowgeometry prints count the window manager's frame twice. + _xdotool("mousemove", "--window", str(window), "--sync", str(rel[0]), str(rel[1])) + time.sleep(0.2) + where = dict( + line.partition("=")[::2] + for line in _xdotool("getmouselocation", "--shell").splitlines() + ) + _xdotool("click", "1") + time.sleep(0.8) + return int(where["X"]), int(where["Y"]) + + +def _png_size(path: Path) -> tuple[int, int]: + with path.open("rb") as handle: + header = handle.read(24) + return int.from_bytes(header[16:20], "big"), int.from_bytes(header[20:24], "big") + + +def _run(root: Path, other_clicks: bool = True) -> tuple[dict, list[str]]: + failures: list[str] = [] + observed: dict = {} + env = { + k: v + for k, v in os.environ.items() + if not k.startswith(("OPENADAPT_", "OAFLOW_")) + } + apps = [] + for title, patient, banner in (TARGET, OTHER): + apps.append( + subprocess.Popen( + [ + sys.executable, + str(APP_SOURCE), + str(root / f"{patient}.sqlite"), + title, + patient, + banner, + "plain", + ], + env=env, + ) + ) + record = None + try: + target_id = _window_id(TARGET[0]) + other_id = _window_id(OTHER[0]) + for window, (x, y) in ((target_id, TARGET_ORIGIN), (other_id, OTHER_ORIGIN)): + _xdotool("windowmove", "--sync", str(window), str(x), str(y)) + time.sleep(0.5) + target_size = _size(target_id) + observed["target_size"] = target_size + observed["other_size"] = _size(other_id) + + out = root / "recording" + stop = root / "stop" + record_log = (root / "record.log").open("w") + record = subprocess.Popen( + [ + sys.executable, + "-m", + "openadapt_flow", + "record", + "--backend", + "linux", + "--linux-window-title", + TARGET[0], + "--out", + str(out), + "--task", + "window-scoped Linux record qualification", + "--until-cmd", + f"test -e {stop}", + ], + stdin=subprocess.DEVNULL, + stdout=record_log, + stderr=subprocess.STDOUT, + env=env, + ) + # Wait for capture to come up: it writes its session directory. + deadline = time.monotonic() + 60 + while time.monotonic() < deadline and record.poll() is None: + if (out / ".capture").exists(): + break + time.sleep(0.3) + time.sleep(3.0) + if record.poll() is not None: + failures.append(f"record exited early with {record.returncode}") + return observed, failures + + expected: list[tuple[int, int]] = [] + clicks: list[dict] = [] + for rel in TARGET_CLICKS: + clicks.append({"window": "target", "screen": _click(target_id, rel)}) + expected.append(rel) + for rel in OTHER_CLICKS if other_clicks else (): + clicks.append({"window": "other", "screen": _click(other_id, rel)}) + clicks.append( + {"window": "target", "screen": _click(target_id, LAST_TARGET_CLICK)} + ) + expected.append(LAST_TARGET_CLICK) + observed["clicks"] = clicks + time.sleep(1.0) + stop.touch() + try: + observed["record_exit"] = record.wait(timeout=180) + except subprocess.TimeoutExpired: + record.kill() + failures.append("record did not stop within 180 s") + return observed, failures + record_log.close() + if observed["record_exit"] != 0: + failures.append(f"record exited {observed['record_exit']}") + observed["record_log_tail"] = (root / "record.log").read_text()[-1500:] + return observed, failures + + events = [ + json.loads(line) + for line in (out / "events.jsonl").read_text().splitlines() + if line.strip() + ] + recorded = [e for e in events if e.get("kind") in ("click", "double_click")] + observed["event_kinds"] = [e.get("kind") for e in events] + observed["recorded_clicks"] = [(e.get("x"), e.get("y")) for e in recorded] + if len(recorded) != len(expected): + failures.append( + f"{len(recorded)} clicks recorded; expected the {len(expected)} target clicks" + ) + else: + for event, rel in zip(recorded, expected): + if ( + abs(float(event["x"]) - rel[0]) > TOLERANCE_PX + or abs(float(event["y"]) - rel[1]) > TOLERANCE_PX + ): + failures.append( + f"click at {(event['x'], event['y'])} is not the target point {rel}" + ) + sizes = sorted({_png_size(p) for p in (out / "frames").glob("*.png")}) + observed["frame_sizes"] = sizes + if sizes != [target_size]: + failures.append(f"frame sizes {sizes} are not the target window's size") + meta = json.loads((out / "meta.json").read_text()) + meta_text = json.dumps(meta) + observed["meta_names_target"] = TARGET[0] in meta_text + observed["meta_names_other"] = OTHER[0] in meta_text + if not observed["meta_names_target"] or observed["meta_names_other"]: + failures.append("meta.json does not name only the target window") + return observed, failures + finally: + if record is not None and record.poll() is None: + record.kill() + for app in apps: + app.kill() + app.wait(timeout=10) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--output", type=Path, default=None, help="write the JSON here too" + ) + parser.add_argument("--keep", action="store_true", help="keep the temp directory") + parser.add_argument( + "--skip-other-clicks", + action="store_true", + help="click only in the target (isolates Capture's dropped-input path)", + ) + args = parser.parse_args() + if shutil.which("xdotool") is None: + print("xdotool is required", file=sys.stderr) + return 2 + root = Path(tempfile.mkdtemp(prefix="oa-linux-window-record-")) + try: + observed, failures = _run(root, other_clicks=not args.skip_other_clicks) + finally: + if not args.keep: + shutil.rmtree(root, ignore_errors=True) + observed["passed"] = not failures + observed["failures"] = failures + line = json.dumps(observed, sort_keys=True) + print(line) + if args.output is not None: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(line + "\n") + for failure in failures: + print(f"window record check failed: {failure}", file=sys.stderr) + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/flow/tests/test_desktop_record.py b/packages/flow/tests/test_desktop_record.py index 6bf9249cc6..3f71a8fa37 100644 --- a/packages/flow/tests/test_desktop_record.py +++ b/packages/flow/tests/test_desktop_record.py @@ -645,20 +645,10 @@ def test_cli_record_default_desktop_rejects_secret(tmp_path: Path) -> None: ["--agent-url", "http://localhost:5001"], "local Capture session cannot bind to a WAA endpoint", ), - ( - "linux", - ["--linux-app", "gedit"], - "no Linux window-scoping primitive", - ), - ( - "linux", - ["--linux-window-title", "Untitled Document 1"], - "no Linux window-scoping primitive", - ), ( "linux", ["--linux-allow-physical-input"], - "no Linux window-scoping primitive", + "recording sends none", ), ( "rdp", @@ -737,6 +727,30 @@ def test_cli_record_macos_target_scopes_capture(tmp_path: Path, monkeypatch) -> assert captured["window"] == {"owner": "TextEdit", "title": "notes.txt"} +@pytest.mark.parametrize( + "flags", + [ + ["--linux-app", "chart", "--linux-window-title", "Chart"], + ["--window", "chart", "--window-title", "Chart"], + ["--window", "chart", "--linux-app", "CHART", "--window-title", "Chart"], + ], +) +def test_cli_record_linux_target_scopes_capture( + tmp_path: Path, monkeypatch, flags: list[str] +) -> None: + """Linux target flags and --window/--window-title scope one X11 window.""" + captured: dict = {} + monkeypatch.setattr( + "openadapt_flow.desktop_record.record_desktop_capture", + _fake_desktop_record(captured), + ) + rc = _run_cli( + ["record", "--backend", "linux", *flags, "--out", str(tmp_path / "rec")] + ) + assert rc == 0 + assert captured["window"] == {"owner": "chart", "title": "Chart"} + + @pytest.mark.parametrize( ("backend", "generic_flags", "target_flags"), [ @@ -746,6 +760,12 @@ def test_cli_record_macos_target_scopes_capture(tmp_path: Path, monkeypatch) -> ["--window-title", "notes.txt"], ["--macos-window-title", "draft.txt"], ), + ("linux", ["--window", "gedit"], ["--linux-app", "chart"]), + ( + "linux", + ["--window-title", "Chart"], + ["--linux-window-title", "Terminal"], + ), ], ) def test_cli_record_refuses_conflicting_capture_targets_before_capture( @@ -1002,7 +1022,7 @@ def test_record_desktop_window_unsupported_platform( """ from openadapt_flow import desktop_record - monkeypatch.setattr(desktop_record.sys, "platform", "linux") + monkeypatch.setattr(desktop_record.sys, "platform", "freebsd14") with pytest.raises(SystemExit, match="not supported on this host"): record_desktop_capture( tmp_path / "rec", @@ -1012,6 +1032,100 @@ def test_record_desktop_window_unsupported_platform( ) +@pytest.mark.parametrize( + ("environ", "message"), + [ + ({"XDG_SESSION_TYPE": "wayland", "DISPLAY": ":0"}, "Wayland session"), + ({"WAYLAND_DISPLAY": "wayland-0", "DISPLAY": ":0"}, "Wayland session"), + ({"XDG_SESSION_TYPE": "x11"}, "DISPLAY is not set"), + ], +) +def test_record_desktop_window_linux_needs_x11( + tmp_path: Path, monkeypatch, environ: dict, message: str +) -> None: + """On Linux, --window is refused before Capture starts unless X11 is up.""" + from openadapt_flow import desktop_record + + monkeypatch.setattr(desktop_record.sys, "platform", "linux") + for name in ("XDG_SESSION_TYPE", "WAYLAND_DISPLAY", "DISPLAY"): + monkeypatch.delenv(name, raising=False) + for name, value in environ.items(): + monkeypatch.setenv(name, value) + log: list = [] + with pytest.raises(SystemExit, match=message): + record_desktop_capture( + tmp_path / "rec", + window={"owner": "chart", "title": None}, + recorder_factory=_make(log), + stop=lambda: True, + announce=False, + ) + assert log == [] + assert not (tmp_path / "rec").exists() + + +def test_record_desktop_window_linux_x11_forwarded( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Under X11 the Linux window spec reaches the Capture recorder.""" + from openadapt_flow import desktop_record + + monkeypatch.setattr(desktop_record.sys, "platform", "linux") + monkeypatch.setattr(desktop_record, "_require_capture_extra", lambda: None) + monkeypatch.delenv("WAYLAND_DISPLAY", raising=False) + monkeypatch.setenv("XDG_SESSION_TYPE", "x11") + monkeypatch.setenv("DISPLAY", ":99") + seen: dict = {} + log: list = [] + + def spy_default(task, cap_dir, window=None): + seen["window"] = window + return _FakeRecorder(task, cap_dir, log) + + monkeypatch.setattr(desktop_record, "_default_recorder_factory", spy_default) + monkeypatch.setattr( + desktop_record, "_installed_capture_lacks_linux_windows", lambda: False + ) + record_desktop_capture( + tmp_path / "rec", + window={"owner": "chart", "title": "Chart"}, + convert=lambda cap_dir, out_dir, *, params=None: Path(out_dir), + stop=lambda: True, + announce=False, + ) + assert seen["window"] == {"owner": "chart", "title": "Chart"} + + +def test_record_desktop_window_linux_refuses_old_capture( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """An openadapt-capture without Linux window capture is refused up front.""" + from openadapt_flow import desktop_record + + monkeypatch.setattr(desktop_record.sys, "platform", "linux") + monkeypatch.delenv("WAYLAND_DISPLAY", raising=False) + monkeypatch.setenv("XDG_SESSION_TYPE", "x11") + monkeypatch.setenv("DISPLAY", ":99") + monkeypatch.setattr( + desktop_record, "_installed_capture_lacks_linux_windows", lambda: True + ) + started: list = [] + monkeypatch.setattr( + desktop_record, + "_default_recorder_factory", + lambda *a, **k: started.append(a) or pytest.fail("Capture started"), + ) + with pytest.raises(SystemExit, match="openadapt-capture 1.3.0 or later"): + record_desktop_capture( + tmp_path / "rec", + window={"owner": "chart", "title": "Chart"}, + stop=lambda: True, + announce=False, + ) + assert started == [] + assert not (tmp_path / "rec").exists() + + def test_record_desktop_no_window_ok_on_any_platform( tmp_path: Path, monkeypatch ) -> None: