test(gtk): live D-Bus/portal comparison against the original Ghostty

Adds scripts/live-dbus-test.sh (`just live-dbus`): each scenario runs
the installed Ghostty 1.3.1 and the Rust build on a private
dbus-run-session bus inside a private headless weston (the user's
session bus is never written to), with Python/Gio mocks for
org.freedesktop.Notifications, the GlobalShortcuts/Settings portal and
org.freedesktop.systemd1, plus a real xdg-desktop-portal run. Scenarios:
single-instance (+detect), +new-window with arguments, notifications,
global shortcuts, color-scheme settings, cgroup scope creation, URL
opening. tests/live_dbus.rs compares the Rust build against goldens
recorded from the original (skips when tools or a fresh production
build are missing).

Two Rust bugs found and fixed (no upstream deviation):
- GlobalShortcuts CreateSession/BindShortcuts payloads used the wrong
  D-Bus signatures ((v), double-boxed values), which a real portal
  rejects; signature tests added.
- CreateSession was sent twice at startup; the config is now bound only
  after the D-Bus connection is set, as in Zig.

All nine scenarios now behave identically to the original
(docs/rewrite/notes/live-dbus.md).

Test Plan:
- just live-dbus
- cargo test --test live_dbus (needs target/vp/production/ghostty)
This commit is contained in:
ddidderr committed 2026-10-12 18:49:53 +02:00
1 parent e44b9271d6
commit 30863e1f0b
25 files changed
+1278 -31

No files matched your search

