From 3d7a4431319d71ef9c739ac74f73b5ebf6d0e431 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 5 Nov 2026 17:56:39 -0500 Subject: [PATCH] Revert "flow: scope Linux X11 recording to one window (#1966)" This reverts commit a23c2d8303deff4fdc7474a7013f0c52992b4bd3. --- .github/workflows/packages.yml | 30 +- packages/flow/CHANGELOG.md | 7 - packages/flow/docs/SURFACES.md | 6 +- packages/flow/docs/desktop/RECORDING.md | 16 +- packages/flow/openadapt_flow/__main__.py | 51 ++-- .../flow/openadapt_flow/desktop_record.py | 62 +--- .../scripts/qualify_linux_window_record.py | 266 ------------------ packages/flow/tests/test_desktop_record.py | 138 +-------- 8 files changed, 56 insertions(+), 520 deletions(-) delete mode 100644 packages/flow/scripts/qualify_linux_window_record.py diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index 5d97331437..ee994aa3b4 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -817,9 +817,7 @@ 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; qualify_linux_window_record.py records - # that app through Capture scoped to its window, with a second window open. - # All results are uploaded. + # on its AT-SPI element identity. Both results are uploaded. flow-linux-atspi: name: flow native Linux (AT-SPI, Xvfb) needs: scope @@ -858,24 +856,19 @@ 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 xdotool xvfb + libcairo2-dev libgirepository-2.0-dev openbox pkg-config xauth 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 - packages/capture/uv.lock + cache-dependency-glob: packages/flow/uv.lock cache-suffix: flow-linux-atspi - - 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. + - name: Install from the lock run: >- - python ../../scripts/ci/locked_install.py --lock 'packages/flow[dev,linux,capture]' - --lock packages/capture - --editable packages/types --editable packages/capture --editable packages/flow + python ../../scripts/ci/locked_install.py --lock 'packages/flow[dev,linux]' + --editable packages/types --editable packages/flow - name: Qualify LinuxBackend on the GTK3 fixture timeout-minutes: 10 run: | @@ -895,17 +888,6 @@ 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 7fb86c3f1e..75a582a84b 100644 --- a/packages/flow/CHANGELOG.md +++ b/packages/flow/CHANGELOG.md @@ -19,13 +19,6 @@ ### 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 d8503b937d..1534cc6d55 100644 --- a/packages/flow/docs/SURFACES.md +++ b/packages/flow/docs/SURFACES.md @@ -147,10 +147,8 @@ openadapt-flow compile rec --out bundle --name my-task openadapt-flow replay bundle --macos-app TextEdit \ --macos-window-title notes.txt -# 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 +# Linux: Capture records the local desktop; AT-SPI selects the replay target. +openadapt-flow record --backend linux --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 36fba457e8..2fb0168e4c 100644 --- a/packages/flow/docs/desktop/RECORDING.md +++ b/packages/flow/docs/desktop/RECORDING.md @@ -165,18 +165,10 @@ 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, 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 +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 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 f1626c11ce..bd65924ff0 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": {"linux_app", "linux_window_title"}, + "linux": set(), "rdp": {"rdp_window", "rdp_window_title", "rdp_readiness_text"}, "citrix": {"rdp_window", "rdp_window_title", "rdp_readiness_text"}, } @@ -198,12 +198,15 @@ 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 == "linux_allow_physical_input": + elif backend == "linux" and attr in { + "linux_app", + "linux_window_title", + "linux_allow_physical_input", + }: reason = ( - "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" + "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" ) elif backend == "rdp" and attr == "rdp_host": reason = ( @@ -249,22 +252,26 @@ def _resolve_record_capture_window( owner = getattr(args, "window", None) title = getattr(args, "window_title", None) - 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. + 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": owner = _merge_record_window_selector( owner, - getattr(args, f"{backend}_app", None), + getattr(args, "macos_app", None), generic_flag="--window", - target_flag=f"--{backend}-app", + target_flag="--macos-app", backend=backend, ) title = _merge_record_window_selector( title, - getattr(args, f"{backend}_window_title", None), + getattr(args, "macos_window_title", None), generic_flag="--window-title", - target_flag=f"--{backend}-window-title", + target_flag="--macos-window-title", backend=backend, ) elif backend in ("rdp", "citrix"): @@ -8508,9 +8515,8 @@ def _add_backend_flags(p: argparse.ArgumentParser) -> None: metavar="APP", help=( "Exact AT-SPI application name for --backend linux (e.g. gedit). " - "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." + "Replay/run only: the current Capture path records the local Linux " + "desktop and refuses this flag. Overrides backend.linux_app." ), ) p.add_argument( @@ -8519,9 +8525,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. 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." + "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." ), ) p.add_argument( @@ -9192,9 +9198,8 @@ 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, 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." + "Supported on macOS and Windows hosts; refused elsewhere. " + "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 c933da2359..5af412840d 100644 --- a/packages/flow/openadapt_flow/desktop_record.py +++ b/packages/flow/openadapt_flow/desktop_record.py @@ -56,50 +56,9 @@ # (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 / 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 +# 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") class _CaptureRecorder(Protocol): @@ -356,22 +315,9 @@ 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"{', '.join(WINDOW_CAPTURE_PLATFORMS)}. Record on a supported " + f"{' and '.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 deleted file mode 100644 index bb44245eaa..0000000000 --- a/packages/flow/scripts/qualify_linux_window_record.py +++ /dev/null @@ -1,266 +0,0 @@ -#!/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 3f71a8fa37..6bf9249cc6 100644 --- a/packages/flow/tests/test_desktop_record.py +++ b/packages/flow/tests/test_desktop_record.py @@ -645,10 +645,20 @@ 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"], - "recording sends none", + "no Linux window-scoping primitive", ), ( "rdp", @@ -727,30 +737,6 @@ 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"), [ @@ -760,12 +746,6 @@ def test_cli_record_linux_target_scopes_capture( ["--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( @@ -1022,7 +1002,7 @@ def test_record_desktop_window_unsupported_platform( """ from openadapt_flow import desktop_record - monkeypatch.setattr(desktop_record.sys, "platform", "freebsd14") + monkeypatch.setattr(desktop_record.sys, "platform", "linux") with pytest.raises(SystemExit, match="not supported on this host"): record_desktop_capture( tmp_path / "rec", @@ -1032,100 +1012,6 @@ 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: