diff --git a/.gitignore b/.gitignore index 05d2e8dd..c289c1cc 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ po/forge.pot .vscode node_modules lib/prefs/metadata.js +__pycache__/ diff --git a/Makefile b/Makefile index 739ec5c3..e14f32dd 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ UUID = "forge@jmmaranan.com" INSTALL_PATH = $(HOME)/.local/share/gnome-shell/extensions/$(UUID) MSGSRC = $(wildcard po/*.po) -.PHONY: all clean install schemas uninstall enable disable log debug patchcss +.PHONY: all clean install schemas uninstall enable disable log debug patchcss test-sandbox all: build install enable restart @@ -96,6 +96,10 @@ restart: gnome-session-quit --logout; \ fi +# Automated tests in a nested, isolated GNOME Shell (see tests/README.md). Never restarts your shell. +test-sandbox: + cd tests && sandbox/run-suite.sh + horizontal-line: @printf '%.s─' $$(seq 1 $$(tput cols)) && echo || true # Prints a line of dashes # diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..78482260 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,242 @@ +# Forge test harness + +Automated, repeatable tests for the [Forge](https://github.com/forge-ext/forge) GNOME Shell tiling +extension. Each test runs Forge in a **nested, isolated GNOME Shell**, drives it with a virtual +mouse and keyboard, and checks the resulting layout exactly: window positions and sizes, gaps, minimum sizes, and each +container's shares. It never touches the GNOME session you are working in. + +It started as the reproduction repository for a set of Forge bug reports, and it is written so +that both **people** and **AI coding agents** can use it: +- reproduce a bug; +- check a fix; +- look for new bugs with the randomized stress test; +- measure performance. + +- [Quick start](#quick-start) +- [How it works](#how-it-works) +- [Scenarios](#scenarios) +- [Writing a scenario](#writing-a-scenario) +- [Debugging](#debugging) +- [For AI agents](#for-ai-agents) +- [Limitations](#limitations) + +## Quick start + +Requirements (Debian/Ubuntu package names): +- GNOME Shell 50+ (the tests run `gnome-shell --headless` with a virtual monitor); +- `make`, `gettext`, `zip`, `unzip` (to build Forge), `dconf-cli` (the `real` profile); +- Python 3 with PyGObject and GTK 3's introspection data (`python3-gi`, `gir1.2-gtk-3.0`); +- GNOME Text Editor (every scenario uses it). + +Optional: +- `07_real_apps.py` also uses Ptyxis, Files (Nautilus) and VS Code, and `12_monitors_x11.py` uses + `xmessage` (`x11-utils`). A scenario whose apps are missing is reported as skipped. +- Pillow (`python3-pil`) for screenshots. +- To watch the tests live: GStreamer with `pipewiresrc` and `gtksink` (`gstreamer1.0-pipewire`, + `gstreamer1.0-gtk3`). + +Run the commands below from `tests/`. It builds the Forge checkout it is part of; set +`FORGE_SRC=/path/to/forge` to test another checkout or a git worktree. From the top of the +repository, `make test-sandbox` runs the whole suite. + +```sh +sandbox/watch.py & # optional: a window that shows the test monitor live +sandbox/run-suite.sh # build Forge, run every scenario, print a summary; + # exit status 0 = every scenario passed (or was skipped) +sandbox/run-suite.sh 01 03 # just these scenarios +``` + +Or step by step: + +```sh +sandbox/launch.sh # build Forge, start the nested shell (1920x1080, headless) +scenarios/03_resize_bounds.py # PASS/FAIL per check; exit code 0 = all passed +sandbox/stop.sh +``` + +Each scenario needs a **fresh** sandbox, because it opens its own windows; `run-suite.sh` starts one per +scenario (with a second monitor for the scenarios that ask for one). It keeps each scenario's output in +`~/.cache/forge-sandbox/suite/-/`; a checkout with uncommitted changes gets its own +directory (`-dirty-`), so a run before and after a change never overwrite each other. +Output looks like this: + +``` +bug: growing a window into a neighbour at its minimum size + PASS 3.1: hold 'grow right' on A for 3304 ms + FAIL 3.1 (while held): no overlap or off-screen while the key repeats + - 29 and 30 overlap by 360 px (2804ms holding 2300ms calls=65) +... +sandbox JS errors: 0 +RESULT: 8/12 passed +``` + +### Profiles and screen sizes + +- `sandbox/launch.sh 2560x1440` sets the virtual monitor size; `SANDBOX_SECOND_MONITOR=1280x1024` adds a second monitor. +- `SANDBOX_PROFILE=real sandbox/launch.sh` mirrors the machine you run it on as closely as + possible: + - its monitor size; + - the session mode (e.g. `ubuntu`, with the Ubuntu dock and desktop icons); + - its enabled and disabled extensions; + - its Forge settings, copied read-only into the sandbox. + + This catches problems that only show up with a real setup. For example, on a wide screen + Forge's auto-split nests containers in the same direction, and that exposed a bug the default + profile could not. +- The scenarios adapt to the screen size and read Forge's shortcuts from its settings. + +## How it works + +- **Isolation.** `sandbox/launch.sh` builds Forge with `make build && make dist` and installs it + into a throwaway directory (`~/.cache/forge-sandbox/sandbox`). It then starts + `gnome-shell --headless --virtual-monitor WxH` with: + - its **own D-Bus session bus** (`dbus-run-session`); + - its own extensions directory (`XDG_DATA_HOME`); + - keyfile settings (`GSETTINGS_BACKEND=keyfile`). + + Your session's dconf, extensions and windows are never touched. The sandbox does share your + `XDG_RUNTIME_DIR` (its Wayland socket lives there), so it never starts a document portal: that + would try to mount over your session's `$XDG_RUNTIME_DIR/doc`. + + ⚠️ Only `make build` and `make dist` are used, because Forge's default `make` target runs + `killall -HUP gnome-shell`. +- **Driving it.** A sandbox-only helper extension (`sandbox/sandbox-unsafe@local`) enables unsafe + mode in the nested shell. That lets the harness run JavaScript inside it through + `org.gnome.Shell.Eval` on the sandbox's private bus; `harness.bus()` refuses any other bus. + Mouse and keyboard input come from Clutter virtual devices created inside the nested shell, so + the whole path is real: key repeat, grabs, Forge's keybindings and live-resize loop. +- **Checking.** `harness.layout_problems()` checks the layout rules after an action: + - every window is inside the work area; + - no two windows overlap, except those sharing a tabbed or stacked container; + - every window has the size Forge laid it out at; + - no window is below the minimum size its app allows; + - each split's shares are in (0, 1] and add up to 1. + + `harness.timeline_problems()` checks the same while a drag or held key is still in progress, + from samples taken every 100 ms. It allows 30 px for Wayland applying a new position a frame + before the new size. +- **Watching.** `sandbox/watch.py` screencasts the test monitor (Mutter ScreenCast on the sandbox + bus → PipeWire → GStreamer) into a window on your desktop, and reconnects to each new sandbox. + +## Scenarios + +| Scenario | What it checks | Bug / PR | +|---|---|---| +| `01_cross_container_snapback.py` | Mouse drags and held resize shortcuts against a neighbour in **another container** keep their size | [#545](https://github.com/forge-ext/forge/issues/545), [#532](https://github.com/forge-ext/forge/issues/532) | +| `02_keyboard_resize_edge.py` | "Grow bottom/top" shortcuts move the bottom/top edge, not the opposite one | [#543](https://github.com/forge-ext/forge/issues/543) | +| `03_resize_bounds.py` | A resize **stops at the neighbour's minimum size**, also while a key is held and with a slow app | [#546](https://github.com/forge-ext/forge/issues/546) | +| `04_min_size_layout.py` | Windows keep their **minimum size** when the space shrinks, and a later resize doesn't drift | [#117](https://github.com/forge-ext/forge/issues/117), [#271](https://github.com/forge-ext/forge/issues/271) | +| `06_stale_tab_bar.py` | No tab bar is left behind when a tab group's windows close together, move out, or it's switched back to a split | [#548](https://github.com/forge-ext/forge/issues/548) | +| `07_real_apps.py` | Checks 01–03 with real apps (Ptyxis, Files, VS Code), in the layout Forge builds by itself and the other direction | | +| `08_nested_same_direction.py` | Resizing a window's outer edge leaves its sibling alone in `HSPLIT[A, HSPLIT[B, C]]` (mouse and keyboard) | [#547](https://github.com/forge-ext/forge/issues/547) | +| `09_fuzz.py` | **Randomized stress test**: random actions, the layout rules checked after each one | finds new bugs | +| `10_performance.py` | Timings of Forge's hot paths, and the window move requests it sends (none should be redundant) | [#558](https://github.com/forge-ext/forge/issues/558) | +| `11_slow_app.py` | A slow app's window is resized, not slid sideways, during a held resize shortcut | [#544](https://github.com/forge-ext/forge/issues/544) | +| `12_monitors_x11.py` | Moving a window to another monitor and back, maximize/unmaximize, an X11 app (second monitor) | regression checks | +| `13_overflow_policy.py` | Proposal: tabs or a stack when windows can't all get their minimum size (skipped on builds without it) | not a fix yet | +| `14_move_out_shares.py` | Moving a window out of its container keeps the size shares at 100% (no gap, nothing off-screen) | [#549](https://github.com/forge-ext/forge/issues/549) | +| `15_render_on_changes.py` | Forge still puts windows back after an app resizes its own window, or after a maximized window is restored | guard: renders still put windows back | +| `16_tabbed_edge_resize.py` | Dragging the edge of a window in a tabbed or stacked group resizes the group, from any of its windows | [#561](https://github.com/forge-ext/forge/issues/561) | + +`09_fuzz.py --seed N --steps M` is deterministic for a given seed (default 303, so suite runs are +repeatable); `--seed random` explores. When a step breaks a rule, it stops and prints the steps so +far, so the failure can be replayed. + +## Writing a scenario + +A scenario is a Python script that opens windows, acts, and checks. The harness (`lib/harness.py`) +provides: + +| Function | Purpose | +|---|---| +| `windows()` | Tiled/floating windows on the current workspace: `id, x, y, w, h, percent, playout, pid, depth, float` | +| `open_editor()`, `open_app(*argv)` | Open a window in the sandbox and wait until Forge tiles it | +| `nested_layout(openers)` | Three windows as `[A] + CON[B, C]`; returns `(A, B, C)` | +| `ensure_parent_layout(id, "HSPLIT"/"VSPLIT")` | Set the direction of a window's container (auto-split varies with screen size) | +| `drag_edge(id, side, delta, during=f)` | Drag a window edge with the virtual mouse; returns a timeline log | +| `hold_keys(id, keys, ms, during=f)` | Hold a key chord (real key repeat); returns a timeline log | +| `GROW_KEYS[side]`, `SHRINK_KEYS[side]`, `chord(name)` | Key chords read from Forge's keybinding settings | +| `stall_app(id, after_s, for_s)` | For `during=`: freeze the window's app (SIGSTOP) as if it were slow to redraw | +| `layout_check(name, what)` | PASS/FAIL on `layout_problems()` after the layout settles | +| `timeline_check(name, what, log, ignore=id)` | PASS/FAIL on the in-progress samples; prints trajectories on failure | +| `settle_check(name, id, side, nb_id, expected_edge)` | An edge ended where it was released, one gap (`gap()`, from Forge's settings) from its neighbour | +| `set_forge_setting(key, value)`, `reset_layout()`, `tree_summary()`, `monitor_size()` | Settings, equal split, `HSPLIT[w1, VSPLIT[w2, w3]]`, work-area size | +| `require(apps=(...), setting=None)` | Skip the scenario (exit 77) if an app isn't installed or the build lacks a setting | +| `summary(results)` | Print `RESULT: n/m` and return the exit code (also fails on new `JS ERROR`s) | + +A minimal scenario: + +```python +#!/usr/bin/env python3 +import os, sys +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib")) +import harness as h + +h.open_editor(); h.open_editor() +a, b = sorted(h.windows(), key=lambda w: w["x"]) +log = h.hold_keys(a["id"], h.GROW_KEYS["right"], 2000) # hold "grow right" for 2 s +r = [h.layout_check("x.1", "layout valid after the hold"), + h.timeline_check("x.1 (while held)", "valid during the hold", log)] +sys.exit(h.summary(r)) +``` + +Screenshots for issues and PRs (`lib/shots.py`): +- `shot(path)`: the test monitor; +- `later(path, s)`: for `during=`, a screenshot partway through an action; +- `annotate(src, dst, boxes)`: labelled boxes; +- `side_by_side(dst, items)`: before/after panels. + +## Debugging + +- `sandbox/sb_eval.py 'JS expression'` runs JavaScript in the sandbox shell. Forge's window manager + is at `Main.extensionManager.lookup("forge@jmmaranan.com").stateObj.extWm`. +- `sandbox/sb_eval.py - < lib/tree.js` prints Forge's tree with rects and percents. +- `lib/trace.js` logs every call of Forge's resize functions with the frame and the baseline; + `lib/perf.js` times Forge's hot functions and counts window move requests. +- `~/.cache/forge-sandbox/sandbox/nested.log` is the nested shell's log (`JS ERROR` lines are + Forge exceptions). +- `sandbox/run-in-sandbox.sh ` runs a program inside the sandbox session. +- Several sandboxes can run in parallel: give each its own `SANDBOX_DIR`, `SANDBOX_DISPLAY` (e.g. + `wayland-sbA`) and Forge checkout (`FORGE_SRC`, e.g. a git worktree), because each build runs `make` in it. +- `SANDBOX_BACKEND=devkit sandbox/launch.sh` also opens the Mutter devkit viewer. Its monitor is not the + test monitor, so use `watch.py` to see the tests. + +## For AI agents + +This harness is designed to be run end to end by a coding agent without a person at the keyboard. + +**Workflow** +1. Reproduce first. Run the relevant scenario on the unmodified code and keep the output. A + failing check with numbers ("overlap by 360 px") is the bug report. +2. Change Forge. Run `npm test` in the Forge checkout (Forge's CI style check, Prettier). +3. Run the scenario again, then `sandbox/run-suite.sh` for regressions (its exit status says whether + everything passed), then `SANDBOX_PROFILE=real sandbox/run-suite.sh` for the machine's real screen + size and extensions. +4. Run `scenarios/09_fuzz.py --seed N` with a few seeds (or `--seed random`). A failure prints its steps: turn them + into a scenario, or fix the checker if the rule was wrong (e.g. tabbed windows share space). +5. For write-ups, capture evidence with `lib/shots.py`: a screenshot partway through the action + shows what a person would see. + +**Rules** +- Never run plain `make` in the Forge checkout (it restarts your GNOME Shell). Use the sandbox + scripts. +- Never talk to the host session's bus. `harness.bus()` only accepts the sandbox's private + `dbus-run-session` bus. Find sandbox processes with `sandbox_pids` (in `sandbox/env.sh`), never + with `pkill -f`/`pgrep -f` patterns: they match your own command lines. +- Don't disable the sandbox's monitor through DisplayConfig (the nested shell crashes). +- Don't pipe a long-running script into `head` (SIGPIPE kills it); use `tail` or redirect to a + file. +- In zsh, `$VAR` with spaces is not word-split: use the provided bash scripts, or apps may start on + the real display. +- Timing matters. Wayland applies a new position before a new size, and apps redraw at their own + pace. Judge "during the action" with the timeline checks (30 px tolerance), and the final state + with `layout_check` after it settles. + +## Limitations + +- GNOME Shell 50+ only. Older versions may work for the checks but lack `get_min_size()`. +- It can't run on GitHub-hosted CI runners, which don't have GNOME Shell 50. Run it locally or on + a self-hosted runner. +- Mouse drags: GNOME resizes the dragged window with the pointer. Mutter's new external + constraints could stop it at a limit, but they can't be changed from JavaScript (GJS copies the + rectangle). So the dragged window itself is exempt from the "while dragging" checks. diff --git a/tests/lib/drag.js b/tests/lib/drag.js new file mode 100644 index 00000000..5fd505de --- /dev/null +++ b/tests/lib/drag.js @@ -0,0 +1,102 @@ +// Simulate a mouse drag inside the SANDBOX shell with a Clutter virtual pointer, recording +// window frames and Forge node percents at each step. Parameters are substituted by +// harness.drag_edge(): +// __X0__ __Y0__ (press point), __DX__ __DY__ (total move), __STEPS__, __STEP_MS__ +(() => { + const X0 = __X0__, + Y0 = __Y0__, + DX = __DX__, + DY = __DY__, + STEPS = __STEPS__, + STEP_MS = __STEP_MS__; + const wm = Main.extensionManager.lookup("forge@jmmaranan.com").stateObj.extWm; + const seat = global.stage.context.get_backend().get_default_seat(); + const dev = seat.create_virtual_device(0 /* CLUTTER_POINTER_DEVICE */); + const now = () => GLib.get_monotonic_time(); + // numeric id of a window's nearest tabbed/stacked container (0: none); those windows share space + const groupIds = (globalThis.__groupIds ??= new WeakMap()); + const groupOf = (n) => { + for (let p = n.parentNode; p; p = p.parentNode) { + if (p.layout === "TABBED" || p.layout === "STACKED") { + if (!groupIds.has(p)) + groupIds.set(p, (globalThis.__groupNext = (globalThis.__groupNext ?? 0) + 1)); + return groupIds.get(p); + } + } + return 0; + }; + const snap = (tag) => { + const wins = wm.tree + .getNodeByType("WINDOW") + .filter( + (n) => + n.nodeValue.get_workspace() === global.workspace_manager.get_active_workspace() && + !n.nodeValue.minimized + ) + .map((n) => { + const f = n.nodeValue.get_frame_rect(); + return `${n.nodeValue.get_id() % 1000}:x=${f.x},y=${f.y},w=${f.width},h=${f.height},p=${( + n.percent ?? 0 + ).toFixed(3)},g=${groupOf(n)}`; + }); + st.log.push( + `${((now() - st.t0) / 1000).toFixed(0)}ms ${tag} focus=${ + global.display.focus_window?.get_id() % 1000 + } grabOp=${wm.grabOp ?? "-"} | ${wins.join(" | ")}` + ); + }; + const st = (globalThis.__drag = { t0: now(), log: [], done: false }); + const ids = [ + global.display.connect("grab-op-begin", (_d, w, op) => + snap(`SIGNAL grab-op-begin op=${op} win=${w?.get_id() % 1000}`) + ), + global.display.connect("grab-op-end", (_d, w, op) => + snap(`SIGNAL grab-op-end op=${op} win=${w?.get_id() % 1000}`) + ), + ]; + const seq = []; + seq.push(() => { + dev.notify_absolute_motion(now(), X0, Y0); + snap("moved to start"); + }); + let pressed = false; + seq.push(() => { + dev.notify_button(now(), 1, 1); + pressed = true; + snap("button pressed"); + }); + for (let i = 1; i <= STEPS; i++) + seq.push(() => { + dev.notify_absolute_motion(now(), X0 + (DX * i) / STEPS, Y0 + (DY * i) / STEPS); + snap(`step ${i}`); + }); + seq.push(() => { + snap("before release"); + }); + seq.push(() => { + dev.notify_button(now(), 1, 0); + pressed = false; + snap("button released"); + }); + for (let i = 1; i <= 4; i++) seq.push(() => snap(`after release +${i * STEP_MS * 3}ms`)); + // Always release the button and disconnect, even if a step throws + const finish = () => { + if (pressed) dev.notify_button(now(), 1, 0); + ids.forEach((id) => global.display.disconnect(id)); + st.done = true; + }; + let k = 0; + GLib.timeout_add(GLib.PRIORITY_DEFAULT, STEP_MS, () => { + try { + if (k < seq.length) { + seq[k++](); + return GLib.SOURCE_CONTINUE; + } + } catch (e) { + st.log.push(`ERROR ${e}`); + } + finish(); + return GLib.SOURCE_REMOVE; + }); + return "started"; +})(); diff --git a/tests/lib/harness.py b/tests/lib/harness.py new file mode 100644 index 00000000..120d8e95 --- /dev/null +++ b/tests/lib/harness.py @@ -0,0 +1,542 @@ +"""Shared helpers for driving the nested sandbox gnome-shell (see sandbox/launch.sh). + +All calls go to the sandbox's private D-Bus bus. Input is injected with Clutter virtual devices +created *inside* the nested shell, so nothing reaches the host session. +""" +import json +import os +import shutil +import signal +import subprocess +import sys +import time + +import gi + +gi.require_version("Gio", "2.0") +from gi.repository import Gio, GLib # noqa: E402 + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +LIB = os.path.join(ROOT, "lib") +SANDBOX_DIR = os.environ.get("SANDBOX_DIR") or os.path.join( + os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache"), "forge-sandbox", "sandbox") + +# Real-session mode (FORGE_TEST_REAL_SESSION=1): talk to the GNOME Shell of the session you are logged +# into, instead of a sandbox. It only works while that shell is in unsafe mode, a security trade-off, +# so it is not used by these tests; see forge-repro's realsession/ for a runner that enables it. +REAL_SESSION = os.environ.get("FORGE_TEST_REAL_SESSION") == "1" + +TOL = 10 # px allowed between an edge's position at release and where it settles +GAP_TOL = 4 # px allowed between the space between two windows and Forge's gap +WM = 'Main.extensionManager.lookup("forge@jmmaranan.com").stateObj.extWm' +# Forge's tree node for monitor 0 on the active workspace. Everything the harness inspects or +# changes is limited to it, so windows on other workspaces are never counted or touched. +HERE = WM + '.tree.findNode(`mo0ws${global.workspace_manager.get_active_workspace_index()}`)' + +_bus = None + + +def bus(): + global _bus + if _bus is None and REAL_SESSION: + _bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) + if _bus is None: + addr = open(os.path.join(SANDBOX_DIR, "bus-address")).read().strip() + if not addr.startswith("unix:path=/tmp/dbus-"): + raise SystemExit(f"refusing: {addr!r} is not a private dbus-run-session bus") + _bus = Gio.DBusConnection.new_for_address_sync( + addr, Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT | Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION, + None, None) + return _bus + + +def js_raw(code): + return bus().call_sync("org.gnome.Shell", "/org/gnome/Shell", "org.gnome.Shell", "Eval", + GLib.Variant("(s)", (code,)), GLib.VariantType("(bs)"), + Gio.DBusCallFlags.NONE, 20000, None).unpack() + + +def js(code): + ok, out = js_raw(code) + if not ok: + raise RuntimeError(out) + return json.loads(out) if out else None + + +def windows(): + """Tiled windows on monitor 0 with frame geometry, node percent and parent info.""" + return json.loads(js(f"""JSON.stringify(({HERE}?.getNodeByType("WINDOW") ?? []) + .map(n => {{ + const f = n.nodeValue.get_frame_rect(); + return {{ id: n.nodeValue.get_id(), x: f.x, y: f.y, w: f.width, h: f.height, float: n.isFloat(), + percent: n.percent ?? 0, playout: n.parentNode.layout, + pid: n.parentNode.nodeType === "CON" ? "con" : "top", + depth: (() => {{ let d = 0; for (let p = n.parentNode; p; p = p.parentNode) if (p.nodeType === "CON") d++; return d; }})() }}; }}))""")) + + +def park_pointer(): + """Move the sandbox pointer to the centre of monitor 0 so new windows open there.""" + js("""(() => { const r = global.display.get_monitor_geometry(0); + const d = global.stage.context.get_backend().get_default_seat().create_virtual_device(0); + d.notify_absolute_motion(GLib.get_monotonic_time(), r.x + r.width / 2, r.y + r.height / 2); + return "ok"; })()""") + + +def open_app(*argv, timeout=10, log=None): + """Open an app inside the sandbox and wait until Forge tiles its window. + `log`: file for the app's output (default: discarded).""" + park_pointer() + before = len(windows()) + out = open(log, "a") if log else subprocess.DEVNULL + launcher = [] if REAL_SESSION else [os.path.join(ROOT, "sandbox", "run-in-sandbox.sh")] + subprocess.Popen([*launcher, *argv], + stdout=out, stderr=out, stdin=subprocess.DEVNULL, start_new_session=True) + for _ in range(int(timeout * 4)): + time.sleep(0.25) + if len(windows()) > before: + time.sleep(1.0) + return + # Say what is on screen instead (e.g. an app's error or "already running" dialog) + shown = js("""JSON.stringify(global.display.list_all_windows().map(w => `${w.get_wm_class()}: ${w.get_title()}`))""") + raise RuntimeError(f"{argv[0]} window did not appear within {timeout} s; windows: {shown}") + + +def open_editor(): + open_app("gnome-text-editor", "--standalone") + + +def run_js_file(name, subs, flag, during=None): + code = open(os.path.join(LIB, name)).read() + for k, v in subs.items(): + code = code.replace(k, str(v)) + js(code) + if during: + during() # runs while the timeline is in progress + for _ in range(160): + time.sleep(0.25) + if js(f"String(globalThis.{flag}?.done)") == "true": + log = json.loads(js(f"JSON.stringify(globalThis.{flag}.log)")) + errors = [l for l in log if l.startswith("ERROR ")] + if errors: + raise RuntimeError(f"{name}: {errors[0][6:]}") + return log + raise RuntimeError(f"{name} did not finish") + + +def find(ws, wid): + return next(w for w in ws if w["id"] == wid) + + +def edge(win, side): + return {"left": win["x"], "right": win["x"] + win["w"], "top": win["y"], "bottom": win["y"] + win["h"]}[side] + + +OPP = {"left": "right", "right": "left", "top": "bottom", "bottom": "top"} +HORIZONTAL = {"left", "right"} + + +def neighbour_side(a, b): + """Side of window a that faces window b (they must share a border).""" + for side in ("left", "right", "top", "bottom"): + if abs(edge(a, side) - edge(b, OPP[side])) < 24: + return side + raise ValueError("windows are not adjacent") + + +def js_error_entries(): + """The Forge JS ERROR entries in the sandbox log (message + stack), as text blocks.""" + try: + if REAL_SESSION: # the session's journal since the run started + lines = subprocess.run(["journalctl", "--user", "-o", "cat", "--since", + "@" + os.environ["FORGE_TEST_SINCE"]], + capture_output=True, text=True).stdout.splitlines() + else: + lines = open(os.path.join(SANDBOX_DIR, "nested.log"), errors="replace").read().splitlines() + except FileNotFoundError: + return [] + entries = [] + for i, line in enumerate(lines): + if "JS ERROR" in line and any("forge@" in l for l in lines[i:i + 12]): + block = [line] + [l for l in lines[i + 1:i + 12] if "@" in l and ".js:" in l] + entries.append("\n".join(block)) + return entries + + +def js_errors(): + """Number of Forge exceptions in the sandbox log: JS ERROR entries whose message or stack + trace mentions Forge (other extensions, e.g. the Ubuntu desktop icons, log their own).""" + return len(js_error_entries()) + + +def gap(): + """The space Forge leaves between two tiled neighbours: twice its window gap (each window is + inset by the gap), from the sandbox's Forge settings.""" + return 2 * int(js(f"""(() => {{ const s = {WM}.ext.settings; + return s.get_uint("window-gap-size") * s.get_uint("window-gap-size-increment"); }})()""")) + + +def settle_check(name, moved_id, side, nb_id, expect_edge, tol=TOL, gap=None): + """PASS if the moved edge settled near expect_edge and the neighbour sits one gap away + (`gap`: default Forge's current gap, see gap()).""" + time.sleep(1.0) + gap = globals()["gap"]() if gap is None else gap + ws = windows() + m, n = find(ws, moved_id), find(ws, nb_id) + got = edge(m, side) + space = abs(edge(n, OPP[side]) - got) + ok = abs(got - expect_edge) <= tol and abs(space - gap) <= GAP_TOL + print(f" {'PASS' if ok else 'FAIL'} {name}: {side} edge expected ~{expect_edge}, settled {got}; " + f"gap to neighbour {space}px") + return ok + + +def drag_edge(win_id, side, delta, steps=20, step_ms=40, during=None): + """Press just outside `side` of the window (in the gap) and drag `delta` px across it. + `during`: optional function run while the drag is in progress (e.g. a screenshot). + Waits for the layout to settle first, so the press lands on the edge where it really is. + Returns (edge_before, grabbed: bool, log).""" + settle() + w = find(windows(), win_id) + e = edge(w, side) + off = 1 if side in ("right", "bottom") else -1 + if side in HORIZONTAL: + x, y, dx, dy = e + off, w["y"] + w["h"] // 2, delta, 0 + else: + x, y, dx, dy = w["x"] + w["w"] // 2, e + off, 0, delta + log = run_js_file("drag.js", {"__X0__": x, "__Y0__": y, "__DX__": dx, "__DY__": dy, + "__STEPS__": steps, "__STEP_MS__": step_ms}, "__drag", during) + return e, any("grab-op-begin" in l for l in log), log + + +MODIFIER_KEYVALS = [("CONTROL_MASK", 0xffe3), ("SHIFT_MASK", 0xffe1), ("MOD1_MASK", 0xffe9), + ("SUPER_MASK", 0xffeb), ("MOD4_MASK", 0xffeb)] + + +def chord(binding): + """Keyvals ("0xffe3, 0xffeb, 0x6f") for Forge's keybinding setting `binding` (its first + accelerator), e.g. chord("window-resize-right-increase") -> Ctrl+Super+O by default.""" + gi.require_version("Gdk", "3.0") + gi.require_version("Gtk", "3.0") + from gi.repository import Gdk, Gtk + accel = js(f"""{WM}.ext.kbdSettings.get_strv("{binding}")[0] ?? "" """) + if not accel: + raise RuntimeError(f"Forge keybinding {binding} is not set") + key, mods = Gtk.accelerator_parse(accel) + keyvals = [] + for name, keyval in MODIFIER_KEYVALS: + if mods & getattr(Gdk.ModifierType, name) and keyval not in keyvals: + keyvals.append(keyval) + return ", ".join(hex(k) for k in keyvals + [key]) + + +class _GrowKeys(dict): + """GROW_KEYS[side]: the chord that grows `side` of the focused window (from the settings).""" + def __missing__(self, side): + self[side] = chord(f"window-resize-{side}-increase") + return self[side] + + +class _ShrinkKeys(dict): + def __missing__(self, side): + self[side] = chord(f"window-resize-{side}-decrease") + return self[side] + + +GROW_KEYS = _GrowKeys() +SHRINK_KEYS = _ShrinkKeys() + + +def work_area(): + """(x, y, width, height) of monitor 0's work area (the screen minus panels and docks).""" + return tuple(js("""(() => { const a = global.workspace_manager.get_active_workspace() + .get_work_area_for_monitor(0); return [a.x, a.y, a.width, a.height]; })()""")) + + +def monitor_size(): + """(width, height) of monitor 0's work area.""" + return work_area()[2:] + + +def hold_keys(win_id, keys, hold_ms=1000, during=None): + """Focus a window and hold a key chord with a virtual keyboard (real key repeat). + `during`: optional function run while the key is held (e.g. stall_app()). + Returns the timeline log (each line has per-window x,y,w,h,p and resize() call count).""" + return run_js_file("holdkey.js", {"__WIN__": win_id % 1000, "__KEYS__": keys, "__HOLD_MS__": hold_ms}, + "__hk", during) + + +def stall_app(win_id, after_s, for_s): + """Returns a function for hold_keys(during=...): after `after_s` s, freeze the window's app + (SIGSTOP) for `for_s` s. The app then stops drawing, like a busy or slow app, while GNOME and + Forge keep going: resize requests pile up until it continues (SIGCONT).""" + pid = int(js(f"global.display.list_all_windows().find(w => w.get_id() === {win_id}).get_pid()")) + # Only ever signal one real process of ours, and never this script's own process group: + # get_pid() can be 0 or -1 when the pid is unknown, and kill() treats those as groups. + if pid <= 1 or pid == os.getpid() or os.getpgid(pid) == os.getpgid(0): + raise RuntimeError(f"stall_app: window {win_id} has no usable pid ({pid})") + + def resume(*_): + try: + os.kill(pid, signal.SIGCONT) + except ProcessLookupError: + pass + + def stall(): + time.sleep(after_s) + # If this script is stopped (timeout, Ctrl+C) while the app is frozen, still resume it + old = {s: signal.signal(s, lambda *a: (resume(), sys.exit(1))) for s in (signal.SIGTERM, signal.SIGINT)} + os.kill(pid, signal.SIGSTOP) + try: + time.sleep(for_s) + finally: + resume() + for s, handler in old.items(): + signal.signal(s, handler) + return stall + + +def parse_line(line): + """Parse a timeline line into {id%1000: dict(x,y,w,h,p)} plus 'calls'.""" + out = {} + if "calls=" in line: + out["calls"] = int(line.split("calls=")[1].split()[0]) + for tok in line.split(" | ")[1:]: + if ":" in tok and "=" in tok and not tok.startswith("CON"): + wid, kv = tok.split(":", 1) + out[int(wid)] = {k: float(v) for k, v in (p.split("=") for p in kv.split(","))} + return out + + +def nested_layout(openers=(open_editor, open_editor, open_editor)): + """Open 3 windows => Forge builds [A] + CON[B, C] on monitor 0. Returns (A, B, C).""" + if windows(): + raise SystemExit("sandbox must be fresh (no windows); run sandbox/launch.sh first") + for opener in openers: + opener() + ws = windows() + con = [w for w in ws if w["pid"] == "con"] + top = [w for w in ws if w["pid"] == "top"] + assert len(con) == 2 and len(top) == 1, f"unexpected layout: {ws}" + horiz = con[0]["playout"] == "HSPLIT" + b, c = sorted(con, key=lambda w: w["x"] if horiz else w["y"]) + return top[0], b, c + + +def summary(results): + """Print the result line and return the exit status: 0 only if every check passed and Forge + logged no JS errors.""" + errs = js_errors() + print(f"sandbox JS errors: {errs}") + print(f"RESULT: {sum(results)}/{len(results)} passed" + (f", {errs} Forge JS errors" if errs else "")) + return 0 if all(results) and errs == 0 else 1 + + +SKIP = 77 # exit status of a skipped scenario (run-suite.sh reports it as skipped, not failed) + + +def require(apps=(), setting=None): + """Skip the scenario (exit 77) unless these apps are installed and this Forge build has + `setting`. Call it at the start of main().""" + missing = [a for a in apps if not shutil.which(a)] + if setting and js(f"""{WM}.ext.settings.settings_schema.has_key("{setting}")""") is not True: + missing.append(f"Forge setting {setting} (not in this build)") + if missing: + print(f"SKIP: needs {', '.join(missing)}") + print(f"RESULT: skipped (needs {', '.join(missing)})") + sys.exit(SKIP) + + +def layout_problems(): + """Check the tiled layout on monitor 0 and return a list of violations (empty = OK): + windows inside the work area, no overlapping windows, each window at the size Forge laid it + out at (it isn't when that is below the app's minimum), windows at or above the minimum size + their app allows, and each container's percents in (0, 1] summing to 1.""" + return json.loads(js(f"""(() => {{ + const wm = {WM}; + const area = global.workspace_manager.get_active_workspace().get_work_area_for_monitor(0); + const probs = []; + const nodes = ({HERE}?.getNodeByType("WINDOW") ?? []).filter(n => !n.isFloat() && !n.nodeValue.minimized); // Forge doesn't tile minimized windows + const tag = (n) => n.nodeValue.get_id() % 1000; + const frames = nodes.map(n => [n, n.nodeValue.get_frame_rect()]); + for (const [n, f] of frames) {{ + if (f.x < area.x - 1 || f.y < area.y - 1 || f.x + f.width > area.x + area.width + 1 || + f.y + f.height > area.y + area.height + 1) + probs.push(`${{tag(n)}} outside the work area: x=${{f.x}} y=${{f.y}} w=${{f.width}} h=${{f.height}}`); + const rr = n.renderRect; + if (rr && (Math.abs(rr.width - f.width) > 2 || Math.abs(rr.height - f.height) > 2)) + probs.push(`${{tag(n)}} is ${{f.width}}x${{f.height}} but was laid out at ${{rr.width}}x${{rr.height}}`); + const [has, mw, mh] = n.nodeValue.get_min_size(); + if (has) {{ + const r = n.nodeValue.get_frame_rect(); r.width = mw; r.height = mh; + const m = n.nodeValue.client_rect_to_frame_rect(r); + if (f.width < m.width - 1 || f.height < m.height - 1) + probs.push(`${{tag(n)}} below its minimum ${{m.width}}x${{m.height}}: ${{f.width}}x${{f.height}}`); + }} + }} + for (let i = 0; i < frames.length; i++) for (let j = i + 1; j < frames.length; j++) {{ + const [a, fa] = frames[i], [b, fb] = frames[j]; + const ox = Math.min(fa.x + fa.width, fb.x + fb.width) - Math.max(fa.x, fb.x); + const oy = Math.min(fa.y + fa.height, fb.y + fb.height) - Math.max(fa.y, fb.y); + // windows in the same tabbed/stacked container share its area by design + const group = (n) => {{ for (let p = n.parentNode; p; p = p.parentNode) + if (p.layout === "TABBED" || p.layout === "STACKED") return p; return null; }}; + if (group(a) && group(a) === group(b)) continue; + if (ox > 2 && oy > 2) probs.push(`${{tag(a)}} and ${{tag(b)}} overlap by ${{ox}}x${{oy}}`); + }} + const parents = new Set(nodes.map(n => n.parentNode)); + for (const n of nodes) for (let p = n.parentNode; p && p.nodeType !== "ROOT"; p = p.parentNode) parents.add(p); + for (const p of parents) {{ + if (!p || !(p.layout === "HSPLIT" || p.layout === "VSPLIT")) continue; + const kids = wm.tree.getTiledChildren(p.childNodes); + if (kids.length < 2) continue; + const pcts = kids.map(k => k.percent ?? 0); + if (pcts.some(v => v <= 0)) continue; // unset percents = equal split + if (pcts.some(v => v > 1)) probs.push(`${{p.layout}} child percent over 100%: ${{pcts.map(v => v.toFixed(3))}}`); + const sum = pcts.reduce((s, v) => s + v, 0); + if (Math.abs(sum - 1) > 0.02) probs.push(`${{p.layout}} percents sum to ${{sum.toFixed(3)}}: ${{pcts.map(v => v.toFixed(3))}}`); + }} + return JSON.stringify(probs); }})()""")) + + +def reset_layout(): + """Back to an equal split everywhere on monitor 0 and re-render.""" + js(f"""(() => {{ const wm = {WM}; + const here = {HERE}; + if (!here) return "ok"; + here.getNodeByType("WINDOW").forEach(n => wm.tree.resetSiblingPercent(n.parentNode)); + here.getNodeByType("CON").forEach(n => wm.tree.resetSiblingPercent(n.parentNode)); + wm.renderTree("test-reset"); return "ok"; }})()""") + time.sleep(0.5) + settle() + + +def layout_check(name, what): + """PASS if layout_problems() is empty after the layout has settled.""" + time.sleep(1.5) + probs = layout_problems() + print(f" {'PASS' if not probs else 'FAIL'} {name}: {what}") + for p in probs: + print(f" - {p}") + return not probs + + +def set_forge_setting(key, value): + """Set a Forge setting inside the sandbox. Returns False if this Forge build has no such key + (setting an unknown GSettings key would abort the shell, so this checks first).""" + kind = "boolean" if isinstance(value, bool) else "uint" if isinstance(value, int) else "string" + return js(f"""(() => {{ const s = {WM}.ext.settings; + if (!s.settings_schema.has_key("{key}")) return false; + s.set_{kind}("{key}", {json.dumps(value)}); return true; }})()""") + + +def tree_summary(): + """Monitor 0's tiling tree as a compact string, e.g. HSPLIT[w12, TABBED[w13, w14]].""" + return js(f"""(() => {{ const wm = {WM}; + const mon = {HERE}; + const fmt = (n) => n.nodeType === "WINDOW" ? `w${{n.nodeValue.get_id() % 1000}}` + : `${{n.layout}}[${{n.childNodes.map(fmt).join(", ")}}]`; + return mon && mon.childNodes.length ? fmt(mon) : "(no windows)"; }})()""") + + +def timeline_problems(log, tol=30, ignore=None): + """Check every sample of a hold_keys()/drag_edge() timeline: windows must not overlap each + other or leave the work area by more than `tol` px while the input is still going on. + (`tol` allows for Wayland showing a new position a frame or two before the new size.) + `ignore`: a window id whose own position is not checked, i.e. the window being dragged with + the mouse, which follows the pointer (GNOME owns it until the button is released).""" + area = js("""(() => { const a = global.workspace_manager.get_active_workspace().get_work_area_for_monitor(0); + return [a.x, a.y, a.width, a.height]; })()""") + ax, ay, aw, ah = area + worst = {} + for line in log: + wins = {k: v for k, v in parse_line(line).items() if k != "calls" and k != (ignore or 0) % 1000} + tag = line.split(" | ")[0][:40] + for wid, w in wins.items(): + out = max(ax - w["x"], ay - w["y"], w["x"] + w["w"] - (ax + aw), w["y"] + w["h"] - (ay + ah)) + if out > tol and out > worst.get(("out", wid), (0, ""))[0]: + worst[("out", wid)] = (out, tag) + ids = sorted(wins) + for i, a in enumerate(ids): + for b in ids[i + 1:]: + wa, wb = wins[a], wins[b] + if wa.get("g") and wa.get("g") == wb.get("g"): + continue # same tabbed/stacked container: they share its area + ox = min(wa["x"] + wa["w"], wb["x"] + wb["w"]) - max(wa["x"], wb["x"]) + oy = min(wa["y"] + wa["h"], wb["y"] + wb["h"]) - max(wa["y"], wb["y"]) + if ox > 2 and oy > 2 and min(ox, oy) > tol and min(ox, oy) > worst.get(("ov", a, b), (0, ""))[0]: + worst[("ov", a, b)] = (min(ox, oy), tag) + probs = [] + for key, (px, tag) in worst.items(): + if key[0] == "out": + probs.append(f"{key[1]} {px:.0f} px outside the work area ({tag})") + else: + probs.append(f"{key[1]} and {key[2]} overlap by {px:.0f} px ({tag})") + return probs + + +def timeline_check(name, what, log, ignore=None): + probs = timeline_problems(log, ignore=ignore) + print(f" {'PASS' if not probs else 'FAIL'} {name}: {what}") + for p in probs: + print(f" - {p}") + if probs: + # the trajectory of every window involved, for diagnosis + ids = {int(t) for p in probs for t in p.replace("(", " ").split() if t.isdigit() and len(t) <= 3} + for wid in sorted(ids): + rows = [] + for line in log: + w = parse_line(line).get(wid) + if w: + rows.append(f"{line.split(' ')[0]}:{w['x']:.0f},{w['y']:.0f},{w['w']:.0f}x{w['h']:.0f}") + print(f" w{wid} (t:x,y,wxh): " + " ".join(rows)) + return not probs + + +def ensure_parent_layout(win_id, layout): + """Make the container (or monitor) holding `win_id` use `layout` ("HSPLIT"/"VSPLIT"), with + Forge's layout toggle. Forge's auto-split picks the direction from the window shape, so it + differs between screen sizes; scenarios that need a direction set it with this.""" + for _ in range(2): + if find(windows(), win_id)["playout"] == layout: + return + js(f"""(() => {{ global.display.list_all_windows().find(w => w.get_id() === {win_id}) + .activate(global.get_current_time()); return "ok"; }})()""") + time.sleep(0.4) + js(f'(() => {{ {WM}.command({{name: "LayoutToggle"}}); return "ok"; }})()') + time.sleep(1.0) + got = find(windows(), win_id)["playout"] + if got != layout: + raise RuntimeError(f"could not set the layout of {win_id % 1000}'s container to {layout} (got {got})") + + +def close_windows(): + """Close the windows Forge manages on this workspace, on every monitor, one at a time (never + the desktop-icons window or other windows Forge does not manage).""" + def managed(): + return js(f"""JSON.stringify({WM}.tree.getNodeByType("WINDOW") + .filter(n => n.nodeValue.get_workspace() === global.workspace_manager.get_active_workspace()) + .map(n => n.nodeValue.get_id()))""") + while ids := json.loads(managed()): + wid = ids[0] + js(f"""(() => {{ global.display.list_all_windows().find(w => w.get_id() === {wid}) + ?.delete(global.get_current_time()); return "ok"; }})()""") + for _ in range(40): + time.sleep(0.25) + if wid not in json.loads(managed()): + break + else: + raise RuntimeError(f"window {wid % 1000} did not close") + time.sleep(0.3) + park_pointer() + + +def settle(max_s=3.0): + """Wait until the layout on this workspace stops changing (checked every 0.3 s, at most + `max_s`). Use before acting on window positions after something that re-lays out.""" + prev = None + for _ in range(int(max_s / 0.3)): + time.sleep(0.3) + cur = [(w["id"], w["x"], w["y"], w["w"], w["h"]) for w in windows()] + if cur == prev: + return + prev = cur diff --git a/tests/lib/holdkey.js b/tests/lib/holdkey.js new file mode 100644 index 00000000..4fd6e288 --- /dev/null +++ b/tests/lib/holdkey.js @@ -0,0 +1,87 @@ +// Hold a real key chord with a virtual KEYBOARD in the sandbox (compositor generates repeats). +// __WIN__ (window id % 1000 to focus), __KEYS__ (keyval list, last one is held), __HOLD_MS__ +(() => { + const wm = Main.extensionManager.lookup("forge@jmmaranan.com").stateObj.extWm; + const seat = global.stage.context.get_backend().get_default_seat(); + const kb = seat.create_virtual_device(1 /* CLUTTER_KEYBOARD_DEVICE */); + const now = () => GLib.get_monotonic_time(); + const st = (globalThis.__hk = { t0: now(), log: [], done: false, calls: 0 }); + // numeric id of a window's nearest tabbed/stacked container (0: none); those windows share space + const groupIds = (globalThis.__groupIds ??= new WeakMap()); + const groupOf = (n) => { + for (let p = n.parentNode; p; p = p.parentNode) { + if (p.layout === "TABBED" || p.layout === "STACKED") { + if (!groupIds.has(p)) + groupIds.set(p, (globalThis.__groupNext = (globalThis.__groupNext ?? 0) + 1)); + return groupIds.get(p); + } + } + return 0; + }; + const snap = (tag) => { + const wins = wm.tree + .getNodeByType("WINDOW") + .filter( + (n) => + n.nodeValue.get_workspace() === global.workspace_manager.get_active_workspace() && + !n.nodeValue.minimized + ) + .map((n) => { + const f = n.nodeValue.get_frame_rect(); + return `${n.nodeValue.get_id() % 1000}:x=${f.x},y=${f.y},w=${f.width},h=${f.height},p=${( + n.percent ?? 0 + ).toFixed(3)},g=${groupOf(n)}`; + }); + const con = wm.tree.getNodeByType("CON")[0]; + st.log.push( + `${((now() - st.t0) / 1000).toFixed(0)}ms ${tag} calls=${st.calls} | ${wins.join(" | ")}${ + con ? ` | CON p=${(con.percent ?? 0).toFixed(3)}` : "" + }` + ); + }; + const keys = [__KEYS__]; + const w = global.display.list_all_windows().find((w) => w.get_id() % 1000 === __WIN__); + if (!w) throw new Error("holdkey: window __WIN__ not found"); + // Count how often Forge's resize() actually runs (wrapped only for this test run). + const orig = wm.resize; + wm.resize = function (...a) { + st.calls++; + return orig.apply(this, a); + }; + w.activate(global.get_current_time()); + const seq = [() => snap("start")]; + const down = new Set(); + const key = (k, state) => { + kb.notify_keyval(now(), k, state); + if (state) down.add(k); + else down.delete(k); + }; + keys.forEach((k) => seq.push(() => key(k, 1))); + seq.push(() => snap("keys down")); + const holdTicks = Math.round(__HOLD_MS__ / 100); + for (let i = 1; i <= holdTicks; i++) seq.push(() => snap(`holding ${i * 100}ms`)); + [...keys].reverse().forEach((k) => seq.push(() => key(k, 0))); + seq.push(() => snap("keys released")); + for (let j = 1; j <= 4; j++) seq.push(() => snap(`after release +${j * 100}ms`)); + // Always release held keys and unwrap resize(), even if a step throws (e.g. a window closed + // mid-hold); the error goes into the log for the harness. + const finish = () => { + [...down].reverse().forEach((k) => key(k, 0)); + wm.resize = orig; + st.done = true; + }; + let k = 0; + GLib.timeout_add(GLib.PRIORITY_DEFAULT, 100, () => { + try { + if (k < seq.length) { + seq[k++](); + return GLib.SOURCE_CONTINUE; + } + } catch (e) { + st.log.push(`ERROR ${e}`); + } + finish(); + return GLib.SOURCE_REMOVE; + }); + return "started"; +})(); diff --git a/tests/lib/perf.js b/tests/lib/perf.js new file mode 100644 index 00000000..b52b06bf --- /dev/null +++ b/tests/lib/perf.js @@ -0,0 +1,91 @@ +// Performance probes for the SANDBOX shell. Wraps Forge's hot functions to time them, and counts +// window moves/resizes (Meta.Window.move_resize_frame / move_frame), per caller and per workspace. +// globalThis.__perf.start() install the wrappers and reset the counters +// globalThis.__perf.stop() remove them, return the report +(() => { + if (globalThis.__perf?.installed) globalThis.__perf.stop(); + const wm = Main.extensionManager.lookup("forge@jmmaranan.com").stateObj.extWm; + const Meta = imports.gi.Meta; + const now = () => GLib.get_monotonic_time(); + const st = { + installed: false, + wrapped: [], + timings: {}, + moves: null, + missing: [], + current: null, + }; + const record = (name, us) => (st.timings[name] ??= []).push(us); + const wrap = (obj, name, label) => { + const orig = obj[name]; + if (typeof orig !== "function") { + st.missing.push(label); + return; + } // reported: not measured + obj[name] = function (...args) { + if (st.current === label) return orig.apply(this, args); // recursion: time the outer call only + const prev = st.current; + st.current = label; + const t0 = now(); + try { + return orig.apply(this, args); + } finally { + record(label, now() - t0); + st.current = prev; + } + }; + st.wrapped.push([obj, name, orig]); + }; + const countMoves = (name) => { + const proto = Meta.Window.prototype; + const orig = proto[name]; + proto[name] = function (...args) { + const caller = st.current ?? "other"; + const c = (st.moves[caller] ??= { calls: 0, otherWorkspace: 0, unchanged: 0 }); + c.calls++; + if (this.get_workspace() !== global.workspace_manager.get_active_workspace()) + c.otherWorkspace++; + if (name === "move_resize_frame") { + const f = this.get_frame_rect(); + const [, x, y, w, h] = args; + if (f.x === x && f.y === y && f.width === w && f.height === h) c.unchanged++; + } + return orig.apply(this, args); + }; + st.wrapped.push([proto, name, orig]); + }; + st.start = () => { + st.timings = {}; + st.moves = {}; + st.missing = []; + wrap(wm.tree, "render", "tree.render"); + wrap(wm.tree, "processNode", "tree.processNode"); + wrap(wm.tree, "apply", "tree.apply"); + wrap(wm, "_handleResizing", "wm._handleResizing"); + wrap(wm, "_liveResizeNeighbors", "wm._liveResizeNeighbors"); + wrap(wm, "resize", "wm.resize"); + countMoves("move_resize_frame"); + countMoves("move_frame"); + st.installed = true; + return "started"; + }; + st.stop = () => { + for (const [obj, name, orig] of st.wrapped.reverse()) obj[name] = orig; + st.wrapped = []; + st.installed = false; + const stats = {}; + for (const [name, list] of Object.entries(st.timings)) { + const s = [...list].sort((a, b) => a - b); + const pct = (p) => s[Math.min(s.length - 1, Math.floor(p * s.length))]; + stats[name] = { + calls: s.length, + mean_us: Math.round(s.reduce((a, b) => a + b, 0) / s.length), + p95_us: pct(0.95), + max_us: s[s.length - 1], + }; + } + return JSON.stringify({ timings: stats, moves: st.moves, missing: st.missing }); + }; + globalThis.__perf = st; + return "ready"; +})(); diff --git a/tests/lib/shots.py b/tests/lib/shots.py new file mode 100644 index 00000000..4446ad2f --- /dev/null +++ b/tests/lib/shots.py @@ -0,0 +1,94 @@ +"""Screenshots of the sandbox's test monitor for issue and PR write-ups. + + shot(path) full test monitor (monitor 0) + later(path, after_s) a function for hold_keys()/drag_edge(during=...) that + takes a screenshot `after_s` seconds into the action + annotate(src, dst, boxes) draw labelled boxes: [(x, y, w, h, colour, label), ...] + side_by_side(dst, [(path, caption), ...], width=...) one image with captions (before/after) +""" +import os +import time + +import gi + +gi.require_version("Gio", "2.0") +from gi.repository import Gio, GLib # noqa: E402 +from PIL import Image, ImageDraw, ImageFont # noqa: E402 + +import harness as h # noqa: E402 + +FONT = "/usr/share/fonts/truetype/ubuntu/Ubuntu-B.ttf" +RED, GREEN, AMBER = (220, 38, 38), (22, 163, 74), (217, 119, 6) + + +def _font(size): + for path in (FONT, "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"): + try: + return ImageFont.truetype(path, size) + except OSError: + continue + return ImageFont.load_default() + + +def shot(path): + """Screenshot of monitor 0 (the test monitor) to `path` (PNG).""" + geo = h.js("(() => { const g = global.display.get_monitor_geometry(0); return [g.x, g.y, g.width, g.height]; })()") + path = os.path.abspath(path) + os.makedirs(os.path.dirname(path), exist_ok=True) + ok, _ = h.bus().call_sync( + "org.gnome.Shell.Screenshot", "/org/gnome/Shell/Screenshot", "org.gnome.Shell.Screenshot", + "ScreenshotArea", GLib.Variant("(iiiibs)", (*geo, False, path)), GLib.VariantType("(bs)"), + Gio.DBusCallFlags.NONE, 10000, None).unpack() + if not ok: + raise RuntimeError(f"screenshot failed: {path}") + return path + + +def later(path, after_s): + """For hold_keys()/drag_edge(during=...): screenshot `after_s` s into the action.""" + def take(): + time.sleep(after_s) + shot(path) + return take + + +def annotate(src, dst, boxes, width=None): + """Draw boxes [(x, y, w, h, colour, label)] (monitor coordinates) on src, save to dst, + optionally scaled to `width` px.""" + img = Image.open(src).convert("RGB") + draw = ImageDraw.Draw(img) + font = _font(max(18, img.width // 70)) + for x, y, w, hh, colour, label in boxes: + draw.rectangle([x, y, x + w, y + hh], outline=colour, width=max(4, img.width // 400)) + if label: + tw, th = draw.textbbox((0, 0), label, font=font)[2:] + # inside the image, even for a box at its right edge + lx, ly = min(x + 8, img.width - tw - 12), max(0, y + 8) + draw.rectangle([lx - 6, ly - 4, lx + tw + 6, ly + th + 8], fill=colour) + draw.text((lx, ly), label, fill=(255, 255, 255), font=font) + if width and img.width != width: + img = img.resize((width, round(img.height * width / img.width)), Image.LANCZOS) + img.save(dst, optimize=True) + return dst + + +def side_by_side(dst, items, width=1600, gap=16): + """items: [(path, caption)] -> one image, each panel `width / len(items)` wide, captions on top.""" + panel_w = (width - gap * (len(items) - 1)) // len(items) + panels = [] + font = _font(28) + for path, caption in items: + img = Image.open(path).convert("RGB") + img = img.resize((panel_w, round(img.height * panel_w / img.width)), Image.LANCZOS) + cap_h = 48 + panel = Image.new("RGB", (panel_w, img.height + cap_h), (255, 255, 255)) + ImageDraw.Draw(panel).text((8, 8), caption, fill=(20, 20, 20), font=font) + panel.paste(img, (0, cap_h)) + panels.append(panel) + out = Image.new("RGB", (width, max(p.height for p in panels)), (255, 255, 255)) + x = 0 + for p in panels: + out.paste(p, (x, 0)) + x += p.width + gap + out.save(dst, optimize=True) + return dst diff --git a/tests/lib/trace.js b/tests/lib/trace.js new file mode 100644 index 00000000..9e1d0077 --- /dev/null +++ b/tests/lib/trace.js @@ -0,0 +1,44 @@ +// Debug aid: wrap Forge WindowManager methods to log each call with the focused window's frame +// and baseline (initRect). Install: trace.js with __ON__=true ; remove with __ON__=false. +(() => { + const wm = Main.extensionManager.lookup("forge@jmmaranan.com").stateObj.extWm; + const names = [ + "_handleGrabOpBegin", + "_handleResizing", + "_handleGrabOpEnd", + "resize", + "renderTree", + ]; + if (!wm.__traceOrig) wm.__traceOrig = {}; + globalThis.__trace = globalThis.__trace ?? []; + const t0 = GLib.get_monotonic_time(); + if (__ON__) { + globalThis.__trace = []; + for (const n of names) { + const orig = wm.__traceOrig[n] ?? wm[n]; + wm.__traceOrig[n] = orig; + wm[n] = function (...args) { + const mw = this.focusMetaWindow; + const node = mw ? this.findNodeWindow(mw) : null; + const f = mw?.get_frame_rect(); + const ir = node?.initRect; + const res = orig.apply(this, args); + globalThis.__trace.push( + `${((GLib.get_monotonic_time() - t0) / 1000).toFixed(1)}ms ${n}` + + (f ? ` frame.w=${f.width} frame.h=${f.height}` : "") + + (ir ? ` base.w=${Math.round(ir.width)} base.h=${Math.round(ir.height)}` : " base=-") + + (node ? ` p=${(node.percent ?? 0).toFixed(3)}` : "") + + (node?.parentNode?.nodeType === "CON" + ? ` con.p=${(node.parentNode.percent ?? 0).toFixed(3)}` + : "") + + (n === "resize" ? ` args=${args.join(",")}` : "") + ); + return res; + }; + } + return "tracing on"; + } + for (const n of Object.keys(wm.__traceOrig)) wm[n] = wm.__traceOrig[n]; + wm.__traceOrig = {}; + return "tracing off"; +})(); diff --git a/tests/lib/tree.js b/tests/lib/tree.js new file mode 100644 index 00000000..f8928ed9 --- /dev/null +++ b/tests/lib/tree.js @@ -0,0 +1,27 @@ +// Print Forge's tree for the sandbox monitor: containers with layout/percent, windows with frames. +(() => { + const wm = Main.extensionManager.lookup("forge@jmmaranan.com").stateObj.extWm; + const out = []; + const r = (x) => (x ? `[${[x.x, x.y, x.width, x.height].map(Math.round).join(",")}]` : "-"); + const walk = (n, d) => { + if (n.nodeType === "WINDOW") { + const f = n.nodeValue.get_frame_rect(); + out.push( + `${" ".repeat(d)}WIN ${n.nodeValue.get_id() % 1000} "${n.nodeValue + .get_title() + .slice(0, 18)}" p=${(n.percent ?? 0).toFixed(3)} frame=${r(f)} rect=${r(n.rect)}${ + n.isFloat() ? " FLOAT" : "" + }${n.isGrabTile() ? " GRAB_TILE" : ""}${n.nodeValue.minimized ? " MINIMIZED" : ""}` + ); + } else { + out.push( + `${" ".repeat(d)}${n.nodeType} ${n.layout ?? ""} p=${(n.percent ?? 0).toFixed(3)} rect=${r( + n.rect + )}` + ); + n.childNodes.forEach((c) => walk(c, d + 1)); + } + }; + wm.tree.getNodeByType("MONITOR").forEach((m) => walk(m, 0)); + return out.join("\n"); +})(); diff --git a/tests/sandbox/env.sh b/tests/sandbox/env.sh new file mode 100755 index 00000000..feb49d2a --- /dev/null +++ b/tests/sandbox/env.sh @@ -0,0 +1,18 @@ +# Shared settings for the sandbox scripts (sourced, not executed). +REPRO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# Forge source checkout to build and test. Default: the checkout this directory is part of (when it +# lives in forge's tests/), otherwise a sibling ../forge clone (standalone copy of these tests). +if [[ -z ${FORGE_SRC:-} ]]; then + if [[ -f $REPRO_ROOT/../extension.js && -f $REPRO_ROOT/../metadata.json ]]; then + FORGE_SRC="$(cd "$REPRO_ROOT/.." && pwd)" + else + FORGE_SRC="$(cd "$REPRO_ROOT/.." && pwd)/forge" + fi +fi +# Where the throwaway sandbox state lives (extensions dir, keyfile settings, logs, bus address). +SANDBOX_DIR="${SANDBOX_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/forge-sandbox/sandbox}" +FORGE_UUID=forge@jmmaranan.com +SANDBOX_DISPLAY=${SANDBOX_DISPLAY:-wayland-forge} +# Print PIDs of this sandbox's gnome-shell only: never the real session's shell, nor another +# sandbox whose display name merely starts with the same text (wayland-forge vs wayland-forge2). +sandbox_pids() { for p in $(pgrep -x gnome-shell); do tr '\0' ' ' < "/proc/$p/cmdline" | grep -qE -- "wayland-display $SANDBOX_DISPLAY( |\$)" && echo "$p"; done; } diff --git a/tests/sandbox/launch.sh b/tests/sandbox/launch.sh new file mode 100755 index 00000000..68d97678 --- /dev/null +++ b/tests/sandbox/launch.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# Start an isolated nested GNOME Shell running Forge built from $FORGE_SRC. +# +# Isolation: the nested shell gets its own D-Bus session bus (dbus-run-session), its own +# extensions directory (XDG_DATA_HOME) and keyfile-backed settings (GSETTINGS_BACKEND=keyfile), +# so the host session's dconf and extensions are never touched. +# +# sandbox/launch.sh [WxH] default 1920x1080: the fixed-size virtual monitor (monitor 0) +# the scenarios run on. To watch it live: sandbox/watch.py & +# SANDBOX_PROFILE=real sandbox/launch.sh +# mirror this machine's session (see below) +# SANDBOX_BACKEND=devkit sandbox/launch.sh +# also open the Mutter devkit viewer (needs mutter-dev-bin). It +# shows an extra monitor, not the test monitor; do NOT disable that +# monitor (the nested shell exits if you do). +set -euo pipefail +source "$(dirname "$0")/env.sh" +SIZE=${1:-1920x1080} +# SANDBOX_BACKEND=headless (default): no window of its own; watch it with sandbox/watch.py. +# SANDBOX_BACKEND=devkit: also opens the Mutter devkit viewer, which shows a second monitor +# whose size follows the viewer window (the scenarios still run on the fixed-size monitor 0). +BACKEND=${SANDBOX_BACKEND:-headless} +[[ $BACKEND == headless || $BACKEND == devkit ]] || { echo "SANDBOX_BACKEND must be headless or devkit"; exit 1; } +# SANDBOX_PROFILE=real: mirror this machine's session as closely as possible: its monitor size +# (unless WxH is given), its session mode (e.g. Ubuntu's dock and desktop icons), its enabled/disabled +# extension lists and its Forge settings. The real settings are only read, and copied into the +# sandbox's own keyfile settings. +# SANDBOX_SECOND_MONITOR=WxH adds a second virtual monitor (monitor 1), e.g. for moves between monitors +EXTRA=${SANDBOX_SECOND_MONITOR:+--virtual-monitor $SANDBOX_SECOND_MONITOR} +PROFILE=${SANDBOX_PROFILE:-plain} +MODE=user +if [[ $PROFILE == real ]]; then + # The session mode of the running session, if gnome-shell has one installed (e.g. ubuntu) + MODE=$(gdbus call --session --dest org.gnome.Shell --object-path /org/gnome/Shell \ + --method org.freedesktop.DBus.Properties.Get org.gnome.Shell Mode 2>/dev/null \ + | grep -oP "'\K[a-z-]+" || true) + [[ -n $MODE && -e /usr/share/gnome-shell/modes/$MODE.json ]] || MODE=user + if [[ -z ${1:-} ]]; then + SIZE=$(gdbus call --session --dest org.gnome.Mutter.DisplayConfig --object-path /org/gnome/Mutter/DisplayConfig \ + --method org.gnome.Mutter.DisplayConfig.GetCurrentState \ + | grep -oP "\\('\\K[0-9]+x[0-9]+(?=@[0-9.]+', [0-9]+, [0-9]+, [0-9.]+, [0-9.]+, \\[[^]]*\\], \\{[^}]*'is-current': )" | head -1) + [[ -n $SIZE ]] || { echo "could not read this session's monitor size"; exit 1; } + fi + HOST_ENABLED=$(gsettings get org.gnome.shell enabled-extensions) + HOST_DISABLED=$(gsettings get org.gnome.shell disabled-extensions) + HOST_FORGE=$(dconf dump /org/gnome/shell/extensions/forge/) +elif [[ $PROFILE != plain ]]; then + echo "SANDBOX_PROFILE must be plain or real"; exit 1 +fi + +for _ in $(seq 20); do [[ -z $(sandbox_pids) ]] && break; sleep 0.5; done +[[ -z $(sandbox_pids) ]] || { echo "sandbox already running (pid $(sandbox_pids))"; exit 1; } +[[ -e $FORGE_SRC/.git ]] || { echo "FORGE_SRC=$FORGE_SRC is not a git checkout of forge"; exit 1; } + +# SANDBOX_DIR is wiped on every launch: only accept a directory that looks like one of ours. +case $SANDBOX_DIR in + "$HOME"/?*|/tmp/?*) ;; + *) echo "refusing: SANDBOX_DIR=$SANDBOX_DIR must be under \$HOME or /tmp"; exit 1 ;; +esac +[[ ! -e $SANDBOX_DIR || -e $SANDBOX_DIR/forge-version || -z $(ls -A "$SANDBOX_DIR") ]] \ + || { echo "refusing: $SANDBOX_DIR exists and isn't a sandbox directory (no forge-version file)"; exit 1; } +rm -rf "$SANDBOX_DIR" +mkdir -p "$SANDBOX_DIR/data/gnome-shell/extensions/$FORGE_UUID" "$SANDBOX_DIR/config" "$SANDBOX_DIR/data/dbus-1/services" +# The document portal mounts a FUSE file system at $XDG_RUNTIME_DIR/doc, which the sandbox shares with +# your session (whose own portal is mounted there). Keep the sandbox's bus from starting one: apps then +# see no document portal instead of one that hangs (the VS Code snap waits on it forever). +printf '[D-BUS Service]\nName=org.freedesktop.portal.Documents\nExec=/bin/false\n' \ + > "$SANDBOX_DIR/data/dbus-1/services/org.freedesktop.portal.Documents.service" + +# Build. Only `make build` / `make dist`: forge's default target runs `killall -HUP gnome-shell`. +# /dev/null ) # dist depends on build +if [[ -z $PO_DIRTY ]]; then + git -C "$FORGE_SRC" checkout -- po/ +else + echo "note: po/ had uncommitted changes, so it was left as the build wrote it" +fi +unzip -q -o "$FORGE_SRC/$FORGE_UUID.zip" -d "$SANDBOX_DIR/data/gnome-shell/extensions/$FORGE_UUID" +# Sandbox-only helper that enables unsafe mode (org.gnome.Shell.Eval) for the test driver. +cp -r "$REPRO_ROOT/sandbox/sandbox-unsafe@local" "$SANDBOX_DIR/data/gnome-shell/extensions/" +git -C "$FORGE_SRC" log -1 --format='forge: %h %s (%D)' > "$SANDBOX_DIR/forge-version" + +export XDG_DATA_HOME=$SANDBOX_DIR/data XDG_CONFIG_HOME=$SANDBOX_DIR/config GSETTINGS_BACKEND=keyfile +if [[ $PROFILE == real ]]; then + python3 - "$HOST_ENABLED" "$HOST_DISABLED" "$HOST_FORGE" "$SANDBOX_DIR/data/gnome-shell/extensions/$FORGE_UUID/schemas" <<'PY' +import ast, configparser, subprocess, sys +enabled, disabled, forge, schemadir = sys.argv[1:] +enabled = [e for e in ast.literal_eval(enabled.replace("@as ", "")) if e != "forge@jmmaranan.com"] +enabled += ["forge@jmmaranan.com", "sandbox-unsafe@local"] +subprocess.run(["gsettings", "set", "org.gnome.shell", "enabled-extensions", str(enabled)], check=True) +subprocess.run(["gsettings", "set", "org.gnome.shell", "disabled-extensions", disabled], check=True) +cfg = configparser.ConfigParser(interpolation=None, strict=False) +cfg.optionxform = str +cfg.read_string(forge) +schemas = {"/": "org.gnome.shell.extensions.forge", "keybindings": "org.gnome.shell.extensions.forge.keybindings"} +for group in cfg.sections(): + if group not in schemas: + continue + for key, value in cfg[group].items(): + # A key this build doesn't have (e.g. left over from an older Forge) is skipped + r = subprocess.run(["gsettings", "--schemadir", schemadir, "set", schemas[group], key, value], + capture_output=True, text=True) + if r.returncode: + print(f"real profile: skipped Forge setting {key}: {r.stderr.strip()}") +print(f"real profile: {len(enabled) - 1} extensions enabled, Forge settings copied") +PY +else + gsettings set org.gnome.shell enabled-extensions "['$FORGE_UUID', 'sandbox-unsafe@local']" +fi +gsettings set org.gnome.mutter dynamic-workspaces false # forge: no dynamic workspaces +gsettings set org.gnome.desktop.wm.preferences num-workspaces 4 +gsettings set org.gnome.shell welcome-dialog-last-shown-version '999' +# The helper extension (and Forge) load on any GNOME Shell version +gsettings set org.gnome.shell disable-extension-version-validation true + +setsid dbus-run-session -- bash -c ' + echo "$DBUS_SESSION_BUS_ADDRESS" > "'"$SANDBOX_DIR"'/bus-address" + exec gnome-shell --wayland --mode='"$MODE"' --'"$BACKEND"' --virtual-monitor '"$SIZE"' '"$EXTRA"' \ + --wayland-display '"$SANDBOX_DISPLAY"' +' > "$SANDBOX_DIR/nested.log" 2>&1 < /dev/null & + +for _ in $(seq 60); do + [[ -s $SANDBOX_DIR/bus-address ]] && grep -q 'GNOME Shell started' "$SANDBOX_DIR/nested.log" 2>/dev/null && break + sleep 0.5 +done +grep -q 'GNOME Shell started' "$SANDBOX_DIR/nested.log" || { echo "sandbox failed to start; see $SANDBOX_DIR/nested.log"; exit 1; } +sleep 2 +cat "$SANDBOX_DIR/forge-version" +echo "sandbox ready: bus $(cat "$SANDBOX_DIR/bus-address")" diff --git a/tests/sandbox/run-in-sandbox.sh b/tests/sandbox/run-in-sandbox.sh new file mode 100755 index 00000000..95923802 --- /dev/null +++ b/tests/sandbox/run-in-sandbox.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Run an application inside the sandbox (its Wayland display, D-Bus bus and settings only). +set -euo pipefail +source "$(dirname "$0")/env.sh" +export DBUS_SESSION_BUS_ADDRESS="$(cat "$SANDBOX_DIR/bus-address")" +export WAYLAND_DISPLAY=$SANDBOX_DISPLAY GDK_BACKEND=wayland +export XDG_DATA_HOME=$SANDBOX_DIR/data XDG_CONFIG_HOME=$SANDBOX_DIR/config GSETTINGS_BACKEND=keyfile +unset DISPLAY +exec "$@" diff --git a/tests/sandbox/run-suite.sh b/tests/sandbox/run-suite.sh new file mode 100755 index 00000000..0bf4b79f --- /dev/null +++ b/tests/sandbox/run-suite.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Run scenarios on one Forge build, each on a fresh sandbox, and print a summary. +# +# sandbox/run-suite.sh [scenario ...] default: all scenarios/*.py +# FORGE_SRC=/path/to/forge SANDBOX_PROFILE=real sandbox/run-suite.sh 03 07 +# +# Scenarios can be given by number (03) or file name. A scenario that needs something this +# machine or build doesn't have (an app, a setting) is reported as skipped, not failed. A line +# "# sandbox: second-monitor" in a scenario makes its sandbox start with a second monitor. +# Full output of each scenario goes to $SUITE_OUT (default +# ~/.cache/forge-sandbox/suite/-/.txt; ends in -dirty- when the checkout has uncommitted changes, so a before/after pair never shares a directory). +# Watch it live with sandbox/watch.py &. +# +# Exit status: 0 if every scenario passed or was skipped, 1 otherwise. +set -uo pipefail +source "$(dirname "$0")/env.sh" +cd "$REPRO_ROOT" + +PROFILE=${SANDBOX_PROFILE:-plain} +BUILD=$(git -C "$FORGE_SRC" rev-parse --short HEAD) +if [[ -n $(git -C "$FORGE_SRC" status --porcelain --untracked-files=no -- . ':!po') ]]; then + BUILD=$BUILD-dirty-$(git -C "$FORGE_SRC" diff HEAD -- . ':!po' | sha1sum | cut -c1-7) +fi +OUT=${SUITE_OUT:-${XDG_CACHE_HOME:-$HOME/.cache}/forge-sandbox/suite/$BUILD-$PROFILE} +mkdir -p "$OUT" + +if [[ $# -eq 0 ]]; then + set -- scenarios/[0-9]*.py +fi + +declare -a SUMMARY +failed=0 +for arg in "$@"; do + scenario=$(ls scenarios/"$arg"*.py 2>/dev/null | head -1) + [[ -n $scenario ]] || scenario=$arg + name=$(basename "$scenario" .py) + second=${SANDBOX_SECOND_MONITOR:-} + grep -q '^# sandbox: second-monitor' "$scenario" && second=${second:-1280x1024} + sandbox/stop.sh >/dev/null + if ! SANDBOX_SECOND_MONITOR=$second sandbox/launch.sh > "$OUT/$name.launch.txt" 2>&1; then + SUMMARY+=("$name: sandbox failed to start (see $OUT/$name.launch.txt)") + failed=1 + continue + fi + echo "== $name on $(cat "$SANDBOX_DIR/forge-version") [$PROFILE]" + timeout 1200 "$scenario" > "$OUT/$name.txt" 2>&1 + status=$? + grep -E "PASS|FAIL|SKIP|^ +- |RESULT|errors: [1-9]|Traceback|Error" "$OUT/$name.txt" + result=$(grep -oE 'RESULT: .*' "$OUT/$name.txt" | tail -1) + case $status in + 0) ;; + 77) result=${result:-skipped} ;; + 124) result="timed out after 1200 s${result:+ ($result)}"; failed=1 ;; + *) result="${result:-no result} (exit $status, see output)"; failed=1 ;; + esac + SUMMARY+=("$name: $result") +done +sandbox/stop.sh >/dev/null + +echo +echo "== summary: $BUILD [$PROFILE], outputs in $OUT" +printf ' %s\n' "${SUMMARY[@]}" +exit $failed diff --git a/tests/sandbox/sandbox-unsafe@local/extension.js b/tests/sandbox/sandbox-unsafe@local/extension.js new file mode 100644 index 00000000..281f0233 --- /dev/null +++ b/tests/sandbox/sandbox-unsafe@local/extension.js @@ -0,0 +1,12 @@ +// SANDBOX ONLY: lets the test harness use org.gnome.Shell.Eval inside the nested shell. +import { Extension } from "resource:///org/gnome/shell/extensions/extension.js"; + +export default class SandboxUnsafe extends Extension { + enable() { + global.context.unsafe_mode = true; + } + + disable() { + global.context.unsafe_mode = false; + } +} diff --git a/tests/sandbox/sandbox-unsafe@local/metadata.json b/tests/sandbox/sandbox-unsafe@local/metadata.json new file mode 100644 index 00000000..eb33b81c --- /dev/null +++ b/tests/sandbox/sandbox-unsafe@local/metadata.json @@ -0,0 +1,6 @@ +{ + "uuid": "sandbox-unsafe@local", + "name": "Sandbox unsafe mode", + "description": "SANDBOX ONLY: enables GNOME Shell unsafe mode (org.gnome.Shell.Eval) in the nested test shell. Never install in a real session.", + "shell-version": ["50"] +} diff --git a/tests/sandbox/sb_eval.py b/tests/sandbox/sb_eval.py new file mode 100755 index 00000000..108c936b --- /dev/null +++ b/tests/sandbox/sb_eval.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +"""Run JavaScript inside the SANDBOX gnome-shell via org.gnome.Shell.Eval on its private bus. + + sandbox/sb_eval.py 'JS expression' sandbox/sb_eval.py - < script.js +Refuses to run unless the address is a private dbus-run-session bus, so it can never reach the +host session's shell. +""" +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib")) +from harness import js_raw # noqa: E402 + +code = sys.stdin.read() if sys.argv[1:] == ["-"] else " ".join(sys.argv[1:]) +ok, out = js_raw(code) +print(out if ok else f"EVAL ERROR: {out}") +sys.exit(0 if ok else 1) diff --git a/tests/sandbox/stop.sh b/tests/sandbox/stop.sh new file mode 100755 index 00000000..a78a999b --- /dev/null +++ b/tests/sandbox/stop.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Stop the nested sandbox shell (never touches the host session's gnome-shell). +source "$(dirname "$0")/env.sh" +for p in $(sandbox_pids); do echo "stopping sandbox gnome-shell $p"; kill -TERM "$p"; done diff --git a/tests/sandbox/watch.py b/tests/sandbox/watch.py new file mode 100755 index 00000000..15a0794d --- /dev/null +++ b/tests/sandbox/watch.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Show the sandbox's test monitor (monitor 0, the fixed-size --virtual-monitor) live in a window. + +The scenarios run on a fixed-size virtual monitor that has no window of its own. This asks the +sandbox shell to screencast that monitor (org.gnome.Mutter.ScreenCast on the sandbox's private +bus) and plays the PipeWire stream in a window on the host desktop. The window stays open across +sandbox restarts (each scenario starts a fresh sandbox) and reconnects automatically. +Close the window (or Ctrl+C) to stop. + + sandbox/watch.py & +""" +import os +import sys + +import gi + +gi.require_version("Gst", "1.0") +gi.require_version("Gtk", "3.0") +from gi.repository import Gio, GLib, Gst, Gtk # noqa: E402 + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib")) +import harness as h # noqa: E402 + +SC = "org.gnome.Mutter.ScreenCast" + + +def log(*args): + print(*args, flush=True) + + +def call(path, iface, method, args, reply): + return h.bus().call_sync(SC, path, iface, method, args, GLib.VariantType(reply) if reply else None, + Gio.DBusCallFlags.NONE, 10000, None) + + +def sandbox_ready(): + """The bus address of a running, fully started sandbox, else None.""" + try: + addr = open(os.path.join(h.SANDBOX_DIR, "bus-address")).read().strip() + started = "GNOME Shell started" in open(os.path.join(h.SANDBOX_DIR, "nested.log"), errors="replace").read() + except FileNotFoundError: + return None + return addr if addr and started else None + + +def test_monitor_connector(): + """Connector of the monitor the scenarios use (monitor 0 in the shell's numbering).""" + geo = h.js("(() => { const g = global.display.get_monitor_geometry(0); return [g.width, g.height]; })()") + state = h.bus().call_sync("org.gnome.Mutter.DisplayConfig", "/org/gnome/Mutter/DisplayConfig", + "org.gnome.Mutter.DisplayConfig", "GetCurrentState", None, None, + Gio.DBusCallFlags.NONE, 10000, None).unpack() + for (connector, *_), modes, _props in state[1]: + for mode in modes: + if mode[6].get("is-current") and [mode[1], mode[2]] == geo: + return connector, geo + raise RuntimeError(f"no monitor with the test monitor's size {geo}") + + +class Watcher: + def __init__(self): + self.addr = None # bus address of the sandbox we are showing + self.session = None + self.pipeline = None + self.window = Gtk.Window(title="Forge sandbox: waiting for the sandbox") + self.window.set_default_size(960, 540) + self.window.connect("destroy", lambda *_: self.quit()) + self.waiting = Gtk.Label(label="Waiting for the sandbox (sandbox/launch.sh)…") + self.window.add(self.waiting) + self.window.show_all() + self.loop = GLib.MainLoop() + GLib.timeout_add(1000, self.poll) + + def set_child(self, widget, title): + child = self.window.get_child() + if child is not widget: + if child: + self.window.remove(child) + self.window.add(widget) + self.window.set_title(title) + self.window.show_all() + + def disconnect(self, why): + if self.pipeline: + self.pipeline.set_state(Gst.State.NULL) + self.pipeline = None + if self.session: + try: + call(self.session, SC + ".Session", "Stop", None, None) + except GLib.Error: + pass + self.session = None + if self.addr: + log(f"disconnected: {why}") + self.addr = None + h._bus = None # the next sandbox has a new private bus + self.set_child(self.waiting, "Forge sandbox: waiting for the sandbox") + + def poll(self): + addr = sandbox_ready() + if addr != self.addr: + if self.addr: + self.disconnect("the sandbox stopped or restarted") + if addr: + try: + self.connect(addr) + except (GLib.Error, RuntimeError, OSError) as e: + log(f"not ready yet: {e}") + self.disconnect("connect failed") + return GLib.SOURCE_CONTINUE + + def connect(self, addr): + h._bus = None + connector, (width, height) = test_monitor_connector() + self.session = call("/org/gnome/Mutter/ScreenCast", SC, "CreateSession", + GLib.Variant("(a{sv})", ({},)), "(o)").unpack()[0] + stream = call(self.session, SC + ".Session", "RecordMonitor", + GLib.Variant("(sa{sv})", (connector, {"cursor-mode": GLib.Variant("u", 1)})), + "(o)").unpack()[0] + self.addr = addr + title = f"Forge sandbox: test monitor ({width}x{height})" + + def on_stream_added(_conn, _sender, _path, _iface, _signal, params): + node = params.unpack()[0] + log(f"watching {connector} ({width}x{height}), PipeWire node {node}") + self.pipeline = Gst.parse_launch( + f"pipewiresrc path={node} always-copy=true do-timestamp=true ! videoconvert ! " + f"gtksink name=sink sync=false") + bus = self.pipeline.get_bus() + bus.add_signal_watch() + bus.connect("message::eos", lambda *_: self.disconnect("end of stream")) + bus.connect("message::error", lambda *_: self.disconnect("stream error")) + self.set_child(self.pipeline.get_by_name("sink").props.widget, title) + self.pipeline.set_state(Gst.State.PLAYING) + + h.bus().signal_subscribe(SC, SC + ".Stream", "PipeWireStreamAdded", stream, None, + Gio.DBusSignalFlags.NONE, on_stream_added) + call(self.session, SC + ".Session", "Start", None, None) + + def quit(self): + self.disconnect("window closed") + self.loop.quit() + + +def main(): + Gst.init(None) + Gtk.init(None) + watcher = Watcher() + try: + watcher.loop.run() + except KeyboardInterrupt: + watcher.quit() + + +main() diff --git a/tests/scenarios/01_cross_container_snapback.py b/tests/scenarios/01_cross_container_snapback.py new file mode 100755 index 00000000..92c5ac06 --- /dev/null +++ b/tests/scenarios/01_cross_container_snapback.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""continuous resize against a neighbour in a DIFFERENT container snaps back on release. + +Layout: [A] + CON[B, C]. Resizing the B|C border (same container) is the control; resizing the +B|A border from B (different containers) is the bug. Covers mouse drags and a held shortcut +(the keyboard form is forge issue #532). + +Run on a fresh sandbox: sandbox/launch.sh && scenarios/01_cross_container_snapback.py +""" +import json +import os +import sys +import time + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib")) +import harness as h # noqa: E402 + + +def drag(name, win, side, nb, delta): + e, grabbed, _ = h.drag_edge(win["id"], side, delta) + if not grabbed: + print(f" FAIL {name}: no resize grab started") + return False + return h.settle_check(name, win["id"], side, nb["id"], e + delta) + + +def hold(name, win, side, nb, hold_ms=1000): + log = h.hold_keys(win["id"], h.GROW_KEYS[side], hold_ms) + rel = h.parse_line(next(l for l in log if "keys released" in l)) + w = rel[win["id"] % 1000] + size_rel = w["w"] if side in h.HORIZONTAL else w["h"] + ws = h.windows() + now = h.find(ws, win["id"]) + size_now = now["w"] if side in h.HORIZONTAL else now["h"] + ok_size = abs(size_now - size_rel) <= h.TOL + print(f" {'PASS' if ok_size else 'FAIL'} {name}: {rel['calls']} key repeats; size at release " + f"{size_rel:.0f}, settled {size_now}") + return ok_size and h.settle_check(f"{name} (layout)", win["id"], side, nb["id"], h.edge(now, side)) + + +def focus(win_id): + h.js(f"(() => {{ const w = global.display.list_all_windows().find(w => w.get_id() === {win_id});" + f" w.activate(global.get_current_time()); return 'ok'; }})()") + time.sleep(0.4) + + +def command(action): + h.js(f"(() => {{ {h.WM}.command({action}); return 'ok'; }})()") + time.sleep(0.8) + + +def border(ws, a_id, b_id): + a, b = h.find(ws, a_id), h.find(ws, b_id) + return h.edge(a, h.neighbour_side(a, b)) + + +def taps(name, win, side, nb, n=3, amount=15): + """AC6: separate short taps still move the edge one step each and persist.""" + e0 = h.edge(h.find(h.windows(), win["id"]), side) + for _ in range(n): + h.hold_keys(win["id"], h.GROW_KEYS[side], 0) + sign = -1 if side in ("left", "top") else 1 + return h.settle_check(name, win["id"], side, nb["id"], e0 + sign * n * amount) + + +def persistence(name, a, b, c): + """AC5: the resized border survives tabbed on/off and opening+closing another window.""" + before = border(h.windows(), b["id"], a["id"]) + focus(b["id"]) + command('{ name: "LayoutTabbedToggle" }') + command('{ name: "LayoutTabbedToggle" }') + focus(c["id"]) + ids = {w["id"] for w in h.windows()} + h.open_editor() + new = next(w["id"] for w in h.windows() if w["id"] not in ids) + h.js(f"(() => {{ global.display.list_all_windows().find(w => w.get_id() === {new}).delete(global.get_current_time()); return 'ok'; }})()") + time.sleep(1.5) + after = border(h.windows(), b["id"], a["id"]) + ok = abs(after - before) <= h.TOL + print(f" {'PASS' if ok else 'FAIL'} {name}: A|CON border {before} -> {after} after tabbed toggle x2 + open/close window") + return ok + + +def deeper(name, a): + """AC3: a window two containers deep (CON > CON2 > X) resized against A at the top level. + Returns (ok, new_window).""" + ws = h.windows() + candidates = [] + for w in ws: + if w["depth"] >= 1: + try: + candidates.append((w, h.neighbour_side(w, h.find(ws, a["id"])))) + except ValueError: + pass + if not candidates: + print(f" FAIL {name}: no container window borders A") + return False, None + x, _ = candidates[0] + focus(x["id"]) + ids = {w["id"] for w in h.windows()} + h.open_editor() + ws = h.windows() + d = next(w for w in ws if w["id"] not in ids) + deep = [w for w in ws if w["depth"] >= 2] + target = None + for w in deep: + try: + target = (w, h.neighbour_side(w, h.find(ws, a["id"]))) + break + except ValueError: + pass + if not target: + print(f" FAIL {name}: no depth-2 window borders A (depths: {[(w['id'] % 1000, w['depth']) for w in ws]})") + return False, d + t, side = target + # Move the edge AWAY from A (A grows) so no window is pushed below its minimum size; + # minimum-size behaviour is covered by its own scenario. + delta = 120 if side in ("left", "top") else -120 + e, grabbed, _ = h.drag_edge(t["id"], side, delta) + if not grabbed: + print(f" FAIL {name}: no resize grab started") + return False, d + print(f" (target {t['id'] % 1000} at depth {t['depth']}, {side} edge vs A)") + return h.settle_check(name, t["id"], side, a["id"], e + delta), d + + +def floating(name, win): + """AC6: floating windows resize freely and don't disturb the tiled layout.""" + focus(win["id"]) + command('{ name: "FloatToggle", mode: "float", x: "center", y: "center", width: 0.5, height: 0.5 }') + tiled_before = {w["id"]: (w["x"], w["y"], w["w"], w["h"]) for w in h.windows() if w["id"] != win["id"]} + f = h.js(f"(() => {{ const r = global.display.list_all_windows().find(w => w.get_id() === {win['id']}).get_frame_rect(); return JSON.stringify([r.x, r.y, r.width, r.height]); }})()") + x, y, w, hh = json.loads(f) + log = h.run_js_file("drag.js", {"__X0__": x + w + 1, "__Y0__": y + hh // 2, "__DX__": 100, "__DY__": 0, + "__STEPS__": 10, "__STEP_MS__": 40}, "__drag") + time.sleep(1.0) + f2 = json.loads(h.js(f"(() => {{ const r = global.display.list_all_windows().find(w => w.get_id() === {win['id']}).get_frame_rect(); return JSON.stringify([r.x, r.y, r.width, r.height]); }})()")) + tiled_after = {w["id"]: (w["x"], w["y"], w["w"], w["h"]) for w in h.windows() if w["id"] != win["id"]} + grew = abs((f2[2] - w) - 100) <= h.TOL + same = tiled_before == tiled_after + ok = grew and same and any("grab-op-begin" in l for l in log) + print(f" {'PASS' if ok else 'FAIL'} {name}: floating width {w} -> {f2[2]} (+100 expected); tiled windows unchanged: {same}") + command('{ name: "FloatToggle", mode: "float", x: "center", y: "center", width: 0.5, height: 0.5 }') + return ok + + +def main(): + a, b, c = h.nested_layout() + print(f"layout: A={a['id'] % 1000} (top level) + CON {b['playout']}[B={b['id'] % 1000}, C={c['id'] % 1000}]") + ws = h.windows() + s_bc = h.neighbour_side(h.find(ws, b["id"]), h.find(ws, c["id"])) + s_ba = h.neighbour_side(h.find(ws, b["id"]), h.find(ws, a["id"])) + grow_ba = -150 if s_ba in ("left", "top") else 150 + shrink_bc = -60 if s_bc in ("right", "bottom") else 60 + + r = [] + print("control: mouse drag, neighbour in the SAME container (B|C)") + r.append(drag("1.1", b, s_bc, c, shrink_bc)) + r.append(drag("1.2", b, s_bc, c, -shrink_bc * 4 // 3)) + print("bug: mouse drag, neighbour in a DIFFERENT container (B|A)") + r.append(drag("1.3", b, s_ba, a, grow_ba)) + r.append(drag("1.4", b, s_ba, a, -grow_ba // 2)) + print("control: held grow shortcut, SAME container (B toward C)") + # 500 ms stays within the room C has above its minimum height; a longer hold would (correctly, + # since the fix scenario 03 checks) stop at C's minimum and no longer measure what this test is about. + r.append(hold("1.5", b, s_bc, c, 500)) + print("bug (#532): held grow shortcut, DIFFERENT container (B toward A)") + r.append(hold("1.6", b, s_ba, a)) + print("no regression: separate taps, DIFFERENT container (B toward A)") + r.append(taps("1.7", b, s_ba, a)) + print("persistence: tabbed toggle + open/close another window") + r.append(persistence("1.8", a, b, c)) + print("deeper nesting: C inside CON2 inside CON, resized against A") + ok9, d = deeper("1.9", a) + r.append(ok9) + if d: + print("no regression: floating window resize") + r.append(floating("1.10", d)) + sys.exit(h.summary(r)) + + +main() diff --git a/tests/scenarios/02_keyboard_resize_edge.py b/tests/scenarios/02_keyboard_resize_edge.py new file mode 100755 index 00000000..a630655a --- /dev/null +++ b/tests/scenarios/02_keyboard_resize_edge.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""vertical keyboard resize moves the OPPOSITE edge while the key repeats. + +`resize()` applies the y adjustment to the wrong vertical direction: "grow/shrink bottom" +moves the window's TOP edge, and "grow/shrink top" moves its BOTTOM edge, until the final +render snaps it back into the layout. Horizontal (left/right) shortcuts are correct and serve +as controls. + +Layout: [A] + CON(VSPLIT)[B, C]. B's bottom edge and C's top edge form the B|C border. +Run on a fresh sandbox: sandbox/launch.sh && scenarios/02_keyboard_resize_edge.py +""" +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib")) +import harness as h # noqa: E402 + +# Forge's window-resize--increase / -decrease shortcuts, read from its settings +# (defaults: grow bottom Ctrl+Super+U, shrink bottom Ctrl+Shift+Super+I, ...). +def keys(side, mode): + return (h.GROW_KEYS if mode == "grow" else h.SHRINK_KEYS)[side] + + +# The non-resized edge may shift by at most two resize steps (2 x 15 px) while the key repeats: +# on Wayland mutter applies a new position immediately but a new size only when the client +# commits it, so a left/top edge resize can show the window shifted by a step or two in +# flight. The bug (moving the wrong edge) instead accumulates with every repeat. +EDGE_TOL = 30 + + +def fixed_edge_check(name, win, side, mode, hold_ms, stall=False): + """While `side` of `win` is resized by keyboard, the opposite edge must not move. + stall: freeze the app for 0.6 s during the hold (a slow or busy app).""" + opp = h.OPP[side] + h.reset_layout() # start each case from a settled, equal layout + during = h.stall_app(win["id"], 0.8, 0.6) if stall else None + log = h.hold_keys(win["id"], keys(side, mode), hold_ms, during=during) + samples = [] + for line in log: + if any(t in line for t in ("start", "keys down", "holding", "keys released")): + w = h.parse_line(line).get(win["id"] % 1000) + if w: + samples.append(h.edge({"x": w["x"], "y": w["y"], "w": w["w"], "h": w["h"]}, opp)) + start = samples[0] + worst = max(samples, key=lambda v: abs(v - start)) + moved_side = [h.edge({"x": w["x"], "y": w["y"], "w": w["w"], "h": w["h"]}, side) + for w in (h.parse_line(l).get(win["id"] % 1000) for l in log) if w] + resized = abs(moved_side[-1] - moved_side[0]) > 0 or mode == "shrink" + ok = abs(worst - start) <= EDGE_TOL + print(f" {'PASS' if ok else 'FAIL'} {name}: {mode} {side} ({hold_ms} ms): {opp} edge " + f"{start:.0f} -> worst {worst:.0f} during repeat (Δ{worst - start:+.0f})" + f"{'' if resized else ' [warning: resized edge did not move]'}") + return ok + + +def main(): + a, b, c = h.nested_layout() + h.ensure_parent_layout(c["id"], "VSPLIT") # B above C (auto-split varies with screen size) + h.reset_layout() + b, c = sorted((h.find(h.windows(), b["id"]), h.find(h.windows(), c["id"])), key=lambda w: w["y"]) + ws = h.windows() + print(f"layout: A={a['id'] % 1000} + CON VSPLIT[B={b['id'] % 1000} (top), C={c['id'] % 1000} (bottom)]") + side_ba = h.neighbour_side(h.find(ws, b["id"]), h.find(ws, a["id"])) + + r = [] + print("bug: vertical shortcuts, sampled during key repeat") + r.append(fixed_edge_check("2.1", b, "bottom", "grow", 700)) + r.append(fixed_edge_check("2.2", b, "bottom", "shrink", 700)) + r.append(fixed_edge_check("2.3", c, "top", "grow", 700)) + r.append(fixed_edge_check("2.4", c, "top", "shrink", 700)) + r.append(fixed_edge_check("2.5", b, "bottom", "grow", 0)) # single tap + print("control: horizontal shortcut (left edge), sampled during key repeat") + r.append(fixed_edge_check("2.6", b, side_ba, "shrink", 700)) + sys.exit(h.summary(r)) + + +main() diff --git a/tests/scenarios/03_resize_bounds.py b/tests/scenarios/03_resize_bounds.py new file mode 100755 index 00000000..8b143025 --- /dev/null +++ b/tests/scenarios/03_resize_bounds.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""a resize keeps going after the neighbour has reached its minimum size. + +Growing a tiled window (held shortcut or mouse drag) keeps shrinking the neighbour's percent +after the neighbour's app refuses to get any smaller. The neighbour is then pushed partly +off-screen or over other windows, and the percents stop adding up (one goes over 100%). + +Expected: the resize stops when the neighbour reaches its minimum size. +Run on a fresh sandbox: sandbox/launch.sh && scenarios/03_resize_bounds.py +""" +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib")) +import harness as h # noqa: E402 + + +def hold_ms(): + """Long enough to run out of room on this screen: key repeat adds ~15 px every ~30 ms after a + 500 ms delay, so ~500 px/s; 1.2 ms per px of screen width is ~2.3x the room there is.""" + return int(1000 + 1.2 * h.monitor_size()[0]) + + +def main(): + if h.windows(): + raise SystemExit("sandbox must be fresh (no windows); run sandbox/launch.sh first") + h.open_editor() + h.open_editor() + a, b = sorted(h.windows(), key=lambda w: w["x"]) + HOLD_MS = hold_ms() + print(f"layout: [A={a['id'] % 1000} | B={b['id'] % 1000}] side by side (Text Editor: min frame 360x200)") + r = [] + print("bug: growing a window into a neighbour at its minimum size") + log = h.hold_keys(a["id"], h.GROW_KEYS["right"], HOLD_MS) + r.append(h.layout_check("3.1", f"hold 'grow right' on A for {HOLD_MS} ms")) + r.append(h.timeline_check("3.1 (while held)", "no overlap or off-screen while the key repeats", log)) + h.reset_layout() + log = h.hold_keys(b["id"], h.GROW_KEYS["left"], HOLD_MS) + r.append(h.layout_check("3.2", f"hold 'grow left' on B for {HOLD_MS} ms")) + r.append(h.timeline_check("3.2 (while held)", "no overlap or off-screen while the key repeats", log)) + h.reset_layout() + far = int(0.7 * h.monitor_size()[0]) + h.drag_edge(a["id"], "right", far, steps=40) + r.append(h.layout_check("3.3", f"drag A's right edge {far} px to the right")) + h.reset_layout() + + print("slow app: hold 'grow left' on B while B's app is frozen for 0.6 s") + log = h.hold_keys(b["id"], h.GROW_KEYS["left"], HOLD_MS, during=h.stall_app(b["id"], 0.8, 0.6)) + r.append(h.layout_check("3.7", f"hold 'grow left' on B for {HOLD_MS} ms, app frozen 0.6 s")) + r.append(h.timeline_check("3.7 (while held)", "no overlap or off-screen while the key repeats", log)) + h.reset_layout() + + print("bug: shrinking the focused window past its minimum leaves a gap (shares sum < 100%)") + h.hold_keys(a["id"], h.SHRINK_KEYS["right"], HOLD_MS) + r.append(h.layout_check("3.4", f"hold 'shrink right' on A for {HOLD_MS} ms")) + h.reset_layout() + + print("bug, vertical: B above C in a container, grow B's bottom edge into C") + h.open_app("gnome-text-editor", "--standalone") # opens next to the focused window + con_ids = [w["id"] for w in h.windows() if w["pid"] == "con"] + if len(con_ids) == 2: + h.ensure_parent_layout(con_ids[0], "VSPLIT") # auto-split varies with screen size + ws = h.windows() + con = sorted([w for w in ws if w["pid"] == "con"], key=lambda w: w["y"]) + if len(con) == 2 and con[0]["playout"] == "VSPLIT": + h.reset_layout() + log = h.hold_keys(con[0]["id"], h.GROW_KEYS["bottom"], HOLD_MS) + r.append(h.layout_check("3.5", f"hold 'grow bottom' on the upper window for {HOLD_MS} ms")) + r.append(h.timeline_check("3.5 (while held)", "no overlap or off-screen while the key repeats", log)) + # Cross-container (parent pairs): grow a window in the container toward the top-level + # window. On builds without the fix scenario 01 checks, the held resize snaps back on release, so + # this case only exercises the limit once scenario 01 passes too. + print("bug, across containers: grow a window in the container into the top-level window") + h.reset_layout() + top = next(w for w in h.windows() if w["pid"] == "top") + side = h.neighbour_side(h.find(h.windows(), con[0]["id"]), top) + log = h.hold_keys(con[0]["id"], h.GROW_KEYS[side], HOLD_MS) + r.append(h.layout_check("3.6", f"hold 'grow {side}' on the upper window for {HOLD_MS} ms")) + r.append(h.timeline_check("3.6 (while held)", "no overlap or off-screen while the key repeats", log)) + else: + print(f" SKIP 3.5: third window did not form a vertical container: {ws}") + sys.exit(h.summary(r)) + + +main() diff --git a/tests/scenarios/04_min_size_layout.py b/tests/scenarios/04_min_size_layout.py new file mode 100755 index 00000000..9bdc8ec7 --- /dev/null +++ b/tests/scenarios/04_min_size_layout.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""the layout ignores windows' minimum sizes. + +Forge splits space purely by percent. When a window's share is smaller than the minimum size +its app allows, GNOME keeps the window at its minimum and it overlaps its neighbour or extends +off-screen (#117, #271). + +Part 1, the windows fit: after a resize has left a window at its minimum, anything that shrinks +the space (here: larger gaps; also a lower display resolution or a panel) squeezes it below +its minimum. Expected: it keeps its minimum and the other windows give up the space. +After such a clamp, the stored shares must match what is shown, or a later resize drifts. +(Windows that can't all fit at their minimum sizes: see 13_overflow_policy.py, a proposal.) + +Run on a fresh sandbox: sandbox/launch.sh && scenarios/04_min_size_layout.py +""" +import os +import sys +import time + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib")) +import harness as h # noqa: E402 + +WM = h.WM + + +def shares(): + return [(w["id"] % 1000, w["x"], w["w"], round(w["percent"], 3)) for w in sorted(h.windows(), key=lambda w: (w["x"], w["y"]))] + + +def close_all(): + """Close the windows one at a time. (Closing all windows of a tabbed container at the same + moment leaves its tab bar on screen: a separate upstream bug, scenario 06.)""" + h.close_windows() + + +def main(): + if h.windows(): + raise SystemExit("sandbox must be fresh (no windows); run sandbox/launch.sh first") + h.set_forge_setting("auto-split-enabled", False) # new windows join the focused window's split + r = [] + + print("bug: the windows fit, but the space shrinks after a resize left one at its minimum") + for _ in range(3): + h.open_editor() + a, b, c = sorted(h.windows(), key=lambda w: w["x"]) + h.drag_edge(a["id"], "right", h.monitor_size()[0] // 3, steps=30) # B (middle) ends at its minimum + time.sleep(1.0) + print(f" after drag: {shares()}") + h.set_forge_setting("window-gap-size-increment", 4) # gaps 4 -> 16 px + time.sleep(0.5) + r.append(h.layout_check("4.1", "gap size 4 -> 16 px with the middle window at its minimum")) + print(f" {shares()}") + + print("bug: a resize after a window was held at its minimum drifts") + h.settle() + a0 = h.find(h.windows(), a["id"]) + before = h.edge(h.find(h.windows(), b["id"]), "right") + h.drag_edge(b["id"], "right", 150, steps=20) + r.append(h.settle_check("4.2", b["id"], "right", c["id"], before + 150)) + a1 = h.find(h.windows(), a["id"]) + ok = abs(a1["w"] - a0["w"]) <= h.TOL and abs(a1["x"] - a0["x"]) <= h.TOL + print(f" {'PASS' if ok else 'FAIL'} 4.2 (others): A {a0['x']},{a0['w']} -> {a1['x']},{a1['w']} (must not change)") + r.append(ok) + sys.exit(h.summary(r)) + +main() diff --git a/tests/scenarios/06_stale_tab_bar.py b/tests/scenarios/06_stale_tab_bar.py new file mode 100755 index 00000000..0c2d3917 --- /dev/null +++ b/tests/scenarios/06_stale_tab_bar.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""a tab bar stays on screen after all of a tabbed container's windows close at once. + +With auto-exit-tabbed (default on), closing the second-to-last tab turns the container back into +a split; when the last window closes, the emptied container is removed, but its tab bar is only +destroyed for containers that are still tabbed. Closing one window at a time hides the bar in the +render in between; closing them together (quitting an app with several windows) leaves it. + +Run on a fresh sandbox: sandbox/launch.sh && scenarios/06_stale_tab_bar.py +""" +import json +import os +import sys +import time + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib")) +import harness as h # noqa: E402 + +WM = h.WM + + +def tab_bars(): + """Stray tab bars: visible ones whose container is no longer tabbed, or no longer in the tree, + as [x, y, width, tabs, reason]. (Tab bars of tabbed containers in the tree are fine.)""" + return json.loads(h.js(f"""JSON.stringify((() => {{ + const cons = {WM}.tree.getNodeByType("CON"); + return global.window_group.get_children() + .filter(c => c.type === "forge-deco" && c.visible && c.width > 0 && c.height > 0) + .map(c => [Math.round(c.x), Math.round(c.y), Math.round(c.width), c.get_n_children(), + !cons.includes(c.parentNode) ? "container gone" : !c.parentNode.isTabbed() ? "not tabbed" : ""]) + .filter(d => d[4]); }})())""")) + + +def tabbed_group(): + """A | TABBED[B, C, D]; returns the ids of B, C, D.""" + before = {w["id"] for w in h.windows()} + h.open_editor() + h.js(f'(() => {{ {WM}.command({{name: "Split", orientation: "vertical"}}); return "ok"; }})()') + time.sleep(0.5) + h.open_editor() + h.open_editor() + h.js(f'(() => {{ {WM}.command({{name: "LayoutTabbedToggle"}}); return "ok"; }})()') + time.sleep(1.0) + return [w["id"] for w in h.windows() if w["id"] not in before] + + +def close(ids, together): + lst = ", ".join(str(i) for i in ids) + if together: + h.js(f"""(() => {{ global.display.list_all_windows().filter(w => [{lst}].includes(w.get_id())) + .forEach(w => w.delete(global.get_current_time())); return "ok"; }})()""") + time.sleep(2.0) + else: + for i in ids: + h.js(f"""(() => {{ global.display.list_all_windows().find(w => w.get_id() === {i}) + .delete(global.get_current_time()); return "ok"; }})()""") + time.sleep(1.2) + + +def check(name, what): + bars = tab_bars() + ok = not bars + print(f" {'PASS' if ok else 'FAIL'} {name}: {what}: visible tab bars {bars}") + return ok + + +def main(): + if h.windows(): + raise SystemExit("sandbox must be fresh (no windows); run sandbox/launch.sh first") + h.set_forge_setting("auto-split-enabled", False) + h.open_editor() # A stays open throughout + r = [] + print("control: close the tabbed windows one at a time") + ids = tabbed_group() + print(f" tree: {h.tree_summary()} tab bars: {tab_bars()}") + close(ids[:1], together=False) + tabs = h.js(f"""(() => {{ const con = {WM}.tree.getNodeByType("CON").find(c => c.isTabbed()); + return con && con.decoration ? con.decoration.get_n_children() : -1; }})()""") + ok = tabs == 2 + print(f" {'PASS' if ok else 'FAIL'} 6.6: after closing 1 of 3 tabs, the group's tab bar shows {tabs} tabs (2 expected)") + r.append(ok) + close(ids[1:], together=False) + r.append(check("6.1", "after closing 3 tabs one by one")) + print("bug: close the tabbed windows together (e.g. quitting an app with several windows)") + ids = tabbed_group() + print(f" tree: {h.tree_summary()} tab bars: {tab_bars()}") + close(ids, together=True) + r.append(check("6.2", "after closing 3 tabs at once")) + h.open_editor() + r.append(check("6.3", "after opening another window")) + + print("bug: windows moved out of a tab group one by one (keyboard move)") + ids = tabbed_group() + print(f" tree: {h.tree_summary()}") + for wid in ids: + h.hold_keys(wid, h.chord("window-move-left"), 0) + time.sleep(1.0) + print(f" tree: {h.tree_summary()}") + r.append(check("6.4", "after moving every window out of the group")) + h.close_windows() + + print("bug: a tab group switched back to a split") + h.open_editor() + ids = tabbed_group() + h.hold_keys(ids[-1], h.chord("con-tabbed-layout-toggle"), 0) + time.sleep(1.0) + print(f" tree: {h.tree_summary()}") + r.append(check("6.5", "after toggling tabbed off")) + sys.exit(h.summary(r)) + + +main() diff --git a/tests/scenarios/07_real_apps.py b/tests/scenarios/07_real_apps.py new file mode 100755 index 00000000..5c5a6205 --- /dev/null +++ b/tests/scenarios/07_real_apps.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Real apps: the real-session checks with Ptyxis, Files (Nautilus) and VS Code. + +Part 1 uses the layout Forge builds by itself (on a wide screen with auto-split: +HSPLIT[Ptyxis, HSPLIT[Files, VS Code]]; on 1920x1080: Files stacked over VS Code, the layout where +the original problems were seen); part 2 toggles the container to the other direction. Run on a fresh sandbox, ideally the one that mirrors this machine: + SANDBOX_PROFILE=real sandbox/launch.sh && scenarios/07_real_apps.py +""" +import os +import sys +import time + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib")) +import harness as h # noqa: E402 + +VSCODE_DIR = os.path.join(h.SANDBOX_DIR, "vscode") # throwaway VS Code profile + + +def ptyxis(): + h.open_app("ptyxis", "--new-window") + + +def files(): + h.open_app("nautilus", "--new-window") + + +def vscode(): + # The VS Code snap forces --ozone-platform=x11, so it runs on the sandbox's Xwayland + # (as it does in a normal session). + display = h.js('GLib.getenv("DISPLAY")') + xauth = h.js('GLib.getenv("XAUTHORITY")') + h.open_app("env", f"DISPLAY={display}", f"XAUTHORITY={xauth}", "code", "--new-window", + f"--user-data-dir={VSCODE_DIR}", "--skip-welcome", "--disable-workspace-trust", timeout=120, + log=os.path.join(h.SANDBOX_DIR, "vscode.log")) + + +def toggle_container_layout(focus_id): + h.js(f"""(() => {{ global.display.list_all_windows().find(w => w.get_id() === {focus_id}) + .activate(global.get_current_time()); return "ok"; }})()""") + time.sleep(0.4) + h.js(f'(() => {{ {h.WM}.command({{name: "LayoutToggle"}}); return "ok"; }})()') + time.sleep(1.0) + + +def size(win, side): + return win["w"] if side in h.HORIZONTAL else win["h"] + + +def checks(prefix, a, b, c): + """Checks 1, 2 and 4 on B's edge that faces A, with C (B's sibling) left alone.""" + ws = h.windows() + side = h.neighbour_side(h.find(ws, b["id"]), h.find(ws, a["id"])) + print(f"layout: {h.tree_summary()}; Files faces Ptyxis on its {side}") + r = [] + h.reset_layout() + delta = -150 if side in ("left", "top") else 150 + before = h.edge(h.find(h.windows(), b["id"]), side) + c0 = h.find(h.windows(), c["id"]) + _, grabbed, _ = h.drag_edge(b["id"], side, delta) + r.append(grabbed and h.settle_check(f"{prefix}.1 check 1: drag Files|Ptyxis border", b["id"], side, a["id"], before + delta)) + c1 = h.find(h.windows(), c["id"]) + if c1["playout"] == ("HSPLIT" if side in h.HORIZONTAL else "VSPLIT"): + # Files and VS Code split in the resize direction: VS Code must keep its size (scenario 08) + ok = abs(size(c1, side) - size(c0, side)) <= h.TOL + print(f" {'PASS' if ok else 'FAIL'} {prefix}.1 (VS Code keeps its size): {size(c0, side)} -> {size(c1, side)} px") + else: + # stacked across the resize direction: VS Code spans the container and follows the edge + ok = abs(h.edge(c1, side) - h.edge(h.find(h.windows(), b["id"]), side)) <= h.TOL + print(f" {'PASS' if ok else 'FAIL'} {prefix}.1 (VS Code follows the edge): VS Code {side} edge " + f"{h.edge(c1, side)}, Files {h.edge(h.find(h.windows(), b['id']), side)}") + r.append(ok) + + h.reset_layout() + log = h.hold_keys(b["id"], h.GROW_KEYS[side], 1000) + rel = h.parse_line(next(l for l in log if "keys released" in l))[b["id"] % 1000] + time.sleep(1.2) + now = h.find(h.windows(), b["id"]) + rel_size = rel["w"] if side in h.HORIZONTAL else rel["h"] + ok = abs(size(now, side) - rel_size) <= h.TOL + print(f" {'PASS' if ok else 'FAIL'} {prefix}.2 check 2: hold 'grow {side}' on Files 1 s: " + f"{rel_size:.0f} at release, settled {size(now, side)}") + r.append(ok) + + h.reset_layout() + hold = int(1000 + 1.2 * max(h.monitor_size())) + log = h.hold_keys(b["id"], h.GROW_KEYS[side], hold) + r.append(h.layout_check(f"{prefix}.4 check 4: hold 'grow {side}' on Files {hold} ms", "stops at Ptyxis's minimum")) + r.append(h.timeline_check(f"{prefix}.4 (while held)", "no overlap or off-screen while the key repeats", log)) + return r + + +def main(): + h.require(apps=("ptyxis", "nautilus", "code")) + a, b, c = h.nested_layout((ptyxis, files, vscode)) + print(f"A=Ptyxis {a['id'] % 1000}, B=Files {b['id'] % 1000}, C=VS Code {c['id'] % 1000}") + r = [] + print("part 1: the layout Forge builds by itself") + r += checks("7", a, b, c) + + print("part 2: the container toggled to the other direction") + toggle_container_layout(c["id"]) + r += checks("7v", a, b, c) + if h.find(h.windows(), b["id"])["playout"] != "VSPLIT": + toggle_container_layout(c["id"]) # check 3 needs Files stacked over VS Code + h.reset_layout() + ws = h.windows() + side = h.neighbour_side(h.find(ws, b["id"]), h.find(ws, c["id"])) + opp = h.OPP[side] + log = h.hold_keys(b["id"], h.GROW_KEYS[side], 700) + samples = [h.edge(w, opp) for w in (h.parse_line(l).get(b["id"] % 1000) for l in log) if w] + worst = max(samples, key=lambda v: abs(v - samples[0])) + ok = abs(worst - samples[0]) <= 30 + print(f" {'PASS' if ok else 'FAIL'} 7v.3 check 3: hold 'grow {side}' on Files: its {opp} edge " + f"{samples[0]:.0f} -> worst {worst:.0f} while held (must stay put)") + r.append(ok) + sys.exit(h.summary(r)) + + +main() diff --git a/tests/scenarios/08_nested_same_direction.py b/tests/scenarios/08_nested_same_direction.py new file mode 100755 index 00000000..be20ebaa --- /dev/null +++ b/tests/scenarios/08_nested_same_direction.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""resizing a window's outer edge moves its sibling's border too, when the window's +container is split in the same direction as its parent (e.g. HSPLIT[A, HSPLIT[B, C]], which +Forge's auto-split creates on wide screens). + +Growing B's left edge (toward A) makes the container [B, C] larger, and Forge then shares the +extra space between B and C by their percents: C grows too and the B|C border moves, although +only the A|B border was touched. With a held shortcut A also loses twice what was asked for. +Only visible with the fix scenario 01 checks (without it these resizes snap back anyway). + +Run on a fresh sandbox: sandbox/launch.sh && scenarios/08_nested_same_direction.py +""" +import os +import sys +import time + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib")) +import harness as h # noqa: E402 + + +def command(name, focus_id=None): + if focus_id: + h.js(f"""(() => {{ global.display.list_all_windows().find(w => w.get_id() === {focus_id}) + .activate(global.get_current_time()); return "ok"; }})()""") + time.sleep(0.4) + h.js(f'(() => {{ {h.WM}.command({{name: "{name}"}}); return "ok"; }})()') + time.sleep(1.0) + + +def size(win, side): + return win["w"] if side in h.HORIZONTAL else win["h"] + + +def sibling_kept(name, c_before, c_id, side): + """C (the sibling) must keep its size and position along the resize direction.""" + c = h.find(h.windows(), c_id) + moved = abs(size(c, side) - size(c_before, side)) + abs(h.edge(c, h.OPP[side]) - h.edge(c_before, h.OPP[side])) + ok = moved <= h.TOL + print(f" {'PASS' if ok else 'FAIL'} {name}: sibling C {size(c_before, side)} -> {size(c, side)} px, " + f"far edge {h.edge(c_before, h.OPP[side])} -> {h.edge(c, h.OPP[side])}") + return ok + + +def run(prefix, a, b, c): + ws = h.windows() + side = h.neighbour_side(h.find(ws, b["id"]), h.find(ws, a["id"])) + r = [] + print(f"layout: {h.tree_summary()}; B's {side} edge faces A") + + h.reset_layout() + c0 = h.find(h.windows(), c["id"]) + log = h.hold_keys(b["id"], h.GROW_KEYS[side], 600) + rel = h.parse_line(next(l for l in log if "keys released" in l))[b["id"] % 1000] + time.sleep(1.2) + now = h.find(h.windows(), b["id"]) + ok = abs(size(now, side) - (rel["w"] if side in h.HORIZONTAL else rel["h"])) <= h.TOL + print(f" {'PASS' if ok else 'FAIL'} {prefix}.1: hold 'grow {side}' on B 600 ms: B at release " + f"{rel['w'] if side in h.HORIZONTAL else rel['h']:.0f}, settled {size(now, side)}") + r.append(ok) + r.append(sibling_kept(f"{prefix}.1 (sibling)", c0, c["id"], side)) + r.append(h.timeline_check(f"{prefix}.1 (while held)", "no overlap or off-screen while the key repeats", log)) + + h.reset_layout() + c0 = h.find(h.windows(), c["id"]) + delta = -200 if side in ("left", "top") else 200 + before = h.edge(h.find(h.windows(), b["id"]), side) + _, grabbed, _ = h.drag_edge(b["id"], side, delta) + r.append(grabbed and h.settle_check(f"{prefix}.2", b["id"], side, a["id"], before + delta)) + r.append(sibling_kept(f"{prefix}.2 (sibling)", c0, c["id"], side)) + + h.reset_layout() + c0 = h.find(h.windows(), c["id"]) + hold = int(1000 + 1.2 * max(h.monitor_size())) + log = h.hold_keys(b["id"], h.SHRINK_KEYS[side], hold) + r.append(h.layout_check(f"{prefix}.3", f"hold 'shrink {side}' on B {hold} ms: B stops at its minimum")) + r.append(sibling_kept(f"{prefix}.3 (sibling)", c0, c["id"], side)) + r.append(h.timeline_check(f"{prefix}.3 (while held)", "no overlap or off-screen while the key repeats", log)) + return r + + +def main(): + a, b, c = h.nested_layout() # [A] + CON[B, C] + r = [] + print("side by side: HSPLIT[A, HSPLIT[B, C]]") + h.ensure_parent_layout(a["id"], "HSPLIT") # the monitor + h.ensure_parent_layout(c["id"], "HSPLIT") # the container + b, c = sorted((h.find(h.windows(), b["id"]), h.find(h.windows(), c["id"])), key=lambda w: w["x"]) + r += run("8", a, b, c) + print("stacked: VSPLIT[A, VSPLIT[B, C]]") + h.ensure_parent_layout(a["id"], "VSPLIT") + h.ensure_parent_layout(c["id"], "VSPLIT") + b, c = sorted((h.find(h.windows(), b["id"]), h.find(h.windows(), c["id"])), key=lambda w: w["y"]) + r += run("8v", a, b, c) + sys.exit(h.summary(r)) + + +main() diff --git a/tests/scenarios/09_fuzz.py b/tests/scenarios/09_fuzz.py new file mode 100755 index 00000000..9e5cfcdc --- /dev/null +++ b/tests/scenarios/09_fuzz.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""Randomized stress test: random actions, and the layout rules checked after every one. + +Each step is one random action a user could take: open or close a window, focus, a Forge +shortcut (split, layout toggles, tabbed/stacked, move, swap, float, gaps, ...), a held resize +shortcut, or a mouse drag of a window edge. After every step the layout must be valid +(harness.layout_problems()), held keys and drags must not overlap or leave the screen while in +progress (harness.timeline_problems()), and Forge must not log a JS ERROR. The first failure +stops the run and prints the seed and the steps so far, so it can be replayed exactly. + + sandbox/launch.sh && scenarios/09_fuzz.py [--seed N|random] [--steps N] +""" +import argparse +import os +import random +import sys +import time + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib")) +import harness as h # noqa: E402 + +SIDES = ["left", "right", "top", "bottom"] +DIRS = ["left", "right", "up", "down"] +# Forge shortcuts the fuzzer presses. Left out: ones that turn tiling off or open other UI +# (prefs-open, prefs-tiling-toggle, workspace-active-tile-toggle, window-toggle-always-float, +# window-snap-*), where the layout rules do not apply. +SHORTCUTS = (["con-split-layout-toggle", "con-split-horizontal", "con-split-vertical", + "con-stacked-layout-toggle", "con-tabbed-layout-toggle", + "con-tabbed-showtab-decoration-toggle", "focus-border-toggle", + "window-gap-size-increase", "window-gap-size-decrease", "window-toggle-float", + "window-swap-last-active"] + + [f"window-{kind}-{d}" for kind in ("swap", "move", "focus") for d in DIRS]) +MAX_WINDOWS = 7 + + +def focus(win_id): + h.js(f"""(() => {{ global.display.list_all_windows().find(w => w.get_id() === {win_id}) + ?.activate(global.get_current_time()); return "ok"; }})()""") + time.sleep(0.3) + + +def close(win_id): + h.js(f"""(() => {{ global.display.list_all_windows().find(w => w.get_id() === {win_id}) + ?.delete(global.get_current_time()); return "ok"; }})()""") + time.sleep(1.0) + + +def tap(win_id, binding): + try: + keys = h.chord(binding) + except RuntimeError: + return None # shortcut not set in these settings + return h.hold_keys(win_id, keys, 0) + + +class Fuzzer: + def __init__(self, seed): + self.rng = random.Random(seed) + self.seed = seed + self.steps = [] + self.errors = h.js_errors() + + def pick_window(self): + ws = sorted(h.windows(), key=lambda w: w["id"]) + return self.rng.choice(ws) if ws else None + + def step(self): + """Do one random action. Returns (description, timeline log or None).""" + ws = h.windows() + roll = self.rng.random() + if not ws or (roll < 0.15 and len(ws) < MAX_WINDOWS): + h.open_editor() + return "open a window", None + win = self.pick_window() + wid = win["id"] + tag = f"w{wid % 1000}" + if roll < 0.22 and len(ws) > 1: + close(wid) + return f"close {tag}", None + if roll < 0.30: + focus(wid) + return f"focus {tag}", None + if roll < 0.60: + binding = self.rng.choice(SHORTCUTS) + focus(wid) + tap(wid, binding) + # layout changes move several windows at once and each app redraws at its own pace, + # so only the end state is checked (in-progress checks are for resizes and drags) + return f"{binding} on {tag}", None + if roll < 0.85: + side = self.rng.choice(SIDES) + kind = self.rng.choice(["increase", "decrease"]) + ms = self.rng.choice([0, 150, 400, 900, 2000]) + try: + keys = h.chord(f"window-resize-{side}-{kind}") + except RuntimeError: + return f"(no shortcut for resize {side} {kind})", None + log = h.hold_keys(wid, keys, ms) + return f"{'tap' if ms == 0 else f'hold {ms} ms'} resize {side} {kind} on {tag}", log + side = self.rng.choice(SIDES) + delta = self.rng.choice([-400, -150, -40, 40, 150, 400]) + if win.get("float"): + return f"(skip drag of floating {tag})", None + # An edge on the screen edge has nothing to resize against, and the press point just + # outside it is on a panel or dock (clicking the Ubuntu dock's icon minimizes the app) + ax, ay, aw, ah = h.work_area() + bound = {"left": ax, "top": ay, "right": ax + aw, "bottom": ay + ah}[side] + if abs(h.edge(win, side) - bound) <= h.gap() + h.GAP_TOL: + return f"(skip drag of {tag}'s {side} edge: it's on the screen edge)", None + _, grabbed, log = h.drag_edge(wid, side, delta) + # the window GNOME grabbed (edges coincide in stacked/tabbed groups, so it may be another) + grab = next((l for l in log if "SIGNAL grab-op-begin" in l), "") + self.dragged = int(grab.split("win=")[1].split()[0]) if "win=" in grab else wid + return f"drag {tag}'s {side} edge {delta:+d} px", log if grabbed else None + + def check(self, log): + probs = h.layout_problems() + if log: + floats = {w["id"] % 1000 for w in h.windows() if w.get("float")} + probs += [p for p in h.timeline_problems(log, ignore=self.dragged) + if not any(p.startswith(f"{f} ") or f" {f} " in p for f in floats)] + errors = h.js_errors() + if errors > self.errors: + for entry in h.js_error_entries()[self.errors:]: + probs.append("Forge JS ERROR:\n " + entry.replace("\n", "\n ")) + self.errors = errors + return probs + + def run(self, count): + tree_js = open(os.path.join(h.LIB, "tree.js")).read() + before = None + for i in range(1, count + 1): + self.dragged = None + what, log = self.step() + h.settle() + self.steps.append(what) + probs = self.check(log) + print(f" {'ok ' if not probs else 'FAIL'} {i:3d}. {what} {h.tree_summary()}", flush=True) + if probs: + for p in probs: + print(f" - {p}") + # Replays can diverge (focus and app timing), so show the full state around the failure + print("\nForge's tree before this step:\n" + (before or "(none)")) + print("\nForge's tree after it:\n" + h.js(tree_js)) + print(f"\nFAILED at step {i} (seed {self.seed}). Steps to reproduce:") + for n, s in enumerate(self.steps, 1): + print(f" {n:3d}. {s}") + return False + before = h.js(tree_js) + return True + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--seed", default="303", + help="replay a run (default 303, so suite runs are repeatable), or 'random' to explore") + ap.add_argument("--steps", type=int, default=150) + args = ap.parse_args() + args.seed = int(time.time()) % 100000 if args.seed == "random" else int(args.seed) + if h.windows(): + raise SystemExit("sandbox must be fresh (no windows); run sandbox/launch.sh first") + # With more windows than fit, "overlap" (today's default) breaks the layout rules by design; + # use tabbed where the build has the setting, else keep the window count low. + global MAX_WINDOWS + if not h.set_forge_setting("min-size-overflow", "tabbed"): + MAX_WINDOWS = 4 + print(f"fuzz: seed {args.seed}, {args.steps} steps, up to {MAX_WINDOWS} windows") + ok = Fuzzer(args.seed).run(args.steps) + sys.exit(h.summary([ok])) + + +main() diff --git a/tests/scenarios/10_performance.py b/tests/scenarios/10_performance.py new file mode 100755 index 00000000..433da909 --- /dev/null +++ b/tests/scenarios/10_performance.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Performance: how much work Forge does per render and per live-resize tick. + +Setup: 5 Text Editor windows on this workspace (nested layout) and 5 on workspace 2. +Workloads, each measured with lib/perf.js (timings of Forge's hot functions and a count of the +window move/resize requests it sends, by caller): + render 30 re-renders (what every focus change, open, close, setting change triggers) + drag a 2 s mouse drag of a border between containers (the live-resize loop, 16 ms ticks) + hold a 2 s held resize shortcut + +Every move/resize request makes the app redraw. During a live resize (a 16 ms loop) requests +for windows on another workspace, or for a window already at that exact geometry, are wasted: + 10.1 live-resize ticks only move windows on the current workspace + 10.2 live-resize ticks only re-send a window's place when it changed (at most one first + request per window per resize) + 10.3 (info) requests per render; renders re-send every window's place on purpose +Timings are reported, not judged (they depend on the machine). + + sandbox/launch.sh && scenarios/10_performance.py [--json out.json] +""" +import argparse +import json +import os +import sys +import time + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib")) +import harness as h # noqa: E402 + +PER_WORKSPACE = 5 + + +def perf_start(): + h.js(open(os.path.join(h.LIB, "perf.js")).read()) + h.js("globalThis.__perf.start()") + + +def perf_stop(): + return json.loads(h.js("globalThis.__perf.stop()")) + + +def move_to_workspace(win_id, index): + h.js(f"""(() => {{ global.display.list_all_windows().find(w => w.get_id() === {win_id}) + .change_workspace_by_index({index}, false); return "ok"; }})()""") + time.sleep(0.8) + + +def setup(): + # 5 windows on workspace 2 (index 1), then 5 here + for _ in range(PER_WORKSPACE): + before = {w["id"] for w in h.windows()} + h.open_editor() + new = [w["id"] for w in h.windows() if w["id"] not in before] + move_to_workspace(new[0], 1) + a, b, c = h.nested_layout() + for _ in range(PER_WORKSPACE - 3): + h.open_editor() + time.sleep(1.0) + return a, b, c + + +def summarize(name, report, ticks_label=None): + t = report["timings"] + moves = report["moves"] + print(f" {name}:") + for label, s in sorted(t.items()): + print(f" {label:24s} {s['calls']:5d} calls mean {s['mean_us']:6d} us p95 {s['p95_us']:6d} us max {s['max_us']:6d} us") + for caller, m in sorted(moves.items()): + per = "" + if caller in t and t[caller]["calls"]: + per = f" ({m['calls'] / t[caller]['calls']:.1f} per call)" + print(f" moves from {caller:24s} {m['calls']:5d}{per}; other workspace {m['otherWorkspace']}, " + f"already there {m['unchanged']}") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--json", help="write the raw report here") + args = ap.parse_args() + if h.windows(): + raise SystemExit("sandbox must be fresh (no windows); run sandbox/launch.sh first") + a, b, c = setup() + print(f"layout here: {h.tree_summary()} (+{PER_WORKSPACE} windows on workspace 2)") + reports = {} + + perf_start() + for _ in range(30): + h.js(f'(() => {{ {h.WM}.renderTree("perf"); return "ok"; }})()') + time.sleep(0.1) + time.sleep(0.5) + reports["render"] = perf_stop() + + ws = h.windows() + side = h.neighbour_side(h.find(ws, b["id"]), h.find(ws, a["id"])) + perf_start() + h.drag_edge(b["id"], side, -200 if side in ("left", "top") else 200, steps=40, step_ms=50) + time.sleep(1.0) + reports["drag"] = perf_stop() + + h.reset_layout() + perf_start() + h.hold_keys(b["id"], h.GROW_KEYS[side], 2000) + time.sleep(1.0) + reports["hold"] = perf_stop() + + for name, rep in reports.items(): + summarize(name, rep) + if args.json: + with open(args.json, "w") as f: + json.dump(reports, f, indent=1) + + r = [] + missing = sorted({m for rep in reports.values() for m in rep.get("missing", [])}) + if missing: + print(f" info this build has no {', '.join(missing)}: those parts aren't measured") + if "wm._liveResizeNeighbors" in missing: + # 10.1/10.2 count the live-resize loop's requests: without it they'd pass without measuring + print(" FAIL 10.1/10.2: can't measure: this build has no live-resize loop (_liveResizeNeighbors)") + sys.exit(h.summary([False])) + other = sum(rep["moves"].get("wm._liveResizeNeighbors", {}).get("otherWorkspace", 0) + for rep in (reports["drag"], reports["hold"])) + ok = other == 0 + print(f" {'PASS' if ok else 'FAIL'} 10.1: live-resize ticks moved windows on another workspace {other} times") + r.append(ok) + live = [rep["moves"].get("wm._liveResizeNeighbors", {}) for rep in (reports["drag"], reports["hold"])] + redundant = sum(m.get("unchanged", 0) for m in live) + allowed = 2 * len(h.windows()) # one first request per window, per drag/hold + ok = redundant <= allowed + print(f" {'PASS' if ok else 'FAIL'} 10.2: live-resize requests to windows already in place: {redundant} " + f"(at most {allowed}: one per window per resize)") + r.append(ok) + apply_moves = reports["render"]["moves"].get("tree.apply", {}) + renders = reports["render"]["timings"].get("tree.render", {}).get("calls", 0) + print(f" info 10.3: {renders} re-renders of an unchanged layout sent {apply_moves.get('calls', 0)} move " + f"requests ({apply_moves.get('unchanged', 0)} to windows already there). Renders re-send every " + f"window's place on purpose (they put windows back if anything moved them)") + sys.exit(h.summary(r)) + + +main() diff --git a/tests/scenarios/11_slow_app.py b/tests/scenarios/11_slow_app.py new file mode 100755 index 00000000..79f2634d --- /dev/null +++ b/tests/scenarios/11_slow_app.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""a held resize shortcut slides a slow app's window instead of resizing it. + +On Wayland a new position takes effect at once, but a new size only when the app redraws. Forge +started each key repeat from the window's frame, which (while the app is slow) already has the +new position but still the old size: growing the left or top edge then moved the whole window, +the opposite edge drifted by one step per key repeat, and it stayed there until the resize ended. +Here the app is frozen (SIGSTOP) for a moment during the hold to make it slow on purpose. + +Layout: [A] + CON(VSPLIT)[B, C]. Run on a fresh sandbox: sandbox/launch.sh && scenarios/11_slow_app.py +""" +import os +import sys +import time + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib")) +import harness as h # noqa: E402 + +EDGE_TOL = 30 # one or two resize steps: Wayland shows a new position just before a new size + + +def opposite_edge(name, win, side, hold_ms, freeze_s): + """While `side` of `win` grows with the app frozen for `freeze_s`, the opposite edge stays.""" + opp = h.OPP[side] + log = h.hold_keys(win["id"], h.GROW_KEYS[side], hold_ms, during=h.stall_app(win["id"], 0.8, freeze_s)) + samples = [h.edge(w, opp) for w in (h.parse_line(l).get(win["id"] % 1000) for l in log) if w] + grown = [h.edge(w, side) for w in (h.parse_line(l).get(win["id"] % 1000) for l in log) if w] + worst = max(samples, key=lambda v: abs(v - samples[0])) + ok = abs(worst - samples[0]) <= EDGE_TOL and abs(samples[-1] - samples[0]) <= 2 + print(f" {'PASS' if ok else 'FAIL'} {name}: grow {side} {hold_ms} ms, app frozen {freeze_s} s: {opp} edge " + f"{samples[0]:.0f} -> worst {worst:.0f}, at the end {samples[-1]:.0f}; {side} edge moved " + f"{grown[-1] - grown[0]:+.0f} px") + return ok + + +def main(): + a, b, c = h.nested_layout() + h.ensure_parent_layout(c["id"], "VSPLIT") + b, c = sorted((h.find(h.windows(), b["id"]), h.find(h.windows(), c["id"])), key=lambda w: w["y"]) + ws = h.windows() + side_ba = h.neighbour_side(h.find(ws, b["id"]), h.find(ws, a["id"])) + print(f"layout: {h.tree_summary()}") + r = [] + print("bug: the app is slow to redraw while its left/top edge grows") + h.reset_layout() + r.append(opposite_edge("11.1", c, "top", 1600, 0.6)) + h.reset_layout() + r.append(opposite_edge("11.2", b, side_ba, 1600, 0.6)) + + print("slow neighbour: A is frozen for 0.2 s while B grows into it") + h.reset_layout() + log = h.hold_keys(b["id"], h.GROW_KEYS[side_ba], 2000, during=h.stall_app(a["id"], 0.9, 0.2)) + probs = h.timeline_problems(log) + print(f" info 11.3: while A was frozen: {probs or 'no overlap'} (A can't redraw; expected to settle)") + r.append(h.layout_check("11.3", "no overlap once A redraws")) + sys.exit(h.summary(r)) + + +main() diff --git a/tests/scenarios/12_monitors_x11.py b/tests/scenarios/12_monitors_x11.py new file mode 100755 index 00000000..24b17e7f --- /dev/null +++ b/tests/scenarios/12_monitors_x11.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +# sandbox: second-monitor +"""Regression checks for how Forge places windows: moving a window to another monitor and back, +an X11 (Xwayland) app, and maximize/unmaximize of a tiled window. Written for the change that +moves and resizes a window in one request instead of move_frame() + move_resize_frame(). + +Needs a second monitor: SANDBOX_SECOND_MONITOR=1280x1024 sandbox/launch.sh && scenarios/12_monitors_x11.py +""" +import os +import sys +import time + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib")) +import harness as h # noqa: E402 + +W = "global.display.list_all_windows().find(w => w.get_id() === {})" + + +def geometry(wid): + return h.js(f"""(() => {{ const w = {W.format(wid)}; const f = w.get_frame_rect(); + const m = w.get_monitor(); const a = global.workspace_manager.get_active_workspace().get_work_area_for_monitor(m); + return {{mon: m, x: f.x, y: f.y, w: f.width, h: f.height, ax: a.x, ay: a.y, aw: a.width, ah: a.height, + max: w.is_maximized()}}; }})()""") + + +def inside(g, tol=2): + return (g["x"] >= g["ax"] - tol and g["y"] >= g["ay"] - tol and + g["x"] + g["w"] <= g["ax"] + g["aw"] + tol and g["y"] + g["h"] <= g["ay"] + g["ah"] + tol) + + +def check(name, ok, what): + print(f" {'PASS' if ok else 'FAIL'} {name}: {what}") + return ok + + +def open_x11(title): + display = h.js('GLib.getenv("DISPLAY")') + xauth = h.js('GLib.getenv("XAUTHORITY")') + h.open_app("env", f"DISPLAY={display}", f"XAUTHORITY={xauth}", "xmessage", "-name", title, title) + + +def main(): + h.require(apps=("xmessage",)) + if h.js("global.display.get_n_monitors()") < 2: + raise SystemExit("needs two monitors: SANDBOX_SECOND_MONITOR=1280x1024 sandbox/launch.sh") + h.open_editor() + h.open_editor() + a, b = sorted(h.windows(), key=lambda w: w["x"]) + r = [] + + print("move a tiled window to the other monitor and back (window-move-right / -left)") + for _ in range(3): + h.hold_keys(b["id"], h.chord("window-move-right"), 0) + time.sleep(1.0) + if geometry(b["id"])["mon"] == 1: + break + g = geometry(b["id"]) + r.append(check("12.1", g["mon"] == 1 and inside(g), f"on monitor {g['mon']}, frame {g['x']},{g['y']} {g['w']}x{g['h']} " + f"inside its work area {g['ax']},{g['ay']} {g['aw']}x{g['ah']}")) + r.append(h.layout_check("12.2", "monitor 0's layout after the move (A alone)")) + for _ in range(3): + h.hold_keys(b["id"], h.chord("window-move-left"), 0) + time.sleep(1.0) + if geometry(b["id"])["mon"] == 0: + break + g = geometry(b["id"]) + r.append(check("12.3", g["mon"] == 0 and inside(g), f"back on monitor {g['mon']}, frame {g['x']},{g['y']} {g['w']}x{g['h']}")) + r.append(h.layout_check("12.4", "monitor 0's layout after moving back")) + + print("maximize and unmaximize a tiled window") + h.js(f'(() => {{ {W.format(a["id"])}.maximize(); return "ok"; }})()') + time.sleep(1.0) + h.js(f'(() => {{ {W.format(a["id"])}.unmaximize(); return "ok"; }})()') + time.sleep(1.5) + r.append(h.layout_check("12.5", "layout after maximize + unmaximize")) + + print("an X11 app, tiled and resized") + open_x11("forge-x11-test") + x11 = next(w for w in h.windows() if w["id"] not in (a["id"], b["id"])) + log = h.hold_keys(x11["id"], h.GROW_KEYS[h.neighbour_side(h.find(h.windows(), x11["id"]), h.find(h.windows(), a["id"]))], 800) + r.append(h.layout_check("12.6", "layout after holding a resize shortcut on the X11 window")) + r.append(h.timeline_check("12.6 (while held)", "no overlap or off-screen while the key repeats", log)) + sys.exit(h.summary(r)) + + +main() diff --git a/tests/scenarios/13_overflow_policy.py b/tests/scenarios/13_overflow_policy.py new file mode 100755 index 00000000..c95d3a8d --- /dev/null +++ b/tests/scenarios/13_overflow_policy.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Proposal (not a bug fix): what happens when tiled windows can't all get their minimum size. + +With more windows than fit at their minimum size (e.g. 6 Text Editors side by side need +6 x 368 px > 1904 px on 1920x1080), today they overlap and extend off-screen. The proposed setting +`min-size-overflow` = tabbed / stacked groups windows until the rest fit. On builds without the +setting the tabbed/stacked checks fail by design; 13.4 checks that the default keeps today's +behaviour. Branch: feat/min-size-overflow (see the design proposal before relying on it). + +Run on a fresh sandbox: sandbox/launch.sh && scenarios/13_overflow_policy.py +""" +import os +import sys +import time + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib")) +import harness as h # noqa: E402 + +WM = h.WM + + +def shares(): + return [(w["id"] % 1000, w["x"], w["w"], round(w["percent"], 3)) for w in sorted(h.windows(), key=lambda w: (w["x"], w["y"]))] + + +def close_all(): + """Close the windows one at a time. (Closing all windows of a tabbed container at the same + moment leaves its tab bar on screen: a separate upstream bug, scenario 06.)""" + h.close_windows() + + +EDITOR_MIN = (360, 200) # GNOME Text Editor's minimum frame size (get_min_size() minus shadows) + + +def overflow_count(vertical): + """One more editor than fits at its minimum size (plus 8 px of gaps) on this screen.""" + width, height = h.monitor_size() + return (height // (EDITOR_MIN[1] + 8) if vertical else width // (EDITOR_MIN[0] + 8)) + 1 + + +def overflow(name, policy, split=None): + """Open one more editor than fits side by side (or stacked vertically with split).""" + close_all() + h.set_forge_setting("window-gap-size-increment", 1) + ok_key = h.set_forge_setting("min-size-overflow", policy) + h.open_editor() + if split: + h.js(f'(() => {{ {WM}.command({{name: "Split", orientation: "{split}"}}); return "ok"; }})()') + time.sleep(0.5) + count = overflow_count(vertical=bool(split)) + for _ in range(count - 1): + h.open_editor() + what = f"{count} windows {'in a vertical container' if split else 'side by side'}, min-size-overflow={policy}" + if not ok_key: + print(f" FAIL {name}: {what}: this Forge build has no min-size-overflow setting") + ok = h.layout_check(name + " (layout)", f"{count} windows with no overflow handling") + return False + ok = h.layout_check(name, what) + print(f" tree: {h.tree_summary()}") + return ok + + +def main(): + if h.windows(): + raise SystemExit("sandbox must be fresh (no windows); run sandbox/launch.sh first") + h.require(setting="min-size-overflow") # a proposal: only its branch has the setting + h.set_forge_setting("auto-split-enabled", False) # new windows join the focused window's split + r = [] + print("more windows than fit at their minimum size") + r.append(overflow("13.1", "tabbed")) + r.append(overflow("13.2", "stacked")) + r.append(overflow("13.3", "tabbed", split="vertical")) + print("control: default policy keeps today's behaviour (windows overlap), no errors") + close_all() + h.set_forge_setting("min-size-overflow", "overlap") + for _ in range(overflow_count(vertical=False)): + h.open_editor() + probs = h.layout_problems() + print(f" {'PASS' if probs else 'FAIL'} 13.4: overlap policy leaves the overflow as it is " + f"({len(probs)} layout problems, expected > 0)") + r.append(bool(probs)) + sys.exit(h.summary(r)) + + +main() diff --git a/tests/scenarios/14_move_out_shares.py b/tests/scenarios/14_move_out_shares.py new file mode 100755 index 00000000..7af4addc --- /dev/null +++ b/tests/scenarios/14_move_out_shares.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +# sandbox: second-monitor +"""Moving a window out of its container can leave its old size share behind. + +Forge sizes the windows of a split by their shares (percents), which must add up to 100%. When +"move window up/down" (or left/right) takes a window out of its container to the workspace level +(because there's nothing in that direction to move into), the moved window keeps the share it had +in its old container, and the workspace level's shares aren't reset. The shares there then add up +to more than 100% and the windows overlap or run off-screen. + +Found by the randomized stress test (09_fuzz.py, seed 303 on the real-size profile). + +Also checks what the fix must not change: a window already at the edge of the workspace keeps +everyone's sizes, and a container emptied by the move doesn't leave a gap. + +Uses a second monitor (to the right) for 14.3: + SANDBOX_SECOND_MONITOR=1280x1024 sandbox/launch.sh && scenarios/14_move_out_shares.py +""" +import os +import sys +import time + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib")) +import harness as h # noqa: E402 + + +def command(win_id, cmd): + h.js(f"""(() => {{ global.display.list_all_windows().find(w => w.get_id() === {win_id}) + .activate(global.get_current_time()); {h.WM}.command({cmd}); return "ok"; }})()""") + time.sleep(1.0) + + +def new_window(known): + h.open_editor() + return next(w for w in h.windows() if w["id"] not in known) + + +def setup(container_last=False): + """A | B at unequal shares, then C joins one of them in a container, also at unequal shares: + HSPLIT[HSPLIT[A, C], B], or HSPLIT[B, HSPLIT[A, C]] with `container_last` (A is then the + right-hand window). Returns (A, B, C).""" + h.open_editor() + h.open_editor() + left, right = sorted(h.windows(), key=lambda w: w["x"]) + h.drag_edge(left["id"], "right", h.monitor_size()[0] // 8) + a, b = (right, left) if container_last else (left, right) + command(a["id"], '{name: "Split", orientation: "horizontal"}') + c = new_window((a["id"], b["id"])) # joins A's container, next to A + h.drag_edge(a["id"], "right", -h.monitor_size()[0] // 16) + return a, b, c + + +def finish(name, what, moved=None): + ok = h.layout_check(name, what) + if moved and moved["id"] not in [w["id"] for w in h.windows()]: + print(f" FAIL {name} (monitor): the moved window left this monitor") + ok = False + print(f" after: {h.tree_summary()}") + h.close_windows() + time.sleep(1.0) + return ok + + +def move_out(name, direction): + a, b, c = setup() + print(f"before: {h.tree_summary()}") + command(c["id"], f'{{name: "Move", direction: "{direction}"}}') + return finish(name, f"move C {direction.lower()} out of its container: shares still add up, " + "no overlap or off-screen", moved=c) + + +def move_out_towards_monitor(name): + """HSPLIT[B, HSPLIT[A, C]] with another monitor to the right: 'move right' on C takes it out + of its container, onto this monitor's workspace level (Forge's MONITOR case).""" + a, b, c = setup(container_last=True) + print(f"before: {h.tree_summary()}") + command(c["id"], '{name: "Move", direction: "Right"}') + return finish(name, "move C right, out of its container towards the other monitor: shares add up", + moved=c) + + +def edge_keeps_shares(name): + """Top-level A | B at custom sizes: 'move up' on A (nothing above it, and it's already at the + workspace level) must leave both windows where they are, at their sizes.""" + h.open_editor() + h.open_editor() + a, b = sorted(h.windows(), key=lambda w: w["x"]) + h.drag_edge(a["id"], "right", h.monitor_size()[0] // 8) + place = lambda: {w["id"] % 1000: (w["x"], w["w"]) for w in h.windows()} # noqa: E731 + before = place() + command(a["id"], '{name: "Move", direction: "Up"}') + after = place() + ok = before.keys() == after.keys() and all( + abs(before[k][0] - after[k][0]) <= h.TOL and abs(before[k][1] - after[k][1]) <= h.TOL for k in before) + print(f" {'PASS' if ok else 'FAIL'} {name}: 'move up' on a window with nothing above keeps every place " + f"(x, width): {before} -> {after}") + h.close_windows() + time.sleep(1.0) + return ok + + +def emptied_container(name): + """HSPLIT[HSPLIT[HSPLIT[W], X], Y], W and X at unequal shares: 'move up' on W empties W's + container; X must take the whole of it, not leave W's share as a gap.""" + h.open_editor() + h.open_editor() + w, y = sorted(h.windows(), key=lambda v: v["x"]) + command(w["id"], '{name: "Split", orientation: "horizontal"}') + x = new_window((w["id"], y["id"])) + command(w["id"], '{name: "Split", orientation: "horizontal"}') + h.drag_edge(w["id"], "right", -h.monitor_size()[0] // 16) + print(f"before: {h.tree_summary()}") + command(w["id"], '{name: "Move", direction: "Up"}') + return finish(name, "move W up out of a nested container: no gap where it was", moved=w) + + +def main(): + if h.windows(): + raise SystemExit("sandbox must be fresh (no windows); run sandbox/launch.sh first") + h.set_forge_setting("auto-split-enabled", False) # new windows join the focused window's split + h.set_forge_setting("move-pointer-focus-enabled", False) + r = [move_out("14.1", "Up"), move_out("14.2", "Down")] + if h.js("global.display.get_n_monitors()") >= 2: + r.append(move_out_towards_monitor("14.3")) + else: + print(" SKIP 14.3: needs a second monitor to the right") + r.append(edge_keeps_shares("14.4")) + r.append(emptied_container("14.5")) + sys.exit(h.summary(r)) + + +main() diff --git a/tests/scenarios/15_render_on_changes.py b/tests/scenarios/15_render_on_changes.py new file mode 100755 index 00000000..e1ade6d5 --- /dev/null +++ b/tests/scenarios/15_render_on_changes.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Forge puts tiled windows back after something else moves or resizes them. + +Forge re-renders when a tiled window's position or size changes. A performance change may skip +renders that would only repeat the last one (a window arriving where Forge just put it), but these +must still render: + + 15.1 an app resizes its own tiled window: Forge puts it back in its place; + 15.2 a window is maximized, its neighbour changes size meanwhile, then the window is restored: + the neighbour is back in its place too; + 15.3 the same, all within 0.8 s of a render (so a "recent request" rule can't hide it). + +These pass on main; they guard against skipping a render that was needed. + +Run on a fresh sandbox: sandbox/launch.sh && scenarios/15_render_on_changes.py +""" +import os +import sys +import time + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib")) +import harness as h # noqa: E402 + + +def resize_behind_forges_back(win_id, dw): + """Resize a window the way an app would (not through Forge).""" + h.js(f"""(() => {{ const w = global.display.list_all_windows().find(w => w.get_id() === {win_id}); + const f = w.get_frame_rect(); w.move_resize_frame(true, f.x, f.y, f.width + {dw}, f.height); + return "ok"; }})()""") + + +def maximize(win_id, on): + h.js(f"""(() => {{ const w = global.display.list_all_windows().find(w => w.get_id() === {win_id}); + if ({'true' if on else 'false'}) w.maximize(); else w.unmaximize(); return "ok"; }})()""") + + +def main(): + if h.windows(): + raise SystemExit("sandbox must be fresh (no windows); run sandbox/launch.sh first") + h.open_editor() + h.open_editor() + a, b = sorted(h.windows(), key=lambda w: w["x"]) + r = [] + + resize_behind_forges_back(b["id"], -200) + time.sleep(1.5) + r.append(h.layout_check("15.1", "an app shrinks its own tiled window: Forge puts it back")) + + maximize(a["id"], True) + time.sleep(1.5) + resize_behind_forges_back(b["id"], -200) + time.sleep(1.5) + maximize(a["id"], False) + time.sleep(1.5) + r.append(h.layout_check("15.2", "restore a maximized window: its neighbour, resized meanwhile, " + "is back in its place")) + + h.js(f'(() => {{ {h.WM}.renderTree("test"); return "ok"; }})()') # Forge requests every frame now + time.sleep(0.2) + maximize(a["id"], True) + time.sleep(0.2) + resize_behind_forges_back(b["id"], -200) + time.sleep(0.2) + maximize(a["id"], False) + r.append(h.layout_check("15.3", "the same within 0.8 s of a render")) + sys.exit(h.summary(r)) + + +main() diff --git a/tests/scenarios/16_tabbed_edge_resize.py b/tests/scenarios/16_tabbed_edge_resize.py new file mode 100755 index 00000000..f3c6bb2e --- /dev/null +++ b/tests/scenarios/16_tabbed_edge_resize.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Resizing a window in a tabbed or stacked group by dragging the group's outer edge. + +A tab (or stack) group is one tile: dragging the edge of its visible window that faces a neighbour +must move the border between the group and that neighbour. Forge looks for the neighbour with +Tree.next(), which walks a tabbed group as if its tabs were side by side (and a stacked group as if +its windows were above each other). So when the visible window isn't the first in its group, the +"neighbour" found is the previous tab: the resize changes shares inside the group, nothing moves, +and the edge snaps back on release. Found in a real session (a VS Code window, second of three tabs, +next to a column of terminals). + + 16.1 control: drag the neighbour's edge that faces the tab group + 16.2 tabbed group, first tab visible: drag its left edge (works on main) + 16.3 tabbed group, last tab visible: drag its left edge + 16.4 stacked group below a window, last window visible: drag its top edge + 16.5 the real-session shape: VSPLIT[A, E] | TABBED[B, C, D], second tab visible, left edge + 16.6 a tab that is itself a split: A | TABBED[C, HSPLIT[B, B2]], B's left edge + 16.7 a split inside a stacked group, dragged on its top edge: X over STACKED[W, VSPLIT[P, Q]]; + the P | Q border must not move (the split is smaller than the group by the headers) + +Run on a fresh sandbox: sandbox/launch.sh && scenarios/16_tabbed_edge_resize.py +""" +import os +import sys +import time + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib")) +import harness as h # noqa: E402 + + +def command(win_id, cmd): + h.js(f"""(() => {{ global.display.list_all_windows().find(w => w.get_id() === {win_id}) + .activate(global.get_current_time()); {h.WM}.command({cmd}); return "ok"; }})()""") + time.sleep(1.0) + + +def activate(win_id): + h.js(f"""(() => {{ global.display.list_all_windows().find(w => w.get_id() === {win_id}) + .activate(global.get_current_time()); return "ok"; }})()""") + time.sleep(0.5) + h.settle() + + +def new_window(): + known = {w["id"] for w in h.windows()} + h.open_editor() + return next(w for w in h.windows() if w["id"] not in known) + + +def group(beside, split, layout_cmd, column=False): + """A beside a group of three windows [B, C, D]: returns (A, [B, C, D]). `split`: the + direction of the monitor split ("horizontal": A | group, "vertical": A over group). + `column`: A is a column VSPLIT[A, E] instead of a single window.""" + a = new_window() + b = new_window() + h.ensure_parent_layout(a["id"], "VSPLIT" if split == "vertical" else "HSPLIT") + if column: + command(a["id"], '{name: "Split", orientation: "vertical"}') + new_window() # E joins A's column + command(b["id"], '{name: "Split", orientation: "horizontal"}') + c = new_window() + d = new_window() + command(b["id"], f'{{name: "{layout_cmd}"}}') + h.settle() + print(f"layout: {h.tree_summary()}") + return a, [b, c, d] + + +def drag_check(name, win_id, side, delta, nb_id): + ws = h.windows() + before = h.edge(h.find(ws, win_id), side) + # the space to the neighbour before the drag (stacked groups add their headers to the gap) + space = abs(h.edge(h.find(ws, nb_id), h.OPP[side]) - before) + _, grabbed, _ = h.drag_edge(win_id, side, delta) + if not grabbed: + print(f" FAIL {name}: the drag didn't start a resize") + return False + return h.settle_check(name, win_id, side, nb_id, before + delta, gap=space) + + +def done(): + h.close_windows() + time.sleep(1.0) + + +def main(): + if h.windows(): + raise SystemExit("sandbox must be fresh (no windows); run sandbox/launch.sh first") + h.set_forge_setting("auto-split-enabled", False) + r = [] + + a, tabs = group("A", "horizontal", "LayoutTabbedToggle") + activate(tabs[0]["id"]) + r.append(drag_check("16.1", a["id"], "right", -200, tabs[0]["id"])) + h.reset_layout() + r.append(drag_check("16.2", tabs[0]["id"], "left", -200, a["id"])) + h.reset_layout() + activate(tabs[2]["id"]) + r.append(drag_check("16.3", tabs[2]["id"], "left", -200, a["id"])) + done() + + a, stack = group("A", "vertical", "LayoutStackedToggle") + activate(stack[2]["id"]) + r.append(drag_check("16.4", stack[2]["id"], "top", -150, a["id"])) + done() + + a, tabs = group("A", "horizontal", "LayoutTabbedToggle", column=True) + activate(tabs[1]["id"]) + r.append(drag_check("16.5", tabs[1]["id"], "left", -200, a["id"])) + done() + + a = new_window() + c = new_window() + h.ensure_parent_layout(a["id"], "HSPLIT") + command(c["id"], '{name: "Split", orientation: "horizontal"}') + b = new_window() # HSPLIT[A, HSPLIT[C, B]] + command(b["id"], '{name: "Split", orientation: "horizontal"}') + new_window() # HSPLIT[A, HSPLIT[C, HSPLIT[B, B2]]] + command(c["id"], '{name: "LayoutTabbedToggle"}') + activate(b["id"]) + print(f"layout: {h.tree_summary()}") + r.append(drag_check("16.6", b["id"], "left", -200, a["id"])) + done() + + x = new_window() + w = new_window() + h.ensure_parent_layout(x["id"], "VSPLIT") + command(w["id"], '{name: "Split", orientation: "vertical"}') + p = new_window() # VSPLIT[X, VSPLIT[W, P]] + command(p["id"], '{name: "Split", orientation: "vertical"}') + q = new_window() # VSPLIT[X, VSPLIT[W, VSPLIT[P, Q]]] + command(w["id"], '{name: "LayoutStackedToggle"}') + activate(p["id"]) + print(f"layout: {h.tree_summary()}") + q_before = h.find(h.windows(), q["id"])["h"] + r.append(drag_check("16.7", p["id"], "top", -150, x["id"])) + q_after = h.find(h.windows(), q["id"])["h"] + ok = abs(q_after - q_before) <= h.TOL + print(f" {'PASS' if ok else 'FAIL'} 16.7 (inside): Q's height {q_before} -> {q_after} (must not change)") + r.append(ok) + sys.exit(h.summary(r)) + + +main()