Files
aaddrick--claude-desktop-de…/tests/launcher-common.bats
T
wehub-resource-sync 3e076d4dd9
Deploy Worker / deploy (push) Failing after 1s
Shellcheck / Check shell scripts (push) Failing after 1s
CI / Build Packages (amd64 - appimage) (push) Has been skipped
CI / Build Packages (amd64 - deb) (push) Has been skipped
CI / Build Packages (amd64 - rpm) (push) Has been skipped
CI / Build Packages (arm64 - appimage) (push) Has been skipped
CI / Build Packages (arm64 - deb) (push) Has been skipped
CI / Test Flags Parsing (push) Failing after 1s
BATS Tests / BATS unit tests (push) Failing after 1s
CI / Build Packages (arm64 - rpm) (push) Has been skipped
CI / Test Build Artifacts (amd64) (push) Has been skipped
CI / Test Build Artifacts (arm64) (push) Has been skipped
CI / Update APT Repository (push) Has been cancelled
CI / Mirror Official .deb to Release (push) Has been cancelled
CI / Update DNF Repository (push) Has been cancelled
CI / Update AUR Package (push) Has been cancelled
CI / Create Release (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:29:21 +08:00

1330 lines
43 KiB
Bash

#!/usr/bin/env bats
#
# launcher-common.bats
# Tests for launcher utility functions in scripts/launcher-common.sh
#
SCRIPT_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME}")" && pwd)"
# Check whether a value exists in the electron_args array.
# Supports glob patterns (e.g., '*WaylandWindowDecorations*').
has_electron_arg() {
local pattern="$1"
local arg
for arg in "${electron_args[@]}"; do
# shellcheck disable=SC2254
[[ $arg == $pattern ]] && return 0
done
return 1
}
# Count how many electron_args entries start with --enable-features=.
# Chromium honours only the last such switch, so the launcher must emit
# exactly one; this lets tests assert that invariant.
count_enable_features() {
local n=0 arg
for arg in "${electron_args[@]}"; do
[[ $arg == --enable-features=* ]] && ((n++))
done
echo "$n"
}
setup() {
TEST_TMP=$(mktemp -d)
export TEST_TMP
# Redirect all filesystem-touching functions to temp dirs
export HOME="$TEST_TMP/home"
export XDG_CACHE_HOME="$TEST_TMP/cache"
export XDG_CONFIG_HOME="$TEST_TMP/config"
export XDG_RUNTIME_DIR="$TEST_TMP/run"
mkdir -p "$HOME" "$XDG_CACHE_HOME" "$XDG_CONFIG_HOME" "$XDG_RUNTIME_DIR"
# Clear display/wayland variables to avoid leaking host state
unset DISPLAY
unset WAYLAND_DISPLAY
unset CLAUDE_USE_WAYLAND
unset NIRI_SOCKET
unset XDG_CURRENT_DESKTOP
unset XDG_SESSION_TYPE
unset CLAUDE_MENU_BAR
unset CLAUDE_TITLEBAR_STYLE
unset COWORK_VM_BACKEND
unset ELECTRON_USE_SYSTEM_TITLE_BAR
unset GTK_IM_MODULE
unset XMODIFIERS
unset QT_IM_MODULE
unset CLAUDE_GTK_IM_MODULE
unset CLAUDE_PASSWORD_STORE
# Copy to temp dir so we can substitute the build-time placeholder
# and co-locate doctor.sh (sourced via BASH_SOURCE dirname).
cp "$SCRIPT_DIR/../scripts/launcher-common.sh" "$TEST_TMP/launcher-common.sh"
cp "$SCRIPT_DIR/../scripts/doctor.sh" "$TEST_TMP/doctor.sh"
sed -i 's/@@WM_CLASS@@/Claude/' "$TEST_TMP/launcher-common.sh"
# shellcheck source=scripts/launcher-common.sh
source "$TEST_TMP/launcher-common.sh"
}
teardown() {
if [[ -n "$TEST_TMP" && -d "$TEST_TMP" ]]; then
rm -rf "$TEST_TMP"
fi
}
# =============================================================================
# setup_logging
# =============================================================================
@test "setup_logging: creates log directory and sets log_file" {
run setup_logging
[[ $status -eq 0 ]]
[[ -d "$XDG_CACHE_HOME/claude-desktop-debian" ]]
}
@test "setup_logging: sets log_file under XDG_CACHE_HOME" {
setup_logging
[[ $log_file == "$XDG_CACHE_HOME/claude-desktop-debian/launcher.log" ]]
}
@test "setup_logging: falls back to HOME/.cache when XDG_CACHE_HOME unset" {
unset XDG_CACHE_HOME
setup_logging
[[ $log_dir == "$HOME/.cache/claude-desktop-debian" ]]
[[ -d "$HOME/.cache/claude-desktop-debian" ]]
}
# =============================================================================
# rotate_log_file / setup_logging rotation (#747)
# =============================================================================
@test "rotation: log under cap is left untouched" {
setup_logging
printf 'small log content\n' > "$log_file"
rotate_log_file
[[ -f $log_file ]]
[[ $(cat "$log_file") == 'small log content' ]]
[[ ! -f "$log_file.1" ]]
}
@test "rotation: log over cap moves to .1 and clears live file" {
setup_logging
printf 'LIVE' > "$log_file"
truncate -s 6M "$log_file"
rotate_log_file
[[ ! -f $log_file ]]
[[ -f "$log_file.1" ]]
[[ $(head -c 4 "$log_file.1") == 'LIVE' ]]
}
@test "rotation: keeps at most 2 old copies, drops oldest" {
setup_logging
printf 'OLD1' > "$log_file.1"
printf 'OLD2' > "$log_file.2"
printf 'LIVE' > "$log_file"
truncate -s 6M "$log_file"
rotate_log_file
[[ $(head -c 4 "$log_file.1") == 'LIVE' ]]
[[ $(head -c 4 "$log_file.2") == 'OLD1' ]]
[[ ! -f "$log_file.3" ]]
}
@test "rotation: missing log file is a no-op returning 0" {
setup_logging
rm -f "$log_file"
run rotate_log_file
[[ $status -eq 0 ]]
[[ ! -f "$log_file.1" ]]
}
@test "setup_logging: still returns 0 after rotating an over-cap file" {
log_dir="$XDG_CACHE_HOME/claude-desktop-debian"
mkdir -p "$log_dir"
log_file="$log_dir/launcher.log"
truncate -s 6M "$log_file"
run setup_logging
[[ $status -eq 0 ]]
[[ -f "$log_file.1" ]]
}
# =============================================================================
# log_message
# =============================================================================
@test "log_message: appends message to log file" {
setup_logging
log_message "test message one"
log_message "test message two"
[[ -f $log_file ]]
run cat "$log_file"
[[ "${lines[0]}" == "test message one" ]]
[[ "${lines[1]}" == "test message two" ]]
}
@test "log_message: redacts OAuth code from claude://login argv (LOG-1)" {
setup_logging
# Both the "Arguments:" and "Executing:" lines carry $* verbatim.
log_message "Arguments: claude://login/google-auth?code=SECRET123&state=xyz"
log_message "Executing: /usr/lib/claude-desktop/claude-desktop --class=Claude claude://login/google-auth?code=SECRET456"
run cat "$log_file"
[[ "$output" != *SECRET123* ]]
[[ "$output" != *SECRET456* ]]
[[ "$output" != *'code='* ]]
# Path is kept for context; only the query string is stripped.
[[ "${lines[0]}" == 'Arguments: claude://login/google-auth?<redacted>' ]]
[[ "${lines[1]}" == *'--class=Claude claude://login/google-auth?<redacted>' ]]
}
@test "log_message: leaves non-login messages untouched" {
setup_logging
log_message 'Executing: /usr/lib/claude-desktop/claude-desktop --class=Claude'
run cat "$log_file"
[[ "${lines[0]}" == 'Executing: /usr/lib/claude-desktop/claude-desktop --class=Claude' ]]
}
# =============================================================================
# log_session_env
# =============================================================================
@test "log_session_env: emits env={ ... } block with all required keys" {
setup_logging
XDG_SESSION_TYPE='wayland'
WAYLAND_DISPLAY='wayland-0'
DISPLAY=':0'
XDG_CURRENT_DESKTOP='KDE'
GTK_IM_MODULE='ibus'
XMODIFIERS='@im=ibus'
QT_IM_MODULE='ibus'
CLAUDE_USE_WAYLAND='1'
CLAUDE_PASSWORD_STORE='basic'
CLAUDE_GTK_IM_MODULE='xim'
CLAUDE_DISABLE_GPU='1'
log_session_env
run cat "$log_file"
# Exact-line match locks block structure (open/close braces on
# their own lines) and per-key formatting in one pass.
# CLAUDE_TITLEBAR_STYLE is no longer honored (v3.0.0 rebase) and was
# dropped from the key list.
[[ "${lines[0]}" == 'env={' ]]
[[ "${lines[1]}" == ' XDG_SESSION_TYPE=wayland' ]]
[[ "${lines[2]}" == ' WAYLAND_DISPLAY=wayland-0' ]]
[[ "${lines[3]}" == ' DISPLAY=:0' ]]
[[ "${lines[4]}" == ' XDG_CURRENT_DESKTOP=KDE' ]]
[[ "${lines[5]}" == ' GTK_IM_MODULE=ibus' ]]
[[ "${lines[6]}" == ' XMODIFIERS=@im=ibus' ]]
[[ "${lines[7]}" == ' QT_IM_MODULE=ibus' ]]
[[ "${lines[8]}" == ' CLAUDE_USE_WAYLAND=1' ]]
[[ "${lines[9]}" == ' CLAUDE_PASSWORD_STORE=basic' ]]
[[ "${lines[10]}" == ' CLAUDE_GTK_IM_MODULE=xim' ]]
[[ "${lines[11]}" == ' CLAUDE_DISABLE_GPU=1' ]]
[[ "${lines[12]}" == '}' ]]
}
@test "log_session_env: unset/empty values render as 'KEY=' (no value)" {
setup_logging
# All vars unset by setup() except this one, which exercises the
# empty-string branch (must be indistinguishable from unset).
GTK_IM_MODULE=''
unset CLAUDE_PASSWORD_STORE
log_session_env
run cat "$log_file"
# Exact-line match proves the line ends right after '=' — a
# substring like *'KEY='* would also match 'KEY=value'.
[[ "${lines[1]}" == ' XDG_SESSION_TYPE=' ]]
[[ "${lines[2]}" == ' WAYLAND_DISPLAY=' ]]
[[ "${lines[3]}" == ' DISPLAY=' ]]
[[ "${lines[4]}" == ' XDG_CURRENT_DESKTOP=' ]]
[[ "${lines[5]}" == ' GTK_IM_MODULE=' ]]
[[ "${lines[6]}" == ' XMODIFIERS=' ]]
[[ "${lines[7]}" == ' QT_IM_MODULE=' ]]
[[ "${lines[8]}" == ' CLAUDE_USE_WAYLAND=' ]]
[[ "${lines[9]}" == ' CLAUDE_PASSWORD_STORE=' ]]
[[ "${lines[10]}" == ' CLAUDE_GTK_IM_MODULE=' ]]
[[ "${lines[11]}" == ' CLAUDE_DISABLE_GPU=' ]]
}
# =============================================================================
# check_display
# =============================================================================
@test "check_display: fails when no display variables set" {
unset DISPLAY
unset WAYLAND_DISPLAY
run check_display
[[ $status -ne 0 ]]
}
@test "check_display: succeeds with DISPLAY set" {
DISPLAY=":0"
run check_display
[[ $status -eq 0 ]]
}
@test "check_display: succeeds with WAYLAND_DISPLAY set" {
WAYLAND_DISPLAY="wayland-0"
run check_display
[[ $status -eq 0 ]]
}
@test "check_display: succeeds with both set" {
DISPLAY=":0"
WAYLAND_DISPLAY="wayland-0"
run check_display
[[ $status -eq 0 ]]
}
# =============================================================================
# detect_display_backend
# =============================================================================
@test "detect_display_backend: X11 session sets is_wayland=false" {
DISPLAY=":0"
setup_logging
detect_display_backend
[[ $is_wayland == false ]]
}
@test "detect_display_backend: Wayland session sets is_wayland=true" {
WAYLAND_DISPLAY="wayland-0"
setup_logging
detect_display_backend
[[ $is_wayland == true ]]
}
@test "detect_display_backend: defaults to XWayland on Wayland" {
WAYLAND_DISPLAY="wayland-0"
setup_logging
detect_display_backend
[[ $is_wayland == true ]]
[[ $use_x11_on_wayland == true ]]
}
@test "detect_display_backend: CLAUDE_USE_WAYLAND=1 forces native Wayland" {
WAYLAND_DISPLAY="wayland-0"
CLAUDE_USE_WAYLAND=1
setup_logging
detect_display_backend
[[ $is_wayland == true ]]
[[ $use_x11_on_wayland == false ]]
}
@test "detect_display_backend: Niri detected via NIRI_SOCKET forces native Wayland" {
WAYLAND_DISPLAY="wayland-0"
NIRI_SOCKET="/tmp/niri.sock"
setup_logging
detect_display_backend
[[ $use_x11_on_wayland == false ]]
}
@test "detect_display_backend: Niri detected via XDG_CURRENT_DESKTOP forces native Wayland" {
WAYLAND_DISPLAY="wayland-0"
XDG_CURRENT_DESKTOP="niri"
setup_logging
detect_display_backend
[[ $use_x11_on_wayland == false ]]
}
@test "detect_display_backend: Niri in colon-separated XDG_CURRENT_DESKTOP" {
WAYLAND_DISPLAY="wayland-0"
XDG_CURRENT_DESKTOP="niri:GNOME"
setup_logging
detect_display_backend
[[ $use_x11_on_wayland == false ]]
}
@test "detect_display_backend: Niri case-insensitive detection" {
WAYLAND_DISPLAY="wayland-0"
XDG_CURRENT_DESKTOP="NIRI"
setup_logging
detect_display_backend
[[ $use_x11_on_wayland == false ]]
}
@test "detect_display_backend: non-Niri non-GNOME Wayland keeps XWayland default" {
WAYLAND_DISPLAY="wayland-0"
XDG_CURRENT_DESKTOP="sway"
setup_logging
detect_display_backend
[[ $use_x11_on_wayland == true ]]
}
@test "detect_display_backend: Niri not forced when CLAUDE_USE_WAYLAND already set" {
# CLAUDE_USE_WAYLAND=1 already forces native, Niri detection shouldn't conflict
WAYLAND_DISPLAY="wayland-0"
CLAUDE_USE_WAYLAND=1
NIRI_SOCKET="/tmp/niri.sock"
setup_logging
detect_display_backend
[[ $use_x11_on_wayland == false ]]
}
@test "detect_display_backend: GNOME Wayland keeps XWayland default (not auto-flipped)" {
# GNOME native+portal is opt-in only; the default session stays on
# mature XWayland to avoid rendering/IME regressions (#404 portal
# route is opt-in via CLAUDE_USE_WAYLAND=1).
WAYLAND_DISPLAY="wayland-0"
XDG_CURRENT_DESKTOP="GNOME"
setup_logging
detect_display_backend
[[ $is_wayland == true ]]
[[ $use_x11_on_wayland == true ]]
}
@test "detect_display_backend: GNOME Wayland + CLAUDE_USE_WAYLAND=1 opts into native" {
WAYLAND_DISPLAY="wayland-0"
XDG_CURRENT_DESKTOP="ubuntu:GNOME"
CLAUDE_USE_WAYLAND=1
setup_logging
detect_display_backend
[[ $use_x11_on_wayland == false ]]
}
@test "detect_display_backend: GNOME on X11 (not Wayland) stays X11" {
DISPLAY=":0"
XDG_CURRENT_DESKTOP="GNOME"
setup_logging
detect_display_backend
[[ $is_wayland == false ]]
# use_x11_on_wayland is the default true; the auto-detect block is
# guarded by is_wayland so it never flips it on an X11 session.
[[ $use_x11_on_wayland == true ]]
}
@test "detect_display_backend: CLAUDE_USE_WAYLAND=0 forces XWayland on GNOME" {
WAYLAND_DISPLAY="wayland-0"
XDG_CURRENT_DESKTOP="GNOME"
CLAUDE_USE_WAYLAND=0
setup_logging
detect_display_backend
[[ $is_wayland == true ]]
[[ $use_x11_on_wayland == true ]]
}
@test "detect_display_backend: CLAUDE_USE_WAYLAND=0 forces XWayland on Niri" {
WAYLAND_DISPLAY="wayland-0"
NIRI_SOCKET="/tmp/niri.sock"
CLAUDE_USE_WAYLAND=0
setup_logging
detect_display_backend
[[ $use_x11_on_wayland == true ]]
}
# =============================================================================
# build_electron_args
# =============================================================================
@test "build_electron_args: includes --class matching upstream productName" {
is_wayland=false
setup_logging
build_electron_args deb
has_electron_arg '--class=Claude'
}
@test "build_electron_args: X11 deb defaults to a minimal argv (opt-in policy)" {
# The launcher is opt-in only: on a plain X11 deb session with no env
# overrides it must pass ONLY --class — nothing that shadows an
# official upstream code path (no titlebar/feature/password-store
# flag). This is the policy regression test; the switch-list smoke
# (tools/chromium-switch-smoke.sh) guards the same invariant in CI.
is_wayland=false
setup_logging
build_electron_args deb
[[ ${#electron_args[@]} -eq 1 ]]
[[ ${electron_args[0]} == '--class=Claude' ]]
}
@test "build_electron_args: CLAUDE_PASSWORD_STORE set - passes flag + logs it" {
is_wayland=false
CLAUDE_PASSWORD_STORE='gnome-libsecret'
setup_logging
build_electron_args deb
has_electron_arg '--password-store=gnome-libsecret'
run cat "$log_file"
[[ $output == *'Password store: gnome-libsecret (env override)'* ]]
}
@test "build_electron_args: CLAUDE_PASSWORD_STORE unset - no --password-store arg" {
# Default: the official os_crypt autodetect owns the decision, so the
# launcher must not emit a --password-store flag at all.
is_wayland=false
setup_logging
build_electron_args deb
# shellcheck disable=SC2314 # last command in test, ! works correctly
! has_electron_arg '--password-store=*'
}
@test "build_electron_args: X11 appimage - includes --no-sandbox" {
is_wayland=false
setup_logging
build_electron_args appimage
has_electron_arg '--no-sandbox'
}
@test "build_electron_args: Wayland XWayland deb - includes x11 platform and no-sandbox" {
is_wayland=true
use_x11_on_wayland=true
setup_logging
build_electron_args deb
has_electron_arg '--ozone-platform=x11'
has_electron_arg '--no-sandbox'
}
@test "build_electron_args: Wayland XWayland deb - no GlobalShortcutsPortal feature" {
# The portal feature is inert under XWayland, so it must not be
# emitted on the X11-via-XWayland path.
is_wayland=true
use_x11_on_wayland=true
setup_logging
build_electron_args deb
# shellcheck disable=SC2314 # last command in test, ! works correctly
! has_electron_arg '*GlobalShortcutsPortal*'
}
@test "build_electron_args: Wayland native deb - includes wayland platform flags" {
is_wayland=true
use_x11_on_wayland=false
setup_logging
build_electron_args deb
has_electron_arg '--ozone-platform=wayland'
has_electron_arg '--enable-wayland-ime'
has_electron_arg '*WaylandWindowDecorations*'
}
@test "build_electron_args: Wayland native deb - enables GlobalShortcutsPortal (#404)" {
is_wayland=true
use_x11_on_wayland=false
setup_logging
build_electron_args deb
has_electron_arg '*GlobalShortcutsPortal*'
}
@test "build_electron_args: Wayland native deb - portal + ozone share one --enable-features" {
# Chromium honours only the last --enable-features switch, so the
# portal feature, UseOzonePlatform and WaylandWindowDecorations must
# all live in a single comma-joined flag — not separate switches.
is_wayland=true
use_x11_on_wayland=false
setup_logging
build_electron_args deb
# Exactly one --enable-features switch (Chromium honours only the
# last), carrying both features. Order inside the value is irrelevant
# to Chromium, so assert each subkey independently rather than with an
# ordered glob.
[[ $(count_enable_features) -eq 1 ]]
has_electron_arg '--enable-features=*UseOzonePlatform*'
has_electron_arg '--enable-features=*GlobalShortcutsPortal*'
}
@test "build_electron_args: Wayland appimage - always includes --no-sandbox" {
is_wayland=true
use_x11_on_wayland=true
setup_logging
build_electron_args appimage
has_electron_arg '--no-sandbox'
}
@test "build_electron_args: Wayland native nix - includes --no-sandbox" {
is_wayland=true
use_x11_on_wayland=false
setup_logging
build_electron_args nix
has_electron_arg '--no-sandbox'
}
@test "build_electron_args: Wayland native includes text-input-version=3" {
is_wayland=true
use_x11_on_wayland=false
setup_logging
build_electron_args deb
has_electron_arg '--wayland-text-input-version=3'
}
# =============================================================================
# setup_electron_env
#
# ELECTRON_FORCE_IS_PACKAGED and ELECTRON_USE_SYSTEM_TITLE_BAR were both
# dropped in the v3.0.0 rebase: the official build ships packaged and
# owns its own window frame, so the launcher no longer sets either.
# =============================================================================
@test "setup_electron_env: CLAUDE_GTK_IM_MODULE set propagates to GTK_IM_MODULE" {
setup_logging
GTK_IM_MODULE='ibus'
CLAUDE_GTK_IM_MODULE='xim'
setup_electron_env
[[ $GTK_IM_MODULE == 'xim' ]]
# Override is logged so users can verify it took effect
run cat "$log_file"
[[ $output == *'GTK_IM_MODULE override: ibus -> xim (via CLAUDE_GTK_IM_MODULE)'* ]]
}
@test "setup_electron_env: CLAUDE_GTK_IM_MODULE set logs <unset> when GTK_IM_MODULE was unset" {
setup_logging
# GTK_IM_MODULE unset by setup()
CLAUDE_GTK_IM_MODULE='xim'
setup_electron_env
[[ $GTK_IM_MODULE == 'xim' ]]
run cat "$log_file"
[[ $output == *'GTK_IM_MODULE override: <unset> -> xim (via CLAUDE_GTK_IM_MODULE)'* ]]
}
@test "setup_electron_env: CLAUDE_GTK_IM_MODULE unset leaves GTK_IM_MODULE alone" {
setup_logging
GTK_IM_MODULE='ibus'
# CLAUDE_GTK_IM_MODULE unset by setup()
setup_electron_env
[[ $GTK_IM_MODULE == 'ibus' ]]
# No override line should appear in the log
run cat "$log_file"
[[ $output != *'GTK_IM_MODULE override'* ]]
}
@test "setup_electron_env: CLAUDE_GTK_IM_MODULE empty leaves GTK_IM_MODULE alone" {
setup_logging
GTK_IM_MODULE='ibus'
CLAUDE_GTK_IM_MODULE=''
setup_electron_env
[[ $GTK_IM_MODULE == 'ibus' ]]
run cat "$log_file"
[[ $output != *'GTK_IM_MODULE override'* ]]
}
# =============================================================================
# cleanup_stale_lock
# =============================================================================
@test "cleanup_stale_lock: no lock file - returns 0" {
mkdir -p "$XDG_CONFIG_HOME/Claude"
run cleanup_stale_lock
[[ $status -eq 0 ]]
}
@test "cleanup_stale_lock: removes stale lock (dead PID)" {
local config_dir="$XDG_CONFIG_HOME/Claude"
mkdir -p "$config_dir"
# Use PID 99999999 which almost certainly doesn't exist
ln -s "myhost-99999999" "$config_dir/SingletonLock"
setup_logging
cleanup_stale_lock
[[ ! -L "$config_dir/SingletonLock" ]]
}
@test "cleanup_stale_lock: keeps lock for running process" {
local config_dir="$XDG_CONFIG_HOME/Claude"
mkdir -p "$config_dir"
# Use our own PID (guaranteed to be running)
ln -s "myhost-$$" "$config_dir/SingletonLock"
setup_logging
cleanup_stale_lock
# Lock should still exist
[[ -L "$config_dir/SingletonLock" ]]
}
@test "cleanup_stale_lock: handles non-numeric PID in lock target" {
local config_dir="$XDG_CONFIG_HOME/Claude"
mkdir -p "$config_dir"
ln -s "myhost-notanumber" "$config_dir/SingletonLock"
setup_logging
run cleanup_stale_lock
[[ $status -eq 0 ]]
# Lock should still exist (function returns early on non-numeric)
[[ -L "$config_dir/SingletonLock" ]]
}
@test "cleanup_stale_lock: handles regular file (not symlink)" {
local config_dir="$XDG_CONFIG_HOME/Claude"
mkdir -p "$config_dir"
echo "not a symlink" > "$config_dir/SingletonLock"
setup_logging
run cleanup_stale_lock
[[ $status -eq 0 ]]
# Regular file should not be touched
[[ -f "$config_dir/SingletonLock" ]]
}
# =============================================================================
# cleanup_stale_cowork_socket
# =============================================================================
@test "cleanup_stale_cowork_socket: no socket - returns 0" {
run cleanup_stale_cowork_socket
[[ $status -eq 0 ]]
}
@test "cleanup_stale_cowork_socket: removes stale socket file" {
# Create a socket-like file (not a real socket, but -S check needs a socket)
# Use python to create a real unix socket for the test
local sock="$XDG_RUNTIME_DIR/cowork-vm-service.sock"
python3 -c "
import socket, sys
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.bind(sys.argv[1])
s.close()
" "$sock" 2>/dev/null || skip "Cannot create test unix socket"
# Stub pgrep so the test is isolated from host process state:
# a real cowork-vm-service daemon on the developer machine would
# trip the function's "daemon alive, leave socket alone" branch.
pgrep() { return 1; }
setup_logging
cleanup_stale_cowork_socket
[[ ! -S "$sock" ]]
}
# =============================================================================
# cleanup_orphaned_cowork_daemon
#
# Reaps a cowork-vm-service daemon left behind by a crashed UI, but only
# when no live Claude UI is running. pgrep/kill/sleep are stubbed; the
# "live UI" case uses a real background process so the /proc cmdline and
# status reads resolve naturally without faking /proc.
# =============================================================================
@test "cleanup_orphaned_cowork_daemon: no daemon running — no action, no log" {
# Daemon pgrep finds nothing, so the function returns before any
# UI scan or kill.
pgrep() { return 1; }
kill() { echo "kill $*" >> "$TEST_TMP/kills"; }
setup_logging
run cleanup_orphaned_cowork_daemon
[[ $status -eq 0 ]]
[[ ! -f "$TEST_TMP/kills" ]]
[[ ! -f $log_file ]]
}
@test "cleanup_orphaned_cowork_daemon: live UI present — daemon left running" {
# A real background process stands in for the live Electron UI so
# the /proc cmdline and status reads resolve naturally. The UI
# scan fingerprints on the launcher-passed --class flag (since
# #700 app.asar no longer appears in any cmdline), so the
# stand-in's argv[0] is renamed to carry it via exec -a. Its state
# is sleeping (not T/t/Z), so the function treats it as a live UI
# and must NOT kill the daemon.
bash -c 'exec -a "--class=Claude" sleep 300' &
ui_pid=$!
# Wait for the exec to land before running the reaper: on a loaded
# runner the child can still carry its pre-exec argv when the UI
# scan reads /proc cmdline, so the fingerprint misses and the
# reaper takes the orphan path (flaked CI on 2026-07-10). Poll the
# reaper's own predicate, not a parallel pattern that can drift —
# a loose grep matches the pre-exec cmdline (--class=Claude inside
# the bash -c quoting) that the strict fingerprint still rejects.
# Fail loudly on timeout: exec lands in ~4ms, so a silent
# fall-through would reproduce the exact flake signature this
# poll exists to kill. Plain assignment, not ((i++)), to dodge
# the errexit trap.
local _i=0
while ! _claude_desktop_ui_cmdline_matches \
"$(tr '\0' ' ' < "/proc/$ui_pid/cmdline" 2>/dev/null)"; do
if ((_i >= 50)); then
echo "stand-in UI $ui_pid never matched the reaper's" \
'fingerprint within 5s' >&2
return 1
fi
sleep 0.1
_i=$((_i + 1))
done
# Match on "$*", not "$2": the UI scan passes -u <uid> and a `--`
# end-of-options separator before the pattern, so the pattern is
# not at a fixed argument position.
pgrep() {
if [[ $* == *cowork-vm-service* ]]; then
echo 4242
elif [[ $* == *--class=Claude* ]]; then
echo "$ui_pid"
fi
}
kill() { echo "kill $*" >> "$TEST_TMP/kills"; }
setup_logging
cleanup_orphaned_cowork_daemon
local rc=$?
builtin kill "$ui_pid" 2>/dev/null
[[ $rc -eq 0 ]]
# Daemon kill must never have been attempted.
[[ ! -f "$TEST_TMP/kills" ]]
}
@test "cleanup_orphaned_cowork_daemon: orphan exits on SIGTERM — no SIGKILL" {
# Daemon present, no live UI. The daemon disappears once SIGTERM is
# sent, so the escalation to SIGKILL must not fire.
local term_sent="$TEST_TMP/term_sent"
pgrep() {
if [[ $* == *cowork-vm-service* ]]; then
[[ -f $term_sent ]] && return 1
echo 4242
else
# UI scan (--class fingerprint): no live UI.
return 1
fi
}
kill() {
echo "kill $*" >> "$TEST_TMP/kills"
# A plain SIGTERM ($1 is the PID, not -KILL) reaps the daemon.
[[ $1 == -KILL ]] || : > "$term_sent"
}
sleep() { :; }
setup_logging
# Via `run` so the function's internal `((_wait++))` (which returns 1
# when _wait starts at 0) doesn't trip bats' errexit. Production has
# no set -e, so this is a harness concern, not a code defect.
run cleanup_orphaned_cowork_daemon
grep -q 'Killed orphaned cowork-vm-service daemon (PIDs: 4242)' \
"$log_file"
# Negative assertions via `run` + status: a bare `! grep` that isn't
# the last command does not fail a bats test (SC2314), so it would be
# a hollow check.
run grep -q 'SIGKILL' "$log_file"
[[ $status -ne 0 ]]
grep -q '^kill 4242$' "$TEST_TMP/kills"
run grep -qF -- '-KILL' "$TEST_TMP/kills"
[[ $status -ne 0 ]]
}
@test "cleanup_orphaned_cowork_daemon: orphan survives SIGTERM — escalates to SIGKILL" {
# Daemon never dies, so after the SIGTERM grace window the function
# escalates to SIGKILL and logs the SIGKILL variant.
pgrep() {
if [[ $* == *cowork-vm-service* ]]; then
echo 4242
else
# UI scan (--class fingerprint): no live UI.
return 1
fi
}
kill() { echo "kill $*" >> "$TEST_TMP/kills"; }
sleep() { :; }
setup_logging
# `run` for the same errexit reason as the SIGTERM test above.
run cleanup_orphaned_cowork_daemon
grep -q 'Killed orphaned cowork-vm-service daemon (SIGKILL, PIDs: 4242)' \
"$log_file"
grep -q '^kill 4242$' "$TEST_TMP/kills"
grep -q '^kill -KILL 4242$' "$TEST_TMP/kills"
}
# =============================================================================
# cleanup_stale_desktop_helpers
# =============================================================================
@test "_desktop_helper_cmdline_matches: matches known Desktop helpers only" {
local config_dir="$XDG_CONFIG_HOME/Claude"
run _desktop_helper_cmdline_matches \
"/usr/lib/claude-desktop/claude-desktop --type=utility --user-data-dir=$config_dir"
[[ $status -eq 0 ]]
# tr '\0' ' ' joins cmdline args with a trailing space, so the
# --user-data-dir arm anchors on "$config_dir " — exact dir only.
run _desktop_helper_cmdline_matches \
"/tmp/.mount_claudeXXXXXX/electron --type=utility --user-data-dir=$config_dir "
[[ $status -eq 0 ]]
run _desktop_helper_cmdline_matches \
"/tmp/.mount_claudeXXXXXX/electron --type=utility --user-data-dir=${config_dir}Dev "
[[ $status -ne 0 ]]
run _desktop_helper_cmdline_matches \
"/usr/lib/claude-desktop/resources/app.asar.unpacked/cowork-vm-service.js"
[[ $status -eq 0 ]]
# Official Rust Cowork helper (spawned via process.resourcesPath).
run _desktop_helper_cmdline_matches \
"/usr/lib/claude-desktop/resources/app.asar.unpacked/cowork-linux-helper --socket /run/user/1000/cowork.sock"
[[ $status -eq 0 ]]
# Phase 3 package rename: our helpers now live under
# /usr/lib/claude-desktop-unofficial/ and must match alongside the
# official /usr/lib/claude-desktop/ arm above.
run _desktop_helper_cmdline_matches \
"/usr/lib/claude-desktop-unofficial/claude-desktop --type=utility --user-data-dir=$config_dir"
[[ $status -eq 0 ]]
run _desktop_helper_cmdline_matches \
"node $config_dir/Claude Extensions/ant.dir.example/server.js"
[[ $status -eq 0 ]]
run _desktop_helper_cmdline_matches \
"/usr/lib/claude-desktop/claude-desktop /usr/lib/claude-desktop/resources/app.asar"
[[ $status -ne 0 ]]
run _desktop_helper_cmdline_matches \
"claude --dangerously-skip-permissions"
[[ $status -ne 0 ]]
run _desktop_helper_cmdline_matches \
"/home/scott/dev/dude/core/agent-dude/dist/index.js mcp"
[[ $status -ne 0 ]]
}
@test "_claude_desktop_ui_cmdline_matches: keys on the --class fingerprint" {
# Live UI: launcher argv carries --class=$WM_CLASS (tr '\0' ' '
# leaves every argument space-terminated). Since #700 app.asar no
# longer appears in any cmdline, so the --class flag from
# build_electron_args is the only stable UI signature.
run _claude_desktop_ui_cmdline_matches \
"/usr/lib/claude-desktop/claude-desktop --class=Claude --enable-features=WaylandWindowDecorations "
[[ $status -eq 0 ]]
# Another Electron app's asar path must not match.
run _claude_desktop_ui_cmdline_matches \
"/opt/other-electron-app/resources/app.asar "
[[ $status -ne 0 ]]
# Look-alike WM class is rejected by the trailing-space anchor.
run _claude_desktop_ui_cmdline_matches \
"/opt/claude-dev/electron --class=ClaudeDev "
[[ $status -ne 0 ]]
# Chromium helpers (--type=) never count as the UI, even if a
# --class flag leaked into their argv.
run _claude_desktop_ui_cmdline_matches \
"/usr/lib/claude-desktop/claude-desktop --type=utility --user-data-dir=$XDG_CONFIG_HOME/Claude --class=Claude "
[[ $status -ne 0 ]]
# The cowork daemon never counts as the UI.
run _claude_desktop_ui_cmdline_matches \
"/usr/lib/claude-desktop/resources/app.asar.unpacked/cowork-vm-service.js --class=Claude "
[[ $status -ne 0 ]]
}
@test "run_electron_and_cleanup: runs cleanup after Electron exits and preserves status" {
local marker="$TEST_TMP/cleanup-ran"
local electron="$TEST_TMP/electron"
cat > "$electron" <<'STUB'
#!/usr/bin/env bash
echo "electron argv: $*"
exit 7
STUB
chmod +x "$electron"
cleanup_after_electron_exit() {
touch "$marker"
}
setup_logging
run run_electron_and_cleanup "$electron" '--flag' 'value'
[[ $status -eq 7 ]]
[[ -f $marker ]]
run cat "$log_file"
[[ $output == *'electron argv: --flag value'* ]]
}
# =============================================================================
# Doctor helper functions
# =============================================================================
@test "_doctor_colors: sets color vars when stdout is a terminal" {
# Force non-terminal to test the else branch
_doctor_colors
# When not a terminal, all should be empty
[[ -z $_green ]]
[[ -z $_red ]]
[[ -z $_yellow ]]
[[ -z $_bold ]]
[[ -z $_reset ]]
}
@test "_pass: outputs PASS with message" {
_doctor_colors
run _pass "test passed"
[[ $output == *"[PASS]"* ]]
[[ $output == *"test passed"* ]]
}
@test "_fail: outputs FAIL with message and increments counter" {
_doctor_colors
_doctor_failures=0
_fail "something broke"
[[ $_doctor_failures -eq 1 ]]
}
@test "_warn: outputs WARN with message" {
_doctor_colors
run _warn "warning message"
[[ $output == *"[WARN]"* ]]
[[ $output == *"warning message"* ]]
}
@test "_info: outputs indented message" {
_doctor_colors
run _info "info message"
[[ $output == *"info message"* ]]
}
# =============================================================================
# _cowork_distro_id
# =============================================================================
@test "_cowork_distro_id: reads ID from /etc/os-release" {
# This test uses the real /etc/os-release on the test system
[[ -f /etc/os-release ]] || skip "No /etc/os-release"
local result
result=$(_cowork_distro_id)
# Should return something non-empty
[[ -n $result ]]
[[ $result != 'unknown' ]]
}
# =============================================================================
# _cowork_pkg_hint
# =============================================================================
@test "_cowork_pkg_hint: debian uses apt" {
local result
result=$(_cowork_pkg_hint debian bubblewrap)
[[ $result == "sudo apt install bubblewrap" ]]
}
@test "_cowork_pkg_hint: ubuntu uses apt" {
local result
result=$(_cowork_pkg_hint ubuntu socat)
[[ $result == "sudo apt install socat" ]]
}
@test "_cowork_pkg_hint: fedora uses dnf" {
local result
result=$(_cowork_pkg_hint fedora bubblewrap)
[[ $result == "sudo dnf install bubblewrap" ]]
}
@test "_cowork_pkg_hint: arch uses pacman" {
local result
result=$(_cowork_pkg_hint arch socat)
[[ $result == "sudo pacman -S socat" ]]
}
@test "_cowork_pkg_hint: qemu maps to distro-specific packages" {
local result
result=$(_cowork_pkg_hint debian qemu)
[[ $result == "sudo apt install qemu-system-x86 qemu-utils" ]]
result=$(_cowork_pkg_hint fedora qemu)
[[ $result == "sudo dnf install qemu-kvm qemu-img" ]]
result=$(_cowork_pkg_hint arch qemu)
[[ $result == "sudo pacman -S qemu-full" ]]
}
@test "_cowork_pkg_hint: unknown distro gives generic message" {
local result
result=$(_cowork_pkg_hint gentoo bubblewrap)
[[ $result == "Install bubblewrap using your package manager" ]]
}
# =============================================================================
# _electron_version
# =============================================================================
@test "_electron_version: reads version from file beside binary" {
mkdir -p "$TEST_TMP/electron"
echo "33.4.0" > "$TEST_TMP/electron/version"
touch "$TEST_TMP/electron/electron"
local result
result=$(_electron_version "$TEST_TMP/electron/electron")
[[ $result == "33.4.0" ]]
}
@test "_electron_version: returns empty when version file missing" {
mkdir -p "$TEST_TMP/electron"
touch "$TEST_TMP/electron/electron"
local result
result=$(_electron_version "$TEST_TMP/electron/electron") || true
[[ -z $result ]]
}
# =============================================================================
# heal_autostart_entry (AUTO-1): repoint the app-written XDG autostart
# entry from the raw ELF / ephemeral AppImage mount to the launcher
# =============================================================================
_write_autostart() {
# $1 = Exec line; remaining upstream-shaped lines are fixed
mkdir -p "$XDG_CONFIG_HOME/autostart"
{
echo '[Desktop Entry]'
echo 'Type=Application'
echo 'Name=Claude'
echo "$1"
echo 'X-GNOME-Autostart-enabled=true'
} > "$XDG_CONFIG_HOME/autostart/claude-desktop.desktop"
}
_autostart_exec() {
grep '^Exec=' "$XDG_CONFIG_HOME/autostart/claude-desktop.desktop"
}
@test "heal_autostart_entry: rewrites the raw-ELF Exec to the launcher" {
_write_autostart 'Exec="/usr/lib/claude-desktop/claude-desktop" --startup'
heal_autostart_entry '/usr/bin/claude-desktop-unofficial'
[[ $(_autostart_exec) == \
'Exec="/usr/bin/claude-desktop-unofficial" --startup' ]]
}
@test "heal_autostart_entry: rewrites an ephemeral AppImage mount path" {
_write_autostart \
'Exec="/tmp/.mount_claudeXYZ/usr/lib/claude-desktop/claude-desktop" --startup'
heal_autostart_entry "$HOME/Apps/Claude.AppImage"
[[ $(_autostart_exec) == "Exec=\"$HOME/Apps/Claude.AppImage\" --startup" ]]
}
@test "heal_autostart_entry: idempotent when already pointing at the launcher" {
_write_autostart 'Exec="/usr/bin/claude-desktop-unofficial" --startup'
local before after
before=$(cat "$XDG_CONFIG_HOME/autostart/claude-desktop.desktop")
heal_autostart_entry '/usr/bin/claude-desktop-unofficial'
after=$(cat "$XDG_CONFIG_HOME/autostart/claude-desktop.desktop")
[[ $before == "$after" ]]
}
@test "heal_autostart_entry: leaves a hand-rolled wrapper Exec alone" {
_write_autostart 'Exec="/home/user/bin/my-claude-wrapper" --startup'
heal_autostart_entry '/usr/bin/claude-desktop-unofficial'
[[ $(_autostart_exec) == 'Exec="/home/user/bin/my-claude-wrapper" --startup' ]]
}
@test "heal_autostart_entry: handles an unquoted hand-edited Exec" {
_write_autostart 'Exec=/usr/lib/claude-desktop/claude-desktop --startup --foo'
heal_autostart_entry '/usr/bin/claude-desktop-unofficial'
[[ $(_autostart_exec) == \
'Exec="/usr/bin/claude-desktop-unofficial" --startup --foo' ]]
}
@test "heal_autostart_entry: no-op when the entry file is absent" {
rm -rf "$XDG_CONFIG_HOME/autostart"
run heal_autostart_entry '/usr/bin/claude-desktop-unofficial'
[[ $status -eq 0 ]]
[[ ! -e "$XDG_CONFIG_HOME/autostart/claude-desktop.desktop" ]]
}
@test "heal_autostart_entry: no-op when the launcher path is empty" {
_write_autostart 'Exec="/usr/lib/claude-desktop/claude-desktop" --startup'
run heal_autostart_entry ''
[[ $status -eq 0 ]]
[[ $(_autostart_exec) == 'Exec="/usr/lib/claude-desktop/claude-desktop" --startup' ]]
}
@test "heal_autostart_entry: preserves non-Exec lines and escapes % in the path" {
_write_autostart 'Exec="/usr/lib/claude-desktop/claude-desktop" --startup'
heal_autostart_entry '/opt/100%/claude-desktop'
[[ $(_autostart_exec) == 'Exec="/opt/100%%/claude-desktop" --startup' ]]
grep -q '^X-GNOME-Autostart-enabled=true$' \
"$XDG_CONFIG_HOME/autostart/claude-desktop.desktop"
grep -q '^Name=Claude$' \
"$XDG_CONFIG_HOME/autostart/claude-desktop.desktop"
}
@test "heal_autostart_entry: logs the heal when logging is set up" {
setup_logging
_write_autostart 'Exec="/usr/lib/claude-desktop/claude-desktop" --startup'
heal_autostart_entry '/usr/bin/claude-desktop-unofficial'
grep -q 'Healed autostart Exec' "$log_file"
grep -q 'AUTO-1' "$log_file"
}
# =============================================================================
# log_message
# =============================================================================
@test "log_message: joins multiple arguments into one line" {
setup_logging
log_message 'Cowork backend: bwrap requested' 'but no node found'
grep -q 'Cowork backend: bwrap requested but no node found' "$log_file"
}
@test "log_message: no-ops before setup_logging" {
run log_message 'orphan message'
[[ $status -eq 0 ]]
[[ -z $output ]]
}
# =============================================================================
# load_launcher_config
# =============================================================================
_write_launcher_cfg() {
mkdir -p "$XDG_CONFIG_HOME/claude-desktop-debian"
printf '%s\n' "$@" \
> "$XDG_CONFIG_HOME/claude-desktop-debian/environment"
}
@test "load_launcher_config: every allowlisted key round-trips" {
# Locks the full allowlist: a \-continuation inside the
# single-quoted list once silently dropped CLAUDE_GTK_IM_MODULE.
unset CLAUDE_DISABLE_GPU COWORK_NODE_PATH
_write_launcher_cfg \
'CLAUDE_USE_WAYLAND=1' \
'CLAUDE_PASSWORD_STORE=gnome-libsecret' \
'CLAUDE_GTK_IM_MODULE=xim' \
'CLAUDE_DISABLE_GPU=1' \
'COWORK_VM_BACKEND=bwrap' \
'COWORK_NODE_PATH=/usr/bin/node'
load_launcher_config
[[ $CLAUDE_USE_WAYLAND == '1' ]]
[[ $CLAUDE_PASSWORD_STORE == 'gnome-libsecret' ]]
[[ $CLAUDE_GTK_IM_MODULE == 'xim' ]]
[[ $CLAUDE_DISABLE_GPU == '1' ]]
[[ $COWORK_VM_BACKEND == 'bwrap' ]]
[[ $COWORK_NODE_PATH == '/usr/bin/node' ]]
}
@test "load_launcher_config: non-allowlisted key is never exported" {
unset LD_PRELOAD
_write_launcher_cfg 'LD_PRELOAD=/tmp/evil.so'
load_launcher_config
[[ -z ${LD_PRELOAD:-} ]]
}
@test "load_launcher_config: environment wins over the config file" {
_write_launcher_cfg 'COWORK_VM_BACKEND=bwrap'
export COWORK_VM_BACKEND='kvm'
load_launcher_config
[[ $COWORK_VM_BACKEND == 'kvm' ]]
}
@test "load_launcher_config: strips one layer of surrounding quotes" {
_write_launcher_cfg "CLAUDE_PASSWORD_STORE='gnome-libsecret'"
load_launcher_config
[[ $CLAUDE_PASSWORD_STORE == 'gnome-libsecret' ]]
}
@test "load_launcher_config: trims whitespace around key and value" {
unset CLAUDE_DISABLE_GPU
_write_launcher_cfg 'CLAUDE_DISABLE_GPU = 1'
load_launcher_config
[[ $CLAUDE_DISABLE_GPU == '1' ]]
}
@test "load_launcher_config: skips comments and blank lines" {
_write_launcher_cfg '# a comment' '' ' ' 'COWORK_VM_BACKEND=bwrap'
load_launcher_config
[[ $COWORK_VM_BACKEND == 'bwrap' ]]
}
@test "load_launcher_config: missing file is a silent no-op" {
run load_launcher_config
[[ $status -eq 0 ]]
[[ -z $output ]]
}
@test "load_launcher_config: file is never executed as shell" {
_write_launcher_cfg "COWORK_VM_BACKEND=\$(touch $TEST_TMP/pwned)"
load_launcher_config
[[ ! -e "$TEST_TMP/pwned" ]]
[[ $COWORK_VM_BACKEND == *'touch'* ]]
}
@test "run_doctor: reads the launcher config file (config-only bwrap flag)" {
# The #772 persona: COWORK_VM_BACKEND=bwrap lives ONLY in the config
# file (GUI launches can't carry env). Doctor must see the same
# environment a launch would and run the bwrap diagnostics.
_write_launcher_cfg 'COWORK_VM_BACKEND=bwrap'
# Fail-fast curl stub: the drift check is best-effort and must not
# slow or flake the suite on a networkless runner.
mkdir -p "$TEST_TMP/bin"
printf '#!/bin/sh\nexit 1\n' > "$TEST_TMP/bin/curl"
chmod +x "$TEST_TMP/bin/curl"
export PATH="$TEST_TMP/bin:$PATH"
run run_doctor ''
[[ $output == *'COWORK_VM_BACKEND=bwrap'* ]]
[[ $output == *'bwrap daemon runtime'* ]]
}
# =============================================================================
# setup_cowork_bwrap_env: resolve the bwrap daemon's node runtime (#772)
# =============================================================================
# The launcher resolves a system node for the bwrap fallback daemon
# (the official Electron ships with the RunAsNode fuse off) and exports
# COWORK_NODE_PATH for the patched spawn. Only the flagged path may
# touch the environment.
# Write an executable node stub at $1 that fails the statfsSync
# capability probe (any invocation exits 1, so --version fails too —
# matching a runtime too old to matter).
_stub_featureless_node() {
printf '#!/bin/sh\nexit 1\n' > "$1"
chmod +x "$1"
}
@test "setup_cowork_bwrap_env: no-op when the backend flag is not bwrap" {
unset COWORK_VM_BACKEND COWORK_NODE_PATH
setup_cowork_bwrap_env
[[ -z ${COWORK_NODE_PATH:-} ]]
}
@test "setup_cowork_bwrap_env: explicit COWORK_NODE_PATH is honored" {
command -v node >/dev/null || skip 'node not installed'
# A symlinked copy in TEST_TMP is distinguishable from what the
# PATH probe would resolve, so this proves precedence (the
# explicit path survives the call), not just the log line.
ln -s "$(command -v node)" "$TEST_TMP/pinned-node"
export COWORK_VM_BACKEND=bwrap
export COWORK_NODE_PATH="$TEST_TMP/pinned-node"
log_file="$TEST_TMP/launcher.log"
: > "$log_file"
setup_cowork_bwrap_env
[[ $COWORK_NODE_PATH == "$TEST_TMP/pinned-node" ]]
grep -qF "daemon node: $TEST_TMP/pinned-node" "$log_file"
}
@test "setup_cowork_bwrap_env: auto-detects node from PATH and exports it" {
command -v node >/dev/null || skip 'node not installed'
export COWORK_VM_BACKEND=bwrap
unset COWORK_NODE_PATH
log_file="$TEST_TMP/launcher.log"
: > "$log_file"
setup_cowork_bwrap_env
[[ ${COWORK_NODE_PATH:-} == "$(command -v node)" ]]
}
@test "setup_cowork_bwrap_env: no node anywhere logs cannot-start, exports nothing" {
# Shadow `command` so -v node/nodejs both miss (the _skip_gtk_query
# pattern) — emptying PATH would break log_message's own tooling.
command() {
if [[ $1 == '-v' && ( $2 == 'node' || $2 == 'nodejs' ) ]]; then
return 1
fi
builtin command "$@"
}
export COWORK_VM_BACKEND=bwrap
unset COWORK_NODE_PATH
log_file="$TEST_TMP/launcher.log"
: > "$log_file"
setup_cowork_bwrap_env
[[ -z ${COWORK_NODE_PATH:-} ]]
grep -q 'cannot start' "$log_file"
}
@test "setup_cowork_bwrap_env: statfsSync-less node logs the capability warning" {
_stub_featureless_node "$TEST_TMP/oldnode"
export COWORK_VM_BACKEND=bwrap
export COWORK_NODE_PATH="$TEST_TMP/oldnode"
log_file="$TEST_TMP/launcher.log"
: > "$log_file"
setup_cowork_bwrap_env
grep -q 'lacks fs.statfsSync' "$log_file"
grep -q 'refuse to start' "$log_file"
}