+275
View File
@@ -0,0 +1,275 @@
# Scenario bodies, sourced by scenario.sh.
scn_single_instance() {
cfg "gtk-single-instance = true"
start_bus_tools
# (A launch with -e is never single-instance: see the "-e launch" step.)
bg env GHOSTTY_DEBUG_INPUT_SCRIPT="$LD/replay-windows.txt" "$BIN" >"$D/app1.log" 2>&1
wait_name com.mitchellh.ghostty; fact "first instance owns name: $?"
settle 4; fact "step first: windows=$(windows) instances=$(instances)"
run_ipc newwin +new-window; settle
fact "step +new-window: windows=$(windows) instances=$(instances)"
run_ipc newwin-e +new-window -e sleep 601; settle
fact "step +new-window -e: windows=$(windows) instances=$(instances)"
run_ipc newwin-wd +new-window --working-directory=/usr; settle
fact "step +new-window --working-directory: windows=$(windows) instances=$(instances)"
run_ipc newwin-e-wd +new-window -e sleep 602 --working-directory=/usr; settle
fact "step +new-window -e..--working-directory: windows=$(windows) instances=$(instances)"
run_ipc newwin-class +new-window --class=com.example.nobody; settle 1
fact "step +new-window --class=nobody: windows=$(windows) instances=$(instances)"
# A second GUI launch is forwarded to the first instance and exits.
timeout 20 "$BIN" >"$D/second.log" 2>&1; fact "second launch rc=$?"
settle; fact "step second launch: windows=$(windows) instances=$(instances)"
children | sed 's/^/child: /' >>"$FACTS"
# A launch with -e is not single-instance: it runs on its own.
bg timeout 8 "$BIN" -e sleep 603 >"$D/dash-e.log" 2>&1
settle 5; fact "step -e launch: windows=$(windows) instances=$(instances)"
settle 5; fact "step -e launch ended: windows=$(windows) instances=$(instances)"
# Custom class: a separate instance on its own bus name / object path.
bg "$BIN" --class=com.example.custom-app >"$D/app2.log" 2>&1
wait_name com.example.custom-app; fact "custom class owns name: $?"
settle 4; fact "step custom: windows=$(windows com.example.custom-app) instances=$(instances)"
run_ipc newwin-custom +new-window --class=com.example.custom-app; settle
fact "step +new-window --class=custom: custom windows=$(windows com.example.custom-app) default windows=$(windows)"
run_ipc newwin-custom-e +new-window --class=com.example.custom-app -e sleep 605; settle
fact "step +new-window --class=custom -e: custom windows=$(windows com.example.custom-app) default windows=$(windows)"
run_ipc newwin-badclass +new-window --class=1bad; fact "badclass done"
busctl --user list --acquired --no-pager | awk '$1 ~ /^(com\.mitchellh|com\.example)/ {print "bus name: "$1}' >>"$FACTS"
gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty \
--method org.gtk.Actions.List 2>&1 | tr -d "'" >"$D/actions.txt"; fact "actions: $(cat "$D/actions.txt")"
gdbus call --session --dest com.example.custom-app --object-path /com/example/custom_app \
--method org.gtk.Actions.List 2>&1 | tr -d "'" >"$D/actions2.txt"; fact "custom actions: $(cat "$D/actions2.txt")"
}
scn_single_instance_detect() {
cfg "gtk-single-instance = detect"
start_bus_tools
bg "$BIN" >"$D/app1.log" 2>&1
wait_name com.mitchellh.ghostty; fact "plain launch owns name: $?"
settle 4; fact "step plain: windows=$(windows) instances=$(instances)"
timeout 20 "$BIN" >"$D/second.log" 2>&1; fact "second plain launch rc=$?"
settle; fact "step second plain: windows=$(windows) instances=$(instances)"
# TERM_PROGRAM set: a dedicated instance (false) that does not use the bus name.
bg env TERM_PROGRAM=x timeout 12 "$BIN" >"$D/termprog.log" 2>&1
settle 6; fact "step TERM_PROGRAM: windows=$(windows) instances=$(instances)"
settle 7; fact "step TERM_PROGRAM ended: windows=$(windows) instances=$(instances)"
# Any CLI argument: dedicated instance.
bg timeout 12 "$BIN" --title=cli >"$D/cliarg.log" 2>&1
settle 6; fact "step CLI arg: windows=$(windows) instances=$(instances)"
settle 7; fact "step CLI arg ended: windows=$(windows) instances=$(instances)"
run_ipc newwin +new-window; settle
fact "step +new-window: windows=$(windows) instances=$(instances)"
}
scn_notifications() {
export FLAG2=$W/go2
rm -f "$FLAG2"
cfg "desktop-notifications = true" "notify-on-command-finished = always" "notify-on-command-finished-after = 1s" \
"command = sh $LD/child-notify.sh"
start_bus_tools
bg "$BIN" >"$D/app.log" 2>&1
settle 16
fact "notifications so far: $(grep -c "\"method\": \"Notify\"" "$MOCK_LOG" 2>/dev/null || true)"
# Activate the first notifications (default action): must not crash.
mockctl Invoke 1 default; settle 2
fact "alive after ActionInvoked: instances=$(instances) windows=$(windows)"
mockctl Invoke 2 default; mockctl Invoke 999 default; settle 1
fact "alive after more ActionInvoked: instances=$(instances) windows=$(windows)"
cfg "desktop-notifications = false" "command = sh $LD/child-notify.sh"
gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty \
--method org.gtk.Actions.Activate reload-config '[]' '{}' >/dev/null; settle 2
touch "$FLAG2"; settle 3
fact "notifications after desktop-notifications=false: $(grep -c "\"method\": \"Notify\"" "$MOCK_LOG" 2>/dev/null || true)"
}
scn_shortcuts() {
cfg "keybind = global:ctrl+alt+x=new_window" "keybind = global:super+shift+b=new_tab" \
"keybind = global:ctrl+shift+f12=reload_config" "keybind = global:alt+space>q=quit" \
"keybind = global:ctrl+alt+q=text:hi" "keybind = ctrl+alt+z=new_window"
start_bus_tools
bg "$BIN" >"$D/app.log" 2>&1
wait_name com.mitchellh.ghostty; settle 5
fact "step start: windows=$(windows)"
id=$(python3 - "$MOCK_LOG" <<'PY'
import json, sys
for l in open(sys.argv[1]):
j = json.loads(l)
if j["method"] == "BindShortcuts":
for sid, opts in j["args"][1]:
if opts.get("description", "").startswith("new_window"):
print(sid)
PY
)
fact "new_window shortcut id: $id"
mockctl Activate "$id"; settle 3
fact "step Activated(new_window): windows=$(windows)"
mockctl Activate nonexistent; settle 1
fact "step Activated(unknown): windows=$(windows)"
# Reload with the keybinds removed: the old session is closed.
cfg "keybind = ctrl+alt+z=new_window"
gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty \
--method org.gtk.Actions.Activate reload-config '[]' '{}' >/dev/null; settle 3
fact "step reload without global: windows=$(windows)"
cfg "keybind = global:ctrl+alt+x=new_window"
gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty \
--method org.gtk.Actions.Activate reload-config '[]' '{}' >/dev/null; settle 3
fact "step reload with global again: windows=$(windows)"
}
scn_settings() {
mkdir -p "$W/themes"
printf 'background = ffeedd\nforeground = 111111\n' >"$W/themes/light"
printf 'background = 112233\nforeground = eeeeee\n' >"$W/themes/dark"
cfg "theme = light:$W/themes/light,dark:$W/themes/dark" "window-theme = auto" "command = sh $LD/child-theme.sh"
MOCK_COLOR_SCHEME=1 start_bus_tools
export OUTF=$D/tty.txt
: >"$OUTF"
bg "$BIN" >"$D/app.log" 2>&1
for _ in $(seq 600); do [ -s "$OUTF" ] && break; sleep 0.1; done
[ -s "$OUTF" ] || fact "ERROR: terminal child produced no output"
settle 3
for scheme in 2 1 0 2; do
printf "\n--- after portal color-scheme=$scheme\n" >>"$OUTF"
mockctl SetColorScheme $scheme; settle 2
done
printf "\n--- after reload window-theme=dark\n" >>"$OUTF"
cfg "theme = light:$W/themes/light,dark:$W/themes/dark" "window-theme = dark" "command = sh $LD/child-theme.sh"
gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty \
--method org.gtk.Actions.Activate reload-config '[]' '{}' >/dev/null; settle 2
printf "\n--- after portal color-scheme=2 with window-theme=dark\n" >>"$OUTF"
mockctl SetColorScheme 2; settle 2
python3 "$LD/summarize-tty.py" "$OUTF" | sed 's/^/tty: /' >>"$FACTS"
}
scn_cgroup() {
start_bus_tools
phase() { # name, config lines...
local name=$1; shift
: >"$MOCK_LOG"
cfg "$@"
bg "$BIN" -e sleep 30 >"$D/app-$name.log" 2>&1
local pid=$!
# wait for the terminal child (the scope is created right before it is exec'd)
for _ in $(seq 250); do pgrep -P $pid -x sleep >/dev/null && break; sleep 0.1; done
settle 3
fact "phase $name:"
grep StartTransientUnit "$MOCK_LOG" | sed 's/^/ call: /' >>"$FACTS"
kill $pid 2>/dev/null
for _ in $(seq 100); do kill -0 $pid 2>/dev/null || break; sleep 0.1; done
kill -9 $pid 2>/dev/null; kill $(tagged sleep) 2>/dev/null; settle 1
}
phase default "gtk-single-instance = false"
phase always "gtk-single-instance = false" "linux-cgroup = always"
phase always-limits "gtk-single-instance = false" "linux-cgroup = always" "linux-cgroup-memory-limit = 123456789" "linux-cgroup-processes-limit = 77"
phase never "gtk-single-instance = false" "linux-cgroup = never"
mockctl SetFail true
phase always-failing "gtk-single-instance = false" "linux-cgroup = always"
fact "cgroup done"
}
scn_openuri() {
# Ghostty 1.3.1 does not use the OpenURI portal (upstream TODO): links and
# the config file are opened by spawning `xdg-open`. A fake xdg-open on
# PATH records the invocations; the mock records any portal OpenURI call.
mkdir -p "$W/bin"
cat >"$W/bin/xdg-open" <<XDG
#!/bin/sh
printf 'xdg-open argc=%s' "\$#" >>"$D/xdg-open.log"
for a in "\$@"; do printf ' [%s]' "\$a" >>"$D/xdg-open.log"; done
printf ' cwd=%s\n' "\$(pwd)" >>"$D/xdg-open.log"
XDG
chmod +x "$W/bin/xdg-open"
export PATH=$W/bin:$PATH GTK_USE_PORTAL=1
: >"$D/xdg-open.log"
cfg "gtk-single-instance = true"
start_bus_tools
bg "$BIN" >"$D/app.log" 2>&1
wait_name com.mitchellh.ghostty; settle 5
gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty \
--method org.gtk.Actions.Activate open-config '[]' '{}' >/dev/null; settle 3
sed "s|$W|\$RUN|g" "$D/xdg-open.log" | sed 's/^/spawn: /' >>"$FACTS"
fact "portal OpenURI/OpenFile calls: $(grep -c 'portal.OpenURI' "$MOCK_LOG")"
}
scn_openurl_click() {
# The original has no input replay and weston's headless pointer is not
# reachable through Xwayland, so this scenario only runs for the Rust
# build; its result is compared against golden/openurl-click.txt, which is
# derived from the Zig source (internal_os/open.zig spawns `xdg-open <url>`).
[ "${LDB_LABEL:-}" = rust ] || return
mkdir -p "$W/bin"
cat >"$W/bin/xdg-open" <<XDG
#!/bin/sh
printf 'xdg-open argc=%s' "\$#" >>"$D/xdg-open.log"
for a in "\$@"; do printf ' [%s]' "\$a" >>"$D/xdg-open.log"; done
printf '\n' >>"$D/xdg-open.log"
XDG
chmod +x "$W/bin/xdg-open"
export PATH=$W/bin:$PATH GDK_BACKEND=x11 OUTF=$D/tty.txt
: >"$D/xdg-open.log"; : >"$OUTF"
cfg "gtk-single-instance = false" "gtk-titlebar = false" "window-padding-x = 0" "window-padding-y = 0" \
"font-size = 14" "window-decoration = none" "command = sh $LD/child-links.sh" "window-width = 60" "window-height = 12"
start_bus_tools
# Launch 1 measures the cell size.
bg "$BIN" >"$D/app-measure.log" 2>&1
local pid=$!
settle 7
rows=$(grep -o '\^\[\[8;[0-9]*;[0-9]*t' "$OUTF" | head -1 | cut -d';' -f2)
cols=$(grep -o '\^\[\[8;[0-9]*;[0-9]*t' "$OUTF" | head -1 | cut -d';' -f3 | tr -d t)
wid=$(xdotool search --onlyvisible --class ghostty 2>/dev/null | head -1)
eval "$(xdotool getwindowgeometry --shell "$wid" 2>/dev/null)"
kill $pid; settle 1
[ -n "${WIDTH:-}" ] && [ -n "${rows:-}" ] || { fact "no geometry"; return; }
cw=$((WIDTH / cols)); ch=$((HEIGHT / rows))
{
for row in 0 1 2 3; do
echo "mouse-move $((cw * 3)) $((row * ch + ch / 2)) ctrl"
echo "wait 600"
echo "click 1 ctrl"
echo "wait 1500"
done
echo "wait 500"
} >"$W/click.txt"
: >"$D/xdg-open.log"; : >"$OUTF"
bg env GHOSTTY_DEBUG_INPUT_SCRIPT="$W/click.txt" "$BIN" >"$D/app.log" 2>&1
settle 16
sed "s|$W|\$RUN|g" "$D/xdg-open.log" | sed 's/^/spawn: /' >>"$FACTS"
}
# The real xdg-desktop-portal frontend + the gtk backend (gsettings keyfile
# backend, so we can change color-scheme live) on the private bus.
scn_real_portal() {
mkdir -p "$W/cfg/xdg-desktop-portal" "$W/cfg/glib-2.0/settings" "$W/themes"
printf '[preferred]\ndefault=gtk\n' >"$W/cfg/xdg-desktop-portal/portals.conf"
portal_env="/usr/bin/env XDG_CURRENT_DESKTOP=GNOME GSETTINGS_BACKEND=keyfile XDG_CONFIG_HOME=$W/cfg HOME=$W/home XDG_DATA_HOME=$W/data XDG_CACHE_HOME=$W/cache WAYLAND_DISPLAY=$WAYLAND_DISPLAY"
svc() { printf '[D-BUS Service]\nName=%s\nExec=%s\n' "$1" "$2" >"$LDB_RUN/services/$1.service"; }
svc org.freedesktop.portal.Desktop "$portal_env /usr/lib/xdg-desktop-portal"
svc org.freedesktop.impl.portal.desktop.gtk "$portal_env /usr/lib/xdg-desktop-portal-gtk"
svc org.freedesktop.impl.portal.PermissionStore "$portal_env /usr/lib/xdg-permission-store"
printf 'background = ffeedd\nforeground = 111111\n' >"$W/themes/light"
printf 'background = 112233\nforeground = eeeeee\n' >"$W/themes/dark"
keyfile=$W/cfg/glib-2.0/settings/keyfile
setscheme() { printf '[org/gnome/desktop/interface]\ncolor-scheme='"'"'%s'"'"'\n' "$1" >"$keyfile"; }
setscheme prefer-dark
cfg "theme = light:$W/themes/light,dark:$W/themes/dark" "window-theme = auto" "command = sh $LD/child-theme.sh" \
"keybind = global:ctrl+alt+x=new_window"
start_bus_tools notifications,systemd
# Start the portal first so that its backends are up.
gdbus call --session --dest org.freedesktop.portal.Desktop --object-path /org/freedesktop/portal/desktop \
--method org.freedesktop.DBus.Properties.Get org.freedesktop.portal.Settings version >"$D/portal-version.txt" 2>&1
fact "portal frontend: $(tr -d '\n' <"$D/portal-version.txt" | cut -c1-80)"
settle 2
export OUTF=$D/tty.txt; : >"$OUTF"
bg env GSETTINGS_BACKEND=memory "$BIN" >"$D/app.log" 2>&1
for _ in $(seq 600); do [ -s "$OUTF" ] && break; sleep 0.1; done
[ -s "$OUTF" ] || fact "ERROR: terminal child produced no output"
settle 3
for scheme in prefer-light prefer-dark default prefer-dark; do
printf "\n--- after gsettings color-scheme=$scheme\n" >>"$OUTF"
setscheme $scheme; settle 3
done
python3 "$LD/summarize-tty.py" "$OUTF" | sed 's/^/tty: /' >>"$FACTS"
# Only the D-Bus error names (the log prefixes differ between the builds).
sed -n 's/.*request failed=\(GDBus.Error:[A-Za-z.]*\).*/\1/p' "$D/app.log" | sed 's/^/global-shortcuts request error: /' >>"$FACTS"
fact "alive: instances=$(instances)"
}