From 60124a4990ed9e1373ed606de2078f584310e1ba Mon Sep 17 00:00:00 2001 From: mattchristenson Date: Sat, 26 Sep 2026 17:13:48 -0500 Subject: [PATCH] test: add an automated test harness that runs Forge in a nested GNOME Shell Forge's tests today are the manual checklist in TESTS.md. This adds tests/: automated, repeatable scenarios that run Forge in a nested, isolated GNOME Shell (gnome-shell --headless, with its own D-Bus bus, extensions directory and settings), drive it with virtual mouse and keyboard devices, and check the resulting layout exactly: every window inside the work area, no overlaps, windows at the size Forge laid them out at and at least their app's minimum size, shares that add up. The same rules are also checked while a drag or a held shortcut is still in progress. - tests/sandbox: build Forge (make build/dist only), start/stop the nested shell, run a suite, watch the test monitor live (watch.py). - tests/lib: the harness (Python), in-shell helpers for drags, held keys, the Forge tree, tracing and performance probes, and screenshots. - tests/scenarios: 01-13, reproductions of known bugs (they fail on main until the matching fixes land), a randomized stress test (09), a performance test (10) and regression checks (12). - tests/README.md: a tutorial for people and for AI coding agents. - make test-sandbox runs the whole suite. It never restarts your shell. It needs GNOME Shell 50+ and can't run on GitHub-hosted runners (no GNOME Shell 50 there), so it is meant for local runs and self-hosted CI. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + Makefile | 6 +- tests/README.md | 242 ++++++++ tests/lib/drag.js | 102 ++++ tests/lib/harness.py | 542 ++++++++++++++++++ tests/lib/holdkey.js | 87 +++ tests/lib/perf.js | 91 +++ tests/lib/shots.py | 94 +++ tests/lib/trace.js | 44 ++ tests/lib/tree.js | 27 + tests/sandbox/env.sh | 18 + tests/sandbox/launch.sh | 131 +++++ tests/sandbox/run-in-sandbox.sh | 9 + tests/sandbox/run-suite.sh | 64 +++ .../sandbox/sandbox-unsafe@local/extension.js | 12 + .../sandbox-unsafe@local/metadata.json | 6 + tests/sandbox/sb_eval.py | 17 + tests/sandbox/stop.sh | 4 + tests/sandbox/watch.py | 154 +++++ .../scenarios/01_cross_container_snapback.py | 182 ++++++ tests/scenarios/02_keyboard_resize_edge.py | 77 +++ tests/scenarios/03_resize_bounds.py | 85 +++ tests/scenarios/04_min_size_layout.py | 66 +++ tests/scenarios/06_stale_tab_bar.py | 112 ++++ tests/scenarios/07_real_apps.py | 119 ++++ tests/scenarios/08_nested_same_direction.py | 97 ++++ tests/scenarios/09_fuzz.py | 172 ++++++ tests/scenarios/10_performance.py | 140 +++++ tests/scenarios/11_slow_app.py | 59 ++ tests/scenarios/12_monitors_x11.py | 86 +++ tests/scenarios/13_overflow_policy.py | 85 +++ tests/scenarios/14_move_out_shares.py | 132 +++++ tests/scenarios/15_render_on_changes.py | 69 +++ tests/scenarios/16_tabbed_edge_resize.py | 144 +++++ 34 files changed, 3275 insertions(+), 1 deletion(-) create mode 100644 tests/README.md create mode 100644 tests/lib/drag.js create mode 100644 tests/lib/harness.py create mode 100644 tests/lib/holdkey.js create mode 100644 tests/lib/perf.js create mode 100644 tests/lib/shots.py create mode 100644 tests/lib/trace.js create mode 100644 tests/lib/tree.js create mode 100755 tests/sandbox/env.sh create mode 100755 tests/sandbox/launch.sh create mode 100755 tests/sandbox/run-in-sandbox.sh create mode 100755 tests/sandbox/run-suite.sh create mode 100644 tests/sandbox/sandbox-unsafe@local/extension.js create mode 100644 tests/sandbox/sandbox-unsafe@local/metadata.json create mode 100755 tests/sandbox/sb_eval.py create mode 100755 tests/sandbox/stop.sh create mode 100755 tests/sandbox/watch.py create mode 100755 tests/scenarios/01_cross_container_snapback.py create mode 100755 tests/scenarios/02_keyboard_resize_edge.py create mode 100755 tests/scenarios/03_resize_bounds.py create mode 100755 tests/scenarios/04_min_size_layout.py create mode 100755 tests/scenarios/06_stale_tab_bar.py create mode 100755 tests/scenarios/07_real_apps.py create mode 100755 tests/scenarios/08_nested_same_direction.py create mode 100755 tests/scenarios/09_fuzz.py create mode 100755 tests/scenarios/10_performance.py create mode 100755 tests/scenarios/11_slow_app.py create mode 100755 tests/scenarios/12_monitors_x11.py create mode 100755 tests/scenarios/13_overflow_policy.py create mode 100755 tests/scenarios/14_move_out_shares.py create mode 100755 tests/scenarios/15_render_on_changes.py create mode 100755 tests/scenarios/16_tabbed_edge_resize.py 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()