import importlib.util import json import os from pathlib import Path import subprocess import tempfile import unittest from unittest.mock import patch BACKEND = Path(__file__).resolve().parents[2] / "quickshell/.config/quickshell/labfy-sway/overview/backend.py" spec = importlib.util.spec_from_file_location("overview_backend", BACKEND) backend = importlib.util.module_from_spec(spec) spec.loader.exec_module(backend) class OverviewBackendTests(unittest.TestCase): def setUp(self): # CONTRACT: even secondary state reads never reach the personal cache. self.temp = tempfile.TemporaryDirectory() self.addCleanup(self.temp.cleanup) env = patch.dict(os.environ, {"XDG_RUNTIME_DIR": self.temp.name, "XDG_CONFIG_HOME": self.temp.name}) env.start(); self.addCleanup(env.stop) previous = os.umask(0o077) self.addCleanup(os.umask, previous) def test_lock_guard_blocks_all_capture_entrypoints(self): # CONTRACT: no compositor read or grim call follows an active lock # guard, including the window-preview path. with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"XDG_RUNTIME_DIR": directory}): (Path(directory) / "labfy-lock.capture-guard").write_text("active\n") with patch.object(backend, "state", side_effect=AssertionError("capture reached state")): self.assertEqual(backend.capture("HEADLESS-1"), {"captured": False}) self.assertEqual(backend.capture_windows({1}), {"captured": [], "nextCursor": 0}) def test_state_keeps_floating_and_excludes_layer_shell(self): rect = {"x": 0, "y": 0, "width": 1000, "height": 700} tiled = {"type": "con", "id": 10, "app_id": "native-app", "name": "Native", "foreign_toplevel_identifier": "a" * 32, "rect": rect, "nodes": [], "floating_nodes": []} floating = {"type": "floating_con", "id": 11, "app_id": "xwayland-app", "window_properties": {"class": "xwayland-app"}, "name": "Floating", "rect": rect, "floating": "user_on", "nodes": [], "floating_nodes": []} workspace = {"type": "workspace", "id": 4, "name": "1: travail", "rect": rect, "layout": "splith", "nodes": [tiled], "floating_nodes": [floating]} output = {"type": "output", "id": 3, "name": "eDP-1", "nodes": [workspace], "floating_nodes": []} tree = {"type": "root", "nodes": [output], "floating_nodes": []} ws = [{"id": 4, "name": "1: travail", "num": 1, "output": "eDP-1", "visible": True, "focused": True}] outputs = [{"name": "eDP-1", "active": True, "focused": True, "rect": rect, "layer_shell_surfaces": [{"namespace": "labfy-sway-bar"}]}] with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"XDG_RUNTIME_DIR": directory}): with patch.object(backend, "sway", side_effect=lambda kind: { "get_tree": tree, "get_workspaces": ws, "get_outputs": outputs}[kind]): result = backend.state() self.assertEqual(len(result["cards"]), 10) self.assertEqual([window["id"] for window in result["cards"][0]["windows"]], [10, 11]) self.assertTrue(result["cards"][0]["windows"][1]["floating"]) self.assertEqual(result["cards"][0]["windows"][0]["foreignId"], "a" * 32) self.assertFalse(result["cards"][1]["exists"]) outputs[0]["layer_shell_surfaces"].append({"namespace": "labfy-applications-menu"}) with patch.object(backend, "sway", side_effect=lambda kind: { "get_tree": tree, "get_workspaces": ws, "get_outputs": outputs}[kind]): self.assertEqual(backend.state()["overlayOutputs"], ["eDP-1"]) def test_move_uses_only_valid_con_id_and_workspace(self): cards = [{"number": 1, "revision": "before", "windows": [{"id": 42}]}, {"number": 4, "revision": "destination", "windows": []}] moved = [{"number": 1, "revision": "after", "windows": []}, {"number": 4, "revision": "after-destination", "windows": [{"id": 42}]}] fake = subprocess.CompletedProcess([], 0, '[{"success":true}]', "") with patch.object(backend, "state", side_effect=[{"cards": cards}, {"cards": moved}, {"cards": cards}, {"cards": cards}]), \ patch.object(backend.subprocess, "run", return_value=fake) as run: result = backend.command("move", 4, 42) self.assertTrue(result["ok"]) self.assertEqual((result["source"], result["destination"], result["changedWorkspaces"]), (1, 4, [1, 4])) self.assertEqual(run.call_args.args[0], ["swaymsg", "-r", "[con_id=42]", "move", "container", "to", "workspace", "number", "4"]) with self.assertRaises(ValueError): backend.command("move", 11, 42) with self.assertRaises(ValueError): backend.command("move", 4, 43) self.assertEqual(run.call_count, 1) def test_revision_changes_for_layout_geometry_and_move_but_not_title_or_focus(self): rect = {"x": 0, "y": 0, "width": 1000, "height": 700} window = {"type": "con", "id": 42, "app_id": "test", "name": "Original", "focused": False, "rect": dict(rect), "nodes": [], "floating_nodes": []} workspace = {"type": "workspace", "id": 4, "num": 1, "output": "eDP-1", "layout": "splith", "rect": rect, "nodes": [window], "floating_nodes": []} first = backend.structure_revision(workspace, workspace, rect) window["name"] = "Nouveau titre" window["focused"] = True self.assertEqual(first, backend.structure_revision(workspace, workspace, rect)) workspace["layout"] = "tabbed" self.assertNotEqual(first, backend.structure_revision(workspace, workspace, rect)) workspace["layout"] = "splith" window["rect"] = {**rect, "width": 500} self.assertNotEqual(first, backend.structure_revision(workspace, workspace, rect)) window["rect"] = rect workspace["nodes"] = [] self.assertNotEqual(first, backend.structure_revision(workspace, workspace, rect)) def test_changed_workspace_rejects_old_snapshot_and_empty_workspace_removes_it(self): rect = {"x": 0, "y": 0, "width": 1000, "height": 700} window = {"type": "con", "id": 42, "app_id": "test", "name": "Test", "rect": dict(rect), "nodes": [], "floating_nodes": []} workspace = {"type": "workspace", "id": 4, "num": 1, "output": "eDP-1", "layout": "splith", "rect": rect, "nodes": [window], "floating_nodes": []} tree = {"type": "root", "nodes": [{"type": "output", "nodes": [workspace]}]} workspaces = [{"id": 4, "num": 1, "output": "eDP-1", "visible": True, "focused": True}] outputs = [{"name": "eDP-1", "active": True, "focused": True, "rect": rect}] with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"XDG_RUNTIME_DIR": directory}), \ patch.object(backend, "sway", side_effect=lambda kind: { "get_tree": tree, "get_workspaces": workspaces, "get_outputs": outputs}[kind]): path = backend.snapshot_path("eDP-1", 1) path.write_bytes(b"JPEG") original = backend.structure_revision(workspace, workspaces[0], rect) backend.snapshot_metadata_path("eDP-1", 1).write_text(json.dumps({ "version": 1, "output": "eDP-1", "number": 1, "workspaceId": 4, "revision": original, "capturedNs": 123, "lockRevision": [], "imageMtimeNs": path.stat().st_mtime_ns})) self.assertEqual(backend.state()["cards"][0]["snapshot"], "") window["name"] = "Titre changé" self.assertEqual(backend.state()["cards"][0]["snapshot"], "") window["rect"] = {**rect, "width": 500} changed = backend.state()["cards"][0] self.assertEqual(changed["snapshot"], "") self.assertEqual([item["id"] for item in changed["windows"]], [42]) workspace["nodes"] = [] empty = backend.state()["cards"][0] self.assertEqual(empty["snapshot"], "") self.assertEqual(empty["windows"], []) self.assertFalse(path.exists()) self.assertFalse(backend.snapshot_metadata_path("eDP-1", 1).exists()) def test_capture_rejects_overlay_and_mid_capture_change(self): card = {"number": 1, "workspaceId": 4, "revision": "first", "visible": True, "output": "eDP-1", "windows": [{"id": 42}]} with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"XDG_RUNTIME_DIR": directory}): with patch.object(backend, "state", return_value={ "cards": [card], "overlayOutputs": ["eDP-1"]}): self.assertFalse(backend.capture("eDP-1")["captured"]) def fake_grim(args, **_kwargs): Path(args[-1]).write_bytes(b"JPEG") return subprocess.CompletedProcess(args, 0, "", "") changed = {**card, "revision": "second"} with patch.object(backend, "state", side_effect=[ {"cards": [card], "overlayOutputs": []}, {"cards": [changed], "overlayOutputs": []}]), \ patch.object(backend.subprocess, "run", side_effect=fake_grim): self.assertFalse(backend.capture("eDP-1")["captured"]) self.assertFalse(backend.snapshot_path("eDP-1", 1).exists()) def test_composed_capture_disabled_even_on_visible_workspace(self): # Whole-output capture would leak excluded applications behind a card. with patch.object(backend.subprocess, "run", side_effect=AssertionError("grim reached")): self.assertEqual(backend.capture("eDP-1"), {"captured": False}) def test_removed_workspace_cleans_only_its_cache_entries(self): tree = {"type": "root", "nodes": [], "floating_nodes": []} outputs = [{"name": "eDP-1", "active": True, "focused": True}] with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"XDG_RUNTIME_DIR": directory}), \ patch.object(backend, "sway", side_effect=lambda kind: { "get_tree": tree, "get_workspaces": [], "get_outputs": outputs}[kind]): obsolete = backend.snapshot_path("eDP-1", 8) retained = backend.cache_dir() / "unrelated.txt" obsolete.write_bytes(b"old") retained.write_bytes(b"other") backend.state() self.assertFalse(obsolete.exists()) self.assertTrue(retained.exists()) def test_window_preview_survives_workspace_move_but_not_window_close(self): rect = {"x": 0, "y": 0, "width": 1000, "height": 700} window = {"type": "con", "id": 42, "app_id": "test", "name": "Test", "foreign_toplevel_identifier": "a" * 32, "rect": rect, "nodes": [], "floating_nodes": []} source = {"type": "workspace", "id": 4, "num": 7, "output": "eDP-1", "layout": "splith", "rect": rect, "nodes": [window], "floating_nodes": []} destination = {"type": "workspace", "id": 5, "num": 8, "output": "eDP-1", "layout": "splith", "rect": rect, "nodes": [], "floating_nodes": []} tree = {"type": "root", "nodes": [source, destination]} workspaces = [{"id": 4, "num": 7, "output": "eDP-1", "visible": True, "focused": True}, {"id": 5, "num": 8, "output": "eDP-1", "visible": False, "focused": False}] outputs = [{"name": "eDP-1", "active": True, "focused": True, "rect": rect}] with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"XDG_RUNTIME_DIR": directory}), \ patch.object(backend, "sway", side_effect=lambda kind: { "get_tree": tree, "get_workspaces": workspaces, "get_outputs": outputs}[kind]): path = backend.window_preview_path(42) path.write_bytes(b"JPEG") backend.window_preview_metadata_path(42).write_text(json.dumps({ "version": 1, "conId": 42, "foreignId": "a" * 32, "capturedNs": 123, "lockRevision": [], "imageMtimeNs": path.stat().st_mtime_ns})) before = backend.state()["cards"] self.assertNotEqual(before[6]["windows"][0]["preview"], "") source["nodes"] = [] destination["nodes"] = [window] moved = backend.state()["cards"] self.assertEqual(moved[6]["windows"], []) self.assertEqual(moved[7]["windows"][0]["preview"], backend.image_url(path)) destination["nodes"] = [] backend.state() self.assertFalse(path.exists()) def test_individual_capture_uses_foreign_id_and_checks_window_lifetime(self): foreign_id = "b" * 32 window = {"id": 42, "foreignId": foreign_id, "preview": ""} before = {"cards": [{"number": 7, "windows": [window]}]} closed = {"cards": [{"number": 7, "windows": []}]} def fake_grim(args, **_kwargs): Path(args[-1]).write_bytes(b"JPEG") return subprocess.CompletedProcess(args, 0, b"", b"") with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"XDG_RUNTIME_DIR": directory}), \ patch.object(backend, "overview_open", return_value=True), \ patch.object(backend.subprocess, "run", side_effect=fake_grim) as run: with patch.object(backend, "state", side_effect=[before, before]): result = backend.capture_windows({7}) self.assertEqual(result["captured"], [42]) self.assertEqual(run.call_args.args[0][0:3], ["grim", "-T", foreign_id]) self.assertIsNotNone(backend.valid_window_preview(42, foreign_id)) self.assertIsNone(backend.valid_window_preview(42, "c" * 32)) with patch.object(backend, "state", side_effect=[before, closed]): result = backend.capture_windows({7}) self.assertEqual(result["captured"], []) def test_individual_captures_are_batched(self): windows = [{"id": ident, "foreignId": f"{ident:032x}", "preview": ""} for ident in range(1, 18)] cards = {"cards": [{"number": 7, "windows": windows}]} def fake_grim(args, **_kwargs): Path(args[-1]).write_bytes(b"JPEG") return subprocess.CompletedProcess(args, 0, b"", b"") with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"XDG_RUNTIME_DIR": directory}), \ patch.object(backend, "state", return_value=cards), \ patch.object(backend, "overview_open", return_value=True), \ patch.object(backend.subprocess, "run", side_effect=fake_grim) as run: first = backend.capture_windows({7}) self.assertEqual(first["captured"], list(range(1, 5))) self.assertEqual(first["nextCursor"], 4) captured = first["captured"] while first["nextCursor"]: first = backend.capture_windows({7}, cursor=first["nextCursor"]) captured += first["captured"] self.assertEqual(captured, list(range(1, 18))) self.assertEqual(run.call_count, 17) if __name__ == "__main__": unittest.main()