#!/usr/bin/env bash # Project sessions: the windows you open together, opened together. # # Three things here are easy to get wrong and were, each caught by running it # rather than reading it: # # * A terminal's directory is not the terminal's own working directory. That # is where the terminal was launched from; the shell inside it is what has # been cd'd. Reading the wrong one appeared correct for exactly as long as # the test terminals happened to have been started from the right place. # * gtk-launch cannot place a window. It activates over D-Bus, so the process # Hyprland started exits and a [workspace N silent] rule has no child to # apply to -- the window lands wherever you were looking. # * DBusActivatable applications do the same thing even when launched # directly, so the window has to be found and moved afterwards. # # The save/open round trip is exercised for real, against a fake HOME and a # stubbed hyprctl, so no window is opened on the machine running the tests. set -uo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" helper="$repo_dir/config/dot/quickshell/scripts/panama-project" workspaces_page="$repo_dir/config/dot/quickshell/modules/settings/WorkspacesPage.qml" service="$repo_dir/config/dot/quickshell/services/Projects.qml" findings=() note() { findings+=("$1"); } [[ -x "$helper" ]] || { printf 'projects contract: helper is missing or not executable\n' >&2; exit 1; } work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT mkdir -p "$work/bin" "$work/home" # A compositor that reports two windows on two workspaces and accepts every # dispatch, recording what it was asked to do. cat >"$work/bin/hyprctl" <<'STUB' #!/usr/bin/env bash if [[ "$1" == "-j" && "$2" == "clients" ]]; then cat "$HYPRCTL_CLIENTS" exit 0 fi if [[ "$1" == "eval" ]]; then printf '%s\n' "$2" >>"$HYPRCTL_LOG" echo ok exit 0 fi echo "{}" STUB chmod +x "$work/bin/hyprctl" cat >"$work/clients.json" <<'JSON' [ {"address":"0x1","class":"kitty","workspace":{"name":"2"},"floating":false,"pid":1}, {"address":"0x2","class":"org.gnome.Nautilus","workspace":{"name":"3"},"floating":false,"pid":2}, {"address":"0x3","class":"bitwarden","workspace":{"name":"special:scratch"},"floating":true,"pid":3} ] JSON export HYPRCTL_CLIENTS="$work/clients.json" export HYPRCTL_LOG="$work/dispatch.log" : >"$HYPRCTL_LOG" run() { PATH="$work/bin:$PATH" HOME="$work/home" XDG_DATA_HOME="$work/home/.local/share" "$helper" "$@"; } # ── Saving ─────────────────────────────────────────────────────────────────── saved="$(run save demo)" [[ "$(jq -r '.ok' <<<"$saved")" == "true" ]] || note "saving a layout failed: $saved" # Two workspaces, not three: the scratchpad is somewhere things are put aside, # not part of a layout, and a floating window is not a tiled arrangement. [[ "$(jq -r '.workspaces' <<<"$saved")" == "2" ]] \ || note "a layout with 2 tiled workspaces and a scratchpad saved $(jq -r '.workspaces' <<<"$saved") workspaces" [[ "$(jq -r '.windows' <<<"$saved")" == "2" ]] \ || note 'the scratchpad or a floating window was recorded as part of the layout' stored="$work/home/.local/share/panama/projects/demo.json" [[ -f "$stored" ]] || note 'saving produced no project file' if [[ -f "$stored" ]]; then # Positions, not numbers. A project saved from 2 and 3 must not insist on 2 # and 3, or opening it lands on top of whatever is there now. jq -e '.workspaces | type == "array"' "$stored" >/dev/null 2>&1 \ || note 'workspaces are not stored as an ordered list of positions' jq -e '[.workspaces[].windows[] | has("index")] | any' "$stored" >/dev/null 2>&1 \ && note 'a workspace number was recorded, so the project would demand specific workspaces' # gtk-launch cannot place a window; the Exec line can. jq -r '.workspaces[].windows[].exec // ""' "$stored" | grep -q 'gtk-launch' \ && note 'projects launch through gtk-launch, which activates over D-Bus and cannot be placed on a workspace' fi # ── Opening ────────────────────────────────────────────────────────────────── : >"$HYPRCTL_LOG" opened="$(run open demo)" [[ "$(jq -r '.ok' <<<"$opened")" == "true" ]] || note "opening a project failed: $opened" # Free workspaces. 2 and 3 are occupied by the stub's own windows, so a project # saved from them must be laid out somewhere else entirely. targets="$(jq -r '.workspaces | join(",")' <<<"$opened")" [[ "$targets" == "1,4" ]] \ || note "a project opened onto [$targets] when 2 and 3 were occupied; it should claim free workspaces" grep -q 'workspace 1 silent' "$HYPRCTL_LOG" \ || note 'windows are not launched with a workspace rule' grep -q 'exec_cmd' "$HYPRCTL_LOG" \ || note 'the Lua dispatch form is not used, and the plain hyprctl dispatch form is a syntax error on this config' # ── Terminal directories ───────────────────────────────────────────────────── # Exercised against a real process tree rather than grepped for. Checking that # the source mentions a shell name proves only that somebody wrote the word: an # earlier version of this contract passed against a helper that had been changed # back to reading the wrong process, because the constant was still there. # # The shape below is the shape that matters -- a parent sitting in one directory # with a shell child in another, which is exactly a terminal you have cd'd # inside. The answer must be the child's directory. mkdir -p "$work/parent" "$work/child" # exec, so the subshell is replaced by python and $! is python's own pid rather # than a shell that has already gone. ( cd "$work/parent" && exec python3 -c " import subprocess, time subprocess.Popen(['bash', '-c', 'sleep 30; :'], cwd='$work/child') time.sleep(30) " ) >/dev/null 2>&1 & parent_pid=$! sleep 2 if [[ -n "$parent_pid" ]]; then answer="$(PATH="$work/bin:$PATH" HOME="$work/home" python3 - "$helper" "$parent_pid" <<'PROBE' import importlib.machinery, importlib.util, sys spec = importlib.util.spec_from_loader("pp", importlib.machinery.SourceFileLoader("pp", sys.argv[1])) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) print(module.working_directory(int(sys.argv[2])) or "") PROBE )" [[ "$answer" == "$work/child" ]] \ || note "a terminal's directory is read as '$answer' rather than the shell's '$work/child' -- the terminal own working directory is where it was launched from, not where you have changed to" kill "$parent_pid" 2>/dev/null || true fi # -d, not --directory with a space, which kitty accepts and silently ignores. grep -qE 'TERMINAL_DIRECTORY_FLAG = "-d"' "$helper" \ || note 'the terminal directory flag is not -d; the space-separated long form is ignored without an error' # ── Placement has to be verified, not assumed ──────────────────────────────── grep -q 'hl.dsp.window.move' "$helper" \ || note 'a window that lands on the wrong workspace is never moved, so DBusActivatable applications stay where they appeared' # ── The page ───────────────────────────────────────────────────────────────── grep -q 'Projects.projects' "$workspaces_page" \ || note 'the Workspaces page does not list saved projects' grep -q 'confirmingProject' "$workspaces_page" \ || note 'a project can be deleted without confirming' grep -q '"list"' "$service" \ || note 'the settings service never reads what is saved' if (( ${#findings[@]} > 0 )); then mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u) printf 'projects contract: %d finding(s)\n' "${#findings[@]}" >&2 printf ' - %s\n' "${findings[@]}" >&2 exit 1 fi printf 'projects contract: PASS\n'