Merge remote-tracking branch 'refs/remotes/portfolio/quattro' into codex/portfolio-9457-20260907
This commit is contained in:
commit
bc235d2807
179 files changed
+10898
-1487
No files matched your search
@@ -1,113 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(dirname "$0")/base-test.sh"
|
||||
|
||||
require_command jq
|
||||
|
||||
migration="$ROOT/migrations/1785344985.sh"
|
||||
test_dir=$(mktemp -d)
|
||||
trap 'rm -rf "$test_dir"' EXIT
|
||||
|
||||
mkdir -p "$test_dir/bin"
|
||||
|
||||
cat >"$test_dir/bin/omarchy-restart-shell" <<'STUB'
|
||||
#!/bin/bash
|
||||
|
||||
echo restart >>"$SHELL_RESTARTS"
|
||||
STUB
|
||||
|
||||
chmod +x "$test_dir/bin/"*
|
||||
|
||||
export SHELL_RESTARTS="$test_dir/shell-restarts"
|
||||
|
||||
home="$test_dir/home"
|
||||
config="$home/.config/omarchy/shell.json"
|
||||
|
||||
run_migration() {
|
||||
: >"$SHELL_RESTARTS"
|
||||
HOME="$home" PATH="$test_dir/bin:$PATH" bash -euo pipefail "$migration" >/dev/null
|
||||
}
|
||||
|
||||
# The shipped default minus the widget is what every machine installed before
|
||||
# this migration has on disk.
|
||||
write_config() {
|
||||
rm -rf "$home"
|
||||
mkdir -p "$home/.config/omarchy"
|
||||
jq "${1:-.}" "$ROOT/config/omarchy/shell.json" >"$config"
|
||||
}
|
||||
|
||||
without_widget='del(.bar.layout[][] | select((if type == "object" then .id else . end) == "omarchy.agents"))'
|
||||
|
||||
ids() {
|
||||
jq -c --arg section "$1" '[.bar.layout[$section][]? | if type == "object" then .id else . end]' "$config"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ shipped default
|
||||
|
||||
jq -e '[.bar.layout.right[].id] | index("omarchy.agents")' "$ROOT/config/omarchy/shell.json" >/dev/null ||
|
||||
fail "shipped config puts the agents widget in the bar"
|
||||
pass "shipped config puts the agents widget in the bar"
|
||||
|
||||
# ------------------------------------------------------------------ placement
|
||||
|
||||
write_config "$without_widget"
|
||||
run_migration
|
||||
|
||||
[[ $(ids right) == '["omarchy.tray","omarchy.agents","omarchy.bluetooth","omarchy.network","omarchy.audio","omarchy.monitor","omarchy.power"]' ]] ||
|
||||
fail "migration inserts the agents widget after the tray" "$(ids right)"
|
||||
pass "migration inserts the agents widget after the tray"
|
||||
|
||||
(($(wc -l <"$SHELL_RESTARTS") == 0)) || fail "migration leaves the shell restart to omarchy update"
|
||||
pass "migration leaves the shell restart to omarchy update"
|
||||
|
||||
before=$(sha256sum "$config")
|
||||
run_migration
|
||||
[[ $before == $(sha256sum "$config") ]] || fail "migration is idempotent" "$(ids right)"
|
||||
pass "migration is idempotent"
|
||||
|
||||
# ------------------------------------------------------------------ curated bars
|
||||
|
||||
# A user who already placed the widget keeps it exactly where they put it, in
|
||||
# whichever section, and never gets a second copy.
|
||||
write_config "$without_widget | .bar.layout.center += [{ id: \"omarchy.agents\" }]"
|
||||
run_migration
|
||||
|
||||
[[ $(ids center) == *'"omarchy.agents"'* ]] || fail "migration leaves a user-placed widget alone" "$(ids center)"
|
||||
[[ $(ids right) != *'"omarchy.agents"'* ]] || fail "migration does not add a second copy" "$(ids right)"
|
||||
pass "migration respects a widget the user already placed"
|
||||
|
||||
# Layouts written before entries grew options are bare id strings.
|
||||
write_config "$without_widget | .bar.layout.right = [\"omarchy.tray\", \"omarchy.agents\", \"omarchy.power\"]"
|
||||
run_migration
|
||||
|
||||
[[ $(ids right) == '["omarchy.tray","omarchy.agents","omarchy.power"]' ]] ||
|
||||
fail "migration reads string-form entries" "$(ids right)"
|
||||
pass "migration reads string-form entries"
|
||||
|
||||
# A tray dropped from the right section must not strand the widget or drop it.
|
||||
write_config "$without_widget | del(.bar.layout.right[] | select(.id == \"omarchy.tray\"))"
|
||||
run_migration
|
||||
|
||||
[[ $(ids right) == '["omarchy.agents",'* ]] || fail "migration places the widget without a tray" "$(ids right)"
|
||||
pass "migration places the widget without a tray"
|
||||
|
||||
# ------------------------------------------------------------------ everything else
|
||||
|
||||
write_config "$without_widget"
|
||||
cp "$config" "$test_dir/before.json"
|
||||
run_migration
|
||||
|
||||
diff <(jq -S 'del(.bar.layout.right)' "$test_dir/before.json") <(jq -S 'del(.bar.layout.right)' "$config") >/dev/null ||
|
||||
fail "migration touches nothing but the right section" "$(diff <(jq -S . "$test_dir/before.json") <(jq -S . "$config"))"
|
||||
pass "migration touches nothing but the right section"
|
||||
|
||||
# A config the migration cannot parse is left alone rather than truncated.
|
||||
rm -rf "$home"
|
||||
mkdir -p "$home/.config/omarchy"
|
||||
printf '{ not json' >"$config"
|
||||
run_migration
|
||||
|
||||
[[ $(cat "$config") == '{ not json' ]] || fail "migration leaves an unparsable config untouched" "$(cat "$config")"
|
||||
pass "migration leaves an unparsable config untouched"
|
||||
@@ -55,6 +55,14 @@ const entries = [
|
||||
}
|
||||
]
|
||||
|
||||
// Keep the packaged launcher when upstream rebuilds register their own entry.
|
||||
const configuredHides = new Set(fs.readFileSync(path.join(root, 'default/omarchy/launcher.hides'), 'utf8').trim().split(/\n/))
|
||||
const hermesEntries = [{ name: 'Hermes', id: 'hermes' }, { name: 'Hermes', id: 'hermes-desktop' }]
|
||||
for (const query of ['', 'hermes']) {
|
||||
const visible = search.sortedEntries(hermesEntries, query, entry => configuredHides.has(entry.id))
|
||||
assertDeepEqual(visible.map(row => row.entry.id), ['hermes-desktop'], 'only the packaged Hermes launcher is visible')
|
||||
}
|
||||
|
||||
const contactMatches = search.sortedEntries(entries, 'contact').map(row => search.entryName(row.entry))
|
||||
assertDeepEqual(contactMatches, ['Google Contacts'], 'contact search only returns direct contact matches')
|
||||
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
apply_lock="$ROOT/bin/omarchy-apply-lock"
|
||||
|
||||
root_path_guard=$(awk '
|
||||
/^if \(\( EUID == 0 \)\); then$/ { inside = 1 }
|
||||
inside { print }
|
||||
inside && /^fi$/ { exit }
|
||||
' "$apply_lock")
|
||||
grep -Fx ' export PATH=/usr/share/omarchy/bin:/usr/local/bin:/usr/bin:/bin' <<<"$root_path_guard" >/dev/null ||
|
||||
fail "the root lock helper replaces its inherited command path"
|
||||
if grep -E '(\.local/bin|target_user|target_home)' <<<"$root_path_guard" >/dev/null; then
|
||||
fail "the root lock helper does not retain a user-controlled command directory"
|
||||
fi
|
||||
pass "the root lock helper uses only trusted command directories"
|
||||
|
||||
grep -F '[[ -x /usr/bin/fprintd-list ]]' "$apply_lock" >/dev/null ||
|
||||
fail "the lock helper checks the trusted fprintd-list executable"
|
||||
grep -F '/usr/bin/fprintd-list "$target_user"' "$apply_lock" >/dev/null ||
|
||||
fail "the lock helper invokes fprintd-list by its trusted absolute path"
|
||||
if grep -F 'omarchy-cmd-present fprintd-list' "$apply_lock" >/dev/null ||
|
||||
grep -E '(^|[[:space:];&|])fprintd-list([[:space:]]|$)' "$apply_lock" >/dev/null ||
|
||||
grep -E 'command[[:space:]]+-v[[:space:]]+fprintd-list' "$apply_lock" >/dev/null; then
|
||||
fail "the lock helper does not resolve fprintd-list through PATH"
|
||||
fi
|
||||
pass "the lock helper pins fprintd-list to its packaged system path"
|
||||
|
||||
# Exercise the helper as real root when the suite already has it, or as root in
|
||||
# an unprivileged user namespace otherwise. A hardened kernel can disable user
|
||||
# namespaces, so preserve the static coverage above and skip only this probe.
|
||||
root_runner=()
|
||||
root_runtime_available=1
|
||||
if (( EUID != 0 )); then
|
||||
if command -v unshare >/dev/null && unshare --user --map-root-user true 2>/dev/null; then
|
||||
root_runner=(unshare --user --map-root-user)
|
||||
else
|
||||
root_runtime_available=0
|
||||
fi
|
||||
fi
|
||||
|
||||
if (( ! root_runtime_available )); then
|
||||
pass "no unprivileged user namespace; skipping the root lock-helper lookup matrix"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Retarget the two PAM files, the trusted fprintd-list binary, and the final
|
||||
# shell status query in copies under this scratch directory. The production
|
||||
# files and service stay untouched even when this suite itself runs as root.
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
poison_bin="$test_tmp/poison-bin"
|
||||
trusted_root_bin="$test_tmp/trusted-root-bin"
|
||||
trusted_fprintd="$test_tmp/trusted-fprintd-list"
|
||||
password_pam="$test_tmp/omarchy-lock-password"
|
||||
fingerprint_pam="$test_tmp/omarchy-lock-fingerprint"
|
||||
attack_marker="$test_tmp/user-fprintd-list-ran"
|
||||
trusted_uid="$test_tmp/trusted-fprintd-list.uid"
|
||||
trusted_args="$test_tmp/trusted-fprintd-list.args"
|
||||
attack_args="$test_tmp/user-fprintd-list.args"
|
||||
patched_helper="$test_tmp/omarchy-apply-lock-patched"
|
||||
absolute_only_helper="$test_tmp/omarchy-apply-lock-absolute-only"
|
||||
root_path_only_helper="$test_tmp/omarchy-apply-lock-root-path-only"
|
||||
unprotected_helper="$test_tmp/omarchy-apply-lock-unprotected"
|
||||
target_user=omarchy-regression-user
|
||||
mkdir -p "$poison_bin" "$trusted_root_bin"
|
||||
|
||||
# The runtime copy pins to this isolated root path. It contains every bare
|
||||
# command the exercised helper needs, but deliberately no fprintd-list.
|
||||
for helper in grep rm tee; do
|
||||
ln -s "/usr/bin/$helper" "$trusted_root_bin/$helper"
|
||||
done
|
||||
|
||||
export TEST_ATTACK_ARGS="$attack_args"
|
||||
export TEST_ATTACK_MARKER="$attack_marker"
|
||||
export TEST_TRUSTED_ARGS="$trusted_args"
|
||||
export TEST_TRUSTED_UID="$trusted_uid"
|
||||
|
||||
cat >"$trusted_fprintd" <<'EOF'
|
||||
#!/bin/bash
|
||||
|
||||
printf '%s\n' "$EUID" >"$TEST_TRUSTED_UID"
|
||||
printf '%s\n' "$*" >"$TEST_TRUSTED_ARGS"
|
||||
echo "Fingerprints are enrolled"
|
||||
EOF
|
||||
|
||||
cat >"$poison_bin/fprintd-list" <<'EOF'
|
||||
#!/bin/bash
|
||||
|
||||
printf '%s\n' "$EUID" >"$TEST_ATTACK_MARKER"
|
||||
printf '%s\n' "$*" >"$TEST_ATTACK_ARGS"
|
||||
echo "Fingerprints are enrolled"
|
||||
EOF
|
||||
|
||||
chmod +x "$trusted_fprintd" "$poison_bin/fprintd-list"
|
||||
|
||||
prepare_helper() {
|
||||
local destination="$1" keep_root_path="$2" use_absolute_fprintd="$3"
|
||||
|
||||
awk \
|
||||
-v password_pam="$password_pam" \
|
||||
-v fingerprint_pam="$fingerprint_pam" \
|
||||
-v trusted_root_bin="$trusted_root_bin" \
|
||||
-v trusted_fprintd="$trusted_fprintd" \
|
||||
-v keep_root_path="$keep_root_path" \
|
||||
-v use_absolute_fprintd="$use_absolute_fprintd" '
|
||||
{
|
||||
line = $0
|
||||
gsub("/etc/pam\\.d/omarchy-lock-password", "\"" password_pam "\"", line)
|
||||
gsub("/etc/pam\\.d/omarchy-lock-fingerprint", "\"" fingerprint_pam "\"", line)
|
||||
|
||||
if (line == "if (( EUID == 0 )); then" && keep_root_path == 0) {
|
||||
print "if (( 0 )); then"
|
||||
next
|
||||
}
|
||||
if (line == " export PATH=/usr/share/omarchy/bin:/usr/local/bin:/usr/bin:/bin") {
|
||||
print " export PATH=\"" trusted_root_bin "\""
|
||||
next
|
||||
}
|
||||
if (line == "if [[ -x /usr/bin/fprintd-list ]] &&") {
|
||||
if (use_absolute_fprintd == 1) {
|
||||
print "if [[ -x \"" trusted_fprintd "\" ]] &&"
|
||||
} else {
|
||||
print "if command -v fprintd-list >/dev/null 2>&1 &&"
|
||||
}
|
||||
next
|
||||
}
|
||||
if (line == " /usr/bin/fprintd-list \"$target_user\" 2>/dev/null | grep -qi finger; then") {
|
||||
if (use_absolute_fprintd == 1) {
|
||||
print " \"" trusted_fprintd "\" \"$target_user\" 2>/dev/null | grep -qi finger; then"
|
||||
} else {
|
||||
print " fprintd-list \"$target_user\" 2>/dev/null | grep -qi finger; then"
|
||||
}
|
||||
next
|
||||
}
|
||||
if (line == "if omarchy-shell lock status >/dev/null 2>&1; then") {
|
||||
print "if false; then"
|
||||
next
|
||||
}
|
||||
|
||||
print line
|
||||
}
|
||||
' "$apply_lock" >"$destination"
|
||||
chmod +x "$destination"
|
||||
}
|
||||
|
||||
prepare_helper "$patched_helper" 1 1
|
||||
prepare_helper "$absolute_only_helper" 0 1
|
||||
prepare_helper "$root_path_only_helper" 1 0
|
||||
prepare_helper "$unprotected_helper" 0 0
|
||||
|
||||
for helper in "$patched_helper" "$absolute_only_helper" "$root_path_only_helper" "$unprotected_helper"; do
|
||||
if grep -F '/etc/pam.d/' "$helper" >/dev/null ||
|
||||
grep -F '/usr/bin/fprintd-list' "$helper" >/dev/null ||
|
||||
grep -F 'omarchy-shell lock status' "$helper" >/dev/null; then
|
||||
fail "the isolated root fixture redirects every live-system lock-helper target"
|
||||
fi
|
||||
done
|
||||
|
||||
reset_runtime_files() {
|
||||
rm -f "$password_pam" "$fingerprint_pam" "$trusted_uid" "$trusted_args" "$attack_marker" "$attack_args"
|
||||
}
|
||||
|
||||
run_as_root() {
|
||||
local helper="$1" description="$2" output
|
||||
|
||||
if ! output=$(PATH="$poison_bin:/usr/bin:/bin" OMARCHY_INSTALL_USER="$target_user" \
|
||||
"${root_runner[@]}" /bin/bash "$helper" 2>&1); then
|
||||
fail "$description" "$output"
|
||||
fi
|
||||
}
|
||||
|
||||
reset_runtime_files
|
||||
run_as_root "$patched_helper" "the fully hardened lock helper runs in an isolated root context"
|
||||
[[ ! -e $attack_marker ]] || fail "the hardened root lock helper executes the user-planted fprintd-list"
|
||||
grep -Fx '0' "$trusted_uid" >/dev/null || fail "the trusted fprintd-list probe runs with EUID 0"
|
||||
grep -Fx "$target_user" "$trusted_args" >/dev/null || fail "the trusted fprintd-list probe receives the target user"
|
||||
[[ -s $password_pam && -s $fingerprint_pam ]] ||
|
||||
fail "the isolated root lock-helper run writes both scratch PAM fixtures"
|
||||
pass "the hardened root lock helper uses the trusted fingerprint probe"
|
||||
|
||||
reset_runtime_files
|
||||
run_as_root "$absolute_only_helper" "the absolute-path-only lock helper runs in an isolated root context"
|
||||
[[ ! -e $attack_marker ]] || fail "an absolute fprintd-list path permits the user-planted command"
|
||||
grep -Fx '0' "$trusted_uid" >/dev/null || fail "the absolute-path defense runs the trusted probe as root"
|
||||
pass "the absolute fprintd-list path independently blocks the user-planted command"
|
||||
|
||||
reset_runtime_files
|
||||
run_as_root "$root_path_only_helper" "the root-PATH-only lock helper runs in an isolated root context"
|
||||
[[ ! -e $attack_marker ]] || fail "the trusted root path permits the user-planted fprintd-list"
|
||||
pass "the trusted root path independently blocks the user-planted command"
|
||||
|
||||
# Mutation control: removing both protections must execute the planted command
|
||||
# as UID 0, proving the matrix detects the original privilege-boundary failure.
|
||||
reset_runtime_files
|
||||
run_as_root "$unprotected_helper" "the unprotected mutation runs in an isolated root context"
|
||||
grep -Fx '0' "$attack_marker" >/dev/null ||
|
||||
fail "the root lock-helper fixture detects a PATH-resolved fprintd-list regression"
|
||||
grep -Fx "$target_user" "$attack_args" >/dev/null ||
|
||||
fail "the planted fprintd-list receives the target user"
|
||||
[[ -s $fingerprint_pam ]] || fail "the planted fprintd-list controls the fingerprint PAM branch"
|
||||
pass "the root lock-helper matrix rejects the vulnerable PATH lookup"
|
||||
@@ -5,6 +5,7 @@ set -euo pipefail
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
require_command magick
|
||||
require_command ffmpeg
|
||||
|
||||
TMPDIR=$(mktemp -d)
|
||||
trap 'rm -rf "$TMPDIR"' EXIT
|
||||
@@ -28,3 +29,15 @@ pass "transparent bar text keeps text color on dark wallpaper"
|
||||
result=$(HOME="$TMPDIR" omarchy-bar-text-color top 20 '#ffffff' '#101010' --background "$TMPDIR/missing.png" --screen 100x100)
|
||||
[[ $result == "#ffffff" ]] || fail "transparent bar text falls back to text color when sampling fails" "expected #ffffff, got $result"
|
||||
pass "transparent bar text falls back to text color when sampling fails"
|
||||
|
||||
# A video background must be sampled one frame at a time. Reading the whole file
|
||||
# emits a value per frame, which parses as nothing and silently falls back —
|
||||
# and decodes the entire wallpaper to find that out.
|
||||
light_top_video="$TMPDIR/light-top.mp4"
|
||||
ffmpeg -y -f lavfi -i "testsrc=size=640x360:rate=10:duration=2" \
|
||||
-vf "drawbox=x=0:y=0:w=640:h=40:color=0xf5f5f5:t=fill" \
|
||||
-c:v libx264 -preset ultrafast -pix_fmt yuv420p "$light_top_video" -loglevel error
|
||||
|
||||
result=$(HOME="$TMPDIR" omarchy-bar-text-color top 40 '#ffffff' '#101010' --background "$light_top_video" --screen 640x360)
|
||||
[[ $result == "#101010" ]] || fail "transparent bar text samples one frame of a video wallpaper" "expected #101010, got $result"
|
||||
pass "transparent bar text samples one frame of a video wallpaper"
|
||||
@@ -1,109 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(dirname "$0")/base-test.sh"
|
||||
|
||||
migration="$ROOT/migrations/1786380259.sh"
|
||||
test_dir=$(mktemp -d)
|
||||
trap 'rm -rf "$test_dir"' EXIT
|
||||
|
||||
mkdir -p "$test_dir/bin"
|
||||
|
||||
# sudo runs the real command, so sed acts on the redirected main.conf below and
|
||||
# the elevated power calls land in the stub beside it.
|
||||
cat >"$test_dir/bin/sudo" <<'STUB'
|
||||
#!/bin/bash
|
||||
|
||||
printf 'sudo %s\n' "$*" >>"$CALLS"
|
||||
exec "$@"
|
||||
STUB
|
||||
|
||||
cat >"$test_dir/bin/omarchy-bluetooth-power" <<'STUB'
|
||||
#!/bin/bash
|
||||
|
||||
printf 'omarchy-bluetooth-power %s\n' "$*" >>"$CALLS"
|
||||
[[ $1 == "is-on" ]] || exit 0
|
||||
[[ ${POWERED:-} == "yes" ]]
|
||||
STUB
|
||||
|
||||
chmod +x "$test_dir/bin/"*
|
||||
|
||||
export CALLS="$test_dir/calls"
|
||||
|
||||
marker="$test_dir/marker"
|
||||
main_conf="$test_dir/main.conf"
|
||||
|
||||
reset_machine() {
|
||||
rm -f "$marker"
|
||||
printf '[Policy]\nAutoEnable=false\n' >"$main_conf"
|
||||
}
|
||||
|
||||
run_migration() {
|
||||
: >"$CALLS"
|
||||
|
||||
OMARCHY_BLUETOOTH_MIGRATION_MARKER="$marker" \
|
||||
OMARCHY_BLUETOOTH_MAIN_CONF="$main_conf" \
|
||||
PATH="$test_dir/bin:$PATH" \
|
||||
bash -euo pipefail "$migration" >/dev/null
|
||||
}
|
||||
|
||||
# An adapter that is powered right now is one the user turned on, so it stays on.
|
||||
reset_machine
|
||||
POWERED=yes run_migration
|
||||
|
||||
grep -qx 'omarchy-bluetooth-power on' "$CALLS" ||
|
||||
fail "migration keeps a powered adapter on" "$(cat "$CALLS")"
|
||||
pass "migration keeps a powered adapter on"
|
||||
|
||||
grep -qx '#AutoEnable=true' "$main_conf" ||
|
||||
fail "migration puts AutoEnable back to its default" "$(cat "$main_conf")"
|
||||
pass "migration puts AutoEnable back to its default"
|
||||
|
||||
[[ -e $marker ]] || fail "migration records the machine as done"
|
||||
pass "migration records the machine as done"
|
||||
|
||||
# Anything else is a machine that has been booting with Bluetooth off, and the
|
||||
# block is what carries that over now AutoEnable no longer holds the adapter down.
|
||||
reset_machine
|
||||
POWERED=no run_migration
|
||||
|
||||
grep -qx 'omarchy-bluetooth-power off' "$CALLS" ||
|
||||
fail "migration carries an unpowered adapter over to the block" "$(cat "$CALLS")"
|
||||
pass "migration carries an unpowered adapter over to the block"
|
||||
|
||||
# No daemon to ask reads the same way: off is what the machine has been doing.
|
||||
reset_machine
|
||||
run_migration
|
||||
|
||||
grep -qx 'omarchy-bluetooth-power off' "$CALLS" ||
|
||||
fail "migration blocks when no adapter can be read" "$(cat "$CALLS")"
|
||||
pass "migration blocks when no adapter can be read"
|
||||
|
||||
# /dev/rfkill is only writable unelevated from an active graphical seat, so an
|
||||
# update run over SSH would abort here and abort again on every retry.
|
||||
grep -qx 'sudo omarchy-bluetooth-power off' "$CALLS" ||
|
||||
fail "migration changes the radio through sudo" "$(cat "$CALLS")"
|
||||
pass "migration changes the radio through sudo"
|
||||
|
||||
# A second account must not undo an administrator's later choice, since migration
|
||||
# completion is recorded per user.
|
||||
printf '[Policy]\nAutoEnable=false\n' >"$main_conf"
|
||||
POWERED=yes run_migration
|
||||
|
||||
grep -qx 'AutoEnable=false' "$main_conf" ||
|
||||
fail "migration leaves a later opt-out alone" "$(cat "$main_conf")"
|
||||
pass "migration leaves a later opt-out alone"
|
||||
|
||||
[[ ! -s $CALLS ]] ||
|
||||
fail "migration touches no radio state on a second run" "$(cat "$CALLS")"
|
||||
pass "migration touches no radio state on a second run"
|
||||
|
||||
# Only the exact line Omarchy wrote is reverted, so a hand-edited opt-out stands.
|
||||
reset_machine
|
||||
printf '[Policy]\nAutoEnable = false\n' >"$main_conf"
|
||||
POWERED=yes run_migration
|
||||
|
||||
grep -qx 'AutoEnable = false' "$main_conf" ||
|
||||
fail "migration keeps a hand-edited AutoEnable" "$(cat "$main_conf")"
|
||||
pass "migration keeps a hand-edited AutoEnable"
|
||||
@@ -71,6 +71,10 @@ jq -e --arg path "$ROOT/bin/omarchy-chromium-copy-url-host" '
|
||||
' "$native_manifest" >/dev/null || fail "copy-url native host manifest uses Omarchy host path and extension id"
|
||||
pass "copy-url native host installer registers the stable extension id"
|
||||
|
||||
[[ -f $test_home/.config/BraveSoftware/Brave-Origin/NativeMessagingHosts/com.omarchy.copy_url.json ]] ||
|
||||
fail "copy-url native host installer covers Brave Origin"
|
||||
pass "copy-url native host installer covers Brave Origin"
|
||||
|
||||
# Chromium ships in the base packages, so fresh installs do not go through
|
||||
# omarchy-install-browser, and they mark every migration as already applied.
|
||||
# The user install still has to register the host itself.
|
||||
|
||||
@@ -33,6 +33,10 @@ jq -e --arg path "$ROOT/bin/omarchy-chromium-ytdlp-host" '
|
||||
' "$manifest_path" >/dev/null
|
||||
pass "yt-dlp native host manifest uses Omarchy host path and extension id"
|
||||
|
||||
[[ -f $test_home/.config/BraveSoftware/Brave-Origin/NativeMessagingHosts/com.omarchy.ytdlp.json ]] ||
|
||||
fail "yt-dlp native host installer covers Brave Origin"
|
||||
pass "yt-dlp native host installer covers Brave Origin"
|
||||
|
||||
parse_result=$(bash -c '
|
||||
OMARCHY_PATH="$3"
|
||||
source "$1"
|
||||
|
||||
+10
-36
@@ -38,6 +38,15 @@ jq -e '
|
||||
' "$ROOT/config/omarchy/shell.json" >/dev/null
|
||||
pass "default clock date format has no leading zero"
|
||||
|
||||
jq -e '
|
||||
def ids: map(.id // .);
|
||||
(.bar.layout.right | ids) as $ids |
|
||||
($ids | index("omarchy.tray")) as $tray |
|
||||
($ids | index("omarchy.agents")) as $agents |
|
||||
$tray != null and $agents == $tray + 1
|
||||
' "$ROOT/config/omarchy/shell.json" >/dev/null
|
||||
pass "default right layout keeps agents next to the tray"
|
||||
|
||||
ROOT="$ROOT" python3 <<'PY'
|
||||
import json
|
||||
import os
|
||||
@@ -142,6 +151,7 @@ package_defaults = [
|
||||
("default/systemd/user/omarchy-fcitx5.service", "/usr/lib/systemd/user/omarchy-fcitx5.service", "systemd/user/omarchy-fcitx5.service"),
|
||||
("default/systemd/user/omarchy-crash-watch.service", "/usr/lib/systemd/user/omarchy-crash-watch.service", "systemd/user/omarchy-crash-watch.service"),
|
||||
("default/systemd/zram-generator.conf.d/90-omarchy.conf", "/usr/lib/systemd/zram-generator.conf.d/90-omarchy.conf", "systemd/zram-generator.conf.d/90-omarchy.conf"),
|
||||
("default/systemd/system/plocate-updatedb.service.d/10-omarchy.conf", "/usr/lib/systemd/system/plocate-updatedb.service.d/10-omarchy.conf", "systemd/system/plocate-updatedb.service.d/10-omarchy.conf"),
|
||||
("default/fonts/omarchy/omarchy.ttf", "/usr/share/fonts/omarchy/omarchy.ttf", "omarchy.ttf"),
|
||||
("default/snapper/root", "/etc/snapper/config-templates/omarchy", "snapper/root"),
|
||||
]
|
||||
@@ -418,39 +428,3 @@ if grep -RIl 'upgrade-to-quattro\|Omarchy 4\.0 is upgraded' "$ROOT/migrations" >
|
||||
fail "4.0 upgrade is not modeled as a migration"
|
||||
fi
|
||||
pass "4.0 upgrade is handled outside the migration runner"
|
||||
|
||||
clock_migration=$(grep -rl 'Remove leading zero from bar clock date' "$ROOT/migrations" | head -n 1 || true)
|
||||
[[ -n $clock_migration ]] || fail "clock date format user migration exists"
|
||||
|
||||
cat >"$TMPDIR/home/.config/omarchy/shell.json" <<'JSON'
|
||||
{
|
||||
"version": 1,
|
||||
"bar": {
|
||||
"layout": {
|
||||
"left": [],
|
||||
"center": [
|
||||
{ "id": "omarchy.clock", "formatAlt": "dd MMMM 'W'ww yyyy" },
|
||||
{ "id": "omarchy.weather" }
|
||||
],
|
||||
"right": [
|
||||
{ "id": "local.clock", "formatAlt": "dd MMMM 'W'ww yyyy" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"plugins": []
|
||||
}
|
||||
JSON
|
||||
|
||||
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" bash "$clock_migration"
|
||||
|
||||
jq -e '
|
||||
.bar.layout.center[0].formatAlt == "d MMMM \u0027W\u0027ww yyyy" and
|
||||
.bar.layout.right[0].formatAlt == "dd MMMM \u0027W\u0027ww yyyy"
|
||||
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
|
||||
pass "clock date format migration removes leading zero from clock"
|
||||
|
||||
before=$(sha256sum "$TMPDIR/home/.config/omarchy/shell.json" | awk '{print $1}')
|
||||
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" bash "$clock_migration"
|
||||
after=$(sha256sum "$TMPDIR/home/.config/omarchy/shell.json" | awk '{print $1}')
|
||||
[[ $before == "$after" ]] || fail "clock date format migration is idempotent"
|
||||
pass "clock date format migration is idempotent"
|
||||
@@ -19,6 +19,7 @@ mise_history="$test_tmp/mise-history"
|
||||
stub_log="$test_tmp/stubs"
|
||||
terminal_log="$test_tmp/terminal"
|
||||
menu_log="$test_tmp/menu"
|
||||
muse_login_log="$test_tmp/muse-login"
|
||||
mkdir -p "$mock_bin" "$test_home"
|
||||
|
||||
cat >"$mock_bin/omarchy-notification-send" <<'SH'
|
||||
@@ -69,6 +70,22 @@ cat >"$mock_bin/omarchy-menu" <<'SH'
|
||||
printf '%s\0' "$@" >"$OMARCHY_TEST_AGENT_MENU_LOG"
|
||||
SH
|
||||
|
||||
cat >"$mock_bin/omarchy-pkg-add" <<'SH'
|
||||
#!/bin/bash
|
||||
echo "Muse must install through mise" >&2
|
||||
exit 1
|
||||
SH
|
||||
ln -s omarchy-pkg-add "$mock_bin/omarchy-pkg-aur-add"
|
||||
|
||||
cat >"$mock_bin/muse" <<'SH'
|
||||
#!/bin/bash
|
||||
if [[ ${1:-} == "login" ]]; then
|
||||
printf 'muse %s\n' "$*" >>"$OMARCHY_TEST_MUSE_LOGIN_LOG"
|
||||
else
|
||||
printf '%s\0' muse "$@" >"$OMARCHY_TEST_AGENT_INLINE_LOG"
|
||||
fi
|
||||
SH
|
||||
|
||||
cat >"$mock_bin/omarchy-test-noop" <<'SH'
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
@@ -91,6 +108,7 @@ export OMARCHY_TEST_MISE_HISTORY="$mise_history"
|
||||
export OMARCHY_TEST_STUB_LOG="$stub_log"
|
||||
export OMARCHY_TEST_AGENT_TERMINAL_LOG="$terminal_log"
|
||||
export OMARCHY_TEST_AGENT_MENU_LOG="$menu_log"
|
||||
export OMARCHY_TEST_MUSE_LOGIN_LOG="$muse_login_log"
|
||||
export OMARCHY_PATH="$ROOT"
|
||||
|
||||
grok_package="npm:@xai-official/grok"
|
||||
@@ -98,6 +116,8 @@ omp_package="github:can1357/oh-my-pi"
|
||||
crush_package="crush"
|
||||
agy_package="antigravity-cli"
|
||||
ori_package="github:OpenRouterLabs/ori-releases"
|
||||
cursor_agent_package="cursor-agent"
|
||||
muse_package="http:muse[url=https://api.meta.ai/muse-launcher.sh,bin=muse,version_list_url=https://api.meta.ai/muse-code/channels/muse-stable,version_json_path=.version]"
|
||||
|
||||
assert_lazy_stub() {
|
||||
local package=$1
|
||||
@@ -116,16 +136,41 @@ assert_lazy_stub "$grok_package" grok
|
||||
assert_lazy_stub "$omp_package" omp
|
||||
assert_lazy_stub "$crush_package" crush
|
||||
assert_lazy_stub "$ori_package" ori
|
||||
assert_lazy_stub "$cursor_agent_package" cursor-agent
|
||||
assert_lazy_stub "$muse_package" muse
|
||||
pass "custom agent lazy stubs preserve their mise packages"
|
||||
|
||||
source "$ROOT/install/user/mise.sh"
|
||||
OMARCHY_TEST_MISSING_COMMAND=cursor-agent source "$ROOT/install/user/mise.sh"
|
||||
grep -Fx "$agy_package agy" "$stub_log" >/dev/null || fail "user setup creates the Antigravity lazy stub"
|
||||
grep -Fx "$grok_package grok" "$stub_log" >/dev/null || fail "user setup creates the Grok lazy stub"
|
||||
grep -Fx "$cursor_agent_package" "$stub_log" >/dev/null || fail "user setup creates the Cursor CLI lazy stub"
|
||||
grep -Fx "$omp_package omp" "$stub_log" >/dev/null || fail "user setup creates the Oh My Pi lazy stub"
|
||||
grep -Fx "$crush_package" "$stub_log" >/dev/null || fail "user setup creates the Crush lazy stub"
|
||||
grep -Fx "$ori_package ori" "$stub_log" >/dev/null || fail "user setup creates the Ori lazy stub"
|
||||
OMARCHY_TEST_MISSING_COMMAND=muse source "$ROOT/install/user/mise.sh"
|
||||
grep -Fx "$muse_package muse" "$stub_log" >/dev/null || fail "user setup creates the Muse lazy stub"
|
||||
pass "user setup creates the custom agent lazy stubs"
|
||||
|
||||
: >"$stub_log"
|
||||
source "$ROOT/install/user/mise.sh"
|
||||
grep -Fx "$cursor_agent_package" "$stub_log" >/dev/null && fail "user setup replaces an existing cursor-agent command"
|
||||
pass "user setup keeps an existing Cursor CLI install"
|
||||
grep -Fx "$muse_package muse" "$stub_log" >/dev/null && fail "user setup replaces an existing Muse command"
|
||||
|
||||
: >"$stub_log"
|
||||
OMARCHY_TEST_MISSING_COMMAND=muse source "$ROOT/migrations/1788724825.sh" >/dev/null
|
||||
grep -Fx "$muse_package muse" "$stub_log" >/dev/null || fail "Muse migration creates its lazy stub"
|
||||
: >"$stub_log"
|
||||
source "$ROOT/migrations/1788724825.sh" >/dev/null
|
||||
[[ ! -s $stub_log ]] || fail "Muse migration replaces an existing command"
|
||||
mkdir -p "$test_home/.local/state/omarchy"
|
||||
touch "$test_home/.local/state/omarchy/preinstalls-removed"
|
||||
OMARCHY_TEST_MISSING_COMMAND=muse source "$ROOT/migrations/1788724825.sh" >/dev/null
|
||||
[[ ! -s $stub_log ]] || fail "Muse migration ignores the preinstall opt-out"
|
||||
rm "$test_home/.local/state/omarchy/preinstalls-removed"
|
||||
pass "Muse migration preserves existing installs and the preinstall opt-out"
|
||||
|
||||
|
||||
: >"$stub_log"
|
||||
source "$ROOT/migrations/1785617047.sh" >/dev/null
|
||||
grep -Fx "$omp_package omp" "$stub_log" >/dev/null || fail "Oh My Pi migration creates a working lazy stub"
|
||||
@@ -134,6 +179,17 @@ grep -Fx "$omp_package omp" "$stub_log" >/dev/null || fail "Oh My Pi migration c
|
||||
source "$ROOT/migrations/1787342993.sh" >/dev/null
|
||||
grep -Fx "$ori_package ori" "$stub_log" >/dev/null || fail "Ori migration creates a working lazy stub"
|
||||
|
||||
: >"$stub_log"
|
||||
export OMARCHY_TEST_MISSING_COMMAND=cursor-agent
|
||||
source "$ROOT/migrations/1788577553.sh" >/dev/null
|
||||
unset OMARCHY_TEST_MISSING_COMMAND
|
||||
grep -Fx "$cursor_agent_package" "$stub_log" >/dev/null || fail "Cursor CLI migration creates a working lazy stub"
|
||||
|
||||
: >"$stub_log"
|
||||
source "$ROOT/migrations/1788577553.sh" >/dev/null
|
||||
[[ ! -s $stub_log ]] || fail "Cursor CLI migration reinstalls an existing cursor-agent command"
|
||||
pass "Cursor CLI migration preserves an existing Cursor CLI install"
|
||||
|
||||
: >"$stub_log"
|
||||
source "$ROOT/migrations/1785846769.sh" >/dev/null
|
||||
grep -Fx "$omp_package omp" "$stub_log" >/dev/null || fail "agent migration repairs the Oh My Pi lazy stub"
|
||||
@@ -216,6 +272,7 @@ touch "$test_home/.local/state/omarchy/preinstalls-removed"
|
||||
source "$ROOT/migrations/1785617047.sh" >/dev/null
|
||||
source "$ROOT/migrations/1785846769.sh" >/dev/null
|
||||
source "$ROOT/migrations/1787342993.sh" >/dev/null
|
||||
OMARCHY_TEST_MISSING_COMMAND=cursor-agent source "$ROOT/migrations/1788577553.sh" >/dev/null
|
||||
[[ ! -s $stub_log ]] || fail "agent migrations respect the preinstall opt-out"
|
||||
[[ ! -e $test_home/.local/bin/omp ]] || fail "agent migration removes the obsolete Oh My Pi wrapper after opt-out"
|
||||
|
||||
@@ -240,13 +297,30 @@ rm "$test_home/.local/state/omarchy/preinstalls-removed"
|
||||
rm -f "$agent_file"
|
||||
pass "agent migrations install working wrappers without overriding the preinstall opt-out"
|
||||
|
||||
"$ROOT/bin/omarchy-mise-install" "$muse_package" muse
|
||||
touch "$test_home/.local/bin/agy" "$test_home/.local/bin/ori"
|
||||
omarchy-remove-preinstalls >/dev/null
|
||||
for command in agy omp ori grok crush; do
|
||||
for command in agy omp ori grok crush cursor-agent muse; do
|
||||
[[ ! -e $test_home/.local/bin/$command ]] || fail "Remove Preinstalls deletes the $command lazy stub"
|
||||
done
|
||||
pass "Remove Preinstalls deletes every optional agent lazy stub"
|
||||
|
||||
# Cursor's installer links the same path, so anything but the mise wrapper is
|
||||
# the user's own install.
|
||||
touch "$test_home/.local/bin/cursor-agent.official"
|
||||
ln -s cursor-agent.official "$test_home/.local/bin/cursor-agent"
|
||||
omarchy-remove-preinstalls >/dev/null
|
||||
[[ -L $test_home/.local/bin/cursor-agent ]] || fail "Remove Preinstalls keeps an official Cursor CLI install"
|
||||
rm -f "$test_home/.local/bin/cursor-agent" "$test_home/.local/bin/cursor-agent.official"
|
||||
pass "Remove Preinstalls keeps an official Cursor CLI install"
|
||||
printf '#!/bin/bash\necho user-muse\n' >"$test_home/.local/bin/muse"
|
||||
chmod +x "$test_home/.local/bin/muse"
|
||||
omarchy-remove-preinstalls >/dev/null
|
||||
[[ $("$test_home/.local/bin/muse") == "user-muse" ]] || fail "Remove Preinstalls deletes a user-managed Muse"
|
||||
rm "$test_home/.local/bin/muse"
|
||||
pass "Remove Preinstalls keeps a user-managed Muse install"
|
||||
|
||||
|
||||
[[ -z $(omarchy-default-agent) ]] || fail "default agent is unset until one is chosen"
|
||||
pass "default agent is unset until one is chosen"
|
||||
|
||||
@@ -307,6 +381,11 @@ declare -A expected_agents=(
|
||||
[gemini-cli]="agy"
|
||||
[copilot]="copilot"
|
||||
[github-copilot]="copilot"
|
||||
[cursor]="cursor-agent"
|
||||
[cursor-agent]="cursor-agent"
|
||||
[muse]="muse"
|
||||
[muse-code]="muse"
|
||||
[musecode]="muse"
|
||||
)
|
||||
|
||||
declare -A expected_packages=(
|
||||
@@ -320,6 +399,8 @@ declare -A expected_packages=(
|
||||
[grok]="$grok_package"
|
||||
[agy]="$agy_package"
|
||||
[copilot]="copilot"
|
||||
[cursor-agent]="$cursor_agent_package"
|
||||
[muse]="$muse_package"
|
||||
)
|
||||
|
||||
for selection in "${!expected_agents[@]}"; do
|
||||
@@ -329,8 +410,12 @@ for selection in "${!expected_agents[@]}"; do
|
||||
[[ $(omarchy-default-agent) == $expected ]] || fail "default agent canonicalizes $selection"
|
||||
|
||||
mapfile -d '' -t mise_args <"$mise_log"
|
||||
[[ ${mise_args[0]} == "use" && ${mise_args[1]} == "-g" && ${mise_args[2]} == ${expected_packages[$expected]} ]] ||
|
||||
[[ ${mise_args[0]} == "use" && ${mise_args[1]} == "-g" ]] ||
|
||||
fail "default agent installs $selection globally through mise"
|
||||
case ${mise_args[2]} in
|
||||
"${expected_packages[$expected]}") ;;
|
||||
*) fail "default agent preserves $selection backend options" ;;
|
||||
esac
|
||||
|
||||
mapfile -d '' -t agent_open_args <"$agent_open_log"
|
||||
[[ ${#agent_open_args[@]} == 1 && ${agent_open_args[0]} == "omarchy-agent" ]] ||
|
||||
@@ -380,6 +465,35 @@ mapfile -d '' -t agent_open_args <"$agent_open_log"
|
||||
fail "installed agent opens in a new terminal after selection"
|
||||
pass "installed agents select and open without notifications"
|
||||
|
||||
# Cursor's installer links the wrapper's path, and the mise shims precede
|
||||
# ~/.local/bin, so a mise copy would shadow the user's own install.
|
||||
touch "$test_home/.local/bin/cursor-agent.official"
|
||||
chmod +x "$test_home/.local/bin/cursor-agent.official"
|
||||
ln -s cursor-agent.official "$test_home/.local/bin/cursor-agent"
|
||||
: >"$terminal_log"
|
||||
: >"$mise_log"
|
||||
: >"$agent_open_log"
|
||||
omarchy-default-agent cursor-agent
|
||||
[[ ! -s $terminal_log ]] || fail "an official Cursor CLI install needs no install terminal"
|
||||
[[ ! -s $mise_log ]] || fail "an official Cursor CLI install is left to itself by mise"
|
||||
[[ $(<"$agent_file") == "cursor-agent" ]] || fail "an official Cursor CLI install becomes the default"
|
||||
mapfile -d '' -t agent_open_args <"$agent_open_log"
|
||||
[[ ${#agent_open_args[@]} == 1 && ${agent_open_args[0]} == "omarchy-agent" ]] ||
|
||||
fail "an official Cursor CLI install opens after selection"
|
||||
rm -f "$test_home/.local/bin/cursor-agent" "$test_home/.local/bin/cursor-agent.official"
|
||||
printf '%s\n' copilot >"$agent_file"
|
||||
pass "selecting an official Cursor CLI install skips mise"
|
||||
|
||||
# A file nothing can run is not an install; the wrapper is still wanted.
|
||||
touch "$test_home/.local/bin/cursor-agent"
|
||||
: >"$terminal_log"
|
||||
omarchy-default-agent cursor-agent
|
||||
mapfile -d '' -t terminal_args <"$terminal_log"
|
||||
[[ ${terminal_args[*]} == "omarchy-default-agent --install cursor-agent" ]] ||
|
||||
fail "a dead file at the wrapper's path still installs Cursor CLI"
|
||||
rm -f "$test_home/.local/bin/cursor-agent"
|
||||
pass "a dead file at the wrapper's path does not pass for an install"
|
||||
|
||||
: >"$agent_open_log"
|
||||
if omarchy-default-agent unsupported >"$test_tmp/invalid-output" 2>&1; then
|
||||
fail "default agent rejects unsupported providers"
|
||||
@@ -414,6 +528,76 @@ grep -F "Could not set Codex as the default coding agent" "$test_tmp/setup-failu
|
||||
[[ ! -s $agent_open_log ]] || fail "failed activation does not open an agent"
|
||||
pass "default agent reports mise failures without notifications"
|
||||
|
||||
# Muse follows the shared mise installation and launch path.
|
||||
: >"$notification_history"
|
||||
: >"$agent_open_log"
|
||||
: >"$terminal_log"
|
||||
omarchy-default-agent muse
|
||||
mapfile -d '' -t terminal_args <"$terminal_log"
|
||||
[[ ${terminal_args[0]} == "omarchy-default-agent" && ${terminal_args[1]} == "--install" && ${terminal_args[2]} == "muse" ]] ||
|
||||
fail "missing Muse installation opens in a terminal"
|
||||
[[ ! -s $notification_history ]] || fail "missing Muse installation skips notifications"
|
||||
[[ ! -s $agent_open_log ]] || fail "missing Muse installation waits to open the agent"
|
||||
[[ $(omarchy-default-agent) == "copilot" ]] || fail "missing Muse installation waits to change the selection"
|
||||
|
||||
if OMARCHY_TEST_MISE_FAIL=true omarchy-default-agent --install muse >"$test_tmp/muse-install-failure-output" 2>&1; then
|
||||
fail "missing Muse rejects a failed mise installation"
|
||||
fi
|
||||
[[ $(omarchy-default-agent) == "copilot" ]] || fail "failed Muse installation preserves the current default"
|
||||
[[ ! -s $muse_login_log && ! -s $agent_open_log ]] || fail "failed Muse installation skips login and launch"
|
||||
grep -F "Could not install Muse Code with mise" "$test_tmp/muse-install-failure-output" >/dev/null ||
|
||||
fail "failed Muse installation identifies mise"
|
||||
pass "failed Muse mise installation preserves the selection and skips login"
|
||||
|
||||
: >"$mise_history"
|
||||
: >"$stub_log"
|
||||
omarchy-default-agent --install muse >"$test_tmp/muse-install-output"
|
||||
grep -Fx "use -g $muse_package" "$mise_history" >/dev/null || fail "visible Muse installation uses the HTTP backend"
|
||||
[[ ! -s $stub_log ]] || fail "Muse selection recreates its preinstalled wrapper"
|
||||
[[ ! -s $muse_login_log ]] || fail "Muse selection runs a separate login flow"
|
||||
[[ $(omarchy-default-agent) == "muse" ]] || fail "visible Muse installation changes the selection"
|
||||
mapfile -d '' -t agent_open_args <"$agent_open_log"
|
||||
[[ ${#agent_open_args[@]} == 2 && ${agent_open_args[0]} == "omarchy-agent" && ${agent_open_args[1]} == "--inline" ]] ||
|
||||
fail "newly installed Muse opens in the installation terminal"
|
||||
pass "Muse installs visibly through mise and opens directly"
|
||||
|
||||
: >"$terminal_log"
|
||||
: >"$muse_login_log"
|
||||
: >"$agent_open_log"
|
||||
OMARCHY_TEST_AGENT_INSTALLED=true omarchy-default-agent muse-code
|
||||
[[ ! -s $terminal_log ]] || fail "installed Muse selection skips the terminal"
|
||||
[[ ! -s $muse_login_log ]] || fail "installed Muse selection skips the login"
|
||||
[[ $(omarchy-default-agent) == "muse" ]] || fail "default agent canonicalizes muse-code"
|
||||
mapfile -d '' -t agent_open_args <"$agent_open_log"
|
||||
[[ ${#agent_open_args[@]} == 1 && ${agent_open_args[0]} == "omarchy-agent" ]] ||
|
||||
fail "installed Muse opens in a new terminal after selection"
|
||||
pass "installed Muse selects and opens directly"
|
||||
|
||||
OMARCHY_TEST_AGENT_INSTALLED=true omarchy-default-agent pi
|
||||
: >"$agent_open_log"
|
||||
if OMARCHY_TEST_AGENT_INSTALLED=true OMARCHY_TEST_MISE_FAIL=true omarchy-default-agent musecode >"$test_tmp/muse-failure-output" 2>&1; then
|
||||
fail "default agent rejects a failed Muse activation"
|
||||
fi
|
||||
[[ $(omarchy-default-agent) == "pi" ]] || fail "failed Muse activation preserves the current default agent"
|
||||
grep -F "Could not set Muse Code as the default coding agent" "$test_tmp/muse-failure-output" >/dev/null ||
|
||||
fail "default agent reports a failed Muse activation"
|
||||
[[ ! -s $agent_open_log ]] || fail "failed Muse activation does not open an agent"
|
||||
pass "default agent reports Muse mise failures without changing the selection"
|
||||
|
||||
# A manually installed launcher belongs to the user; selecting it must not
|
||||
# install a second copy or replace it with the Omarchy wrapper.
|
||||
printf '#!/bin/bash\necho user-muse\n' >"$test_home/.local/bin/muse"
|
||||
chmod +x "$test_home/.local/bin/muse"
|
||||
: >"$mise_history"
|
||||
: >"$stub_log"
|
||||
: >"$terminal_log"
|
||||
omarchy-default-agent muse
|
||||
[[ $(omarchy-default-agent) == "muse" ]] || fail "a user-installed Muse can be selected"
|
||||
[[ ! -s $mise_history && ! -s $stub_log && ! -s $terminal_log ]] || fail "a user-installed Muse skips installation and wrapper creation"
|
||||
[[ $("$test_home/.local/bin/muse") == "user-muse" ]] || fail "a user-installed Muse is preserved"
|
||||
rm "$test_home/.local/bin/muse"
|
||||
pass "selecting a user-installed Muse preserves its launcher"
|
||||
|
||||
rm "$mock_bin/omarchy-agent"
|
||||
hash -r
|
||||
|
||||
@@ -431,8 +615,10 @@ assert_launched() {
|
||||
fail "$agent launch $description" "expected: ${expected[*]}\nactual: ${actual[*]}"
|
||||
|
||||
for ((index = 0; index < ${#expected[@]}; index++)); do
|
||||
[[ ${actual[$index]} == ${expected[$index]} ]] ||
|
||||
fail "$agent launch $description" "expected: ${expected[*]}\nactual: ${actual[*]}"
|
||||
case ${actual[$index]} in
|
||||
"${expected[$index]}") ;;
|
||||
*) fail "$agent launch $description" "expected: ${expected[*]}\nactual: ${actual[*]}" ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
@@ -460,20 +646,39 @@ assert_launch opencode opencode --auto --prompt "Review this project"
|
||||
assert_launch ori ori code --interactive --prompt "Review this project"
|
||||
assert_launch claude claude --permission-mode auto -- "Review this project"
|
||||
assert_launch codex codex --approve-for-me -- "Review this project"
|
||||
assert_launch muse muse --approval-mode never -- "Review this project"
|
||||
assert_launch crush crush run "Review this project"
|
||||
assert_launch grok grok --permission-mode bypassPermissions -- "Review this project"
|
||||
assert_launch cursor-agent cursor-agent --yolo --trust agent -- "Review this project"
|
||||
assert_launch hermes env -u HERMES_SESSION_SOURCE hermes chat --yolo --tui "--query=Review this project"
|
||||
assert_launch agy agy --dangerously-skip-permissions --prompt-interactive "Review this project"
|
||||
assert_launch copilot copilot --allow-all --interactive "Review this project"
|
||||
pass "agent launcher adapts initial prompts for every supported agent"
|
||||
|
||||
literal_muse_prompt=$'--disable-sandbox !Crash {$(touch must-not-run)}\ntrailing\\ '
|
||||
printf '%s\n' "muse" >"$agent_file"
|
||||
omarchy-agent-prompt "$literal_muse_prompt"
|
||||
assert_launched muse "separates prompt text from options" muse --approval-mode never -- "$literal_muse_prompt"
|
||||
pass "Muse receives option-like prompts as one literal argument"
|
||||
|
||||
literal_hermes_prompt=$' --help !Crash /quit {$(touch must-not-run)}\ntrailing\\ '
|
||||
printf '%s\n' "hermes" >"$agent_file"
|
||||
omarchy-agent-prompt "$literal_hermes_prompt"
|
||||
assert_launched hermes "binds its literal initial prompt" env -u HERMES_SESSION_SOURCE \
|
||||
hermes chat --yolo --tui "--query=$literal_hermes_prompt"
|
||||
pass "Hermes receives prompted launches as one literal query argument"
|
||||
|
||||
assert_bypass pi pi
|
||||
assert_bypass omp omp --auto-approve
|
||||
assert_bypass opencode opencode --auto
|
||||
assert_bypass ori ori code
|
||||
assert_bypass claude claude --permission-mode auto
|
||||
assert_bypass codex codex --approve-for-me
|
||||
assert_bypass muse muse --approval-mode never
|
||||
assert_bypass crush crush --yolo
|
||||
assert_bypass grok grok --permission-mode bypassPermissions
|
||||
assert_bypass cursor-agent cursor-agent --yolo --trust
|
||||
assert_bypass hermes hermes --yolo
|
||||
assert_bypass agy agy --dangerously-skip-permissions
|
||||
assert_bypass copilot copilot --allow-all
|
||||
pass "agent launcher skips permission prompts for every supported agent"
|
||||
@@ -531,3 +736,68 @@ fi
|
||||
grep -F "missing is not installed" "$test_tmp/missing-output" >/dev/null ||
|
||||
fail "agent launcher explains when the default command is missing"
|
||||
pass "agent launcher reports a missing default command"
|
||||
|
||||
# OpenClaw comes from its pacman package, not mise: choosing it must route
|
||||
# through omarchy-install-openclaw-cli and never touch a mise environment.
|
||||
cat >"$mock_bin/omarchy-pkg-present" <<'SH'
|
||||
#!/bin/bash
|
||||
[[ $1 == openclaw && ${OMARCHY_TEST_OPENCLAW_INSTALLED:-false} == "true" ]]
|
||||
SH
|
||||
cat >"$mock_bin/omarchy-pkg-add" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "pkg-add $*" >>"$OMARCHY_TEST_STUB_LOG"
|
||||
SH
|
||||
cat >"$mock_bin/omarchy-launch-openclaw" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\0' omarchy-launch-openclaw "$@" >"$OMARCHY_TEST_AGENT_INLINE_LOG"
|
||||
SH
|
||||
cat >"$mock_bin/openclaw" <<'SH'
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
SH
|
||||
chmod +x "$mock_bin/omarchy-pkg-present" "$mock_bin/omarchy-pkg-add" \
|
||||
"$mock_bin/omarchy-launch-openclaw" "$mock_bin/openclaw"
|
||||
|
||||
: >"$launch_log"
|
||||
: >"$terminal_log"
|
||||
: >"$mise_history"
|
||||
OMARCHY_TEST_OPENCLAW_INSTALLED=true omarchy-default-agent openclaw
|
||||
read -r chosen <"$agent_file"
|
||||
[[ $chosen == openclaw ]] || fail "choosing OpenClaw records it as the default agent"
|
||||
mapfile -d '' -t launch_args <"$launch_log"
|
||||
[[ ${launch_args[*]} == "--app-id=org.omarchy.agent omarchy-launch-openclaw --tui" ]] ||
|
||||
fail "choosing OpenClaw launches its terminal UI"
|
||||
[[ ! -s $terminal_log ]] || fail "an installed OpenClaw needs no install terminal"
|
||||
! grep -q 'use -g openclaw' "$mise_history" || fail "OpenClaw never installs through mise"
|
||||
pass "choosing OpenClaw uses the package and launches its terminal UI"
|
||||
|
||||
: >"$terminal_log"
|
||||
OMARCHY_TEST_OPENCLAW_INSTALLED=false omarchy-default-agent openclaw
|
||||
mapfile -d '' -t terminal_args <"$terminal_log"
|
||||
[[ ${terminal_args[*]} == "omarchy-default-agent --install openclaw" ]] ||
|
||||
fail "a missing OpenClaw routes through the install terminal"
|
||||
pass "a missing OpenClaw routes through the install terminal"
|
||||
|
||||
: >"$stub_log"
|
||||
: >"$inline_log"
|
||||
OMARCHY_TEST_OPENCLAW_INSTALLED=false omarchy-default-agent --install openclaw >/dev/null
|
||||
grep -Fx "pkg-add openclaw" "$stub_log" >/dev/null ||
|
||||
fail "installing OpenClaw as default agent adds its package"
|
||||
mapfile -d '' -t inline_args <"$inline_log"
|
||||
[[ ${inline_args[*]} == "omarchy-launch-openclaw --tui" ]] ||
|
||||
fail "installing OpenClaw as default agent hands over to its terminal UI"
|
||||
pass "installing OpenClaw as default agent adds its package"
|
||||
|
||||
: >"$launch_log"
|
||||
omarchy agent prompt "Review this project"
|
||||
mapfile -d '' -t launch_args <"$launch_log"
|
||||
# Element-wise: the prompt must travel as one argv entry, which a space-joined
|
||||
# comparison could not tell apart from a prompt split into words.
|
||||
[[ ${#launch_args[@]} == 5 &&
|
||||
${launch_args[0]} == "--app-id=org.omarchy.agent" &&
|
||||
${launch_args[1]} == "omarchy-launch-openclaw" &&
|
||||
${launch_args[2]} == "--tui" &&
|
||||
${launch_args[3]} == "--message" &&
|
||||
${launch_args[4]} == "Review this project" ]] ||
|
||||
fail "OpenClaw receives prompts through --message" "argv: ${launch_args[*]}"
|
||||
pass "OpenClaw receives prompts through --message"
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# The fingerprint driver migration only repairs a machine an earlier version of
|
||||
# it left with fprintd and no libfprint; any installed driver is left alone.
|
||||
# The real package helpers run over a stubbed pacman.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
migration="$ROOT/migrations/1785090473.sh"
|
||||
scratch=$(mktemp -d)
|
||||
trap 'rm -rf "$scratch"' EXIT
|
||||
mkdir -p "$scratch/bin"
|
||||
export CALL_LOG="$scratch/calls"
|
||||
export PATH="$scratch/bin:$ROOT/bin:$PATH"
|
||||
|
||||
cat > "$scratch/bin/sudo" <<'STUB'
|
||||
#!/bin/bash
|
||||
exec "$@"
|
||||
STUB
|
||||
# INSTALLED lists the installed package names, one per line; an install adds
|
||||
# its packages to INSTALLED_LOG so omarchy-pkg-add's follow-up query sees them.
|
||||
cat > "$scratch/bin/pacman" <<'STUB'
|
||||
#!/bin/bash
|
||||
case "$1" in
|
||||
-Q) grep -qx "$2" <<< "${INSTALLED:-}" || grep -qx "$2" "$INSTALLED_LOG" ;;
|
||||
-S)
|
||||
printf 'pacman %s\n' "$*" >> "$CALL_LOG"
|
||||
for arg in "$@"; do
|
||||
[[ $arg == -* ]] || printf '%s\n' "$arg" >> "$INSTALLED_LOG"
|
||||
done
|
||||
;;
|
||||
*) printf 'pacman %s\n' "$*" >> "$CALL_LOG" ;;
|
||||
esac
|
||||
STUB
|
||||
chmod +x "$scratch/bin/"*
|
||||
export INSTALLED_LOG="$scratch/installed"
|
||||
|
||||
run_migration() {
|
||||
: > "$CALL_LOG"
|
||||
: > "$INSTALLED_LOG"
|
||||
bash -euo pipefail "$migration" > /dev/null
|
||||
}
|
||||
|
||||
INSTALLED='fprintd' run_migration
|
||||
grep -qx 'pacman -S --noconfirm --needed libfprint-git' "$CALL_LOG" || fail "fprintd without a library gets libfprint-git"
|
||||
pass "fprintd without a library gets libfprint-git"
|
||||
|
||||
INSTALLED=$'libfprint-git\nfprintd' run_migration
|
||||
[[ ! -s $CALL_LOG ]] || fail "an installed libfprint-git is left alone" "$(<"$CALL_LOG")"
|
||||
pass "an installed libfprint-git is left alone"
|
||||
|
||||
INSTALLED=$'libfprint\nfprintd' run_migration
|
||||
[[ ! -s $CALL_LOG ]] || fail "an installed stock libfprint is left alone" "$(<"$CALL_LOG")"
|
||||
pass "an installed stock libfprint is left alone"
|
||||
|
||||
INSTALLED='' run_migration
|
||||
[[ ! -s $CALL_LOG ]] || fail "a machine without fprintd is left alone" "$(<"$CALL_LOG")"
|
||||
pass "a machine without fprintd is left alone"
|
||||
Executable
+93
@@ -0,0 +1,93 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# The fingerprint setup installs libfprint-git in place of stock libfprint. The
|
||||
# two conflict, so the swap has to happen inside one --ask 4 transaction, and a
|
||||
# rerun with everything installed must not touch pacman at all. The real
|
||||
# omarchy-pkg-missing runs; pacman and the privileged calls are stubbed.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
scratch=$(mktemp -d)
|
||||
trap 'rm -rf "$scratch"' EXIT
|
||||
mkdir -p "$scratch/bin"
|
||||
export CALL_LOG="$scratch/calls"
|
||||
export PATH="$scratch/bin:$ROOT/bin:$PATH"
|
||||
|
||||
cat > "$scratch/bin/omarchy-hw-fingerprint" <<'STUB'
|
||||
#!/bin/bash
|
||||
exit "${HARDWARE_STATUS:-0}"
|
||||
STUB
|
||||
cat > "$scratch/bin/sudo" <<'STUB'
|
||||
#!/bin/bash
|
||||
case "$1" in
|
||||
pacman | fprintd-enroll) exec "$@" ;;
|
||||
*) echo "Unexpected privileged call: $*" >> "$CALL_LOG"; exit 99 ;;
|
||||
esac
|
||||
STUB
|
||||
# INSTALLED lists the installed package names, one per line.
|
||||
cat > "$scratch/bin/pacman" <<'STUB'
|
||||
#!/bin/bash
|
||||
case "$1" in
|
||||
-Q) grep -qx "$2" <<< "${INSTALLED:-}" ;;
|
||||
-S)
|
||||
printf 'pacman %s\n' "$*" >> "$CALL_LOG"
|
||||
exit "${INSTALL_STATUS:-0}"
|
||||
;;
|
||||
*) printf 'pacman %s\n' "$*" >> "$CALL_LOG"; exit 99 ;;
|
||||
esac
|
||||
STUB
|
||||
cat > "$scratch/bin/fprintd-enroll" <<'STUB'
|
||||
#!/bin/bash
|
||||
# Stop before verification/PAM; no host authentication files may be changed.
|
||||
echo enroll >> "$CALL_LOG"
|
||||
exit 1
|
||||
STUB
|
||||
cat > "$scratch/bin/fprintd-verify" <<'STUB'
|
||||
#!/bin/bash
|
||||
echo verify >> "$CALL_LOG"
|
||||
exit 1
|
||||
STUB
|
||||
chmod +x "$scratch/bin/"*
|
||||
|
||||
run_setup() {
|
||||
: > "$CALL_LOG"
|
||||
if "$ROOT/bin/omarchy-setup-security-fingerprint" > "$scratch/output" 2>&1; then
|
||||
fail "setup stops on the simulated enrollment or installation failure"
|
||||
fi
|
||||
if grep -q 'Unexpected privileged call' "$CALL_LOG"; then
|
||||
fail "setup does not change PAM after failed enrollment"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_installs() {
|
||||
grep -qx 'pacman -S --needed --noconfirm --ask 4 libfprint-git fprintd usbutils' "$CALL_LOG" || fail "$1"
|
||||
(( $(grep -c '^pacman ' "$CALL_LOG") == 1 )) || fail "$1: one pacman transaction"
|
||||
}
|
||||
|
||||
run_setup
|
||||
assert_installs "a fresh machine installs libfprint-git, fprintd and usbutils"
|
||||
grep -qx enroll "$CALL_LOG" || fail "installation is followed by enrollment"
|
||||
pass "a fresh machine installs libfprint-git and reaches enrollment"
|
||||
|
||||
INSTALLED=$'libfprint\nfprintd\nusbutils' run_setup
|
||||
assert_installs "installed stock libfprint is replaced in the same transaction"
|
||||
pass "installed stock libfprint is replaced without a removal step"
|
||||
|
||||
INSTALLED=$'libfprint-git\nfprintd\nusbutils' run_setup
|
||||
if grep -q '^pacman' "$CALL_LOG"; then
|
||||
fail "a rerun with everything installed does not touch pacman"
|
||||
fi
|
||||
grep -qx enroll "$CALL_LOG" || fail "a rerun with everything installed reaches enrollment"
|
||||
pass "a rerun with everything installed goes straight to enrollment"
|
||||
|
||||
INSTALL_STATUS=1 run_setup
|
||||
if grep -qx enroll "$CALL_LOG"; then
|
||||
fail "a failed package transaction prevents enrollment"
|
||||
fi
|
||||
pass "a failed installation stops before enrollment"
|
||||
|
||||
HARDWARE_STATUS=1 run_setup
|
||||
[[ ! -s $CALL_LOG ]] || fail "missing hardware stops before package operations"
|
||||
pass "missing hardware performs no package operations"
|
||||
@@ -0,0 +1,58 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from kitty.config import load_config
|
||||
from kitty.options.utils import parse_map
|
||||
|
||||
root = Path(os.environ['ROOT'])
|
||||
system = root / 'etc/xdg/kitty/kitty.conf'
|
||||
template = root / 'config/kitty/kitty.conf'
|
||||
legacy = root / 'test/shell.d/fixtures/kitty/legacy.conf'
|
||||
active_lines = [line for line in template.read_text().splitlines() if line and not line.startswith('#')]
|
||||
assert active_lines == ['include ~/.local/state/omarchy/current/theme/kitty.conf']
|
||||
|
||||
with TemporaryDirectory() as tmp:
|
||||
user = Path(tmp) / 'kitty.conf'
|
||||
# Use a local theme to avoid depending on the developer's generated state.
|
||||
theme = Path(tmp) / 'theme.conf'
|
||||
theme.write_text('background #123456\n')
|
||||
themed = f'include {theme}\n'
|
||||
user.write_text(themed)
|
||||
errors = []
|
||||
opts = load_config(str(system), str(user), accumulate_bad_lines=errors)
|
||||
assert not errors, errors
|
||||
assert opts.allow_remote_control == 'socket-only'
|
||||
assert opts.listen_on == 'unix:${XDG_RUNTIME_DIR}/omarchy-kitty-{kitty_pid}'
|
||||
|
||||
old = Path(tmp) / 'legacy.conf'
|
||||
old.write_text(legacy.read_text().replace(active_lines[0], themed.strip()))
|
||||
before = load_config(str(old), accumulate_bad_lines=errors)
|
||||
# Moving defaults must preserve appearance and behavior except remote control.
|
||||
for key in ('font_family', 'bold_italic_font', 'font_size', 'window_padding_width',
|
||||
'hide_window_decorations', 'confirm_os_window_close', 'cursor_shape',
|
||||
'cursor_blink_interval', 'shell_integration', 'enable_audio_bell',
|
||||
'tab_bar_edge', 'tab_bar_style', 'tab_powerline_style',
|
||||
'tab_title_template', 'background'):
|
||||
assert getattr(opts, key) == getattr(before, key), key
|
||||
|
||||
def binding(options, shortcut):
|
||||
trigger = next(parse_map(shortcut)).trigger
|
||||
return [entry.definition for entry in options.keyboard_modes[''].keymap.get(trigger, [])]
|
||||
|
||||
for shortcut in ('ctrl+insert', 'shift+insert', 'shift+enter', 'alt+shift+enter'):
|
||||
assert binding(opts, shortcut) == binding(before, shortcut), shortcut
|
||||
|
||||
user.write_text(themed + 'font_size 15\nmap ctrl+insert\nmap shift+insert copy_to_clipboard\n')
|
||||
opts = load_config(str(system), str(user), accumulate_bad_lines=errors)
|
||||
assert opts.font_size == 15
|
||||
assert not any(binding(opts, 'ctrl+insert'))
|
||||
assert binding(opts, 'shift+insert')[-1] == 'copy_to_clipboard'
|
||||
|
||||
user.write_text(themed + 'clear_all_shortcuts yes\nmap f1 new_window\n')
|
||||
opts = load_config(str(system), str(user), accumulate_bad_lines=errors)
|
||||
assert len(opts.keyboard_modes[''].keymap) == 1
|
||||
assert binding(opts, 'f1') == ['new_window']
|
||||
assert not errors, errors
|
||||
|
||||
print('ok - Kitty loads defaults and theme, preserves appearance, and supports user overrides and unmapping')
|
||||
@@ -0,0 +1,35 @@
|
||||
include ~/.local/state/omarchy/current/theme/kitty.conf
|
||||
|
||||
# Font
|
||||
font_family JetBrainsMono Nerd Font
|
||||
bold_italic_font auto
|
||||
font_size 9.0
|
||||
|
||||
# Window
|
||||
window_padding_width 14
|
||||
hide_window_decorations yes
|
||||
confirm_os_window_close 0
|
||||
|
||||
# Keybindings
|
||||
map ctrl+insert copy_to_clipboard
|
||||
map shift+insert paste_from_clipboard
|
||||
# Send Shift+Enter as CSI-u so TUIs can distinguish it from Enter.
|
||||
map shift+enter send_text all \e[13;2u
|
||||
# Kitty legacy encoding sends Alt+Shift+Enter the same as Alt+Enter; send CSI-u so tmux can match M-S-Enter.
|
||||
map alt+shift+enter send_text all \e[13;4u
|
||||
|
||||
# Allow remote access
|
||||
allow_remote_control yes
|
||||
listen_on unix:${XDG_RUNTIME_DIR}/omarchy-kitty-{kitty_pid}
|
||||
|
||||
# Aesthetics
|
||||
cursor_shape block
|
||||
cursor_blink_interval 0
|
||||
shell_integration no-cursor
|
||||
enable_audio_bell no
|
||||
|
||||
# Minimal Tab bar styling
|
||||
tab_bar_edge bottom
|
||||
tab_bar_style powerline
|
||||
tab_powerline_style slanted
|
||||
tab_title_template {title}{' :{}:'.format(num_windows) if num_windows > 1 else ''}
|
||||
@@ -0,0 +1,31 @@
|
||||
pragma Singleton
|
||||
import QtQuick
|
||||
import Quickshell.Networking
|
||||
|
||||
QtObject {
|
||||
property int backend: NetworkBackendType.NetworkManager
|
||||
property bool wifiEnabled: true
|
||||
property bool canCheckConnectivity: true
|
||||
property bool connectivityCheckEnabled: true
|
||||
property int connectivity: NetworkConnectivity.Full
|
||||
property int checks: 0
|
||||
function checkConnectivity() { checks++ }
|
||||
|
||||
property var devices: ({ values: [wifi] })
|
||||
property QtObject wifi: QtObject {
|
||||
property int type: DeviceType.Wifi
|
||||
property string name: "test-wifi"
|
||||
property bool connected: true
|
||||
property bool scannerEnabled: false
|
||||
property var networks: ({ values: [network] })
|
||||
}
|
||||
property QtObject network: QtObject {
|
||||
property string name: "Guest Wi-Fi"
|
||||
property bool connected: true
|
||||
property bool known: true
|
||||
property bool stateChanging: false
|
||||
property real signalStrength: 0.8
|
||||
property int security: WifiSecurityType.Open
|
||||
signal connectionFailed(int reason)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
singleton NetworkMock 1.0 NetworkMock.qml
|
||||
@@ -0,0 +1,161 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Networking
|
||||
import qs.Commons
|
||||
import "mocks"
|
||||
import "network" as Network
|
||||
|
||||
ShellRoot {
|
||||
id: test
|
||||
property bool failed: false
|
||||
function check(ok, message) {
|
||||
if (!ok) {
|
||||
failed = true
|
||||
console.log("RESULT fail " + message)
|
||||
}
|
||||
}
|
||||
|
||||
// Not visible in the normal test run. The optional preview maps the real
|
||||
// KeyboardPanel for a screenshot, without ever altering the host network.
|
||||
Item {
|
||||
Network.Panel {
|
||||
id: panel
|
||||
bar: QtObject {
|
||||
property color foreground: Color.foreground
|
||||
property color barForeground: Color.foreground
|
||||
property color urgent: Color.urgent
|
||||
property string fontFamily: Style.font.family
|
||||
property string position: "top"
|
||||
property int barSize: 24
|
||||
property bool vertical: false
|
||||
property bool foregroundAnimationEnabled: false
|
||||
property var activePopout: null
|
||||
function requestPopout(owner) { activePopout = owner }
|
||||
function releasePopout(owner) { activePopout = null }
|
||||
function registerClickTarget(target) {}
|
||||
function unregisterClickTarget(target) {}
|
||||
function hideTooltip(target) {}
|
||||
function showTooltip(target, text) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 250
|
||||
running: true
|
||||
onTriggered: {
|
||||
test.check(panel.kind === "wifi", "connected Wi-Fi fixture")
|
||||
test.check(panel.connectivity === "full", "normal connectivity")
|
||||
test.check(!panel.testButton.visible && !panel.testBarButton.active, "no false portal banner")
|
||||
test.check(!panel.testPoll.running, "normal connectivity adds no polling")
|
||||
test.check(NetworkMock.checks > 0, "checks at connection/startup")
|
||||
var before = NetworkMock.checks
|
||||
panel.checkConnectivity()
|
||||
test.check(NetworkMock.checks === before + 1, "manual check delegates to NM")
|
||||
NetworkMock.connectivity = NetworkConnectivity.Portal
|
||||
Qt.callLater(portalChecks)
|
||||
}
|
||||
}
|
||||
|
||||
function portalChecks() {
|
||||
check(panel.hasCaptivePortal && panel.restricted, "native portal activates restricted mode")
|
||||
check(panel.testButton.visible, "portal button visible")
|
||||
check(panel.testButton.text === "Open Captive Portal", "prominent action label")
|
||||
check(panel.icon === "" && panel.testBarButton.active, "blocked bar icon and warning color")
|
||||
check(panel.testMeta.text === "SIGN-IN REQUIRED", "status replaces cheerful connection phrase")
|
||||
check(panel.testTitle.text === "Guest Wi-Fi", "connected SSID survives missing route details")
|
||||
check(panel.testPoll.running && panel.testPoll.interval === 10000, "restricted recheck runs while closed")
|
||||
var before = NetworkMock.checks
|
||||
panel.testPoll.triggered()
|
||||
check(NetworkMock.checks === before + 1, "background timer rechecks through NM")
|
||||
panel.testKeys.textKey("r")
|
||||
check(NetworkMock.checks === before + 2, "r requests fresh connectivity")
|
||||
// Exercise the existing cursor model, not a separate test-only action.
|
||||
panel.cursorActive = true
|
||||
panel.focusSection = "header"
|
||||
panel.testKeys.moveRequested(0, 1)
|
||||
check(panel.focusSection === "portal", "down from header reaches portal")
|
||||
panel.testKeys.moveRequested(0, 1)
|
||||
check(panel.focusSection === "dns", "down from portal skips absent band")
|
||||
panel.testKeys.moveRequested(0, -1)
|
||||
check(panel.focusSection === "portal", "up from DNS reaches portal")
|
||||
panel.bandAvailable = ["2.4", "5"]
|
||||
panel.testKeys.moveRequested(0, 1)
|
||||
check(panel.focusSection === "band", "down from portal reaches available band")
|
||||
panel.testKeys.moveRequested(0, -1)
|
||||
check(panel.focusSection === "portal", "up from band reaches portal")
|
||||
panel.testKeys.activateRequested()
|
||||
NetworkMock.connectivity = NetworkConnectivity.Full
|
||||
Qt.callLater(recoveryChecks)
|
||||
}
|
||||
|
||||
function recoveryChecks() {
|
||||
check(!panel.hasCaptivePortal && !panel.restricted, "login recovery clears restriction")
|
||||
check(!panel.testPoll.running, "recovery stops extra checks")
|
||||
check(!panel.testButton.visible && !panel.testBarButton.active, "recovery hides button and warning color")
|
||||
check(panel.focusSection === "header", "disappearing button leaves valid cursor")
|
||||
check(panel.icon !== "", "signal icon returns")
|
||||
// No browser launch when the portal is gone (runner asserts one launch).
|
||||
panel.openCaptivePortal()
|
||||
NetworkMock.connectivity = NetworkConnectivity.Limited
|
||||
Qt.callLater(limitedChecks)
|
||||
}
|
||||
|
||||
function limitedChecks() {
|
||||
check(panel.restricted && !panel.hasCaptivePortal, "outage is not mislabelled as a portal")
|
||||
check(!panel.testButton.visible && panel.testMeta.text === "LIMITED INTERNET ACCESS", "limited state has no login button")
|
||||
NetworkMock.connectivity = NetworkConnectivity.Portal
|
||||
NetworkMock.connectivityCheckEnabled = false
|
||||
Qt.callLater(disabledChecks)
|
||||
}
|
||||
|
||||
function disabledChecks() {
|
||||
check(!panel.hasCaptivePortal && panel.connectivity === "unknown", "disabled checks ignore cached portal")
|
||||
check(!panel.testPoll.running, "disabled checks stop polling")
|
||||
var before = NetworkMock.checks
|
||||
panel.checkConnectivity()
|
||||
check(NetworkMock.checks === before, "does not enable or invoke disabled checks")
|
||||
NetworkMock.connectivityCheckEnabled = true
|
||||
NetworkMock.network.connected = false
|
||||
NetworkMock.wifi.connected = false
|
||||
Qt.callLater(disconnectedChecks)
|
||||
}
|
||||
|
||||
function disconnectedChecks() {
|
||||
check(panel.kind === "disconnected" && !panel.hasCaptivePortal, "disconnect clears stale portal")
|
||||
check(!panel.testButton.visible && panel.icon === "", "disconnected icon not portal icon")
|
||||
if (failed) { Qt.quit(); return }
|
||||
console.log("RESULT pass")
|
||||
var preview = Quickshell.env("NETWORK_TEST_PREVIEW")
|
||||
if (preview === "portal" || preview === "full") {
|
||||
NetworkMock.network.connected = true
|
||||
NetworkMock.wifi.connected = true
|
||||
NetworkMock.connectivity = preview === "portal" ? NetworkConnectivity.Portal : NetworkConnectivity.Full
|
||||
panel.open()
|
||||
previewCapture.start()
|
||||
previewDone.start()
|
||||
} else {
|
||||
// Give the detached, stubbed browser command time to append its argv.
|
||||
done.start()
|
||||
}
|
||||
}
|
||||
|
||||
// Optional fresh, panel-only captures. Rendering the card itself excludes
|
||||
// the host desktop, and the network details above come only from fixtures.
|
||||
// NETWORK_TEST_PREVIEW=portal (or full), NETWORK_TEST_SCREENSHOT=/tmp/new.png
|
||||
Timer {
|
||||
id: previewCapture
|
||||
interval: 750
|
||||
onTriggered: {
|
||||
var path = Quickshell.env("NETWORK_TEST_SCREENSHOT")
|
||||
if (!path) return
|
||||
var card = panel.testKeys.parent.parent
|
||||
card.grabToImage(function(result) {
|
||||
test.check(result.saveToFile(path), "save fresh preview screenshot")
|
||||
Qt.quit()
|
||||
})
|
||||
}
|
||||
}
|
||||
Timer { id: done; interval: 300; onTriggered: Qt.quit() }
|
||||
Timer { id: previewDone; interval: 15000; onTriggered: Qt.quit() }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import QtQuick
|
||||
import "services/AuthServiceStore.js" as AuthServiceStore
|
||||
|
||||
QtObject {
|
||||
function retain(id, service) {
|
||||
AuthServiceStore.put(id, service)
|
||||
}
|
||||
|
||||
function has(id) {
|
||||
return AuthServiceStore.has(id)
|
||||
}
|
||||
|
||||
function isTrusted(id) {
|
||||
return AuthServiceStore.isTrusted(id)
|
||||
}
|
||||
|
||||
function updateManifest(id, manifest) {
|
||||
AuthServiceStore.updateManifest(id, manifest)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import QtQuick
|
||||
import "services/AuthServiceStore.js" as AuthServiceStore
|
||||
|
||||
QtObject {
|
||||
function has(id) {
|
||||
return AuthServiceStore.has(id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import "services"
|
||||
|
||||
ShellRoot {
|
||||
id: root
|
||||
|
||||
property var calls: []
|
||||
property QtObject ownService: QtObject {
|
||||
property string marker: "own"
|
||||
property var manifest: null
|
||||
}
|
||||
|
||||
AuthStoreOwner { id: authStoreOwner }
|
||||
AuthStoreReader { id: authStoreReader }
|
||||
|
||||
Component {
|
||||
id: apiComponent
|
||||
PluginShellApi { }
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: resultFile
|
||||
path: Quickshell.env("OMARCHY_QML_TEST_RESULT")
|
||||
atomicWrites: true
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
var caller = "example.safe"
|
||||
authStoreOwner.retain("omarchy.lock", root.ownService)
|
||||
authStoreOwner.updateManifest("omarchy.lock", { version: "kept" })
|
||||
var api = apiComponent.createObject(null, {
|
||||
pluginId: caller,
|
||||
idleConfig: { screensaver: 60, lock: 120 },
|
||||
_serviceLookup: function(requestedId) {
|
||||
return requestedId === caller ? root.ownService : null
|
||||
},
|
||||
_summon: function(requestedId) {
|
||||
if (requestedId !== caller) return false
|
||||
root.calls = root.calls.concat(["summon"])
|
||||
return true
|
||||
},
|
||||
_hide: function(requestedId) {
|
||||
if (requestedId !== caller) return false
|
||||
root.calls = root.calls.concat(["hide"])
|
||||
return true
|
||||
},
|
||||
_toggle: function(requestedId) {
|
||||
if (requestedId !== caller) return false
|
||||
root.calls = root.calls.concat(["toggle"])
|
||||
return true
|
||||
},
|
||||
_isOpen: function(requestedId) { return requestedId === caller },
|
||||
_updateSettings: function(requestedId) {
|
||||
if (requestedId !== caller) return false
|
||||
root.calls = root.calls.concat(["settings"])
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
var own = api.serviceFor(caller)
|
||||
var result = {
|
||||
detached: api.parent === undefined || api.parent === null,
|
||||
ownService: own && own.marker === "own",
|
||||
foreignService: api.serviceFor("omarchy.lock") === null,
|
||||
firstPartyService: api.firstPartyServiceFor("omarchy.polkit") === null,
|
||||
ownSummon: api.summon(caller, "{}") === true,
|
||||
foreignSummon: api.summon("omarchy.lock", "{}") === false,
|
||||
ownHide: api.hide(caller) === true,
|
||||
foreignHide: api.hide("omarchy.lock") === false,
|
||||
ownToggle: api.toggle(caller, "{}") === true,
|
||||
foreignToggle: api.toggle("omarchy.lock", "{}") === false,
|
||||
ownOpen: api.isPluginOpen(caller) === true,
|
||||
foreignOpen: api.isPluginOpen("omarchy.lock") === false,
|
||||
ownSettings: api.updateEntryInline(caller, {}) === true,
|
||||
foreignSettings: api.updateEntryInline("omarchy.lock", {}) === false,
|
||||
detachedIdleConfig: api.idleConfig.screensaver === 60 && api.idleConfig.lock === 120,
|
||||
authStoreOwnerRetains: authStoreOwner.has("omarchy.lock") === true,
|
||||
authStoreOwnerRemembersTrust: authStoreOwner.isTrusted("omarchy.lock") === true,
|
||||
authStoreOwnerUpdatesManifest: root.ownService.manifest
|
||||
&& root.ownService.manifest.version === "kept",
|
||||
authStoreImportIsolated: authStoreReader.has("omarchy.lock") === false,
|
||||
noGenericPluginShellFactory: typeof api.pluginShellForId !== "function",
|
||||
calls: root.calls
|
||||
}
|
||||
result.ok = Object.keys(result).every(function(key) {
|
||||
return key === "ok" || key === "calls" || result[key] === true
|
||||
}) && JSON.stringify(result.calls) === JSON.stringify(["summon", "hide", "toggle", "settings"])
|
||||
resultFile.setText(JSON.stringify(result))
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,9 @@ ShellRoot {
|
||||
scan += block("firstparty", "/first/bar", manifest("omarchy.bar", ["bar"], { bar: "Bar.qml" }))
|
||||
scan += block("firstparty", "/first/panels/grouped", manifest("omarchy.grouped-panel", ["panel"], { panel: "Panel.qml" }))
|
||||
scan += block("firstparty", "/first/hybrid", manifest("omarchy.hybrid", ["menu", "bar-widget"], { menu: "Menu.qml", barWidget: "Widget.qml" }))
|
||||
var futureAuth = manifest("omarchy.future-auth", ["service"], { service: "Service.qml" })
|
||||
futureAuth.omarchy = { capabilities: ["authentication"] }
|
||||
scan += block("firstparty", "/first/future-auth", futureAuth)
|
||||
scan += block("thirdparty", "/third/panel", manifest("third.panel", ["panel"], { panel: "Panel.qml" }))
|
||||
scan += block("thirdparty", "/third/widget", manifest("third.widget", ["bar-widget"], { barWidget: "Widget.qml" }, { defaultSection: "left" }))
|
||||
scan += block("thirdparty", "/third/center-widget", manifest("third.center-widget", ["bar-widget"], { barWidget: "Widget.qml" }))
|
||||
@@ -103,6 +106,12 @@ ShellRoot {
|
||||
localBar.omarchy = { clonedFrom: "omarchy.bar" }
|
||||
scan += block("thirdparty", "/third/local-bar", localBar)
|
||||
scan += block("thirdparty", "/third/bar", manifest("third.bar", ["bar"], { bar: "Bar.qml" }))
|
||||
var localFutureAuth = manifest("local.future-auth", ["service"], { service: "Service.qml" })
|
||||
localFutureAuth.omarchy = { clonedFrom: "omarchy.future-auth" }
|
||||
scan += block("thirdparty", "/third/local-future-auth", localFutureAuth)
|
||||
var spoofedAuth = manifest("third.spoofed-auth", ["service"], { service: "Service.qml" })
|
||||
spoofedAuth.omarchy = { capabilities: ["authentication"] }
|
||||
scan += block("thirdparty", "/third/spoofed-auth", spoofedAuth)
|
||||
scan += block("thirdparty", "/third/shadow", manifest("omarchy.first-widget", ["panel"], { panel: "Panel.qml" }))
|
||||
scan += block("thirdparty", "/third/reserved", manifest("omarchy.reserved", ["panel"], { panel: "Panel.qml" }))
|
||||
scan += block("thirdparty", "/third/unsafe", manifest("third.unsafe", ["panel"], { panel: "../Panel.qml" }))
|
||||
@@ -116,22 +125,28 @@ ShellRoot {
|
||||
root.assertDeepEqual(pluginIds(), [
|
||||
"local.bar",
|
||||
"local.first-widget",
|
||||
"local.future-auth",
|
||||
"local.grouped-panel",
|
||||
"local.hybrid",
|
||||
"local.weather",
|
||||
"omarchy.bar",
|
||||
"omarchy.first-widget",
|
||||
"omarchy.future-auth",
|
||||
"omarchy.grouped-panel",
|
||||
"omarchy.hybrid",
|
||||
"third.bar",
|
||||
"third.center-widget",
|
||||
"third.panel",
|
||||
"third.right-widget",
|
||||
"third.spoofed-auth",
|
||||
"third.widget"
|
||||
], "registry merges valid first-party and third-party manifests")
|
||||
|
||||
root.assertTrue(registry.installedPlugins["omarchy.first-widget"].__isFirstParty === true, "first-party manifests are stamped")
|
||||
root.assertTrue(registry.installedPlugins["third.panel"].__isFirstParty === false, "third-party manifests are stamped")
|
||||
root.assertDeepEqual(registry.installedPlugins["omarchy.future-auth"].__hostCapabilities, ["authentication"], "trusted manifests stamp authentication capability")
|
||||
root.assertDeepEqual(registry.installedPlugins["local.future-auth"].__hostCapabilities, ["authentication"], "clones inherit trusted host capabilities")
|
||||
root.assertDeepEqual(registry.installedPlugins["third.spoofed-auth"].__hostCapabilities, [], "third-party manifests cannot self-grant host capabilities")
|
||||
root.assertEqual(registry.installedPlugins["omarchy.grouped-panel"].__sourceDir, "/first/panels/grouped", "grouped plugin source paths are preserved")
|
||||
root.assertEqual(registry.entryPointUrl(registry.installedPlugins["third.panel"], "panel"), "file:///third/panel/Panel.qml", "entryPointUrl resolves plugin-relative paths")
|
||||
root.assertEqual(registry.entryPointUrl(registry.installedPlugins["third.widget"], "barWidget"), "file:///third/widget/Widget.qml", "entryPointUrl resolves bar widget paths")
|
||||
|
||||
Executable
+133
@@ -0,0 +1,133 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
migration="$ROOT/migrations/1787760281.sh"
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
mock_bin="$test_tmp/bin"
|
||||
test_home="$test_tmp/home"
|
||||
hermes="$test_home/.local/bin/hermes"
|
||||
marker="# Written by omarchy-install-hermes-cli."
|
||||
mkdir -p "$mock_bin" "$test_home/.local/bin" "$test_home/.local/state/omarchy"
|
||||
|
||||
cat >"$mock_bin/omarchy-pkg-present" <<'SH'
|
||||
#!/bin/bash
|
||||
[[ ${OMARCHY_TEST_DESKTOP_INSTALLED:-0} == 1 ]]
|
||||
SH
|
||||
|
||||
cat >"$mock_bin/omarchy-cmd-missing" <<'SH'
|
||||
#!/bin/bash
|
||||
! command -v "$1" >/dev/null 2>&1
|
||||
SH
|
||||
|
||||
mise_log="$test_tmp/mise-log"
|
||||
cat >"$mock_bin/mise" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\0' "$@" >>"$OMARCHY_TEST_MISE_LOG"
|
||||
[[ $1 != "where" ]]
|
||||
SH
|
||||
|
||||
chmod +x "$mock_bin"/*
|
||||
|
||||
# The real installer is on PATH so the migration writes today's stub, not a
|
||||
# copy of it.
|
||||
run_migration() {
|
||||
OMARCHY_TEST_DESKTOP_INSTALLED="${1:-0}" \
|
||||
OMARCHY_TEST_MISE_LOG="$mise_log" \
|
||||
HOME="$test_home" \
|
||||
PATH="$mock_bin:$ROOT/bin:$PATH" \
|
||||
bash -euo pipefail "$migration" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
run_migration || fail "the migration installs the wrapper on a plain install"
|
||||
[[ -x $hermes ]] && grep -qxF "$marker" "$hermes" || fail "the migration writes the Omarchy wrapper"
|
||||
pass "the migration installs the Hermes wrapper"
|
||||
|
||||
before=$(cat "$hermes")
|
||||
run_migration || fail "rerunning the migration succeeds"
|
||||
[[ $(cat "$hermes") == "$before" ]] || fail "rerunning the migration leaves the same wrapper"
|
||||
pass "the migration is idempotent"
|
||||
|
||||
chmod -x "$hermes"
|
||||
run_migration || fail "the migration repairs a non-executable Omarchy wrapper"
|
||||
[[ -x $hermes ]] && grep -qxF "$marker" "$hermes" ||
|
||||
fail "the migration restores a non-executable Omarchy wrapper"
|
||||
pass "the migration repairs a non-executable Omarchy wrapper"
|
||||
|
||||
rm -f "$hermes"
|
||||
touch "$test_home/.local/state/omarchy/preinstalls-removed"
|
||||
run_migration || fail "the migration succeeds for users who removed the preinstalls"
|
||||
[[ ! -e $hermes ]] || fail "the migration respects the preinstalls opt-out"
|
||||
pass "the migration skips users who removed the preinstalls"
|
||||
rm -f "$test_home/.local/state/omarchy/preinstalls-removed"
|
||||
|
||||
run_migration 1 || fail "the migration succeeds when Hermes Desktop owns Hermes"
|
||||
[[ ! -e $hermes ]] || fail "the migration writes nothing when Hermes Desktop owns Hermes"
|
||||
pass "the migration stands aside for Hermes Desktop"
|
||||
|
||||
# Standing aside is not the same as leaving a second Hermes behind: the wrapper
|
||||
# an earlier install wrote and the mise copy it points at both go when the
|
||||
# desktop app owns Hermes, even though the app has not finished setting up.
|
||||
printf '%s\n' "#!/bin/bash" "$marker" >"$hermes"
|
||||
chmod +x "$hermes"
|
||||
: >"$mise_log"
|
||||
run_migration 1 || fail "the migration succeeds when Hermes Desktop owns Hermes and the old wrapper is present"
|
||||
[[ ! -e $hermes ]] || fail "the migration removes the Omarchy wrapper when Hermes Desktop owns Hermes"
|
||||
mise_calls=$(tr '\0' ' ' <"$mise_log")
|
||||
[[ $mise_calls == *"rm -g "* ]] || fail "the migration removes the global mise Hermes for Hermes Desktop"
|
||||
[[ $mise_calls == *"uninstall --all "* ]] || fail "the migration uninstalls the mise Hermes for Hermes Desktop"
|
||||
pass "the migration clears the old Omarchy Hermes for Hermes Desktop"
|
||||
|
||||
# ...while anyone else's hermes stays exactly where it is, and is not run.
|
||||
foreign_ran="$test_tmp/foreign-ran"
|
||||
foreign_body="#!/bin/bash
|
||||
touch $foreign_ran
|
||||
exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\""
|
||||
printf '%s\n' "$foreign_body" >"$hermes"
|
||||
chmod +x "$hermes"
|
||||
run_migration 1 || fail "the migration succeeds over a foreign hermes when Hermes Desktop owns Hermes"
|
||||
[[ -x $hermes && $(cat "$hermes") == "$foreign_body" ]] ||
|
||||
fail "the migration leaves a foreign hermes alone when Hermes Desktop owns Hermes"
|
||||
[[ ! -e $foreign_ran ]] || fail "the migration does not run a foreign hermes"
|
||||
pass "the migration preserves a foreign hermes for Hermes Desktop"
|
||||
rm -f "$hermes"
|
||||
|
||||
official_body="#!/bin/bash
|
||||
unset PYTHONPATH
|
||||
unset PYTHONHOME
|
||||
exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\""
|
||||
printf '%s\n' "$official_body" >"$hermes"
|
||||
chmod +x "$hermes"
|
||||
run_migration || fail "the migration succeeds over a foreign hermes command"
|
||||
[[ $(cat "$hermes") == "$official_body" ]] || fail "the migration leaves a foreign hermes command alone"
|
||||
pass "the migration preserves a foreign hermes command"
|
||||
|
||||
chmod -x "$hermes"
|
||||
run_migration || fail "the migration succeeds over a non-executable foreign hermes"
|
||||
[[ -f $hermes && ! -x $hermes && $(cat "$hermes") == "$official_body" ]] ||
|
||||
fail "the migration leaves a non-executable foreign hermes alone"
|
||||
pass "the migration preserves a non-executable foreign hermes"
|
||||
|
||||
rm -f "$hermes"
|
||||
ln -s "$test_home/nowhere/hermes" "$hermes"
|
||||
run_migration || fail "the migration succeeds over a dangling hermes link"
|
||||
[[ -L $hermes && $(readlink "$hermes") == "$test_home/nowhere/hermes" ]] ||
|
||||
fail "the migration leaves a dangling hermes link alone"
|
||||
pass "the migration preserves a dangling hermes link"
|
||||
|
||||
rm -f "$hermes"
|
||||
mkdir "$hermes"
|
||||
run_migration || fail "the migration succeeds over a directory at the hermes path"
|
||||
[[ -d $hermes ]] || fail "the migration leaves a directory at the hermes path alone"
|
||||
pass "the migration preserves a directory at the hermes path"
|
||||
|
||||
rmdir "$hermes"
|
||||
printf '%s\n' "#!/bin/bash" "# Replaces the stub omarchy-install-hermes-cli used to write." >"$hermes"
|
||||
chmod +x "$hermes"
|
||||
run_migration || fail "the migration succeeds over a wrapper that mentions the installer"
|
||||
grep -qxF "$marker" "$hermes" && fail "the migration does not rewrite a wrapper that merely mentions the installer"
|
||||
pass "the migration preserves a wrapper that merely mentions the installer"
|
||||
Executable
+576
@@ -0,0 +1,576 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
mock_bin="$test_tmp/bin"
|
||||
test_home="$test_tmp/home"
|
||||
mise_log="$test_tmp/mise-log"
|
||||
mkdir -p "$mock_bin" "$test_home/.local/bin"
|
||||
|
||||
cat >"$mock_bin/omarchy-pkg-present" <<'SH'
|
||||
#!/bin/bash
|
||||
[[ ${OMARCHY_TEST_DESKTOP_INSTALLED:-0} == 1 ]]
|
||||
SH
|
||||
|
||||
cat >"$mock_bin/omarchy-cmd-missing" <<'SH'
|
||||
#!/bin/bash
|
||||
! command -v "$1" >/dev/null 2>&1
|
||||
SH
|
||||
|
||||
# `mise where` must fail so the installer sees no Hermes behind the stub.
|
||||
#
|
||||
# With OMARCHY_TEST_MISE_X_HERMES=1, `mise x -- hermes ...` emulates the Hermes
|
||||
# the Omarchy stub runs, so the readiness probe can be exercised through a
|
||||
# mise-installed hermes and not only the foreign and desktop wrappers. Off by
|
||||
# default, so `mise x` stays silent for every test that does not opt in.
|
||||
cat >"$mock_bin/mise" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\0' "$@" >>"$OMARCHY_TEST_MISE_LOG"
|
||||
if [[ $1 == "where" && ${OMARCHY_TEST_MISE_WHERE_OK:-0} == 1 ]]; then
|
||||
printf '%s\n' "$OMARCHY_TEST_MISE_ROOT"
|
||||
exit 0
|
||||
fi
|
||||
if [[ $1 == "x" && ${OMARCHY_TEST_MISE_X_HERMES:-0} == 1 ]]; then
|
||||
# Args are `x <tool> -- hermes <hermes-args...>`; skip to what follows hermes.
|
||||
shift
|
||||
while (( $# )) && [[ $1 != "--" ]]; do shift; done
|
||||
shift 2
|
||||
if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then
|
||||
[[ ${OMARCHY_TEST_HERMES_CAPABLE:-1} == 1 ]] && echo "[-q QUERY, --query QUERY] [--tui]"
|
||||
else
|
||||
echo "hermes-agent 0.0.0-test"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
[[ $1 != "where" ]]
|
||||
SH
|
||||
|
||||
chmod +x "$mock_bin"/*
|
||||
|
||||
run_installer() {
|
||||
OMARCHY_TEST_DESKTOP_INSTALLED="$1" \
|
||||
OMARCHY_TEST_MISE_WHERE_OK="${OMARCHY_TEST_MISE_WHERE_OK:-0}" \
|
||||
OMARCHY_TEST_MISE_ROOT="$test_tmp/mise" \
|
||||
OMARCHY_TEST_MISE_LOG="$mise_log" \
|
||||
HOME="$test_home" \
|
||||
PATH="$mock_bin:$PATH" \
|
||||
bash "$ROOT/bin/omarchy-install-hermes-cli" ${2:+"$2"} >/dev/null 2>&1
|
||||
}
|
||||
|
||||
stub_marker="# Written by omarchy-install-hermes-cli."
|
||||
python_pin="3.13"
|
||||
app_stub_body='#!/bin/bash
|
||||
exec /home/x/.hermes/hermes-agent/venv/bin/hermes "$@"'
|
||||
|
||||
# Writing the stub must not provision anything: user setup calls this on every
|
||||
# machine, including the ones that never run Hermes.
|
||||
: >"$mise_log"
|
||||
rm -f "$test_home/.local/bin/hermes"
|
||||
run_installer 0 || fail "installer failed with no desktop installed"
|
||||
[[ -x $test_home/.local/bin/hermes ]] || fail "installer writes a hermes stub when the desktop is absent"
|
||||
grep -qxF "$stub_marker" "$test_home/.local/bin/hermes" || fail "the stub records which command wrote it"
|
||||
tr '\0' ' ' <"$mise_log" | grep -q "use -g --quiet uv" &&
|
||||
fail "writing the stub does not install uv"
|
||||
pass "writing the Hermes stub provisions nothing"
|
||||
|
||||
# The desktop app owns Hermes, so our own stub must go rather than sit there
|
||||
# answering `hermes` until the app's bootstrap replaces it.
|
||||
printf '%s\n' "#!/bin/bash" "$stub_marker" >"$test_home/.local/bin/hermes"
|
||||
chmod +x "$test_home/.local/bin/hermes"
|
||||
run_installer 1 || true
|
||||
[[ ! -e $test_home/.local/bin/hermes ]] ||
|
||||
fail "the desktop taking over removes the stub this command wrote"
|
||||
pass "installing the desktop app removes the CLI stub"
|
||||
|
||||
# ...but the app's own hermes is not ours to delete.
|
||||
printf '%s\n' "$app_stub_body" >"$test_home/.local/bin/hermes"
|
||||
chmod +x "$test_home/.local/bin/hermes"
|
||||
run_installer 1 || true
|
||||
[[ -x $test_home/.local/bin/hermes ]] ||
|
||||
fail "the desktop app's own hermes command survives"
|
||||
pass "the app's own hermes command is left alone"
|
||||
|
||||
# A copy mise cannot vouch for is still a second Hermes.
|
||||
printf '%s\n' "#!/bin/bash" "$stub_marker" >"$test_home/.local/bin/hermes"
|
||||
chmod +x "$test_home/.local/bin/hermes"
|
||||
: >"$mise_log"
|
||||
OMARCHY_TEST_MISE_WHERE_OK=1 run_installer 1 || true
|
||||
tr '\0' '\n' <"$mise_log" | grep -q "uninstall" ||
|
||||
fail "takeover removes a mise copy even when it is not healthy"
|
||||
pass "takeover removes an unhealthy mise copy"
|
||||
|
||||
# --check answers about Hermes being usable, not about the venv appearing. The
|
||||
# venv exists from the python-deps stage, several stages before the command.
|
||||
rm -rf "$test_home/.hermes"
|
||||
rm -f "$test_home/.local/bin/hermes"
|
||||
run_installer 1 --check && fail "--check reports Hermes missing before the app installs it"
|
||||
# The venv command answers the readiness probes, as the real one does: foreign
|
||||
# wrappers below exec it, and the installer runs both before trusting them.
|
||||
mkdir -p "$test_home/.hermes/hermes-agent/venv/bin"
|
||||
cat >"$test_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH'
|
||||
#!/bin/bash
|
||||
if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then
|
||||
[[ ${OMARCHY_TEST_HERMES_CAPABLE:-1} == 1 ]] && echo "[-q QUERY, --query QUERY] [--tui]"
|
||||
else
|
||||
echo "hermes-agent 0.0.0-test"
|
||||
fi
|
||||
SH
|
||||
chmod +x "$test_home/.hermes/hermes-agent/venv/bin/hermes"
|
||||
run_installer 1 --check && fail "--check waits for the install to finish, not just the venv"
|
||||
touch "$test_home/.hermes/hermes-agent/.hermes-bootstrap-complete"
|
||||
printf '%s\n' "#!/bin/bash" "exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" >"$test_home/.local/bin/hermes"
|
||||
chmod +x "$test_home/.local/bin/hermes"
|
||||
run_installer 1 --check || fail "--check reports Hermes present once the app has finished"
|
||||
pass "--check follows the app's completed install"
|
||||
|
||||
# An executable called hermes that belongs to something else is not this
|
||||
# install being ready.
|
||||
printf '%s\n' "#!/bin/bash" "exec /usr/local/bin/somebody-elses-hermes \"\$@\"" >"$test_home/.local/bin/hermes"
|
||||
chmod +x "$test_home/.local/bin/hermes"
|
||||
run_installer 1 --check && fail "--check rejects a hermes command belonging to something else"
|
||||
pass "--check rejects a foreign hermes command"
|
||||
|
||||
# A hermes the user installed themselves -- the official installer, a wrapper of
|
||||
# their own -- is not ours to replace. --check follows whether it runs, and
|
||||
# installing steps aside so the default agent uses it.
|
||||
official_body="#!/bin/bash
|
||||
unset PYTHONPATH
|
||||
unset PYTHONHOME
|
||||
exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\""
|
||||
printf '%s\n' "$official_body" >"$test_home/.local/bin/hermes"
|
||||
chmod +x "$test_home/.local/bin/hermes"
|
||||
run_installer 0 --check || fail "--check accepts a working foreign hermes command"
|
||||
run_installer 0 || fail "installing over a foreign hermes command returns success"
|
||||
run_installer 0 --now || fail "--now over a foreign hermes command returns success"
|
||||
[[ $(cat "$test_home/.local/bin/hermes") == "$official_body" ]] ||
|
||||
fail "a foreign hermes command is left untouched"
|
||||
pass "a foreign hermes command is preserved and satisfies --check"
|
||||
|
||||
OMARCHY_TEST_HERMES_CAPABLE=0 run_installer 0 --check &&
|
||||
fail "--check rejects a foreign Hermes without native prompted sessions"
|
||||
OMARCHY_TEST_HERMES_CAPABLE=0 run_installer 0 &&
|
||||
fail "installing refuses a foreign Hermes without native prompted sessions"
|
||||
[[ $(cat "$test_home/.local/bin/hermes") == "$official_body" ]] ||
|
||||
fail "an older foreign Hermes command is left untouched"
|
||||
pass "a foreign Hermes must support native prompted sessions"
|
||||
|
||||
# Broken foreign paths are still foreign. They cannot be used, so --check says
|
||||
# so and the installer refuses rather than replacing them.
|
||||
printf '%s\n' "$official_body" >"$test_home/.local/bin/hermes"
|
||||
chmod -x "$test_home/.local/bin/hermes"
|
||||
run_installer 0 --check && fail "--check rejects a non-executable foreign hermes"
|
||||
run_installer 0 && fail "the installer does not succeed over a non-executable foreign hermes"
|
||||
[[ -f $test_home/.local/bin/hermes && ! -x $test_home/.local/bin/hermes ]] ||
|
||||
fail "a non-executable foreign hermes is left untouched"
|
||||
pass "a non-executable foreign hermes is preserved"
|
||||
|
||||
# The executable bit is not enough: a wrapper whose interpreter is gone passes
|
||||
# -x and still cannot run. The probe has to run it to find out, and finding
|
||||
# out never touches the file.
|
||||
broken_interp_body="#!$test_home/nowhere/python3
|
||||
print('hermes')"
|
||||
printf '%s\n' "$broken_interp_body" >"$test_home/.local/bin/hermes"
|
||||
chmod +x "$test_home/.local/bin/hermes"
|
||||
run_installer 0 --check && fail "--check rejects a foreign hermes whose interpreter is missing"
|
||||
run_installer 0 && fail "the installer does not succeed over a foreign hermes whose interpreter is missing"
|
||||
run_installer 0 --now && fail "--now does not succeed over a foreign hermes whose interpreter is missing"
|
||||
[[ -x $test_home/.local/bin/hermes && $(cat "$test_home/.local/bin/hermes") == "$broken_interp_body" ]] ||
|
||||
fail "a foreign hermes whose interpreter is missing is left untouched"
|
||||
pass "a foreign hermes with a missing interpreter is preserved and rejected"
|
||||
|
||||
# Likewise a wrapper that execs a target that is no longer there.
|
||||
broken_target_body="#!/bin/bash
|
||||
exec $test_home/nowhere/hermes \"\$@\""
|
||||
printf '%s\n' "$broken_target_body" >"$test_home/.local/bin/hermes"
|
||||
chmod +x "$test_home/.local/bin/hermes"
|
||||
run_installer 0 --check && fail "--check rejects a foreign hermes whose target is missing"
|
||||
run_installer 0 && fail "the installer does not succeed over a foreign hermes whose target is missing"
|
||||
run_installer 0 --now && fail "--now does not succeed over a foreign hermes whose target is missing"
|
||||
[[ -x $test_home/.local/bin/hermes && $(cat "$test_home/.local/bin/hermes") == "$broken_target_body" ]] ||
|
||||
fail "a foreign hermes whose target is missing is left untouched"
|
||||
pass "a foreign hermes with a missing target is preserved and rejected"
|
||||
|
||||
foreign_target="$test_home/foreign/hermes"
|
||||
mkdir -p "$(dirname "$foreign_target")"
|
||||
printf '%s\n' "$official_body" >"$foreign_target"
|
||||
chmod +x "$foreign_target"
|
||||
rm -f "$test_home/.local/bin/hermes"
|
||||
ln -s "$foreign_target" "$test_home/.local/bin/hermes"
|
||||
run_installer 0 --check || fail "--check accepts a foreign link to a working hermes command"
|
||||
run_installer 0 || fail "the installer succeeds over a foreign link to a working hermes command"
|
||||
run_installer 0 --now || fail "--now succeeds over a foreign link to a working hermes command"
|
||||
[[ -L $test_home/.local/bin/hermes && $(readlink "$test_home/.local/bin/hermes") == "$foreign_target" ]] ||
|
||||
fail "a foreign link to a working hermes command is left untouched"
|
||||
pass "a foreign link to a working hermes command is preserved"
|
||||
|
||||
rm -f "$test_home/.local/bin/hermes"
|
||||
ln -s "$test_home/nowhere/hermes" "$test_home/.local/bin/hermes"
|
||||
run_installer 0 --check && fail "--check rejects a dangling hermes link"
|
||||
run_installer 0 && fail "the installer does not succeed over a dangling hermes link"
|
||||
[[ -L $test_home/.local/bin/hermes && $(readlink "$test_home/.local/bin/hermes") == "$test_home/nowhere/hermes" ]] ||
|
||||
fail "a dangling hermes link is left untouched"
|
||||
pass "a dangling hermes link is preserved"
|
||||
|
||||
# A directory passes -x on search permission alone. It is still not a command.
|
||||
rm -f "$test_home/.local/bin/hermes"
|
||||
mkdir "$test_home/.local/bin/hermes"
|
||||
run_installer 0 --check && fail "--check rejects a directory at the hermes path"
|
||||
run_installer 0 && fail "the installer does not succeed over a directory at the hermes path"
|
||||
[[ -d $test_home/.local/bin/hermes ]] || fail "a directory at the hermes path is left untouched"
|
||||
pass "a directory at the hermes path is preserved and rejected"
|
||||
|
||||
# Mentioning the installer is not the same as being written by it.
|
||||
rmdir "$test_home/.local/bin/hermes"
|
||||
mentions_body="#!/bin/bash
|
||||
# Replaces the stub omarchy-install-hermes-cli used to write.
|
||||
exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\""
|
||||
printf '%s\n' "$mentions_body" >"$test_home/.local/bin/hermes"
|
||||
chmod +x "$test_home/.local/bin/hermes"
|
||||
run_installer 0 || fail "installing over a wrapper that mentions the installer returns success"
|
||||
[[ $(cat "$test_home/.local/bin/hermes") == "$mentions_body" ]] ||
|
||||
fail "a wrapper that merely mentions the installer is left untouched"
|
||||
pass "ownership needs the exact marker line, not a mention"
|
||||
|
||||
# Our own stub is ours to rewrite, so reinstalling refreshes it to the current
|
||||
# template.
|
||||
rm -f "$test_home/.local/bin/hermes"
|
||||
printf '%s\n' "#!/bin/bash" "$stub_marker" "# stale template" >"$test_home/.local/bin/hermes"
|
||||
chmod +x "$test_home/.local/bin/hermes"
|
||||
run_installer 0 || fail "reinstalling over our own stub succeeds"
|
||||
grep -qxF "$stub_marker" "$test_home/.local/bin/hermes" || fail "the refreshed stub still carries the marker"
|
||||
grep -q "stale template" "$test_home/.local/bin/hermes" && fail "reinstalling rewrites our own stub"
|
||||
grep -q "exec env -u UV_PYTHON mise x" "$test_home/.local/bin/hermes" || fail "the refreshed stub is the current template"
|
||||
pass "reinstalling refreshes the Omarchy stub"
|
||||
|
||||
mkdir -p "$test_tmp/mise/hermes-agent/lib/python$python_pin"
|
||||
: >"$mise_log"
|
||||
OMARCHY_TEST_MISE_WHERE_OK=1 run_installer 0 || fail "reinstalling replaces an older owned Hermes environment"
|
||||
tr '\0' '\n' <"$mise_log" | grep -q '^rm$' || fail "an older owned Hermes environment is removed from mise config"
|
||||
tr '\0' '\n' <"$mise_log" | grep -q '^uninstall$' || fail "an older owned Hermes environment is uninstalled"
|
||||
pass "reinstalling replaces an older owned Hermes environment"
|
||||
|
||||
# The mise-installed path is what a machine without the desktop app runs, and
|
||||
# --check gates the default agent there too. The stub is present and its mise
|
||||
# environment resolves, so readiness turns on the hermes mise runs -- exercised
|
||||
# here in both directions, since the desktop and foreign cases cover only their
|
||||
# own wrappers.
|
||||
run_mise_check() {
|
||||
OMARCHY_TEST_DESKTOP_INSTALLED=0 \
|
||||
OMARCHY_TEST_MISE_WHERE_OK=1 \
|
||||
OMARCHY_TEST_MISE_ROOT="$test_tmp/mise" \
|
||||
OMARCHY_TEST_MISE_LOG="$mise_log" \
|
||||
OMARCHY_TEST_MISE_X_HERMES=1 \
|
||||
OMARCHY_TEST_HERMES_CAPABLE="$1" \
|
||||
HOME="$test_home" \
|
||||
PATH="$mock_bin:$PATH" \
|
||||
bash "$ROOT/bin/omarchy-install-hermes-cli" --check >/dev/null 2>&1
|
||||
}
|
||||
run_mise_check 1 || fail "--check accepts a mise-installed hermes that runs the seeded session"
|
||||
run_mise_check 0 && fail "--check rejects a mise-installed hermes without the flags omarchy-agent passes"
|
||||
pass "--check follows the mise-installed hermes it would actually run"
|
||||
|
||||
rm -f "$test_home/.local/bin/hermes"
|
||||
: >"$mise_log"
|
||||
OMARCHY_TEST_MISE_WHERE_OK=1 run_installer 0 &&
|
||||
fail "installing refuses to claim an unmarked Hermes mise environment"
|
||||
tr '\0' '\n' <"$mise_log" | grep -Eq '^(rm|uninstall)$' &&
|
||||
fail "an unmarked Hermes mise environment is never removed"
|
||||
[[ ! -e $test_home/.local/bin/hermes ]] ||
|
||||
fail "an unmarked Hermes mise environment is not given an Omarchy wrapper"
|
||||
pass "a Hermes mise environment needs wrapper ownership before replacement"
|
||||
|
||||
# install/user/mise.sh is sourced by install/user/all.sh through run_logged,
|
||||
# which runs it under `bash -eE` and hands its exit code back to
|
||||
# omarchy-provision-user's `set -euo pipefail`. Everything that finalizes a user
|
||||
# -- the default browser, the mailto handler, the first-install migration
|
||||
# markers, the finalize-user marker -- runs after that source, so this leaf
|
||||
# returning non-zero costs the user all of it. The Hermes installer is the only
|
||||
# line in it that can fail, and it does exactly that whenever hermes-desktop is
|
||||
# installed but the app has not been launched yet: the case a second user on a
|
||||
# shared machine hits on their first login.
|
||||
mise_sh_home="$test_tmp/mise-sh-home"
|
||||
mkdir -p "$mise_sh_home/.local/bin"
|
||||
|
||||
cat >"$mock_bin/omarchy-mise-install" <<'SH'
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
SH
|
||||
chmod +x "$mock_bin/omarchy-mise-install"
|
||||
|
||||
# Desktop installed, nothing bootstrapped: omarchy-install-hermes-cli exits 1.
|
||||
OMARCHY_TEST_DESKTOP_INSTALLED=1 \
|
||||
OMARCHY_TEST_MISE_LOG="$mise_log" \
|
||||
HOME="$mise_sh_home" \
|
||||
PATH="$mock_bin:$ROOT/bin:$PATH" \
|
||||
bash "$ROOT/bin/omarchy-install-hermes-cli" >/dev/null 2>&1 &&
|
||||
fail "the Hermes installer exits non-zero when the desktop app has not set Hermes up"
|
||||
|
||||
# Sourced exactly as run_logged does it.
|
||||
OMARCHY_TEST_DESKTOP_INSTALLED=1 \
|
||||
OMARCHY_TEST_MISE_LOG="$mise_log" \
|
||||
HOME="$mise_sh_home" \
|
||||
PATH="$mock_bin:$ROOT/bin:$PATH" \
|
||||
bash -eE -c 'source "$1"' bash "$ROOT/install/user/mise.sh" >/dev/null 2>&1 ||
|
||||
fail "user setup survives a Hermes install that cannot finish"
|
||||
pass "user setup survives a Hermes install that cannot finish"
|
||||
|
||||
# UV_PYTHON pins the interpreter Hermes is built against. Left in the
|
||||
# environment it reaches Hermes itself and every command the agent shells out
|
||||
# to, so a `uv` run in the user's own project resolves 3.13 there as well --
|
||||
# uv only warns that this contradicts the project's requires-python, then
|
||||
# builds the venv anyway. The stub drops it before handing over.
|
||||
leak_home="$test_tmp/leak-home"
|
||||
leak_bin="$test_tmp/leak-bin"
|
||||
leak_log="$test_tmp/leak-log"
|
||||
leak_prefix="$test_tmp/leak-prefix"
|
||||
mkdir -p "$leak_home/.local/bin" "$leak_bin" "$leak_prefix/hermes-agent/lib/python$python_pin"
|
||||
|
||||
# A mise whose `where` satisfies the stub's probe, so the stub goes straight to
|
||||
# handing over, and whose `x` records the UV_PYTHON it was handed.
|
||||
cat >"$leak_bin/mise" <<SH
|
||||
#!/bin/bash
|
||||
case \$1 in
|
||||
where) echo "$leak_prefix" ;;
|
||||
x) printf '%s' "\${UV_PYTHON-}" >"$leak_log" ;;
|
||||
esac
|
||||
SH
|
||||
chmod +x "$leak_bin/mise"
|
||||
|
||||
OMARCHY_TEST_DESKTOP_INSTALLED=0 \
|
||||
OMARCHY_TEST_MISE_LOG="$mise_log" \
|
||||
HOME="$leak_home" \
|
||||
PATH="$mock_bin:$PATH" \
|
||||
bash "$ROOT/bin/omarchy-install-hermes-cli" >/dev/null 2>&1 ||
|
||||
fail "the installer writes a stub for the leak check"
|
||||
|
||||
HOME="$leak_home" PATH="$leak_bin:$mock_bin:$PATH" \
|
||||
"$leak_home/.local/bin/hermes" --version >/dev/null 2>&1
|
||||
|
||||
[[ -f $leak_log ]] || fail "the stub reaches the command it wraps"
|
||||
[[ -z $(cat "$leak_log") ]] ||
|
||||
fail "the interpreter pin does not follow Hermes into the commands it runs"
|
||||
pass "the interpreter pin does not follow Hermes into the commands it runs"
|
||||
|
||||
# --owns is the one answer to whether the wrapper on PATH is this installer's.
|
||||
# Remove Preinstalls and the migration both ask it rather than carrying their
|
||||
# own copy of the marker, so a change to what ownership means reaches them.
|
||||
owns_home="$test_tmp/owns-home"
|
||||
mkdir -p "$owns_home/.local/bin"
|
||||
|
||||
run_owns() {
|
||||
OMARCHY_TEST_DESKTOP_INSTALLED=0 \
|
||||
OMARCHY_TEST_MISE_LOG="$mise_log" \
|
||||
HOME="$owns_home" \
|
||||
PATH="$mock_bin:$PATH" \
|
||||
bash "$ROOT/bin/omarchy-install-hermes-cli" --owns
|
||||
}
|
||||
|
||||
rm -f "$owns_home/.local/bin/hermes"
|
||||
run_owns && fail "--owns says no when there is no wrapper at all"
|
||||
|
||||
printf '%s\n' "#!/bin/bash" "$stub_marker" >"$owns_home/.local/bin/hermes"
|
||||
chmod +x "$owns_home/.local/bin/hermes"
|
||||
run_owns || fail "--owns recognises the stub this installer wrote"
|
||||
|
||||
printf '%s\n' "#!/bin/bash" "# Replaces the stub omarchy-install-hermes-cli used to write." \
|
||||
>"$owns_home/.local/bin/hermes"
|
||||
run_owns && fail "--owns needs the exact marker line, not a mention"
|
||||
|
||||
# Quoting the marker inside a longer line is not the same as carrying it: the
|
||||
# match is whole-line, so a wrapper describing what it replaced stays the
|
||||
# user's.
|
||||
printf '%s\n' "#!/bin/bash" "# Replaced '$stub_marker' with my own." \
|
||||
>"$owns_home/.local/bin/hermes"
|
||||
run_owns && fail "--owns needs the marker to be the whole line, not part of one"
|
||||
|
||||
rm -f "$owns_home/.local/bin/hermes"
|
||||
ln -s "$test_home/.local/bin/hermes" "$owns_home/.local/bin/hermes"
|
||||
run_owns && fail "--owns disclaims a symlink, whatever it resolves to"
|
||||
rm -f "$owns_home/.local/bin/hermes"
|
||||
pass "--owns answers for the wrapper this installer wrote and nothing else"
|
||||
|
||||
# The marker lives in exactly one place. Every other caller asks --owns, so a
|
||||
# second copy is drift waiting to happen.
|
||||
marker_copies=$(grep -rl "Written by omarchy-install-hermes-cli" \
|
||||
"$ROOT/bin" "$ROOT/install" "$ROOT/migrations" 2>/dev/null | wc -l)
|
||||
(( marker_copies == 1 )) ||
|
||||
fail "only omarchy-install-hermes-cli spells out the ownership marker"
|
||||
pass "the ownership marker is written down once"
|
||||
|
||||
# --remove tears down a Hermes CLI this installer owns, so Remove Hermes can
|
||||
# clear one the desktop app never superseded. It turns on the same ownership as
|
||||
# the rest of the file, so its cases mirror that split.
|
||||
remove_home="$test_tmp/remove-home"
|
||||
mkdir -p "$remove_home/.local/bin"
|
||||
|
||||
run_remove() {
|
||||
OMARCHY_TEST_DESKTOP_INSTALLED=0 \
|
||||
OMARCHY_TEST_MISE_WHERE_OK="${OMARCHY_TEST_MISE_WHERE_OK:-0}" \
|
||||
OMARCHY_TEST_MISE_ROOT="$test_tmp/mise" \
|
||||
OMARCHY_TEST_MISE_LOG="$mise_log" \
|
||||
HOME="$remove_home" \
|
||||
PATH="$mock_bin:$PATH" \
|
||||
bash "$ROOT/bin/omarchy-install-hermes-cli" --remove
|
||||
}
|
||||
|
||||
rm -f "$remove_home/.local/bin/hermes"
|
||||
: >"$mise_log"
|
||||
run_remove || fail "--remove succeeds when there is nothing to remove"
|
||||
# No stub means no proof the mise environment -- if one even exists -- is
|
||||
# Omarchy's, so nothing may reach mise at all.
|
||||
tr '\0' '\n' <"$mise_log" | grep -Eq '^(rm|uninstall)$' &&
|
||||
fail "--remove leaves mise alone when nothing proves ownership"
|
||||
pass "--remove is idempotent when no Hermes CLI is present"
|
||||
|
||||
printf '%s\n' "#!/bin/bash" "$stub_marker" >"$remove_home/.local/bin/hermes"
|
||||
chmod +x "$remove_home/.local/bin/hermes"
|
||||
: >"$mise_log"
|
||||
run_remove || fail "--remove succeeds tearing down an owned CLI"
|
||||
tr '\0' '\n' <"$mise_log" | grep -q '^rm$' || fail "--remove drops the mise tool from config"
|
||||
tr '\0' '\n' <"$mise_log" | grep -q '^uninstall$' || fail "--remove uninstalls the mise tool"
|
||||
[[ ! -e $remove_home/.local/bin/hermes ]] || fail "--remove takes the stub it owns"
|
||||
pass "--remove tears down the mise CLI and the stub this installer owns"
|
||||
|
||||
# When mise still resolves the tool after the teardown, the environment
|
||||
# survived whatever uninstall claimed, and --remove has to say so.
|
||||
printf '%s\n' "#!/bin/bash" "$stub_marker" >"$remove_home/.local/bin/hermes"
|
||||
chmod +x "$remove_home/.local/bin/hermes"
|
||||
OMARCHY_TEST_MISE_WHERE_OK=1 run_remove && fail "--remove claims success while mise still resolves the tool"
|
||||
pass "--remove fails when the mise environment survives the teardown"
|
||||
|
||||
foreign_remove_body="#!/bin/bash
|
||||
exec /usr/local/bin/my-own-hermes \"\$@\""
|
||||
printf '%s\n' "$foreign_remove_body" >"$remove_home/.local/bin/hermes"
|
||||
chmod +x "$remove_home/.local/bin/hermes"
|
||||
: >"$mise_log"
|
||||
OMARCHY_TEST_MISE_WHERE_OK=1 run_remove || fail "--remove succeeds with a foreign hermes present"
|
||||
[[ -f $remove_home/.local/bin/hermes && $(cat "$remove_home/.local/bin/hermes") == "$foreign_remove_body" ]] ||
|
||||
fail "--remove leaves a hermes it does not own untouched"
|
||||
# The wrapper may front a mise environment the user built against the very same
|
||||
# spec; without the marker there is no telling, so the environment stays too.
|
||||
tr '\0' '\n' <"$mise_log" | grep -Eq '^(rm|uninstall)$' &&
|
||||
fail "--remove never removes a mise environment it cannot prove is Omarchy's"
|
||||
pass "--remove leaves a Hermes the user installed themselves"
|
||||
|
||||
# Judged by what is left, not by what rm claimed: a stub that survives the
|
||||
# teardown is a CLI still installed, and --remove has to say so.
|
||||
printf '%s\n' "#!/bin/bash" "$stub_marker" >"$remove_home/.local/bin/hermes"
|
||||
chmod +x "$remove_home/.local/bin/hermes"
|
||||
chmod 555 "$remove_home/.local/bin"
|
||||
run_remove && fail "--remove claims success while the stub survives"
|
||||
chmod 755 "$remove_home/.local/bin"
|
||||
rm -f "$remove_home/.local/bin/hermes"
|
||||
pass "--remove fails when the stub cannot be removed"
|
||||
|
||||
# The app's marker says its install once landed, not that it is still there. A
|
||||
# wrapper whose runtime has since gone answers for nothing, so readiness runs
|
||||
# the command, exactly as it does for a hermes the user installed themselves.
|
||||
ready_home="$test_tmp/ready-home"
|
||||
mkdir -p "$ready_home/.hermes/hermes-agent/venv/bin" "$ready_home/.local/bin"
|
||||
touch "$ready_home/.hermes/hermes-agent/.hermes-bootstrap-complete"
|
||||
printf '%s\n' "#!/bin/bash" "exec $ready_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" \
|
||||
>"$ready_home/.local/bin/hermes"
|
||||
chmod +x "$ready_home/.local/bin/hermes"
|
||||
|
||||
run_ready_check() {
|
||||
OMARCHY_TEST_DESKTOP_INSTALLED=1 \
|
||||
OMARCHY_TEST_MISE_LOG="$mise_log" \
|
||||
HOME="$ready_home" \
|
||||
PATH="$mock_bin:$PATH" \
|
||||
bash "$ROOT/bin/omarchy-install-hermes-cli" --check >/dev/null 2>&1
|
||||
}
|
||||
|
||||
run_ready_check && fail "--check rejects the app's wrapper when its runtime is gone"
|
||||
|
||||
cat >"$ready_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH'
|
||||
#!/bin/bash
|
||||
if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then
|
||||
echo "[-q QUERY, --query QUERY] [--tui]"
|
||||
else
|
||||
echo "hermes-agent 0.0.0-test"
|
||||
fi
|
||||
SH
|
||||
chmod +x "$ready_home/.hermes/hermes-agent/venv/bin/hermes"
|
||||
run_ready_check || fail "--check accepts the app's wrapper once it runs"
|
||||
pass "readiness runs the app's command rather than trusting its marker"
|
||||
|
||||
# A release whose help lists only the old probe's --oneshot marker cannot run
|
||||
# the seeded --tui --query session omarchy-agent starts, so it is not ready.
|
||||
cat >"$ready_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH'
|
||||
#!/bin/bash
|
||||
if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then
|
||||
echo "--oneshot"
|
||||
else
|
||||
echo "hermes-agent 0.0.0-test"
|
||||
fi
|
||||
SH
|
||||
chmod +x "$ready_home/.hermes/hermes-agent/venv/bin/hermes"
|
||||
run_ready_check && fail "--check accepts a release without the flags omarchy-agent passes"
|
||||
pass "a release listing only --oneshot is not prompt-ready"
|
||||
|
||||
# A release that lists --tui-theme and --query-log but has dropped the bare
|
||||
# --tui/--query omarchy-agent passes must not read as ready on the substring
|
||||
# alone. The probe matches at a flag boundary for exactly this case.
|
||||
cat >"$ready_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH'
|
||||
#!/bin/bash
|
||||
if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then
|
||||
echo "[--tui-theme THEME] [--query-log FILE]"
|
||||
else
|
||||
echo "hermes-agent 0.0.0-test"
|
||||
fi
|
||||
SH
|
||||
chmod +x "$ready_home/.hermes/hermes-agent/venv/bin/hermes"
|
||||
run_ready_check && fail "--check accepts a release whose flags only contain --tui/--query as a substring"
|
||||
pass "a flag that merely contains --tui or --query is not prompt-ready"
|
||||
|
||||
# Each flag answers for itself: a release that kept --tui but dropped --query,
|
||||
# or the reverse, cannot run the seeded session either, so neither grep may
|
||||
# ride on the other's match.
|
||||
for kept in '--tui' '-q QUERY, --query QUERY'; do
|
||||
cat >"$ready_home/.hermes/hermes-agent/venv/bin/hermes" <<SH
|
||||
#!/bin/bash
|
||||
if [[ \${1:-} == "chat" && \${2:-} == "--help" ]]; then
|
||||
echo "[$kept]"
|
||||
else
|
||||
echo "hermes-agent 0.0.0-test"
|
||||
fi
|
||||
SH
|
||||
chmod +x "$ready_home/.hermes/hermes-agent/venv/bin/hermes"
|
||||
run_ready_check && fail "--check accepts a release listing only $kept"
|
||||
done
|
||||
pass "either flag alone is not prompt-ready"
|
||||
|
||||
# An underscore continues a flag name just as a dash does: --tui_mode is not
|
||||
# --tui.
|
||||
cat >"$ready_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH'
|
||||
#!/bin/bash
|
||||
if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then
|
||||
echo "[--tui_mode MODE] [--query_log FILE]"
|
||||
else
|
||||
echo "hermes-agent 0.0.0-test"
|
||||
fi
|
||||
SH
|
||||
chmod +x "$ready_home/.hermes/hermes-agent/venv/bin/hermes"
|
||||
run_ready_check && fail "--check accepts flags that extend --tui/--query with an underscore"
|
||||
pass "an underscore continuation is not the bare flag"
|
||||
|
||||
# A flag mentioned in another option's help text is not that option. Hermes
|
||||
# already writes "With --tui:" into --dev's description, so prose has to stay
|
||||
# prose even when both names appear in it.
|
||||
cat >"$ready_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH'
|
||||
#!/bin/bash
|
||||
if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then
|
||||
echo " --dev With --tui: run sources via tsx"
|
||||
echo " --log FILE Where --query output lands"
|
||||
else
|
||||
echo "hermes-agent 0.0.0-test"
|
||||
fi
|
||||
SH
|
||||
chmod +x "$ready_home/.hermes/hermes-agent/venv/bin/hermes"
|
||||
run_ready_check && fail "--check accepts flags that appear only in option descriptions"
|
||||
pass "a flag mentioned in prose is not a defined option"
|
||||
@@ -0,0 +1,359 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
for command in git jq python3; do require_command "$command"; done
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf -- "$test_tmp"' EXIT
|
||||
export OMARCHY_TEST_ROOT="$test_tmp"
|
||||
mkdir -p "$test_tmp/bin" "$test_tmp/package/resources" "$test_tmp/share" "$test_tmp/seed"
|
||||
|
||||
# Real Git exercises patch checks and preservation; all package, desktop and
|
||||
# service commands are mocks. No command reaches the live user installation.
|
||||
git -C "$test_tmp/seed" init -q -b main
|
||||
printf 'venv/\n.hermes-bootstrap-complete\napps/desktop/release/\n__pycache__/\n' >"$test_tmp/seed/.gitignore"
|
||||
printf 'before\n' >"$test_tmp/seed/runtime.txt"
|
||||
mkdir -p "$test_tmp/seed/apps/desktop/src"
|
||||
printf 'desktop source\n' >"$test_tmp/seed/apps/desktop/src/main.js"
|
||||
mkdir -p "$test_tmp/seed/hermes_cli"
|
||||
cat >"$test_tmp/seed/hermes_cli/main.py" <<'PY'
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
def _write_desktop_build_stamp(project_root, *, source_mode):
|
||||
home = Path(os.environ['HERMES_HOME'])
|
||||
assert project_root == home / 'hermes-agent'
|
||||
assert source_mode is False
|
||||
assert (project_root / 'apps/desktop/release/linux-unpacked/resources/app.asar').is_file()
|
||||
(home / 'desktop-build-stamp.json').write_text('upstream build stamp')
|
||||
with (Path(os.environ['OMARCHY_TEST_ROOT']) / 'events').open('a') as log:
|
||||
log.write('build-stamp\n')
|
||||
PY
|
||||
git -C "$test_tmp/seed" add .
|
||||
git -C "$test_tmp/seed" -c user.name=Test -c user.email=test@example.invalid commit -qm fixture
|
||||
release_commit=$(git -C "$test_tmp/seed" rev-parse HEAD)
|
||||
printf 'after\n' >"$test_tmp/seed/runtime.txt"
|
||||
git -C "$test_tmp/seed" diff >"$test_tmp/share/runtime.patch"
|
||||
printf 'before\n' >"$test_tmp/seed/runtime.txt"
|
||||
printf 'newer desktop source\n' >"$test_tmp/seed/apps/desktop/src/main.js"
|
||||
git -C "$test_tmp/seed" add apps/desktop/src/main.js
|
||||
git -C "$test_tmp/seed" -c user.name=Test -c user.email=test@example.invalid commit -qm newer-main
|
||||
origin_commit=$(git -C "$test_tmp/seed" rev-parse HEAD)
|
||||
export OMARCHY_TEST_RELEASE_COMMIT="$release_commit"
|
||||
printf '{"branch":"main","commit":"%s"}\n' "$release_commit" >"$test_tmp/package/resources/install-stamp.json"
|
||||
printf 'packaged app\n' >"$test_tmp/package/resources/app.asar"
|
||||
printf '#!/bin/bash\nexit 0\n' >"$test_tmp/package/Hermes"
|
||||
touch "$test_tmp/package/chrome-sandbox"
|
||||
chmod 755 "$test_tmp/package/Hermes"
|
||||
chmod 4755 "$test_tmp/package/chrome-sandbox"
|
||||
|
||||
cat >"$test_tmp/share/install.sh" <<'MOCK'
|
||||
#!/bin/bash
|
||||
set -e
|
||||
printf 'bootstrap\n' >>"$OMARCHY_TEST_ROOT/events"
|
||||
printf '%s\n' "$@" >"$OMARCHY_TEST_ROOT/install-args"
|
||||
[[ ${OMARCHY_TEST_INSTALL_FAIL:-0} != 1 ]] || exit 7
|
||||
commit=$OMARCHY_TEST_RELEASE_COMMIT
|
||||
force=false
|
||||
while (( $# )); do
|
||||
case "$1" in
|
||||
--dir) runtime=$2; shift ;;
|
||||
--commit) commit=$2; shift ;;
|
||||
--force-commit) force=true ;;
|
||||
--hermes-home) [[ $2 == "$HERMES_HOME" ]] ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
mkdir -p -- "${runtime%/*}"
|
||||
if [[ ! -d $runtime ]]; then
|
||||
git clone -q --depth 1 "file://$OMARCHY_TEST_ROOT/seed" "$runtime"
|
||||
else
|
||||
git -C "$runtime" checkout -q main
|
||||
git -C "$runtime" pull -q --ff-only origin main
|
||||
fi
|
||||
git -C "$runtime" fetch -q origin "$commit"
|
||||
if [[ $force == true ]] || ! git -C "$runtime" merge-base --is-ancestor "$commit" HEAD; then
|
||||
git -C "$runtime" checkout -q --detach "$commit"
|
||||
fi
|
||||
mkdir -p "$runtime/venv/bin"
|
||||
git -C "$runtime" rev-parse HEAD >"$runtime/venv/dependency-commit"
|
||||
printf '#!/bin/bash\nexit 0\n' >"$runtime/venv/bin/hermes"
|
||||
chmod +x "$runtime/venv/bin/hermes"
|
||||
printf '#!/bin/bash\nexec /usr/bin/python3 "$@"\n' >"$runtime/venv/bin/python"
|
||||
chmod +x "$runtime/venv/bin/python"
|
||||
[[ ${OMARCHY_TEST_NO_MARKER:-0} == 1 ]] || touch "$runtime/.hermes-bootstrap-complete"
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
for command in hermes hermes-agent hermes-acp; do
|
||||
rm -f "$HOME/.local/bin/$command"
|
||||
printf 'native runtime shim\n' >"$HOME/.local/bin/$command"
|
||||
done
|
||||
MOCK
|
||||
|
||||
cat >"$test_tmp/bin/omarchy-pkg-add" <<'MOCK'
|
||||
#!/bin/bash
|
||||
printf 'package %s\n' "$*" >>"$OMARCHY_TEST_ROOT/events"
|
||||
[[ ${OMARCHY_TEST_PACKAGE_FAIL:-0} != 1 ]]
|
||||
MOCK
|
||||
cat >"$test_tmp/bin/git" <<'MOCK'
|
||||
#!/bin/bash
|
||||
if [[ ${OMARCHY_TEST_FETCH_FAIL:-0} == 1 && " $* " == *" --unshallow "* ]]; then exit 8; fi
|
||||
exec /usr/bin/git "$@"
|
||||
MOCK
|
||||
cat >"$test_tmp/bin/omarchy-install-hermes-cli" <<'MOCK'
|
||||
#!/bin/bash
|
||||
printf 'handoff\n' >>"$OMARCHY_TEST_ROOT/events"
|
||||
exit 1
|
||||
MOCK
|
||||
cat >"$test_tmp/bin/setsid" <<'MOCK'
|
||||
#!/bin/bash
|
||||
exec "$@"
|
||||
MOCK
|
||||
cat >"$test_tmp/bin/cp" <<'MOCK'
|
||||
#!/bin/bash
|
||||
if [[ ${OMARCHY_TEST_COPY_FAIL:-0} == 1 ]]; then
|
||||
touch "${@: -1}/partial-copy"
|
||||
exit 9
|
||||
fi
|
||||
exec /usr/bin/cp "$@"
|
||||
MOCK
|
||||
cat >"$test_tmp/bin/mv" <<'MOCK'
|
||||
#!/bin/bash
|
||||
if [[ ${OMARCHY_TEST_COPY_RACE:-0} == 1 && $1 == -T ]]; then
|
||||
mkdir -p "${@: -1}"
|
||||
fi
|
||||
exec /usr/bin/mv "$@"
|
||||
MOCK
|
||||
cat >"$test_tmp/bin/uwsm-app" <<'MOCK'
|
||||
#!/bin/bash
|
||||
[[ $1 == -- ]] || exit 1
|
||||
shift
|
||||
exec "$@"
|
||||
MOCK
|
||||
cat >"$test_tmp/bin/hermes-desktop" <<'MOCK'
|
||||
#!/bin/bash
|
||||
sleep 0.05
|
||||
native="$HERMES_HOME/hermes-agent/apps/desktop/release/linux-unpacked"
|
||||
if [[ -x $native/Hermes && -f $native/resources/app.asar ]]; then
|
||||
printf 'launch\n' >>"$OMARCHY_TEST_ROOT/events"
|
||||
else
|
||||
printf 'launch-before-copy\n' >>"$OMARCHY_TEST_ROOT/events"
|
||||
fi
|
||||
MOCK
|
||||
cat >"$test_tmp/bin/systemctl" <<'MOCK'
|
||||
#!/bin/bash
|
||||
printf 'theme-stop\n' >>"$OMARCHY_TEST_ROOT/events"
|
||||
MOCK
|
||||
cat >"$test_tmp/bin/systemd-run" <<'MOCK'
|
||||
#!/bin/bash
|
||||
printf 'theme-start\n' >>"$OMARCHY_TEST_ROOT/events"
|
||||
# Join the mock asynchronous launch so every test owns its full lifetime.
|
||||
for (( attempt=0; attempt<100; attempt++ )); do
|
||||
if grep -q '^launch' "$OMARCHY_TEST_ROOT/events"; then exit 0; fi
|
||||
sleep 0.01
|
||||
done
|
||||
exit 1
|
||||
MOCK
|
||||
chmod +x "$test_tmp/bin/"*
|
||||
|
||||
# Substitute only system package paths in a scratch copy of the actual script.
|
||||
python3 - "$ROOT/bin/omarchy-install-ai-hermes" "$test_tmp" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
source, scratch = Path(sys.argv[1]), Path(sys.argv[2])
|
||||
script = source.read_text()
|
||||
for original, replacement in {
|
||||
'/opt/hermes-desktop': str(scratch / 'package'),
|
||||
'/usr/share/hermes-desktop': str(scratch / 'share'),
|
||||
'/usr/bin/hermes-desktop': str(scratch / 'bin/hermes-desktop'),
|
||||
}.items():
|
||||
script = script.replace(original, replacement)
|
||||
(scratch / 'installer').write_text(script)
|
||||
PY
|
||||
|
||||
new_home() {
|
||||
test_home="$test_tmp/$1"
|
||||
hermes_home="$test_home/.hermes"
|
||||
runtime="$hermes_home/hermes-agent"
|
||||
native="$runtime/apps/desktop/release/linux-unpacked"
|
||||
mkdir -p "$test_home"
|
||||
: >"$test_tmp/events"
|
||||
}
|
||||
run_installer() {
|
||||
HOME="$test_home" HERMES_HOME="${OMARCHY_TEST_HOME:-$hermes_home}" PATH="$test_tmp/bin:$PATH" \
|
||||
bash "$test_tmp/installer" >"$test_tmp/output" 2>&1
|
||||
}
|
||||
assert_stopped() {
|
||||
if grep -Eq '^(launch|theme-|build-stamp)' "$test_tmp/events"; then fail "$1"; fi
|
||||
}
|
||||
|
||||
new_home fresh
|
||||
run_installer || fail "fresh setup succeeds" "$(cat "$test_tmp/output")"
|
||||
expected=$(printf '%s\n' --skip-setup --branch main --commit "$release_commit" --force-commit --dir "$runtime" --hermes-home "$hermes_home")
|
||||
[[ $(cat "$test_tmp/install-args") == "$expected" ]] || fail "upstream installer receives the pinned main arguments"
|
||||
[[ $(head -3 "$test_tmp/events") == $'package hermes-desktop\nhandoff\nbootstrap' ]] || fail "package and CLI handoff precede runtime bootstrap"
|
||||
grep -qx launch "$test_tmp/events" || fail "native app is copied before launch"
|
||||
[[ $(sed -n '4p' "$test_tmp/events") == build-stamp ]] || fail "upstream build stamp follows the app copy and precedes launch"
|
||||
[[ $(cat "$hermes_home/desktop-build-stamp.json") == 'upstream build stamp' ]] || fail "the upstream helper records the completed packaged build"
|
||||
[[ $(cat "$runtime/runtime.txt") == after ]] || fail "the release runtime receives its patch"
|
||||
[[ $(stat -c %a "$native/chrome-sandbox") == 755 ]] || fail "the user sandbox is not setuid"
|
||||
[[ $(stat -c %a "$test_tmp/package/chrome-sandbox") == 4755 ]] || fail "package sandbox permissions remain unchanged"
|
||||
[[ $(git -C "$runtime" symbolic-ref --short HEAD) == main && $(git -C "$runtime" rev-parse main) == "$release_commit" ]] || fail "main starts at the release rather than the clone tip"
|
||||
[[ $(cat "$runtime/venv/dependency-commit") == "$release_commit" ]] || fail "dependencies are installed for the release"
|
||||
[[ $(git -C "$runtime" rev-parse --is-shallow-repository) == false ]] || fail "first update has connected history"
|
||||
# Reproduce the updater's checkout/count/pull sequence while origin stays put.
|
||||
git clone -q "$runtime" "$test_tmp/first-update"
|
||||
git -C "$test_tmp/first-update" remote set-url origin "file://$test_tmp/seed"
|
||||
git -C "$test_tmp/first-update" fetch -q origin main
|
||||
git -C "$test_tmp/first-update" checkout -q main
|
||||
[[ $(git -C "$test_tmp/first-update" rev-list HEAD..origin/main --count) == 1 ]] || fail "first update detects work even when origin has not moved since install"
|
||||
git -C "$test_tmp/first-update" pull -q --ff-only origin main
|
||||
[[ $(git -C "$test_tmp/first-update" rev-parse HEAD) == "$origin_commit" ]] || fail "first update fast-forwards to origin"
|
||||
pass "fresh setup pins main, patches the matching runtime and copies the complete app before launch"
|
||||
|
||||
printf 'user app\n' >"$native/resources/app.asar"
|
||||
printf 'user build stamp\n' >"$hermes_home/desktop-build-stamp.json"
|
||||
: >"$test_tmp/events"
|
||||
run_installer || fail "repeat setup succeeds" "$(cat "$test_tmp/output")"
|
||||
! grep -qx bootstrap "$test_tmp/events" || fail "repeat setup does not bootstrap again"
|
||||
! grep -qx build-stamp "$test_tmp/events" || fail "existing app never reruns the build stamp writer"
|
||||
[[ $(cat "$hermes_home/desktop-build-stamp.json") == 'user build stamp' ]] || fail "existing native build stamp remains unchanged"
|
||||
[[ $(cat "$native/resources/app.asar") == 'user app' ]] || fail "existing native app remains unchanged"
|
||||
pass "repeat setup accepts the applied patch and preserves the existing native app"
|
||||
|
||||
# Advancing the runtime must never reinstall the release or reapply its patch.
|
||||
printf 'new main\n' >"$runtime/runtime.txt"
|
||||
git -C "$runtime" add runtime.txt
|
||||
git -C "$runtime" -c user.name=Test -c user.email=test@example.invalid commit -qm update
|
||||
: >"$test_tmp/events"
|
||||
run_installer || fail "a complete updated runtime and native app are reused"
|
||||
[[ $(cat "$runtime/runtime.txt") == 'new main' ]] || fail "updated runtime is not release-patched"
|
||||
mv "$native" "$test_tmp/saved-native"
|
||||
: >"$test_tmp/events"
|
||||
run_installer && fail "a newer runtime cannot receive an older native app"
|
||||
[[ ! -e $native ]] || fail "no mismatched native app was copied"
|
||||
grep -q 'hermes desktop --build-only' "$test_tmp/output" || fail "missing newer native app has actionable guidance"
|
||||
assert_stopped "a missing updated app prevents launch and theme setup"
|
||||
pass "updated runtimes are preserved and never seeded with the old packaged app"
|
||||
|
||||
new_home dirty-desktop
|
||||
HOME="$test_home" HERMES_HOME="$hermes_home" bash "$test_tmp/share/install.sh" --dir "$runtime" --hermes-home "$hermes_home"
|
||||
printf 'local desktop edit\n' >"$runtime/apps/desktop/src/main.js"
|
||||
: >"$test_tmp/events"
|
||||
run_installer && fail "modified desktop sources cannot be certified as the packaged build"
|
||||
[[ ! -e $native && ! -e $hermes_home/desktop-build-stamp.json ]] || fail "modified desktop sources receive neither packaged app nor build stamp"
|
||||
[[ $(cat "$runtime/apps/desktop/src/main.js") == 'local desktop edit' ]] || fail "desktop source edits are preserved"
|
||||
grep -q 'hermes desktop --build-only' "$test_tmp/output" || fail "modified desktop sources have build guidance"
|
||||
assert_stopped "modified desktop sources prevent stamping, launch and theme setup"
|
||||
pass "a matching commit with modified desktop sources is preserved without seeding or stamping"
|
||||
|
||||
for failure in package install marker; do
|
||||
new_home "$failure-failure"
|
||||
case "$failure" in
|
||||
package) OMARCHY_TEST_PACKAGE_FAIL=1 run_installer && fail "package failure stops setup" ;;
|
||||
install) OMARCHY_TEST_INSTALL_FAIL=1 run_installer && fail "installer failure stops setup" ;;
|
||||
marker) OMARCHY_TEST_NO_MARKER=1 run_installer && fail "missing marker stops setup" ;;
|
||||
esac
|
||||
[[ ! -e $native ]] || fail "failed setup does not seed the app"
|
||||
assert_stopped "failed setup prevents launch and theme setup"
|
||||
done
|
||||
pass "package, upstream installer and readiness failures stop before launch"
|
||||
|
||||
for failure in copy race; do
|
||||
new_home "$failure-failure"
|
||||
if [[ $failure == "copy" ]]; then
|
||||
OMARCHY_TEST_COPY_FAIL=1 run_installer && fail "copy failure stops setup"
|
||||
[[ ! -e $native ]] || fail "partial copy is never published"
|
||||
else
|
||||
OMARCHY_TEST_COPY_RACE=1 run_installer && fail "concurrent native app stops publication"
|
||||
[[ -d $native && -z $(ls -A "$native") ]] || fail "concurrent empty app directory is preserved"
|
||||
fi
|
||||
[[ -z $(find "${native%/*}" -maxdepth 1 -name '.linux-unpacked.*' -print) ]] || fail "owned staging directory is cleaned up"
|
||||
assert_stopped "publication failure prevents launch"
|
||||
done
|
||||
pass "failed copies and concurrent app creation preserve existing work and clean only staging"
|
||||
|
||||
new_home incomplete-native
|
||||
run_installer || fail "incomplete native fixture sets up"
|
||||
rm "$native/resources/app.asar"
|
||||
: >"$test_tmp/events"
|
||||
run_installer && fail "incomplete existing app requires repair"
|
||||
[[ ! -e $native/resources/app.asar ]] || fail "incomplete existing app is not overwritten"
|
||||
assert_stopped "incomplete native app prevents launch"
|
||||
pass "an incomplete existing native app is preserved"
|
||||
|
||||
new_home patch-conflict
|
||||
run_installer || fail "patch conflict fixture sets up"
|
||||
printf 'local edit\n' >"$runtime/runtime.txt"
|
||||
: >"$test_tmp/events"
|
||||
run_installer && fail "unexpected patch conflict stops setup"
|
||||
[[ $(cat "$runtime/runtime.txt") == 'local edit' ]] || fail "conflicting runtime changes are preserved"
|
||||
assert_stopped "patch conflict prevents launch"
|
||||
rm "$runtime/.hermes-bootstrap-complete"
|
||||
: >"$test_tmp/events"
|
||||
run_installer && fail "incomplete modified runtime cannot be reset by upstream installer"
|
||||
! grep -qx bootstrap "$test_tmp/events" || fail "modified runtime never reaches upstream installer"
|
||||
pass "patch conflicts and incomplete modified runtimes retain local changes and stop safely"
|
||||
|
||||
new_home full-history-retry
|
||||
git clone -q "$test_tmp/seed" "$runtime"
|
||||
git -C "$runtime" checkout -q --detach "$release_commit"
|
||||
run_installer || fail "clean incomplete full-history release checkout is repaired" "$(cat "$test_tmp/output")"
|
||||
[[ $(cat "$runtime/venv/dependency-commit") == "$release_commit" ]] || fail "full-history retry pins before installing dependencies"
|
||||
[[ $(git -C "$runtime" rev-parse HEAD) == "$release_commit" && -f $native/resources/app.asar ]] || fail "full-history retry seeds the matching release"
|
||||
pass "full-history retries force the guarded release pin before dependency setup"
|
||||
|
||||
new_home local-main
|
||||
git clone -q "$test_tmp/seed" "$runtime"
|
||||
printf 'local branch work\n' >"$runtime/keep"
|
||||
git -C "$runtime" add keep
|
||||
git -C "$runtime" -c user.name=Test -c user.email=test@example.invalid commit -qm local-work
|
||||
local_main=$(git -C "$runtime" rev-parse main)
|
||||
git -C "$runtime" checkout -q --detach "$release_commit"
|
||||
run_installer && fail "local main commits cannot be reset by upstream installation"
|
||||
! grep -qx bootstrap "$test_tmp/events" || fail "local main is checked before upstream installer"
|
||||
[[ $(git -C "$runtime" rev-parse main) == "$local_main" ]] || fail "local main commit stays referenced"
|
||||
pass "detached release checkouts do not hide local main work from the installer guard"
|
||||
|
||||
new_home deepen-retry
|
||||
OMARCHY_TEST_FETCH_FAIL=1 run_installer && fail "history fetch failure stops setup"
|
||||
[[ ! -e $native ]] || fail "failed history fetch does not seed the app"
|
||||
assert_stopped "failed history fetch prevents launch"
|
||||
: >"$test_tmp/events"
|
||||
run_installer || fail "history fetch can be retried after runtime setup" "$(cat "$test_tmp/output")"
|
||||
! grep -qx bootstrap "$test_tmp/events" || fail "history retry does not repeat upstream installation"
|
||||
pass "a history fetch failure can be retried without reinstalling the ready runtime"
|
||||
|
||||
new_home existing-commands
|
||||
mkdir -p "$test_home/.local/bin"
|
||||
printf 'foreign wrapper\n' >"$test_home/.local/bin/hermes"
|
||||
printf 'symlink target\n' >"$test_home/target"
|
||||
ln -s "$test_home/target" "$test_home/.local/bin/hermes-agent"
|
||||
ln -s "$test_home/missing" "$test_home/.local/bin/hermes-acp"
|
||||
run_installer || fail "existing commands are preserved before upstream replaces them" "$(cat "$test_tmp/output")"
|
||||
backups=("$test_home/.local/bin/".hermes-before-desktop.*)
|
||||
[[ ${#backups[@]} == 1 && -d ${backups[0]} ]] || fail "one backup directory preserves existing command names"
|
||||
[[ $(cat "${backups[0]}/hermes") == 'foreign wrapper' ]] || fail "foreign wrapper bytes are saved"
|
||||
[[ $(readlink "${backups[0]}/hermes-agent") == "$test_home/target" && $(readlink "${backups[0]}/hermes-acp") == "$test_home/missing" ]] || fail "working and broken symlinks are saved as links"
|
||||
[[ $(cat "$test_home/target") == 'symlink target' ]] || fail "upstream does not overwrite the original symlink target"
|
||||
grep -qF "${backups[0]}" "$test_tmp/output" || fail "backup location is reported"
|
||||
pass "pre-existing command files and symlinks are backed up before replacement"
|
||||
|
||||
new_home old-package
|
||||
mv "$test_tmp/package/resources/install-stamp.json" "$test_tmp/saved-install-stamp.json"
|
||||
run_installer && fail "an old installed package cannot bootstrap"
|
||||
grep -q 'omarchy update' "$test_tmp/output" || fail "old package has actionable upgrade guidance"
|
||||
! grep -qx handoff "$test_tmp/events" || fail "old package is rejected before CLI handoff"
|
||||
! grep -qx bootstrap "$test_tmp/events" || fail "old package never reaches upstream installer"
|
||||
mv "$test_tmp/saved-install-stamp.json" "$test_tmp/package/resources/install-stamp.json"
|
||||
pass "old package fails with upgrade guidance before changing the runtime or CLI"
|
||||
|
||||
new_home custom-profile
|
||||
hermes_home="$test_home/custom home"
|
||||
runtime="$hermes_home/hermes-agent"
|
||||
OMARCHY_TEST_HOME="$hermes_home/PrOfIlEs/coder/../coder/" run_installer || fail "profile setup succeeds"
|
||||
[[ -x $runtime/apps/desktop/release/linux-unpacked/Hermes ]] || fail "profile uses the canonical root runtime"
|
||||
grep -qxF "$hermes_home" "$test_tmp/install-args" || fail "canonical custom home reaches upstream installer"
|
||||
pass "custom profile paths normalize to the shared Hermes home"
|
||||
Executable
+384
@@ -0,0 +1,384 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
mock_bin="$test_tmp/bin"
|
||||
test_home="$test_tmp/home"
|
||||
mkdir -p "$mock_bin"
|
||||
|
||||
# Keep package-path checks scoped to the fixture, even with a live app open.
|
||||
python3 - "$ROOT/bin/omarchy-remove-ai-hermes" "$test_tmp" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
source, scratch = map(Path, sys.argv[1:])
|
||||
(scratch / 'remover').write_text(source.read_text().replace('/opt/hermes-desktop', str(scratch / 'package')))
|
||||
PY
|
||||
|
||||
cat >"$mock_bin/omarchy-pkg-drop" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\0' "$@" >>"$OMARCHY_TEST_DROP_LOG"
|
||||
SH
|
||||
|
||||
# The CLI teardown is the installer's own, exercised in hermes-cli-test.sh; here
|
||||
# it is mocked to a logger so this test stays about what Remove Hermes does with
|
||||
# ~/.hermes, and to keep real mise out of a run with HOME pointed at a fixture.
|
||||
cat >"$mock_bin/omarchy-install-hermes-cli" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\0' "$@" >>"$OMARCHY_TEST_INSTALLER_LOG"
|
||||
exit "${OMARCHY_TEST_INSTALLER_STATUS:-0}"
|
||||
SH
|
||||
|
||||
# The remover asks through gum whether the user's data should go too. The stub
|
||||
# answers "no" unless a test says otherwise, and logs every call: a real gum
|
||||
# would hang a test run, and one that answered "yes" on its own would be the
|
||||
# very data loss the default-no exists to prevent.
|
||||
cat >"$mock_bin/gum" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\0' "$@" >>"$OMARCHY_TEST_GUM_LOG"
|
||||
if [[ -n ${OMARCHY_TEST_PROMPT_GATE:-} ]]; then
|
||||
touch "$OMARCHY_TEST_PROMPT_GATE.started"
|
||||
for (( attempt=0; attempt<500; attempt++ )); do
|
||||
[[ ! -e $OMARCHY_TEST_PROMPT_GATE.continue ]] || exit 0
|
||||
sleep 0.01
|
||||
done
|
||||
exit 1
|
||||
fi
|
||||
exit "${OMARCHY_TEST_GUM_STATUS:-1}"
|
||||
SH
|
||||
cat >"$mock_bin/systemctl" <<'SH'
|
||||
#!/bin/bash
|
||||
echo "systemctl $*" >>"$OMARCHY_TEST_SYSTEMCTL_LOG"
|
||||
SH
|
||||
|
||||
chmod +x "$mock_bin"/*
|
||||
|
||||
seed_install() {
|
||||
rm -rf "$test_home"
|
||||
mkdir -p "$test_home/.hermes/hermes-agent" "$test_home/.hermes/bootstrap-cache" \
|
||||
"$test_home/.hermes/bin" "$test_home/.hermes/node/bin" \
|
||||
"$test_home/.hermes/memories" "$test_home/.hermes/sessions" \
|
||||
"$test_home/.config/Hermes" "$test_home/.local/bin"
|
||||
printf 'chat\n' >"$test_home/.hermes/sessions/one.json"
|
||||
printf 'memory\n' >"$test_home/.hermes/memories/one.md"
|
||||
printf 'soul\n' >"$test_home/.hermes/SOUL.md"
|
||||
printf 'uv\n' >"$test_home/.hermes/bin/uv"
|
||||
ln -sf "$test_home/.hermes/node/bin/node" "$test_home/.local/bin/node"
|
||||
ln -sf "$test_home/.hermes/node/bin/npm" "$test_home/.local/bin/npm"
|
||||
ln -sf /usr/bin/npx "$test_home/.local/bin/npx"
|
||||
printf 'node\n' >"$test_home/.hermes/node/bin/node"
|
||||
touch "$test_home/.hermes/hermes-agent/.hermes-bootstrap-complete"
|
||||
}
|
||||
|
||||
# </dev/null pins stdin off a terminal, so these runs exercise the
|
||||
# non-interactive path no matter where the suite itself is running.
|
||||
remove() {
|
||||
: >"$test_tmp/installer-log"
|
||||
: >"$test_tmp/gum-log"
|
||||
: >"$test_tmp/systemctl-log"
|
||||
OMARCHY_TEST_DROP_LOG="$test_tmp/drop-log" \
|
||||
OMARCHY_TEST_INSTALLER_LOG="$test_tmp/installer-log" \
|
||||
OMARCHY_TEST_INSTALLER_STATUS="${OMARCHY_TEST_INSTALLER_STATUS:-0}" \
|
||||
OMARCHY_TEST_SYSTEMCTL_LOG="$test_tmp/systemctl-log" \
|
||||
OMARCHY_TEST_GUM_LOG="$test_tmp/gum-log" \
|
||||
HOME="$test_home" PATH="$mock_bin:$PATH" \
|
||||
bash "$test_tmp/remover" </dev/null >"$test_tmp/output" 2>&1
|
||||
}
|
||||
|
||||
# script(1) puts the remover on a pty, which is the only way -t 0 answers true
|
||||
# without a person at a real one; the stubbed gum then supplies the answer.
|
||||
remove_tty() {
|
||||
: >"$test_tmp/installer-log"
|
||||
: >"$test_tmp/gum-log"
|
||||
: >"$test_tmp/systemctl-log"
|
||||
OMARCHY_TEST_DROP_LOG="$test_tmp/drop-log" \
|
||||
OMARCHY_TEST_INSTALLER_LOG="$test_tmp/installer-log" \
|
||||
OMARCHY_TEST_SYSTEMCTL_LOG="$test_tmp/systemctl-log" \
|
||||
OMARCHY_TEST_GUM_LOG="$test_tmp/gum-log" \
|
||||
OMARCHY_TEST_GUM_STATUS="${OMARCHY_TEST_GUM_STATUS:-1}" \
|
||||
HOME="$test_home" PATH="$mock_bin:$PATH" \
|
||||
script -qec "bash '$test_tmp/remover'" /dev/null >"$test_tmp/output" 2>&1
|
||||
}
|
||||
|
||||
# The app brings its own uv and its own node; both are runtime, not data.
|
||||
seed_install
|
||||
printf '%s\n' "#!/bin/bash" "exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" \
|
||||
>"$test_home/.local/bin/hermes"
|
||||
remove || fail "remove succeeds"
|
||||
[[ ! -d $test_home/.hermes/hermes-agent ]] || fail "the runtime checkout is removed"
|
||||
[[ ! -d $test_home/.hermes/bin ]] || fail "the uv the app installed is removed"
|
||||
[[ ! -d $test_home/.hermes/node ]] || fail "the node the app installed is removed"
|
||||
pass "removal takes the whole runtime the app installed"
|
||||
|
||||
grep -Fxq 'systemctl --user stop omarchy-hermes-theme.service' "$test_tmp/systemctl-log" ||
|
||||
fail "the unit the installer left waiting to hand over the theme is stopped" "$(cat "$test_tmp/systemctl-log")"
|
||||
pass "removal stops the installer's theme hand-over"
|
||||
|
||||
[[ -d $test_home/.config/Hermes ]] ||
|
||||
fail "gateway connections, tokens and settings survive removal"
|
||||
pass "removal keeps the app's connections and settings"
|
||||
|
||||
# -L, not -e: a dangling symlink fails -e while very much still being there.
|
||||
[[ ! -L $test_home/.local/bin/node ]] || fail "a node symlink into ~/.hermes is removed"
|
||||
[[ ! -L $test_home/.local/bin/npm ]] || fail "an npm symlink into ~/.hermes is removed"
|
||||
[[ -L $test_home/.local/bin/npx ]] || fail "an npx symlink pointing elsewhere survives"
|
||||
pass "removal clears only the managed Node links it stranded"
|
||||
|
||||
[[ -f $test_home/.hermes/sessions/one.json ]] || fail "chats survive removal"
|
||||
[[ -f $test_home/.hermes/memories/one.md ]] || fail "memories survive removal"
|
||||
[[ -f $test_home/.hermes/SOUL.md ]] || fail "SOUL.md survives removal"
|
||||
pass "removal keeps what belongs to the user"
|
||||
|
||||
# Without a terminal there is nobody to ask, so gum must not even be reached:
|
||||
# a gum that answered "yes" on its own would be a data loss.
|
||||
[[ ! -s $test_tmp/gum-log ]] ||
|
||||
fail "removal does not ask about the user's data without a terminal"
|
||||
pass "removal keeps the user's data unasked when there is no terminal"
|
||||
|
||||
[[ ! -e $test_home/.local/bin/hermes ]] || fail "the app's own hermes command is removed"
|
||||
pass "removal takes the command the app installed"
|
||||
|
||||
# Removal also asks the installer to tear down a mise CLI the app superseded, so
|
||||
# a copy left from before the app took over does not linger once Hermes is gone.
|
||||
tr '\0' '\n' <"$test_tmp/installer-log" | grep -qx -- '--remove' ||
|
||||
fail "removal asks the installer to tear down its own CLI"
|
||||
pass "removal tears down the mise CLI through the installer"
|
||||
|
||||
# A hermes command the app did not write survives even when the app did install
|
||||
# a runtime of its own.
|
||||
seed_install
|
||||
printf '%s\n' "#!/bin/bash" "exec /usr/local/bin/my-own-hermes \"\$@\"" \
|
||||
>"$test_home/.local/bin/hermes"
|
||||
remove || fail "remove succeeds with a foreign hermes present"
|
||||
[[ -f $test_home/.local/bin/hermes ]] ||
|
||||
fail "a hermes command the app did not write survives removal"
|
||||
pass "removal leaves a hermes it does not own"
|
||||
|
||||
# Installed but never launched. The app provisions its runtime on first launch
|
||||
# and marks it complete when it lands, so without that marker everything under
|
||||
# ~/.hermes predates the app -- an official install, or one built by hand -- and
|
||||
# the paths are identical either way. Dropping the package is the whole job.
|
||||
seed_install
|
||||
rm -f "$test_home/.hermes/hermes-agent/.hermes-bootstrap-complete"
|
||||
printf 'my local edit\n' >"$test_home/.hermes/hermes-agent/PATCH"
|
||||
printf '%s\n' "#!/bin/bash" "exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" \
|
||||
>"$test_home/.local/bin/hermes"
|
||||
remove || fail "remove succeeds when the app never finished installing Hermes"
|
||||
# The stranded pre-desktop CLI is exactly the interrupted-install case, so the
|
||||
# teardown must be asked for here too, not only when the app's runtime landed.
|
||||
tr '\0' '\n' <"$test_tmp/installer-log" | grep -qx -- '--remove' ||
|
||||
fail "removal tears down the CLI even when the app never finished installing"
|
||||
[[ -d $test_home/.hermes/hermes-agent ]] ||
|
||||
fail "a Hermes runtime the app never installed survives removal"
|
||||
[[ -f $test_home/.hermes/hermes-agent/PATCH ]] ||
|
||||
fail "local changes to a runtime the app never installed survive removal"
|
||||
[[ -d $test_home/.hermes/bin && -d $test_home/.hermes/node ]] ||
|
||||
fail "the rest of a runtime the app never installed survives removal"
|
||||
[[ -f $test_home/.local/bin/hermes ]] ||
|
||||
fail "the command a runtime the app never installed put on PATH survives removal"
|
||||
[[ -L $test_home/.local/bin/node ]] ||
|
||||
fail "node links belonging to a runtime the app never installed survive removal"
|
||||
pass "removal leaves a Hermes the app never installed"
|
||||
|
||||
# ~/.hermes carries a dot, so a pattern rather than a plain string would also
|
||||
# claim a wrapper pointing at a sibling directory that merely looks like it.
|
||||
seed_install
|
||||
mkdir -p "$test_home/xhermes/bin"
|
||||
sibling_body="#!/bin/bash
|
||||
exec $test_home/xhermes/bin/hermes \"\$@\""
|
||||
printf '%s\n' "$sibling_body" >"$test_home/.local/bin/hermes"
|
||||
remove || fail "remove succeeds with a wrapper pointing at a sibling directory"
|
||||
[[ -f $test_home/.local/bin/hermes && $(cat "$test_home/.local/bin/hermes") == "$sibling_body" ]] ||
|
||||
fail "a wrapper pointing at ~/xhermes is not mistaken for one pointing into ~/.hermes"
|
||||
pass "removal matches the runtime path as a plain string"
|
||||
|
||||
# On a terminal the user is asked, default no: declining leaves every piece of
|
||||
# data where it was.
|
||||
seed_install
|
||||
remove_tty || fail "remove succeeds when the data question is declined"
|
||||
tr '\0' '\n' <"$test_tmp/gum-log" | grep -qx 'confirm' ||
|
||||
fail "removal asks about the user's data on a terminal"
|
||||
[[ -f $test_home/.hermes/sessions/one.json && -d $test_home/.config/Hermes ]] ||
|
||||
fail "declining the question keeps the user's data"
|
||||
pass "removal asks on a terminal and declining keeps the data"
|
||||
|
||||
# An explicit yes is the one path that takes the data too.
|
||||
seed_install
|
||||
OMARCHY_TEST_GUM_STATUS=0 remove_tty || fail "remove succeeds when the data goes too"
|
||||
[[ ! -e $test_home/.hermes && ! -e $test_home/.config/Hermes ]] ||
|
||||
fail "a yes deletes ~/.hermes and ~/.config/Hermes"
|
||||
pass "removal deletes the user's data only on an explicit yes"
|
||||
|
||||
# Without the bootstrap marker the runtime is not the app's to take unasked,
|
||||
# but the data question is still the user's to answer: declining keeps the
|
||||
# whole tree -- runtime included -- untouched.
|
||||
seed_install
|
||||
rm -f "$test_home/.hermes/hermes-agent/.hermes-bootstrap-complete"
|
||||
remove_tty || fail "remove succeeds when the app never installed Hermes"
|
||||
tr '\0' '\n' <"$test_tmp/gum-log" | grep -qx 'confirm' ||
|
||||
fail "removal still asks about the data without the bootstrap marker"
|
||||
[[ -d $test_home/.hermes/hermes-agent && -d $test_home/.config/Hermes ]] ||
|
||||
fail "declining keeps a Hermes the app never installed"
|
||||
pass "removal asks without the marker and declining keeps everything"
|
||||
|
||||
# The prompt names ~/.hermes itself, so a yes takes the whole tree there too,
|
||||
# unowned runtime and all -- that is what was asked and answered.
|
||||
seed_install
|
||||
rm -f "$test_home/.hermes/hermes-agent/.hermes-bootstrap-complete"
|
||||
OMARCHY_TEST_GUM_STATUS=0 remove_tty ||
|
||||
fail "remove succeeds when the data goes too without the marker"
|
||||
[[ ! -e $test_home/.hermes && ! -e $test_home/.config/Hermes ]] ||
|
||||
fail "a yes takes ~/.hermes whole when the marker never appeared"
|
||||
pass "removal honors a yes on the named paths without the marker"
|
||||
|
||||
# A CLI teardown that fails must not stop the runtime handling, and must not be
|
||||
# papered over either: the data work still happens, and the failure reaches the
|
||||
# caller's exit code.
|
||||
seed_install
|
||||
printf '%s\n' "#!/bin/bash" "exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" \
|
||||
>"$test_home/.local/bin/hermes"
|
||||
OMARCHY_TEST_INSTALLER_STATUS=1 remove && fail "a failed CLI teardown surfaces in the exit code"
|
||||
[[ ! -d $test_home/.hermes/hermes-agent ]] ||
|
||||
fail "a failed CLI teardown does not stop the runtime removal"
|
||||
pass "a failed CLI teardown is reported after the runtime is handled"
|
||||
|
||||
# Real SQLite writers exercise the kernel's live/deleted file descriptors.
|
||||
# Package, service and confirmation commands remain confined to the mocks.
|
||||
python3 - "$test_tmp" <<'PY'
|
||||
import os
|
||||
from pathlib import Path
|
||||
import pty
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
scratch = Path(sys.argv[1])
|
||||
writer_code = '''import os, sqlite3, sys
|
||||
c = sqlite3.connect(os.environ['TEST_DB'])
|
||||
c.execute('pragma journal_mode=wal')
|
||||
c.execute('create table fixture(value)')
|
||||
c.execute("insert into fixture values ('keep')")
|
||||
c.commit()
|
||||
print('ready', flush=True)
|
||||
sys.stdin.readline()
|
||||
c.close()
|
||||
'''
|
||||
|
||||
def setup(name):
|
||||
home = scratch / name
|
||||
runtime = home / '.hermes/hermes-agent'
|
||||
runtime.mkdir(parents=True)
|
||||
(runtime / '.hermes-bootstrap-complete').touch()
|
||||
(home / '.config/Hermes').mkdir(parents=True)
|
||||
env = {**os.environ, 'HOME': str(home), 'PATH': f"{scratch / 'bin'}:/usr/bin:/bin",
|
||||
'OMARCHY_TEST_GUM_STATUS': '0'}
|
||||
for key in ('DROP', 'INSTALLER', 'SYSTEMCTL', 'GUM'):
|
||||
log = home / (key + '.log')
|
||||
log.touch()
|
||||
env['OMARCHY_TEST_' + key + '_LOG'] = str(log)
|
||||
return home, runtime, env
|
||||
|
||||
def writer(db):
|
||||
child = subprocess.Popen([sys.executable, '-u', '-c', writer_code],
|
||||
env={**os.environ, 'TEST_DB': str(db)},
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
|
||||
assert child.stdout.readline().strip() == 'ready'
|
||||
return child
|
||||
|
||||
def stop(child):
|
||||
if child.poll() is None:
|
||||
child.stdin.write('\n')
|
||||
child.stdin.flush()
|
||||
child.wait(timeout=5)
|
||||
|
||||
def remove(env):
|
||||
master, slave = pty.openpty()
|
||||
try:
|
||||
return subprocess.run(['bash', str(scratch / 'remover')], env=env,
|
||||
stdin=slave, capture_output=True, text=True, timeout=10)
|
||||
finally:
|
||||
os.close(master)
|
||||
os.close(slave)
|
||||
|
||||
def blocked(result, home, runtime, child):
|
||||
assert result.returncode != 0 and str(child.pid) in result.stderr, result
|
||||
assert 'Close Hermes' in result.stderr, result.stderr
|
||||
assert (runtime / '.hermes-bootstrap-complete').exists()
|
||||
assert all((home / (name + '.log')).stat().st_size == 0
|
||||
for name in ('DROP', 'INSTALLER', 'SYSTEMCTL', 'GUM'))
|
||||
assert child.poll() is None, 'remover must not kill sessions'
|
||||
|
||||
for deleted in (False, True):
|
||||
home, runtime, env = setup('deleted-writer' if deleted else 'live-writer')
|
||||
db = home / '.hermes/state.db'
|
||||
child = writer(db)
|
||||
try:
|
||||
if deleted:
|
||||
for suffix in ('', '-wal', '-shm'):
|
||||
Path(str(db) + suffix).unlink()
|
||||
db.write_bytes(b'new database generation')
|
||||
blocked(remove(env), home, runtime, child)
|
||||
if deleted:
|
||||
assert db.read_bytes() == b'new database generation'
|
||||
finally:
|
||||
stop(child)
|
||||
assert remove(env).returncode == 0, 'removal succeeds once the writer closes'
|
||||
assert not (home / '.hermes').exists()
|
||||
print('ok - live and deleted SQLite holders block removal before any side effects; closing them allows retry')
|
||||
|
||||
for kind in ('terminal', 'desktop', 'working-directory'):
|
||||
home, runtime, env = setup(kind)
|
||||
executable_name = str(scratch / 'package/Hermes') if kind == 'desktop' else str(runtime / 'hermes')
|
||||
args = ['sleep', '30'] if kind == 'working-directory' else [executable_name, '30']
|
||||
child = subprocess.Popen(args, executable='/usr/bin/sleep',
|
||||
cwd=runtime if kind == 'working-directory' else scratch)
|
||||
try:
|
||||
blocked(remove(env), home, runtime, child)
|
||||
finally:
|
||||
child.terminate()
|
||||
child.wait(timeout=5)
|
||||
print('ok - terminal, packaged desktop and runtime working-directory processes are detected without a database')
|
||||
|
||||
home, runtime, env = setup('unrelated-writer')
|
||||
sibling = home / '.hermes-other'
|
||||
sibling.mkdir()
|
||||
child = writer(sibling / 'state.db')
|
||||
try:
|
||||
assert remove(env).returncode == 0, 'a sibling database does not block Hermes removal'
|
||||
assert child.poll() is None
|
||||
finally:
|
||||
stop(child)
|
||||
print('ok - unrelated database holders are left alone')
|
||||
|
||||
home, runtime, env = setup('prompt-race')
|
||||
gate = home / 'prompt'
|
||||
env['OMARCHY_TEST_PROMPT_GATE'] = str(gate)
|
||||
master, slave = pty.openpty()
|
||||
remover = subprocess.Popen(['bash', str(scratch / 'remover')], env=env, stdin=slave,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
os.close(slave)
|
||||
child = None
|
||||
try:
|
||||
deadline = time.monotonic() + 5
|
||||
while not Path(str(gate) + '.started').exists():
|
||||
assert remover.poll() is None and time.monotonic() < deadline, 'prompt was not reached'
|
||||
time.sleep(0.01)
|
||||
child = writer(home / '.hermes/state.db')
|
||||
Path(str(gate) + '.continue').touch()
|
||||
stdout, stderr = remover.communicate(timeout=10)
|
||||
assert remover.returncode != 0 and str(child.pid) in stderr, (stdout, stderr)
|
||||
assert (home / '.hermes/state.db-wal').exists()
|
||||
assert (home / '.config/Hermes').exists()
|
||||
finally:
|
||||
if child is not None:
|
||||
stop(child)
|
||||
if remover.poll() is None:
|
||||
remover.terminate()
|
||||
remover.wait(timeout=5)
|
||||
os.close(master)
|
||||
print('ok - a writer started during confirmation blocks data deletion')
|
||||
PY
|
||||
Executable
+75
@@ -0,0 +1,75 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(dirname "$0")/base-test.sh"
|
||||
|
||||
migration="$ROOT/migrations/1787843905.sh"
|
||||
[[ -f $migration ]] || fail "Hermes skills migration exists"
|
||||
|
||||
test_dir=$(mktemp -d)
|
||||
trap 'rm -rf "$test_dir"' EXIT
|
||||
home="$test_dir/home"
|
||||
|
||||
run_migration() {
|
||||
HOME="$home" OMARCHY_PATH="$ROOT" bash -euo pipefail "$migration" >/dev/null ||
|
||||
fail "migration exits clean"
|
||||
}
|
||||
|
||||
assert_link() {
|
||||
local link="$1"
|
||||
local skill="$2"
|
||||
local description="$3"
|
||||
|
||||
[[ -L $link && $(readlink "$link") == "$ROOT/default/agents/skills/$skill" ]] ||
|
||||
fail "$description" "$link -> $(readlink "$link" 2>/dev/null || echo missing)"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ default home, no profiles
|
||||
|
||||
rm -rf "$home"
|
||||
mkdir -p "$home"
|
||||
run_migration
|
||||
|
||||
for skill in omarchy diagnose-crash; do
|
||||
assert_link "$home/.hermes/skills/$skill" "$skill" "migration links $skill into the default Hermes home"
|
||||
done
|
||||
[[ -e $home/.hermes/profiles ]] && fail "migration does not create Hermes profiles"
|
||||
pass "migration links the default Hermes home and does not create profiles"
|
||||
|
||||
run_migration
|
||||
for skill in omarchy diagnose-crash; do
|
||||
assert_link "$home/.hermes/skills/$skill" "$skill" "migration is idempotent on the default home for $skill"
|
||||
done
|
||||
pass "migration is idempotent on the default home"
|
||||
|
||||
# ------------------------------------------------------------------ pre-existing profile
|
||||
|
||||
rm -rf "$home"
|
||||
mkdir -p "$home/.hermes/profiles/james"
|
||||
run_migration
|
||||
|
||||
for skill in omarchy diagnose-crash; do
|
||||
assert_link "$home/.hermes/skills/$skill" "$skill" "migration links $skill into the default Hermes home when a profile exists"
|
||||
assert_link "$home/.hermes/profiles/james/skills/$skill" "$skill" "migration links $skill into a pre-existing Hermes profile"
|
||||
done
|
||||
[[ -d $home/.hermes/profiles/james ]] || fail "migration leaves the pre-existing profile in place"
|
||||
profile_count=$(find "$home/.hermes/profiles" -mindepth 1 -maxdepth 1 -type d | wc -l)
|
||||
(( profile_count == 1 )) || fail "migration does not create extra profiles" "count=$profile_count"
|
||||
pass "migration links a pre-existing Hermes profile and does not create extras"
|
||||
|
||||
run_migration
|
||||
for skill in omarchy diagnose-crash; do
|
||||
assert_link "$home/.hermes/skills/$skill" "$skill" "migration is idempotent on the default home when a profile exists for $skill"
|
||||
assert_link "$home/.hermes/profiles/james/skills/$skill" "$skill" "migration is idempotent on a pre-existing profile for $skill"
|
||||
done
|
||||
pass "migration is idempotent on a pre-existing profile"
|
||||
|
||||
# ------------------------------------------------------------------ missing skill source
|
||||
|
||||
rm -rf "$home"
|
||||
mkdir -p "$home" "$test_dir/empty-omarchy"
|
||||
HOME="$home" OMARCHY_PATH="$test_dir/empty-omarchy" bash -euo pipefail "$migration" >/dev/null ||
|
||||
fail "migration exits clean when the skill source is missing"
|
||||
[[ -e $home/.hermes ]] && fail "migration no-ops when the skill source is missing"
|
||||
pass "migration no-ops when the skill source is missing"
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# The migration hands an existing Hermes Desktop install the Omarchy skin. It
|
||||
# is exercised here with the package probe and the skin hook stubbed, so a
|
||||
# migration that reached a Hermes Omarchy did not install, or that marked a
|
||||
# failed hand-over done, shows up in what it ran.
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
migration="$ROOT/migrations/1788619462.sh"
|
||||
[[ -f $migration ]] || fail "Hermes skin migration exists"
|
||||
[[ $(stat -c %a "$migration") == "644" ]] || fail "migration is a plain 0644 file"
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
mock_bin="$test_tmp/bin"
|
||||
calls="$test_tmp/calls"
|
||||
mkdir -p "$mock_bin"
|
||||
|
||||
cat >"$mock_bin/omarchy-pkg-present" <<'SH'
|
||||
#!/bin/bash
|
||||
[[ $1 == "hermes-desktop" && ${OMARCHY_TEST_DESKTOP_INSTALLED:-0} == 1 ]]
|
||||
SH
|
||||
|
||||
cat >"$mock_bin/omarchy-theme-set-hermes" <<'SH'
|
||||
#!/bin/bash
|
||||
echo "omarchy-theme-set-hermes $*" >>"$OMARCHY_TEST_CALLS"
|
||||
[[ ${OMARCHY_TEST_HOOK_FAILS:-0} == 0 ]]
|
||||
SH
|
||||
|
||||
chmod +x "$mock_bin"/*
|
||||
|
||||
run_migration() {
|
||||
: >"$calls"
|
||||
OMARCHY_TEST_DESKTOP_INSTALLED="${OMARCHY_TEST_DESKTOP_INSTALLED:-1}" \
|
||||
OMARCHY_TEST_HOOK_FAILS="${OMARCHY_TEST_HOOK_FAILS:-0}" \
|
||||
OMARCHY_TEST_CALLS="$calls" \
|
||||
PATH="$mock_bin:$PATH" \
|
||||
HOME="$test_tmp/home" \
|
||||
OMARCHY_PATH="$ROOT" \
|
||||
bash -euo pipefail "$migration" >/dev/null
|
||||
}
|
||||
|
||||
OMARCHY_TEST_DESKTOP_INSTALLED=0 run_migration || fail "migration exits clean without Hermes Desktop"
|
||||
[[ ! -s $calls ]] || fail "a machine without Hermes Desktop is left alone" "$(cat "$calls")"
|
||||
pass "migration only applies where Omarchy installed Hermes Desktop"
|
||||
|
||||
run_migration || fail "migration exits clean with Hermes Desktop installed"
|
||||
[[ $(cat "$calls") == "omarchy-theme-set-hermes --activate" ]] ||
|
||||
fail "the skin is rendered, published and activated through the hook's deliberate form" "$(cat "$calls")"
|
||||
pass "migration hands the skin over through the hook"
|
||||
|
||||
if OMARCHY_TEST_HOOK_FAILS=1 run_migration; then
|
||||
fail "a hand-over that failed on Omarchy's side stays pending"
|
||||
fi
|
||||
pass "migration stays pending when the hand-over fails"
|
||||
@@ -0,0 +1,401 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# omarchy-theme-set-hermes writes a file another program parses and asks that
|
||||
# program to switch to it while it is still on its default. Both are exercised
|
||||
# here against a throwaway HOME with the Hermes readiness probe, the hermes
|
||||
# command and the theme refresh stubbed, so a skin that stopped being
|
||||
# validated, a write into a Hermes that was never set up, or an activation
|
||||
# that trampled a chosen skin shows up in what landed on disk and what was run.
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
mock_bin="$test_tmp/bin"
|
||||
mkdir -p "$mock_bin"
|
||||
|
||||
cat >"$mock_bin/omarchy-install-hermes-cli" <<'SH'
|
||||
#!/bin/bash
|
||||
echo "check" >>"$OMARCHY_TEST_HERMES_CALLS"
|
||||
[[ $1 == "--check" && ${OMARCHY_TEST_HERMES_READY:-0} == "1" ]]
|
||||
SH
|
||||
|
||||
# A theme switch finishes the hand-over only for the desktop app Omarchy
|
||||
# installed; --activate is asked for by name and does not look.
|
||||
cat >"$mock_bin/omarchy-pkg-present" <<'SH'
|
||||
#!/bin/bash
|
||||
[[ $1 == "hermes-desktop" && ${OMARCHY_TEST_DESKTOP_INSTALLED:-1} == "1" ]]
|
||||
SH
|
||||
|
||||
# The hook runs the hermes the probe vets, ~/.local/bin/hermes, not one on PATH;
|
||||
# reset_home installs this stub there and a decoy on PATH that must never run.
|
||||
cat >"$mock_bin/hermes" <<'SH'
|
||||
#!/bin/bash
|
||||
echo "PATH hermes ran: $*" >>"$OMARCHY_TEST_HERMES_CALLS"
|
||||
exit 1
|
||||
SH
|
||||
|
||||
cat >"$mock_bin/hermes-stub" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$*" >>"$OMARCHY_TEST_HERMES_CALLS"
|
||||
if [[ $1 == "config" && $2 == "get" ]]; then
|
||||
[[ ${OMARCHY_TEST_HERMES_GET_FAILS:-0} == 0 ]] || exit 1
|
||||
printf '%s\n' "${OMARCHY_TEST_HERMES_SKIN-default}"
|
||||
fi
|
||||
if [[ $1 == "config" && $2 == "set" ]]; then
|
||||
[[ ${OMARCHY_TEST_HERMES_SET_FAILS:-0} == 0 ]] || exit 1
|
||||
fi
|
||||
SH
|
||||
|
||||
# A refresh re-stages the current theme, which is where the skin gets rendered.
|
||||
cat >"$mock_bin/omarchy-theme-refresh" <<'SH'
|
||||
#!/bin/bash
|
||||
echo "refresh" >>"$OMARCHY_TEST_HERMES_CALLS"
|
||||
printf 'name: omarchy\ndescription: Omarchy system theme\ncolors:\n background: "#1a1b26"\n' \
|
||||
>"$HOME/.local/state/omarchy/current/theme/hermes.yaml"
|
||||
SH
|
||||
|
||||
# --wait sleeps between its polls and once more after activating; the stub
|
||||
# records the delays it was asked for and returns at once.
|
||||
cat >"$mock_bin/sleep" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'sleep %s\n' "$1" >>"$OMARCHY_TEST_HERMES_CALLS"
|
||||
if [[ $1 == 60 && -n ${OMARCHY_TEST_SWAP_SOURCE:-} ]]; then
|
||||
printf '%s\n' "$OMARCHY_TEST_SWAP_SOURCE" >"$HOME/.local/state/omarchy/current/theme/hermes.yaml"
|
||||
fi
|
||||
if [[ $1 == 60 && ${OMARCHY_TEST_DROP_SKINS:-0} == 1 ]]; then
|
||||
rm -f "$HOME/.hermes/skins/omarchy.yaml" "$HOME"/.hermes/profiles/*/skins/omarchy.yaml
|
||||
fi
|
||||
SH
|
||||
|
||||
chmod +x "$mock_bin"/*
|
||||
|
||||
good_skin='name: omarchy
|
||||
description: Omarchy system theme
|
||||
colors:
|
||||
background: "#1a1b26"
|
||||
ui_text: "#a9b1d6"
|
||||
ui_accent: "#7aa2f7"'
|
||||
|
||||
test_home="$test_tmp/home"
|
||||
hermes_home="$test_home/.hermes"
|
||||
skin="$hermes_home/skins/omarchy.yaml"
|
||||
hermes_calls="$test_tmp/hermes-calls"
|
||||
|
||||
# Each case gets a fresh HOME so no file survives from the one before. The
|
||||
# Hermes home is created the way provisioning does on every machine; only
|
||||
# --set-up adds the config that says Hermes itself has run, on its default
|
||||
# skin unless --on names another.
|
||||
reset_home() {
|
||||
local source="$good_skin"
|
||||
|
||||
rm -rf "$test_home"
|
||||
mkdir -p "$test_home/.local/state/omarchy/current/theme" "$test_home/.local/bin" "$hermes_home/skills"
|
||||
cp "$mock_bin/hermes-stub" "$test_home/.local/bin/hermes"
|
||||
: >"$hermes_calls"
|
||||
|
||||
while (( $# > 0 )); do
|
||||
case "$1" in
|
||||
--set-up) printf 'display:\n skin: default\n' >"$hermes_home/config.yaml" ;;
|
||||
--on) printf 'display:\n skin: %s\n' "$2" >"$hermes_home/config.yaml"; shift ;;
|
||||
*) source="$1" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
printf '%s\n' "$source" >"$test_home/.local/state/omarchy/current/theme/hermes.yaml"
|
||||
}
|
||||
|
||||
run_hook() {
|
||||
OMARCHY_TEST_HERMES_READY="${OMARCHY_TEST_HERMES_READY:-0}" \
|
||||
OMARCHY_TEST_HERMES_SKIN="${OMARCHY_TEST_HERMES_SKIN-default}" \
|
||||
OMARCHY_TEST_HERMES_GET_FAILS="${OMARCHY_TEST_HERMES_GET_FAILS:-0}" \
|
||||
OMARCHY_TEST_HERMES_SET_FAILS="${OMARCHY_TEST_HERMES_SET_FAILS:-0}" \
|
||||
OMARCHY_TEST_HERMES_CALLS="$hermes_calls" \
|
||||
OMARCHY_TEST_DESKTOP_INSTALLED="${OMARCHY_TEST_DESKTOP_INSTALLED:-1}" \
|
||||
OMARCHY_TEST_SWAP_SOURCE="${OMARCHY_TEST_SWAP_SOURCE:-}" \
|
||||
OMARCHY_TEST_DROP_SKINS="${OMARCHY_TEST_DROP_SKINS:-0}" \
|
||||
PATH="$mock_bin:$PATH" \
|
||||
HOME="$test_home" \
|
||||
HERMES_HOME='' \
|
||||
"$ROOT/bin/omarchy-theme-set-hermes" "$@"
|
||||
}
|
||||
|
||||
# -- publishing ---------------------------------------------------------------
|
||||
|
||||
reset_home
|
||||
run_hook
|
||||
[[ ! -e $hermes_home/skins ]] || fail "a Hermes home that only holds the Omarchy skill gets no skin"
|
||||
[[ ! -s $hermes_calls ]] || fail "nothing is run for a Hermes that never ran" "$(cat "$hermes_calls")"
|
||||
pass "a theme switch leaves a machine that never ran Hermes alone"
|
||||
|
||||
reset_home --set-up
|
||||
mkdir -p "$hermes_home/profiles/work"
|
||||
run_hook 2>"$test_tmp/stderr"
|
||||
diff -q "$test_home/.local/state/omarchy/current/theme/hermes.yaml" "$skin" >/dev/null ||
|
||||
fail "the generated skin is published to ~/.hermes/skins/omarchy.yaml"
|
||||
diff -q "$skin" "$hermes_home/profiles/work/skins/omarchy.yaml" >/dev/null ||
|
||||
fail "an existing Hermes profile gets the skin too"
|
||||
[[ $(ls "$hermes_home/skins") == "omarchy.yaml" ]] || fail "no temporary file is left beside the skin"
|
||||
[[ $(cat "$hermes_calls") == "check" ]] || fail "a Hermes that is not ready is asked nothing more" "$(cat "$hermes_calls")"
|
||||
[[ ! -s $test_tmp/stderr ]] || fail "a theme switch says nothing about Hermes" "$(cat "$test_tmp/stderr")"
|
||||
pass "the skin is published to the Hermes home and every profile"
|
||||
|
||||
reset_home --set-up 'name: omarchy
|
||||
description: Omarchy system theme
|
||||
colors:
|
||||
background: "{{ background }}"'
|
||||
mkdir -p "$hermes_home/skins"
|
||||
printf 'name: omarchy\ncolors:\n background: "#000000"\n' >"$skin"
|
||||
run_hook 2>"$test_tmp/stderr"
|
||||
grep -q '#000000' "$skin" || fail "an unresolved placeholder keeps the previous skin in place"
|
||||
grep -q 'not a plain color palette' "$test_tmp/stderr" || fail "an unresolved placeholder is reported"
|
||||
pass "a skin with unresolved colors is not published"
|
||||
|
||||
# mv would otherwise move the temp file inside a directory at the skin's path,
|
||||
# leaving Hermes a directory to read and the temp file behind.
|
||||
reset_home --set-up
|
||||
mkdir -p "$skin"
|
||||
if run_hook 2>/dev/null; then
|
||||
fail "a directory at the skin's path is an error, not a place to put the skin"
|
||||
fi
|
||||
[[ -z $(ls -A "$skin") && $(ls "$hermes_home/skins") == "omarchy.yaml" ]] ||
|
||||
fail "nothing is left inside or beside a directory at the skin's path" "$(ls -R "$hermes_home/skins")"
|
||||
pass "a directory at the skin's path is refused cleanly"
|
||||
|
||||
for bad in \
|
||||
$'name: omarchy\ndescription: Omarchy system theme\ncolors:\n background: "#1a1b26"\nbanner_logo: "[link=file:///etc/passwd]x[/link]"' \
|
||||
$'name: omarchy\ndescription: Omarchy system theme\ncolors:\n background: "#1a1b26\\"\\n ui_text: \\"#ffffff"' \
|
||||
$'name: nord\ndescription: Nord\ncolors:\n background: "#2e3440"' \
|
||||
$'description: Omarchy system theme\ncolors:\n background: "#1a1b26"' \
|
||||
$'name: omarchy\ndescription: Nord: arctic palette\ncolors:\n background: "#2e3440"' \
|
||||
$'name: omarchy\ncolors:\n background: "#1a1b26"\n#\rbanner_logo: "[link=file:///etc/passwd]x[/link]"' \
|
||||
$'name: omarchy\ncolors:\n background: "#1a1b26"\n#\xe2\x80\xa8banner_logo: "evil"' \
|
||||
$'name: omarchy\ncolors:\n background: "#1a1b26"\n#\xc2\x85banner_logo: "evil"' \
|
||||
$'name: omarchy\n background: "#1a1b26"\ncolors:' \
|
||||
$'name: omarchy\ncolors:\n background: "#1a1b26"\ncolors:' \
|
||||
$'colors:\n background: "#1a1b26"\nname: omarchy' \
|
||||
$'name: omarchy\ncolors:'; do
|
||||
reset_home --set-up "$bad"
|
||||
run_hook 2>/dev/null
|
||||
[[ ! -e $skin ]] || fail "a skin that is not exactly a named palette of hex colors is not published" "$bad"
|
||||
done
|
||||
pass "a skin is held to the shape Hermes loads, on the lines Hermes' YAML reader sees"
|
||||
|
||||
# A NUL cannot travel through a shell string, so it is written straight to the
|
||||
# source; grep reads past one where YAML stops.
|
||||
reset_home --set-up
|
||||
printf 'name: omarchy\ncolors:\n background: "#1a1b26"\0\n' >"$test_home/.local/state/omarchy/current/theme/hermes.yaml"
|
||||
run_hook 2>/dev/null
|
||||
[[ ! -e $skin ]] || fail "a NUL byte in the skin is rejected"
|
||||
pass "a skin carrying a NUL byte is not published"
|
||||
|
||||
reset_home --set-up $'# rendered by Omarchy\nname: omarchy\n\ndescription: Omarchy system theme\ncolors:\n background: "#1a1b26"\n'
|
||||
run_hook 2>/dev/null
|
||||
[[ -f $skin ]] || fail "comments and blank lines are allowed around the palette"
|
||||
pass "a well-formed skin with comments and blank lines is published"
|
||||
|
||||
# -- a theme switch finishes a missed hand-over ---------------------------------
|
||||
|
||||
reset_home --set-up
|
||||
OMARCHY_TEST_HERMES_READY=1 run_hook 2>"$test_tmp/stderr"
|
||||
[[ $(cat "$hermes_calls") == $'check\nconfig get display.skin\nconfig set display.skin omarchy' ]] ||
|
||||
fail "a ready Hermes still on its default is switched by a theme switch" "$(cat "$hermes_calls")"
|
||||
[[ ! -s $test_tmp/stderr ]] || fail "a theme switch activates quietly" "$(cat "$test_tmp/stderr")"
|
||||
pass "a theme switch activates the skin on a Hermes still on its default"
|
||||
|
||||
reset_home --on omarchy
|
||||
OMARCHY_TEST_HERMES_READY=1 run_hook
|
||||
[[ -f $skin ]] || fail "the skin is published when it is already active"
|
||||
[[ ! -s $hermes_calls ]] || fail "a Hermes already on the skin is not started" "$(cat "$hermes_calls")"
|
||||
pass "a theme switch does not start a Hermes already on the skin"
|
||||
|
||||
reset_home --set-up
|
||||
OMARCHY_TEST_HERMES_READY=1 OMARCHY_TEST_DESKTOP_INSTALLED=0 run_hook
|
||||
[[ -f $skin ]] || fail "a Hermes installed some other way still gets the skin published"
|
||||
[[ ! -s $hermes_calls ]] || fail "a theme switch does not touch a Hermes Omarchy did not install as the app" "$(cat "$hermes_calls")"
|
||||
pass "a theme switch activates only for the desktop app Omarchy installed"
|
||||
|
||||
reset_home --set-up
|
||||
OMARCHY_TEST_HERMES_READY=1 OMARCHY_TEST_DESKTOP_INSTALLED=0 run_hook --activate 2>/dev/null
|
||||
grep -Fxq 'config set display.skin omarchy' "$hermes_calls" ||
|
||||
fail "--activate switches whichever Hermes it is asked about" "$(cat "$hermes_calls")"
|
||||
pass "--activate does not ask which Hermes it is"
|
||||
|
||||
reset_home --on ares
|
||||
OMARCHY_TEST_HERMES_READY=1 OMARCHY_TEST_HERMES_SKIN=ares run_hook 2>"$test_tmp/stderr"
|
||||
[[ -f $skin ]] || fail "a chosen skin still gets the Omarchy skin published beside it"
|
||||
[[ ! -s $hermes_calls ]] || fail "a theme switch does not start a Hermes whose config names a chosen skin" "$(cat "$hermes_calls")"
|
||||
[[ ! -s $test_tmp/stderr ]] || fail "a chosen skin is left without comment on a theme switch" "$(cat "$test_tmp/stderr")"
|
||||
pass "a theme switch leaves a skin the user chose in Hermes"
|
||||
|
||||
# Hermes reads the config of the profile named in active_profile, so that is
|
||||
# the config that says whether there is anything left to do.
|
||||
reset_home --on omarchy
|
||||
mkdir -p "$hermes_home/profiles/work"
|
||||
printf 'display:\n skin: default\n' >"$hermes_home/profiles/work/config.yaml"
|
||||
echo work >"$hermes_home/active_profile"
|
||||
OMARCHY_TEST_HERMES_READY=1 run_hook
|
||||
grep -Fxq 'config set display.skin omarchy' "$hermes_calls" ||
|
||||
fail "an active profile still on its default is switched even when the root config names the skin" "$(cat "$hermes_calls")"
|
||||
pass "a theme switch follows the active Hermes profile"
|
||||
|
||||
reset_home --set-up
|
||||
mkdir -p "$hermes_home/profiles/work"
|
||||
printf 'display:\n skin: ares\n' >"$hermes_home/profiles/work/config.yaml"
|
||||
echo work >"$hermes_home/active_profile"
|
||||
OMARCHY_TEST_HERMES_READY=1 run_hook
|
||||
[[ ! -s $hermes_calls ]] || fail "a skin chosen in the active profile is not replaced" "$(cat "$hermes_calls")"
|
||||
pass "a theme switch leaves a skin chosen in the active Hermes profile"
|
||||
|
||||
# Hermes selects a profile once its directory exists; without a config of its
|
||||
# own it is on the default skin whatever the root config says.
|
||||
reset_home --on omarchy
|
||||
mkdir -p "$hermes_home/profiles/work"
|
||||
echo Work >"$hermes_home/active_profile"
|
||||
OMARCHY_TEST_HERMES_READY=1 run_hook
|
||||
grep -Fxq 'config set display.skin omarchy' "$hermes_calls" ||
|
||||
fail "an active profile without a config of its own is on the default and gets switched" "$(cat "$hermes_calls")"
|
||||
pass "a theme switch follows an active profile that has no config yet"
|
||||
|
||||
# Only a plainly named skin ends a switch early; anything Hermes might read as
|
||||
# its default is left for Hermes to answer.
|
||||
for line in 'skin: "default"' 'skin: default # chosen long ago' 'skin: null' 'skin: false'; do
|
||||
reset_home --set-up
|
||||
printf 'display:\n %s\n' "$line" >"$hermes_home/config.yaml"
|
||||
OMARCHY_TEST_HERMES_READY=1 run_hook
|
||||
grep -Fxq 'config set display.skin omarchy' "$hermes_calls" ||
|
||||
fail "a config line Hermes reads as the default still gets the skin activated" "$line: $(cat "$hermes_calls")"
|
||||
done
|
||||
pass "a theme switch asks Hermes about any skin line that is not a plain name"
|
||||
|
||||
reset_home --set-up
|
||||
mkdir -p "$hermes_home/profiles/broken"
|
||||
: >"$hermes_home/profiles/broken/skins"
|
||||
OMARCHY_TEST_HERMES_READY=1 run_hook 2>"$test_tmp/stderr" || fail "a profile that cannot take the skin does not fail the hook"
|
||||
[[ ! -s $test_tmp/stderr ]] || fail "a theme switch stays quiet about a profile it could not reach" "$(cat "$test_tmp/stderr")"
|
||||
[[ -f $skin ]] || fail "the Hermes home still gets the skin beside a broken profile"
|
||||
grep -Fxq 'config set display.skin omarchy' "$hermes_calls" ||
|
||||
fail "activation still happens beside a broken profile" "$(cat "$hermes_calls")"
|
||||
pass "a profile that cannot take the skin costs nobody else"
|
||||
|
||||
# -- activation ---------------------------------------------------------------
|
||||
|
||||
reset_home
|
||||
run_hook --activate 2>"$test_tmp/stderr"
|
||||
[[ ! -e $hermes_home/skins && ! -e $hermes_home/config.yaml ]] ||
|
||||
fail "--activate writes nothing into a Hermes that has never run"
|
||||
grep -q 'not set up yet' "$test_tmp/stderr" || fail "--activate says why nothing happened"
|
||||
pass "--activate waits for Hermes to have been set up"
|
||||
|
||||
reset_home --set-up
|
||||
run_hook --activate 2>"$test_tmp/stderr"
|
||||
[[ -f $skin ]] || fail "--activate publishes the skin when Hermes is not ready"
|
||||
[[ $(cat "$hermes_calls") == "check" ]] || fail "a Hermes that is not ready is not run" "$(cat "$hermes_calls")"
|
||||
grep -q 'hermes config set display.skin omarchy' "$test_tmp/stderr" || fail "an unready Hermes gets the command that finishes the job"
|
||||
pass "--activate publishes but does not run a Hermes that is not ready"
|
||||
|
||||
reset_home --set-up
|
||||
OMARCHY_TEST_HERMES_READY=1 run_hook --activate 2>"$test_tmp/stderr"
|
||||
[[ $(cat "$hermes_calls") == $'check\nconfig get display.skin\nconfig set display.skin omarchy' ]] ||
|
||||
fail "a ready Hermes is asked for its skin and then to switch" "$(cat "$hermes_calls")"
|
||||
grep -q 'on the Omarchy skin' "$test_tmp/stderr" || fail "--activate reports success"
|
||||
pass "--activate goes through hermes config set when Hermes runs"
|
||||
|
||||
reset_home --on omarchy
|
||||
OMARCHY_TEST_HERMES_READY=1 OMARCHY_TEST_HERMES_SKIN=omarchy run_hook --activate 2>/dev/null
|
||||
grep -Fxq 'config set display.skin omarchy' "$hermes_calls" ||
|
||||
fail "--activate goes through Hermes even when the config already names the skin" "$(cat "$hermes_calls")"
|
||||
pass "--activate always asks Hermes to switch"
|
||||
|
||||
for chosen in omarchy ""; do
|
||||
reset_home --set-up
|
||||
OMARCHY_TEST_HERMES_READY=1 OMARCHY_TEST_HERMES_SKIN="$chosen" run_hook --activate 2>/dev/null
|
||||
grep -Fxq 'config set display.skin omarchy' "$hermes_calls" ||
|
||||
fail "the default and the omarchy skin are both replaced" "skin='$chosen': $(cat "$hermes_calls")"
|
||||
done
|
||||
pass "--activate replaces Hermes' default skin"
|
||||
|
||||
reset_home --set-up
|
||||
OMARCHY_TEST_HERMES_READY=1 OMARCHY_TEST_HERMES_SKIN=ares run_hook --activate 2>"$test_tmp/stderr"
|
||||
[[ -f $skin ]] || fail "a chosen skin still gets the Omarchy skin published beside it"
|
||||
! grep -q 'config set' "$hermes_calls" || fail "a skin the user chose is not replaced" "$(cat "$hermes_calls")"
|
||||
grep -q "'ares' skin" "$test_tmp/stderr" || fail "leaving a chosen skin is reported"
|
||||
pass "--activate leaves a skin the user chose in Hermes"
|
||||
|
||||
reset_home --set-up
|
||||
OMARCHY_TEST_HERMES_READY=1 OMARCHY_TEST_HERMES_GET_FAILS=1 run_hook --activate 2>"$test_tmp/stderr" || fail "a Hermes that does not answer is not an error"
|
||||
! grep -q 'config set' "$hermes_calls" || fail "no answer from Hermes is not taken for the default" "$(cat "$hermes_calls")"
|
||||
grep -q 'did not say' "$test_tmp/stderr" || fail "an unanswered question is reported"
|
||||
pass "--activate does not switch a Hermes that did not say which skin it is on"
|
||||
|
||||
reset_home --set-up
|
||||
mkdir -p "$hermes_home/hermes-agent"
|
||||
touch "$hermes_home/hermes-agent/.hermes-bootstrap-complete"
|
||||
OMARCHY_TEST_HERMES_READY=1 OMARCHY_TEST_HERMES_SET_FAILS=1 run_hook --wait 2>"$test_tmp/stderr" || fail "a Hermes that refuses the write is not an error"
|
||||
grep -q 'refused' "$test_tmp/stderr" || fail "a refused write is reported"
|
||||
! grep -q 'sleep 60' "$hermes_calls" || fail "nothing is announced for a write that did not happen" "$(cat "$hermes_calls")"
|
||||
pass "--activate reports a Hermes that refused the skin and moves on"
|
||||
|
||||
# -- a skin the current theme has not rendered yet -------------------------------
|
||||
|
||||
reset_home --set-up
|
||||
rm "$test_home/.local/state/omarchy/current/theme/hermes.yaml"
|
||||
run_hook
|
||||
[[ ! -e $hermes_home/skins && ! -s $hermes_calls ]] ||
|
||||
fail "a theme switch without a rendered skin publishes nothing" "$(cat "$hermes_calls")"
|
||||
pass "a theme switch has nothing to do without a rendered skin"
|
||||
|
||||
reset_home --set-up
|
||||
rm "$test_home/.local/state/omarchy/current/theme/hermes.yaml"
|
||||
if run_hook --activate 2>"$test_tmp/stderr"; then
|
||||
fail "--activate fails when no theme has been selected"
|
||||
fi
|
||||
grep -q 'Select an Omarchy theme' "$test_tmp/stderr" || fail "a missing theme is reported"
|
||||
pass "--activate fails without a current theme to render the skin from"
|
||||
|
||||
reset_home --set-up
|
||||
rm "$test_home/.local/state/omarchy/current/theme/hermes.yaml"
|
||||
echo tokyo-night >"$test_home/.local/state/omarchy/current/theme.name"
|
||||
OMARCHY_TEST_HERMES_READY=1 run_hook --activate 2>/dev/null
|
||||
[[ $(head -1 "$hermes_calls") == "refresh" ]] || fail "a theme applied before the template existed is re-staged" "$(cat "$hermes_calls")"
|
||||
[[ -f $skin ]] || fail "the freshly rendered skin is published"
|
||||
grep -Fxq 'config set display.skin omarchy' "$hermes_calls" || fail "the freshly rendered skin is activated" "$(cat "$hermes_calls")"
|
||||
pass "--activate renders the skin for a theme that predates it"
|
||||
|
||||
# -- waiting for the desktop app's first launch ----------------------------------
|
||||
|
||||
reset_home --set-up
|
||||
mkdir -p "$hermes_home/hermes-agent"
|
||||
touch "$hermes_home/hermes-agent/.hermes-bootstrap-complete"
|
||||
OMARCHY_TEST_HERMES_READY=1 run_hook --wait 2>/dev/null
|
||||
[[ $(cat "$hermes_calls") == $'check\nconfig get display.skin\nconfig set display.skin omarchy\nsleep 60' ]] ||
|
||||
fail "--wait activates as soon as the runtime marker is there, then republishes after the gateway is up" "$(cat "$hermes_calls")"
|
||||
[[ -f $skin ]] || fail "--wait publishes the skin"
|
||||
pass "--wait activates once the desktop app has built its runtime"
|
||||
|
||||
reset_home --set-up
|
||||
mkdir -p "$hermes_home/hermes-agent" "$hermes_home/profiles/work"
|
||||
touch "$hermes_home/hermes-agent/.hermes-bootstrap-complete"
|
||||
OMARCHY_TEST_HERMES_READY=1 OMARCHY_TEST_SWAP_SOURCE=$'name: omarchy\ncolors:\n background: "#1a1b26"\nbanner_logo: "evil"' run_hook --wait 2>/dev/null
|
||||
! grep -q 'banner_logo' "$skin" || fail "the republish after the gateway is up does not publish a theme that changed underneath into something rejected"
|
||||
pass "--wait checks the skin again before republishing it"
|
||||
|
||||
reset_home --set-up
|
||||
mkdir -p "$hermes_home/hermes-agent" "$hermes_home/profiles/work"
|
||||
touch "$hermes_home/hermes-agent/.hermes-bootstrap-complete"
|
||||
OMARCHY_TEST_HERMES_READY=1 OMARCHY_TEST_DROP_SKINS=1 run_hook --wait 2>/dev/null
|
||||
[[ -f $skin && -f $hermes_home/profiles/work/skins/omarchy.yaml ]] ||
|
||||
fail "the republish after the gateway is up writes the skin to the Hermes home and every profile again" "$(ls -R "$hermes_home")"
|
||||
pass "--wait republishes the skin everywhere once the gateway is up"
|
||||
|
||||
reset_home
|
||||
OMARCHY_TEST_HERMES_READY=1 run_hook --wait 2>"$test_tmp/stderr"
|
||||
[[ $(head -1 "$hermes_calls") == "sleep 10" && ! -e $hermes_home/skins ]] ||
|
||||
fail "--wait polls for the runtime instead of running Hermes" "$(head -3 "$hermes_calls")"
|
||||
[[ $(grep -c 'sleep 10' "$hermes_calls") == 180 ]] || fail "--wait gives up after 30 minutes" "$(grep -c 'sleep 10' "$hermes_calls")"
|
||||
grep -q 'did not finish setting up' "$test_tmp/stderr" || fail "giving up is reported"
|
||||
pass "--wait polls until the desktop app has built its runtime and gives up in time"
|
||||
@@ -40,6 +40,13 @@ hl = setmetatable({
|
||||
release = opts.release == true,
|
||||
})
|
||||
end,
|
||||
unbind = function(keys)
|
||||
for index = #bindings, 1, -1 do
|
||||
if bindings[index].keys == keys then
|
||||
table.remove(bindings, index)
|
||||
end
|
||||
end
|
||||
end,
|
||||
config = function() end,
|
||||
env = function() end,
|
||||
monitor = function() end,
|
||||
@@ -188,3 +195,17 @@ probe=$(PATH="$stub_bin:$PATH" list_bindings "$home" \
|
||||
grep -Fqx "ALT+SHIFT+SUPER+RIGHT" <<<"$probe" ||
|
||||
fail "the conflict check ignores modifier order"
|
||||
pass "the conflict check catches collisions across keycodes and modifier order"
|
||||
|
||||
rebound=$(PATH="$stub_bin:$PATH" list_bindings "$home" \
|
||||
'o.rebind("SUPER + SHIFT + F", "Flea", { launch = "flea" })' | \
|
||||
awk -F'\t' '$1 == "SHIFT+SUPER+F"')
|
||||
[[ $rebound == $'SHIFT+SUPER+F\tSUPER + SHIFT + F\tFlea' ]] ||
|
||||
fail "rebinding replaces the default file manager without stacking actions" "$rebound"
|
||||
pass "rebinding replaces the default file manager without stacking actions"
|
||||
|
||||
rebound=$(PATH="$stub_bin:$PATH" list_bindings "$home" \
|
||||
'o.rebind("F9", "Dictation on release", "voxtype record toggle", { release = true })' | \
|
||||
awk -F'\t' '$2 == "F9"')
|
||||
[[ $rebound == $'F9 (release)\tF9\tDictation on release' ]] ||
|
||||
fail "rebinding replaces all bindings for a key and preserves binding options" "$rebound"
|
||||
pass "rebinding replaces all bindings for a key and preserves binding options"
|
||||
Executable
+154
@@ -0,0 +1,154 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh"
|
||||
|
||||
test_dir=$(mktemp -d)
|
||||
trap 'rm -rf "$test_dir"' EXIT
|
||||
test_home="$test_dir/home"
|
||||
kitty_config="$test_home/.config/kitty/kitty.conf"
|
||||
legacy="$ROOT/test/shell.d/fixtures/kitty/legacy.conf"
|
||||
migration="$ROOT/migrations/1788745941.sh"
|
||||
mkdir -p "$(dirname "$kitty_config")" "$test_dir/bin"
|
||||
|
||||
run_migration() {
|
||||
env HOME="$test_home" OMARCHY_PATH="$ROOT" PATH="$ROOT/bin:$PATH" bash -euo pipefail "$migration"
|
||||
}
|
||||
|
||||
cp "$legacy" "$kitty_config"
|
||||
output=$(run_migration)
|
||||
cmp -s "$ROOT/config/kitty/kitty.conf" "$kitty_config" || fail "stock config becomes the user template"
|
||||
backups=("$kitty_config".bak.*)
|
||||
cmp -s "$legacy" "${backups[0]}" || fail "refresh backs up the original config"
|
||||
[[ $output == *"Close and reopen all Kitty windows"* ]] || fail "migration requires a full restart"
|
||||
pass "stock config is refreshed with a backup and restart guidance"
|
||||
|
||||
output=$(run_migration)
|
||||
cmp -s "$ROOT/config/kitty/kitty.conf" "$kitty_config" || fail "stock migration is idempotent"
|
||||
[[ $output != *"Close and reopen"* ]] || fail "rerun does not repeat restart guidance"
|
||||
pass "stock migration is idempotent"
|
||||
|
||||
cat >"$kitty_config" <<'CONF'
|
||||
# Keep this comment and my theme choice
|
||||
include my-theme.conf
|
||||
font_family My Font
|
||||
font_size 13
|
||||
map ctrl+insert
|
||||
include shortcuts.conf
|
||||
map shift+insert paste_from_clipboard
|
||||
allow_remote_control yes
|
||||
allow_remote_control y
|
||||
allow_remote_control true
|
||||
# allow_remote_control yes
|
||||
listen_on unix:/tmp/my-kitty
|
||||
CONF
|
||||
printf 'allow_remote_control yes \n' >>"$kitty_config"
|
||||
cp "$kitty_config" "$test_dir/custom-original"
|
||||
cat >"$test_dir/expected" <<'CONF'
|
||||
# Keep this comment and my theme choice
|
||||
include my-theme.conf
|
||||
font_family My Font
|
||||
font_size 13
|
||||
map ctrl+insert
|
||||
include shortcuts.conf
|
||||
map shift+insert paste_from_clipboard
|
||||
# allow_remote_control yes
|
||||
# allow_remote_control y
|
||||
# allow_remote_control true
|
||||
# allow_remote_control yes
|
||||
listen_on unix:/tmp/my-kitty
|
||||
CONF
|
||||
printf '# allow_remote_control yes \n' >>"$test_dir/expected"
|
||||
chmod 600 "$kitty_config"
|
||||
run_migration >/dev/null
|
||||
cmp -s "$test_dir/expected" "$kitty_config" || fail "customizations survive the security repair"
|
||||
[[ $(stat -c %a "$kitty_config") == "600" ]] || fail "migration preserves config permissions"
|
||||
backup=$(rg -l 'allow_remote_control true' "$kitty_config".bak.* | tail -1)
|
||||
cmp -s "$test_dir/custom-original" "$backup" || fail "custom config is backed up"
|
||||
run_migration >/dev/null
|
||||
cmp -s "$test_dir/expected" "$kitty_config" || fail "custom migration is idempotent"
|
||||
pass "custom config repair preserves ordering, mappings, theme, permissions, and original backup"
|
||||
|
||||
for mode in no n false socket-only socket password; do
|
||||
printf 'allow_remote_control %s\nfont_size 13\n' "$mode" >"$kitty_config"
|
||||
cp "$kitty_config" "$test_dir/expected"
|
||||
run_migration >/dev/null
|
||||
cmp -s "$test_dir/expected" "$kitty_config" || fail "migration preserves $mode"
|
||||
done
|
||||
pass "explicit restricted remote-control modes are preserved"
|
||||
|
||||
printf 'font_size 13\n' >"$kitty_config"
|
||||
cp "$kitty_config" "$test_dir/expected"
|
||||
run_migration >/dev/null
|
||||
cmp -s "$test_dir/expected" "$kitty_config" || fail "omitted setting stays omitted"
|
||||
rm "$kitty_config"
|
||||
run_migration >/dev/null
|
||||
[[ ! -e $kitty_config ]] || fail "absent config stays absent"
|
||||
pass "migration leaves omitted settings and absent user configs alone"
|
||||
|
||||
printf 'allow_remote_control yes\nfont_size 13\n' >"$test_dir/dotfiles.conf"
|
||||
ln -s "$test_dir/dotfiles.conf" "$kitty_config"
|
||||
run_migration >/dev/null
|
||||
[[ -L $kitty_config ]] || fail "migration preserves a dotfile symlink"
|
||||
grep -qx '# allow_remote_control yes' "$test_dir/dotfiles.conf" || fail "symlink target is repaired"
|
||||
pass "custom dotfile symlinks survive the repair"
|
||||
rm "$kitty_config"
|
||||
|
||||
# Exercise the real font commands without changing the running desktop.
|
||||
for command in pkill omarchy-restart-shell omarchy-hook omarchy-notification-send; do
|
||||
printf '#!/bin/bash\nexit 0\n' >"$test_dir/bin/$command"
|
||||
done
|
||||
printf '#!/bin/bash\nexit 1\n' >"$test_dir/bin/pgrep"
|
||||
printf '#!/bin/bash\nprintf "Test Font\\n"\n' >"$test_dir/bin/fc-list"
|
||||
printf '#!/bin/bash\nexit 0\n' >"$test_dir/bin/kitty"
|
||||
cat >"$test_dir/bin/gsettings" <<'SH'
|
||||
#!/bin/bash
|
||||
if [[ $1 == "get" ]]; then
|
||||
if [[ $3 == "font-name" ]]; then
|
||||
echo "'Sans 11'"
|
||||
else
|
||||
echo 1.0
|
||||
fi
|
||||
fi
|
||||
SH
|
||||
chmod +x "$test_dir/bin/"*
|
||||
|
||||
run_command() {
|
||||
env HOME="$test_home" OMARCHY_PATH="$ROOT" PATH="$test_dir/bin:$ROOT/bin:$PATH" "$ROOT/bin/$@"
|
||||
}
|
||||
|
||||
cp "$ROOT/config/kitty/kitty.conf" "$kitty_config"
|
||||
output=$(run_command omarchy-display-text-size)
|
||||
[[ $output == *"terminal font: 9 pt"* ]] || fail "size report accounts for inherited Kitty default"
|
||||
run_command omarchy-font-set 'Test Font'
|
||||
run_command omarchy-display-text-size 16
|
||||
grep -qx 'font_family Test Font' "$kitty_config" || fail "font command creates family override"
|
||||
run_command omarchy-font-set Font
|
||||
grep -qx 'font_family Font' "$kitty_config" || fail "font command updates family override"
|
||||
[[ $(grep -c '^font_family ' "$kitty_config") == "1" ]] || fail "font update avoids duplicate overrides"
|
||||
grep -qx 'font_size 12.0' "$kitty_config" || fail "size command creates size override"
|
||||
grep -qx '# font_size 12' "$kitty_config" || fail "font commands keep commented instructions"
|
||||
run_command omarchy-display-text-size 18
|
||||
[[ $(grep -c '^font_size ' "$kitty_config") == "1" ]] || fail "size update avoids duplicate overrides"
|
||||
run_command omarchy-display-text-size reset
|
||||
grep -qx 'font_size 9.0' "$kitty_config" || fail "size reset restores default"
|
||||
pass "font controls add and update overrides in the minimal template"
|
||||
|
||||
rm "$kitty_config"
|
||||
output=$(run_command omarchy-display-text-size)
|
||||
[[ $output == *"terminal font: 9 pt"* ]] || fail "size report handles absent Kitty config"
|
||||
run_command omarchy-font-set 'Test Font'
|
||||
run_command omarchy-display-text-size 16
|
||||
grep -qx 'font_family Test Font' "$kitty_config" || fail "font command handles absent config"
|
||||
grep -qx 'font_size 12.0' "$kitty_config" || fail "size command handles absent setting"
|
||||
! grep -q '^include ' "$kitty_config" || fail "font controls must not opt users back into theming"
|
||||
rm "$kitty_config"
|
||||
run_command omarchy-display-text-size 16
|
||||
grep -qx 'font_size 12.0' "$kitty_config" || fail "size command handles absent config"
|
||||
pass "font controls create missing Kitty overrides without restoring the theme include"
|
||||
|
||||
if "$ROOT/bin/omarchy-cmd-present" kitty; then
|
||||
kitty +runpy "$(cat "$ROOT/test/shell.d/fixtures/kitty/check-config.py")"
|
||||
else
|
||||
pass "Kitty not installed; skipping native config parser checks"
|
||||
fi
|
||||
@@ -31,8 +31,9 @@ chmod +x "$mock_bin"/*
|
||||
launch_log="$test_tmp/launch-log"
|
||||
PATH="$mock_bin:$PATH" OMARCHY_TEST_INSTALLED=true OMARCHY_TEST_LOG="$launch_log" \
|
||||
bash "$ROOT/bin/omarchy-launch-1password"
|
||||
grep -Fxq 'launch:-- 1password' "$launch_log" || fail "1Password launcher starts the installed app"
|
||||
pass "1Password launcher starts the installed app"
|
||||
grep -Fxq 'launch:-- 1password --force-device-scale-factor=1' "$launch_log" ||
|
||||
fail "1Password launcher starts the installed app at a fixed scale factor"
|
||||
pass "1Password launcher starts the installed app at a fixed scale factor"
|
||||
|
||||
PATH="$mock_bin:$PATH" OMARCHY_TEST_INSTALLED=false OMARCHY_TEST_LOG="$launch_log" \
|
||||
bash "$ROOT/bin/omarchy-launch-1password"
|
||||
|
||||
Executable
+189
@@ -0,0 +1,189 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
mkdir -p "$tmp_dir/bin" "$tmp_dir/home"
|
||||
export TEST_LOG="$tmp_dir/log"
|
||||
export PATH="$tmp_dir/bin:$PATH"
|
||||
export HOME="$tmp_dir/home"
|
||||
|
||||
for stub in omarchy-launch-webapp omarchy-launch-floating-terminal-with-presentation omarchy-openclaw-onboard; do
|
||||
cat >"$tmp_dir/bin/$stub" <<SCRIPT
|
||||
#!/bin/bash
|
||||
printf '$stub:%s\n' "\$*" >>"\$TEST_LOG"
|
||||
SCRIPT
|
||||
chmod +x "$tmp_dir/bin/$stub"
|
||||
done
|
||||
|
||||
# The retry loop sleeps between polls; a no-op keeps the suite fast.
|
||||
printf '#!/bin/bash\n' >"$tmp_dir/bin/sleep"
|
||||
chmod +x "$tmp_dir/bin/sleep"
|
||||
|
||||
# The launcher asks systemd whether the gateway unit is enabled; a flag file
|
||||
# stands in for the user manager.
|
||||
cat >"$tmp_dir/bin/systemctl" <<SCRIPT
|
||||
#!/bin/bash
|
||||
printf 'systemctl:%s\\n' "\$*" >>"\$TEST_LOG"
|
||||
[[ \$* == *is-enabled* ]] && [[ -f $tmp_dir/gateway-enabled ]]
|
||||
SCRIPT
|
||||
chmod +x "$tmp_dir/bin/systemctl"
|
||||
|
||||
# A machine that never onboarded gets the wizard, not a dashboard probe.
|
||||
"$ROOT/bin/omarchy-launch-openclaw"
|
||||
|
||||
# Through omarchy-openclaw-onboard, never bare `openclaw onboard`, which as of
|
||||
# 2026.9.1 ends in a browser tab with a foreground gateway and never returns.
|
||||
grep -q '^omarchy-launch-floating-terminal-with-presentation:omarchy-openclaw-onboard && \[\[ -f \$HOME/.openclaw/openclaw.json \]\] && omarchy-launch-openclaw$' "$TEST_LOG" ||
|
||||
fail "OpenClaw launch hands a never-onboarded machine to the wizard" "$(grep floating "$TEST_LOG")"
|
||||
! grep -q '^omarchy-launch-webapp:' "$TEST_LOG" ||
|
||||
fail "OpenClaw launch hands a never-onboarded machine to the wizard" "webapp opened anyway"
|
||||
pass "OpenClaw launch hands a never-onboarded machine to the wizard"
|
||||
|
||||
mkdir -p "$HOME/.openclaw"
|
||||
touch "$HOME/.openclaw/openclaw.json"
|
||||
|
||||
# A running gateway answers the first probe; its handoff URL opens as the app.
|
||||
cat >"$tmp_dir/bin/openclaw" <<'SCRIPT'
|
||||
#!/bin/bash
|
||||
printf 'openclaw:%s\n' "$*" >>"$TEST_LOG"
|
||||
[[ $* == *--json* ]] &&
|
||||
echo '{"ok":true,"url":"http://127.0.0.1:18789/?token=shared","browserUrl":"http://127.0.0.1:18789/#handoff"}'
|
||||
SCRIPT
|
||||
chmod +x "$tmp_dir/bin/openclaw"
|
||||
"$ROOT/bin/omarchy-launch-openclaw"
|
||||
|
||||
grep -q '^omarchy-launch-webapp:http://127.0.0.1:18789/#handoff$' "$TEST_LOG" ||
|
||||
fail "OpenClaw launch opens the running gateway's handoff URL"
|
||||
pass "OpenClaw launch opens the running gateway's handoff URL"
|
||||
|
||||
# A machine whose gateway unit was never enabled (never installed, or an
|
||||
# install that died after writing the unit) gets `gateway install --force`,
|
||||
# then is polled until the dashboard answers. Never `dashboard --yes`: since
|
||||
# 2026.9.1 that neither installs nor starts anything and, once the gateway is
|
||||
# up, pushes a one-time pairing URL into the clipboard.
|
||||
cat >"$tmp_dir/bin/openclaw" <<SCRIPT
|
||||
#!/bin/bash
|
||||
printf 'openclaw:%s\\n' "\$*" >>"\$TEST_LOG"
|
||||
if [[ \$* == *--json* ]]; then
|
||||
[[ -f $tmp_dir/gateway-up ]] || { echo '{"ok":false,"reason":"Gateway is not running."}'; exit 1; }
|
||||
echo '{"ok":true,"browserUrl":"http://127.0.0.1:18789/#cold-start"}'
|
||||
elif [[ \$1 == gateway ]]; then
|
||||
touch "$tmp_dir/gateway-up"
|
||||
fi
|
||||
SCRIPT
|
||||
chmod +x "$tmp_dir/bin/openclaw"
|
||||
rm -f "$tmp_dir/gateway-up" "$tmp_dir/gateway-enabled"
|
||||
mkdir -p "$HOME/.config/systemd/user"
|
||||
touch "$HOME/.config/systemd/user/openclaw-gateway.service"
|
||||
: >"$TEST_LOG"
|
||||
"$ROOT/bin/omarchy-launch-openclaw"
|
||||
|
||||
grep -q '^openclaw:gateway install --force$' "$TEST_LOG" ||
|
||||
fail "OpenClaw launch installs a never-enabled gateway before opening the app"
|
||||
! grep -q '^openclaw:gateway start$' "$TEST_LOG" ||
|
||||
fail "OpenClaw launch installs a never-enabled gateway before opening the app" "started the half-installed unit instead"
|
||||
grep -q '^omarchy-launch-webapp:http://127.0.0.1:18789/#cold-start$' "$TEST_LOG" ||
|
||||
fail "OpenClaw launch installs a never-enabled gateway before opening the app" "webapp never opened"
|
||||
pass "OpenClaw launch installs a never-enabled gateway before opening the app"
|
||||
|
||||
# An enabled but stopped unit is started, not reinstalled.
|
||||
touch "$tmp_dir/gateway-enabled"
|
||||
rm -f "$tmp_dir/gateway-up"
|
||||
: >"$TEST_LOG"
|
||||
"$ROOT/bin/omarchy-launch-openclaw"
|
||||
|
||||
grep -q '^openclaw:gateway start$' "$TEST_LOG" ||
|
||||
fail "OpenClaw launch starts a stopped gateway before opening the app"
|
||||
! grep -q '^openclaw:gateway install' "$TEST_LOG" ||
|
||||
fail "OpenClaw launch starts a stopped gateway before opening the app" "reinstalled the unit"
|
||||
! grep -q -- '--yes' "$TEST_LOG" ||
|
||||
fail "OpenClaw launch starts a stopped gateway before opening the app" "fell back to dashboard --yes"
|
||||
grep -q '^omarchy-launch-webapp:http://127.0.0.1:18789/#cold-start$' "$TEST_LOG" ||
|
||||
fail "OpenClaw launch starts a stopped gateway before opening the app" "webapp never opened"
|
||||
pass "OpenClaw launch starts a stopped gateway before opening the app"
|
||||
rm -f "$tmp_dir/gateway-enabled"
|
||||
|
||||
# A dashboard probe that hangs is cut off, so the launch still fails cleanly
|
||||
# instead of sitting forever behind an app-grid icon with no terminal.
|
||||
cat >"$tmp_dir/bin/timeout" <<SCRIPT
|
||||
#!/bin/bash
|
||||
printf 'timeout:%s\\n' "\$1" >>"\$TEST_LOG"
|
||||
exit 124
|
||||
SCRIPT
|
||||
chmod +x "$tmp_dir/bin/timeout"
|
||||
: >"$TEST_LOG"
|
||||
rc=0
|
||||
"$ROOT/bin/omarchy-launch-openclaw" >/dev/null 2>&1 || rc=$?
|
||||
[[ $rc != 0 ]] || fail "OpenClaw launch bounds a hanging dashboard probe"
|
||||
grep -q '^timeout:10$' "$TEST_LOG" || fail "OpenClaw launch bounds a hanging dashboard probe" "probe ran without a timeout"
|
||||
pass "OpenClaw launch bounds a hanging dashboard probe"
|
||||
rm -f "$tmp_dir/bin/timeout"
|
||||
|
||||
# A gateway that never answers fails the launch instead of opening a dead page.
|
||||
cat >"$tmp_dir/bin/openclaw" <<'SCRIPT'
|
||||
#!/bin/bash
|
||||
printf 'openclaw:%s\n' "$*" >>"$TEST_LOG"
|
||||
[[ $* == *--json* ]] && exit 1
|
||||
exit 0
|
||||
SCRIPT
|
||||
chmod +x "$tmp_dir/bin/openclaw"
|
||||
webapp_calls_before=$(grep -c '^omarchy-launch-webapp:' "$TEST_LOG" || true)
|
||||
rc=0
|
||||
"$ROOT/bin/omarchy-launch-openclaw" >/dev/null 2>&1 || rc=$?
|
||||
webapp_calls_after=$(grep -c '^omarchy-launch-webapp:' "$TEST_LOG" || true)
|
||||
|
||||
[[ $rc != 0 ]] || fail "OpenClaw launch fails cleanly when the gateway never comes up"
|
||||
[[ $webapp_calls_before == "$webapp_calls_after" ]] ||
|
||||
fail "OpenClaw launch fails cleanly when the gateway never comes up" "webapp opened anyway"
|
||||
pass "OpenClaw launch fails cleanly when the gateway never comes up"
|
||||
|
||||
# --tui runs onboarding in the terminal it is already in, then attaches to the
|
||||
# gateway with `openclaw tui` -- never the embedded chat, which the running
|
||||
# gateway's state-directory lock would refuse.
|
||||
rm -f "$HOME/.openclaw/openclaw.json"
|
||||
cat >"$tmp_dir/bin/omarchy-openclaw-onboard" <<SCRIPT
|
||||
#!/bin/bash
|
||||
printf 'omarchy-openclaw-onboard:%s\n' "\$*" >>"\$TEST_LOG"
|
||||
mkdir -p "\$HOME/.openclaw" && touch "\$HOME/.openclaw/openclaw.json"
|
||||
SCRIPT
|
||||
chmod +x "$tmp_dir/bin/omarchy-openclaw-onboard"
|
||||
cat >"$tmp_dir/bin/openclaw" <<'SCRIPT'
|
||||
#!/bin/bash
|
||||
printf 'openclaw:%s\n' "$*" >>"$TEST_LOG"
|
||||
[[ $* == *--json* ]] && echo '{"ok":true,"browserUrl":"http://127.0.0.1:18789/#tui"}'
|
||||
exit 0
|
||||
SCRIPT
|
||||
chmod +x "$tmp_dir/bin/openclaw"
|
||||
floating_calls_before=$(grep -c '^omarchy-launch-floating-terminal-with-presentation:' "$TEST_LOG" || true)
|
||||
"$ROOT/bin/omarchy-launch-openclaw" --tui
|
||||
|
||||
grep -q '^omarchy-openclaw-onboard:$' "$TEST_LOG" ||
|
||||
fail "--tui onboards in the current terminal" "$(grep 'onboard' "$TEST_LOG")"
|
||||
floating_calls_after=$(grep -c '^omarchy-launch-floating-terminal-with-presentation:' "$TEST_LOG" || true)
|
||||
[[ $floating_calls_before == "$floating_calls_after" ]] ||
|
||||
fail "--tui onboards in the current terminal" "spawned a floating terminal"
|
||||
grep -q '^openclaw:tui$' "$TEST_LOG" || fail "--tui attaches to the gateway"
|
||||
pass "--tui onboards in place and attaches to the gateway"
|
||||
|
||||
# The stub also logs argv one entry per line, so a prompt split into words
|
||||
# would show up as extra lines rather than pass a space-joined comparison.
|
||||
cat >"$tmp_dir/bin/openclaw" <<'SCRIPT'
|
||||
#!/bin/bash
|
||||
printf 'openclaw:%s\n' "$*" >>"$TEST_LOG"
|
||||
[[ $1 == tui ]] && printf 'argv:[%s]\n' "$@" >>"$TEST_LOG"
|
||||
[[ $* == *--json* ]] && echo '{"ok":true,"browserUrl":"http://127.0.0.1:18789/#tui"}'
|
||||
exit 0
|
||||
SCRIPT
|
||||
chmod +x "$tmp_dir/bin/openclaw"
|
||||
: >"$TEST_LOG"
|
||||
"$ROOT/bin/omarchy-launch-openclaw" --tui --message "Review this project"
|
||||
[[ $(grep -c '^argv:' "$TEST_LOG") == 3 ]] && grep -q '^argv:\[Review this project\]$' "$TEST_LOG" ||
|
||||
fail "--tui seeds the session through --message" "$(grep '^argv:' "$TEST_LOG" | tr '\n' ' ')"
|
||||
! grep -q '^omarchy-launch-webapp:' "$TEST_LOG" ||
|
||||
fail "--tui seeds the session through --message" "webapp opened instead"
|
||||
pass "--tui seeds the session through --message"
|
||||
@@ -1,111 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
migration="$ROOT/migrations/1786482992.sh"
|
||||
packaged_defaults="$ROOT/etc/limine-entry-tool.d/omarchy-defaults.conf"
|
||||
|
||||
grep -Fq 'KERNEL_CMDLINE[default]+=" initramfs_async=0"' "$packaged_defaults" ||
|
||||
fail "the packaged Limine defaults still unpack the initramfs synchronously"
|
||||
pass "packaged Limine defaults keep Plymouth alive at the LUKS prompt"
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
stub_bin="$test_tmp/bin"
|
||||
calls="$test_tmp/calls.log"
|
||||
mkdir -p "$stub_bin"
|
||||
: >"$calls"
|
||||
|
||||
cat >"$stub_bin/omarchy-cmd-present" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
(( ${LIMINE_MKINITCPIO_INSTALLED:-1} == 1 ))
|
||||
SH
|
||||
|
||||
cat >"$stub_bin/sudo" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
printf 'sudo' >>"$TEST_LOG"
|
||||
printf '\t%s' "$@" >>"$TEST_LOG"
|
||||
printf '\n' >>"$TEST_LOG"
|
||||
"$@"
|
||||
SH
|
||||
|
||||
cat >"$stub_bin/limine-mkinitcpio" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
echo 'limine-mkinitcpio' >>"$TEST_LOG"
|
||||
SH
|
||||
|
||||
chmod +x "$stub_bin"/*
|
||||
|
||||
defaults_conf="$test_tmp/omarchy-defaults.conf"
|
||||
running_cmdline="$test_tmp/cmdline"
|
||||
rebuild_marker="$test_tmp/rebuild-complete"
|
||||
|
||||
cp "$packaged_defaults" "$defaults_conf"
|
||||
|
||||
# A boot image baked before quattro's defaults landed: the pre-quattro command
|
||||
# line plus the machine's own root parameters, and none of the new ones.
|
||||
stale_cmdline='quiet splash cryptdevice=PARTUUID=fake:root root=/dev/mapper/root rw'
|
||||
|
||||
# Everything the packaged defaults ask for, as a rebuilt image would carry it.
|
||||
configured=$(sed -n 's/^KERNEL_CMDLINE\[default\]+="\(.*\)"[[:space:]]*$/\1/p' "$defaults_conf" | tr '\n' ' ')
|
||||
current_cmdline="cryptdevice=PARTUUID=fake:root root=/dev/mapper/root rw $configured"
|
||||
|
||||
run_migration() {
|
||||
PATH="$stub_bin:$PATH" \
|
||||
TEST_LOG="$calls" \
|
||||
OMARCHY_LIMINE_DEFAULTS_CONF="$defaults_conf" \
|
||||
OMARCHY_RUNNING_CMDLINE="$running_cmdline" \
|
||||
OMARCHY_LIMINE_REBUILD_MARKER="$rebuild_marker" \
|
||||
bash -euo pipefail "$migration" >/dev/null
|
||||
}
|
||||
|
||||
echo "$stale_cmdline" >"$running_cmdline"
|
||||
run_migration
|
||||
|
||||
grep -Fxq 'limine-mkinitcpio' "$calls" ||
|
||||
fail "a boot image older than the Limine defaults is rebuilt"
|
||||
[[ -f $rebuild_marker ]] || fail "the rebuild records the machine-wide repair"
|
||||
pass "migration rebuilds a boot image that predates the Limine defaults"
|
||||
|
||||
: >"$calls"
|
||||
run_migration
|
||||
|
||||
[[ ! -s $calls ]] || fail "a recorded rebuild is not repeated" "$(cat "$calls")"
|
||||
pass "migration is machine-idempotent before reboot"
|
||||
|
||||
rm -f "$rebuild_marker"
|
||||
: >"$calls"
|
||||
run_migration
|
||||
|
||||
grep -Fxq 'limine-mkinitcpio' "$calls" ||
|
||||
fail "an interrupted rebuild is retried"
|
||||
[[ -f $rebuild_marker ]] || fail "a retried rebuild records completion"
|
||||
pass "migration retries an interrupted rebuild"
|
||||
|
||||
echo "$current_cmdline" >"$running_cmdline"
|
||||
rm -f "$rebuild_marker"
|
||||
: >"$calls"
|
||||
run_migration
|
||||
|
||||
[[ ! -s $calls ]] || fail "a boot image matching the defaults is left alone" "$(cat "$calls")"
|
||||
[[ ! -e $rebuild_marker ]] || fail "an untouched machine is not marked as repaired"
|
||||
pass "migration skips a boot image that already carries the defaults"
|
||||
|
||||
echo "$stale_cmdline" >"$running_cmdline"
|
||||
: >"$calls"
|
||||
LIMINE_MKINITCPIO_INSTALLED=0 run_migration
|
||||
|
||||
[[ ! -s $calls ]] || fail "installs without limine-mkinitcpio are skipped" "$(cat "$calls")"
|
||||
|
||||
mv "$defaults_conf" "$defaults_conf.away"
|
||||
run_migration
|
||||
mv "$defaults_conf.away" "$defaults_conf"
|
||||
|
||||
[[ ! -s $calls ]] || fail "installs without the Limine defaults are skipped" "$(cat "$calls")"
|
||||
pass "migration skips installs it does not apply to"
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
packaged_defaults="$ROOT/etc/limine-entry-tool.d/omarchy-defaults.conf"
|
||||
|
||||
grep -Fq 'KERNEL_CMDLINE[default]+=" initramfs_async=0"' "$packaged_defaults" ||
|
||||
fail "the packaged Limine defaults still unpack the initramfs synchronously"
|
||||
pass "packaged Limine defaults keep Plymouth alive at the LUKS prompt"
|
||||
+86
-157
@@ -4,166 +4,95 @@ set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
config_script="$ROOT/install/config/locate.sh"
|
||||
require_command python3
|
||||
require_command updatedb
|
||||
require_command plocate
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
python3 - <<'PY'
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
stock_conf() {
|
||||
cat >"$1" <<'CONF'
|
||||
PRUNE_BIND_MOUNTS = "yes"
|
||||
PRUNEFS = "9p afs autofs cifs fuse nfs nfs4 proc sysfs tmpfs"
|
||||
PRUNENAMES = ".git .hg .svn"
|
||||
PRUNEPATHS = "/afs /media /mnt /net /sfs /tmp /udev /var/cache /var/lib/pacman/local /var/lock /var/run /var/spool /var/tmp"
|
||||
CONF
|
||||
}
|
||||
root = Path(os.environ["ROOT"])
|
||||
|
||||
# updatedb dies on a config that defines a variable twice, so hand every
|
||||
# rewritten file to the real parser rather than trusting the greps below.
|
||||
empty_tree="$test_tmp/empty-tree"
|
||||
mkdir -p "$empty_tree"
|
||||
def check(condition, description):
|
||||
if not condition:
|
||||
raise SystemExit("not ok - " + description)
|
||||
print("ok - " + description, flush=True)
|
||||
|
||||
assert_conf_parses() {
|
||||
command -v updatedb >/dev/null || return 0
|
||||
drop_in = root / "default/systemd/system/plocate-updatedb.service.d/10-omarchy.conf"
|
||||
directives = [line.strip() for line in drop_in.read_text().splitlines() if line.strip() and not line.startswith("#")]
|
||||
check(len(directives) == 3 and directives[:2] == ["[Service]", "ExecStart="] and directives[2].startswith("ExecStart="),
|
||||
"locate drop-in replaces the command and preserves upstream service restrictions")
|
||||
command = shlex.split(directives[2].removeprefix("ExecStart="))
|
||||
options = ["--prune-bind-mounts=no", "--add-prunepaths=/.snapshots"]
|
||||
check(command == ["/usr/bin/updatedb", *options],
|
||||
"locate service runs updatedb directly with fixed Btrfs options")
|
||||
check("ConditionACPower=true" in (root / "etc/systemd/system/plocate-updatedb.service.d/ac-only.conf").read_text(),
|
||||
"scheduled locate indexing keeps its AC-power condition")
|
||||
check(not (root / "install/config/locate.sh").exists() and not (root / "migrations/1784809451.sh").exists(),
|
||||
"the retired locate configuration helper and migration are absent")
|
||||
for directory in ("bin", "install", "migrations"):
|
||||
for path in (root / directory).rglob("*"):
|
||||
if path.is_file():
|
||||
content = path.read_text()
|
||||
if "OMARCHY_UPDATEDB_CONF_PATH" in content or "config/locate.sh" in content:
|
||||
raise SystemExit("not ok - retired locate configuration path remains in " + str(path))
|
||||
check(True, "runtime and installation no longer reference the configuration rewrite")
|
||||
|
||||
local errors
|
||||
errors=$(updatedb --config-file "$1" -U "$empty_tree" -o "$test_tmp/plocate.db" 2>&1 >/dev/null | grep -F "$1:" || true)
|
||||
[[ -z $errors ]] || fail "updatedb accepts the rewritten config" "$errors"
|
||||
}
|
||||
with tempfile.TemporaryDirectory(prefix="omarchy-locate-") as scratch:
|
||||
scratch = Path(scratch)
|
||||
fake_bin = scratch / "bin"
|
||||
fake_bin.mkdir()
|
||||
stubs = {
|
||||
"updatedb": 'printf "%s\\n" "$@" >"$TEST_CALLS"',
|
||||
"sudo": 'exec "$@"',
|
||||
"fzf": 'cat >/dev/null\nprintf "%s\\n" test-package',
|
||||
"yay": 'if [[ ${1:-} == "-Slqa" ]]; then printf "%s\\n" test-package; fi',
|
||||
"omarchy-sudo-keepalive": ':',
|
||||
"omarchy-show-done": ':',
|
||||
}
|
||||
for name, body in stubs.items():
|
||||
path = fake_bin / name
|
||||
path.write_text("#!/bin/bash\n" + body + "\n")
|
||||
path.chmod(0o755)
|
||||
calls = scratch / "updatedb-arguments"
|
||||
env = dict(os.environ, PATH=str(fake_bin) + ":" + os.environ["PATH"], TEST_CALLS=str(calls))
|
||||
for relative in ("install/post-install/localdb.sh", "bin/omarchy-pkg-aur-install"):
|
||||
subprocess.run(["bash", "-euo", "pipefail", str(root / relative)], env=env, check=True)
|
||||
check(calls.read_text().splitlines() == options,
|
||||
relative + " passes the scheduled service options directly")
|
||||
calls.unlink()
|
||||
|
||||
conf="$test_tmp/updatedb.conf"
|
||||
stock_conf "$conf"
|
||||
|
||||
OMARCHY_UPDATEDB_CONF_PATH="$conf" bash -euo pipefail "$config_script" >/dev/null
|
||||
|
||||
grep -qFx 'PRUNE_BIND_MOUNTS = "no"' "$conf" || fail "locate config indexes Btrfs subvolume mounts like /home"
|
||||
grep -qF 'PRUNEPATHS = "/.snapshots /afs' "$conf" || fail "locate config prunes /.snapshots"
|
||||
assert_conf_parses "$conf"
|
||||
pass "locate config skips Btrfs snapshots and indexes Btrfs subvolumes"
|
||||
|
||||
OMARCHY_UPDATEDB_CONF_PATH="$conf" bash -euo pipefail "$config_script" >/dev/null
|
||||
|
||||
[[ $(grep -o '/\.snapshots' "$conf" | wc -l) -eq 1 ]] || fail "locate config is idempotent"
|
||||
assert_conf_parses "$conf"
|
||||
pass "locate config leaves an already-configured file alone"
|
||||
|
||||
OMARCHY_UPDATEDB_CONF_PATH="$test_tmp/missing.conf" bash -euo pipefail "$config_script" >/dev/null
|
||||
pass "locate config tolerates a missing updatedb.conf"
|
||||
|
||||
# A hand-edited updatedb.conf may drop the settings entirely, or write them
|
||||
# without the spaces around the "=" or the quotes that the stock Arch file uses.
|
||||
conf="$test_tmp/sparse-updatedb.conf"
|
||||
printf '%s\n' 'PRUNENAMES = ".git .hg .svn"' >"$conf"
|
||||
|
||||
OMARCHY_UPDATEDB_CONF_PATH="$conf" bash -euo pipefail "$config_script" >/dev/null
|
||||
|
||||
grep -qFx 'PRUNE_BIND_MOUNTS = "no"' "$conf" || fail "locate config adds a missing PRUNE_BIND_MOUNTS"
|
||||
grep -qFx 'PRUNEPATHS = "/.snapshots"' "$conf" || fail "locate config adds a missing PRUNEPATHS"
|
||||
assert_conf_parses "$conf"
|
||||
pass "locate config adds settings a hand-edited updatedb.conf is missing"
|
||||
|
||||
conf="$test_tmp/unspaced-updatedb.conf"
|
||||
printf '%s\n' 'PRUNE_BIND_MOUNTS="yes"' 'PRUNEPATHS="/tmp /var/tmp"' >"$conf"
|
||||
|
||||
OMARCHY_UPDATEDB_CONF_PATH="$conf" bash -euo pipefail "$config_script" >/dev/null
|
||||
|
||||
grep -qFx 'PRUNE_BIND_MOUNTS = "no"' "$conf" || fail "locate config rewrites an unspaced PRUNE_BIND_MOUNTS"
|
||||
grep -qFx 'PRUNEPATHS = "/.snapshots /tmp /var/tmp"' "$conf" || fail "locate config prunes /.snapshots in an unspaced PRUNEPATHS"
|
||||
[[ $(grep -c 'PRUNEPATHS' "$conf") -eq 1 ]] || fail "locate config keeps a single PRUNEPATHS setting"
|
||||
assert_conf_parses "$conf"
|
||||
pass "locate config handles updatedb.conf written without spaces around ="
|
||||
|
||||
# updatedb allows a comment after a value and indented settings, and defining
|
||||
# either setting twice makes it refuse to run at all.
|
||||
conf="$test_tmp/commented-updatedb.conf"
|
||||
printf '%s\n' ' PRUNE_BIND_MOUNTS = "yes" # subvolumes look like bind mounts' \
|
||||
'PRUNEPATHS = "/tmp" # scratch' >"$conf"
|
||||
|
||||
OMARCHY_UPDATEDB_CONF_PATH="$conf" bash -euo pipefail "$config_script" >/dev/null
|
||||
|
||||
grep -qFx 'PRUNE_BIND_MOUNTS = "no"' "$conf" || fail "locate config rewrites an indented PRUNE_BIND_MOUNTS"
|
||||
grep -qFx 'PRUNEPATHS = "/.snapshots /tmp"' "$conf" || fail "locate config keeps the paths a commented PRUNEPATHS already prunes"
|
||||
[[ $(grep -c 'PRUNEPATHS' "$conf") -eq 1 ]] || fail "locate config replaces a commented PRUNEPATHS instead of adding a second one"
|
||||
assert_conf_parses "$conf"
|
||||
pass "locate config handles indented settings and trailing comments"
|
||||
|
||||
# A hand-edited file may have dropped the quotes updatedb requires, which
|
||||
# leaves it unparseable until something writes the setting out properly.
|
||||
conf="$test_tmp/unquoted-updatedb.conf"
|
||||
printf '%s\n' 'PRUNEPATHS = /tmp' >"$conf"
|
||||
|
||||
OMARCHY_UPDATEDB_CONF_PATH="$conf" bash -euo pipefail "$config_script" >/dev/null
|
||||
|
||||
grep -qFx 'PRUNEPATHS = "/.snapshots"' "$conf" || fail "locate config repairs an unquoted PRUNEPATHS"
|
||||
[[ $(grep -c 'PRUNEPATHS' "$conf") -eq 1 ]] || fail "locate config replaces an unquoted PRUNEPATHS instead of adding a second one"
|
||||
assert_conf_parses "$conf"
|
||||
pass "locate config handles updatedb.conf written without quotes"
|
||||
|
||||
# A path that merely ends in /.snapshots is not the root snapshot directory.
|
||||
conf="$test_tmp/nested-snapshots-updatedb.conf"
|
||||
printf '%s\n' 'PRUNEPATHS = "/var/lib/machines/.snapshots"' >"$conf"
|
||||
|
||||
OMARCHY_UPDATEDB_CONF_PATH="$conf" bash -euo pipefail "$config_script" >/dev/null
|
||||
|
||||
grep -qFx 'PRUNEPATHS = "/.snapshots /var/lib/machines/.snapshots"' "$conf" || fail "locate config prunes /.snapshots alongside a path that ends in it"
|
||||
assert_conf_parses "$conf"
|
||||
pass "locate config tells /.snapshots apart from a path that ends in it"
|
||||
|
||||
locate_migration=$(grep -rl 'Configure locate to skip Btrfs snapshots' "$ROOT/migrations" | head -n 1 || true)
|
||||
[[ -n $locate_migration ]] || fail "locate migration exists"
|
||||
|
||||
fake_bin="$test_tmp/bin"
|
||||
mkdir -p "$fake_bin"
|
||||
|
||||
cat >"$fake_bin/sudo" <<'STUB'
|
||||
#!/bin/bash
|
||||
exec "$@"
|
||||
STUB
|
||||
chmod +x "$fake_bin/sudo"
|
||||
|
||||
cat >"$fake_bin/systemctl" <<'STUB'
|
||||
#!/bin/bash
|
||||
printf 'systemctl %s\n' "$*" >>"$TEST_LOG"
|
||||
STUB
|
||||
chmod +x "$fake_bin/systemctl"
|
||||
|
||||
conf="$test_tmp/migration-updatedb.conf"
|
||||
stock_conf "$conf"
|
||||
|
||||
TEST_LOG="$test_tmp/calls.log" \
|
||||
PATH="$fake_bin:$PATH" \
|
||||
OMARCHY_PATH="$ROOT" \
|
||||
OMARCHY_UPDATEDB_CONF_PATH="$conf" \
|
||||
bash -euo pipefail "$locate_migration" >/dev/null
|
||||
|
||||
grep -qFx 'PRUNE_BIND_MOUNTS = "no"' "$conf" || fail "locate migration rewrites updatedb.conf"
|
||||
grep -qF 'PRUNEPATHS = "/.snapshots /afs' "$conf" || fail "locate migration prunes /.snapshots"
|
||||
grep -qFx 'systemctl restart --no-block plocate-updatedb.service' "$test_tmp/calls.log" || fail "locate migration replaces an in-flight run and rebuilds the index without blocking"
|
||||
pass "locate migration fixes existing installs and rebuilds the index"
|
||||
|
||||
: >"$test_tmp/calls.log"
|
||||
|
||||
TEST_LOG="$test_tmp/calls.log" \
|
||||
PATH="$fake_bin:$PATH" \
|
||||
OMARCHY_PATH="$ROOT" \
|
||||
OMARCHY_UPDATEDB_CONF_PATH="$conf" \
|
||||
bash -euo pipefail "$locate_migration" >/dev/null
|
||||
|
||||
[[ ! -s $test_tmp/calls.log ]] || fail "locate migration skips already-configured installs"
|
||||
pass "locate migration is a no-op once updatedb.conf is configured"
|
||||
|
||||
# A dev checkout carries migrations from a release whose install scripts the
|
||||
# checked-out tree may not have yet, and omarchy-migrate runs under set -e.
|
||||
: >"$test_tmp/calls.log"
|
||||
conf="$test_tmp/no-config-script-updatedb.conf"
|
||||
stock_conf "$conf"
|
||||
|
||||
TEST_LOG="$test_tmp/calls.log" \
|
||||
PATH="$fake_bin:$PATH" \
|
||||
OMARCHY_PATH="$test_tmp/empty" \
|
||||
OMARCHY_UPDATEDB_CONF_PATH="$conf" \
|
||||
bash -euo pipefail "$locate_migration" >/dev/null ||
|
||||
fail "locate migration survives a tree without the locate config script"
|
||||
|
||||
[[ ! -s $test_tmp/calls.log ]] || fail "locate migration touches nothing without the locate config script"
|
||||
pass "locate migration is a no-op when the locate config script is missing"
|
||||
tree = scratch / "tree"
|
||||
visible = tree / "home/current-file"
|
||||
excluded = tree / "private&pipe|directory"
|
||||
hidden = excluded / "private-file"
|
||||
visible.parent.mkdir(parents=True)
|
||||
excluded.mkdir()
|
||||
visible.touch()
|
||||
hidden.touch()
|
||||
conf = scratch / "updatedb.conf"
|
||||
conf.write_text('PRUNE_BIND_MOUNTS = "yes"\nPRUNEPATHS = "' + str(excluded) + '"\n')
|
||||
conf.chmod(0o640)
|
||||
original = conf.read_bytes()
|
||||
metadata = conf.stat()
|
||||
database = scratch / "plocate.db"
|
||||
run = [*command, "--config-file", str(conf), "--database-root", str(tree),
|
||||
"--output", str(database), "--require-visibility", "no", "--debug-pruning"]
|
||||
result = subprocess.run(run, capture_output=True, text=True, check=True)
|
||||
debug = result.stdout + result.stderr
|
||||
check("prune_bind_mounts\\000\n0\\000" in debug and "/.snapshots\\000" in debug,
|
||||
"real updatedb overrides bind-mount pruning and adds root snapshots to exclusions")
|
||||
entries = subprocess.check_output(["plocate", "--database", str(database), ""], text=True).splitlines()
|
||||
check(str(visible) in entries and str(hidden) not in entries,
|
||||
"real locate indexes current files and preserves literal administrator exclusions")
|
||||
check(conf.read_bytes() == original and (conf.stat().st_mode, conf.stat().st_uid, conf.stat().st_gid, conf.stat().st_mtime_ns)
|
||||
== (metadata.st_mode, metadata.st_uid, metadata.st_gid, metadata.st_mtime_ns),
|
||||
"indexing preserves configuration bytes, permissions, ownership, and modification time")
|
||||
subprocess.run(run, capture_output=True, check=True)
|
||||
repeated = subprocess.check_output(["plocate", "--database", str(database), ""], text=True).splitlines()
|
||||
check(repeated == entries, "repeated indexing retains the same results and exclusions")
|
||||
PY
|
||||
@@ -69,7 +69,7 @@ PATH="$stub_bin:$PATH" XDG_CACHE_HOME="$cache_home" \
|
||||
fail "image menu recovers thumbnails from stranded locks"
|
||||
(( $(awk 'END { print NR }' "$cache_dir/$cache_key.rows") == 3 )) ||
|
||||
fail "image menu rebuilds every row after cache invalidation"
|
||||
[[ $(head -n 1 "$cache_dir/$cache_key.signature") == "v3" ]] ||
|
||||
[[ $(head -n 1 "$cache_dir/$cache_key.signature") == "v4" ]] ||
|
||||
fail "image menu invalidates stale row caches"
|
||||
[[ ! -e $stale_tmp ]] ||
|
||||
fail "image menu clears partial thumbnails left by killed generators"
|
||||
|
||||
@@ -224,8 +224,13 @@ const expectedAgents = {
|
||||
claude: { icon: '', label: 'Claude' },
|
||||
codex: { icon: '\ue905', iconFont: 'omarchy', label: 'Codex' },
|
||||
grok: { icon: '\ue904', iconFont: 'omarchy', label: 'Grok' },
|
||||
hermes: { icon: '\ue90a', iconFont: 'omarchy', label: 'Hermes' },
|
||||
openclaw: { icon: '\ue90c', iconFont: 'omarchy', label: 'OpenClaw' },
|
||||
copilot: { icon: '', label: 'Copilot' },
|
||||
crush: { icon: '', label: 'Crush' },
|
||||
muse: { icon: '', label: 'Muse Code' },
|
||||
'cursor-agent': { icon: '\ue90d', iconFont: 'omarchy', label: 'Cursor CLI' },
|
||||
|
||||
}
|
||||
assert(
|
||||
Object.entries(expectedAgents).every(([agent, expected]) => {
|
||||
@@ -238,13 +243,13 @@ assert(
|
||||
&& !entry.when
|
||||
&& entry.checked.includes(`== \"${agent}\"`)
|
||||
}),
|
||||
'menu exposes every mise-installable coding agent with its own glyph under Defaults > Agent'
|
||||
'menu exposes every supported coding agent with its own glyph under Defaults > Agent'
|
||||
)
|
||||
assertDeepEqual(
|
||||
defaultItems
|
||||
.filter(item => item.parent === 'setup.default.agent')
|
||||
.map(item => item.label),
|
||||
['Antigravity', 'Claude', 'Codex', 'Copilot', 'Crush', 'Grok', 'omp', 'OpenCode', 'Ori', 'Pi'],
|
||||
['Antigravity', 'Claude', 'Codex', 'Copilot', 'Crush', 'Cursor CLI', 'Grok', 'Hermes', 'Muse Code', 'omp', 'OpenClaw', 'OpenCode', 'Ori', 'Pi'],
|
||||
'menu sorts coding agents alphabetically'
|
||||
)
|
||||
const expectedDefaults = {
|
||||
@@ -636,5 +641,5 @@ assert(
|
||||
JS
|
||||
|
||||
font_charset=$(fc-query --format='%{charset}' "$ROOT/default/fonts/omarchy/omarchy.ttf")
|
||||
[[ $font_charset == *"e900-e909"* ]] || fail "Omarchy icon font includes every custom menu glyph"
|
||||
[[ $font_charset == *"e900-e90d"* ]] || fail "Omarchy icon font includes every custom menu glyph"
|
||||
pass "Omarchy icon font includes the official agent marks"
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh"
|
||||
|
||||
tmpdir=$(mktemp -d)
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
|
||||
home="$tmpdir/home"
|
||||
stub_bin="$tmpdir/bin"
|
||||
mkdir -p "$home" "$stub_bin"
|
||||
|
||||
# Stands in for the real mise so a generated wrapper can be run and asked what
|
||||
# arguments it passed on.
|
||||
cat >"$stub_bin/mise" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
printf 'mise' >>"$OMARCHY_MISE_TEST_LOG"
|
||||
for arg in "$@"; do
|
||||
printf '\t%s' "$arg" >>"$OMARCHY_MISE_TEST_LOG"
|
||||
done
|
||||
printf '\n' >>"$OMARCHY_MISE_TEST_LOG"
|
||||
SH
|
||||
chmod +x "$stub_bin/mise"
|
||||
|
||||
install_wrapper() {
|
||||
HOME="$home" "$ROOT/bin/omarchy-mise-install" "$@"
|
||||
}
|
||||
|
||||
# The ordinary case still works, and every call site in install/user/mise.sh
|
||||
# passes names of this shape.
|
||||
install_wrapper npm:playwright playwright >/dev/null
|
||||
[[ -x $home/.local/bin/playwright ]] ||
|
||||
fail "a normal install writes an executable wrapper"
|
||||
|
||||
log="$tmpdir/normal.log"
|
||||
: >"$log"
|
||||
OMARCHY_MISE_TEST_LOG="$log" PATH="$stub_bin:$PATH" "$home/.local/bin/playwright" >/dev/null
|
||||
grep -Fqx $'mise\tuse\t-g\t--quiet\tnpm:playwright' "$log" ||
|
||||
fail "the wrapper asks mise for the package it was given" "$(cat "$log")"
|
||||
|
||||
pass "a normal install writes a wrapper that names its package"
|
||||
|
||||
# A package name is data. Quoted with %q it reaches mise as one argument
|
||||
# instead of being read as shell source when the wrapper runs.
|
||||
install_wrapper 'npm:pkg$(touch '"$tmpdir"'/PWNED)end' hostile >/dev/null
|
||||
|
||||
log="$tmpdir/hostile.log"
|
||||
: >"$log"
|
||||
OMARCHY_MISE_TEST_LOG="$log" PATH="$stub_bin:$PATH" "$home/.local/bin/hostile" >/dev/null
|
||||
|
||||
[[ -e $tmpdir/PWNED ]] &&
|
||||
fail "a package name with shell characters does not run when the wrapper does" \
|
||||
"wrapper: $(cat "$home/.local/bin/hostile")"
|
||||
|
||||
grep -Fqx $'mise\tuse\t-g\t--quiet\tnpm:pkg$(touch '"$tmpdir"'/PWNED)end' "$log" ||
|
||||
fail "the package reaches mise whole" "$(cat "$log")"
|
||||
|
||||
pass "a package name with shell characters reaches mise as one argument"
|
||||
|
||||
# The command name is a file name under ~/.local/bin. These shapes escape it,
|
||||
# hide it, make something that reads as an option, or carry characters that have
|
||||
# no business in a file name. Labelled so a newline in the value does not end up
|
||||
# inside the test output.
|
||||
refused=(
|
||||
"a slash" "../escaped"
|
||||
"a leading dot" ".hidden"
|
||||
"a leading dash" "-dash"
|
||||
"a newline" $'with\nnewline'
|
||||
"a tab" $'with\ttab'
|
||||
)
|
||||
|
||||
for (( i = 0; i < ${#refused[@]}; i += 2 )); do
|
||||
label=${refused[i]}
|
||||
name=${refused[i + 1]}
|
||||
|
||||
if install_wrapper somepkg "$name" >/dev/null 2>"$tmpdir/err"; then
|
||||
fail "a command name with $label is refused"
|
||||
fi
|
||||
grep -Fq 'is not usable as a command name' "$tmpdir/err" ||
|
||||
fail "the refusal says why for a command name with $label" "$(cat "$tmpdir/err")"
|
||||
done
|
||||
|
||||
pass "command names that are not plain file names are refused"
|
||||
|
||||
# The refusal has to land before the rm, which would otherwise delete the
|
||||
# escaped path on its way to failing.
|
||||
victim="$tmpdir/victim"
|
||||
printf 'keep me\n' >"$victim"
|
||||
if install_wrapper somepkg "../../../..$victim" >/dev/null 2>&1; then
|
||||
fail "an escaping command name is refused"
|
||||
fi
|
||||
[[ -f $victim ]] ||
|
||||
fail "an escaping command name removes nothing outside ~/.local/bin"
|
||||
|
||||
pass "an escaping command name removes nothing outside ~/.local/bin"
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const network = requireFromRoot('shell/plugins/panels/network/Model.js')
|
||||
const states = { Unknown: 0, None: 1, Portal: 2, Limited: 3, Full: 4 }
|
||||
|
||||
for (const kind of ['wifi', 'ethernet']) {
|
||||
for (const [native, expected] of [
|
||||
['Unknown', 'unknown'], ['None', 'none'], ['Portal', 'portal'],
|
||||
['Limited', 'limited'], ['Full', 'full']
|
||||
]) {
|
||||
assertEqual(network.connectivityState(kind, states[native], states, true), expected,
|
||||
`${kind} maps native ${native} connectivity without confusing an outage with a portal`)
|
||||
}
|
||||
assertEqual(network.connectivityState(kind, 99, states, true), 'unknown', `${kind} handles unknown connectivity`)
|
||||
for (const native of Object.values(states)) {
|
||||
assertEqual(network.connectivityState(kind, native, states, false), 'unknown', `${kind} ignores stale results with probing disabled (${native})`)
|
||||
}
|
||||
}
|
||||
for (const native of Object.values(states)) {
|
||||
assertEqual(network.connectivityState('disconnected', native, states, true), 'none', `disconnect clears stale connectivity (${native})`)
|
||||
}
|
||||
for (const state of ['portal', 'limited']) {
|
||||
assertEqual(network.connectionIcon('wifi', 80, state), '', `${state} uses a blocked Wi-Fi icon`)
|
||||
assertEqual(network.connectionIcon('ethernet', 80, state), '', `${state} uses a blocked Ethernet icon`)
|
||||
assertEqual(network.connectionIcon('disconnected', 80, state), '', `${state} does not override the disconnected icon`)
|
||||
}
|
||||
for (const state of ['full', 'unknown', 'none', undefined]) {
|
||||
for (const signal of [-1, 0, 20, 40, 60, 80, 100]) {
|
||||
assertEqual(network.connectionIcon('wifi', signal, state), network.wifiIconFor(signal), `${state} preserves Wi-Fi strength ${signal}`)
|
||||
}
|
||||
assertEqual(network.connectionIcon('ethernet', -1, state), '', `${state} preserves the Ethernet icon`)
|
||||
}
|
||||
const url = new URL(network.captivePortalUrl)
|
||||
assertEqual(url.protocol, 'http:', 'browser entry point uses plain HTTP so a portal can intercept it')
|
||||
assertEqual(url.hostname, 'ping.archlinux.org', 'browser entry point is fixed rather than portal-supplied')
|
||||
assertEqual(url.username + url.password, '', 'browser entry point contains no credentials')
|
||||
JS
|
||||
|
||||
require_compositor "network captive-portal runtime test"
|
||||
require_command quickshell
|
||||
|
||||
stage=$(mktemp -d)
|
||||
trap 'rm -rf -- "$stage"' EXIT
|
||||
fixture="$SHELL_TEST_DIR/fixtures/network-captive-portal"
|
||||
mkdir -p "$stage/network" "$stage/bin" "$stage/home"
|
||||
ln -s "$ROOT/shell/Ui" "$stage/Ui"
|
||||
ln -s "$ROOT/shell/Commons" "$stage/Commons"
|
||||
cp -r "$fixture/mocks" "$stage/mocks"
|
||||
cp "$fixture/shell.qml" "$stage/shell.qml"
|
||||
cp "$ROOT/shell/plugins/panels/network/Model.js" "$stage/network/Model.js"
|
||||
node - "$ROOT" "$stage" <<'JS'
|
||||
const fs = require('fs')
|
||||
const [root, stage] = process.argv.slice(2)
|
||||
let source = fs.readFileSync(`${root}/shell/plugins/panels/network/Panel.qml`, 'utf8')
|
||||
// Keep installed enum values and actual UI bindings. Only replace the singleton
|
||||
// and expose private IDs in the disposable copy, never in production code.
|
||||
source = source.replace('import Quickshell.Networking', 'import Quickshell.Networking\nimport "../mocks"')
|
||||
source = source.replace(/\bNetworking\./g, 'NetworkMock.')
|
||||
source = source.replace(' id: root', ` id: root
|
||||
property alias testButton: portalAction
|
||||
property alias testKeys: keyCatcher
|
||||
property alias testMeta: heroMeta
|
||||
property alias testTitle: heroSsid
|
||||
property alias testPoll: connectivityPoll
|
||||
property alias testBarButton: button`)
|
||||
fs.writeFileSync(`${stage}/network/Panel.qml`, source)
|
||||
JS
|
||||
printf '#!/bin/bash\nexit 0\n' > "$stage/bin/noop"
|
||||
chmod +x "$stage/bin/noop"
|
||||
for command in omarchy-dns omarchy-network-band; do
|
||||
ln -s noop "$stage/bin/$command"
|
||||
done
|
||||
# Preview uses only synthetic details, never the host's SSID or addresses.
|
||||
# Normal assertions keep the details empty to exercise missing-route handling.
|
||||
printf '#!/bin/bash\nif [[ -n ${NETWORK_TEST_PREVIEW:-} ]]; then\n printf "type\\twifi\\niface\\ttest-wifi\\nssid\\tGuest Wi-Fi\\nip\\t192.0.2.10\\ngateway\\t192.0.2.1\\n"\nfi\n' > "$stage/bin/omarchy-network-status"
|
||||
chmod +x "$stage/bin/omarchy-network-status"
|
||||
printf '#!/bin/bash\nprintf "%%s\\n" "$@" >> "$NETWORK_TEST_BROWSER_LOG"\n' > "$stage/bin/omarchy-launch-browser"
|
||||
chmod +x "$stage/bin/omarchy-launch-browser"
|
||||
|
||||
# All networking and external actions are mocked; the real connection and
|
||||
# browser are never touched, and the fixture writes only to its scratch HOME.
|
||||
output=$(HOME="$stage/home" OMARCHY_PATH="$ROOT" PATH="$stage/bin:$PATH" \
|
||||
NETWORK_TEST_BROWSER_LOG="$stage/browser.log" \
|
||||
timeout 30 quickshell -p "$stage" --no-color 2>&1) || fail "network portal fixture exits cleanly" "$output"
|
||||
[[ $output == *"RESULT pass"* ]] || fail "network portal runtime assertions pass" "$output"
|
||||
if rg -q 'RESULT fail|ReferenceError|TypeError|Error:|Unable to assign|Binding loop' <<< "$output"; then
|
||||
fail "network portal fixture has no QML errors" "$output"
|
||||
fi
|
||||
[[ -f $stage/browser.log ]] || fail "portal action launches the browser"
|
||||
[[ $(<"$stage/browser.log") == "http://ping.archlinux.org/nm-check.txt" ]] || fail "portal opens exactly one fixed HTTP URL"
|
||||
pass "network portal, recovery, disabled checks, outage, disconnect, keyboard navigation, and browser argv work in QML"
|
||||
@@ -6,7 +6,6 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
dns="$ROOT/bin/omarchy-dns"
|
||||
hardware_network="$ROOT/install/hardware/network.sh"
|
||||
migration="$ROOT/migrations/1782002156.sh"
|
||||
|
||||
! grep -F 'systemd-networkd' "$dns" >/dev/null || fail "omarchy-dns no longer restarts systemd-networkd"
|
||||
grep -F 'NetworkManager/conf.d/20-omarchy-dns.conf' "$dns" >/dev/null
|
||||
@@ -26,10 +25,3 @@ grep -F 'systemd-networkd.socket' "$hardware_network" >/dev/null
|
||||
grep -F '20-wlan.network' "$hardware_network" >/dev/null
|
||||
grep -F 'omarchy-networkd-retired' "$hardware_network" >/dev/null
|
||||
pass "hardware setup retires archinstall networkd state"
|
||||
|
||||
grep -F 'OMARCHY_UPGRADE_TO_QUATTRO_LIVE' "$migration" >/dev/null
|
||||
grep -F 'systemctl disable --now "$unit"' "$migration" >/dev/null
|
||||
grep -F 'systemctl stop systemd-networkd.service' "$migration" >/dev/null
|
||||
grep -F 'NetworkManager.service' "$migration" >/dev/null
|
||||
grep -F '20-wlan.network' "$migration" >/dev/null
|
||||
pass "migration repairs upgraded systems with networkd still active"
|
||||
@@ -1,131 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
migration="$ROOT/migrations/1786605598.sh"
|
||||
packaged_hooks="$ROOT/etc/mkinitcpio.conf.d/omarchy_hooks.conf"
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
stub_bin="$test_tmp/bin"
|
||||
calls="$test_tmp/calls.log"
|
||||
mkdir -p "$stub_bin"
|
||||
: >"$calls"
|
||||
|
||||
cat >"$stub_bin/omarchy-cmd-present" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
(( ${LIMINE_MKINITCPIO_INSTALLED:-1} == 1 ))
|
||||
SH
|
||||
|
||||
cat >"$stub_bin/sudo" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
printf 'sudo' >>"$TEST_LOG"
|
||||
printf '\t%s' "$@" >>"$TEST_LOG"
|
||||
printf '\n' >>"$TEST_LOG"
|
||||
"$@"
|
||||
SH
|
||||
|
||||
cat >"$stub_bin/limine-mkinitcpio" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
echo 'limine-mkinitcpio' >>"$TEST_LOG"
|
||||
SH
|
||||
|
||||
chmod +x "$stub_bin"/*
|
||||
|
||||
hooks_conf="$test_tmp/omarchy_hooks.conf"
|
||||
nvidia_conf="$test_tmp/nvidia.conf"
|
||||
rebuild_marker="$test_tmp/rebuild-complete"
|
||||
|
||||
cp "$packaged_hooks" "$hooks_conf"
|
||||
echo 'MODULES+=(nvidia nvidia_modeset nvidia_uvm nvidia_drm)' >"$nvidia_conf"
|
||||
|
||||
# Each argument is a PCI device as "vendor:class", in sysfs's own format.
|
||||
write_pci_devices() {
|
||||
rm -rf "$test_tmp/devices"
|
||||
mkdir -p "$test_tmp/devices"
|
||||
|
||||
local index=0
|
||||
local spec
|
||||
for spec in "$@"; do
|
||||
local slot
|
||||
slot=$(printf '0000:%02x:00.0' "$index")
|
||||
mkdir -p "$test_tmp/devices/$slot"
|
||||
printf '%s\n' "${spec%%:*}" >"$test_tmp/devices/$slot/vendor"
|
||||
printf '%s\n' "${spec##*:}" >"$test_tmp/devices/$slot/class"
|
||||
index=$((index + 1))
|
||||
done
|
||||
}
|
||||
|
||||
run_migration() {
|
||||
PATH="$stub_bin:$PATH" \
|
||||
TEST_LOG="$calls" \
|
||||
OMARCHY_MKINITCPIO_HOOKS_CONF="$hooks_conf" \
|
||||
OMARCHY_MKINITCPIO_NVIDIA_CONF="$nvidia_conf" \
|
||||
OMARCHY_KMS_REBUILD_MARKER="$rebuild_marker" \
|
||||
OMARCHY_PCI_DEVICES_PATH="$test_tmp/devices" \
|
||||
bash -euo pipefail "$migration" >/dev/null
|
||||
}
|
||||
|
||||
# NVIDIA-only machine with the proprietary driver: the packaged conditional
|
||||
# drops kms, so the stale initramfs must be rebuilt once.
|
||||
write_pci_devices 0x10de:0x030000
|
||||
run_migration
|
||||
|
||||
grep -Fxq 'limine-mkinitcpio' "$calls" ||
|
||||
fail "an NVIDIA-only machine rebuilds its initramfs"
|
||||
[[ -f $rebuild_marker ]] || fail "the rebuild records the machine-wide repair"
|
||||
pass "migration rebuilds the initramfs on an NVIDIA-only machine"
|
||||
|
||||
: >"$calls"
|
||||
run_migration
|
||||
|
||||
[[ ! -s $calls ]] || fail "a recorded rebuild is not repeated" "$(cat "$calls")"
|
||||
pass "migration is machine-idempotent across users"
|
||||
|
||||
rm -f "$rebuild_marker"
|
||||
: >"$calls"
|
||||
run_migration
|
||||
|
||||
grep -Fxq 'limine-mkinitcpio' "$calls" ||
|
||||
fail "an interrupted rebuild is retried"
|
||||
pass "migration retries an interrupted rebuild"
|
||||
|
||||
# Hybrid machine: the conditional keeps kms, so the initramfs already matches.
|
||||
write_pci_devices 0x1002:0x030000 0x10de:0x030200
|
||||
rm -f "$rebuild_marker"
|
||||
: >"$calls"
|
||||
run_migration
|
||||
|
||||
[[ ! -s $calls ]] || fail "a hybrid machine is left alone" "$(cat "$calls")"
|
||||
[[ ! -e $rebuild_marker ]] || fail "an untouched machine is not marked as repaired"
|
||||
pass "migration skips a hybrid machine that keeps kms"
|
||||
|
||||
# A user-edited hooks conf predating the conditional (the packaged update sits
|
||||
# in a .pacnew) still carries kms unconditionally: nothing to rebuild for.
|
||||
write_pci_devices 0x10de:0x030000
|
||||
echo 'HOOKS=(base udev autodetect modconf kms block filesystems fsck)' >"$hooks_conf"
|
||||
: >"$calls"
|
||||
run_migration
|
||||
|
||||
[[ ! -s $calls ]] || fail "a user-edited hooks conf is left alone" "$(cat "$calls")"
|
||||
pass "migration skips a hooks conf without the conditional"
|
||||
|
||||
cp "$packaged_hooks" "$hooks_conf"
|
||||
|
||||
: >"$calls"
|
||||
LIMINE_MKINITCPIO_INSTALLED=0 run_migration
|
||||
|
||||
[[ ! -s $calls ]] || fail "installs without limine-mkinitcpio are skipped" "$(cat "$calls")"
|
||||
|
||||
mv "$nvidia_conf" "$nvidia_conf.away"
|
||||
run_migration
|
||||
mv "$nvidia_conf.away" "$nvidia_conf"
|
||||
|
||||
[[ ! -s $calls ]] || fail "machines without the proprietary NVIDIA driver are skipped" "$(cat "$calls")"
|
||||
pass "migration skips installs it does not apply to"
|
||||
Executable
+288
@@ -0,0 +1,288 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
mkdir -p "$tmp_dir/bin" "$tmp_dir/home"
|
||||
export TEST_LOG="$tmp_dir/log"
|
||||
export PATH="$tmp_dir/bin:$PATH"
|
||||
export HOME="$tmp_dir/home"
|
||||
export OMARCHY_OPENCLAW_ONBOARD_SETTLE_SECONDS=0
|
||||
unit="$HOME/.config/systemd/user/openclaw-gateway.service"
|
||||
export unit
|
||||
|
||||
# systemd and ss stand in for themselves. Once the wizard has written the unit
|
||||
# it is running as pid 4242, and that same process holds the gateway port
|
||||
# unless a test plants a different listener (an orphan) in $listener_file.
|
||||
listener_file="$tmp_dir/listener"
|
||||
export listener_file
|
||||
cat >"$tmp_dir/bin/systemctl" <<'SCRIPT'
|
||||
#!/bin/bash
|
||||
printf 'systemctl:%s\n' "$*" >>"$TEST_LOG"
|
||||
if [[ $* == *MainPID* ]]; then
|
||||
[[ -f $unit ]] && echo 4242 || echo 0
|
||||
exit 0
|
||||
fi
|
||||
[[ $* == *is-active* ]] && [[ -f $unit ]]
|
||||
SCRIPT
|
||||
chmod +x "$tmp_dir/bin/systemctl"
|
||||
cat >"$tmp_dir/bin/ss" <<'SCRIPT'
|
||||
#!/bin/bash
|
||||
pid=
|
||||
[[ -f $listener_file ]] && pid=$(cat "$listener_file")
|
||||
[[ -z $pid && -f $unit ]] && pid=4242
|
||||
[[ -n $pid ]] && echo "LISTEN 0 511 127.0.0.1:18789 0.0.0.0:* users:((\"node\",pid=$pid,fd=3))"
|
||||
exit 0
|
||||
SCRIPT
|
||||
chmod +x "$tmp_dir/bin/ss"
|
||||
export OMARCHY_OPENCLAW_ONBOARD_GATEWAY_TIMEOUT=1
|
||||
|
||||
# A wizard that configures OpenClaw, prints its outro, and then lingers forever
|
||||
# on an open handle: the 2026.9.1 --skip-ui behaviour. It "installs the
|
||||
# gateway" by writing the config a moment in, so the gateway only starts
|
||||
# answering partway through, never at once.
|
||||
cat >"$tmp_dir/bin/openclaw" <<'SCRIPT'
|
||||
#!/bin/bash
|
||||
printf 'openclaw:%s\n' "$*" >>"$TEST_LOG"
|
||||
case $1 in
|
||||
onboard)
|
||||
( sleep 1; mkdir -p "$HOME/.openclaw" "${unit%/*}"; touch "$HOME/.openclaw/openclaw.json" "$unit" ) &
|
||||
trap 'echo terminated >>"$TEST_LOG"; exit 143' TERM
|
||||
while :; do sleep 0.2; done
|
||||
;;
|
||||
dashboard)
|
||||
[[ -f $HOME/.openclaw/openclaw.json ]] && echo '{"ok":true,"port":18789}' || { echo '{"ok":false}'; exit 1; }
|
||||
;;
|
||||
esac
|
||||
SCRIPT
|
||||
chmod +x "$tmp_dir/bin/openclaw"
|
||||
|
||||
start=$SECONDS
|
||||
rc=0
|
||||
"$ROOT/bin/omarchy-openclaw-onboard" </dev/null >/dev/null 2>&1 || rc=$?
|
||||
elapsed=$((SECONDS - start))
|
||||
|
||||
grep -q '^openclaw:onboard --flow quickstart --install-daemon --skip-ui$' "$TEST_LOG" ||
|
||||
fail "onboarding runs the classic quickstart wizard with the service install" "$(grep '^openclaw:onboard' "$TEST_LOG" || true)"
|
||||
pass "onboarding runs the classic quickstart wizard with the service install"
|
||||
|
||||
[[ $rc == 0 ]] || fail "a wizard that lingers after the gateway is up is stopped and counts as success" "rc=$rc"
|
||||
grep -q '^terminated$' "$TEST_LOG" ||
|
||||
fail "a wizard that lingers after the gateway is up is stopped and counts as success" "wizard was never signalled"
|
||||
(( elapsed < 30 )) || fail "a wizard that lingers after the gateway is up is stopped and counts as success" "took ${elapsed}s"
|
||||
pass "a wizard that lingers after the gateway is up is stopped and counts as success"
|
||||
|
||||
# The wizard only starts being stopped once the gateway actually answers: a
|
||||
# stub that never writes the config is left alone and must be ended by its own
|
||||
# exit, not the watcher.
|
||||
: >"$TEST_LOG"
|
||||
rm -rf "$HOME/.openclaw" "$unit" "$listener_file"
|
||||
cat >"$tmp_dir/bin/openclaw" <<'SCRIPT'
|
||||
#!/bin/bash
|
||||
printf 'openclaw:%s\n' "$*" >>"$TEST_LOG"
|
||||
[[ $1 == onboard ]] && { echo "Skipped for now."; exit 3; }
|
||||
echo '{"ok":false}'; exit 1
|
||||
SCRIPT
|
||||
chmod +x "$tmp_dir/bin/openclaw"
|
||||
rc=0
|
||||
"$ROOT/bin/omarchy-openclaw-onboard" </dev/null >/dev/null 2>&1 || rc=$?
|
||||
[[ $rc == 3 ]] || fail "an abandoned wizard passes its exit code through" "rc=$rc"
|
||||
[[ ! -f $HOME/.openclaw/openclaw.json ]] || fail "an abandoned wizard passes its exit code through" "config appeared"
|
||||
! grep -q '^terminated$' "$TEST_LOG" || fail "an abandoned wizard passes its exit code through" "watcher signalled it anyway"
|
||||
pass "an abandoned wizard passes its exit code through"
|
||||
|
||||
# A wizard that finishes and exits on its own is simply waited for.
|
||||
: >"$TEST_LOG"
|
||||
cat >"$tmp_dir/bin/openclaw" <<'SCRIPT'
|
||||
#!/bin/bash
|
||||
printf 'openclaw:%s\n' "$*" >>"$TEST_LOG"
|
||||
[[ $1 == onboard ]] && { mkdir -p "$HOME/.openclaw"; touch "$HOME/.openclaw/openclaw.json"; exit 0; }
|
||||
echo '{"ok":true,"port":18789}'
|
||||
SCRIPT
|
||||
chmod +x "$tmp_dir/bin/openclaw"
|
||||
rc=0
|
||||
"$ROOT/bin/omarchy-openclaw-onboard" </dev/null >/dev/null 2>&1 || rc=$?
|
||||
[[ $rc == 0 ]] || fail "a wizard that exits cleanly is waited for" "rc=$rc"
|
||||
pass "a wizard that exits cleanly is waited for"
|
||||
|
||||
# Input reaches the wizard: it runs in the foreground, not backgrounded (which
|
||||
# would stop it on SIGTTIN the moment it read stdin).
|
||||
: >"$TEST_LOG"
|
||||
rm -rf "$HOME/.openclaw" "$unit" "$listener_file"
|
||||
cat >"$tmp_dir/bin/openclaw" <<'SCRIPT'
|
||||
#!/bin/bash
|
||||
printf 'openclaw:%s\n' "$*" >>"$TEST_LOG"
|
||||
case $1 in
|
||||
onboard)
|
||||
read -r answer
|
||||
printf 'answer:%s\n' "$answer" >>"$TEST_LOG"
|
||||
mkdir -p "$HOME/.openclaw"; touch "$HOME/.openclaw/openclaw.json"
|
||||
exit 0
|
||||
;;
|
||||
dashboard) echo '{"ok":true,"port":18789}' ;;
|
||||
esac
|
||||
SCRIPT
|
||||
chmod +x "$tmp_dir/bin/openclaw"
|
||||
printf 'gpt-5\n' | "$ROOT/bin/omarchy-openclaw-onboard" >/dev/null 2>&1
|
||||
grep -q '^answer:gpt-5$' "$TEST_LOG" ||
|
||||
fail "the wizard reads terminal input" "$(grep '^answer' "$TEST_LOG" || echo 'no input reached it')"
|
||||
pass "the wizard reads terminal input"
|
||||
|
||||
# An already-configured machine whose gateway answers is not onboarded again:
|
||||
# the wizard's repair pass would otherwise be stopped mid-prompt the moment
|
||||
# the watcher saw the pre-existing gateway.
|
||||
: >"$TEST_LOG"
|
||||
mkdir -p "$HOME/.openclaw" && touch "$HOME/.openclaw/openclaw.json"
|
||||
cat >"$tmp_dir/bin/openclaw" <<'SCRIPT'
|
||||
#!/bin/bash
|
||||
printf 'openclaw:%s\n' "$*" >>"$TEST_LOG"
|
||||
[[ $1 == onboard ]] && { echo wizard-ran >>"$TEST_LOG"; exit 0; }
|
||||
echo '{"ok":true,"port":18789}'
|
||||
SCRIPT
|
||||
chmod +x "$tmp_dir/bin/openclaw"
|
||||
rc=0
|
||||
"$ROOT/bin/omarchy-openclaw-onboard" </dev/null >/dev/null 2>&1 || rc=$?
|
||||
[[ $rc == 0 ]] || fail "an already-running OpenClaw is left alone" "rc=$rc"
|
||||
! grep -q '^wizard-ran$' "$TEST_LOG" || fail "an already-running OpenClaw is left alone" "wizard ran anyway"
|
||||
pass "an already-running OpenClaw is left alone"
|
||||
|
||||
# Setup applied but the gateway never answers: the wizard would linger
|
||||
# forever, so it is stopped after the deadline and that is reported as failure.
|
||||
: >"$TEST_LOG"
|
||||
rm -rf "$HOME/.openclaw" "$unit" "$listener_file"
|
||||
cat >"$tmp_dir/bin/openclaw" <<'SCRIPT'
|
||||
#!/bin/bash
|
||||
printf 'openclaw:%s\n' "$*" >>"$TEST_LOG"
|
||||
case $1 in
|
||||
onboard)
|
||||
mkdir -p "$HOME/.openclaw"; touch "$HOME/.openclaw/openclaw.json"
|
||||
trap 'echo terminated >>"$TEST_LOG"; exit 143' TERM
|
||||
while :; do sleep 0.2; done
|
||||
;;
|
||||
dashboard) echo '{"ok":false}'; exit 1 ;;
|
||||
esac
|
||||
SCRIPT
|
||||
chmod +x "$tmp_dir/bin/openclaw"
|
||||
start=$SECONDS
|
||||
rc=0
|
||||
err=$("$ROOT/bin/omarchy-openclaw-onboard" </dev/null 2>&1 >/dev/null) || rc=$?
|
||||
elapsed=$((SECONDS - start))
|
||||
[[ $rc == 1 ]] || fail "a gateway that never comes up ends the wait with a failure" "rc=$rc"
|
||||
grep -q '^terminated$' "$TEST_LOG" || fail "a gateway that never comes up ends the wait with a failure" "wizard left running"
|
||||
[[ $err == *"gateway status"* ]] || fail "a gateway that never comes up ends the wait with a failure" "no diagnostic: $err"
|
||||
(( elapsed < 30 )) || fail "a gateway that never comes up ends the wait with a failure" "took ${elapsed}s"
|
||||
pass "a gateway that never comes up ends the wait with a failure"
|
||||
|
||||
# A signal at this script alone takes the wizard down with it.
|
||||
: >"$TEST_LOG"
|
||||
rm -rf "$HOME/.openclaw" "$unit" "$listener_file"
|
||||
cat >"$tmp_dir/bin/openclaw" <<'SCRIPT'
|
||||
#!/bin/bash
|
||||
printf 'openclaw:%s\n' "$*" >>"$TEST_LOG"
|
||||
case $1 in
|
||||
onboard) trap 'echo terminated >>"$TEST_LOG"; exit 143' TERM; while :; do sleep 0.2; done ;;
|
||||
dashboard) echo '{"ok":false}'; exit 1 ;;
|
||||
esac
|
||||
SCRIPT
|
||||
chmod +x "$tmp_dir/bin/openclaw"
|
||||
"$ROOT/bin/omarchy-openclaw-onboard" </dev/null >/dev/null 2>&1 &
|
||||
wrapper=$!
|
||||
sleep 1
|
||||
kill -TERM "$wrapper"
|
||||
wait "$wrapper" 2>/dev/null || true
|
||||
sleep 1
|
||||
grep -q '^terminated$' "$TEST_LOG" || fail "stopping the wrapper stops the wizard" "wizard survived"
|
||||
! pgrep -f "$tmp_dir/bin/openclaw onboard" >/dev/null || fail "stopping the wrapper stops the wizard" "wizard process still alive"
|
||||
pass "stopping the wrapper stops the wizard"
|
||||
|
||||
# A config left by an earlier, incomplete setup does not start the gateway
|
||||
# deadline: only this run applying setup does. The wizard here takes longer
|
||||
# than the deadline at its prompts, never touches the old config, and must be
|
||||
# left to finish on its own.
|
||||
: >"$TEST_LOG"
|
||||
mkdir -p "$HOME/.openclaw" && touch "$HOME/.openclaw/openclaw.json"
|
||||
sleep 1
|
||||
cat >"$tmp_dir/bin/openclaw" <<'SCRIPT'
|
||||
#!/bin/bash
|
||||
printf 'openclaw:%s\n' "$*" >>"$TEST_LOG"
|
||||
case $1 in
|
||||
onboard) trap 'echo terminated >>"$TEST_LOG"; exit 143' TERM; sleep 4; echo finished >>"$TEST_LOG"; exit 0 ;;
|
||||
dashboard) echo '{"ok":false}'; exit 1 ;;
|
||||
esac
|
||||
SCRIPT
|
||||
chmod +x "$tmp_dir/bin/openclaw"
|
||||
rc=0
|
||||
"$ROOT/bin/omarchy-openclaw-onboard" </dev/null >/dev/null 2>&1 || rc=$?
|
||||
[[ $rc == 0 ]] || fail "a stale config does not start the gateway deadline" "rc=$rc"
|
||||
grep -q '^finished$' "$TEST_LOG" || fail "a stale config does not start the gateway deadline" "wizard was cut short"
|
||||
! grep -q '^terminated$' "$TEST_LOG" || fail "a stale config does not start the gateway deadline" "wizard was signalled"
|
||||
pass "a stale config does not start the gateway deadline"
|
||||
|
||||
# ...while the same stale config being rewritten by this run does arm it.
|
||||
: >"$TEST_LOG"
|
||||
cat >"$tmp_dir/bin/openclaw" <<'SCRIPT'
|
||||
#!/bin/bash
|
||||
printf 'openclaw:%s\n' "$*" >>"$TEST_LOG"
|
||||
case $1 in
|
||||
onboard) touch "$HOME/.openclaw/openclaw.json"; trap 'echo terminated >>"$TEST_LOG"; exit 143' TERM; while :; do sleep 0.2; done ;;
|
||||
dashboard) echo '{"ok":false}'; exit 1 ;;
|
||||
esac
|
||||
SCRIPT
|
||||
chmod +x "$tmp_dir/bin/openclaw"
|
||||
rc=0
|
||||
"$ROOT/bin/omarchy-openclaw-onboard" </dev/null >/dev/null 2>&1 || rc=$?
|
||||
[[ $rc == 1 ]] || fail "a config rewritten by this run arms the gateway deadline" "rc=$rc"
|
||||
grep -q '^terminated$' "$TEST_LOG" || fail "a config rewritten by this run arms the gateway deadline" "wizard left running"
|
||||
pass "a config rewritten by this run arms the gateway deadline"
|
||||
|
||||
# A gateway that answers while no config exists is not this run's: with no
|
||||
# config `openclaw dashboard --json` still probes the default port, so a
|
||||
# process left behind by something else (an earlier guided onboarding's
|
||||
# foreground gateway) would otherwise end a wizard still at its first prompt.
|
||||
: >"$TEST_LOG"
|
||||
rm -rf "$HOME/.openclaw" "$unit" "$listener_file"
|
||||
cat >"$tmp_dir/bin/openclaw" <<'SCRIPT'
|
||||
#!/bin/bash
|
||||
printf 'openclaw:%s\n' "$*" >>"$TEST_LOG"
|
||||
case $1 in
|
||||
onboard) trap 'echo terminated >>"$TEST_LOG"; exit 143' TERM; sleep 4; echo finished >>"$TEST_LOG"; exit 0 ;;
|
||||
dashboard) echo '{"ok":true,"port":18789}' ;;
|
||||
esac
|
||||
SCRIPT
|
||||
chmod +x "$tmp_dir/bin/openclaw"
|
||||
rc=0
|
||||
"$ROOT/bin/omarchy-openclaw-onboard" </dev/null >/dev/null 2>&1 || rc=$?
|
||||
[[ $rc == 0 ]] || fail "an orphaned gateway answering with no config does not end the wizard" "rc=$rc"
|
||||
grep -q '^finished$' "$TEST_LOG" || fail "an orphaned gateway answering with no config does not end the wizard" "wizard was cut short"
|
||||
! grep -q '^terminated$' "$TEST_LOG" || fail "an orphaned gateway answering with no config does not end the wizard" "wizard was signalled"
|
||||
pass "an orphaned gateway answering with no config does not end the wizard"
|
||||
|
||||
# And after setup, an orphan still bound to the port is not success even
|
||||
# while the unit reports active: Type=simple counts it active from the fork,
|
||||
# before it has discovered the port is taken. The port's owner has to be the
|
||||
# unit's own main process. Here it never is, so the deadline ends the wait as
|
||||
# a failure rather than opening the app on the orphan.
|
||||
: >"$TEST_LOG"
|
||||
rm -rf "$HOME/.openclaw" "$unit" "$listener_file"
|
||||
echo 999 >"$listener_file"
|
||||
cat >"$tmp_dir/bin/openclaw" <<'SCRIPT'
|
||||
#!/bin/bash
|
||||
printf 'openclaw:%s\n' "$*" >>"$TEST_LOG"
|
||||
case $1 in
|
||||
onboard) mkdir -p "$HOME/.openclaw" "${unit%/*}"; touch "$HOME/.openclaw/openclaw.json" "$unit"; trap 'echo terminated >>"$TEST_LOG"; exit 143' TERM; while :; do sleep 0.2; done ;;
|
||||
dashboard) echo '{"ok":true,"port":18789}' ;;
|
||||
esac
|
||||
SCRIPT
|
||||
chmod +x "$tmp_dir/bin/openclaw"
|
||||
rc=0
|
||||
"$ROOT/bin/omarchy-openclaw-onboard" </dev/null >/dev/null 2>&1 || rc=$?
|
||||
[[ $rc == 1 ]] || fail "an orphan holding the port is not mistaken for the active unit" "rc=$rc"
|
||||
grep -q '^terminated$' "$TEST_LOG" || fail "an orphan holding the port is not mistaken for the active unit" "wizard left running"
|
||||
grep -q '^systemctl:--user show -p MainPID --value openclaw-gateway.service$' "$TEST_LOG" ||
|
||||
fail "an orphan holding the port is not mistaken for the active unit" "unit ownership never checked"
|
||||
pass "an orphan holding the port is not mistaken for the active unit"
|
||||
rm -f "$listener_file"
|
||||
Executable
+201
@@ -0,0 +1,201 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
TMPDIR=""
|
||||
QS_PID=""
|
||||
|
||||
cleanup() {
|
||||
if [[ -n $QS_PID ]] && kill -0 "$QS_PID" 2>/dev/null; then
|
||||
kill "$QS_PID" 2>/dev/null || true
|
||||
wait "$QS_PID" 2>/dev/null || true
|
||||
fi
|
||||
if [[ -n $TMPDIR && -d $TMPDIR ]]; then
|
||||
rm -rf "$TMPDIR"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
shell_qml="$ROOT/shell/shell.qml"
|
||||
bar_qml="$ROOT/shell/plugins/bar/Bar.qml"
|
||||
plugin_shell_api="$ROOT/shell/services/PluginShellApi.qml"
|
||||
idle_service="$ROOT/shell/plugins/services/idle/Service.qml"
|
||||
|
||||
# Normalize horizontal and vertical whitespace so the wiring assertions survive
|
||||
# harmless QML reflow. The runtime fixture below behaviorally covers
|
||||
# PluginShellApi and AuthServiceStore; these checks remain the guard for their
|
||||
# integration through shell.qml and Bar.qml, including without a compositor.
|
||||
qml_matches() {
|
||||
local file=$1
|
||||
local pattern=$2
|
||||
|
||||
tr '\n\r\t' ' ' < "$file" | grep -Eq "$pattern"
|
||||
}
|
||||
|
||||
qml_matches "$shell_qml" 'comp\.createObject\( *manifest\.__isFirstParty *&& *!authenticationService *\? *serviceHost *: *null *\)' ||
|
||||
fail "third-party and authentication services are detached from the host object tree"
|
||||
qml_matches "$shell_qml" 'AuthServiceStore\.put\( *key, *inst *\)' ||
|
||||
fail "authentication services are retained outside the host service map"
|
||||
qml_matches "$shell_qml" 'AuthServiceStore\.isTrusted\( *key *\)' ||
|
||||
fail "live authentication classification survives public manifest mutation"
|
||||
qml_matches "$shell_qml" 'AuthServiceStore\.updateManifest\( *id, *shell\.publicPluginManifest\( *m *\) *\)' ||
|
||||
fail "kept authentication services receive only a public manifest snapshot"
|
||||
qml_matches "$shell_qml" 'if *\( *!serviceKeepLoaded\( *authenticationId *\) *\) *AuthServiceStore\.destroy\( *authenticationId *\)' ||
|
||||
fail "keepLoaded authentication services survive plugin rescans"
|
||||
pass "third-party and authentication services are detached from the host object tree"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const fs = require('fs')
|
||||
const vm = require('vm')
|
||||
const store = {}
|
||||
vm.createContext(store)
|
||||
vm.runInContext(
|
||||
fs.readFileSync(path.join(root, 'shell/services/AuthServiceStore.js'), 'utf8'),
|
||||
store
|
||||
)
|
||||
const service = { destroy() {} }
|
||||
store.put('omarchy.lock', service)
|
||||
store.destroy('omarchy.lock')
|
||||
assert(
|
||||
!store.has('omarchy.lock') && store.isTrusted('omarchy.lock'),
|
||||
'authentication classification survives service teardown'
|
||||
)
|
||||
JS
|
||||
|
||||
qml_matches "$shell_qml" 'inst\.shell *= *shell\.pluginShellFor\( *manifest *\)' ||
|
||||
fail "service plugins receive a scoped shell facade"
|
||||
qml_matches "$shell_qml" 'item\.shell *= *shell\.pluginShellFor\( *panelEntry\.manifest *\)' ||
|
||||
fail "panel plugins receive a scoped shell facade"
|
||||
qml_matches "$shell_qml" 'target\.shell *= *shell\.pluginShellFor\( *manifest *\)' ||
|
||||
fail "full-bar plugins receive a scoped shell facade"
|
||||
pass "third-party entry points receive scoped shell facades"
|
||||
|
||||
if qml_matches "$plugin_shell_api" 'function +pluginShellForId\('; then
|
||||
fail "replacement-bar facade exposes a generic plugin-shell factory"
|
||||
fi
|
||||
qml_matches "$bar_qml" 'else if *\( *root\.shell *&& *typeof root\.shell\.pluginShellForBarEntry *=== *"function" *\) *\{[^}]*pluginShell *= *root\.shell\.pluginShellForBarEntry\( *key, *moduleName *\)' ||
|
||||
fail "replacement bars do not fall back to a service-less entry facade"
|
||||
pass "replacement bars cannot manufacture another plugin's service facade"
|
||||
|
||||
qml_matches "$shell_qml" 'target\.barConfig *= *shell\.barConfigFor\( *manifest *\)' ||
|
||||
fail "initial replacement-bar configuration is not detached"
|
||||
qml_matches "$shell_qml" 'bar\.barConfig *= *shell\.barConfigFor\( *shell\.activeBarManifest *\)' ||
|
||||
fail "replacement-bar configuration updates are not detached"
|
||||
pass "replacement bars receive detached configuration snapshots"
|
||||
|
||||
qml_matches "$bar_qml" 'target\.bar *= *firstParty *\? *root *: *root\.pluginBarApiFor\( *pluginApiId, *moduleName, *registered *\)' ||
|
||||
fail "third-party widgets receive a bar facade instead of the host bar"
|
||||
qml_matches "$bar_qml" 'api\.clickTargets *= *root\.pluginClickTargets\( *api\.pluginId *\)' ||
|
||||
fail "third-party bar facades exclude other widgets from their object graph"
|
||||
pass "third-party widgets receive a bar facade instead of the host bar"
|
||||
|
||||
qml_matches "$shell_qml" 'widgets: *shell\.publicBarWidgetSnapshot\( *\)' ||
|
||||
fail "third-party widget registries receive detached snapshots"
|
||||
qml_matches "$bar_qml" 'root\.markPluginObject\( *pluginId, *target, *"clickTarget" *\)' ||
|
||||
fail "third-party bar-object ownership is stamped by the host callback"
|
||||
qml_matches "$bar_qml" 'root\.markPluginObject\( *pluginId, *owner, *"popout" *\)' ||
|
||||
fail "owner-less popouts receive trusted ownership before activation"
|
||||
qml_matches "$shell_qml" 'manifest\.__hostCapabilities\.indexOf\( *"authentication" *\)' ||
|
||||
fail "authentication isolation follows host-stamped capabilities"
|
||||
pass "registry mutation and ownership boundaries are host-controlled"
|
||||
|
||||
qml_matches "$bar_qml" 'root\.moduleWidgets\( *moduleName *\)' ||
|
||||
fail "custom bar module widget lookups use their real module name"
|
||||
qml_matches "$shell_qml" 'shell\.pluginShellForBarEntry\( *cacheKey *\+ *":" *\+ *ownerId, *moduleName *\)' ||
|
||||
fail "full-bar plugins receive a scoped settings facade for custom modules"
|
||||
pass "custom bar modules retain settings and popout identity"
|
||||
|
||||
if qml_matches "$bar_qml" 'on(Foreground|BarForeground|Background|Urgent|FontFamily|Vertical|BarSize|Transparent)Changed: *sync'; then
|
||||
fail "animated scalar properties still trigger full facade resyncs"
|
||||
fi
|
||||
qml_matches "$bar_qml" 'api\.foreground *= *Qt\.binding\( *function\( *\) *\{ *return root\.foreground *\} *\)' ||
|
||||
fail "third-party bar scalar mirrors use bindings"
|
||||
qml_matches "$shell_qml" 'shell\.prunePluginApis\( *\)' ||
|
||||
fail "disabled plugin facade caches are pruned"
|
||||
pass "plugin facade synchronization is bounded"
|
||||
|
||||
qml_matches "$shell_qml" 'descriptor\.profile *!== *expectedProfile[^}]*shell\.revokePluginShellApi\( *shellKey *\)' ||
|
||||
fail "manifest capability changes do not revoke cached plugin facades"
|
||||
qml_matches "$shell_qml" 'shell\.barPluginMayControl\( *currentManifest\( *\), *requestedId *\)' ||
|
||||
fail "bar lifecycle callbacks do not validate the current manifest"
|
||||
qml_matches "$shell_qml" 'return hasCurrentBarCapabilities\( *\) *\? *shell\.mutatePluginBarConfig\( *mutator *\) *: *false' ||
|
||||
fail "bar configuration mutation does not validate the current manifest"
|
||||
pass "manifest changes revoke cached facade capabilities"
|
||||
|
||||
qml_matches "$shell_qml" 'idleConfig: *shell\.publicIdleConfigFor\( *manifest *\)' ||
|
||||
fail "cloned idle services do not receive their configured timeouts"
|
||||
qml_matches "$shell_qml" 'shellApi\.idleConfig *= *shell\.publicIdleConfigFor\( *shellManifest *\)' ||
|
||||
fail "cloned idle service configuration does not refresh"
|
||||
qml_matches "$idle_service" 'shell *&& *shell\.idleConfig *\? *shell\.idleConfig *: *\(\{\}\)' ||
|
||||
fail "the idle service does not consume its scoped configuration"
|
||||
bar_entry_shell=$(sed -n '/^ function pluginShellForBarEntry(/,/^ function pluginShellFor(/p' "$shell_qml")
|
||||
tr '\n\r\t' ' ' <<<"$bar_entry_shell" |
|
||||
grep -Eq 'var id *= *shell\.pluginRegistry\.resolveEnabledId\( *target *\)[^}]*return shell\.pluginRegistry\.installedPlugins\[id\] *\|\| *null' ||
|
||||
fail "replacement-bar clone authorization does not follow the enabled implementation"
|
||||
tr '\n\r\t' ' ' <<<"$bar_entry_shell" |
|
||||
grep -Eq 'shell\.pluginCloneMaySummon\( *currentManifest\( *\), *requestedId *\)' ||
|
||||
fail "built-in clones in replacement bars cannot summon their existing auxiliary UI"
|
||||
qml_matches "$shell_qml" 'shell\.pluginCloneMaySummon\( *currentManifest\( *\), *requestedId *\)' ||
|
||||
fail "built-in clones cannot summon their existing auxiliary UI"
|
||||
qml_matches "$shell_qml" '"omarchy\.media": *\["omarchy\.osd"\]' ||
|
||||
fail "media clones cannot summon their existing OSD target"
|
||||
qml_matches "$shell_qml" '"omarchy\.network": *\["omarchy\.speedtest", *"omarchy\.wifiqr"\]' ||
|
||||
fail "network clones cannot summon their existing auxiliary panels"
|
||||
pass "built-in service and widget clones retain narrow configuration and UI integration"
|
||||
|
||||
qml_matches "$shell_qml" 'shell\.serviceFor\( *shell\.pluginRegistry\.resolveEnabledId\( *id *\) *\)' ||
|
||||
fail "narrow first-party service proxies do not resolve enabled clones"
|
||||
qml_matches "$shell_qml" 'return serviceFor\( *shell\.pluginRegistry\.resolveEnabledId\( *pluginId *\) *\)' ||
|
||||
fail "trusted first-party service lookups do not resolve enabled clones"
|
||||
qml_matches "$shell_qml" 'allowOwnService *&& *shell\.pluginOwnsTarget\( *key, *requestedId *\)[^}]*return shell\.pluginServiceFor\( *key, *requestedId *\)' ||
|
||||
fail "cloned widgets cannot use a source id to reach their own service"
|
||||
pass "service facades resolve enabled clones without widening replacement-bar access"
|
||||
|
||||
require_compositor "plugin authentication boundary runtime test"
|
||||
|
||||
if ! command -v quickshell >/dev/null 2>&1; then
|
||||
pass "quickshell not installed; skipping plugin authentication boundary runtime test"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
require_command jq
|
||||
|
||||
TMPDIR=$(mktemp -d)
|
||||
result="$TMPDIR/result.json"
|
||||
log="$TMPDIR/quickshell.log"
|
||||
config_dir="$TMPDIR/plugin-auth-boundary"
|
||||
mkdir -p "$config_dir" "$TMPDIR/home"
|
||||
cp "$SHELL_TEST_DIR/fixtures/plugin-auth-boundary/"*.qml "$config_dir/"
|
||||
ln -s "$ROOT/shell/services" "$config_dir/services"
|
||||
|
||||
OMARCHY_QML_TEST_RESULT="$result" \
|
||||
HOME="$TMPDIR/home" \
|
||||
XDG_CONFIG_HOME="$TMPDIR/home/.config" \
|
||||
XDG_CACHE_HOME="$TMPDIR/home/.cache" \
|
||||
XDG_STATE_HOME="$TMPDIR/home/.local/state" \
|
||||
quickshell -p "$config_dir" --no-color >"$log" 2>&1 &
|
||||
QS_PID=$!
|
||||
|
||||
for _ in {1..80}; do
|
||||
[[ -s $result ]] && break
|
||||
if ! kill -0 "$QS_PID" 2>/dev/null; then
|
||||
sed -n '1,220p' "$log" >&2
|
||||
fail "plugin authentication boundary fixture exited before writing result"
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
[[ -s $result ]] || {
|
||||
sed -n '1,220p' "$log" >&2
|
||||
fail "plugin authentication boundary runtime test timed out"
|
||||
}
|
||||
|
||||
if ! jq -e '.ok == true' "$result" >/dev/null; then
|
||||
jq . "$result" >&2
|
||||
sed -n '1,220p' "$log" >&2
|
||||
fail "plugin authentication boundary runtime behavior"
|
||||
fi
|
||||
|
||||
pass "plugin authentication boundary runtime behavior"
|
||||
@@ -197,5 +197,18 @@ for (const [id, section] of Object.entries({
|
||||
}
|
||||
check(byId['omarchy.media']?.barWidget?.defaultSection === undefined, 'omarchy.media must use the center fallback')
|
||||
|
||||
for (const id of ['omarchy.lock', 'omarchy.idle', 'omarchy.polkit', 'omarchy.notifications', 'omarchy.media']) {
|
||||
check(byId[id]?.keepLoaded === true, `${id} must stay loaded across plugin reloads`)
|
||||
}
|
||||
|
||||
const shellSource = fs.readFileSync(path.join(root, 'shell/shell.qml'), 'utf8')
|
||||
const unloadMatch = shellSource.match(/function unloadPluginServices\(\) \{[\s\S]*?\n \}/)
|
||||
check(!!unloadMatch, 'unloadPluginServices is defined')
|
||||
check(!!unloadMatch && /serviceKeepLoaded/.test(unloadMatch[0]), 'unloadPluginServices honors keepLoaded')
|
||||
check(
|
||||
/function _syncServices\(\) \{[\s\S]*Drop services for plugins that have been disabled/.test(shellSource),
|
||||
'_syncServices still drops disabled or removed services'
|
||||
)
|
||||
|
||||
assert(errors.length === 0, 'plugin manifests match shell registry contract', errors.join('\n'))
|
||||
JS
|
||||
@@ -36,7 +36,10 @@ SH
|
||||
|
||||
chmod +x "$mock_bin"/*
|
||||
|
||||
export PATH="$mock_bin:$PATH"
|
||||
# $ROOT/bin after the mocks: Remove Preinstalls asks omarchy-install-hermes-cli
|
||||
# whether the wrapper is Omarchy's rather than matching the marker itself, and
|
||||
# that is the real command at runtime. The mocks still shadow what they name.
|
||||
export PATH="$mock_bin:$ROOT/bin:$PATH"
|
||||
export HOME="$test_home"
|
||||
export OMARCHY_TEST_PKG_LOG="$pkg_log"
|
||||
|
||||
@@ -89,3 +92,43 @@ pass "declining Remove Preinstalls changes nothing"
|
||||
"$ROOT/bin/omarchy-remove-preinstalls" >/dev/null
|
||||
[[ -f $marker ]] || fail "Remove Preinstalls records the opt-out"
|
||||
pass "Remove Preinstalls records the opt-out"
|
||||
|
||||
# Hermes' wrapper is only a preinstall when omarchy-install-hermes-cli wrote it.
|
||||
# The desktop app's command and an official install live at the same path and
|
||||
# are the user's, whether or not any package says so.
|
||||
hermes="$test_home/.local/bin/hermes"
|
||||
mkdir -p "$(dirname "$hermes")"
|
||||
|
||||
printf '%s\n' "#!/bin/bash" "# Written by omarchy-install-hermes-cli." >"$hermes"
|
||||
chmod +x "$hermes"
|
||||
"$ROOT/bin/omarchy-remove-preinstalls" >/dev/null
|
||||
[[ ! -e $hermes ]] || fail "Remove Preinstalls deletes the Omarchy Hermes wrapper"
|
||||
pass "Remove Preinstalls deletes the Omarchy Hermes wrapper"
|
||||
|
||||
printf '%s\n' "#!/bin/bash" "exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" >"$hermes"
|
||||
chmod +x "$hermes"
|
||||
"$ROOT/bin/omarchy-remove-preinstalls" >/dev/null
|
||||
[[ -x $hermes ]] || fail "Remove Preinstalls keeps the desktop app's Hermes command"
|
||||
pass "Remove Preinstalls keeps the desktop app's Hermes command"
|
||||
|
||||
official_body="#!/bin/bash
|
||||
unset PYTHONPATH
|
||||
unset PYTHONHOME
|
||||
exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\""
|
||||
printf '%s\n' "$official_body" >"$hermes"
|
||||
chmod +x "$hermes"
|
||||
"$ROOT/bin/omarchy-remove-preinstalls" >/dev/null
|
||||
[[ -x $hermes && $(cat "$hermes") == "$official_body" ]] || fail "Remove Preinstalls keeps an official Hermes install"
|
||||
pass "Remove Preinstalls keeps an official Hermes install"
|
||||
|
||||
printf '%s\n' "#!/bin/bash" "# Replaces the stub omarchy-install-hermes-cli used to write." >"$hermes"
|
||||
chmod +x "$hermes"
|
||||
"$ROOT/bin/omarchy-remove-preinstalls" >/dev/null
|
||||
[[ -x $hermes ]] || fail "Remove Preinstalls keeps a wrapper that merely mentions the installer"
|
||||
pass "Remove Preinstalls keeps a wrapper that merely mentions the installer"
|
||||
|
||||
rm -f "$hermes"
|
||||
ln -s "$test_home/nowhere/hermes" "$hermes"
|
||||
"$ROOT/bin/omarchy-remove-preinstalls" >/dev/null
|
||||
[[ -L $hermes ]] || fail "Remove Preinstalls keeps a foreign hermes link"
|
||||
pass "Remove Preinstalls keeps a foreign hermes link"
|
||||
@@ -8,7 +8,7 @@ test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
mock_bin="$test_tmp/bin"
|
||||
mkdir -p "$mock_bin" "$test_tmp/home"
|
||||
mkdir -p "$mock_bin" "$test_tmp/home" "$test_tmp/home/.hermes/profiles/james"
|
||||
|
||||
for command in xdg-user-dirs-update xdg-settings xdg-mime; do
|
||||
printf '#!/bin/bash\nexit 0\n' >"$mock_bin/$command"
|
||||
@@ -30,6 +30,14 @@ for skill in omarchy diagnose-crash; do
|
||||
link="$test_tmp/home/.gemini/config/skills/$skill"
|
||||
[[ -L $link && $(readlink "$link") == "$ROOT/default/agents/skills/$skill" ]] ||
|
||||
fail "omarchy-provision-user provisions the $skill skill for Antigravity"
|
||||
|
||||
link="$test_tmp/home/.hermes/skills/$skill"
|
||||
[[ -L $link && $(readlink "$link") == "$ROOT/default/agents/skills/$skill" ]] ||
|
||||
fail "omarchy-provision-user provisions the $skill skill for Hermes"
|
||||
|
||||
link="$test_tmp/home/.hermes/profiles/james/skills/$skill"
|
||||
[[ -L $link && $(readlink "$link") == "$ROOT/default/agents/skills/$skill" ]] ||
|
||||
fail "omarchy-provision-user provisions the $skill skill for a Hermes profile"
|
||||
done
|
||||
|
||||
pass "omarchy-provision-user provisions Antigravity skills"
|
||||
pass "omarchy-provision-user provisions Antigravity and Hermes skills"
|
||||
@@ -15,6 +15,22 @@ printf 'drop:%s\n' "$*" >>"$TEST_LOG"
|
||||
SCRIPT
|
||||
chmod +x "$tmp_dir/bin/omarchy-pkg-drop"
|
||||
|
||||
# omarchy-remove-ai-perplexity asks through gum whether the user's data goes
|
||||
# too. The stub answers "no" unless a test says otherwise and logs the call: a
|
||||
# real gum would hang the run, and one that answered "yes" on its own would be
|
||||
# the data loss the default-no exists to prevent. It logs to its own file
|
||||
# because the OpenClaw section below greps TEST_LOG to prove gum was never
|
||||
# reached, and the pty runs here call it on purpose.
|
||||
cat >"$tmp_dir/bin/gum" <<'SCRIPT'
|
||||
#!/bin/bash
|
||||
printf 'gum:%s\n' "$*" >>"$TEST_GUM_LOG"
|
||||
exit "${TEST_GUM_STATUS:-1}"
|
||||
SCRIPT
|
||||
chmod +x "$tmp_dir/bin/gum"
|
||||
|
||||
export TEST_GUM_LOG="$tmp_dir/gum-log"
|
||||
touch "$TEST_GUM_LOG"
|
||||
|
||||
export TEST_LOG="$tmp_dir/log"
|
||||
export PATH="$tmp_dir/bin:$PATH"
|
||||
|
||||
@@ -75,6 +91,88 @@ for kept in .grok .claude.json .npm .local/share/opencode; do
|
||||
done
|
||||
pass "T3 Code removal keeps the agent state it bootstrapped"
|
||||
|
||||
# Perplexity's other products (the CLI, Comet) share the perplexity-* prefix;
|
||||
# the desktop app owns only its rpc-server runtime and Electron caches. The
|
||||
# logins, vault, and flags are the user's and only go when a terminal said so.
|
||||
seed_perplexity() {
|
||||
fresh_home
|
||||
mkdir -p "$HOME/.config/Perplexity" "$HOME/.cache/Perplexity" \
|
||||
"$HOME/.cache/perplexity-rpc-server" "$HOME/.local/share/perplexity-rpc-server" \
|
||||
"$HOME/.local/state/perplexity" "$HOME/.cache/perplexity-personal-computer-poc"
|
||||
touch "$HOME/.config/perplexity-flags.conf"
|
||||
}
|
||||
|
||||
# </dev/null pins stdin off a terminal, so this run exercises the
|
||||
# non-interactive path no matter where the suite itself is running.
|
||||
seed_perplexity
|
||||
"$ROOT/bin/omarchy-remove-ai-perplexity" </dev/null >/dev/null
|
||||
|
||||
for gone in .cache/Perplexity .cache/perplexity-rpc-server .local/share/perplexity-rpc-server; do
|
||||
[[ ! -e $HOME/$gone ]] || fail "Perplexity removal deletes the app's runtime and caches" "$gone"
|
||||
done
|
||||
pass "Perplexity removal deletes the app's runtime and caches"
|
||||
|
||||
for kept in .config/Perplexity .local/state/perplexity .config/perplexity-flags.conf .cache/perplexity-personal-computer-poc; do
|
||||
[[ -e $HOME/$kept ]] || fail "Perplexity removal keeps the user's data and other products' caches" "$kept"
|
||||
done
|
||||
pass "Perplexity removal keeps the user's data and other products' caches"
|
||||
|
||||
grep -qx 'drop:perplexity' "$TEST_LOG" || fail "Perplexity removal drops the perplexity package"
|
||||
pass "Perplexity removal drops the perplexity package"
|
||||
|
||||
# Without a terminal there is nobody to ask, so gum must not even be reached:
|
||||
# one that answered "yes" on its own would be a data loss.
|
||||
! grep -q '^gum:' "$TEST_GUM_LOG" || fail "Perplexity removal keeps the user's data unasked when there is no terminal"
|
||||
pass "Perplexity removal keeps the user's data unasked when there is no terminal"
|
||||
|
||||
# script(1) puts the remover on a pty, the only way -t 0 answers true in a
|
||||
# test; the stubbed gum then supplies the answer.
|
||||
seed_perplexity
|
||||
script -qec "'$ROOT/bin/omarchy-remove-ai-perplexity'" /dev/null >/dev/null 2>&1
|
||||
|
||||
[[ -d $HOME/.config/Perplexity && -d $HOME/.local/state/perplexity && -f $HOME/.config/perplexity-flags.conf ]] ||
|
||||
fail "Perplexity removal keeps the user's data when the answer is no"
|
||||
pass "Perplexity removal keeps the user's data when the answer is no"
|
||||
|
||||
# The stub answers "no" on its own, so only the logged arguments prove the
|
||||
# real gum would not default to yes on a bare Enter.
|
||||
grep -q '^gum:confirm --default=false ' "$TEST_GUM_LOG" ||
|
||||
fail "Perplexity removal asks with the destructive answer defaulted off"
|
||||
pass "Perplexity removal asks with the destructive answer defaulted off"
|
||||
|
||||
# A partial install has no user data to measure; du failing must not abort
|
||||
# the removal between the package and the prompt.
|
||||
seed_perplexity
|
||||
rm -rf "$HOME/.config/Perplexity" "$HOME/.local/state/perplexity"
|
||||
script -qec "'$ROOT/bin/omarchy-remove-ai-perplexity'" /dev/null >/dev/null 2>&1 ||
|
||||
fail "Perplexity removal survives user-data directories that are already gone"
|
||||
pass "Perplexity removal survives user-data directories that are already gone"
|
||||
|
||||
# gum draws its prompt on stderr; with stderr redirected the question would be
|
||||
# invisible, so the remover must keep the data instead of blocking on it.
|
||||
seed_perplexity
|
||||
gum_calls_before=$(wc -l <"$TEST_GUM_LOG")
|
||||
script -qec "'$ROOT/bin/omarchy-remove-ai-perplexity' 2>/dev/null" /dev/null >/dev/null 2>&1 ||
|
||||
fail "Perplexity removal completes when stderr is not a terminal"
|
||||
[[ -d $HOME/.config/Perplexity ]] && (( $(wc -l <"$TEST_GUM_LOG") == gum_calls_before )) ||
|
||||
fail "Perplexity removal keeps the user's data unasked when stderr is not a terminal"
|
||||
pass "Perplexity removal keeps the user's data unasked when stderr is not a terminal"
|
||||
|
||||
seed_perplexity
|
||||
TEST_GUM_STATUS=0 script -qec "'$ROOT/bin/omarchy-remove-ai-perplexity'" /dev/null >/dev/null 2>&1
|
||||
|
||||
for gone in .config/Perplexity .local/state/perplexity .config/perplexity-flags.conf; do
|
||||
[[ ! -e $HOME/$gone ]] || fail "Perplexity removal deletes the user's data on an explicit yes" "$gone"
|
||||
done
|
||||
pass "Perplexity removal deletes the user's data on an explicit yes"
|
||||
|
||||
# Without HOME the rm -rf paths would degrade to /-rooted ones; -u makes that a
|
||||
# refusal instead.
|
||||
if env -u HOME "$ROOT/bin/omarchy-remove-ai-perplexity" </dev/null >/dev/null 2>&1; then
|
||||
fail "Perplexity removal refuses to run without HOME"
|
||||
fi
|
||||
pass "Perplexity removal refuses to run without HOME"
|
||||
|
||||
# ~/.grok belongs to the Grok CLI that omarchy-default-agent installs.
|
||||
fresh_home
|
||||
mkdir -p "$HOME/.config/Grok Bot" "$HOME/.grokbot" "$HOME/.grok"
|
||||
@@ -93,3 +191,161 @@ ollama_row=$(grep '^ "remove.ai.ollama":' "$ROOT/default/omarchy/omarchy-menu.j
|
||||
[[ $ollama_row == *'"when":"omarchy-pkg-present ollama"'* ]] ||
|
||||
fail "Ollama removal is offered only where the package is installed" "$ollama_row"
|
||||
pass "Ollama removal is offered only where the package is installed"
|
||||
|
||||
# OpenClaw's gateway unit and web app launcher are the app's own; the agent
|
||||
# state in ~/.openclaw is the user's. systemctl and openclaw are stubbed so the
|
||||
# sandbox never reaches the real user manager or a real gateway.
|
||||
cat >"$tmp_dir/bin/systemctl" <<'SCRIPT'
|
||||
#!/bin/bash
|
||||
printf 'systemctl:%s\n' "$*" >>"$TEST_LOG"
|
||||
SCRIPT
|
||||
chmod +x "$tmp_dir/bin/systemctl"
|
||||
|
||||
# Default openclaw stub: the packaged CLI predates `gateway uninstall`, so the
|
||||
# remover has to fall back to its manual systemd path.
|
||||
cat >"$tmp_dir/bin/openclaw" <<'SCRIPT'
|
||||
#!/bin/bash
|
||||
printf 'openclaw:%s\n' "$*" >>"$TEST_LOG"
|
||||
exit 1
|
||||
SCRIPT
|
||||
chmod +x "$tmp_dir/bin/openclaw"
|
||||
|
||||
fresh_openclaw_home() {
|
||||
fresh_home
|
||||
mkdir -p "$HOME/.config/systemd/user/default.target.wants" "$HOME/.openclaw" \
|
||||
"$HOME/.local/share/applications" "$HOME/.local/share/icons/hicolor/256x256/apps"
|
||||
touch "$HOME/.config/systemd/user/openclaw-gateway.service" \
|
||||
"$HOME/.config/systemd/user/openclaw-gateway.service.bak" \
|
||||
"$HOME/.config/systemd/user/openclaw-node.service" \
|
||||
"$HOME/.openclaw/openclaw.json" \
|
||||
"$HOME/.local/share/applications/OpenClaw.desktop" \
|
||||
"$HOME/.local/share/icons/hicolor/256x256/apps/openclaw.png"
|
||||
ln -s ../openclaw-gateway.service \
|
||||
"$HOME/.config/systemd/user/default.target.wants/openclaw-gateway.service"
|
||||
ln -s ../openclaw-node.service \
|
||||
"$HOME/.config/systemd/user/default.target.wants/openclaw-node.service"
|
||||
}
|
||||
|
||||
# gum would ask about ~/.openclaw; the tests never run on a terminal, so it
|
||||
# must not even be reached. Logging it proves that.
|
||||
cat >"$tmp_dir/bin/gum" <<'SCRIPT'
|
||||
#!/bin/bash
|
||||
printf 'gum:%s\n' "$*" >>"$TEST_LOG"
|
||||
exit 0
|
||||
SCRIPT
|
||||
chmod +x "$tmp_dir/bin/gum"
|
||||
|
||||
fresh_openclaw_home
|
||||
"$ROOT/bin/omarchy-remove-ai-openclaw" >/dev/null
|
||||
|
||||
for gone in .config/systemd/user/openclaw-gateway.service \
|
||||
.config/systemd/user/openclaw-gateway.service.bak \
|
||||
.config/systemd/user/default.target.wants/openclaw-gateway.service \
|
||||
.config/systemd/user/openclaw-node.service \
|
||||
.config/systemd/user/default.target.wants/openclaw-node.service \
|
||||
.local/share/applications/OpenClaw.desktop \
|
||||
.local/share/icons/hicolor/256x256/apps/openclaw.png; do
|
||||
[[ ! -e $HOME/$gone && ! -L $HOME/$gone ]] || fail "OpenClaw removal deletes the service and launcher it installed" "$gone"
|
||||
done
|
||||
pass "OpenClaw removal deletes the service and launcher it installed"
|
||||
|
||||
grep -q '^systemctl:--user disable --now openclaw-gateway.service$' "$TEST_LOG" ||
|
||||
fail "OpenClaw removal stops the gateway service"
|
||||
grep -q '^systemctl:--user disable --now openclaw-node.service$' "$TEST_LOG" ||
|
||||
fail "OpenClaw removal stops the gateway service" "node host service left running"
|
||||
for unit in openclaw-gateway.service openclaw-node.service; do
|
||||
grep -q "^systemctl:--user reset-failed $unit\$" "$TEST_LOG" ||
|
||||
fail "OpenClaw removal stops the gateway service" "$unit left in systemd's failed list"
|
||||
done
|
||||
pass "OpenClaw removal stops the gateway service"
|
||||
|
||||
# Without a terminal there is nobody to ask, so the state stays and gum is
|
||||
# never invoked (a gum that answered "yes" on its own would be a data loss).
|
||||
[[ -f $HOME/.openclaw/openclaw.json ]] || fail "OpenClaw removal keeps the user's agent state"
|
||||
! grep -q '^gum:' "$TEST_LOG" ||
|
||||
fail "OpenClaw removal keeps the user's agent state" "asked about ~/.openclaw without a terminal"
|
||||
pass "OpenClaw removal keeps the user's agent state"
|
||||
|
||||
# A CLI that knows `gateway uninstall` owns the teardown; the manual systemd
|
||||
# fallback must not run.
|
||||
cat >"$tmp_dir/bin/openclaw" <<'SCRIPT'
|
||||
#!/bin/bash
|
||||
printf 'openclaw:%s\n' "$*" >>"$TEST_LOG"
|
||||
rm -f "$HOME/.config/systemd/user/openclaw-$1.service" \
|
||||
"$HOME/.config/systemd/user/default.target.wants/openclaw-$1.service"
|
||||
SCRIPT
|
||||
chmod +x "$tmp_dir/bin/openclaw"
|
||||
|
||||
: >"$TEST_LOG"
|
||||
fresh_openclaw_home
|
||||
"$ROOT/bin/omarchy-remove-ai-openclaw" >/dev/null
|
||||
|
||||
grep -q '^openclaw:gateway uninstall$' "$TEST_LOG" ||
|
||||
fail "OpenClaw removal prefers upstream's own gateway teardown"
|
||||
grep -q '^openclaw:node uninstall$' "$TEST_LOG" ||
|
||||
fail "OpenClaw removal prefers upstream's own gateway teardown" "node host not handed to upstream"
|
||||
! grep -q '^systemctl:--user disable' "$TEST_LOG" ||
|
||||
fail "OpenClaw removal prefers upstream's own gateway teardown" "manual disable ran too"
|
||||
pass "OpenClaw removal prefers upstream's own gateway teardown"
|
||||
|
||||
# Without the unit file there is nothing of ours registered, so systemd stays untouched.
|
||||
systemctl_calls_before=$(grep -c '^systemctl:' "$TEST_LOG" || true)
|
||||
fresh_home
|
||||
"$ROOT/bin/omarchy-remove-ai-openclaw" >/dev/null
|
||||
systemctl_calls_after=$(grep -c '^systemctl:' "$TEST_LOG" || true)
|
||||
|
||||
[[ $systemctl_calls_before == "$systemctl_calls_after" ]] ||
|
||||
fail "OpenClaw removal leaves systemd alone when onboarding never ran"
|
||||
pass "OpenClaw removal leaves systemd alone when onboarding never ran"
|
||||
|
||||
# A gateway that will not stop aborts the removal before the package drop:
|
||||
# pacman would otherwise strand the live process on deleted code.
|
||||
cat >"$tmp_dir/bin/openclaw" <<'SCRIPT'
|
||||
#!/bin/bash
|
||||
printf 'openclaw:%s\n' "$*" >>"$TEST_LOG"
|
||||
exit 1
|
||||
SCRIPT
|
||||
chmod +x "$tmp_dir/bin/openclaw"
|
||||
cat >"$tmp_dir/bin/systemctl" <<'SCRIPT'
|
||||
#!/bin/bash
|
||||
printf 'systemctl:%s\n' "$*" >>"$TEST_LOG"
|
||||
[[ $2 == disable ]] && exit 1
|
||||
[[ $2 == is-active ]] && exit 0
|
||||
exit 0
|
||||
SCRIPT
|
||||
chmod +x "$tmp_dir/bin/systemctl"
|
||||
|
||||
drop_calls_before=$(grep -c '^drop:openclaw$' "$TEST_LOG" || true)
|
||||
fresh_openclaw_home
|
||||
rc=0
|
||||
"$ROOT/bin/omarchy-remove-ai-openclaw" >/dev/null 2>&1 || rc=$?
|
||||
drop_calls_after=$(grep -c '^drop:openclaw$' "$TEST_LOG" || true)
|
||||
|
||||
[[ $rc != 0 ]] || fail "OpenClaw removal aborts when the gateway cannot be stopped"
|
||||
[[ -f $HOME/.config/systemd/user/openclaw-gateway.service ]] ||
|
||||
fail "OpenClaw removal aborts when the gateway cannot be stopped" "unit file deleted"
|
||||
[[ $drop_calls_before == "$drop_calls_after" ]] ||
|
||||
fail "OpenClaw removal aborts when the gateway cannot be stopped" "package dropped anyway"
|
||||
pass "OpenClaw removal aborts when the gateway cannot be stopped"
|
||||
|
||||
# An unreachable user manager is not a stopped gateway: every probe failing
|
||||
# with no answer must still abort, not read as "confirmed inactive".
|
||||
cat >"$tmp_dir/bin/systemctl" <<'SCRIPT'
|
||||
#!/bin/bash
|
||||
printf 'systemctl:%s\n' "$*" >>"$TEST_LOG"
|
||||
echo "Failed to connect to user scope bus" >&2
|
||||
exit 1
|
||||
SCRIPT
|
||||
chmod +x "$tmp_dir/bin/systemctl"
|
||||
|
||||
: >"$TEST_LOG"
|
||||
fresh_openclaw_home
|
||||
rc=0
|
||||
"$ROOT/bin/omarchy-remove-ai-openclaw" >/dev/null 2>&1 || rc=$?
|
||||
|
||||
[[ $rc != 0 ]] || fail "OpenClaw removal aborts when systemd cannot be reached"
|
||||
[[ -f $HOME/.config/systemd/user/openclaw-gateway.service ]] ||
|
||||
fail "OpenClaw removal aborts when systemd cannot be reached" "unit file deleted"
|
||||
! grep -q '^drop:openclaw$' "$TEST_LOG" ||
|
||||
fail "OpenClaw removal aborts when systemd cannot be reached" "package dropped anyway"
|
||||
pass "OpenClaw removal aborts when systemd cannot be reached"
|
||||
@@ -74,6 +74,238 @@ Item {
|
||||
}
|
||||
QML
|
||||
|
||||
# A keepLoaded service must keep its instance (and in-memory state) across a
|
||||
# plugin rescan. The marker below can only survive if the object does.
|
||||
keep_service_id="acme.keep-service"
|
||||
keep_service_dir="$test_home/.config/omarchy/plugins/$keep_service_id"
|
||||
mkdir -p "$keep_service_dir"
|
||||
cat >"$keep_service_dir/manifest.json" <<JSON
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "$keep_service_id",
|
||||
"name": "Keep Service",
|
||||
"version": "1.0.0",
|
||||
"kinds": ["service"],
|
||||
"keepLoaded": true,
|
||||
"entryPoints": {"service": "Service.qml"}
|
||||
}
|
||||
JSON
|
||||
cat >"$keep_service_dir/Service.qml" <<'QML'
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
|
||||
Item {
|
||||
property string marker: ""
|
||||
|
||||
IpcHandler {
|
||||
target: "acme-keep"
|
||||
|
||||
function set(value: string): string {
|
||||
marker = value
|
||||
return "ok"
|
||||
}
|
||||
|
||||
function get(): string {
|
||||
return marker
|
||||
}
|
||||
}
|
||||
}
|
||||
QML
|
||||
|
||||
# A replacement bar must not receive a generic factory for another plugin's
|
||||
# live service, and its barConfig must be a detached snapshot on both initial
|
||||
# injection and later host-config updates.
|
||||
victim_service_id="acme.victim-service"
|
||||
victim_service_dir="$test_home/.config/omarchy/plugins/$victim_service_id"
|
||||
mkdir -p "$victim_service_dir"
|
||||
cat >"$victim_service_dir/manifest.json" <<JSON
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "$victim_service_id",
|
||||
"name": "Victim Service",
|
||||
"version": "1.0.0",
|
||||
"kinds": ["service"],
|
||||
"entryPoints": {"service": "Service.qml"}
|
||||
}
|
||||
JSON
|
||||
cat >"$victim_service_dir/Service.qml" <<'QML'
|
||||
import QtQuick
|
||||
|
||||
Item {
|
||||
property string privateValue: "victim-secret"
|
||||
}
|
||||
QML
|
||||
|
||||
# A clone of the built-in media service exercises both supported service paths:
|
||||
# its own widget receives the raw companion service under the trusted bar, while
|
||||
# a replacement bar receives only the narrow media proxy resolved to the clone.
|
||||
media_clone_id="acme.media-clone"
|
||||
media_clone_dir="$test_home/.config/omarchy/plugins/$media_clone_id"
|
||||
mkdir -p "$media_clone_dir"
|
||||
cat >"$media_clone_dir/manifest.json" <<JSON
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "$media_clone_id",
|
||||
"name": "Media Clone",
|
||||
"version": "1.0.0",
|
||||
"kinds": ["service", "bar-widget"],
|
||||
"entryPoints": {"service": "Service.qml", "barWidget": "BarWidget.qml"},
|
||||
"barWidget": {"defaultSection": "center"},
|
||||
"omarchy": {"clonedFrom": "omarchy.media"}
|
||||
}
|
||||
JSON
|
||||
cat >"$media_clone_dir/Service.qml" <<'QML'
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
|
||||
Item {
|
||||
id: root
|
||||
property string marker: "clone-service"
|
||||
property bool enabled: true
|
||||
property var activePlayer: null
|
||||
property var sourcePlayers: []
|
||||
property var shell: null
|
||||
|
||||
function runAction(action, showFeedback, targetKey) {}
|
||||
function playerKey(player) { return "" }
|
||||
function selectPlayer(playerKey) {}
|
||||
|
||||
IpcHandler {
|
||||
target: "acme-media-clone-service"
|
||||
function ping(): string { return marker }
|
||||
function summonOsd(): string {
|
||||
return root.shell && root.shell.summon("omarchy.osd", "{}") ? "true" : "false"
|
||||
}
|
||||
}
|
||||
}
|
||||
QML
|
||||
cat >"$media_clone_dir/BarWidget.qml" <<'QML'
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
|
||||
Item {
|
||||
id: root
|
||||
property var bar: null
|
||||
|
||||
IpcHandler {
|
||||
target: "acme-media-clone-widget"
|
||||
function probeOwnService(): string {
|
||||
var service = root.bar && root.bar.shell
|
||||
? root.bar.shell.firstPartyServiceFor("omarchy.media") : null
|
||||
return JSON.stringify({
|
||||
reachable: !!service,
|
||||
marker: service ? String(service.marker || "") : ""
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
QML
|
||||
|
||||
review_bar_id="acme.review-bar"
|
||||
review_bar_dir="$test_home/.config/omarchy/plugins/$review_bar_id"
|
||||
mkdir -p "$review_bar_dir"
|
||||
cat >"$review_bar_dir/manifest.json" <<JSON
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "$review_bar_id",
|
||||
"name": "Review Bar",
|
||||
"version": "1.0.0",
|
||||
"kinds": ["bar", "service"],
|
||||
"keepLoaded": true,
|
||||
"entryPoints": {"bar": "Bar.qml", "service": "Service.qml"}
|
||||
}
|
||||
JSON
|
||||
cat >"$review_bar_dir/Bar.qml" <<'QML'
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var shell: null
|
||||
property var barConfig: ({})
|
||||
|
||||
IpcHandler {
|
||||
target: "acme-review-bar"
|
||||
|
||||
function probeVictim(): string {
|
||||
var genericFactory = root.shell
|
||||
&& typeof root.shell.pluginShellForId === "function"
|
||||
var entryFacade = root.shell
|
||||
&& typeof root.shell.pluginShellForBarEntry === "function"
|
||||
? root.shell.pluginShellForBarEntry("probe", "acme.victim-service") : null
|
||||
var victim = entryFacade && typeof entryFacade.serviceFor === "function"
|
||||
? entryFacade.serviceFor("acme.victim-service") : null
|
||||
return JSON.stringify({
|
||||
genericFactory: !!genericFactory,
|
||||
entryFacade: !!entryFacade,
|
||||
victimServiceReachable: !!victim
|
||||
})
|
||||
}
|
||||
|
||||
function snapshot(): string {
|
||||
return JSON.stringify(root.barConfig || {})
|
||||
}
|
||||
|
||||
function probeMediaProxy(): string {
|
||||
var service = root.shell
|
||||
? root.shell.firstPartyServiceFor("omarchy.media") : null
|
||||
return JSON.stringify({ reachable: !!service, enabled: service ? service.enabled === true : false })
|
||||
}
|
||||
|
||||
function probeMediaWidgetSummon(): string {
|
||||
var entryFacade = root.shell
|
||||
&& typeof root.shell.pluginShellForBarEntry === "function"
|
||||
? root.shell.pluginShellForBarEntry("probe-media", "acme.media-clone") : null
|
||||
return JSON.stringify({
|
||||
entryFacade: !!entryFacade,
|
||||
osdSummoned: entryFacade ? entryFacade.summon("omarchy.osd", "{}") : false,
|
||||
foreignSummoned: entryFacade ? entryFacade.summon("omarchy.lock", "{}") : false
|
||||
})
|
||||
}
|
||||
|
||||
function mutateSnapshot(): string {
|
||||
if (root.barConfig && root.barConfig.layout
|
||||
&& root.barConfig.layout.left && root.barConfig.layout.left.length > 0)
|
||||
root.barConfig.layout.left[0].id = "tampered.by.review-bar"
|
||||
return snapshot()
|
||||
}
|
||||
}
|
||||
}
|
||||
QML
|
||||
cat >"$review_bar_dir/Service.qml" <<'QML'
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
|
||||
Item {
|
||||
id: root
|
||||
property var shell: null
|
||||
property var retainedShell: null
|
||||
|
||||
onShellChanged: if (!retainedShell && shell) retainedShell = shell
|
||||
|
||||
function mutationAllowed(candidate) {
|
||||
if (!candidate) return false
|
||||
try {
|
||||
return typeof candidate.mutateShellConfig === "function"
|
||||
&& candidate.mutateShellConfig(function(config) {}) === true
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "acme-review-capability"
|
||||
function probe(): string {
|
||||
return JSON.stringify({
|
||||
currentAllowed: root.mutationAllowed(root.shell),
|
||||
retainedAllowed: root.mutationAllowed(root.retainedShell)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
QML
|
||||
|
||||
cat >"$stub_bin/omarchy-update-available" <<'SH'
|
||||
#!/bin/bash
|
||||
echo "Omarchy update available (test)"
|
||||
@@ -188,6 +420,15 @@ pass "shell IPC summon and hide contract works"
|
||||
jq -e '.hasPlayer | type == "boolean"' <<<"$(shell_ipc media status)" >/dev/null || fail_with_log "media IPC returns status JSON"
|
||||
jq -e '.enabled | type == "boolean"' <<<"$(shell_ipc idle status)" >/dev/null || fail_with_log "idle IPC returns status JSON"
|
||||
jq -e '.locked | type == "boolean"' <<<"$(shell_ipc lock status)" >/dev/null || fail_with_log "lock IPC returns status JSON"
|
||||
[[ $(shell_ipc shell setPluginEnabled "$keep_service_id" true) == "ok" ]] ||
|
||||
fail_with_log "keepLoaded fixture service could not be enabled"
|
||||
keep_marker_set=""
|
||||
for _ in {1..80}; do
|
||||
keep_marker_set=$(shell_ipc acme-keep set "survived" 2>/dev/null || true)
|
||||
[[ $keep_marker_set == "ok" ]] && break
|
||||
sleep 0.1
|
||||
done
|
||||
[[ $keep_marker_set == "ok" ]] || fail_with_log "keepLoaded fixture service IPC responds"
|
||||
[[ $(shell_ipc image-selector ping) == "ok" ]] || fail_with_log "image selector IPC responds"
|
||||
[[ $(shell_ipc osd ping) == "ok" ]] || fail_with_log "OSD IPC responds"
|
||||
[[ $(shell_ipc osd show '{"message":"Runtime smoke","duration":0}') == "ok" ]] || fail_with_log "OSD IPC opens"
|
||||
@@ -215,6 +456,31 @@ shell_ipc_quiet image-selector cancel "$selector_done_file" >/dev/null
|
||||
rm -f "$selector_selection_file" "$selector_done_file"
|
||||
pass "image selector IPC survives plugin rescan"
|
||||
|
||||
lock_status_after=$(shell_ipc lock status)
|
||||
jq -e '.locked | type == "boolean"' <<<"$lock_status_after" >/dev/null || fail_with_log "lock IPC survives plugin rescan"
|
||||
lock_event_after=$(jq -r '.lastEvent // empty' <<<"$lock_status_after")
|
||||
[[ $lock_event_after != lock-stranded* ]] ||
|
||||
fail_with_log "plugin rescan does not strand the session lock ($lock_event_after)"
|
||||
# A recreated instance would answer with a fresh, empty marker.
|
||||
[[ $(shell_ipc acme-keep get) == "survived" ]] ||
|
||||
fail_with_log "plugin rescan keeps the keepLoaded service instance mounted"
|
||||
pass "keepLoaded service instance survives plugin rescan"
|
||||
|
||||
# Dropping the service entry point from the manifest must drop the kept
|
||||
# instance instead of leaving a zombie behind.
|
||||
jq 'del(.keepLoaded) | .kinds = ["overlay"] | .entryPoints = {"overlay": "Service.qml"}' \
|
||||
"$keep_service_dir/manifest.json" >"$keep_service_dir/manifest.json.tmp"
|
||||
mv "$keep_service_dir/manifest.json.tmp" "$keep_service_dir/manifest.json"
|
||||
keep_gone=""
|
||||
for _ in {1..80}; do
|
||||
keep_gone=$(shell_ipc acme-keep get 2>/dev/null || true)
|
||||
[[ $keep_gone != "survived" ]] && break
|
||||
sleep 0.1
|
||||
done
|
||||
[[ $keep_gone != "survived" ]] ||
|
||||
fail_with_log "kept service is dropped when its plugin stops declaring a service"
|
||||
pass "kept service is dropped when its plugin stops declaring a service"
|
||||
|
||||
shell_ipc_quiet omarchy.system-update refresh >/dev/null 2>&1 || true
|
||||
sleep 0.8
|
||||
|
||||
@@ -362,3 +628,122 @@ jq -e 'all(.bar.layout.right[]; (.id // .) != "omarchy.keyboard-layout")' \
|
||||
<<<"$(shell_ipc shell listShellConfig)" >/dev/null ||
|
||||
fail_with_log "bar put added a second copy of a widget already on the bar"
|
||||
pass "bar put leaves a widget already on the bar alone"
|
||||
|
||||
# Run the replacement-bar probes last: switching bar loaders can transiently
|
||||
# leave bar-aware panels without a visual host, which should not add noise to
|
||||
# the default-bar assertions above.
|
||||
[[ $(shell_ipc shell setPluginEnabled "$media_clone_id" true) == "ok" ]] ||
|
||||
fail_with_log "media clone fixture could not be enabled"
|
||||
clone_widget_probe=""
|
||||
for _ in {1..80}; do
|
||||
clone_widget_probe=$(shell_ipc acme-media-clone-widget probeOwnService 2>/dev/null || true)
|
||||
if jq -e '.reachable == true and .marker == "clone-service"' \
|
||||
<<<"$clone_widget_probe" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.reachable == true and .marker == "clone-service"' \
|
||||
<<<"$clone_widget_probe" >/dev/null || {
|
||||
printf 'Clone own-service probe: %s\n' "$clone_widget_probe" >&2
|
||||
fail_with_log "a cloned widget resolves its source id to its own companion service"
|
||||
}
|
||||
pass "trusted bar gives a cloned widget its own companion service"
|
||||
|
||||
[[ $(shell_ipc acme-media-clone-service summonOsd) == "true" ]] ||
|
||||
fail_with_log "a cloned media service cannot summon its existing OSD target"
|
||||
pass "a cloned built-in service retains its auxiliary UI integration"
|
||||
|
||||
[[ $(shell_ipc shell setPluginEnabled "$victim_service_id" true) == "ok" ]] ||
|
||||
fail_with_log "victim service fixture could not be enabled"
|
||||
[[ $(shell_ipc shell enablePlugin "$review_bar_id" '{}') == "ok" ]] ||
|
||||
fail_with_log "replacement-bar fixture could not be enabled"
|
||||
|
||||
review_probe=""
|
||||
for _ in {1..80}; do
|
||||
review_probe=$(shell_ipc acme-review-bar probeVictim 2>/dev/null || true)
|
||||
if jq -e '.genericFactory == false and .entryFacade == false and .victimServiceReachable == false' \
|
||||
<<<"$review_probe" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
if ! kill -0 "$QS_PID" 2>/dev/null; then
|
||||
fail_with_log "test shell exited while loading the replacement-bar fixture"
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.genericFactory == false and .entryFacade == false and .victimServiceReachable == false' \
|
||||
<<<"$review_probe" >/dev/null || {
|
||||
printf 'Replacement-bar service probe: %s\n' "$review_probe" >&2
|
||||
fail_with_log "replacement bar cannot recover another plugin's live service"
|
||||
}
|
||||
|
||||
media_proxy_probe=$(shell_ipc acme-review-bar probeMediaProxy)
|
||||
jq -e '.reachable == true and .enabled == true' <<<"$media_proxy_probe" >/dev/null || {
|
||||
printf 'Replacement-bar media proxy probe: %s\n' "$media_proxy_probe" >&2
|
||||
fail_with_log "replacement-bar service proxies resolve enabled clones"
|
||||
}
|
||||
|
||||
media_summon_probe=$(shell_ipc acme-review-bar probeMediaWidgetSummon)
|
||||
jq -e '.entryFacade == true and .osdSummoned == true and .foreignSummoned == false' \
|
||||
<<<"$media_summon_probe" >/dev/null || {
|
||||
printf 'Replacement-bar media summon probe: %s\n' "$media_summon_probe" >&2
|
||||
fail_with_log "replacement-bar clone facades retain only their auxiliary UI integration"
|
||||
}
|
||||
|
||||
bar_config_before=$(shell_ipc shell listShellConfig | jq -c '.bar')
|
||||
shell_ipc acme-review-bar mutateSnapshot >/dev/null
|
||||
bar_config_after=$(shell_ipc shell listShellConfig | jq -c '.bar')
|
||||
[[ $bar_config_after == "$bar_config_before" ]] ||
|
||||
fail_with_log "replacement bar mutated the initially injected host configuration"
|
||||
|
||||
[[ $(shell_ipc shell setBarWidget omarchy.clock format '"HH:mm:ss"' '{}') == "ok" ]] ||
|
||||
fail_with_log "host bar configuration could not be updated for snapshot testing"
|
||||
updated_snapshot=""
|
||||
for _ in {1..80}; do
|
||||
updated_snapshot=$(shell_ipc acme-review-bar snapshot 2>/dev/null || true)
|
||||
if jq -e 'any(.layout.center[]; (.id // .) == "omarchy.clock" and .format == "HH:mm:ss")' \
|
||||
<<<"$updated_snapshot" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e 'any(.layout.center[]; (.id // .) == "omarchy.clock" and .format == "HH:mm:ss")' \
|
||||
<<<"$updated_snapshot" >/dev/null ||
|
||||
fail_with_log "replacement bar did not receive the refreshed configuration snapshot"
|
||||
bar_config_before=$(shell_ipc shell listShellConfig | jq -c '.bar')
|
||||
shell_ipc acme-review-bar mutateSnapshot >/dev/null
|
||||
bar_config_after=$(shell_ipc shell listShellConfig | jq -c '.bar')
|
||||
[[ $bar_config_after == "$bar_config_before" ]] ||
|
||||
fail_with_log "replacement bar mutated a refreshed host configuration"
|
||||
|
||||
pass "replacement-bar service and configuration boundaries hold at runtime"
|
||||
|
||||
capability_before=$(shell_ipc acme-review-capability probe)
|
||||
jq -e '.currentAllowed == true and .retainedAllowed == true' \
|
||||
<<<"$capability_before" >/dev/null ||
|
||||
fail_with_log "bar service fixture did not initially receive bar capabilities"
|
||||
|
||||
# Keep the same enabled plugin ID and service instance while dropping the bar
|
||||
# kind. Both the currently injected facade and a reference retained by the
|
||||
# plugin must lose the old configuration capability after the manifest rescan.
|
||||
jq '.kinds = ["service"] | .entryPoints = {"service": "Service.qml"}' \
|
||||
"$review_bar_dir/manifest.json" >"$review_bar_dir/manifest.json.tmp"
|
||||
mv "$review_bar_dir/manifest.json.tmp" "$review_bar_dir/manifest.json"
|
||||
capability_after=""
|
||||
for _ in {1..80}; do
|
||||
capability_after=$(shell_ipc acme-review-capability probe 2>/dev/null || true)
|
||||
if jq -e '.currentAllowed == false and .retainedAllowed == false' \
|
||||
<<<"$capability_after" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
if ! kill -0 "$QS_PID" 2>/dev/null; then
|
||||
fail_with_log "test shell exited while revoking changed manifest capabilities"
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.currentAllowed == false and .retainedAllowed == false' \
|
||||
<<<"$capability_after" >/dev/null || {
|
||||
printf 'Capability revocation probe: %s\n' "$capability_after" >&2
|
||||
fail_with_log "cached plugin facades revoke capabilities removed from the manifest"
|
||||
}
|
||||
pass "manifest reload revokes cached facade capabilities"
|
||||
@@ -1,162 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
stub_bin="$test_tmp/bin"
|
||||
mkdir -p "$stub_bin"
|
||||
|
||||
cat >"$stub_bin/systemctl" <<'STUB'
|
||||
#!/bin/bash
|
||||
|
||||
printf '%s\n' "$*" >>"$SYSTEMCTL_CALLS"
|
||||
|
||||
case "$*" in
|
||||
'--user show-environment')
|
||||
[[ ${MANAGER_AVAILABLE:-true} == "true" ]]
|
||||
;;
|
||||
'--user daemon-reload')
|
||||
if [[ ${FAIL_SYSTEMCTL_ACTION:-} == "daemon-reload" ]]; then
|
||||
echo "reload failed" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
'--user show --property=ActiveState --value graphical-session.target')
|
||||
if [[ ${FAIL_SYSTEMCTL_ACTION:-} == "show-graphical" ]]; then
|
||||
echo "state lookup failed" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "${GRAPHICAL_STATE:-inactive}"
|
||||
;;
|
||||
'--user show --property=ActiveState --value omarchy-sleep-lock.service')
|
||||
printf '%s\n' "${SLEEP_LOCK_STATE:-inactive}"
|
||||
;;
|
||||
'--user restart omarchy-sleep-lock.service')
|
||||
if [[ ${FAIL_SYSTEMCTL_ACTION:-} == "restart" ]]; then
|
||||
echo "restart failed" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
'--user stop omarchy-sleep-lock.service')
|
||||
if [[ ${FAIL_SYSTEMCTL_ACTION:-} == "stop" ]]; then
|
||||
echo "stop failed" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
'--user reset-failed omarchy-sleep-lock.service') ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
STUB
|
||||
chmod +x "$stub_bin/systemctl"
|
||||
|
||||
migration="$ROOT/migrations/1785608166.sh"
|
||||
|
||||
run_migration() {
|
||||
local home="$1" calls="$2"
|
||||
shift 2
|
||||
|
||||
mkdir -p "$home/run"
|
||||
HOME="$home" XDG_CONFIG_HOME="$home/.config" XDG_RUNTIME_DIR="$home/run" \
|
||||
SYSTEMCTL_CALLS="$calls" PATH="$stub_bin:$PATH" \
|
||||
"$@" bash -euo pipefail "$migration"
|
||||
}
|
||||
|
||||
active_home="$test_tmp/active-home"
|
||||
active_calls="$test_tmp/active-calls"
|
||||
mkdir -p "$active_home/.config/systemd/user"
|
||||
printf '%s\n' '[Service]' 'ExecStart=/usr/bin/omarchy-system-sleep-monitor' \
|
||||
>"$active_home/.config/systemd/user/omarchy-sleep-lock.service"
|
||||
|
||||
run_migration "$active_home" "$active_calls" \
|
||||
env GRAPHICAL_STATE=active SLEEP_LOCK_STATE=active >/dev/null
|
||||
|
||||
dropin="$active_home/.config/systemd/user/omarchy-sleep-lock.service.d/90-omarchy-session-environment.conf"
|
||||
grep -Fx 'After=dbus.socket wayland-session-waitenv.service' "$dropin" >/dev/null ||
|
||||
fail "sleep lock migration orders a retained unit after the session environment import"
|
||||
grep -Fx 'PartOf=graphical-session.target' "$dropin" >/dev/null ||
|
||||
fail "sleep lock migration ties a retained unit to the graphical session lifecycle"
|
||||
grep -Fx 'ConditionEnvironment=OMARCHY_PATH' "$dropin" >/dev/null ||
|
||||
fail "sleep lock migration gates a retained unit on the Omarchy path"
|
||||
grep -Fx 'ConditionEnvironment=WAYLAND_DISPLAY' "$dropin" >/dev/null ||
|
||||
fail "sleep lock migration gates a retained unit on the Wayland display"
|
||||
pass "sleep lock migration repairs retained full-unit overrides with a drop-in"
|
||||
|
||||
grep -Fx -- '--user daemon-reload' "$active_calls" >/dev/null ||
|
||||
fail "sleep lock migration reloads the live user manager"
|
||||
grep -Fx -- '--user reset-failed omarchy-sleep-lock.service' "$active_calls" >/dev/null ||
|
||||
fail "sleep lock migration cannot recover a start-limited monitor"
|
||||
grep -Fx -- '--user restart omarchy-sleep-lock.service' "$active_calls" >/dev/null ||
|
||||
fail "sleep lock migration leaves an active monitor with its inherited environment"
|
||||
grep -Fx -- '--user stop omarchy-sleep-lock.service' "$active_calls" >/dev/null &&
|
||||
fail "sleep lock migration stops the monitor inside an active graphical session"
|
||||
pass "sleep lock migration replaces the monitor inside an active graphical session"
|
||||
|
||||
inactive_home="$test_tmp/inactive-home"
|
||||
inactive_calls="$test_tmp/inactive-calls"
|
||||
mkdir -p "$inactive_home"
|
||||
|
||||
run_migration "$inactive_home" "$inactive_calls" \
|
||||
env GRAPHICAL_STATE=inactive >/dev/null
|
||||
|
||||
grep -Fx -- '--user stop omarchy-sleep-lock.service' "$inactive_calls" >/dev/null ||
|
||||
fail "sleep lock migration leaves a stale monitor running after logout"
|
||||
grep -Fx -- '--user restart omarchy-sleep-lock.service' "$inactive_calls" >/dev/null &&
|
||||
fail "sleep lock migration starts the monitor outside a graphical session"
|
||||
pass "sleep lock migration stops a monitor left behind after logout"
|
||||
|
||||
failed_calls="$test_tmp/failed-calls"
|
||||
if run_migration "$test_tmp/failed-home" "$failed_calls" \
|
||||
env GRAPHICAL_STATE=active SLEEP_LOCK_STATE=active FAIL_SYSTEMCTL_ACTION=restart \
|
||||
>"$test_tmp/failed-output" 2>&1; then
|
||||
fail "sleep lock migration marks a failed active-session repair complete"
|
||||
fi
|
||||
grep -F 'will be retried by omarchy-migrate' "$test_tmp/failed-output" >/dev/null ||
|
||||
fail "sleep lock migration does not explain that a failed repair remains pending"
|
||||
pass "sleep lock migration keeps an active-session repair failure retryable"
|
||||
|
||||
reload_failed_calls="$test_tmp/reload-failed-calls"
|
||||
if run_migration "$test_tmp/reload-failed-home" "$reload_failed_calls" \
|
||||
env FAIL_SYSTEMCTL_ACTION=daemon-reload >"$test_tmp/reload-failed-output" 2>&1; then
|
||||
fail "sleep lock migration ignores a failed user-manager reload"
|
||||
fi
|
||||
grep -F 'Could not reload the user service manager' "$test_tmp/reload-failed-output" >/dev/null ||
|
||||
fail "sleep lock migration does not report a failed user-manager reload"
|
||||
pass "sleep lock migration keeps a failed user-manager reload retryable"
|
||||
|
||||
stop_failed_calls="$test_tmp/stop-failed-calls"
|
||||
if run_migration "$test_tmp/stop-failed-home" "$stop_failed_calls" \
|
||||
env GRAPHICAL_STATE=inactive FAIL_SYSTEMCTL_ACTION=stop \
|
||||
>"$test_tmp/stop-failed-output" 2>&1; then
|
||||
fail "sleep lock migration ignores a stale monitor that could not be stopped"
|
||||
fi
|
||||
grep -F 'Could not stop stale omarchy-sleep-lock.service' "$test_tmp/stop-failed-output" >/dev/null ||
|
||||
fail "sleep lock migration does not report a stale monitor stop failure"
|
||||
pass "sleep lock migration keeps a stale-monitor stop failure retryable"
|
||||
|
||||
state_failed_calls="$test_tmp/state-failed-calls"
|
||||
if run_migration "$test_tmp/state-failed-home" "$state_failed_calls" \
|
||||
env FAIL_SYSTEMCTL_ACTION=show-graphical >"$test_tmp/state-failed-output" 2>&1; then
|
||||
fail "sleep lock migration ignores a failed session-state inspection"
|
||||
fi
|
||||
grep -F 'Could not inspect graphical-session.target' "$test_tmp/state-failed-output" >/dev/null ||
|
||||
fail "sleep lock migration does not report a failed session-state inspection"
|
||||
pass "sleep lock migration keeps an indeterminate session repair retryable"
|
||||
|
||||
deferred_home="$test_tmp/deferred-home"
|
||||
deferred_calls="$test_tmp/deferred-calls"
|
||||
mkdir -p "$deferred_home/.config/systemd/user"
|
||||
touch "$deferred_home/.config/systemd/user/omarchy-sleep-lock.service"
|
||||
|
||||
run_migration "$deferred_home" "$deferred_calls" \
|
||||
env MANAGER_AVAILABLE=false >/dev/null
|
||||
|
||||
[[ -f $deferred_home/.config/systemd/user/omarchy-sleep-lock.service.d/90-omarchy-session-environment.conf ]] ||
|
||||
fail "sleep lock migration does not persist the repair without a live user manager"
|
||||
deferred_call_count=$(wc -l <"$deferred_calls")
|
||||
(( deferred_call_count == 1 )) ||
|
||||
fail "sleep lock migration tries to mutate a user manager that is not running"
|
||||
pass "sleep lock migration defers safely when no user manager is running"
|
||||
@@ -0,0 +1,705 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(dirname "$0")/base-test.sh"
|
||||
|
||||
migration="$ROOT/migrations/1788662350.sh"
|
||||
test_tmp=$(mktemp -d -p /tmp)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
mock_omarchy="$test_tmp/omarchy"
|
||||
sleep_dir="$test_tmp/system-sleep"
|
||||
systemd_dir="$test_tmp/systemd"
|
||||
drop_in="$systemd_dir/supergfxd.service.d/delay-start.conf"
|
||||
quarantine="$test_tmp/quarantine"
|
||||
reload_needed_marker="$test_tmp/reload-needed"
|
||||
migration_copy="$test_tmp/migration.sh"
|
||||
stub_bin="$test_tmp/bin"
|
||||
calls="$test_tmp/calls"
|
||||
|
||||
mkdir -p "$mock_omarchy/default/systemd/system-sleep" \
|
||||
"$mock_omarchy/default/systemd/system/supergfxd.service.d" \
|
||||
"$sleep_dir" "${drop_in%/*}" "$stub_bin"
|
||||
cp "$ROOT/default/systemd/system-sleep/keyboard-backlight" \
|
||||
"$mock_omarchy/default/systemd/system-sleep/keyboard-backlight"
|
||||
cp "$ROOT/default/systemd/system-sleep/force-igpu" \
|
||||
"$mock_omarchy/default/systemd/system-sleep/force-igpu"
|
||||
cp "$ROOT/default/systemd/system/supergfxd.service.d/delay-start.conf" \
|
||||
"$mock_omarchy/default/systemd/system/supergfxd.service.d/delay-start.conf"
|
||||
|
||||
[[ $(grep -Fxc 'system_sleep_dir=/usr/lib/systemd/system-sleep' "$migration") == 1 ]] ||
|
||||
fail "migration fixes one literal system-sleep directory"
|
||||
[[ $(grep -Fxc 'supergfxd_drop_in=/etc/systemd/system/supergfxd.service.d/delay-start.conf' "$migration") == 1 ]] ||
|
||||
fail "migration fixes one literal supergfxd drop-in"
|
||||
|
||||
sed \
|
||||
-e "s|system_sleep_dir=/usr/lib/systemd/system-sleep|system_sleep_dir=$sleep_dir|" \
|
||||
-e "s|supergfxd_drop_in=/etc/systemd/system/supergfxd.service.d/delay-start.conf|supergfxd_drop_in=$drop_in|" \
|
||||
-e "s|quarantine_root=/var/lib/omarchy/migrations/1788662350-system-sleep|quarantine_root=$quarantine|" \
|
||||
-e "s|/var/lib/omarchy/migrations/1788662350-systemd-reload-needed|$reload_needed_marker|" \
|
||||
-e "s|/usr/bin/stat|$stub_bin/stat|g" \
|
||||
-e "s|/usr/bin/readlink|$stub_bin/readlink|g" \
|
||||
"$migration" >"$migration_copy"
|
||||
|
||||
cat >"$stub_bin/stat" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
path=${!#}
|
||||
if [[ :${INACCESSIBLE_AS_USER:-}: == *":$path:"* && ${FAKE_SUDO:-0} == 0 ]]; then
|
||||
exit 13
|
||||
fi
|
||||
|
||||
actual_file_mode=$(/usr/bin/stat -c '%f' -- "$path") || exit 1
|
||||
actual_mode=$(/usr/bin/stat -c '%a' -- "$path") || exit 1
|
||||
|
||||
if [[ :${FAKE_ROOT_DIRS:-}: == *":$path:"* ]]; then
|
||||
uid=0
|
||||
gid=0
|
||||
mode=$(printf '%o' "$((8#$actual_mode & ~8#022))")
|
||||
elif [[ :${FAKE_ROOT_FILES:-}: == *":$path:"* ]]; then
|
||||
uid=0
|
||||
gid=${FAKE_ROOT_GID:-0}
|
||||
mode=${FAKE_ROOT_MODE:-$actual_mode}
|
||||
else
|
||||
exec /usr/bin/stat "$@"
|
||||
fi
|
||||
|
||||
file_type=$((16#$actual_file_mode & 16#f000))
|
||||
file_mode=$(printf '%x' "$((file_type | 8#$mode))")
|
||||
|
||||
case "$*" in
|
||||
*"%f %u %g %a"*) printf '%s %s %s %s\n' "$file_mode" "$uid" "$gid" "$mode" ;;
|
||||
*"%u %g %a"*) printf '%s %s %s\n' "$uid" "$gid" "$mode" ;;
|
||||
*"%a"*) printf '%s\n' "$mode" ;;
|
||||
*) exec /usr/bin/stat "$@" ;;
|
||||
esac
|
||||
SH
|
||||
|
||||
cat >"$stub_bin/readlink" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
path=${!#}
|
||||
if [[ :${INACCESSIBLE_AS_USER:-}: == *":$path:"* && ${FAKE_SUDO:-0} == 0 ]]; then
|
||||
exit 13
|
||||
fi
|
||||
|
||||
exec /usr/bin/readlink "$@"
|
||||
SH
|
||||
chmod +x "$stub_bin/stat" "$stub_bin/readlink"
|
||||
|
||||
cat >"$stub_bin/sudo" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
printf 'sudo' >>"$CALLS"
|
||||
printf '\t%s' "$@" >>"$CALLS"
|
||||
printf '\n' >>"$CALLS"
|
||||
|
||||
case "$1" in
|
||||
*/stat | */readlink)
|
||||
FAKE_SUDO=1 exec "$@"
|
||||
;;
|
||||
/usr/bin/test)
|
||||
shift
|
||||
if [[ $1 == "-x" && :${FAKE_ROOT_DIRS:-}: == *":$2:"* ]]; then
|
||||
exit 0
|
||||
else
|
||||
exec /usr/bin/test "$@"
|
||||
fi
|
||||
;;
|
||||
/usr/bin/mktemp | /usr/bin/mv | /usr/bin/chmod | /usr/bin/cp | /usr/bin/rm)
|
||||
exec "$@"
|
||||
;;
|
||||
/usr/bin/systemctl)
|
||||
if [[ -n ${SYSTEMCTL_FAIL_ONCE_FILE:-} && -e $SYSTEMCTL_FAIL_ONCE_FILE ]]; then
|
||||
/usr/bin/rm -f -- "$SYSTEMCTL_FAIL_ONCE_FILE"
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
/usr/bin/install)
|
||||
shift
|
||||
args=()
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
-o | -g)
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
args+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
exec /usr/bin/install "${args[@]}"
|
||||
;;
|
||||
*)
|
||||
printf 'unexpected sudo command: %s\n' "$*" >&2
|
||||
exit 97
|
||||
;;
|
||||
esac
|
||||
SH
|
||||
chmod +x "$stub_bin/sudo"
|
||||
|
||||
run_migration() {
|
||||
local fake_root_dirs
|
||||
|
||||
: >"$calls"
|
||||
fake_root_dirs="/:/tmp:$test_tmp:$sleep_dir:$systemd_dir:${drop_in%/*}"
|
||||
[[ -z ${EXTRA_FAKE_ROOT_DIRS:-} ]] || fake_root_dirs+=":$EXTRA_FAKE_ROOT_DIRS"
|
||||
|
||||
CALLS="$calls" \
|
||||
FAKE_ROOT_DIRS="$fake_root_dirs" \
|
||||
FAKE_ROOT_FILES="${FAKE_ROOT_FILES:-${2:-}}" \
|
||||
FAKE_ROOT_MODE="${FAKE_ROOT_MODE:-${3:-}}" \
|
||||
FAKE_ROOT_GID="${FAKE_ROOT_GID:-0}" \
|
||||
INACCESSIBLE_AS_USER="${INACCESSIBLE_AS_USER:-}" \
|
||||
SYSTEMCTL_FAIL_ONCE_FILE="${SYSTEMCTL_FAIL_ONCE_FILE:-}" \
|
||||
OMARCHY_PATH="$mock_omarchy" \
|
||||
PATH="$stub_bin:$PATH" bash -euo pipefail "$migration_copy" >/dev/null
|
||||
}
|
||||
|
||||
printf 'attacker keyboard\n' >"$sleep_dir/keyboard-backlight"
|
||||
printf 'attacker gpu\n' >"$sleep_dir/force-igpu"
|
||||
printf 'attacker drop-in\n' >"$drop_in"
|
||||
chmod 0777 "$sleep_dir/keyboard-backlight" "$sleep_dir/force-igpu"
|
||||
chmod 0666 "$drop_in"
|
||||
exec 9>>"$sleep_dir/keyboard-backlight"
|
||||
|
||||
run_migration Integrated
|
||||
printf 'write through stale attacker descriptor\n' >&9
|
||||
exec 9>&-
|
||||
|
||||
cmp -s "$mock_omarchy/default/systemd/system-sleep/keyboard-backlight" "$sleep_dir/keyboard-backlight" ||
|
||||
fail "migration replaces the user-owned keyboard hook with trusted content"
|
||||
cmp -s "$mock_omarchy/default/systemd/system-sleep/force-igpu" "$sleep_dir/force-igpu" ||
|
||||
fail "migration replaces the user-owned GPU hook with trusted content"
|
||||
cmp -s "$mock_omarchy/default/systemd/system/supergfxd.service.d/delay-start.conf" "$drop_in" ||
|
||||
fail "migration replaces the user-owned root service drop-in with trusted content"
|
||||
[[ $(stat -c '%a' "$sleep_dir/keyboard-backlight") == 755 ]] ||
|
||||
fail "migration activates the repaired keyboard hook"
|
||||
[[ $(stat -c '%a' "$sleep_dir/force-igpu") == 755 ]] ||
|
||||
fail "migration activates force-igpu only in Integrated mode"
|
||||
[[ $(stat -c '%a' "$drop_in") == 644 ]] ||
|
||||
fail "migration installs the service drop-in as configuration"
|
||||
grep -Fx $'sudo\t/usr/bin/systemctl\tdaemon-reload' "$calls" >/dev/null ||
|
||||
fail "migration reloads systemd after repairing its root service drop-in"
|
||||
[[ ! -e $reload_needed_marker ]] ||
|
||||
fail "migration leaves a reload marker after systemd accepted the repaired drop-in"
|
||||
[[ $(stat -c '%a' "$quarantine") == 700 ]] ||
|
||||
fail "migration keeps preserved unsafe custom content in a root-only directory"
|
||||
keyboard_backup=$(find "$quarantine" -path '*/keyboard-backlight.*/original' -type f -print -quit)
|
||||
force_backup=$(find "$quarantine" -path '*/force-igpu.*/original' -type f -print -quit)
|
||||
drop_in_backup=$(find "$quarantine" -path '*/delay-start.conf.*/original' -type f -print -quit)
|
||||
grep -Fxq 'attacker keyboard' "$keyboard_backup" ||
|
||||
fail "migration preserves unknown keyboard-hook content before replacing it"
|
||||
grep -Fxq 'attacker gpu' "$force_backup" ||
|
||||
fail "migration preserves unknown force-iGPU content before replacing it"
|
||||
grep -Fxq 'attacker drop-in' "$drop_in_backup" ||
|
||||
fail "migration preserves unknown service-drop-in content before replacing it"
|
||||
pass "migration replaces writable privileged files with trusted root-owned copies"
|
||||
|
||||
backup_count=$(find "$quarantine" -mindepth 2 -maxdepth 2 -name original | wc -l)
|
||||
FAKE_ROOT_FILES="$sleep_dir/keyboard-backlight:$sleep_dir/force-igpu:$drop_in" \
|
||||
run_migration Integrated
|
||||
[[ ! -s $calls ]] ||
|
||||
fail "migration changes already-repaired privileged files on a second run" "$(<"$calls")"
|
||||
[[ $(find "$quarantine" -mindepth 2 -maxdepth 2 -name original | wc -l) == "$backup_count" ]] ||
|
||||
fail "migration creates duplicate quarantines on a second run"
|
||||
pass "migration is idempotent after repairing unsafe privileged files"
|
||||
|
||||
printf 'attacker drop-in\n' >"$drop_in"
|
||||
chmod 0666 "$drop_in"
|
||||
reload_failure="$test_tmp/fail-systemd-reload-once"
|
||||
touch "$reload_failure"
|
||||
set +e
|
||||
SYSTEMCTL_FAIL_ONCE_FILE="$reload_failure" \
|
||||
FAKE_ROOT_FILES="$sleep_dir/keyboard-backlight:$sleep_dir/force-igpu" \
|
||||
run_migration Integrated
|
||||
reload_status=$?
|
||||
set -e
|
||||
(( reload_status != 0 )) ||
|
||||
fail "migration reports success after systemd rejects the repaired drop-in"
|
||||
cmp -s "$mock_omarchy/default/systemd/system/supergfxd.service.d/delay-start.conf" "$drop_in" ||
|
||||
fail "migration does not repair the drop-in before the simulated reload failure"
|
||||
[[ -e $reload_needed_marker && $(stat -c '%a' "$reload_needed_marker") == 644 ]] ||
|
||||
fail "migration does not persist the reload requirement before replacing the drop-in"
|
||||
|
||||
FAKE_ROOT_FILES="$sleep_dir/keyboard-backlight:$sleep_dir/force-igpu:$drop_in" \
|
||||
run_migration Integrated
|
||||
grep -Fx $'sudo\t/usr/bin/systemctl\tdaemon-reload' "$calls" >/dev/null ||
|
||||
fail "migration does not retry a failed reload after the drop-in is already safe"
|
||||
[[ ! -e $reload_needed_marker ]] ||
|
||||
fail "migration does not clear the reload requirement after a successful retry"
|
||||
|
||||
FAKE_ROOT_FILES="$sleep_dir/keyboard-backlight:$sleep_dir/force-igpu:$drop_in" \
|
||||
run_migration Integrated
|
||||
[[ ! -s $calls ]] ||
|
||||
fail "migration repeats a successfully completed reload repair" "$(<"$calls")"
|
||||
pass "migration persists and retries systemd reload after failure or interruption"
|
||||
|
||||
keyboard_backup_count=$(find "$quarantine" -path '*/keyboard-backlight.*/original' | wc -l)
|
||||
cp "$mock_omarchy/default/systemd/system-sleep/keyboard-backlight" \
|
||||
"$sleep_dir/keyboard-backlight"
|
||||
chmod 0644 "$sleep_dir/keyboard-backlight"
|
||||
FAKE_ROOT_FILES="$sleep_dir/force-igpu:$drop_in" run_migration Integrated
|
||||
[[ $(stat -c '%a' "$sleep_dir/keyboard-backlight") == 755 ]] ||
|
||||
fail "migration does not safely activate a user-owned canonical hook"
|
||||
[[ $(find "$quarantine" -path '*/keyboard-backlight.*/original' | wc -l) == "$keyboard_backup_count" ]] ||
|
||||
fail "migration quarantines an exact legacy artifact as administrator content"
|
||||
pass "migration replaces exact vulnerable installer artifacts without inventing backups"
|
||||
|
||||
legacy_keyboard="$test_tmp/legacy-keyboard-backlight"
|
||||
cat >"$legacy_keyboard" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
# Turn off keyboard backlight before hibernate to prevent hang on power-off.
|
||||
# The ASUS keyboard controller can block S4 shutdown if LEDs are active.
|
||||
|
||||
if [[ $1 == "pre" && $2 == "hibernate" ]]; then
|
||||
device=""
|
||||
for candidate in /sys/class/leds/*kbd_backlight*; do
|
||||
if [[ -e "$candidate" ]]; then
|
||||
device="$(basename "$candidate")"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -n "$device" ]]; then
|
||||
brightnessctl -d "$device" set 0 >/dev/null 2>&1
|
||||
fi
|
||||
fi
|
||||
SH
|
||||
[[ $(sha256sum "$legacy_keyboard" | cut -d' ' -f1) == f313a81e47401f0d38b8602e5997f52c5286d5e97f74027564ddd515b3d16511 ]] ||
|
||||
fail "keyboard-backlight legacy fixture no longer matches the migration fingerprint"
|
||||
keyboard_backup_count=$(find "$quarantine" -path '*/keyboard-backlight.*/original' | wc -l)
|
||||
cp "$legacy_keyboard" "$sleep_dir/keyboard-backlight"
|
||||
chmod 0644 "$sleep_dir/keyboard-backlight"
|
||||
FAKE_ROOT_FILES="$sleep_dir/keyboard-backlight:$sleep_dir/force-igpu:$drop_in" \
|
||||
run_migration Integrated
|
||||
cmp -s "$mock_omarchy/default/systemd/system-sleep/keyboard-backlight" "$sleep_dir/keyboard-backlight" ||
|
||||
fail "migration does not upgrade the released keyboard-backlight hook"
|
||||
[[ $(stat -c '%a' "$sleep_dir/keyboard-backlight") == 755 ]] ||
|
||||
fail "migration leaves the released keyboard-backlight hook non-executable"
|
||||
[[ $(find "$quarantine" -path '*/keyboard-backlight.*/original' | wc -l) == "$keyboard_backup_count" ]] ||
|
||||
fail "migration quarantines the released keyboard hook as administrator content"
|
||||
pass "migration activates the released root-owned keyboard-backlight hook"
|
||||
|
||||
cp "$legacy_keyboard" "$sleep_dir/keyboard-backlight"
|
||||
chmod 0755 "$sleep_dir/keyboard-backlight"
|
||||
FAKE_ROOT_FILES="$sleep_dir/keyboard-backlight:$sleep_dir/force-igpu:$drop_in" \
|
||||
run_migration Integrated
|
||||
cmp -s "$mock_omarchy/default/systemd/system-sleep/keyboard-backlight" "$sleep_dir/keyboard-backlight" ||
|
||||
fail "migration mistakes executable released hook bytes for a current artifact"
|
||||
pass "migration refreshes recognized legacy hook contents at the final mode"
|
||||
|
||||
printf 'attacker gpu\n' >"$sleep_dir/force-igpu"
|
||||
chmod 0777 "$sleep_dir/force-igpu"
|
||||
run_migration Hybrid
|
||||
[[ $(stat -c '%a' "$sleep_dir/force-igpu") == 755 ]] ||
|
||||
fail "migration does not activate the trusted self-guarding force-igpu hook"
|
||||
pass "migration repairs force-igpu without depending on a live GPU-mode query"
|
||||
|
||||
printf 'administrator customization\n' >"$sleep_dir/keyboard-backlight"
|
||||
chmod 0755 "$sleep_dir/keyboard-backlight"
|
||||
run_migration Integrated "$sleep_dir/keyboard-backlight" 755
|
||||
grep -Fxq 'administrator customization' "$sleep_dir/keyboard-backlight" ||
|
||||
fail "migration preserves a secure administrator-owned custom hook"
|
||||
|
||||
cp "$mock_omarchy/default/systemd/system-sleep/keyboard-backlight" "$sleep_dir/keyboard-backlight"
|
||||
chmod 0644 "$sleep_dir/keyboard-backlight"
|
||||
run_migration Integrated "$sleep_dir/keyboard-backlight" 644
|
||||
[[ $(stat -c '%a' "$sleep_dir/keyboard-backlight") == 755 ]] ||
|
||||
fail "migration leaves an exact packaged keyboard hook non-executable"
|
||||
|
||||
printf 'administrator customization\n' >"$sleep_dir/keyboard-backlight"
|
||||
chmod 0644 "$sleep_dir/keyboard-backlight"
|
||||
run_migration Integrated "$sleep_dir/keyboard-backlight" 644
|
||||
[[ $(stat -c '%a' "$sleep_dir/keyboard-backlight") == 644 ]] ||
|
||||
fail "migration changes the mode of a safe noncanonical administrator hook"
|
||||
grep -Fxq 'administrator customization' "$sleep_dir/keyboard-backlight" ||
|
||||
fail "migration replaces a safe noncanonical administrator hook"
|
||||
pass "migration activates only exact packaged hooks while preserving safe custom files"
|
||||
|
||||
legacy_force_igpu="$test_tmp/legacy-force-igpu"
|
||||
cat >"$legacy_force_igpu" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
# Use the Vfio to Integrated trick to turn off NVIDIA dgpu when in integrated mode
|
||||
# without needing to restart the computer. This is needed because computers like the Asus G14
|
||||
# will wake after suspend in Hybrid mode, even if the system was in Integrated mode before
|
||||
# suspending.
|
||||
|
||||
case "$1" in
|
||||
pre)
|
||||
# Before hibernating, switch to Vfio so the nvidia driver is detached from the dGPU.
|
||||
# Without this, hibernate resume fails because the nvidia driver can't freeze a
|
||||
# powered-off dGPU (returns -EIO), which aborts the entire resume.
|
||||
if [[ $2 == "hibernate" ]]; then
|
||||
/usr/bin/supergfxctl -m Vfio
|
||||
sleep 1
|
||||
fi
|
||||
;;
|
||||
post)
|
||||
# small delay so the device is fully re-enumerated
|
||||
sleep 4
|
||||
|
||||
# force-bind dGPU to vfio (fully detached from nvidia)
|
||||
/usr/bin/supergfxctl -m Vfio
|
||||
sleep 1
|
||||
|
||||
# then go back to Integrated, which powers it off again
|
||||
/usr/bin/supergfxctl -m Integrated
|
||||
;;
|
||||
esac
|
||||
SH
|
||||
[[ $(sha256sum "$legacy_force_igpu" | cut -d' ' -f1) == d604e7c4903829563e45fc52188fc5602c3f1bc66e247f0a2cc0a974ed6e57db ]] ||
|
||||
fail "force-igpu legacy fixture no longer matches the migration fingerprint"
|
||||
cp "$legacy_force_igpu" "$sleep_dir/force-igpu"
|
||||
chmod 0644 "$sleep_dir/force-igpu"
|
||||
FAKE_ROOT_FILES="$sleep_dir/keyboard-backlight:$sleep_dir/force-igpu:$drop_in" \
|
||||
run_migration Integrated
|
||||
cmp -s "$mock_omarchy/default/systemd/system-sleep/force-igpu" "$sleep_dir/force-igpu" ||
|
||||
fail "migration does not upgrade the exact legacy force-igpu hook"
|
||||
[[ $(stat -c '%a' "$sleep_dir/force-igpu") == 755 ]] ||
|
||||
fail "migration leaves the exact legacy force-igpu hook non-executable"
|
||||
pass "migration activates the exact legacy force-igpu artifact with its new guard"
|
||||
|
||||
printf 'wheel-managed customization\n' >"$sleep_dir/keyboard-backlight"
|
||||
chmod 0755 "$sleep_dir/keyboard-backlight"
|
||||
FAKE_ROOT_FILES="$sleep_dir/keyboard-backlight:$sleep_dir/force-igpu:$drop_in" \
|
||||
FAKE_ROOT_GID=10 run_migration Integrated
|
||||
grep -Fxq 'wheel-managed customization' "$sleep_dir/keyboard-backlight" ||
|
||||
fail "migration replaces a safe root:wheel administrator hook"
|
||||
[[ ! -s $calls ]] ||
|
||||
fail "migration escalates while preserving safe root:wheel entries"
|
||||
pass "migration treats non-writable root-owned files as safe regardless of group"
|
||||
|
||||
admin_dir="$test_tmp/admin-hooks"
|
||||
admin_keyboard="$admin_dir/keyboard"
|
||||
admin_delay="$admin_dir/delay.conf"
|
||||
mkdir -p "$admin_dir"
|
||||
printf 'protected keyboard customization\n' >"$admin_keyboard"
|
||||
printf 'protected delay customization\n' >"$admin_delay"
|
||||
chmod 0755 "$admin_keyboard"
|
||||
chmod 0644 "$admin_delay"
|
||||
rm -f "$sleep_dir/keyboard-backlight" "$drop_in"
|
||||
ln -s "$admin_keyboard" "$sleep_dir/keyboard-backlight"
|
||||
ln -s "$admin_delay" "$drop_in"
|
||||
|
||||
EXTRA_FAKE_ROOT_DIRS="$admin_dir" \
|
||||
FAKE_ROOT_FILES="$admin_keyboard:$admin_delay:$sleep_dir/force-igpu" \
|
||||
FAKE_ROOT_GID=10 run_migration Integrated
|
||||
[[ -L $sleep_dir/keyboard-backlight && $(readlink "$sleep_dir/keyboard-backlight") == "$admin_keyboard" ]] ||
|
||||
fail "migration replaces a safe administrator-managed keyboard-hook symlink"
|
||||
[[ -L $drop_in && $(readlink "$drop_in") == "$admin_delay" ]] ||
|
||||
fail "migration replaces a safe administrator-managed service-drop-in symlink"
|
||||
[[ ! -s $calls ]] ||
|
||||
fail "migration escalates while preserving safe administrator symlinks"
|
||||
pass "migration preserves symlinks whose full target paths are root-controlled"
|
||||
|
||||
dangling_target="$admin_dir/future-keyboard"
|
||||
rm -f "$sleep_dir/keyboard-backlight" "$dangling_target"
|
||||
ln -s "$dangling_target" "$sleep_dir/keyboard-backlight"
|
||||
|
||||
EXTRA_FAKE_ROOT_DIRS="$admin_dir" \
|
||||
FAKE_ROOT_FILES="$admin_delay:$sleep_dir/force-igpu:$drop_in" \
|
||||
run_migration Integrated
|
||||
[[ -L $sleep_dir/keyboard-backlight && $(readlink "$sleep_dir/keyboard-backlight") == "$dangling_target" ]] ||
|
||||
fail "migration replaces a safe dangling administrator symlink"
|
||||
[[ ! -s $calls ]] ||
|
||||
fail "migration asks for sudo to verify an absent target below a searchable root-controlled directory"
|
||||
pass "migration handles safe dangling administrator symlinks without sudo"
|
||||
|
||||
escaping_user_dir="$test_tmp/escaping-user-hooks"
|
||||
escaping_user_hook="$escaping_user_dir/keyboard"
|
||||
escaping_missing_dir="$admin_dir/future"
|
||||
escaping_target="$escaping_missing_dir/../../escaping-user-hooks/keyboard"
|
||||
mkdir -p "$escaping_user_dir"
|
||||
printf 'future unsafe keyboard customization\n' >"$escaping_user_hook"
|
||||
chmod 0755 "$escaping_user_hook"
|
||||
rm -f "$sleep_dir/keyboard-backlight"
|
||||
ln -s "$escaping_target" "$sleep_dir/keyboard-backlight"
|
||||
|
||||
EXTRA_FAKE_ROOT_DIRS="$admin_dir" \
|
||||
FAKE_ROOT_FILES="$admin_delay:$sleep_dir/force-igpu:$drop_in" \
|
||||
run_migration Integrated
|
||||
[[ ! -L $sleep_dir/keyboard-backlight ]] ||
|
||||
fail "migration trusts a dangling symlink whose unresolved suffix escapes to a user-controlled path"
|
||||
cmp -s "$mock_omarchy/default/systemd/system-sleep/keyboard-backlight" \
|
||||
"$sleep_dir/keyboard-backlight" ||
|
||||
fail "migration does not replace a future user-controlled dangling symlink"
|
||||
pass "migration resolves the full dangling-symlink suffix before trusting it"
|
||||
|
||||
protected_dir="$test_tmp/root-only-hooks"
|
||||
protected_target="$protected_dir/target"
|
||||
protected_bridge="$protected_dir/bridge"
|
||||
mkdir -p "$protected_dir"
|
||||
printf 'root-only administrator customization\n' >"$protected_target"
|
||||
ln -s "$protected_target" "$protected_bridge"
|
||||
chmod 0700 "$protected_dir"
|
||||
rm -f "$sleep_dir/keyboard-backlight"
|
||||
ln -s "$protected_bridge" "$sleep_dir/keyboard-backlight"
|
||||
|
||||
EXTRA_FAKE_ROOT_DIRS="$admin_dir:$protected_dir" \
|
||||
FAKE_ROOT_FILES="$protected_target:$admin_delay:$sleep_dir/force-igpu:$drop_in" \
|
||||
INACCESSIBLE_AS_USER="$protected_bridge:$protected_target" \
|
||||
run_migration Integrated
|
||||
[[ -L $sleep_dir/keyboard-backlight && $(readlink "$sleep_dir/keyboard-backlight") == "$protected_bridge" ]] ||
|
||||
fail "migration replaces a safe symlink whose target is hidden by a root-only directory"
|
||||
grep -q $'^sudo\t.*/stat\t-c\t%f %u %g %a\t--\t.*/root-only-hooks/bridge$' "$calls" ||
|
||||
fail "migration does not inspect inaccessible symlink metadata with privilege"
|
||||
grep -q $'^sudo\t.*/readlink\t--\t.*/root-only-hooks/bridge$' "$calls" ||
|
||||
fail "migration does not resolve an inaccessible administrator symlink with privilege"
|
||||
grep -q $'^sudo\t.*/stat\t-c\t%f %u %g %a\t--\t.*/root-only-hooks/target$' "$calls" ||
|
||||
fail "migration does not inspect an inaccessible administrator target with privilege"
|
||||
pass "migration preserves root-controlled symlink chains hidden from the invoking user"
|
||||
|
||||
protected_dangling_dir="$test_tmp/root-only-dangling"
|
||||
protected_dangling_target="$protected_dangling_dir/future-keyboard"
|
||||
mkdir -p "$protected_dangling_dir"
|
||||
chmod 0000 "$protected_dangling_dir"
|
||||
rm -f "$sleep_dir/keyboard-backlight"
|
||||
ln -s "$protected_dangling_target" "$sleep_dir/keyboard-backlight"
|
||||
|
||||
EXTRA_FAKE_ROOT_DIRS="$admin_dir:$protected_dangling_dir" \
|
||||
FAKE_ROOT_FILES="$admin_delay:$sleep_dir/force-igpu:$drop_in" \
|
||||
INACCESSIBLE_AS_USER="$protected_dangling_target" \
|
||||
run_migration Integrated
|
||||
[[ -L $sleep_dir/keyboard-backlight && $(readlink "$sleep_dir/keyboard-backlight") == "$protected_dangling_target" ]] ||
|
||||
fail "migration replaces a safe dangling symlink below a root-only directory"
|
||||
grep -q $'^sudo\t.*/stat\t-c\t%f %u %g %a\t--\t.*/root-only-dangling/future-keyboard$' "$calls" ||
|
||||
fail "migration does not inspect a protected dangling target with privilege"
|
||||
grep -q $'^sudo\t/usr/bin/test\t-x\t.*/root-only-dangling$' "$calls" ||
|
||||
fail "migration does not distinguish a protected missing target from an inaccessible parent"
|
||||
pass "migration preserves dangling administrator symlinks below root-only directories"
|
||||
|
||||
user_dir="$test_tmp/user-hooks"
|
||||
user_keyboard="$user_dir/keyboard"
|
||||
mkdir -p "$user_dir"
|
||||
printf 'unsafe symlink customization\n' >"$user_keyboard"
|
||||
chmod 0755 "$user_keyboard"
|
||||
rm -f "$sleep_dir/keyboard-backlight"
|
||||
ln -s "$user_keyboard" "$sleep_dir/keyboard-backlight"
|
||||
|
||||
EXTRA_FAKE_ROOT_DIRS="$admin_dir" \
|
||||
FAKE_ROOT_FILES="$admin_delay:$sleep_dir/force-igpu" run_migration Integrated
|
||||
[[ ! -L $sleep_dir/keyboard-backlight ]] ||
|
||||
fail "migration leaves a user-controlled keyboard-hook symlink active"
|
||||
cmp -s "$mock_omarchy/default/systemd/system-sleep/keyboard-backlight" \
|
||||
"$sleep_dir/keyboard-backlight" ||
|
||||
fail "migration does not replace an unsafe symlink with trusted hook content"
|
||||
symlink_backup=$(find "$quarantine" -path '*/keyboard-backlight.*/original' -type l -print -quit)
|
||||
[[ -n $symlink_backup && $(readlink "$symlink_backup") == "$user_keyboard" ]] ||
|
||||
fail "migration discards an unsafe custom symlink instead of preserving it"
|
||||
pass "migration quarantines unsafe symlinks outside the active systemd directory"
|
||||
|
||||
bridge="$user_dir/bridge"
|
||||
ln -s "$admin_keyboard" "$bridge"
|
||||
rm -f "$sleep_dir/keyboard-backlight"
|
||||
ln -s "$bridge" "$sleep_dir/keyboard-backlight"
|
||||
|
||||
EXTRA_FAKE_ROOT_DIRS="$admin_dir" \
|
||||
FAKE_ROOT_FILES="$admin_keyboard:$admin_delay:$sleep_dir/force-igpu" \
|
||||
run_migration Integrated
|
||||
[[ ! -L $sleep_dir/keyboard-backlight ]] ||
|
||||
fail "migration trusts a symlink chain routed through a user-controlled directory"
|
||||
cmp -s "$mock_omarchy/default/systemd/system-sleep/keyboard-backlight" \
|
||||
"$sleep_dir/keyboard-backlight" ||
|
||||
fail "migration does not repair an indirectly user-controlled symlink"
|
||||
pass "migration checks every intermediate component in a symlink chain"
|
||||
|
||||
hook_copy="$test_tmp/force-igpu-hook"
|
||||
hook_calls="$test_tmp/force-igpu-calls"
|
||||
hook_queries="$test_tmp/force-igpu-queries"
|
||||
hook_config="$test_tmp/supergfxd.conf"
|
||||
hook_marker="$test_tmp/force-igpu-restore"
|
||||
hook_pending="$test_tmp/force-igpu-pending"
|
||||
sed \
|
||||
-e "s|/usr/bin/supergfxctl|$stub_bin/hook-supergfxctl|g" \
|
||||
-e "s|/usr/bin/install|$stub_bin/hook-install|g" \
|
||||
-e "s|/etc/supergfxd.conf|$hook_config|g" \
|
||||
-e "s|/run/omarchy-force-igpu-integrated|$hook_marker|g" \
|
||||
"$ROOT/default/systemd/system-sleep/force-igpu" >"$hook_copy"
|
||||
cat >"$stub_bin/hook-supergfxctl" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
case "$1" in
|
||||
-m)
|
||||
printf '%s\n' "$*" >>"$HOOK_CALLS"
|
||||
if [[ ${HOOK_BLOCK_MODE:-} == "$2" ]]; then
|
||||
trap '' TERM
|
||||
/usr/bin/sleep 30
|
||||
fi
|
||||
current=$(sed -n 's/.*"mode"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$HOOK_CONFIG")
|
||||
if [[ $current != "$2" ]]; then
|
||||
printf '%s %s\n' "$2" "${HOOK_CONFIRM_AFTER:-1}" >"$HOOK_PENDING"
|
||||
fi
|
||||
;;
|
||||
-g)
|
||||
printf '%s\n' "$*" >>"$HOOK_QUERIES"
|
||||
if [[ -f $HOOK_PENDING ]]; then
|
||||
read -r pending remaining <"$HOOK_PENDING"
|
||||
if [[ ${HOOK_FAIL_MODE:-} != "$pending" ]]; then
|
||||
remaining=$((remaining - 1))
|
||||
if (( remaining <= 0 )); then
|
||||
sed -i "s/\"mode\"[[:space:]]*:[[:space:]]*\"[^\"]*\"/\"mode\": \"$pending\"/" "$HOOK_CONFIG"
|
||||
rm -f -- "$HOOK_PENDING"
|
||||
else
|
||||
printf '%s %s\n' "$pending" "$remaining" >"$HOOK_PENDING"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
sed -n 's/.*"mode"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$HOOK_CONFIG"
|
||||
;;
|
||||
esac
|
||||
SH
|
||||
cat >"$stub_bin/hook-install" <<'SH'
|
||||
#!/bin/bash
|
||||
args=()
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
-o | -g)
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
args+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
exec /usr/bin/install "${args[@]}"
|
||||
SH
|
||||
cat >"$stub_bin/sleep" <<'SH'
|
||||
#!/bin/bash
|
||||
:
|
||||
SH
|
||||
chmod +x "$stub_bin/hook-supergfxctl" "$stub_bin/hook-install" "$stub_bin/sleep"
|
||||
|
||||
hook_env=(
|
||||
"HOOK_CALLS=$hook_calls"
|
||||
"HOOK_QUERIES=$hook_queries"
|
||||
"HOOK_CONFIG=$hook_config"
|
||||
"HOOK_PENDING=$hook_pending"
|
||||
"PATH=$stub_bin:$PATH"
|
||||
)
|
||||
|
||||
printf '{ "mode": "Hybrid" }\n' >"$hook_config"
|
||||
env "${hook_env[@]}" bash "$hook_copy" pre suspend
|
||||
env "${hook_env[@]}" bash "$hook_copy" post suspend
|
||||
[[ ! -e $hook_calls ]] || fail "force-igpu runs while the root-owned config says Hybrid"
|
||||
[[ ! -e $hook_marker ]] || fail "force-igpu records restore intent while configured for Hybrid mode"
|
||||
|
||||
rm -f "$hook_config"
|
||||
env "${hook_env[@]}" bash "$hook_copy" pre suspend
|
||||
env "${hook_env[@]}" bash "$hook_copy" post suspend
|
||||
[[ ! -e $hook_calls ]] || fail "force-igpu runs when its mode config is unavailable"
|
||||
[[ ! -e $hook_marker ]] || fail "force-igpu records restore intent without a mode config"
|
||||
|
||||
printf '{ "mode": "Integrated" }\n' >"$hook_config"
|
||||
env "${hook_env[@]}" bash "$hook_copy" pre suspend
|
||||
[[ -f $hook_marker && $(stat -c '%a' "$hook_marker") == 600 ]] ||
|
||||
fail "force-igpu does not securely record Integrated restore intent during pre-suspend"
|
||||
HOOK_CONFIRM_AFTER=2 env "${hook_env[@]}" bash "$hook_copy" post suspend
|
||||
[[ $(wc -l <"$hook_calls") == 2 ]] ||
|
||||
fail "force-igpu does not run both GPU transitions in Integrated mode"
|
||||
grep -Fqx -- '-m Integrated' "$hook_calls" ||
|
||||
fail "force-igpu does not restore Integrated mode after suspend"
|
||||
[[ ! -e $hook_marker ]] || fail "force-igpu leaves stale restore intent after suspend"
|
||||
(( $(wc -l <"$hook_queries") >= 4 )) ||
|
||||
fail "force-igpu does not wait for asynchronous GPU transitions"
|
||||
pass "force-igpu confirms asynchronous transitions for Integrated sleep cycles"
|
||||
|
||||
: >"$hook_calls"
|
||||
: >"$hook_queries"
|
||||
printf '{ "mode": "Integrated" }\n' >"$hook_config"
|
||||
env "${hook_env[@]}" bash "$hook_copy" pre suspend
|
||||
set +e
|
||||
HOOK_CONFIRM_AFTER=2 HOOK_FAIL_MODE=Integrated env "${hook_env[@]}" \
|
||||
bash "$hook_copy" post suspend >/dev/null 2>&1
|
||||
restore_status=$?
|
||||
set -e
|
||||
(( restore_status != 0 )) || fail "force-igpu reports success without confirming Integrated mode"
|
||||
grep -Fq '"mode": "Vfio"' "$hook_config" ||
|
||||
fail "force-igpu failure test does not leave the transition in Vfio mode"
|
||||
[[ -f $hook_marker ]] || fail "force-igpu discards restore intent after an asynchronous transition failure"
|
||||
HOOK_CONFIRM_AFTER=2 env "${hook_env[@]}" bash "$hook_copy" post suspend
|
||||
grep -Fq '"mode": "Integrated"' "$hook_config" ||
|
||||
fail "force-igpu does not recover the Integrated transition on the next sleep cycle"
|
||||
[[ ! -e $hook_marker ]] || fail "force-igpu leaves restore intent after a confirmed retry"
|
||||
pass "force-igpu retains restore intent until Integrated mode is confirmed"
|
||||
|
||||
: >"$hook_calls"
|
||||
: >"$hook_queries"
|
||||
printf '{ "mode": "Integrated" }\n' >"$hook_config"
|
||||
env "${hook_env[@]}" bash "$hook_copy" pre suspend
|
||||
set +e
|
||||
HOOK_BLOCK_MODE=Vfio env "${hook_env[@]}" \
|
||||
bash "$hook_copy" post suspend >/dev/null 2>&1
|
||||
blocked_request_status=$?
|
||||
set -e
|
||||
(( blocked_request_status != 0 )) || fail "force-igpu waits forever for a blocked GPU transition request"
|
||||
[[ -f $hook_marker ]] || fail "force-igpu discards restore intent after a blocked transition request"
|
||||
[[ ! -s $hook_queries ]] || fail "force-igpu polls before a blocked transition request returns"
|
||||
env "${hook_env[@]}" bash "$hook_copy" post suspend
|
||||
[[ ! -e $hook_marker ]] || fail "force-igpu cannot retry after a blocked transition request"
|
||||
pass "force-igpu bounds blocked transition requests and retains retry intent"
|
||||
|
||||
: >"$hook_calls"
|
||||
printf '{ "mode": "Integrated" }\n' >"$hook_config"
|
||||
env "${hook_env[@]}" bash "$hook_copy" pre hibernate
|
||||
grep -Fq '"mode": "Vfio"' "$hook_config" ||
|
||||
fail "force-igpu test double does not model the pre-hibernate Vfio persistence"
|
||||
[[ -f $hook_marker ]] || fail "force-igpu loses restore intent during the Vfio transition"
|
||||
env "${hook_env[@]}" bash "$hook_copy" post hibernate
|
||||
[[ $(wc -l <"$hook_calls") == 3 ]] ||
|
||||
fail "force-igpu skips the post-hibernate transitions after Vfio changes the config"
|
||||
[[ $(tail -1 "$hook_calls") == "-m Integrated" ]] ||
|
||||
fail "force-igpu does not finish post-hibernate restoration in Integrated mode"
|
||||
grep -Fq '"mode": "Integrated"' "$hook_config" ||
|
||||
fail "force-igpu leaves supergfxd configured for Vfio after hibernation"
|
||||
[[ ! -e $hook_marker ]] || fail "force-igpu leaves stale restore intent after hibernation"
|
||||
pass "force-igpu restores Integrated mode after pre-hibernate persists Vfio"
|
||||
|
||||
: >"$hook_calls"
|
||||
printf '{ "mode": "Integrated" }\n' >"$hook_config"
|
||||
SYSTEMD_SLEEP_ACTION=suspend env "${hook_env[@]}" bash "$hook_copy" pre suspend-then-hibernate
|
||||
SYSTEMD_SLEEP_ACTION=suspend env "${hook_env[@]}" bash "$hook_copy" post suspend-then-hibernate
|
||||
[[ $(wc -l <"$hook_calls") == 2 ]] ||
|
||||
fail "force-igpu does not complete the initial suspend phase of suspend-then-hibernate"
|
||||
SYSTEMD_SLEEP_ACTION=hibernate env "${hook_env[@]}" bash "$hook_copy" pre suspend-then-hibernate
|
||||
[[ $(wc -l <"$hook_calls") == 3 && $(tail -1 "$hook_calls") == "-m Vfio" ]] ||
|
||||
fail "force-igpu skips the Vfio transition before compound hibernation"
|
||||
grep -Fq '"mode": "Vfio"' "$hook_config" ||
|
||||
fail "force-igpu does not detach the dGPU during the hibernate phase"
|
||||
[[ -f $hook_marker ]] || fail "force-igpu loses restore intent during compound hibernation"
|
||||
SYSTEMD_SLEEP_ACTION=hibernate env "${hook_env[@]}" bash "$hook_copy" post suspend-then-hibernate
|
||||
[[ $(wc -l <"$hook_calls") == 5 && $(tail -1 "$hook_calls") == "-m Integrated" ]] ||
|
||||
fail "force-igpu does not restore Integrated mode after compound hibernation"
|
||||
grep -Fq '"mode": "Integrated"' "$hook_config" ||
|
||||
fail "force-igpu leaves supergfxd configured for Vfio after compound hibernation"
|
||||
[[ ! -e $hook_marker ]] || fail "force-igpu leaves stale restore intent after compound hibernation"
|
||||
pass "force-igpu handles both phases of suspend-then-hibernate"
|
||||
|
||||
keyboard_hook_copy="$test_tmp/keyboard-backlight-hook"
|
||||
keyboard_calls="$test_tmp/keyboard-backlight-calls"
|
||||
keyboard_led_dir="$test_tmp/leds"
|
||||
mkdir -p "$keyboard_led_dir/asus::kbd_backlight"
|
||||
sed "s|/sys/class/leds/\*kbd_backlight\*|$keyboard_led_dir/*kbd_backlight*|" \
|
||||
"$ROOT/default/systemd/system-sleep/keyboard-backlight" >"$keyboard_hook_copy"
|
||||
cat >"$stub_bin/brightnessctl" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$*" >>"$KEYBOARD_CALLS"
|
||||
SH
|
||||
chmod +x "$stub_bin/brightnessctl"
|
||||
|
||||
SYSTEMD_SLEEP_ACTION=suspend KEYBOARD_CALLS="$keyboard_calls" PATH="$stub_bin:$PATH" \
|
||||
bash "$keyboard_hook_copy" pre suspend-then-hibernate
|
||||
[[ ! -e $keyboard_calls ]] || fail "keyboard-backlight runs during the suspend phase of compound sleep"
|
||||
SYSTEMD_SLEEP_ACTION=hibernate KEYBOARD_CALLS="$keyboard_calls" PATH="$stub_bin:$PATH" \
|
||||
bash "$keyboard_hook_copy" pre suspend-then-hibernate
|
||||
grep -Fqx -- '-d asus::kbd_backlight set 0' "$keyboard_calls" ||
|
||||
fail "keyboard-backlight skips the hibernate phase of compound sleep"
|
||||
pass "keyboard-backlight handles the hibernate phase of suspend-then-hibernate"
|
||||
@@ -71,14 +71,6 @@ grep -Fx 'WantedBy=graphical-session.target' "$fcitx_service" >/dev/null ||
|
||||
grep -Fx 'ConditionEnvironment=WAYLAND_DISPLAY' "$fcitx_service" >/dev/null ||
|
||||
fail "an update over SSH has a live user manager and no display; starting fcitx5 there wedges the unit active-but-blind, and Wants= will not replace it at graphical login"
|
||||
|
||||
fcitx_migration="$ROOT/migrations/1785167800.sh"
|
||||
grep -F 'is-active --quiet graphical-session.target' "$fcitx_migration" >/dev/null ||
|
||||
fail "migration kills fcitx5 and starts the unit outside a graphical session"
|
||||
grep -F 'systemctl --user enable omarchy-fcitx5.service' "$fcitx_migration" >/dev/null ||
|
||||
fail "migration must enable without --now; --now starts the unit before the session-gate check"
|
||||
grep -F 'Could not start omarchy-fcitx5.service' "$fcitx_migration" >/dev/null ||
|
||||
fail "migration pkills a working fcitx5, so a failed handover must be reported instead of marked complete"
|
||||
|
||||
grep -F 'pkill -x fcitx5' "$ROOT/bin/omarchy-restart-xcompose" >/dev/null ||
|
||||
fail "restart-xcompose cannot reload a fcitx5 running outside the unit, so it silently keeps serving the old table"
|
||||
|
||||
@@ -111,10 +103,4 @@ pass "systemd-oomd acts on sustained memory stall"
|
||||
|
||||
grep -Fx 'systemctl enable systemd-oomd.service' "$ROOT/install/config/enable-services.sh" >/dev/null ||
|
||||
fail "new installs ship the oomd drop-ins with the daemon that reads them disabled"
|
||||
|
||||
oomd_migration=$(grep -rl 'systemd-oomd.service' "$ROOT/migrations" | head -n 1 || true)
|
||||
[[ -n $oomd_migration ]] ||
|
||||
fail "existing installs never enable systemd-oomd; enable-services.sh only runs at install time"
|
||||
grep -F 'systemctl --user daemon-reload' "$oomd_migration" >/dev/null ||
|
||||
fail "migration leaves the user manager unaware of app.slice candidacy until the next login"
|
||||
pass "existing installs enable systemd-oomd and report app.slice without a relogin"
|
||||
pass "new installs enable systemd-oomd"
|
||||
Executable
+81
@@ -0,0 +1,81 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
test_home="$test_tmp/home"
|
||||
mock_bin="$test_tmp/bin"
|
||||
mkdir -p "$mock_bin"
|
||||
|
||||
# Run the real theme refresh, renderer and publisher in a throwaway home.
|
||||
# Only package installation, the T3 CLI and desktop launch are stubbed.
|
||||
cat >"$mock_bin/omarchy-pkg-add" <<'SH'
|
||||
#!/bin/bash
|
||||
[[ $1 == "t3code-bin" ]]
|
||||
SH
|
||||
|
||||
cat >"$mock_bin/t3" <<'SH'
|
||||
#!/bin/bash
|
||||
[[ $1 == "theme" && $2 == "set" && $3 == "omarchy" && $4 == "--base-dir" && $5 == "$T3CODE_HOME" ]] || exit 1
|
||||
palette="$T3CODE_HOME/userdata/themes/omarchy.json"
|
||||
if [[ ! -f $palette ]] || ! jq -e '.name == "Omarchy" and (.accent | test("^#[0-9a-fA-F]{6}$"))' "$palette" >/dev/null; then
|
||||
echo "No published Omarchy theme"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ${OMARCHY_TEST_T3_FAIL:-0} == "1" ]]; then
|
||||
echo "T3 theme selection failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "T3 selected Omarchy"
|
||||
SH
|
||||
|
||||
cat >"$mock_bin/setsid" <<'SH'
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
SH
|
||||
chmod +x "$mock_bin"/*
|
||||
|
||||
run_installer() {
|
||||
HOME="$test_home" T3CODE_HOME="$test_tmp/custom t3 home" \
|
||||
OMARCHY_PATH="$ROOT" PATH="$mock_bin:$ROOT/bin:$PATH" \
|
||||
OMARCHY_THEME_HEADLESS=1 XDG_RUNTIME_DIR="$test_tmp" \
|
||||
"$ROOT/bin/omarchy-install-ai-t3-code" >"$test_tmp/output" 2>&1
|
||||
}
|
||||
|
||||
state="$test_home/.local/state/omarchy/current"
|
||||
palette="$test_tmp/custom t3 home/userdata/themes/omarchy.json"
|
||||
settings="$test_tmp/custom t3 home/userdata/settings.json"
|
||||
mkdir -p "$state/theme"
|
||||
echo 'tokyo-night' >"$state/theme.name"
|
||||
cp "$ROOT/themes/tokyo-night/colors.toml" "$state/theme/colors.toml"
|
||||
|
||||
run_installer || fail "installing after an update succeeds" "$(cat "$test_tmp/output")"
|
||||
[[ -f $state/theme/t3code.json && -f $palette ]] || fail "the missing palette is rendered and published"
|
||||
cmp -s "$state/theme/t3code.json" "$palette" || fail "T3 receives the current theme's palette"
|
||||
grep -q 'T3 selected Omarchy' "$test_tmp/output" || fail "the published palette is selected"
|
||||
grep -q 'Opening T3 Code' "$test_tmp/output" || fail "the themed app opens"
|
||||
[[ ! -e $test_home/.t3 ]] || fail "the custom T3 home is respected"
|
||||
pass "an update with no staged T3 palette renders, publishes and selects it before launch"
|
||||
|
||||
# Preserve an already rendered palette, including a user-supplied color.
|
||||
jq '.accent = "#abcdef"' "$state/theme/t3code.json" >"$test_tmp/palette.json"
|
||||
cp "$test_tmp/palette.json" "$state/theme/t3code.json"
|
||||
run_installer || fail "installing with an existing palette succeeds" "$(cat "$test_tmp/output")"
|
||||
cmp -s "$test_tmp/palette.json" "$palette" || fail "an existing palette is published without re-staging"
|
||||
pass "an existing palette is used without refreshing the current theme"
|
||||
|
||||
printf '{"defaultTheme":"dark","keep":"existing settings"}\n' >"$settings"
|
||||
cp "$settings" "$test_tmp/settings-before.json"
|
||||
if OMARCHY_TEST_T3_FAIL=1 run_installer; then
|
||||
fail "a failed T3 theme selection fails the installer"
|
||||
fi
|
||||
grep -q 'T3 theme selection failed' "$test_tmp/output" || fail "the CLI failure is visible"
|
||||
if grep -Eq 'Opening T3 Code|T3 Code has been installed' "$test_tmp/output"; then
|
||||
fail "a failed selection does not report successful setup"
|
||||
fi
|
||||
cmp -s "$test_tmp/settings-before.json" "$settings" || fail "a failed selection does not rewrite settings"
|
||||
pass "a CLI failure remains visible and leaves the existing settings intact"
|
||||
@@ -1,59 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh"
|
||||
|
||||
migration=$(grep -rl 'Make Shift+Enter distinguishable for terminals and Codex' "$ROOT/migrations" | head -n 1 || true)
|
||||
[[ -n $migration ]] || fail "terminal Shift+Enter migration exists"
|
||||
|
||||
TMPDIR=$(mktemp -d)
|
||||
trap 'rm -rf "$TMPDIR"' EXIT
|
||||
|
||||
test_home="$TMPDIR/home"
|
||||
mkdir -p \
|
||||
"$test_home/.config/alacritty" \
|
||||
"$test_home/.config/foot" \
|
||||
"$test_home/.config/ghostty" \
|
||||
"$test_home/.config/kitty"
|
||||
|
||||
cp "$ROOT/config/alacritty/alacritty.toml" "$test_home/.config/alacritty/alacritty.toml"
|
||||
sed -i 's/\\u001B\[13;2u/\\u001B\\r/' "$test_home/.config/alacritty/alacritty.toml"
|
||||
|
||||
cp "$ROOT/config/kitty/kitty.conf" "$test_home/.config/kitty/kitty.conf"
|
||||
sed -i '/shift+enter/d' "$test_home/.config/kitty/kitty.conf"
|
||||
|
||||
cp "$ROOT/config/ghostty/config" "$test_home/.config/ghostty/config"
|
||||
sed -i '/shift+enter/d' "$test_home/.config/ghostty/config"
|
||||
|
||||
cp "$ROOT/config/foot/foot.ini" "$test_home/.config/foot/foot.ini"
|
||||
sed -i '/text-bindings/,$d' "$test_home/.config/foot/foot.ini"
|
||||
|
||||
HOME="$test_home" bash "$migration" >/dev/null
|
||||
|
||||
grep -qxF '{ key = "Return", mods = "Shift", chars = "\u001B[13;2u" },' "$test_home/.config/alacritty/alacritty.toml" ||
|
||||
fail "migration updates Alacritty Shift+Return to CSI-u"
|
||||
pass "migration updates Alacritty Shift+Return to CSI-u"
|
||||
|
||||
grep -qxF 'map shift+enter send_text all \e[13;2u' "$test_home/.config/kitty/kitty.conf" ||
|
||||
fail "migration adds Kitty Shift+Enter CSI-u binding"
|
||||
grep -qxF 'map alt+shift+enter send_text all \e[13;4u' "$test_home/.config/kitty/kitty.conf" ||
|
||||
fail "migration adds Kitty Alt+Shift+Enter CSI-u binding"
|
||||
pass "migration adds Kitty CSI-u bindings"
|
||||
|
||||
grep -qxF 'keybind = shift+enter=csi:13;2u' "$test_home/.config/ghostty/config" ||
|
||||
fail "migration adds Ghostty Shift+Enter CSI-u binding"
|
||||
grep -qxF 'keybind = alt+shift+enter=csi:13;4u' "$test_home/.config/ghostty/config" ||
|
||||
fail "migration adds Ghostty Alt+Shift+Enter CSI-u binding"
|
||||
pass "migration adds Ghostty CSI-u bindings"
|
||||
|
||||
grep -qxF '\x1b[13;2u=Shift+Return' "$test_home/.config/foot/foot.ini" ||
|
||||
fail "migration adds Foot Shift+Return CSI-u binding"
|
||||
grep -qxF '\x1b[13;4u=Mod1+Shift+Return' "$test_home/.config/foot/foot.ini" ||
|
||||
fail "migration adds Foot Alt+Shift+Return CSI-u binding"
|
||||
pass "migration adds Foot CSI-u bindings"
|
||||
|
||||
before=$(find "$test_home/.config" -type f -print0 | sort -z | xargs -0 sha256sum)
|
||||
HOME="$test_home" bash "$migration" >/dev/null
|
||||
after=$(find "$test_home/.config" -type f -print0 | sort -z | xargs -0 sha256sum)
|
||||
|
||||
[[ $before == "$after" ]] || fail "terminal Shift+Enter migration is idempotent"
|
||||
pass "terminal Shift+Enter migration is idempotent"
|
||||
@@ -207,7 +207,7 @@ pass "a theme name cannot climb out of the theme directories"
|
||||
# generates. Every generated theme file is either denied to an installed theme or
|
||||
# recorded here as carrying colour, so a new template fails until it is placed.
|
||||
denied=(alacritty.toml foot.ini ghostty.conf kitty.conf gum_env.lua hyprland.lua neovim.lua vscode.json)
|
||||
colour_only=(btop.theme chromium.theme claude.json helix.toml hyprland-preview-share-picker.css keyboard.rgb obsidian.css pi.json shell.toml vscode-theme.json)
|
||||
colour_only=(btop.theme chromium.theme claude.json helix.toml hermes.yaml hyprland-preview-share-picker.css keyboard.rgb obsidian.css pi.json shell.toml t3code.json vscode-theme.json)
|
||||
|
||||
for tpl in "$ROOT"/default/themed/*.tpl; do
|
||||
generated=$(basename "$tpl" .tpl)
|
||||
|
||||
@@ -51,3 +51,36 @@ pass "bar on is idempotent"
|
||||
HOME="$test_home" omarchy-toggle-bar off
|
||||
[[ ! -f $bar_flag ]] || fail "bar off disables bar-off toggle"
|
||||
pass "bar off disables bar-off toggle"
|
||||
|
||||
# The gaps half of full screen copies a flag file in and reloads Hyprland, so
|
||||
# give it this checkout to copy from and a hyprctl that answers without a
|
||||
# compositor.
|
||||
export OMARCHY_PATH="$ROOT"
|
||||
stub_bin="$TMPDIR/bin"
|
||||
mkdir -p "$stub_bin"
|
||||
printf '#!/bin/bash\nexit 0\n' >"$stub_bin/hyprctl"
|
||||
chmod +x "$stub_bin/hyprctl"
|
||||
export PATH="$stub_bin:$PATH"
|
||||
|
||||
gaps_flag="$test_home/.local/state/omarchy/toggles/hypr/window-no-gaps.lua"
|
||||
|
||||
HOME="$test_home" omarchy-toggle-fullscreen-desktop
|
||||
[[ -f $bar_flag && -f $gaps_flag ]] || fail "fullscreen toggle hides the bar and the gaps together"
|
||||
pass "fullscreen toggle hides the bar and the gaps together"
|
||||
|
||||
HOME="$test_home" omarchy-toggle-fullscreen-desktop
|
||||
[[ ! -f $bar_flag && ! -f $gaps_flag ]] || fail "fullscreen toggle restores the bar and the gaps together"
|
||||
pass "fullscreen toggle restores the bar and the gaps together"
|
||||
|
||||
HOME="$test_home" omarchy-toggle-bar on
|
||||
HOME="$test_home" omarchy-toggle-fullscreen-desktop
|
||||
[[ -f $bar_flag && -f $gaps_flag ]] || fail "fullscreen toggle pulls a half-hidden desktop into full screen"
|
||||
pass "fullscreen toggle pulls a half-hidden desktop into full screen"
|
||||
|
||||
HOME="$test_home" omarchy-toggle-fullscreen-desktop off
|
||||
[[ ! -f $bar_flag && ! -f $gaps_flag ]] || fail "fullscreen off leaves full screen"
|
||||
pass "fullscreen off leaves full screen"
|
||||
|
||||
HOME="$test_home" omarchy-toggle-fullscreen-desktop on
|
||||
[[ -f $bar_flag && -f $gaps_flag ]] || fail "fullscreen on enters full screen"
|
||||
pass "fullscreen on enters full screen"
|
||||
@@ -132,3 +132,47 @@ if problems:
|
||||
PYTHON
|
||||
|
||||
pass "no Omarchy script writes a path under /usr that no package owns"
|
||||
|
||||
for script in bin/omarchy-hibernation-setup bin/omarchy-toggle-hybrid-gpu; do
|
||||
grep -F '"${destination%/*}/.${destination##*/}.omarchy.XXXXXX"' "$ROOT/$script" >/dev/null ||
|
||||
fail "$script reserves a hidden sibling for the privileged replacement"
|
||||
grep -F 'sudo /usr/bin/install -m "$mode" -o root -g root -T "$source" "$stage"' "$ROOT/$script" >/dev/null ||
|
||||
fail "$script prepares privileged files with final root ownership and mode"
|
||||
grep -F 'sudo /usr/bin/mv -Tf -- "$stage" "$destination"' "$ROOT/$script" >/dev/null ||
|
||||
fail "$script atomically replaces the privileged destination"
|
||||
if grep -F 'sudo /usr/bin/chmod "$mode" "$destination"' "$ROOT/$script" >/dev/null; then
|
||||
fail "$script changes mode after publishing the privileged destination"
|
||||
fi
|
||||
done
|
||||
|
||||
grep -F ' /usr/lib/systemd/system-sleep/keyboard-backlight 0755' "$ROOT/bin/omarchy-hibernation-setup" >/dev/null ||
|
||||
fail "hibernation setup installs keyboard-backlight as a root-owned executable"
|
||||
|
||||
hook_install_line=$(rg -n '^if ! install_root_file .*keyboard-backlight' "$ROOT/bin/omarchy-hibernation-setup" | cut -d: -f1)
|
||||
resume_marker_line=$(rg -n '^echo "HOOKS\+=\(resume\)"' "$ROOT/bin/omarchy-hibernation-setup" | cut -d: -f1)
|
||||
[[ -n $hook_install_line && -n $resume_marker_line ]] ||
|
||||
fail "hibernation setup keeps recognizable hook-install and resume-marker steps"
|
||||
(( hook_install_line < resume_marker_line )) ||
|
||||
fail "hibernation setup marks completion before a failed hook install can be retried"
|
||||
|
||||
grep -F ' /usr/lib/systemd/system-sleep/force-igpu 0755' "$ROOT/bin/omarchy-toggle-hybrid-gpu" >/dev/null ||
|
||||
fail "hybrid GPU setup installs force-igpu as a root-owned executable"
|
||||
grep -F ' /etc/systemd/system/supergfxd.service.d/delay-start.conf 0644' "$ROOT/bin/omarchy-toggle-hybrid-gpu" >/dev/null ||
|
||||
fail "hybrid GPU setup installs its root service drop-in as root-owned configuration"
|
||||
|
||||
delay_install_line=$(rg -n '^ if ! install_root_file .*delay-start\.conf' "$ROOT/bin/omarchy-toggle-hybrid-gpu" | cut -d: -f1)
|
||||
force_install_line=$(rg -n '^ if ! install_root_file .*force-igpu' "$ROOT/bin/omarchy-toggle-hybrid-gpu" | cut -d: -f1)
|
||||
config_switch_line=$(rg -n '^ sudo sed -i \\' "$ROOT/bin/omarchy-toggle-hybrid-gpu" | tail -1 | cut -d: -f1)
|
||||
[[ -n $delay_install_line && -n $force_install_line && -n $config_switch_line ]] ||
|
||||
fail "hybrid GPU setup keeps recognizable support-file and config-switch steps"
|
||||
(( delay_install_line < config_switch_line && force_install_line < config_switch_line )) ||
|
||||
fail "hybrid GPU setup switches config before every required file is installed"
|
||||
|
||||
grep -Fq '/usr/bin/grep -Eq' "$ROOT/default/systemd/system-sleep/force-igpu" ||
|
||||
fail "force-igpu does not guard execution with the configured GPU mode"
|
||||
|
||||
if rg -n 'cp -p.*(system-sleep|supergfxd\.service\.d)' "$ROOT/bin/omarchy-hibernation-setup" "$ROOT/bin/omarchy-toggle-hybrid-gpu"; then
|
||||
fail "privileged sleep and hybrid GPU files are never copied with source ownership"
|
||||
fi
|
||||
|
||||
pass "system-sleep hooks and the hybrid GPU drop-in enforce root ownership"
|
||||
@@ -6,6 +6,10 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
upgrade_to_quattro="$ROOT/bin/omarchy-upgrade-to-quattro"
|
||||
|
||||
function_body() {
|
||||
awk -v name="$1" '$0 == name "() {" { inside = 1; next } inside && $0 == "}" { exit } inside' "$upgrade_to_quattro"
|
||||
}
|
||||
|
||||
snapshot_line=$(grep -n '^create_pre_upgrade_snapshot$' "$upgrade_to_quattro" | cut -d: -f1)
|
||||
pacman_line=$(grep -n '^configure_pacman_channel$' "$upgrade_to_quattro" | cut -d: -f1)
|
||||
[[ -n $snapshot_line && -n $pacman_line ]] || fail "upgrade snapshot and first mutation calls exist"
|
||||
@@ -73,6 +77,44 @@ grep -F 'OMARCHY_INSTALL_USER="$target_user"' "$upgrade_to_quattro" >/dev/null
|
||||
grep -F '"$apply_lock"' "$upgrade_to_quattro" >/dev/null
|
||||
pass "Omarchy 4 upgrade configures lock screen authentication for the target user"
|
||||
|
||||
root_path_count=$(awk '/^root_path=/{ count++ } END { print count + 0 }' "$upgrade_to_quattro")
|
||||
(( root_path_count == 1 )) || fail "Omarchy 4 upgrade defines exactly one root command path"
|
||||
grep -Fx 'root_path=/usr/share/omarchy/bin:/usr/local/bin:/usr/bin:/bin' "$upgrade_to_quattro" >/dev/null ||
|
||||
fail "Omarchy 4 upgrade limits root command lookup to trusted system directories"
|
||||
if grep -E '^root_path=.*(target_home|\.local/bin)' "$upgrade_to_quattro" >/dev/null; then
|
||||
fail "Omarchy 4 upgrade does not put the target user's bin directory on the root command path"
|
||||
fi
|
||||
grep -Fx 'package_path="$root_path:$target_home/.local/bin"' "$upgrade_to_quattro" >/dev/null ||
|
||||
fail "Omarchy 4 upgrade retains the target user's bin directory for user commands"
|
||||
|
||||
lock_authentication_body=$(function_body configure_lock_authentication)
|
||||
lock_path_assignment_count=$(awk '{ count += gsub(/(^|[[:space:]])PATH=/, "") } END { print count + 0 }' <<<"$lock_authentication_body")
|
||||
(( lock_path_assignment_count == 1 )) ||
|
||||
fail "Omarchy 4 upgrade gives the privileged lock helper exactly one command path"
|
||||
grep -Fx ' PATH="$root_path" \' <<<"$lock_authentication_body" >/dev/null ||
|
||||
fail "Omarchy 4 upgrade gives the privileged lock helper the trusted root path"
|
||||
if grep -E '(package_path|target_home|\.local/bin)' <<<"$lock_authentication_body" >/dev/null; then
|
||||
fail "Omarchy 4 upgrade does not give the privileged lock helper the target user's path"
|
||||
fi
|
||||
|
||||
firewall_body=$(function_body apply_firewall_defaults)
|
||||
firewall_path_assignment_count=$(awk '{ count += gsub(/(^|[[:space:]])PATH=/, "") } END { print count + 0 }' <<<"$firewall_body")
|
||||
(( firewall_path_assignment_count == 1 )) ||
|
||||
fail "Omarchy 4 upgrade gives the privileged firewall helper exactly one command path"
|
||||
grep -Fx ' as_root env OMARCHY_PATH=/usr/share/omarchy PATH="$root_path" \' <<<"$firewall_body" >/dev/null ||
|
||||
fail "Omarchy 4 upgrade gives the privileged firewall helper the trusted root path"
|
||||
if grep -E '(package_path|target_home|\.local/bin)' <<<"$firewall_body" >/dev/null; then
|
||||
fail "Omarchy 4 upgrade does not give the privileged firewall helper the target user's path"
|
||||
fi
|
||||
|
||||
user_omarchy_body=$(function_body run_as_user_omarchy)
|
||||
grep -F 'PATH="$package_path"' <<<"$user_omarchy_body" >/dev/null ||
|
||||
fail "Omarchy 4 upgrade retains the package and user path for target-user commands"
|
||||
if grep -F 'PATH="$root_path"' <<<"$user_omarchy_body" >/dev/null; then
|
||||
fail "Omarchy 4 upgrade does not narrow target-user commands to the root-only path"
|
||||
fi
|
||||
pass "Omarchy 4 upgrade separates privileged and target-user command paths"
|
||||
|
||||
grep -F 'install/helpers/browser-policy.sh' "$upgrade_to_quattro" >/dev/null ||
|
||||
fail "Omarchy 4 upgrade uses the shared browser-policy helper"
|
||||
grep -F 'as_root test -f "$browser_policy_helper"' "$upgrade_to_quattro" >/dev/null ||
|
||||
@@ -100,10 +142,6 @@ pass "Omarchy 4 upgrade retires systemd-networkd for NetworkManager"
|
||||
# Booting with both managers enabled leaves them fighting over the Wi-Fi
|
||||
# adapter, so enabling NetworkManager and disabling iwd cannot be separated by
|
||||
# any step that might abort in between.
|
||||
function_body() {
|
||||
awk -v name="$1" '$0 == name "() {" { inside = 1; next } inside && $0 == "}" { exit } inside' "$upgrade_to_quattro"
|
||||
}
|
||||
|
||||
migrations_body=$(function_body run_post_upgrade_migrations)
|
||||
grep -F 'fail "Omarchy migrations did not complete.' <<<"$migrations_body" >/dev/null ||
|
||||
fail "Omarchy 4 upgrade fails when a migration cannot complete"
|
||||
|
||||
Executable
+366
@@ -0,0 +1,366 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const fs = require('fs')
|
||||
|
||||
const utilQml = fs.readFileSync(path.join(root, 'shell/Commons/Util.qml'), 'utf8')
|
||||
const mediaQml = fs.readFileSync(path.join(root, 'shell/Ui/BackgroundMedia.qml'), 'utf8')
|
||||
const videoQml = fs.readFileSync(path.join(root, 'shell/Ui/BackgroundVideo.qml'), 'utf8')
|
||||
const backgroundQml = fs.readFileSync(path.join(root, 'shell/plugins/background/Background.qml'), 'utf8')
|
||||
const lockQml = fs.readFileSync(path.join(root, 'shell/plugins/lock/LockView.qml'), 'utf8')
|
||||
const themeSwitcher = fs.readFileSync(path.join(root, 'bin/omarchy-theme-switcher'), 'utf8')
|
||||
const quattroUpgrade = fs.readFileSync(path.join(root, 'bin/omarchy-upgrade-to-quattro'), 'utf8')
|
||||
const multimediaMigration = fs.readFileSync(path.join(root, 'migrations/1786609204.sh'), 'utf8')
|
||||
const barTextColor = fs.readFileSync(path.join(root, 'bin/omarchy-bar-text-color'), 'utf8')
|
||||
const menuImages = fs.readFileSync(path.join(root, 'bin/omarchy-menu-images'), 'utf8')
|
||||
const lockView = fs.readFileSync(path.join(root, 'shell/plugins/lock/LockView.qml'), 'utf8')
|
||||
const lockService = fs.readFileSync(path.join(root, 'shell/plugins/lock/Service.qml'), 'utf8')
|
||||
const batteryService = fs.readFileSync(path.join(root, 'shell/plugins/services/battery/Service.qml'), 'utf8')
|
||||
const themeSet = fs.readFileSync(path.join(root, 'bin/omarchy-theme-set'), 'utf8')
|
||||
const directImageList = fs.readFileSync(path.join(root, 'shell/plugins/image-picker/list.sh'), 'utf8')
|
||||
|
||||
assert(
|
||||
/function isVideoPath\(path\)[\s\S]*\.test\(String\(path \|\| ""\)\)/.test(utilQml) &&
|
||||
!utilQml.includes('split(/[?#]/)'),
|
||||
'shared media helper identifies video paths without truncating valid local names'
|
||||
)
|
||||
assert(
|
||||
videoQml.includes('loops: MediaPlayer.Infinite') &&
|
||||
videoQml.includes('autoPlay: root.playbackEnabled') &&
|
||||
videoQml.includes('fillMode: VideoOutput.PreserveAspectCrop') &&
|
||||
/imageUrl: path && !Util\.isVideoPath\(path\) \? Util\.fileUrl\(path\) \+ \(version \? "\?v=" \+ version : ""\) : ""/.test(mediaQml) &&
|
||||
/videoUrl: path && Util\.isVideoPath\(path\) \? Util\.fileUrl\(path\) : ""/.test(mediaQml),
|
||||
'background media plays aspect-cropped videos on a loop, and hands each loader only its own kind of file'
|
||||
)
|
||||
assert(
|
||||
videoQml.includes('MediaPlayer.LoadedMedia') &&
|
||||
videoQml.includes('primePauseTimer') &&
|
||||
videoQml.includes('mediaGeneration') &&
|
||||
videoQml.includes('videoSink') &&
|
||||
videoQml.includes('onVideoFrameChanged') &&
|
||||
videoQml.includes('MediaPlayer.BufferedMedia') &&
|
||||
videoQml.includes('mediaStatus !== MediaPlayer.BufferedMedia') &&
|
||||
videoQml.includes('interval: 1000') &&
|
||||
videoQml.includes('interval: 50') &&
|
||||
videoQml.includes('frameReceived') &&
|
||||
videoQml.includes('output.clearOutput()') &&
|
||||
!videoQml.includes('KeepLastFrame') &&
|
||||
/onPlaybackEnabledChanged:[\s\S]*?if \(playbackEnabled\) player\.play\(\)[\s\S]*?else player\.pause\(\)/.test(videoQml) &&
|
||||
videoQml.includes('primingGeneration') &&
|
||||
videoQml.includes('player.play()') &&
|
||||
videoQml.includes('player.pause()'),
|
||||
'paused video sources are primed to display their first frame'
|
||||
)
|
||||
assert(
|
||||
!/^\s*import QtMultimedia/m.test(mediaQml) &&
|
||||
mediaQml.includes('source: "BackgroundVideo.qml"'),
|
||||
'the still-image path never imports QtMultimedia, so image-only sessions do not map it'
|
||||
)
|
||||
assert(
|
||||
!/^\s*Video\s*\{/m.test(videoQml) &&
|
||||
/property bool audioEnabled: false/.test(videoQml) &&
|
||||
/property bool audioEnabled: false/.test(mediaQml) &&
|
||||
/active: root\.audioEnabled && player\.hasAudio/.test(videoQml) &&
|
||||
/audioOutput: audioLoader\.item/.test(videoQml) &&
|
||||
/muted: root\.priming \|\| !root\.playbackEnabled/.test(videoQml) &&
|
||||
/property: "audioEnabled"\s*\n\s*value: root\.audioEnabled/.test(mediaQml) &&
|
||||
/firstScreen: Quickshell\.screens\.length > 0\s*\n\s*&& String\(Quickshell\.screens\[0\]\.name/.test(backgroundQml) &&
|
||||
backgroundQml.includes('audioEnabled: panel.firstScreen') &&
|
||||
!lockQml.includes('audioEnabled'),
|
||||
'a sound track plays from the first monitor only, a silent file builds no audio output, and the lock stays quiet'
|
||||
)
|
||||
assert(
|
||||
/property: "mediaSource"[\s\S]*?when: videoLoader\.item !== null && Util\.isVideoPath\(root\.path\)\s*\n\s*restoreMode: Binding\.RestoreNone/.test(mediaQml) &&
|
||||
!videoQml.includes('Component.onDestruction'),
|
||||
'a player on its way out keeps its source, so nothing is left loading for its destructor to cancel'
|
||||
)
|
||||
assert(
|
||||
!mediaQml.includes('mipmap'),
|
||||
'the shared image path leaves mipmapping off, as the desktop background had it'
|
||||
)
|
||||
assert(
|
||||
/instant \|\| !displayedBackground \|\| isVideo\(path\) \|\| isVideo\(displayedBackground\)[\s\S]*displayedBackground = finalPath/.test(backgroundQml),
|
||||
'video switches bypass the image-only reveal stack and use the durable background path'
|
||||
)
|
||||
assert(backgroundQml.includes('BackgroundMedia {') && lockQml.includes('BackgroundMedia {'), 'desktop and lock screen share video-capable media rendering')
|
||||
assert(
|
||||
lockQml.includes('source: wallpaper.video ? null : wallpaper') &&
|
||||
lockQml.includes('visible: !wallpaper.video') &&
|
||||
lockQml.includes('visible: wallpaper.video'),
|
||||
'lock screen bypasses its image effect for video output'
|
||||
)
|
||||
assert(
|
||||
/sessionObscured:\s*lockActive \|\| screensaverActive/.test(backgroundQml) &&
|
||||
backgroundQml.includes('playbackEnabled: !root.sessionObscured && !root.powerSaverActive && !panel.fullscreenHere') &&
|
||||
backgroundQml.includes('omarchy.lock') &&
|
||||
backgroundQml.includes('omarchy.idle') &&
|
||||
backgroundQml.includes('omarchy.battery'),
|
||||
'desktop playback stops while covered or on battery power-saver'
|
||||
)
|
||||
assert(
|
||||
backgroundQml.includes('Hyprland.monitorFor(modelData)') &&
|
||||
/fullscreenHere: visibleWorkspace \? visibleWorkspace\.hasFullscreen : false/.test(backgroundQml) &&
|
||||
!backgroundQml.includes('ToplevelManager.activeToplevel'),
|
||||
'a fullscreen window pauses only the output it covers, wherever focus is'
|
||||
)
|
||||
assert(
|
||||
/if \(displayedBackground === finalPath\) displayedReloads \+= 1/.test(backgroundQml) &&
|
||||
backgroundQml.includes('reloads: root.displayedReloads') &&
|
||||
/active: root\.path !== "" && root\.video && !root\.reloading/.test(mediaQml) &&
|
||||
/onReloadsChanged: \{[\s\S]*?reloading = true/.test(mediaQml),
|
||||
'a theme switch that keeps the video path still reopens the replaced file'
|
||||
)
|
||||
assert(
|
||||
barTextColor.includes('magick "$background_path[0]"'),
|
||||
'bar colour sampling reads one frame instead of decoding a whole video'
|
||||
)
|
||||
assert(
|
||||
menuImages.includes('pending_video_file') && /video_jobs=\$\(\( \$\(nproc\) \/ 4 \)\)/.test(menuImages),
|
||||
'video thumbnails fan out narrower than single-threaded vips jobs'
|
||||
)
|
||||
assert(
|
||||
themeSwitcher.includes('fast_signature="v2"'),
|
||||
'the theme preview cache rebuilds after preview discovery learned about video'
|
||||
)
|
||||
assert(
|
||||
/lazy_thumbnails == true && \$cache_only != true \]\] && ! is_video_path/.test(menuImages),
|
||||
'a video never stands in as its own lazy thumbnail, so the fan out cap always applies'
|
||||
)
|
||||
assert(
|
||||
/thumbnail_command=\(timeout -k \d+ \d+ ffmpegthumbnailer/.test(menuImages),
|
||||
'a stalled video cannot hold the picker shut, because its generator is time bounded'
|
||||
)
|
||||
assert(
|
||||
directImageList.includes('generate_video_thumbnail') &&
|
||||
/timeout -k \d+ \d+ ffmpegthumbnailer/.test(directImageList) &&
|
||||
/if \[\[ ! -f \$thumbnail \]\] && ! is_video_path/.test(directImageList),
|
||||
'a direct picker scan generates bounded video thumbnails without content-hashing the media'
|
||||
)
|
||||
assert(
|
||||
themeSet.includes('choose_staged_theme_background') &&
|
||||
themeSet.includes('background_transition_uses_snapshots') &&
|
||||
themeSet.includes('BACKGROUND_TRANSITION_SNAPSHOTS=false') &&
|
||||
/if \[\[ \$BACKGROUND_TRANSITION_SNAPSHOTS == "true" \]\]/.test(themeSet) &&
|
||||
/if \[\[ -z \$CHOSEN_THEME_BACKGROUND \|\| ! -f \$CHOSEN_THEME_BACKGROUND \]\]/.test(themeSet),
|
||||
'theme changes disable both transition snapshots whenever either side is a video'
|
||||
)
|
||||
assert(
|
||||
/function onScreensChanged\(\) \{[\s\S]*?root\.displaysBlank = false/.test(lockService),
|
||||
'a display coming back gives up the blank state instead of freezing a visible wallpaper'
|
||||
)
|
||||
assert(
|
||||
lockService.includes('function screenBlank(screenName)') &&
|
||||
lockService.includes('function applyMonitorDpms(text)') &&
|
||||
lockService.includes('command: ["hyprctl", "monitors", "-j"]') &&
|
||||
lockService.includes('running: root.locked && root.videoBackground') &&
|
||||
/displaysBlank: root\.screenBlank\(lockSurface\.screen/.test(lockService) &&
|
||||
/function runWake\(\) \{[\s\S]*?root\.monitorDpmsKnown = false/.test(lockService) &&
|
||||
/function runBlank\(\) \{[\s\S]*?root\.monitorDpmsKnown = false/.test(lockService),
|
||||
'a locked video wallpaper follows what each panel actually did, not only what the lock asked for'
|
||||
)
|
||||
assert(
|
||||
lockView.includes('playbackEnabled: root.loadBackground && !root.displaysBlank') &&
|
||||
lockView.includes('&& !root.powerSaverActive') &&
|
||||
/displaysBlank: root\.screenBlank\(/.test(lockService) &&
|
||||
/powerSaverActive: root\.powerSaverActive/.test(lockService) &&
|
||||
/function runBlank\(\) \{\s*\n\s*root\.displaysBlank = true/.test(lockService) &&
|
||||
/function runWake\(\) \{\s*\n\s*root\.displaysBlank = false/.test(lockService),
|
||||
'the lock screen stops playback once displays go dark or power-saver is active'
|
||||
)
|
||||
assert(
|
||||
batteryService.includes('property string activePowerProfile') &&
|
||||
batteryService.includes('UPower.onBattery && activePowerProfile === "power-saver"') &&
|
||||
batteryService.includes('["powerprofilesctl", "get"]') &&
|
||||
batteryService.includes('interval: 2000'),
|
||||
'the battery service tracks the active power-saver profile'
|
||||
)
|
||||
assert(
|
||||
themeSwitcher.includes("-iname '*.mp4'") &&
|
||||
themeSwitcher.includes('mp4 m4v mov webm mkv avi') &&
|
||||
themeSwitcher.includes('preview.mp4'),
|
||||
'theme switcher previews video-only themes, named preview files included'
|
||||
)
|
||||
assert(quattroUpgrade.includes("-iname '*.mp4'"), 'Quattro upgrade can seed a video-only theme background')
|
||||
assert(
|
||||
multimediaMigration.includes('omarchy-pkg-add qt6-multimedia qt6-multimedia-ffmpeg'),
|
||||
'existing Quattro installations receive video playback dependencies'
|
||||
)
|
||||
JS
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
mkdir -p "$test_tmp/bin" "$test_tmp/backgrounds" "$test_tmp/generator-cache" "$test_tmp/direct-cache"
|
||||
printf 'not a real video\n' >"$test_tmp/backgrounds/sample.mp4"
|
||||
|
||||
cat >"$test_tmp/bin/ffmpegthumbnailer" <<'SH'
|
||||
#!/bin/bash
|
||||
while (( $# > 0 )); do
|
||||
case "$1" in
|
||||
-i) input=$2; shift 2 ;;
|
||||
-o) output=$2; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
printf 'thumbnail for %s\n' "$input" >"$output"
|
||||
SH
|
||||
chmod +x "$test_tmp/bin/ffmpegthumbnailer"
|
||||
|
||||
cat >"$test_tmp/bin/md5sum" <<'SH'
|
||||
#!/bin/bash
|
||||
if (( $# > 0 )); then
|
||||
printf 'unexpected file hash: %s\n' "$*" >>"$MD5_FILE_CALLS"
|
||||
fi
|
||||
exec /usr/bin/md5sum "$@"
|
||||
SH
|
||||
chmod +x "$test_tmp/bin/md5sum"
|
||||
|
||||
md5_file_calls="$test_tmp/md5-file-calls"
|
||||
PATH="$test_tmp/bin:$PATH" XDG_CACHE_HOME="$test_tmp/generator-cache" MD5_FILE_CALLS="$md5_file_calls" \
|
||||
"$ROOT/bin/omarchy-menu-images" --prepare-only "$test_tmp/backgrounds"
|
||||
|
||||
generator_thumbnail=$(find "$test_tmp/generator-cache/omarchy/image-selector" -maxdepth 1 -type f -name '*.jpg' -print -quit)
|
||||
[[ -s $generator_thumbnail ]] || fail "menu image generator creates a video thumbnail"
|
||||
|
||||
generator_row=$(XDG_CACHE_HOME="$test_tmp/generator-cache" "$ROOT/shell/plugins/image-picker/list.sh" "$test_tmp/backgrounds")
|
||||
IFS=$'\t' read -r generator_row_path generator_row_thumbnail <<<"$generator_row"
|
||||
[[ $generator_row_path == "$test_tmp/backgrounds/sample.mp4" && $generator_row_thumbnail == "$generator_thumbnail" ]] || \
|
||||
fail "direct picker consumes the menu image generator thumbnail" "$generator_row"
|
||||
|
||||
row=$(PATH="$test_tmp/bin:$PATH" XDG_CACHE_HOME="$test_tmp/direct-cache" MD5_FILE_CALLS="$md5_file_calls" \
|
||||
"$ROOT/shell/plugins/image-picker/list.sh" "$test_tmp/backgrounds")
|
||||
|
||||
IFS=$'\t' read -r row_path row_thumbnail <<<"$row"
|
||||
[[ $row_path == "$test_tmp/backgrounds/sample.mp4" && $row_thumbnail == *.jpg && -s $row_thumbnail ]] || \
|
||||
fail "image picker lists videos with their cached thumbnail" "$row"
|
||||
[[ ! -s $md5_file_calls ]] || fail "direct video scans avoid hashing the complete media file" "$(<"$md5_file_calls")"
|
||||
|
||||
failed_backgrounds="$test_tmp/failed-backgrounds"
|
||||
failed_cache="$test_tmp/failed-cache"
|
||||
mkdir -p "$failed_backgrounds" "$failed_cache"
|
||||
printf 'broken video\n' >"$failed_backgrounds/broken.mp4"
|
||||
cat >"$test_tmp/bin/ffmpegthumbnailer" <<'SH'
|
||||
#!/bin/bash
|
||||
exit 1
|
||||
SH
|
||||
|
||||
cached_row=$(PATH="$test_tmp/bin:$PATH" XDG_CACHE_HOME="$test_tmp/direct-cache" MD5_FILE_CALLS="$md5_file_calls" \
|
||||
"$ROOT/shell/plugins/image-picker/list.sh" "$test_tmp/backgrounds")
|
||||
[[ $cached_row == "$row" ]] || fail "direct picker reuses its cached video thumbnail" "$cached_row"
|
||||
|
||||
failed_rows=$(PATH="$test_tmp/bin:$PATH" XDG_CACHE_HOME="$failed_cache" MD5_FILE_CALLS="$md5_file_calls" \
|
||||
"$ROOT/shell/plugins/image-picker/list.sh" "$failed_backgrounds")
|
||||
[[ -z $failed_rows ]] || fail "direct picker omits a video whose thumbnail fails" "$failed_rows"
|
||||
|
||||
failed_marker=$(find "$failed_cache/omarchy/image-selector" -maxdepth 1 -type f -name '*.failed' -print -quit)
|
||||
[[ -n $failed_marker ]] || fail "direct picker remembers a video the converter rejected"
|
||||
|
||||
thumbnailer_calls="$test_tmp/thumbnailer-calls"
|
||||
cat >"$test_tmp/bin/ffmpegthumbnailer" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$*" >>"$THUMBNAILER_CALLS"
|
||||
exit 1
|
||||
SH
|
||||
|
||||
failed_rows=$(PATH="$test_tmp/bin:$PATH" XDG_CACHE_HOME="$failed_cache" THUMBNAILER_CALLS="$thumbnailer_calls" \
|
||||
"$ROOT/shell/plugins/image-picker/list.sh" "$failed_backgrounds")
|
||||
[[ -z $failed_rows && ! -e $thumbnailer_calls ]] || fail "direct picker skips a rejected video on the next scan" "$(cat "$thumbnailer_calls" 2>/dev/null)"
|
||||
|
||||
generator_failed_cache="$test_tmp/generator-failed-cache"
|
||||
PATH="$test_tmp/bin:$PATH" XDG_CACHE_HOME="$generator_failed_cache" THUMBNAILER_CALLS="$thumbnailer_calls" \
|
||||
"$ROOT/bin/omarchy-menu-images" --prepare-only "$failed_backgrounds"
|
||||
[[ -s $thumbnailer_calls ]] || fail "menu image generator tries a video it has not seen"
|
||||
generator_marker=$(find "$generator_failed_cache/omarchy/image-selector" -maxdepth 1 -type f -name '*.failed' -print -quit)
|
||||
[[ -n $generator_marker ]] || fail "menu image generator remembers a video the converter rejected"
|
||||
rm -f "$thumbnailer_calls"
|
||||
PATH="$test_tmp/bin:$PATH" XDG_CACHE_HOME="$generator_failed_cache" THUMBNAILER_CALLS="$thumbnailer_calls" \
|
||||
"$ROOT/bin/omarchy-menu-images" --prepare-only "$failed_backgrounds"
|
||||
[[ ! -e $thumbnailer_calls ]] || fail "menu image generator skips a rejected video on the next open" "$(<"$thumbnailer_calls")"
|
||||
|
||||
# A repaired file gets a fresh key, so the old marker no longer applies, and
|
||||
# the rows are not cached over its absence, so an in-place repair that leaves
|
||||
# the directory's mtime alone is still noticed.
|
||||
touch -d '2 minutes' "$failed_backgrounds/broken.mp4"
|
||||
PATH="$test_tmp/bin:$PATH" XDG_CACHE_HOME="$generator_failed_cache" THUMBNAILER_CALLS="$thumbnailer_calls" \
|
||||
"$ROOT/bin/omarchy-menu-images" --prepare-only "$failed_backgrounds"
|
||||
[[ -s $thumbnailer_calls ]] || fail "menu image generator retries a video that changed since it was rejected"
|
||||
|
||||
timeout_backgrounds="$test_tmp/timeout-backgrounds"
|
||||
timeout_cache="$test_tmp/timeout-cache"
|
||||
mkdir -p "$timeout_backgrounds"
|
||||
printf 'slow video\n' >"$timeout_backgrounds/slow.mp4"
|
||||
cat >"$test_tmp/bin/ffmpegthumbnailer" <<'SH'
|
||||
#!/bin/bash
|
||||
exit 124
|
||||
SH
|
||||
timeout_rows=$(PATH="$test_tmp/bin:$PATH" XDG_CACHE_HOME="$timeout_cache" \
|
||||
"$ROOT/shell/plugins/image-picker/list.sh" "$timeout_backgrounds")
|
||||
[[ -z $timeout_rows ]] || fail "direct picker omits a video whose thumbnail timed out" "$timeout_rows"
|
||||
timeout_marker=$(find "$timeout_cache/omarchy/image-selector" -maxdepth 1 -type f -name '*.failed' -print -quit)
|
||||
[[ -z $timeout_marker ]] || fail "a timed out video is left to retry rather than remembered as failed"
|
||||
|
||||
grep -qx 'qt6-multimedia' "$ROOT/install/omarchy-base.packages" || fail "Qt Multimedia runtime is a base package"
|
||||
grep -qx 'qt6-multimedia-ffmpeg' "$ROOT/install/omarchy-base.packages" || fail "Qt Multimedia FFmpeg backend is a base package"
|
||||
|
||||
pass "menu image generator creates thumbnails consumed by the picker"
|
||||
pass "direct picker generates and reuses still thumbnails"
|
||||
pass "direct picker omits videos whose thumbnails cannot be generated"
|
||||
pass "a rejected video is remembered so it costs nothing on the next open"
|
||||
pass "a timed out video is left to retry"
|
||||
pass "a locked video wallpaper follows the panels' real DPMS state"
|
||||
pass "Qt Multimedia playback dependencies are declared"
|
||||
|
||||
source <(awk '
|
||||
/^(is_video_path|snapshot_background_path|background_transition_uses_snapshots|choose_theme_background|choose_staged_theme_background|set_theme_background)\(\) \{/ { copying=1 }
|
||||
copying { print }
|
||||
copying && /^}$/ { copying=0 }
|
||||
' "$ROOT/bin/omarchy-theme-set")
|
||||
|
||||
transition_home="$test_tmp/transition-home"
|
||||
CURRENT_THEME_PATH="$transition_home/.local/state/omarchy/current/theme"
|
||||
NEXT_THEME_PATH="$transition_home/.local/state/omarchy/current/next-theme"
|
||||
CURRENT_BACKGROUND_LINK="$transition_home/.local/state/omarchy/current/background"
|
||||
BACKGROUND_TRANSITION_CACHE="$transition_home/.cache/omarchy/background-transitions"
|
||||
THEME_NAME="video-test"
|
||||
HOME="$transition_home"
|
||||
mkdir -p "$CURRENT_THEME_PATH/backgrounds" "$NEXT_THEME_PATH/backgrounds" "$HOME/.config/omarchy/backgrounds/$THEME_NAME"
|
||||
printf 'old image\n' >"$CURRENT_THEME_PATH/backgrounds/old.png"
|
||||
printf 'old image staged\n' >"$NEXT_THEME_PATH/backgrounds/old.png"
|
||||
printf 'new video\n' >"$NEXT_THEME_PATH/backgrounds/new.mp4"
|
||||
ln -s "$CURRENT_THEME_PATH/backgrounds/old.png" "$CURRENT_BACKGROUND_LINK"
|
||||
|
||||
choose_staged_theme_background || fail "staged video background is selected before the theme swap"
|
||||
expected_staged_background="$CURRENT_THEME_PATH/backgrounds/new.mp4"
|
||||
[[ $CHOSEN_THEME_BACKGROUND == $expected_staged_background ]] || \
|
||||
fail "staged background resolves to its durable post-swap path" "$CHOSEN_THEME_BACKGROUND"
|
||||
if background_transition_uses_snapshots "$CHOSEN_THEME_BACKGROUND"; then
|
||||
fail "image to video theme transitions skip snapshots"
|
||||
fi
|
||||
background_transition_uses_snapshots "$CURRENT_THEME_PATH/backgrounds/new.png" || \
|
||||
fail "image to image theme transitions retain snapshots"
|
||||
|
||||
rm "$CURRENT_BACKGROUND_LINK"
|
||||
printf 'old video\n' >"$CURRENT_THEME_PATH/backgrounds/old.mp4"
|
||||
ln -s "$CURRENT_THEME_PATH/backgrounds/old.mp4" "$CURRENT_BACKGROUND_LINK"
|
||||
if background_transition_uses_snapshots "$CURRENT_THEME_PATH/backgrounds/new.png"; then
|
||||
fail "video to image theme transitions skip snapshots"
|
||||
fi
|
||||
|
||||
video_snapshot=$(snapshot_background_path "$CURRENT_THEME_PATH/backgrounds/old.mp4" "video")
|
||||
[[ -z $video_snapshot && ! -e $BACKGROUND_TRANSITION_CACHE ]] || fail "video files are never snapshotted"
|
||||
|
||||
CHOSEN_THEME_BACKGROUND="$transition_home/disappeared.mp4"
|
||||
BACKGROUND_TRANSITION_SNAPSHOTS=false
|
||||
OLD_BACKGROUND_SNAPSHOT=""
|
||||
colors_payload=""
|
||||
shell_payload=""
|
||||
shell_ipc() { :; }
|
||||
set_theme_background
|
||||
[[ -f $CHOSEN_THEME_BACKGROUND && $(readlink "$CURRENT_BACKGROUND_LINK") == "$CHOSEN_THEME_BACKGROUND" ]] || \
|
||||
fail "theme changes recover when a preselected background disappears" "$CHOSEN_THEME_BACKGROUND"
|
||||
|
||||
pass "theme transitions skip snapshots whenever either side is a video"
|
||||
pass "theme changes recover from a missing preselected background"
|
||||
@@ -1,104 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh"
|
||||
|
||||
migration=$(grep -rl 'Move zram tuning to a vendor drop-in' "$ROOT/migrations" | head -n 1 || true)
|
||||
[[ -n $migration ]] || fail "zram drop-in migration exists"
|
||||
|
||||
TMPDIR=$(mktemp -d)
|
||||
trap 'rm -rf "$TMPDIR"' EXIT
|
||||
|
||||
# The migration shells out to pacman (ownership check) and sudo (removal).
|
||||
# Stub both so the test never touches the real system, and let each case pick
|
||||
# what `pacman -Qo` reports through PACMAN_OWNS.
|
||||
stub_bin="$TMPDIR/bin"
|
||||
mkdir -p "$stub_bin"
|
||||
|
||||
cat >"$stub_bin/pacman" <<'STUB'
|
||||
#!/bin/bash
|
||||
[[ ${PACMAN_OWNS:-0} == 1 ]]
|
||||
STUB
|
||||
|
||||
cat >"$stub_bin/sudo" <<'STUB'
|
||||
#!/bin/bash
|
||||
exec "$@"
|
||||
STUB
|
||||
|
||||
chmod +x "$stub_bin/pacman" "$stub_bin/sudo"
|
||||
|
||||
# The migration removes the /etc copy only once the drop-in that replaces it is
|
||||
# installed. Point that at a fixture so the result does not depend on whether
|
||||
# the machine running the tests happens to carry the real one.
|
||||
dropin="$TMPDIR/90-omarchy.conf"
|
||||
: >"$dropin"
|
||||
|
||||
# omarchy-migrate runs each migration with `bash -euo pipefail` and stops the
|
||||
# whole chain on a non-zero exit, so match that invocation exactly.
|
||||
run_migration() {
|
||||
local conf="$1"
|
||||
PATH="$stub_bin:$PATH" OMARCHY_ZRAM_CONF="$conf" OMARCHY_ZRAM_DROPIN="$dropin" \
|
||||
bash -euo pipefail "$migration" >/dev/null ||
|
||||
fail "migration exits clean for $(basename "$conf")"
|
||||
}
|
||||
|
||||
# archinstall's own output: a [zram0] section with nothing but the algorithm.
|
||||
conf="$TMPDIR/archinstall.conf"
|
||||
printf '[zram0]\ncompression-algorithm = zstd\n' >"$conf"
|
||||
run_migration "$conf"
|
||||
[[ -f $conf ]] && fail "migration removes archinstall's generated config"
|
||||
pass "migration removes archinstall's generated config"
|
||||
|
||||
# Same shape, different algorithm, plus comments and blank lines.
|
||||
conf="$TMPDIR/commented.conf"
|
||||
printf '# written by archinstall\n\n[zram0]\ncompression-algorithm = lz4\n\n' >"$conf"
|
||||
run_migration "$conf"
|
||||
[[ -f $conf ]] && fail "migration ignores comments and a non-zstd algorithm"
|
||||
pass "migration ignores comments and a non-zstd algorithm"
|
||||
|
||||
# A config that sets nothing decides nothing, and must not take the migration
|
||||
# chain down with it.
|
||||
conf="$TMPDIR/comments-only.conf"
|
||||
printf '# nothing to see here\n\n' >"$conf"
|
||||
run_migration "$conf"
|
||||
[[ -f $conf ]] && fail "migration removes a config that sets nothing"
|
||||
pass "migration removes a config that sets nothing"
|
||||
|
||||
conf="$TMPDIR/empty.conf"
|
||||
: >"$conf"
|
||||
run_migration "$conf"
|
||||
[[ -f $conf ]] && fail "migration removes an empty config"
|
||||
pass "migration removes an empty config"
|
||||
|
||||
# A local override must survive.
|
||||
conf="$TMPDIR/local.conf"
|
||||
printf '[zram0]\ncompression-algorithm = zstd\nzram-size = ram / 4\n' >"$conf"
|
||||
run_migration "$conf"
|
||||
[[ -f $conf ]] || fail "migration keeps a locally edited config"
|
||||
pass "migration keeps a locally edited config"
|
||||
|
||||
# Package-owned copies go away with their package; the migration must not touch
|
||||
# them.
|
||||
conf="$TMPDIR/owned.conf"
|
||||
printf '[zram0]\ncompression-algorithm = zstd\n' >"$conf"
|
||||
PATH="$stub_bin:$PATH" PACMAN_OWNS=1 OMARCHY_ZRAM_CONF="$conf" OMARCHY_ZRAM_DROPIN="$dropin" \
|
||||
bash -euo pipefail "$migration" >/dev/null ||
|
||||
fail "migration exits clean for a package-owned config"
|
||||
[[ -f $conf ]] || fail "migration keeps a package-owned config"
|
||||
pass "migration keeps a package-owned config"
|
||||
|
||||
# Nothing to do, and running twice must stay clean.
|
||||
conf="$TMPDIR/absent.conf"
|
||||
run_migration "$conf"
|
||||
run_migration "$conf"
|
||||
pass "migration no-ops when the config is already gone"
|
||||
|
||||
# Without the drop-in installed, the /etc copy is the only thing configuring
|
||||
# zram at all. Removing it would leave the machine with no zram device, so the
|
||||
# migration has to leave it alone and stay clean doing it.
|
||||
conf="$TMPDIR/no-dropin.conf"
|
||||
printf '[zram0]\ncompression-algorithm = zstd\n' >"$conf"
|
||||
PATH="$stub_bin:$PATH" OMARCHY_ZRAM_CONF="$conf" OMARCHY_ZRAM_DROPIN="$TMPDIR/absent-dropin.conf" \
|
||||
bash -euo pipefail "$migration" >/dev/null ||
|
||||
fail "migration exits clean when the drop-in is missing"
|
||||
[[ -f $conf ]] || fail "migration keeps the config when the drop-in is missing"
|
||||
pass "migration keeps the config until the drop-in is installed"
|
||||
Reference in new issue
Block a user