Merge quattro and preserve per-theme wallpaper memory
This commit is contained in:
commit
0385610cfe
582 files changed
+33732
-3163
No files matched your search
@@ -18,10 +18,12 @@ mkdir -p "$(dirname "$hook_path")"
|
||||
cat >"$test_bin/omarchy-notification-send" <<'EOF'
|
||||
#!/bin/bash
|
||||
echo notification >>"$TEST_LOG"
|
||||
exec_args=()
|
||||
while (($# > 0)); do
|
||||
[[ $1 == "--exec" ]] && echo "exec:$2" >>"$TEST_LOG"
|
||||
if [[ $1 == "--exec" ]]; then shift; exec_args=("$@"); break; fi
|
||||
shift
|
||||
done
|
||||
((${#exec_args[@]})) && echo "exec:${exec_args[*]}" >>"$TEST_LOG"
|
||||
EOF
|
||||
chmod +x "$test_bin/omarchy-notification-send"
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ mkdir -p "$TEST_HOME/.codex/sessions/$(date +%Y/%m/%d)" "$TEST_HOME/bin"
|
||||
cat >"$TEST_HOME/bin/codex" <<'EOF'
|
||||
#!/bin/bash
|
||||
|
||||
if [[ -n ${CODEX_ARGS_FILE:-} ]]; then
|
||||
printf '%s\0' "$@" >"$CODEX_ARGS_FILE"
|
||||
fi
|
||||
|
||||
while read -r request; do
|
||||
id=$(jq -r '.id // empty' <<<"$request")
|
||||
method=$(jq -r '.method // empty' <<<"$request")
|
||||
@@ -40,9 +44,18 @@ cat >"$session" <<EOF
|
||||
{"timestamp":"$timestamp","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":180,"cached_input_tokens":110,"output_tokens":30,"reasoning_output_tokens":8,"total_tokens":210},"last_token_usage":{"input_tokens":80,"cached_input_tokens":50,"output_tokens":10,"reasoning_output_tokens":3,"total_tokens":90}}}}
|
||||
EOF
|
||||
|
||||
result=$(HOME="$TEST_HOME" CODEX_HOME="$TEST_HOME/.codex" XDG_DATA_HOME="$TEST_HOME/.local/share" PATH="$TEST_HOME/bin:$PATH" \
|
||||
result=$(HOME="$TEST_HOME" CODEX_HOME="$TEST_HOME/.codex" CODEX_ARGS_FILE="$TEST_HOME/codex-args" XDG_DATA_HOME="$TEST_HOME/.local/share" PATH="$TEST_HOME/bin:$PATH" \
|
||||
"$ROOT/bin/omarchy-agent-usage-codex")
|
||||
|
||||
# NUL-separated, so the assertion sees argument boundaries: a single "-a on-request"
|
||||
# would flatten to the same text as two arguments but is not a policy codex accepts.
|
||||
expected_args=(-s read-only -a on-request app-server)
|
||||
mapfile -d '' -t codex_args <"$TEST_HOME/codex-args"
|
||||
|
||||
[[ ${codex_args[*]@Q} == "${expected_args[*]@Q}" ]] ||
|
||||
fail "Codex collector uses the supported approval policy" "${codex_args[*]@Q}"
|
||||
pass "Codex collector uses the supported approval policy"
|
||||
|
||||
[[ $(jq -r '.todayTotalTokens' <<<"$result") == "210" ]] ||
|
||||
fail "Codex collector counts each turn once" "$result"
|
||||
pass "Codex collector counts each turn once"
|
||||
|
||||
@@ -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
|
||||
skip "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"
|
||||
Executable
+147
@@ -0,0 +1,147 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
export PATH="$ROOT/bin:$PATH"
|
||||
|
||||
columns() {
|
||||
awk 'NR == 1 { print length($0) }'
|
||||
}
|
||||
|
||||
# The wordmark FIGlet itself draws for this font, so a change to the embedded
|
||||
# font or to the kerning shows up here rather than in someone's terminal.
|
||||
expected=$(
|
||||
cat <<'WORDMARK'
|
||||
▄██████▄ ▄▄▄▄███▄▄▄▄ ▄████████ ▄████████ ▄████████ ▄█ █▄ ▄██ ▄
|
||||
███ ███ ▄██▀▀▀███▀▀▀██▄ ███ ███ ███ ███ ███ ███ ███ ███ ███ ██▄
|
||||
███ ███ ███ ███ ███ ███ ███ ███ ███ ███ █▀ ███ ███ ███▄▄▄███
|
||||
███ ███ ███ ███ ███ ███ ███ ▄███▄▄▄▄██▀ ███ ▄███▄▄▄▄███▄▄ ▀▀▀▀▀▀███
|
||||
███ ███ ███ ███ ███ ▀███████████ ▀▀███▀▀▀▀▀ ███ ▀▀███▀▀▀▀███▀ ▄██ ███
|
||||
███ ███ ███ ███ ███ ███ ███ ▀███████████ ███ █▄ ███ ███ ███ ███
|
||||
███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███
|
||||
▀██████▀ ▀█ ███ █▀ ███ █▀ ███ ███ ████████▀ ███ █▀ ▀█████▀
|
||||
███ ███
|
||||
WORDMARK
|
||||
)
|
||||
|
||||
output=$(omarchy-ascii Omarchy)
|
||||
[[ $output == "$expected" ]] || fail "the wordmark matches the reference rendering" "expected:
|
||||
$expected
|
||||
actual:
|
||||
$output"
|
||||
pass "the wordmark matches the reference rendering"
|
||||
|
||||
# figlet.js and figlet.c disagree about a first M: the C one trims the column of
|
||||
# blanks every row of that glyph shares, the JavaScript one keeps it. asciiart.eu
|
||||
# runs figlet.js, so this pins the leading blanks rather than leaving the next
|
||||
# reader to "fix" them.
|
||||
expected_m=$(
|
||||
cat <<'LETTER_M'
|
||||
▄▄▄▄███▄▄▄▄
|
||||
▄██▀▀▀███▀▀▀██▄
|
||||
███ ███ ███
|
||||
███ ███ ███
|
||||
███ ███ ███
|
||||
███ ███ ███
|
||||
███ ███ ███
|
||||
▀█ ███ █▀
|
||||
|
||||
LETTER_M
|
||||
)
|
||||
|
||||
output=$(omarchy-ascii M)
|
||||
[[ $output == "$expected_m" ]] || fail "a leading M keeps the blanks figlet.js gives it" "expected:
|
||||
$expected_m
|
||||
actual:
|
||||
$output"
|
||||
pass "a leading M keeps the blanks figlet.js gives it"
|
||||
|
||||
output=$(printf 'Omarchy' | omarchy-ascii)
|
||||
[[ $output == "$expected" ]] || fail "text can arrive on stdin"
|
||||
pass "text can arrive on stdin"
|
||||
|
||||
# The route is the way a user reaches this, and it dispatches on whether the
|
||||
# metadata says an argument is required -- so a piped run has to be exercised
|
||||
# through `omarchy` itself, not just through the binary.
|
||||
output=$(printf 'Omarchy' | omarchy ascii)
|
||||
[[ $output == "$expected" ]] || fail "piped text renders through the omarchy route" "got:
|
||||
$output"
|
||||
pass "piped text renders through the omarchy route"
|
||||
|
||||
# The block characters are three bytes each, so the column arithmetic has to
|
||||
# count columns rather than bytes wherever the locale lands.
|
||||
output=$(LC_ALL=C omarchy-ascii Omarchy)
|
||||
[[ $output == "$expected" ]] || fail "a byte-only locale draws the same wordmark"
|
||||
pass "a byte-only locale draws the same wordmark"
|
||||
|
||||
blanks=$(omarchy-ascii Omarchy | grep -c ' $' || true)
|
||||
[[ $blanks == "0" ]] || fail "no line is padded with trailing blanks" "$blanks lines end in a blank"
|
||||
pass "no line is padded with trailing blanks"
|
||||
|
||||
# The space is a glyph of five hardblank columns, so it is worth exactly five
|
||||
# columns of art -- not merely more than none.
|
||||
tight=$(omarchy-ascii "Hi" | columns)
|
||||
spaced=$(omarchy-ascii "H i" | columns)
|
||||
(( tight == 18 )) || fail "Hi is 18 columns wide" "got $tight"
|
||||
(( spaced == tight + 5 )) || fail "a space between words is five columns" "expected $((tight + 5)), got $spaced"
|
||||
pass "a space between words is five columns"
|
||||
|
||||
rows=$(omarchy-ascii Hi | wc -l)
|
||||
(( rows == 9 )) || fail "a block is nine rows" "got $rows"
|
||||
pass "a block is nine rows"
|
||||
|
||||
# An empty line is a block of its own in figlet, and dropping it would silently
|
||||
# close up the gap someone put there on purpose.
|
||||
rows=$(printf 'A\n\nB\n' | omarchy-ascii | wc -l)
|
||||
(( rows == 27 )) || fail "an empty line still draws its block" "expected 27 rows, got $rows"
|
||||
pass "an empty line still draws its block"
|
||||
|
||||
# awk reads escapes in a variable it is given on the command line, so text has
|
||||
# to reach it as input instead: a backslash is a character the font lacks, not
|
||||
# the start of an escape.
|
||||
rows=$(omarchy-ascii 'A\nB' 2>/dev/null | wc -l)
|
||||
(( rows == 9 )) || fail "a backslash in the text is not an escape" "expected 9 rows, got $rows"
|
||||
pass "a backslash in the text is not an escape"
|
||||
|
||||
# Delta Corps Priest 1 carries letters and spaces only. Dropping the rest in
|
||||
# silence would leave a version number looking like a bug in the renderer.
|
||||
status=0
|
||||
warning=$(omarchy-ascii "Omarchy 4.0" 2>&1 >/dev/null) || status=$?
|
||||
(( status == 0 )) || fail "text with unusable characters still draws" "exited $status"
|
||||
[[ $warning == *"Skipped"* && $warning == *"4"* && $warning == *"."* && $warning == *"0"* ]] ||
|
||||
fail "skipped characters are named on stderr" "got: $warning"
|
||||
pass "skipped characters are named on stderr"
|
||||
|
||||
output=$(omarchy-ascii "Omarchy 4.0" 2>/dev/null)
|
||||
[[ $output == "$expected" ]] || fail "the drawable characters still render"
|
||||
pass "the drawable characters still render"
|
||||
|
||||
# Naming a skipped control character by writing it out would send it to the
|
||||
# terminal as a control character.
|
||||
warning=$(printf 'A\001B\n' | omarchy-ascii 2>&1 >/dev/null)
|
||||
[[ $warning == *'\x01'* ]] || fail "a skipped control character is named by code" "got: $warning"
|
||||
[[ $warning != *$'\001'* ]] || fail "a skipped control character is not written out raw"
|
||||
pass "a skipped control character is named by code"
|
||||
|
||||
status=0
|
||||
output=$(omarchy-ascii "4.0" 2>/dev/null) || status=$?
|
||||
(( status == 1 )) || fail "text the font cannot draw at all fails" "exited $status"
|
||||
[[ -z $output ]] || fail "text the font cannot draw at all prints nothing"
|
||||
pass "text the font cannot draw at all fails"
|
||||
|
||||
# A mistyped option would otherwise be drawn as art, silently.
|
||||
status=0
|
||||
output=$(omarchy-ascii --width 40 2>&1 >/dev/null) || status=$?
|
||||
(( status == 1 )) || fail "an unknown option is refused" "exited $status"
|
||||
[[ $output == *"Unknown option: --width"* ]] || fail "an unknown option is named" "got: $output"
|
||||
pass "an unknown option is refused"
|
||||
|
||||
output=$(omarchy-ascii -- Hi | columns)
|
||||
(( output == 18 )) || fail "text after -- is still text" "got $output columns"
|
||||
pass "text after -- is still text"
|
||||
|
||||
output=$(omarchy-ascii --help)
|
||||
[[ $output == *"Usage: omarchy-ascii"* ]] || fail "help renders"
|
||||
pass "help renders"
|
||||
@@ -7,7 +7,7 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
require_compositor "bar icon geometry test"
|
||||
|
||||
if ! command -v quickshell >/dev/null 2>&1; then
|
||||
pass "quickshell not installed; skipping bar icon geometry test"
|
||||
skip "quickshell not installed; skipping bar icon geometry test"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -21,7 +21,7 @@ trap cleanup EXIT
|
||||
require_compositor "bar widget contract test"
|
||||
|
||||
if ! command -v quickshell >/dev/null 2>&1; then
|
||||
pass "quickshell not installed; skipping bar widget contract test"
|
||||
skip "quickshell not installed; skipping bar widget contract test"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
@@ -14,6 +14,10 @@ pass() {
|
||||
printf 'ok - %s\n' "$1"
|
||||
}
|
||||
|
||||
skip() {
|
||||
printf 'ok - %s # SKIP\n' "$1"
|
||||
}
|
||||
|
||||
fail() {
|
||||
local description="$1"
|
||||
local detail="${2:-}"
|
||||
@@ -72,7 +76,7 @@ require_compositor() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
pass "no Wayland compositor; skipping $description"
|
||||
skip "no Wayland compositor; skipping $description"
|
||||
exit 0
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ raw_command_checks=$(rg -l 'command -v' "$ROOT/bin" \
|
||||
[[ -z $raw_command_checks ]] || fail "bin commands use command helpers" "$raw_command_checks"
|
||||
pass "bin commands use command helpers"
|
||||
|
||||
raw_notifications=$(rg -l -P '^[[:space:]]*[^#[:space:]].*\bnotify-send\b' "$ROOT/bin" \
|
||||
| rg -v '/omarchy-notification-send$' || true)
|
||||
[[ -z $raw_notifications ]] || fail "bin commands use the notification helper" "$raw_notifications"
|
||||
raw_notifications=$(rg -l -P '^[[:space:]]*[^#[:space:]].*\bnotify-send\b' "$ROOT/bin" || true)
|
||||
[[ -z $raw_notifications ]] || fail "bin commands use the notification helper, never notify-send" "$raw_notifications"
|
||||
pass "bin commands use the notification helper"
|
||||
@@ -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"
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
#!/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
|
||||
|
||||
animation="$ROOT/bin/omarchy-branding-about-animation"
|
||||
source "$animation"
|
||||
|
||||
logo="$tmp_dir/logo.txt"
|
||||
write_logo() { printf '%s\n' "$@" >"$logo"; }
|
||||
base=$'\e[0m\e[1m\e[32m'
|
||||
top=3 left=3 columns=120
|
||||
|
||||
build() { sheen_build "$logo" "$top" "$left" "$base" "$columns"; }
|
||||
refuses() {
|
||||
if build; then
|
||||
fail "$1"
|
||||
else
|
||||
pass "$1"
|
||||
fi
|
||||
}
|
||||
|
||||
write_logo '████████████████████████' '████████ ████████' '████████████████████████'
|
||||
build || fail "a logo of plain block art animates"
|
||||
pass "a logo of plain block art animates"
|
||||
|
||||
mapfile -t expected <"$logo"
|
||||
(( ${#SHEEN_FRAMES[@]} > ${#expected[0]} )) || fail "the glint takes more frames than the logo is wide" "${#SHEEN_FRAMES[@]}"
|
||||
pass "the glint takes more frames than the logo is wide"
|
||||
|
||||
# Every frame is the same logo in different colours. A frame that changed a
|
||||
# character, reached past the logo, or lit more than the band is wide would be
|
||||
# drawing over whatever the caller put beside it, and nothing on screen would say so.
|
||||
esc=$'\e'
|
||||
band_width=$(( SHEEN_HALF * 2 + 1 ))
|
||||
misplaced=0 rewritten=0 overrun=0 washed=0 unbased=0 widest_glint=0 first_lit=0
|
||||
lit_rows=""
|
||||
for index in "${!SHEEN_FRAMES[@]}"; do
|
||||
row=0
|
||||
frame_lit=0
|
||||
while IFS= read -r drawn; do
|
||||
[[ -n $drawn ]] || continue
|
||||
[[ $drawn == "$esc[$((top + row));${left}H"* ]] || misplaced=$((misplaced + 1))
|
||||
|
||||
rest=${drawn#*H}
|
||||
[[ $rest == "$base"* ]] || unbased=$((unbased + 1))
|
||||
rest=${rest#"$base"}
|
||||
head=${rest%%"$SHEEN_BAND"*} && rest=${rest#*"$SHEEN_BAND"}
|
||||
lit=${rest%%"$base"*} && tail=${rest#*"$base"}
|
||||
|
||||
[[ $head$lit$tail == "${expected[row]}" ]] || rewritten=$((rewritten + 1))
|
||||
(( ${#head} + ${#lit} + ${#tail} > ${#expected[row]} )) && overrun=$((overrun + 1))
|
||||
(( ${#lit} > band_width )) && washed=$((washed + 1))
|
||||
if (( ${#lit} > 0 )); then
|
||||
lit_rows+="$row "
|
||||
frame_lit=$((frame_lit + 1))
|
||||
fi
|
||||
row=$((row + 1))
|
||||
done < <(printf '%s\n' "${SHEEN_FRAMES[index]}" | sed "s/$esc\[[0-9]*;[0-9]*H/\n&/g")
|
||||
(( row == ${#expected[@]} )) || fail "a frame draws every row of the logo" "drew $row of ${#expected[@]}"
|
||||
(( frame_lit > widest_glint )) && widest_glint=$frame_lit
|
||||
(( index == 0 )) && first_lit=$frame_lit
|
||||
done
|
||||
|
||||
(( misplaced == 0 )) || fail "every row is drawn on the cell it was given" "$misplaced misplaced"
|
||||
pass "every row is drawn on the cell it was given"
|
||||
|
||||
(( unbased == 0 )) || fail "every row starts in the colour it was handed" "$unbased rows"
|
||||
pass "every row starts in the colour it was handed"
|
||||
|
||||
(( rewritten == 0 )) || fail "the sheen only recolours the logo, never rewrites it" "$rewritten rows changed"
|
||||
pass "the sheen only recolours the logo, never rewrites it"
|
||||
|
||||
(( overrun == 0 )) || fail "no frame reaches past the logo" "$overrun overruns"
|
||||
pass "no frame reaches past the logo"
|
||||
|
||||
# A band of light leaning across the logo, not a wash over half of it.
|
||||
(( washed == 0 )) || fail "the glint stays a band the whole way across" "$washed rows lit wider than $band_width"
|
||||
pass "the glint stays a band the whole way across"
|
||||
|
||||
for row in "${!expected[@]}"; do
|
||||
[[ " $lit_rows" == *" $row "* ]] || fail "the glint crosses every row of the logo" "row $row is never lit"
|
||||
done
|
||||
pass "the glint crosses every row of the logo"
|
||||
|
||||
(( widest_glint == ${#expected[@]} )) || fail "the glint leans across the whole logo at once" "widest frame lit $widest_glint of ${#expected[@]}"
|
||||
pass "the glint leans across the whole logo at once"
|
||||
|
||||
(( first_lit == 1 )) || fail "a glint arrives from off the logo" "the first frame lights $first_lit rows"
|
||||
pass "a glint arrives from off the logo"
|
||||
|
||||
settled=""
|
||||
for row in "${!expected[@]}"; do
|
||||
settled+="$esc[$((top + row));${left}H$base${expected[row]}$SHEEN_BAND$base"
|
||||
done
|
||||
[[ ${SHEEN_FRAMES[-1]} == "$settled" ]] || fail "a glint settles back to the logo it was given"
|
||||
pass "a glint settles back to the logo it was given"
|
||||
|
||||
# A terminal that renders bold text in bright colours — foot's bold-text-in-bright
|
||||
# does exactly this — maps a bold regular colour to its bright counterpart, so a
|
||||
# band using one of those vanishes into a logo drawn in the matching regular
|
||||
# colour, and nobody sees the animation at all.
|
||||
[[ ! $SHEEN_BAND =~ \[9[0-6]m ]] || fail "the band avoids the colours a bold logo can brighten into" "$(printf '%q' "$SHEEN_BAND")"
|
||||
pass "the band avoids the colours a bold logo can brighten into"
|
||||
|
||||
# One character has to be one cell, or putting a row back moves what follows it.
|
||||
# Everything that breaks that is refused by the one check, so everything that
|
||||
# breaks it is tested against the one check.
|
||||
write_logo '$1████' ' ████'
|
||||
refuses "a logo built from colour placeholders is left still"
|
||||
write_logo "$(printf 'A\tB')" 'CC'
|
||||
refuses "a logo with a tab someone else expands is left still"
|
||||
write_logo "$(printf 'A\033[31mB')" 'CCCCC'
|
||||
refuses "a logo carrying an escape is left still"
|
||||
write_logo 'AAA中文BBB' 'CCCCCCCCCC'
|
||||
refuses "a logo with double-width glyphs is left still"
|
||||
write_logo "$(printf 'AAAe\xcc\x81BBB')" 'CCCCCCCC'
|
||||
refuses "a logo with a combining mark is left still"
|
||||
write_logo "$(printf 'AA\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa9BB')" 'CCCCCCC'
|
||||
refuses "a logo with a joined emoji is left still"
|
||||
|
||||
# The same check answers for a shell that is counting bytes rather than
|
||||
# characters, which is the only thing that would make a block logo unsafe here.
|
||||
write_logo '████████' '████████'
|
||||
byte_counting=$(LC_ALL=C bash -c 'source "$1"; sheen_build "$2" 3 3 "" 120 && echo animated || echo still' _ "$animation" "$logo")
|
||||
[[ $byte_counting == "still" ]] || fail "a shell counting bytes leaves a block logo still" "$byte_counting"
|
||||
pass "a shell counting bytes leaves a block logo still"
|
||||
|
||||
ascii_counting=$(LC_ALL=C bash -c 'printf "%s\n" AAAA BBBB > "$2"; source "$1"; sheen_build "$2" 3 3 "" 120 && echo animated || echo still' _ "$animation" "$tmp_dir/ascii.txt")
|
||||
[[ $ascii_counting == "animated" ]] || fail "a shell counting bytes still animates plain ASCII" "$ascii_counting"
|
||||
pass "a shell counting bytes still animates plain ASCII"
|
||||
|
||||
write_logo '████████████████████████' '████████ ████████'
|
||||
columns=10
|
||||
refuses "a logo wider than the columns it was given is left still"
|
||||
columns=120
|
||||
|
||||
: >"$logo"
|
||||
refuses "an empty logo is left still"
|
||||
|
||||
rm -f "$logo"
|
||||
missing=$(build 2>&1 >/dev/null || true)
|
||||
[[ -z $missing ]] || fail "a missing logo says nothing on the terminal it would draw on" "$missing"
|
||||
pass "a missing logo says nothing on the terminal it would draw on"
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
TMPDIR=$(mktemp -d)
|
||||
# The /tmp-fallback case (below) must place its decoy at exactly the fixed path the
|
||||
# old wrapper would have formed, so it cannot use a random mktemp name. Track whether
|
||||
# we created it and remove it on exit only then -- never touch a path we did not create.
|
||||
tmp_cache="/tmp/omarchy-brightness-display-apple.device"
|
||||
created_tmp_cache=0
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$TMPDIR"
|
||||
# Remove the /tmp decoy only if this test is the one that created it.
|
||||
if (( created_tmp_cache )); then
|
||||
rm -f "$tmp_cache"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Stubs on PATH: drop sudo so asdcontrol runs directly, record every asdcontrol
|
||||
# invocation, make detection deterministic by having --detect report no device,
|
||||
# and no-op the OSD. On a host without any /dev/*hiddev* node the wrapper's
|
||||
# detect_apple_display_device returns before it ever runs asdcontrol, so the
|
||||
# reject cases assert on the negative: a refused cache value is never handed to
|
||||
# `asdcontrol <dev> -- <step>`. Blind-trust validation would hand it over and be
|
||||
# caught here.
|
||||
stub_dir="$TMPDIR/stubs"
|
||||
mkdir -p "$stub_dir"
|
||||
|
||||
asd_log="$TMPDIR/asdcontrol.log"
|
||||
|
||||
cat >"$stub_dir/sudo" <<'STUB'
|
||||
#!/bin/bash
|
||||
exec "$@"
|
||||
STUB
|
||||
chmod +x "$stub_dir/sudo"
|
||||
|
||||
cat >"$stub_dir/asdcontrol" <<STUB
|
||||
#!/bin/bash
|
||||
printf '%s\n' "\$*" >>"$asd_log"
|
||||
# --detect reports nothing, so detection never yields a device.
|
||||
if [[ \$1 == "--detect" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
# A brightness read (a lone device arg) returns a plausible value; a set
|
||||
# (<device> -- <step>) just succeeds.
|
||||
if [[ \$# -eq 1 ]]; then
|
||||
printf '%s: BRIGHTNESS=30000\n' "\$1"
|
||||
fi
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "$stub_dir/asdcontrol"
|
||||
|
||||
cat >"$stub_dir/omarchy-osd" <<'STUB'
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "$stub_dir/omarchy-osd"
|
||||
|
||||
run_wrapper() {
|
||||
# $1: value for XDG_RUNTIME_DIR ("" means unset); remaining args go to the wrapper.
|
||||
local xdg="$1"
|
||||
shift
|
||||
: >"$asd_log"
|
||||
if [[ -n $xdg ]]; then
|
||||
XDG_RUNTIME_DIR="$xdg" PATH="$stub_dir:$ROOT/bin:$PATH" \
|
||||
omarchy-brightness-display-apple "$@" 2>&1 || true
|
||||
else
|
||||
env -u XDG_RUNTIME_DIR PATH="$stub_dir:$ROOT/bin:$PATH" \
|
||||
omarchy-brightness-display-apple "$@" 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
# --- A cache value that is not a hiddev character device is rejected ----------
|
||||
xdg_dir="$TMPDIR/xdg"
|
||||
mkdir -p "$xdg_dir"
|
||||
cache_file="$xdg_dir/omarchy-brightness-display-apple.device"
|
||||
|
||||
regular_file="$TMPDIR/not-a-device"
|
||||
: >"$regular_file"
|
||||
|
||||
poisons=("/dev/null" "$regular_file" "/tmp/omarchy-evil")
|
||||
|
||||
# The cases above all fail on the pathname prefix, so none of them reaches the -c
|
||||
# test -- drop `&& -c $cached` from the wrapper and they all still pass. A path
|
||||
# that matches the hiddev glob but is not a character device is what -c is for,
|
||||
# and it is the realistic stale cache: the display replugs, the interface
|
||||
# renumbers, and the cached node is simply gone. Add it only when the host really
|
||||
# has no such node, so a machine with the display attached cannot fail here.
|
||||
if [[ ! -e /dev/hiddev999 ]]; then
|
||||
poisons+=("/dev/hiddev999")
|
||||
fi
|
||||
|
||||
for poison in "${poisons[@]}"; do
|
||||
printf '%s\n' "$poison" >"$cache_file"
|
||||
output=$(run_wrapper "$xdg_dir" "+5%")
|
||||
if grep -qF -- "$poison -- +5%" "$asd_log"; then
|
||||
fail "wrapper handed a non-hiddev cache value to asdcontrol: $poison" "$output"
|
||||
fi
|
||||
done
|
||||
pass "wrapper rejects a cached path that is not a hiddev character device"
|
||||
|
||||
# NOTE: the /dev/hiddev999 case above covers the -c test for a glob-matching path
|
||||
# that does not exist. The remaining arm -- a path under /dev that exists, matches
|
||||
# the glob, and is not a character device -- cannot be built without root, since
|
||||
# only real device nodes live there.
|
||||
|
||||
# --- A legitimate cached hiddev node is trusted (only where HW is present) ----
|
||||
real_hiddev=""
|
||||
for candidate in /dev/usb/hiddev* /dev/hiddev*; do
|
||||
if [[ -c $candidate ]]; then
|
||||
real_hiddev="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ -n $real_hiddev ]]; then
|
||||
printf '%s\n' "$real_hiddev" >"$cache_file"
|
||||
run_wrapper "$xdg_dir" "+5%" >/dev/null
|
||||
grep -qF -- "$real_hiddev -- +5%" "$asd_log" ||
|
||||
fail "wrapper did not trust a valid cached hiddev node: $real_hiddev"
|
||||
pass "wrapper trusts a cached hiddev character device without re-detecting"
|
||||
else
|
||||
skip "no /dev/hiddev* character device present; skipping the valid-cache case"
|
||||
fi
|
||||
|
||||
# --- With no XDG_RUNTIME_DIR, the predictable /tmp cache is not consulted ------
|
||||
# Assert on the open, not on the contents. A decoy holding a rejectable path proves
|
||||
# nothing: the validation above refuses it whether or not the /tmp fallback is still
|
||||
# there, so that assertion passes against both wrappers. A FIFO with no writer blocks
|
||||
# whoever opens it, so a wrapper that consults the path hangs and one that ignores it
|
||||
# exits -- which separates the two. mkfifo is atomic and fails outright if the path is
|
||||
# taken, so it neither overwrites a file nor follows a symlink; the fixed path is
|
||||
# required, being exactly the path the old code would have formed. Clear the flag as
|
||||
# soon as the decoy is gone, so a concurrent run's decoy cannot be removed by this
|
||||
# run's EXIT trap.
|
||||
if mkfifo "$tmp_cache" 2>/dev/null; then
|
||||
created_tmp_cache=1
|
||||
status=0
|
||||
env -u XDG_RUNTIME_DIR PATH="$stub_dir:$ROOT/bin:$PATH" \
|
||||
timeout 5 omarchy-brightness-display-apple "+5%" >/dev/null 2>&1 || status=$?
|
||||
rm -f "$tmp_cache"
|
||||
created_tmp_cache=0
|
||||
(( status != 124 )) ||
|
||||
fail "wrapper consulted the world-writable /tmp cache with no XDG_RUNTIME_DIR" \
|
||||
"it blocked reading the FIFO decoy at $tmp_cache"
|
||||
pass "wrapper ignores the /tmp cache path when XDG_RUNTIME_DIR is unset"
|
||||
else
|
||||
skip "$tmp_cache already present or not safely creatable; skipping the /tmp-fallback case"
|
||||
fi
|
||||
Executable
+330
@@ -0,0 +1,330 @@
|
||||
#!/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
|
||||
|
||||
export OMARCHY_PATH="$ROOT"
|
||||
export OMARCHY_PROVISIONING_DIR="$test_tmp/provisioning"
|
||||
|
||||
source "$ROOT/install/helpers/browser-policy.sh"
|
||||
|
||||
# Temp dirs are user-owned; drop -o/-g so install(1) can run unprivileged.
|
||||
unprivileged_as_root() {
|
||||
if [[ $1 == "install" ]]; then
|
||||
shift
|
||||
local args=()
|
||||
local skip=0
|
||||
local arg
|
||||
for arg in "$@"; do
|
||||
if (( skip )); then
|
||||
skip=0
|
||||
continue
|
||||
fi
|
||||
case $arg in
|
||||
-o|-g) skip=1 ;;
|
||||
*) args+=("$arg") ;;
|
||||
esac
|
||||
done
|
||||
command install "${args[@]}"
|
||||
else
|
||||
"$@"
|
||||
fi
|
||||
}
|
||||
|
||||
write_dir=$test_tmp/writable
|
||||
mkdir -p "$write_dir"
|
||||
browser_policy_install_color "$write_dir" "#aabbcc" ||
|
||||
fail "theme colour writes into a writable policy directory"
|
||||
grep -F '"BrowserThemeColor": "#aabbcc"' "$write_dir/color.json" >/dev/null ||
|
||||
fail "theme colour writes BrowserThemeColor"
|
||||
mode=$(stat -c '%a' "$write_dir/color.json")
|
||||
[[ $mode == "644" ]] || fail "theme colour creates a root-mode policy file" "mode=$mode"
|
||||
pass "theme colour writes a 0644 color.json"
|
||||
|
||||
if (( EUID == 0 )); then
|
||||
skip "running as root; skipping the mktemp-failure check"
|
||||
else
|
||||
chmod u+w "$write_dir"
|
||||
export TMPDIR=$test_tmp/missing-tmp
|
||||
if browser_policy_install_color "$write_dir" "#dead00" 2>/dev/null; then
|
||||
fail "theme colour fails when mktemp cannot create a file"
|
||||
fi
|
||||
unset TMPDIR
|
||||
grep -F '"BrowserThemeColor": "#aabbcc"' "$write_dir/color.json" >/dev/null ||
|
||||
fail "a failed mktemp leaves an existing color.json intact"
|
||||
pass "a failed mktemp does not truncate color.json"
|
||||
fi
|
||||
|
||||
printf 'original\n' >"$test_tmp/pwn"
|
||||
rm -f "$write_dir/color.json"
|
||||
ln -s "$test_tmp/pwn" "$write_dir/color.json"
|
||||
browser_policy_install_color "$write_dir" "#aabbcc" ||
|
||||
fail "theme colour replaces a planted color.json symlink"
|
||||
[[ -f $write_dir/color.json && ! -L $write_dir/color.json ]] ||
|
||||
fail "theme colour unlinks a planted color.json symlink instead of writing through it"
|
||||
grep -Fxq 'original' "$test_tmp/pwn" || fail "theme colour leaves the symlink target unchanged"
|
||||
pass "theme colour does not follow a planted color.json symlink"
|
||||
|
||||
plant_write=$test_tmp/plant-dir
|
||||
mkdir -p "$plant_write/color.json/nested"
|
||||
printf 'inside\n' >"$plant_write/color.json/nested/x"
|
||||
browser_policy_install_color "$plant_write" "#aabbcc" ||
|
||||
fail "theme colour replaces a planted color.json directory"
|
||||
[[ -f $plant_write/color.json && ! -d $plant_write/color.json ]] ||
|
||||
fail "theme colour does not write into a planted color.json directory"
|
||||
pass "theme colour does not write into a planted color.json directory"
|
||||
|
||||
missing_dir=$test_tmp/missing
|
||||
browser_policy_install_color "$missing_dir" "#aabbcc" ||
|
||||
fail "theme colour skips a policy directory that does not exist"
|
||||
[[ ! -e $missing_dir ]] || fail "theme colour does not create a missing policy directory"
|
||||
pass "theme colour skips a missing policy directory"
|
||||
|
||||
if browser_policy_install_color "$write_dir" "aabbcc" 2>/dev/null; then
|
||||
fail "theme colour rejects hex without a leading #"
|
||||
fi
|
||||
if browser_policy_install_color "$write_dir" "#AABBCC" 2>/dev/null; then
|
||||
fail "theme colour rejects uppercase hex"
|
||||
fi
|
||||
pass "theme colour accepts only # plus six lowercase hex digits"
|
||||
|
||||
planted_dir=$test_tmp/planted
|
||||
mkdir -p "$planted_dir/evil"
|
||||
printf 'evil\n' >"$planted_dir/evil/f"
|
||||
printf 'old\n' >"$planted_dir/color.json"
|
||||
as_root() { unprivileged_as_root "$@"; }
|
||||
browser_policy_setup_dir "$planted_dir"
|
||||
[[ ! -e $planted_dir/evil ]] || fail "policy setup drops a non-empty non-root subdirectory"
|
||||
[[ ! -e $planted_dir/color.json ]] || fail "policy setup drops a non-root color.json"
|
||||
[[ -d $planted_dir ]] || fail "policy setup leaves the managed directory in place"
|
||||
mode=$(stat -c '%a' "$planted_dir")
|
||||
[[ $mode == "755" ]] || fail "policy setup leaves the managed directory 0755" "mode=$mode"
|
||||
pass "policy setup drops non-root files and non-empty subdirectories"
|
||||
|
||||
owned=$test_tmp/not-root
|
||||
mkdir -p "$owned"
|
||||
chmod 755 "$owned"
|
||||
if browser_policy_dir_hardened "$owned"; then
|
||||
fail "a user-owned 0755 directory is not treated as hardened"
|
||||
fi
|
||||
pass "a hardened directory must be root-owned"
|
||||
|
||||
saved_parent_dirs=("${BROWSER_POLICY_PARENT_DIRS[@]}")
|
||||
parent_root=$test_tmp/parents
|
||||
mkdir -p "$parent_root/etc/chromium/policies/managed/keep"
|
||||
printf 'keep\n' >"$parent_root/etc/chromium/policies/managed/keep/x"
|
||||
chmod 0777 "$parent_root/etc/chromium" "$parent_root/etc/chromium/policies"
|
||||
chmod 755 "$parent_root/etc/chromium/policies/managed"
|
||||
BROWSER_POLICY_PARENT_DIRS=(
|
||||
"$parent_root/etc/chromium"
|
||||
"$parent_root/etc/chromium/policies"
|
||||
)
|
||||
as_root() { unprivileged_as_root "$@"; }
|
||||
if browser_policy_parents_hardened "$parent_root/etc/chromium/policies/managed"; then
|
||||
fail "a world-writable policy parent is not treated as hardened"
|
||||
fi
|
||||
browser_policy_setup_parents_for "$parent_root/etc/chromium/policies/managed"
|
||||
mode=$(stat -c '%a' "$parent_root/etc/chromium")
|
||||
[[ $mode == "755" ]] || fail "setup tightens /etc/chromium" "mode=$mode"
|
||||
mode=$(stat -c '%a' "$parent_root/etc/chromium/policies")
|
||||
[[ $mode == "755" ]] || fail "setup tightens /etc/chromium/policies" "mode=$mode"
|
||||
[[ -d $parent_root/etc/chromium/policies/managed/keep ]] ||
|
||||
fail "parent repair does not purge the managed directory"
|
||||
pass "policy parent directories are tightened to 0755 without purging the leaf"
|
||||
|
||||
symlink_root=$test_tmp/symlink-parents
|
||||
mkdir -p "$symlink_root/etc" "$symlink_root/attacker/policies/managed"
|
||||
printf 'planted\n' >"$symlink_root/attacker/policies/managed/evil.json"
|
||||
ln -s "$symlink_root/attacker" "$symlink_root/etc/chromium"
|
||||
BROWSER_POLICY_PARENT_DIRS=(
|
||||
"$symlink_root/etc/chromium"
|
||||
"$symlink_root/etc/chromium/policies"
|
||||
)
|
||||
as_root() { unprivileged_as_root "$@"; }
|
||||
browser_policy_setup_dir "$symlink_root/etc/chromium/policies/managed"
|
||||
[[ ! -L $symlink_root/etc/chromium ]] || fail "setup replaces a planted /etc/chromium symlink"
|
||||
[[ -d $symlink_root/etc/chromium && ! -L $symlink_root/etc/chromium ]] ||
|
||||
fail "setup recreates /etc/chromium as a real directory"
|
||||
[[ -d $symlink_root/etc/chromium/policies && ! -L $symlink_root/etc/chromium/policies ]] ||
|
||||
fail "setup recreates /etc/chromium/policies as a real directory"
|
||||
[[ ! -e $symlink_root/etc/chromium/policies/managed/evil.json ]] ||
|
||||
fail "setup does not keep policy that lived behind a planted parent symlink"
|
||||
grep -Fxq 'planted' "$symlink_root/attacker/policies/managed/evil.json" ||
|
||||
fail "replacing a parent symlink does not delete the symlink target"
|
||||
BROWSER_POLICY_PARENT_DIRS=("${saved_parent_dirs[@]}")
|
||||
pass "policy setup does not follow a planted parent symlink"
|
||||
|
||||
leaf_link_root=$test_tmp/leaf-link
|
||||
mkdir -p "$leaf_link_root/etc/chromium/policies" "$leaf_link_root/attacker"
|
||||
printf 'planted\n' >"$leaf_link_root/attacker/evil.json"
|
||||
chmod 755 "$leaf_link_root/etc/chromium" "$leaf_link_root/etc/chromium/policies"
|
||||
ln -s "$leaf_link_root/attacker" "$leaf_link_root/etc/chromium/policies/managed"
|
||||
BROWSER_POLICY_PARENT_DIRS=(
|
||||
"$leaf_link_root/etc/chromium"
|
||||
"$leaf_link_root/etc/chromium/policies"
|
||||
)
|
||||
as_root() { unprivileged_as_root "$@"; }
|
||||
if browser_policy_dir_hardened "$leaf_link_root/etc/chromium/policies/managed"; then
|
||||
fail "a planted managed symlink is not treated as hardened"
|
||||
fi
|
||||
browser_policy_setup_dir "$leaf_link_root/etc/chromium/policies/managed"
|
||||
[[ ! -L $leaf_link_root/etc/chromium/policies/managed ]] ||
|
||||
fail "setup replaces a planted managed symlink"
|
||||
[[ -d $leaf_link_root/etc/chromium/policies/managed && ! -L $leaf_link_root/etc/chromium/policies/managed ]] ||
|
||||
fail "setup recreates managed as a real directory"
|
||||
[[ ! -e $leaf_link_root/etc/chromium/policies/managed/evil.json ]] ||
|
||||
fail "setup does not keep policy that lived behind a planted managed symlink"
|
||||
grep -Fxq 'planted' "$leaf_link_root/attacker/evil.json" ||
|
||||
fail "replacing a managed symlink does not delete the symlink target"
|
||||
BROWSER_POLICY_PARENT_DIRS=("${saved_parent_dirs[@]}")
|
||||
pass "policy setup does not follow a planted managed symlink"
|
||||
|
||||
fx_link_root=$test_tmp/fx-link
|
||||
mkdir -p "$fx_link_root/attacker" "$fx_link_root/opt"
|
||||
printf 'planted\n' >"$fx_link_root/attacker/policies.json"
|
||||
ln -s "$fx_link_root/attacker" "$fx_link_root/opt/zen"
|
||||
as_root() { unprivileged_as_root "$@"; }
|
||||
if browser_policy_firefox_hardened "$fx_link_root/opt/zen"; then
|
||||
fail "a planted Firefox distribution symlink is not treated as hardened"
|
||||
fi
|
||||
browser_policy_setup_firefox_distribution "$fx_link_root/opt/zen" ||
|
||||
fail "Firefox setup replaces a planted distribution symlink"
|
||||
[[ ! -L $fx_link_root/opt/zen ]] || fail "Firefox setup unlinks a planted distribution symlink"
|
||||
[[ -d $fx_link_root/opt/zen && ! -L $fx_link_root/opt/zen ]] ||
|
||||
fail "Firefox setup recreates the distribution directory"
|
||||
[[ -f $fx_link_root/opt/zen/policies.json && ! -L $fx_link_root/opt/zen/policies.json ]] ||
|
||||
fail "Firefox setup writes policies.json into the recreated directory"
|
||||
grep -Fxq 'planted' "$fx_link_root/attacker/policies.json" ||
|
||||
fail "replacing a Firefox distribution symlink does not delete the symlink target"
|
||||
pass "Firefox setup does not follow a planted distribution symlink"
|
||||
|
||||
[[ $(browser_policy_theme_hex "242,240,229") == "#f2f0e5" ]] ||
|
||||
fail "theme colour converts an RGB triple to hex"
|
||||
[[ $(browser_policy_theme_hex $'14,31,41\n') == "#0e1f29" ]] ||
|
||||
fail "theme colour accepts a trailing newline"
|
||||
[[ $(browser_policy_theme_hex "0,0,0") == "#000000" ]] ||
|
||||
fail "theme colour pads single-digit components"
|
||||
[[ $(browser_policy_theme_hex " 12 , 11 , 12 ") == "#0c0b0c" ]] ||
|
||||
fail "theme colour tolerates surrounding whitespace"
|
||||
[[ $(browser_policy_theme_hex "08,09,10") == "#08090a" ]] ||
|
||||
fail "theme colour treats leading zeros as decimal"
|
||||
for malformed in "" "not,a,color" "1,2" "1,2,3,4" "256,0,0" "999,999,999" "-1,0,0" \
|
||||
"1,2,3;id" '1,2,$(id)' "0x10,0,0" "1,2,3 4,5,6"; do
|
||||
[[ $(browser_policy_theme_hex "$malformed") == "#1c2027" ]] ||
|
||||
fail "theme colour falls back to the stock grey for '$malformed'"
|
||||
done
|
||||
pass "theme colour is six hex digits or the stock grey"
|
||||
|
||||
for theme in "$ROOT"/themes/*/chromium.theme; do
|
||||
[[ -f $theme ]] || continue
|
||||
rgb=$(<$theme)
|
||||
hex=$(browser_policy_theme_hex "$rgb")
|
||||
[[ $hex =~ ^#[0-9a-f]{6}$ ]] ||
|
||||
fail "shipped $(basename "$(dirname "$theme")") chromium.theme parses as hex" "got: $hex from $(printf %q "$rgb")"
|
||||
if [[ $hex == "#1c2027" && ! $rgb =~ ^[[:space:]]*28[[:space:]]*,[[:space:]]*32[[:space:]]*,[[:space:]]*39[[:space:]]*$ ]]; then
|
||||
fail "shipped $(basename "$(dirname "$theme")") chromium.theme is a valid RGB triple" "got: $(printf %q "$rgb")"
|
||||
fi
|
||||
done
|
||||
pass "shipped chromium.theme files parse as RGB triples"
|
||||
|
||||
grep -F 'browser_policy_theme_hex' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null ||
|
||||
fail "omarchy-theme-set-browser parses chromium.theme through browser_policy_theme_hex"
|
||||
grep -F 'omarchy-theme-set-browser-policy' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null ||
|
||||
fail "omarchy-theme-set-browser writes colour through omarchy-theme-set-browser-policy"
|
||||
if grep -E 'printf.*THEME_RGB_COLOR' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null; then
|
||||
fail "omarchy-theme-set-browser does not hand unvetted theme words to printf"
|
||||
fi
|
||||
pass "omarchy-theme-set-browser validates the theme colour"
|
||||
|
||||
fx_policy=$test_tmp/policies.json
|
||||
printf '%s\n' '{"policies":{}}' >"$fx_policy"
|
||||
chmod 644 "$fx_policy"
|
||||
if browser_policy_firefox_policy_file_ok "$fx_policy"; then
|
||||
fail "a user-owned policies.json is not treated as hardened"
|
||||
fi
|
||||
ln -sf "$fx_policy" "$test_tmp/policies-link.json"
|
||||
if browser_policy_firefox_policy_file_ok "$test_tmp/policies-link.json"; then
|
||||
fail "a policies.json symlink is not treated as hardened"
|
||||
fi
|
||||
pass "Firefox policy files must be root-owned regular files without group or other write"
|
||||
|
||||
dist=$test_tmp/distribution
|
||||
mkdir -p "$dist"
|
||||
printf 'original\n' >"$test_tmp/firefox-pwn"
|
||||
ln -s "$test_tmp/firefox-pwn" "$dist/policies.json"
|
||||
as_root() { unprivileged_as_root "$@"; }
|
||||
browser_policy_install_firefox_policies "$dist" ||
|
||||
fail "Firefox policy install replaces a planted policies.json symlink"
|
||||
[[ -f $dist/policies.json && ! -L $dist/policies.json ]] ||
|
||||
fail "Firefox policy install unlinks a planted policies.json symlink instead of writing through it"
|
||||
grep -Fxq 'original' "$test_tmp/firefox-pwn" || fail "Firefox policy install leaves the symlink target unchanged"
|
||||
grep -q '"policies"' "$dist/policies.json" || fail "Firefox policy install writes the stock policies"
|
||||
pass "Firefox policy install does not follow a planted policies.json symlink"
|
||||
|
||||
dir_dist=$test_tmp/distribution-dir
|
||||
mkdir -p "$dir_dist"
|
||||
mkdir "$dir_dist/policies.json"
|
||||
as_root() { unprivileged_as_root "$@"; }
|
||||
if browser_policy_install_firefox_policies "$dir_dist" 2>/dev/null; then
|
||||
fail "Firefox policy install refuses a planted policies.json directory"
|
||||
fi
|
||||
[[ -d $dir_dist/policies.json ]] || fail "Firefox policy install leaves a planted policies.json directory in place"
|
||||
pass "Firefox policy install does not write into a planted policies.json directory"
|
||||
|
||||
grep -F 'exit "$failed"' "$ROOT/bin/omarchy-theme-set-browser" >/dev/null ||
|
||||
fail "omarchy-theme-set-browser exits non-zero when a policy write fails"
|
||||
pass "omarchy-theme-set-browser exits non-zero when a policy write fails"
|
||||
|
||||
# Bash 5.3 adopts the EXIT trap's last status as the script's exit status, so a
|
||||
# handler ending on a false test turns a clean run into a failure and aborts the
|
||||
# migration that calls this through omarchy-theme-set-browser.
|
||||
policy_cleanup=$(sed -n '/^cleanup() {/,/^}/p' "$ROOT/bin/omarchy-theme-set-browser-policy")
|
||||
[[ -n $policy_cleanup ]] || fail "omarchy-theme-set-browser-policy defines an EXIT cleanup handler"
|
||||
eval "$policy_cleanup"
|
||||
staged=""
|
||||
cleanup || fail "omarchy-theme-set-browser-policy's EXIT trap succeeds with nothing staged"
|
||||
staged=$test_tmp/staged-policy
|
||||
: >"$staged"
|
||||
cleanup || fail "omarchy-theme-set-browser-policy's EXIT trap succeeds with a staged file"
|
||||
[[ ! -e $staged ]] || fail "omarchy-theme-set-browser-policy's EXIT trap removes the staged file"
|
||||
unset -f cleanup
|
||||
pass "omarchy-theme-set-browser-policy's EXIT trap never leaks a failure status"
|
||||
|
||||
grep -F 'omarchy-theme-set-browser || true' "$ROOT/migrations/1787515927.sh" >/dev/null ||
|
||||
fail "the policy-directory migration hardens Firefox even when the theme refresh fails"
|
||||
pass "the policy-directory migration does not abort on a failed theme refresh"
|
||||
|
||||
policy_files=(
|
||||
"$ROOT/bin/omarchy-install-browser"
|
||||
"$ROOT/bin/omarchy-provision-owner"
|
||||
"$ROOT/bin/omarchy-theme-set-browser"
|
||||
"$ROOT/bin/omarchy-theme-set-browser-policy"
|
||||
"$ROOT/bin/omarchy-upgrade-to-quattro"
|
||||
"$ROOT/install/config/theme-system.sh"
|
||||
"$ROOT/install/config/browser-policy.sh"
|
||||
"$ROOT/install/helpers/browser-policy.sh"
|
||||
"$ROOT/migrations/1787515927.sh"
|
||||
)
|
||||
if grep -nE 'chmod a\+rwx\b|chmod a\+rw\b|chmod a\+w\b|chmod o\+w|chmod ugo\+w|chmod 2775\b|chmod 2777\b|chmod 0777\b|chmod 777\b|install -d -m 0?[27]?777|omarchy-browser-policy' "${policy_files[@]}" >/dev/null; then
|
||||
fail "browser policy setup is not world-writable and does not use omarchy-browser-policy"
|
||||
fi
|
||||
pass "browser policy setup is not world-writable"
|
||||
|
||||
mapfile -t migrations < <(rg -l 'Stop world-writable Chromium and Firefox policy directories' "$ROOT/migrations")
|
||||
(( ${#migrations[@]} == 1 )) || fail "exactly one migration locks existing policy directories" "${migrations[*]}"
|
||||
grep -F 'browser_policy_setup_dir' "${migrations[0]}" >/dev/null ||
|
||||
fail "the policy-directory migration repairs managed directories"
|
||||
if grep -F 'browser_policy_grant_user' "${migrations[0]}" >/dev/null; then
|
||||
fail "the policy-directory migration does not grant a browser-policy group"
|
||||
fi
|
||||
grep -F 'BROWSER_POLICY_FIREFOX_DIRS' "${migrations[0]}" >/dev/null ||
|
||||
fail "the policy-directory migration covers Firefox and Zen"
|
||||
grep -F 'browser_policy_firefox_policy_file_ok' "${migrations[0]}" >/dev/null ||
|
||||
fail "the policy-directory migration keeps a trusted Firefox policies.json"
|
||||
grep -F '/opt/zen-browser/distribution' "$ROOT/install/helpers/browser-policy.sh" >/dev/null ||
|
||||
fail "the shared helper names the Zen distribution directory"
|
||||
pass "a migration locks existing policy directories"
|
||||
Executable
+184
@@ -0,0 +1,184 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(dirname "$0")/base-test.sh"
|
||||
|
||||
helper="$ROOT/bin/omarchy-theme-set-browser-policy"
|
||||
setter="$ROOT/bin/omarchy-theme-set-browser"
|
||||
sudoers_file="$ROOT/etc/sudoers.d/omarchy-theme-browser"
|
||||
rule='%wheel ALL=(root) NOPASSWD: /usr/bin/omarchy-theme-set-browser-policy [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]'
|
||||
|
||||
# Exactly one rule, matched whole. Dropping the argument -- which sudoers reads
|
||||
# as "any arguments" -- or widening the glob to `*` would let the grant carry
|
||||
# something other than a color while leaving this line looking right.
|
||||
rules=$(grep -vE '^[[:space:]]*(#|$)' "$sudoers_file")
|
||||
[[ $rules == "$rule" ]] ||
|
||||
fail "browser policy sudoers file carries exactly the six-hex-digit rule and nothing else" "got: $rules"
|
||||
|
||||
if command -v visudo >/dev/null; then
|
||||
visudo -cf "$sudoers_file" >/dev/null || fail "browser policy sudoers rule parses"
|
||||
fi
|
||||
|
||||
grep -Fx 'PACKAGED_PATH=/usr/bin/omarchy-theme-set-browser-policy' "$helper" >/dev/null ||
|
||||
fail "omarchy-theme-set-browser-policy elevates the path the sudoers rule names"
|
||||
|
||||
grep -E 'sudo -n -l -l' "$helper" >/dev/null ||
|
||||
fail "omarchy-theme-set-browser-policy reads the grant from the long sudo listing"
|
||||
|
||||
grep -Eq '^\s*export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin' "$helper" ||
|
||||
fail "omarchy-theme-set-browser-policy pins PATH to trusted system directories when it holds root"
|
||||
gated=$(grep -A1 -E '^if \(\( EUID == 0 \)\); then$' "$helper" || true)
|
||||
[[ $gated == *"export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin"* ]] ||
|
||||
fail "omarchy-theme-set-browser-policy gates the trusted-PATH pin on holding root"
|
||||
|
||||
pass "browser policy sudoers rule is scoped to a single color argument"
|
||||
|
||||
for dir in /etc/chromium/policies/managed /etc/opt/chrome/policies/managed \
|
||||
/etc/opt/edge/policies/managed /etc/brave/policies/managed; do
|
||||
grep -Fx " $dir" "$helper" >/dev/null ||
|
||||
fail "omarchy-theme-set-browser-policy names $dir in its fixed policy directory list"
|
||||
done
|
||||
|
||||
policy_dir_count=$(sed -n '/^POLICY_DIRS=(/,/^)/p' "$helper" | grep -c '^ /')
|
||||
((policy_dir_count == 4)) ||
|
||||
fail "omarchy-theme-set-browser-policy writes only the four known policy directories" \
|
||||
"got: $policy_dir_count"
|
||||
|
||||
grep -F 'install -m 0644 -o root -g root -T' "$helper" >/dev/null ||
|
||||
fail "omarchy-theme-set-browser-policy installs color.json with install -T"
|
||||
if grep -E 'mv -f' "$helper" >/dev/null; then
|
||||
fail "omarchy-theme-set-browser-policy does not mv into a planted color.json directory"
|
||||
fi
|
||||
|
||||
pass "browser policy helper writes a fixed set of policy directories"
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
stub_bin="$test_tmp/bin"
|
||||
mkdir -p "$stub_bin"
|
||||
|
||||
cat >"$stub_bin/pkexec" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'pkexec %s\n' "$*" >"$ELEVATION_LOG"
|
||||
SH
|
||||
chmod +x "$stub_bin/pkexec"
|
||||
|
||||
# STUB_GRANTED empty stands for an install whose omarchy-settings predates the
|
||||
# sudoers file. The default is granted, matching a current Omarchy.
|
||||
cat >"$stub_bin/sudo" <<'SH'
|
||||
#!/bin/bash
|
||||
if [[ $1 == -n && $2 == -l ]]; then
|
||||
if [[ ${STUB_GRANTED-granted} == "granted" ]]; then
|
||||
echo " Options: !authenticate"
|
||||
else
|
||||
echo " Matched: ${!#}"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
printf 'sudo %s\n' "$*" >"$ELEVATION_LOG"
|
||||
SH
|
||||
chmod +x "$stub_bin/sudo"
|
||||
|
||||
if ((EUID == 0)); then
|
||||
skip "running as root; skipping the elevation checks, which would rewrite this machine's browser policy"
|
||||
else
|
||||
elevation_for() {
|
||||
: >"$test_tmp/elevation"
|
||||
ELEVATION_LOG="$test_tmp/elevation" \
|
||||
PATH="$stub_bin:$PATH" \
|
||||
bash "$helper" "$@" </dev/null >/dev/null 2>&1 || true
|
||||
cat "$test_tmp/elevation"
|
||||
}
|
||||
|
||||
elevation=$(elevation_for 1c2027)
|
||||
[[ $elevation == "sudo /usr/bin/omarchy-theme-set-browser-policy 1c2027" ]] ||
|
||||
fail "omarchy-theme-set-browser-policy takes the passwordless sudo grant without a terminal" \
|
||||
"got: $elevation"
|
||||
|
||||
dev_linked=$(OMARCHY_PATH="$test_tmp/checkout" elevation_for 1c2027)
|
||||
[[ $dev_linked == "sudo /usr/bin/omarchy-theme-set-browser-policy 1c2027" ]] ||
|
||||
fail "omarchy-theme-set-browser-policy elevates the system install wherever OMARCHY_PATH points" \
|
||||
"got: $dev_linked"
|
||||
|
||||
pass "browser policy helper elevates a valid color through the sudo grant"
|
||||
|
||||
ungranted=$(STUB_GRANTED="" elevation_for 1c2027)
|
||||
[[ $ungranted == "pkexec /usr/bin/omarchy-theme-set-browser-policy 1c2027" ]] ||
|
||||
fail "omarchy-theme-set-browser-policy falls back to polkit where the grant does not reach" \
|
||||
"got: $ungranted"
|
||||
|
||||
pass "browser policy helper falls back to polkit wherever the grant does not reach"
|
||||
|
||||
for bad in "" "1C2027" "abc12" "abc1234" "1c202g" "../../etc/passwd" "1c2027 1c2027" \
|
||||
'$(id)' "1c2027;id" "#1c2027"; do
|
||||
if PATH="$stub_bin:$PATH" ELEVATION_LOG="$test_tmp/elevation" \
|
||||
bash "$helper" "$bad" </dev/null >/dev/null 2>&1; then
|
||||
fail "omarchy-theme-set-browser-policy rejects '$bad'"
|
||||
fi
|
||||
|
||||
rejected=$(elevation_for "$bad")
|
||||
[[ -z $rejected ]] ||
|
||||
fail "omarchy-theme-set-browser-policy rejects '$bad' before elevating" "got: $rejected"
|
||||
done
|
||||
|
||||
if PATH="$stub_bin:$PATH" bash "$helper" 1c2027 ffffff </dev/null >/dev/null 2>&1; then
|
||||
fail "omarchy-theme-set-browser-policy rejects more than one argument"
|
||||
fi
|
||||
|
||||
pass "browser policy helper accepts nothing but six lowercase hex digits"
|
||||
fi
|
||||
|
||||
setter_bin="$test_tmp/setter-bin"
|
||||
mkdir -p "$setter_bin"
|
||||
|
||||
cat >"$setter_bin/omarchy-theme-set-browser-policy" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$*" >"$COLOR_LOG"
|
||||
SH
|
||||
chmod +x "$setter_bin/omarchy-theme-set-browser-policy"
|
||||
|
||||
cat >"$setter_bin/omarchy-cmd-present" <<'SH'
|
||||
#!/bin/bash
|
||||
exit 1
|
||||
SH
|
||||
chmod +x "$setter_bin/omarchy-cmd-present"
|
||||
|
||||
setter_home="$test_tmp/home"
|
||||
theme_dir="$setter_home/.local/state/omarchy/current/theme"
|
||||
mkdir -p "$theme_dir"
|
||||
|
||||
color_for_theme() {
|
||||
: >"$test_tmp/color"
|
||||
if [[ $# -gt 0 ]]; then
|
||||
printf '%s' "$1" >"$theme_dir/chromium.theme"
|
||||
else
|
||||
rm -f "$theme_dir/chromium.theme"
|
||||
fi
|
||||
|
||||
HOME="$setter_home" COLOR_LOG="$test_tmp/color" PATH="$setter_bin:$stub_bin:$PATH" \
|
||||
OMARCHY_PATH="$ROOT" bash "$setter" </dev/null >/dev/null 2>&1 || true
|
||||
cat "$test_tmp/color"
|
||||
}
|
||||
|
||||
[[ $(color_for_theme "242,240,229") == "f2f0e5" ]] ||
|
||||
fail "omarchy-theme-set-browser converts an RGB triple to six hex digits"
|
||||
[[ $(color_for_theme $'14,31,41\n') == "0e1f29" ]] ||
|
||||
fail "omarchy-theme-set-browser accepts a trailing newline"
|
||||
[[ $(color_for_theme "0,0,0") == "000000" ]] ||
|
||||
fail "omarchy-theme-set-browser pads single-digit components"
|
||||
[[ $(color_for_theme " 12 , 11 , 12 ") == "0c0b0c" ]] ||
|
||||
fail "omarchy-theme-set-browser tolerates surrounding whitespace"
|
||||
|
||||
for malformed in "" "not,a,color" "1,2" "1,2,3,4" "256,0,0" "999,999,999" "-1,0,0" \
|
||||
"1,2,3;id" '1,2,$(id)' "0x10,0,0" "1,2,3 4,5,6"; do
|
||||
color=$(color_for_theme "$malformed")
|
||||
[[ $color == "1c2027" ]] ||
|
||||
fail "omarchy-theme-set-browser falls back to the stock colour for '$malformed'" "got: $color"
|
||||
done
|
||||
|
||||
[[ $(color_for_theme) == "1c2027" ]] ||
|
||||
fail "omarchy-theme-set-browser falls back to the stock colour with no theme file"
|
||||
|
||||
pass "browser theme color is derived as six hex digits or falls back to the stock grey"
|
||||
@@ -20,7 +20,7 @@ JS
|
||||
require_compositor "Button hover geometry runtime test"
|
||||
|
||||
if ! command -v quickshell >/dev/null 2>&1; then
|
||||
pass "quickshell not installed; skipping Button hover geometry runtime test"
|
||||
skip "quickshell not installed; skipping Button hover geometry runtime test"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
@@ -31,6 +31,12 @@ for arg in "$@"; do printf "\t%s" "$arg" >>"$OMARCHY_CHANNEL_TEST_LOG"; done
|
||||
printf "\n" >>"$OMARCHY_CHANNEL_TEST_LOG"
|
||||
'
|
||||
|
||||
write_stub omarchy-update-pacman '#!/bin/bash
|
||||
printf "update-pacman" >>"$OMARCHY_CHANNEL_TEST_LOG"
|
||||
for arg in "$@"; do printf "\t%s" "$arg" >>"$OMARCHY_CHANNEL_TEST_LOG"; done
|
||||
printf "\n" >>"$OMARCHY_CHANNEL_TEST_LOG"
|
||||
'
|
||||
|
||||
write_stub omarchy-dev-unlink '#!/bin/bash
|
||||
printf "unlink" >>"$OMARCHY_CHANNEL_TEST_LOG"
|
||||
for arg in "$@"; do printf "\t%s" "$arg" >>"$OMARCHY_CHANNEL_TEST_LOG"; done
|
||||
@@ -106,7 +112,7 @@ assert_log_line() {
|
||||
|
||||
run_channel stable
|
||||
assert_log_line $'refresh\tstable' "stable refreshes the stable pacman channel"
|
||||
assert_log_line $'sudo\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy\tomarchy-settings' "stable installs stable Omarchy packages"
|
||||
assert_log_line $'update-pacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy\tomarchy-settings' "stable installs stable Omarchy packages"
|
||||
assert_log_line $'unlink\t--no-reboot' "stable restores the package-backed Omarchy path without an early reboot prompt"
|
||||
assert_log_line $'update\t-y\tOMARCHY_PATH=/usr/share/omarchy' "stable runs the normal update pipeline from the package-backed path"
|
||||
if grep -q $'^state\tset\treboot-required$' "$log_file"; then
|
||||
@@ -116,17 +122,17 @@ pass "stable does not require reboot when already package-backed"
|
||||
|
||||
run_channel rc
|
||||
assert_log_line $'refresh\trc' "rc refreshes the rc pacman channel"
|
||||
assert_log_line $'sudo\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy\tomarchy-settings' "rc installs rc Omarchy packages"
|
||||
assert_log_line $'update-pacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy\tomarchy-settings' "rc installs rc Omarchy packages"
|
||||
assert_log_line $'unlink\t--no-reboot' "rc restores the package-backed Omarchy path without an early reboot prompt"
|
||||
assert_log_line $'update\t-y\tOMARCHY_PATH=/usr/share/omarchy' "rc runs the normal update pipeline from the package-backed path"
|
||||
|
||||
OMARCHY_TEST_PATH="$ROOT" run_channel edge
|
||||
assert_log_line $'refresh\tedge' "edge refreshes the edge pacman channel"
|
||||
assert_log_line $'sudo\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev' "edge installs development Omarchy packages"
|
||||
assert_log_line $'update-pacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev' "edge installs development Omarchy packages"
|
||||
assert_log_line $'unlink\t--no-reboot' "edge unlinks dev without an early reboot prompt"
|
||||
assert_log_line $'state\tset\treboot-required' "edge marks reboot required when leaving dev"
|
||||
assert_log_line $'update\t-y\tOMARCHY_PATH=/usr/share/omarchy' "edge runs the normal update pipeline from the package-backed path"
|
||||
[[ $(grep -E '^(unlink|state|update)' "$log_file") == $'unlink\t--no-reboot\nstate\tset\treboot-required\nupdate\t-y\tOMARCHY_PATH=/usr/share/omarchy' ]] ||
|
||||
[[ $(grep -E $'^(unlink|state|update)\t' "$log_file") == $'unlink\t--no-reboot\nstate\tset\treboot-required\nupdate\t-y\tOMARCHY_PATH=/usr/share/omarchy' ]] ||
|
||||
fail "edge defers the reboot prompt until the update restart stage" "$(cat "$log_file")"
|
||||
pass "edge defers the reboot prompt until the update restart stage"
|
||||
|
||||
@@ -146,12 +152,12 @@ rmdir "$checkout"
|
||||
run_channel dev
|
||||
assert_log_line $'gum\tconfirm\t--default=false\tSwitch to dev channel?' "dev asks for confirmation"
|
||||
assert_log_line $'refresh\tedge' "dev refreshes the edge pacman channel"
|
||||
assert_log_line $'sudo\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev' "dev installs development Omarchy packages"
|
||||
assert_log_line $'git\tclone\thttps://github.com/basecamp/omarchy.git\t'"$checkout" "dev clones the source checkout to ~/omarchy"
|
||||
assert_log_line $'update-pacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev' "dev installs development Omarchy packages"
|
||||
assert_log_line $'git\tclone\thttps://github.com/omacom/omarchy.git\t'"$checkout" "dev clones the source checkout to ~/omarchy"
|
||||
assert_log_line $'link\t'"$checkout"$'\t--no-reboot' "dev links ~/omarchy without an early reboot prompt"
|
||||
assert_log_line $'state\tset\treboot-required' "dev defers the reboot prompt to the update pipeline"
|
||||
assert_log_line $'update\t-y\tOMARCHY_PATH='"$checkout" "dev runs the normal update pipeline from the source checkout"
|
||||
[[ $(grep -E '^(git|link|state|refresh|sudo|update)' "$log_file") == $'git\tclone\thttps://github.com/basecamp/omarchy.git\t'"$checkout"$'\nlink\t'"$checkout"$'\t--no-reboot\nstate\tset\treboot-required\nrefresh\tedge\nsudo\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev\nupdate\t-y\tOMARCHY_PATH='"$checkout" ]] ||
|
||||
[[ $(grep -E '^(git|link|state|refresh|sudo|update)' "$log_file") == $'git\tclone\thttps://github.com/omacom/omarchy.git\t'"$checkout"$'\nlink\t'"$checkout"$'\t--no-reboot\nstate\tset\treboot-required\nrefresh\tedge\nupdate-pacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev\nupdate\t-y\tOMARCHY_PATH='"$checkout" ]] ||
|
||||
fail "dev activates the checkout before changing or updating packages" "$(cat "$log_file")"
|
||||
pass "dev activates the checkout before changing or updating packages"
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
# Exercise the privileged installer without writing to the host's /usr/share.
|
||||
if ! command -v bwrap >/dev/null || ! bwrap --ro-bind / / --unshare-user --uid 0 --gid 0 true 2>/dev/null; then
|
||||
skip "user namespaces unavailable; skipping isolated Claude extension installation"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
mkdir -p "$test_tmp/share" "$test_tmp/bin"
|
||||
installer="$ROOT/bin/omarchy-install-chromium-claude"
|
||||
extension_id=fcoeoabgfenejglbffodgkkbkcdhcgfn
|
||||
sandbox=(bwrap --ro-bind / / --bind "$test_tmp" "$test_tmp" --dev /dev --proc /proc --unshare-user)
|
||||
|
||||
"${sandbox[@]}" --uid 0 --gid 0 --bind "$test_tmp/share" /usr/share bash "$installer"
|
||||
for browser in chromium google-chrome microsoft-edge; do
|
||||
file="$test_tmp/share/$browser/extensions/$extension_id.json"
|
||||
jq -e '.external_update_url == "https://clients2.google.com/service/update2/crx"' "$file" >/dev/null ||
|
||||
fail "$browser registers the official Claude Web Store extension"
|
||||
[[ $(stat -c '%a' "$file") == "644" ]] || fail "$browser extension registration is readable"
|
||||
done
|
||||
pass "Claude extension installer registers all supported browser families"
|
||||
|
||||
cat >"$test_tmp/bin/pkexec" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$@" >"$AUTH_LOG"
|
||||
exit 42
|
||||
SH
|
||||
chmod +x "$test_tmp/bin/pkexec"
|
||||
export AUTH_LOG="$test_tmp/auth-log"
|
||||
export PATH="$test_tmp/bin:$PATH"
|
||||
|
||||
# A read-only /usr/share and a failing auth stub prove that a repeated run
|
||||
# neither rewrites the files nor requests authentication.
|
||||
"${sandbox[@]}" --uid 1000 --gid 1000 --ro-bind "$test_tmp/share" /usr/share bash "$installer" </dev/null
|
||||
[[ ! -e $AUTH_LOG ]] || fail "configured Claude extensions need no authentication"
|
||||
pass "configured Claude extensions need neither writes nor authentication"
|
||||
|
||||
rm "$test_tmp/share/google-chrome/extensions/$extension_id.json"
|
||||
status=0
|
||||
"${sandbox[@]}" --uid 1000 --gid 1000 --ro-bind "$test_tmp/share" /usr/share bash "$installer" </dev/null || status=$?
|
||||
[[ $status == 42 ]] || fail "Claude extension installer propagates authentication failure"
|
||||
[[ $(cat "$AUTH_LOG") == "/usr/bin/omarchy-install-chromium-claude" ]] ||
|
||||
fail "menu installation elevates only the packaged installer"
|
||||
pass "missing registration uses pkexec and propagates authentication failure"
|
||||
@@ -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"
|
||||
@@ -50,3 +54,202 @@ bash -c '
|
||||
' bash "$ROOT/bin/omarchy-chromium-ytdlp-host" "javascript:alert(1)" "$ROOT" &&
|
||||
fail "yt-dlp native host rejects non-web URLs"
|
||||
pass "yt-dlp native host rejects non-web URLs"
|
||||
|
||||
host_fn() {
|
||||
OMARCHY_PATH="$ROOT" OMARCHY_YTDLP_DIR="${download_dir:-$TMPDIR}" bash -c '
|
||||
source "$1"
|
||||
shift
|
||||
"$@"
|
||||
' bash "$ROOT/bin/omarchy-chromium-ytdlp-host" "$@"
|
||||
}
|
||||
|
||||
download_dir="$TMPDIR/videos"
|
||||
mkdir -p "$download_dir" "$TMPDIR/outside"
|
||||
good_file="$download_dir/clip [id].mp4"
|
||||
printf 'x' >"$good_file"
|
||||
printf 'x' >"$TMPDIR/outside/secret"
|
||||
ln -s "$TMPDIR/outside/secret" "$download_dir/escape.mp4"
|
||||
|
||||
resolved=$(host_fn resolve_download_file "$good_file")
|
||||
expected=$(realpath -e -- "$good_file")
|
||||
[[ $resolved == "$expected" ]] ||
|
||||
fail "yt-dlp native host accepts a regular file in the download dir" "$resolved"
|
||||
pass "yt-dlp native host accepts a regular file in the download dir"
|
||||
|
||||
host_fn resolve_download_file "--include=not-a-file" &&
|
||||
fail "yt-dlp native host rejects a forged mpv option as the download path"
|
||||
pass "yt-dlp native host rejects a forged mpv option as the download path"
|
||||
|
||||
host_fn resolve_download_file "$TMPDIR/outside/secret" &&
|
||||
fail "yt-dlp native host rejects a path outside the download dir"
|
||||
pass "yt-dlp native host rejects a path outside the download dir"
|
||||
|
||||
host_fn resolve_download_file "$download_dir/escape.mp4" &&
|
||||
fail "yt-dlp native host rejects a symlink that escapes the download dir"
|
||||
pass "yt-dlp native host rejects a symlink that escapes the download dir"
|
||||
|
||||
host_fn resolve_download_file $'clip.mp4\nOMARCHY_FILE\t--include=not-a-file' &&
|
||||
fail "yt-dlp native host rejects a path containing control characters"
|
||||
pass "yt-dlp native host rejects a path containing control characters"
|
||||
|
||||
newline_target="$download_dir/target"$'\n'
|
||||
printf 'x' >"$newline_target"
|
||||
# The decoy is the point: dropping the trailing newline lands on a real, different
|
||||
# file, so a resolver that strips it resolves to the wrong one instead of failing.
|
||||
printf 'x' >"$download_dir/target"
|
||||
ln -s "target"$'\n' "$download_dir/newline-link.mp4"
|
||||
|
||||
host_fn resolve_download_file "$download_dir/newline-link.mp4" &&
|
||||
fail "yt-dlp native host rejects a symlink whose target name ends in a newline"
|
||||
pass "yt-dlp native host rejects a symlink whose target name ends in a newline"
|
||||
|
||||
ln -s / "$TMPDIR/root-link"
|
||||
root_resolved=$(download_dir="$TMPDIR/root-link" host_fn resolve_download_file /etc/passwd)
|
||||
[[ $root_resolved == "/etc/passwd" ]] ||
|
||||
fail "yt-dlp native host accepts a file when the download dir resolves to /" "$root_resolved"
|
||||
pass "yt-dlp native host accepts a file when the download dir resolves to /"
|
||||
|
||||
title=$(host_fn title_from_file "$good_file")
|
||||
[[ $title == "clip [id]" ]] || fail "yt-dlp native host titles the toast from the filename" "$title"
|
||||
pass "yt-dlp native host titles the toast from the filename"
|
||||
|
||||
decoded_title=$(host_fn decode_title '"My Great Clip"')
|
||||
[[ $decoded_title == "My Great Clip" ]] ||
|
||||
fail "yt-dlp native host shows the page title on the toast" "$decoded_title"
|
||||
pass "yt-dlp native host shows the page title on the toast"
|
||||
|
||||
forged_title=$(host_fn decode_title '"Clip\nOMARCHY_FILE\tPlay me\t--include=not-a-file"')
|
||||
[[ $forged_title == "Clip" ]] ||
|
||||
fail "yt-dlp native host keeps only the readable part of a forged title" "$forged_title"
|
||||
pass "yt-dlp native host keeps only the readable part of a forged title"
|
||||
|
||||
host_fn decode_title '"--include=not-a-file"' &&
|
||||
fail "yt-dlp native host refuses a leading-dash title the notifier would reject as an option"
|
||||
pass "yt-dlp native host refuses a leading-dash title the notifier would reject as an option"
|
||||
|
||||
host_fn decode_title 'null' &&
|
||||
fail "yt-dlp native host refuses a title that is not a JSON string"
|
||||
pass "yt-dlp native host refuses a title that is not a JSON string"
|
||||
|
||||
dash_title=$(host_fn title_from_file "$download_dir/--include.mp4")
|
||||
[[ $dash_title == "Video" ]] || fail "yt-dlp native host does not pass a leading-dash title to the notifier" "$dash_title"
|
||||
pass "yt-dlp native host does not pass a leading-dash title to the notifier"
|
||||
|
||||
# The click action passes the path as a discrete --exec argument (asserted
|
||||
# end-to-end below against the real download); `--` keeps mpv from parsing a
|
||||
# leading-dash filename as an option.
|
||||
|
||||
parse_script="$TMPDIR/parse-ytdlp-lines.sh"
|
||||
cat >"$parse_script" <<'EOF'
|
||||
source "$1"
|
||||
filepath=""
|
||||
while IFS= read -r line; do
|
||||
case $line in
|
||||
OMARCHY_FILE*)
|
||||
resolved=$(resolve_download_file "${line#OMARCHY_FILE$'\t'}") || continue
|
||||
filepath=$resolved
|
||||
;;
|
||||
esac
|
||||
done
|
||||
printf '%s' "$filepath"
|
||||
EOF
|
||||
|
||||
# A title that ends in a newline closes its own record, so the forged record is the
|
||||
# last one and the real path lands on a line the loop ignores.
|
||||
poisoned=$(
|
||||
printf '%s\n' \
|
||||
$'OMARCHY_FILE\t'"$good_file" \
|
||||
$'OMARCHY_FILE\tPlay me\t--include=not-a-file' \
|
||||
$'\t'"$good_file" |
|
||||
OMARCHY_PATH="$ROOT" OMARCHY_YTDLP_DIR="$download_dir" bash "$parse_script" "$ROOT/bin/omarchy-chromium-ytdlp-host"
|
||||
)
|
||||
|
||||
[[ $poisoned == "$expected" ]] ||
|
||||
fail "yt-dlp native host keeps a real file after a forged OMARCHY_FILE record" "$poisoned"
|
||||
pass "yt-dlp native host keeps a real file after a forged OMARCHY_FILE record"
|
||||
|
||||
# Everything above tests the helpers in isolation. Drive the real download_url with
|
||||
# stubbed tools so the yt-dlp invocation and the toast's click command are covered
|
||||
# too: a record template that carried the title again would pass every test above.
|
||||
fake_root="$TMPDIR/fake"
|
||||
mkdir -p "$fake_root/bin"
|
||||
fake_dir="$TMPDIR/fake-videos"
|
||||
mkdir -p "$fake_dir"
|
||||
fake_file="$fake_dir/Real_Clip [id].mp4"
|
||||
printf 'x' >"$fake_file"
|
||||
ytdlp_argv="$TMPDIR/ytdlp-argv"
|
||||
notify_argv="$TMPDIR/notify-argv"
|
||||
|
||||
cat >"$fake_root/bin/yt-dlp" <<'EOF'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$*" >>"$YTDLP_ARGV_LOG"
|
||||
for arg in "$@"; do
|
||||
if [[ $arg == "--no-simulate" ]]; then
|
||||
printf 'OMARCHY_FILE\t%s\n' "$YTDLP_FAKE_FILE"
|
||||
[[ -n ${YTDLP_SKIP_TITLE:-} ]] || printf 'OMARCHY_TITLE\t%s\n' '"My Great Clip"'
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
exit 0
|
||||
EOF
|
||||
|
||||
cat >"$fake_root/bin/omarchy-notification-send" <<'EOF'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$*" >>"$NOTIFY_ARGV_LOG"
|
||||
EOF
|
||||
|
||||
for stub in omarchy-osd omarchy-shell ffmpeg; do
|
||||
printf '#!/bin/bash\nexit 0\n' >"$fake_root/bin/$stub"
|
||||
done
|
||||
chmod +x "$fake_root/bin/"*
|
||||
|
||||
YTDLP_ARGV_LOG="$ytdlp_argv" NOTIFY_ARGV_LOG="$notify_argv" YTDLP_FAKE_FILE="$fake_file" \
|
||||
OMARCHY_PATH="$fake_root" OMARCHY_YTDLP_DIR="$fake_dir" \
|
||||
bash -c '
|
||||
source "$1"
|
||||
download_url "$2"
|
||||
' bash "$ROOT/bin/omarchy-chromium-ytdlp-host" "https://example.test/watch" >/dev/null 2>&1
|
||||
|
||||
(($(grep -c -- '--no-exec-before-download' "$ytdlp_argv") == 2)) ||
|
||||
fail "yt-dlp native host disarms configured exec hooks on both yt-dlp runs" "$(cat "$ytdlp_argv")"
|
||||
pass "yt-dlp native host disarms configured exec hooks on both yt-dlp runs"
|
||||
|
||||
# The stub answers with a title record whatever it is asked for, so assert the request
|
||||
# as well as the reply: without this the template could be dropped and nothing notice.
|
||||
grep -qF -- $'OMARCHY_TITLE\t%(title)j' "$ytdlp_argv" ||
|
||||
fail "yt-dlp native host asks for the title JSON-encoded" "$(cat "$ytdlp_argv")"
|
||||
pass "yt-dlp native host asks for the title JSON-encoded"
|
||||
|
||||
grep -qF -- "-o %(title)s.%(ext)s" "$ytdlp_argv" ||
|
||||
fail "yt-dlp native host names the saved file after the page title" "$(cat "$ytdlp_argv")"
|
||||
pass "yt-dlp native host names the saved file after the page title"
|
||||
|
||||
grep -q -- '--restrict-filenames' "$ytdlp_argv" &&
|
||||
fail "yt-dlp native host keeps spaces in the saved filename" "$(cat "$ytdlp_argv")"
|
||||
pass "yt-dlp native host keeps spaces in the saved filename"
|
||||
|
||||
grep -qF -- $'OMARCHY_FILE\t%(title)s' "$ytdlp_argv" &&
|
||||
fail "yt-dlp native host never prints the title into the file record" "$(cat "$ytdlp_argv")"
|
||||
pass "yt-dlp native host never prints the title into the file record"
|
||||
|
||||
grep -q -- "--exec mpv -- " "$notify_argv" ||
|
||||
fail "yt-dlp native host builds the click command as mpv -- <path>" "$(cat "$notify_argv")"
|
||||
pass "yt-dlp native host builds the click command as mpv -- <path>"
|
||||
|
||||
grep -qF -- "Download complete My Great Clip" "$notify_argv" ||
|
||||
fail "yt-dlp native host toasts the page title, not the sanitised filename" "$(cat "$notify_argv")"
|
||||
pass "yt-dlp native host toasts the page title, not the sanitised filename"
|
||||
|
||||
# A page that gives no usable title falls back to the filename rather than an empty toast.
|
||||
: >"$notify_argv"
|
||||
: >"$ytdlp_argv"
|
||||
YTDLP_ARGV_LOG="$ytdlp_argv" NOTIFY_ARGV_LOG="$notify_argv" YTDLP_FAKE_FILE="$fake_file" \
|
||||
YTDLP_SKIP_TITLE=1 OMARCHY_PATH="$fake_root" OMARCHY_YTDLP_DIR="$fake_dir" \
|
||||
bash -c '
|
||||
source "$1"
|
||||
download_url "$2"
|
||||
' bash "$ROOT/bin/omarchy-chromium-ytdlp-host" "https://example.test/watch" >/dev/null 2>&1
|
||||
|
||||
grep -qF -- "Download complete Real_Clip [id]" "$notify_argv" ||
|
||||
fail "yt-dlp native host falls back to the filename when no title record arrives" "$(cat "$notify_argv")"
|
||||
pass "yt-dlp native host falls back to the filename when no title record arrives"
|
||||
@@ -277,12 +277,12 @@ printf '%s\n' "$1" >"$EDITOR_PATH_OUT"
|
||||
cat "$1" >"$EDITOR_TEXT_OUT"
|
||||
SH
|
||||
|
||||
cat >"$TMPDIR/bin/tensaku-edit" <<'SH'
|
||||
cat >"$TMPDIR/bin/omasnap" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$*" >"$TENSAKU_OUT"
|
||||
printf '%s\n' "$*" >"$OMASNAP_OUT"
|
||||
SH
|
||||
|
||||
chmod +x "$TMPDIR/bin/wl-copy" "$TMPDIR/bin/wl-paste" "$TMPDIR/bin/wtype" "$TMPDIR/bin/omarchy-launch-browser" "$TMPDIR/bin/omarchy-launch-editor" "$TMPDIR/bin/tensaku-edit"
|
||||
chmod +x "$TMPDIR/bin/wl-copy" "$TMPDIR/bin/wl-paste" "$TMPDIR/bin/wtype" "$TMPDIR/bin/omarchy-launch-browser" "$TMPDIR/bin/omarchy-launch-editor" "$TMPDIR/bin/omasnap"
|
||||
|
||||
capture_output=$(XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh")
|
||||
[[ $capture_output == '{"type":"text","text":"terminal copy"}' ]] || fail "clipboard capture records normal text events"
|
||||
@@ -513,8 +513,8 @@ pass "clipboard open helper opens text entries in editor"
|
||||
[[ $(<"$TMPDIR/editor-path") == "$TMPDIR"/state/omarchy/clipboard-open/clipboard.*.txt ]] || fail "clipboard open helper writes text entries to a temporary file"
|
||||
pass "clipboard open helper writes text entries to a temporary file"
|
||||
|
||||
TENSAKU_OUT="$TMPDIR/tensaku" HOME="$TMPDIR/home" PATH="$TMPDIR/bin:$PATH" \
|
||||
OMASNAP_OUT="$TMPDIR/omasnap" HOME="$TMPDIR/home" PATH="$TMPDIR/bin:$PATH" \
|
||||
"$ROOT/bin/omarchy-clipboard-open" --history-index 2
|
||||
|
||||
[[ $(<"$TMPDIR/tensaku") == "$TMPDIR/image.png" ]] || fail "clipboard open helper opens image entries in Tensaku"
|
||||
pass "clipboard open helper opens image entries in Tensaku"
|
||||
[[ $(<"$TMPDIR/omasnap") == "$TMPDIR/image.png" ]] || fail "clipboard open helper opens image entries in Omasnap"
|
||||
pass "clipboard open helper opens image entries in Omasnap"
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
export OMARCHY_PATH="$ROOT"
|
||||
|
||||
if "$ROOT/bin/omarchy-cmd-present" -p; then
|
||||
fail "cmd-present treats an option-shaped command name literally"
|
||||
fi
|
||||
pass "cmd-present treats an option-shaped command name literally"
|
||||
|
||||
"$ROOT/bin/omarchy-cmd-missing" -p ||
|
||||
fail "cmd-missing treats an option-shaped command name literally"
|
||||
pass "cmd-missing treats an option-shaped command name literally"
|
||||
|
||||
"$ROOT/bin/omarchy-cmd-present" bash || fail "cmd-present still finds an ordinary command"
|
||||
pass "cmd-present still finds an ordinary command"
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
mock_bin="$test_tmp/bin"
|
||||
mkdir -p "$mock_bin"
|
||||
|
||||
cat >"$mock_bin/pacman" <<'STUB'
|
||||
#!/bin/bash
|
||||
|
||||
if [[ -n ${OMARCHY_TEST_PACKAGE_CALLS:-} ]]; then
|
||||
{
|
||||
printf 'pacman'
|
||||
printf ' <%s>' "$@"
|
||||
printf '\n'
|
||||
} >>"$OMARCHY_TEST_PACKAGE_CALLS"
|
||||
fi
|
||||
|
||||
if [[ $1 == "-S" ]]; then
|
||||
exit 0
|
||||
elif [[ $1 != "-Q" ]]; then
|
||||
exit 2
|
||||
fi
|
||||
shift
|
||||
|
||||
if [[ -n ${OMARCHY_TEST_PACKAGE_CALLS:-} ]]; then
|
||||
exit 0
|
||||
elif [[ ${1:-} == "--" ]]; then
|
||||
shift
|
||||
[[ ${1:-} == "installed" ]]
|
||||
elif [[ ${1:-} == "--help" ]]; then
|
||||
exit 0
|
||||
else
|
||||
[[ ${1:-} == "installed" ]]
|
||||
fi
|
||||
STUB
|
||||
chmod +x "$mock_bin/pacman"
|
||||
|
||||
if PATH="$mock_bin:$PATH" "$ROOT/bin/omarchy-pkg-present" --help; then
|
||||
fail "pkg-present treats an option-shaped package name literally"
|
||||
fi
|
||||
pass "pkg-present treats an option-shaped package name literally"
|
||||
|
||||
PATH="$mock_bin:$PATH" "$ROOT/bin/omarchy-pkg-missing" --help ||
|
||||
fail "pkg-missing treats an option-shaped package name literally"
|
||||
pass "pkg-missing treats an option-shaped package name literally"
|
||||
|
||||
PATH="$mock_bin:$PATH" "$ROOT/bin/omarchy-pkg-present" installed ||
|
||||
fail "pkg-present still finds an installed package"
|
||||
pass "pkg-present still finds an installed package"
|
||||
|
||||
package_calls="$test_tmp/package-calls"
|
||||
cat >"$mock_bin/omarchy-pkg-missing" <<'STUB'
|
||||
#!/bin/bash
|
||||
|
||||
exit 0
|
||||
STUB
|
||||
cat >"$mock_bin/sudo" <<'STUB'
|
||||
#!/bin/bash
|
||||
|
||||
exec "$@"
|
||||
STUB
|
||||
cat >"$mock_bin/yay" <<'STUB'
|
||||
#!/bin/bash
|
||||
|
||||
: "${OMARCHY_TEST_PACKAGE_CALLS:?}"
|
||||
{
|
||||
printf 'yay'
|
||||
printf ' <%s>' "$@"
|
||||
printf '\n'
|
||||
} >>"$OMARCHY_TEST_PACKAGE_CALLS"
|
||||
STUB
|
||||
chmod +x "$mock_bin/omarchy-pkg-missing" "$mock_bin/sudo" "$mock_bin/yay"
|
||||
|
||||
: >"$package_calls"
|
||||
PATH="$mock_bin:$ROOT/bin:$PATH" OMARCHY_TEST_PACKAGE_CALLS="$package_calls" \
|
||||
"$ROOT/bin/omarchy-pkg-add" --hookdir=/tmp/hooks
|
||||
|
||||
grep -Fx 'pacman <-S> <--noconfirm> <--needed> <--> <--hookdir=/tmp/hooks>' "$package_calls" >/dev/null ||
|
||||
fail "pkg-add separates package operands from pacman options" "$(<"$package_calls")"
|
||||
grep -Fx 'pacman <-Q> <--> <--hookdir=/tmp/hooks>' "$package_calls" >/dev/null ||
|
||||
fail "pkg-add verifies an option-shaped package name literally" "$(<"$package_calls")"
|
||||
pass "pkg-add keeps option-shaped package names out of pacman option parsing"
|
||||
|
||||
: >"$package_calls"
|
||||
PATH="$mock_bin:$ROOT/bin:$PATH" OMARCHY_TEST_PACKAGE_CALLS="$package_calls" \
|
||||
"$ROOT/bin/omarchy-pkg-aur-add" --hookdir=/tmp/hooks
|
||||
|
||||
grep -Fx 'yay <-S> <--noconfirm> <--needed> <--> <--hookdir=/tmp/hooks>' "$package_calls" >/dev/null ||
|
||||
fail "pkg-aur-add separates package operands from yay options" "$(<"$package_calls")"
|
||||
grep -Fx 'pacman <-Q> <--> <--hookdir=/tmp/hooks>' "$package_calls" >/dev/null ||
|
||||
fail "pkg-aur-add verifies an option-shaped package name literally" "$(<"$package_calls")"
|
||||
pass "pkg-aur-add keeps option-shaped package names out of package-manager option parsing"
|
||||
|
||||
cat >"$mock_bin/grep" <<'STUB'
|
||||
#!/bin/bash
|
||||
|
||||
if [[ $1 != "-qi" ]]; then
|
||||
exit 2
|
||||
fi
|
||||
shift
|
||||
|
||||
if [[ ${1:-} == "--help" ]]; then
|
||||
exit 0
|
||||
elif [[ ${1:-} == "--" ]]; then
|
||||
shift
|
||||
fi
|
||||
[[ ${1:-} == "known-hardware" ]]
|
||||
STUB
|
||||
chmod +x "$mock_bin/grep"
|
||||
|
||||
if PATH="$mock_bin:$PATH" "$ROOT/bin/omarchy-hw-match" --help; then
|
||||
fail "hw-match treats an option-shaped hardware pattern literally"
|
||||
fi
|
||||
pass "hw-match treats an option-shaped hardware pattern literally"
|
||||
|
||||
PATH="$mock_bin:$PATH" "$ROOT/bin/omarchy-hw-match" known-hardware ||
|
||||
fail "hw-match still accepts an ordinary hardware pattern"
|
||||
pass "hw-match still accepts an ordinary hardware pattern"
|
||||
|
||||
pkill_calls="$test_tmp/pkill-calls"
|
||||
cat >"$mock_bin/pkill" <<'STUB'
|
||||
#!/bin/bash
|
||||
|
||||
: "${OMARCHY_TEST_PKILL_CALLS:?}"
|
||||
printf '<%s>\n' "$@" >"$OMARCHY_TEST_PKILL_CALLS"
|
||||
STUB
|
||||
cat >"$mock_bin/setsid" <<'STUB'
|
||||
#!/bin/bash
|
||||
|
||||
if [[ -n ${OMARCHY_TEST_SETSID_CALLS:-} ]]; then
|
||||
printf '<%s>\n' "$@" >"$OMARCHY_TEST_SETSID_CALLS"
|
||||
fi
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "$mock_bin/pkill" "$mock_bin/setsid"
|
||||
|
||||
PATH="$mock_bin:$PATH" OMARCHY_TEST_PKILL_CALLS="$pkill_calls" \
|
||||
"$ROOT/bin/omarchy-restart-app" "-9 kitty"
|
||||
|
||||
pkill_argv=$(<"$pkill_calls")
|
||||
[[ $pkill_argv == $'<-x>\n<-->\n<-9 kitty>' ]] ||
|
||||
fail "restart-app treats the application name as one literal pkill pattern" "$pkill_argv"
|
||||
pass "restart-app treats the application name as one literal pkill pattern"
|
||||
|
||||
fake_home="$test_tmp/home"
|
||||
editor_calls="$test_tmp/editor-calls"
|
||||
mkdir -p "$fake_home/.local/state/omarchy/defaults"
|
||||
printf 'nvim\n' >"$fake_home/.local/state/omarchy/defaults/editor"
|
||||
|
||||
cat >"$mock_bin/nvim" <<'STUB'
|
||||
#!/bin/bash
|
||||
|
||||
: "${OMARCHY_TEST_EDITOR_CALLS:?}"
|
||||
printf '<%s>\n' "$@" >"$OMARCHY_TEST_EDITOR_CALLS"
|
||||
STUB
|
||||
chmod +x "$mock_bin/nvim"
|
||||
|
||||
HOME="$fake_home" PATH="$mock_bin:$ROOT/bin:$PATH" OMARCHY_TEST_EDITOR_CALLS="$editor_calls" \
|
||||
"$ROOT/bin/omarchy-launch-editor" --inline -cquit
|
||||
|
||||
editor_argv=$(<"$editor_calls")
|
||||
[[ $editor_argv == $'<-->\n<-cquit>' ]] ||
|
||||
fail "launch-editor separates an option-shaped path from editor options" "$editor_argv"
|
||||
pass "launch-editor separates an option-shaped path from editor options"
|
||||
|
||||
cat >"$mock_bin/omarchy-launch-tui" <<'STUB'
|
||||
#!/bin/bash
|
||||
|
||||
: "${OMARCHY_TEST_EDITOR_CALLS:?}"
|
||||
printf '<%s>\n' "$@" >"$OMARCHY_TEST_EDITOR_CALLS"
|
||||
STUB
|
||||
chmod +x "$mock_bin/omarchy-launch-tui"
|
||||
|
||||
HOME="$fake_home" PATH="$mock_bin:$ROOT/bin:$PATH" OMARCHY_TEST_EDITOR_CALLS="$editor_calls" \
|
||||
"$ROOT/bin/omarchy-launch-editor" -cquit
|
||||
|
||||
editor_argv=$(<"$editor_calls")
|
||||
[[ $editor_argv == $'<nvim>\n<-->\n<-cquit>' ]] ||
|
||||
fail "launch-editor separates a terminal editor path from editor options" "$editor_argv"
|
||||
pass "launch-editor separates a terminal editor path from editor options"
|
||||
|
||||
setsid_calls="$test_tmp/setsid-calls"
|
||||
printf 'code\n' >"$fake_home/.local/state/omarchy/defaults/editor"
|
||||
cat >"$mock_bin/code" <<'STUB'
|
||||
#!/bin/bash
|
||||
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "$mock_bin/code"
|
||||
|
||||
HOME="$fake_home" PATH="$mock_bin:$ROOT/bin:$PATH" OMARCHY_TEST_SETSID_CALLS="$setsid_calls" \
|
||||
"$ROOT/bin/omarchy-launch-editor" -cquit
|
||||
|
||||
setsid_argv=$(<"$setsid_calls")
|
||||
[[ $setsid_argv == $'<uwsm-app>\n<-->\n<code>\n<-->\n<-cquit>' ]] ||
|
||||
fail "launch-editor separates a graphical editor path from editor options" "$setsid_argv"
|
||||
pass "launch-editor separates a graphical editor path from editor options"
|
||||
|
||||
hook_calls="$test_tmp/hook-calls"
|
||||
hook_home="$test_tmp/hook-home"
|
||||
printf '#!/bin/bash\n' >"$test_tmp/--help"
|
||||
cat >"$mock_bin/basename" <<'STUB'
|
||||
#!/bin/bash
|
||||
|
||||
: "${OMARCHY_TEST_HOOK_CALLS:?}"
|
||||
{
|
||||
printf 'basename'
|
||||
printf ' <%s>' "$@"
|
||||
printf '\n'
|
||||
} >>"$OMARCHY_TEST_HOOK_CALLS"
|
||||
printf 'literal-hook\n'
|
||||
STUB
|
||||
cat >"$mock_bin/cp" <<'STUB'
|
||||
#!/bin/bash
|
||||
|
||||
: "${OMARCHY_TEST_HOOK_CALLS:?}"
|
||||
{
|
||||
printf 'cp'
|
||||
printf ' <%s>' "$@"
|
||||
printf '\n'
|
||||
} >>"$OMARCHY_TEST_HOOK_CALLS"
|
||||
STUB
|
||||
for command in mkdir chmod; do
|
||||
printf '#!/bin/bash\nexit 0\n' >"$mock_bin/$command"
|
||||
done
|
||||
chmod +x "$mock_bin/basename" "$mock_bin/cp" "$mock_bin/mkdir" "$mock_bin/chmod"
|
||||
|
||||
: >"$hook_calls"
|
||||
(
|
||||
cd -- "$test_tmp"
|
||||
HOME="$hook_home" PATH="$mock_bin:$PATH" OMARCHY_TEST_HOOK_CALLS="$hook_calls" \
|
||||
"$ROOT/bin/omarchy-hook-install" post-update --help >/dev/null
|
||||
)
|
||||
|
||||
hook_argv=$(<"$hook_calls")
|
||||
expected_hook_argv=$'basename <--> <--help>\ncp <--> <--help> <'"$hook_home"'/.config/omarchy/hooks/post-update.d/literal-hook>'
|
||||
[[ $hook_argv == $expected_hook_argv ]] ||
|
||||
fail "hook-install treats its source file as an operand" "$hook_argv"
|
||||
pass "hook-install treats its source file as an operand"
|
||||
@@ -62,7 +62,7 @@ run_guard() {
|
||||
' bash "$SHELL_TEST_DIR" 2>&1 || printf 'guard exited %s\n' "$?"
|
||||
}
|
||||
|
||||
skipped="ok - no Wayland compositor; skipping sample runtime test"
|
||||
skipped="ok - no Wayland compositor; skipping sample runtime test # SKIP"
|
||||
|
||||
output=$(run_guard -u WAYLAND_DISPLAY XDG_RUNTIME_DIR="$runtime_dir")
|
||||
[[ $output == "$skipped" ]] || fail "guard skips without a display" "$output"
|
||||
@@ -76,7 +76,7 @@ pass "guard skips when the socket is unreachable"
|
||||
|
||||
# Everything below needs a socket to stand in for a live or abandoned compositor.
|
||||
if (( ! socket_bound )); then
|
||||
pass "cannot bind a Unix socket here; skipping the cases that need one"
|
||||
skip "cannot bind a Unix socket here; skipping the cases that need one"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
+19
-36
@@ -33,11 +33,29 @@ jq -e '
|
||||
' "$ROOT/config/omarchy/shell.json" >/dev/null
|
||||
pass "default center anchor exists in center layout"
|
||||
|
||||
jq -e '
|
||||
def ids: map(.id // .);
|
||||
(.bar.layout.center | ids) as $ids |
|
||||
($ids | index("omacom.elsewhen")) as $elsewhen |
|
||||
($ids | index("omarchy.clock")) as $clock |
|
||||
$elsewhen != null and $clock == $elsewhen + 1
|
||||
' "$ROOT/config/omarchy/shell.json" >/dev/null
|
||||
pass "default center layout puts elsewhen immediately before the clock"
|
||||
|
||||
jq -e '
|
||||
any(.bar.layout.center[]; (.id // .) == "omarchy.clock" and (.formatAlt // "") == "d MMMM \u0027W\u0027ww yyyy")
|
||||
' "$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 +160,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 +437,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"
|
||||
@@ -1,228 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh"
|
||||
|
||||
require_command jq
|
||||
require_command python3
|
||||
|
||||
migration="$ROOT/migrations/1786643346.sh"
|
||||
test_dir=$(mktemp -d)
|
||||
trap 'rm -rf "$test_dir"' EXIT
|
||||
|
||||
home="$test_dir/home"
|
||||
profile_root="$home/.config/chromium"
|
||||
preferences="$profile_root/Default/Preferences"
|
||||
mkdir -p "$(dirname "$preferences")"
|
||||
|
||||
# Any id Chromium once derived from the extension's keyless load path; the
|
||||
# repair keys off the registered command name, not the id.
|
||||
ghost_id="ikkebdkaanlebnifjnbeiaklodhbjcci"
|
||||
pinned_id="bgpiichlckmfanooecilcjemknkcpngb"
|
||||
|
||||
write_stale_preferences() {
|
||||
jq -n --arg ghost "$ghost_id" --arg pinned "$pinned_id" '{extensions: {commands: {"linux:Alt+Shift+L": {command_name: "copy-url", extension: $ghost, global: false}}, settings: {($ghost): {commands: {"copy-url": {suggested_key: "Alt+Shift+L", was_assigned: true}}}, ($pinned): {commands: {"copy-url": {suggested_key: "Alt+Shift+L"}}}}}}' >"$preferences"
|
||||
}
|
||||
|
||||
stub_bin="$test_dir/bin"
|
||||
mkdir -p "$stub_bin"
|
||||
|
||||
REAL_PYTHON=$(command -v python3)
|
||||
export REAL_PYTHON
|
||||
|
||||
run_migration() {
|
||||
HOME="$home" PATH="$stub_bin:$PATH" bash -euo pipefail "$migration" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# A running Chromium-family browser marks its profile root with a SingletonLock
|
||||
# symlink to <hostname>-<pid>, a target that never exists on disk. That lock —
|
||||
# not the mere presence of a browser process — is what the migration waits on.
|
||||
open_browser() {
|
||||
mkdir -p "$profile_root"
|
||||
ln -sfn "test-host-1234" "$profile_root/SingletonLock"
|
||||
}
|
||||
close_browser() {
|
||||
rm -f "$profile_root/SingletonLock"
|
||||
}
|
||||
|
||||
# The affected profile being open prompts for the windows to be closed;
|
||||
# declining (or having no terminal to ask in) defers the repair so a
|
||||
# rewrite-on-exit cannot revert it.
|
||||
printf '#!/bin/bash\nexit 1\n' >"$stub_bin/gum"
|
||||
chmod +x "$stub_bin/gum"
|
||||
write_stale_preferences
|
||||
open_browser
|
||||
|
||||
before_hash=$(sha256sum "$preferences" | cut -d' ' -f1)
|
||||
|
||||
run_migration && fail "migration defers while the affected profile is open"
|
||||
[[ $(sha256sum "$preferences" | cut -d' ' -f1) == "$before_hash" ]] ||
|
||||
fail "migration leaves preferences alone while the affected profile is open"
|
||||
pass "migration defers the repair while the affected profile is open"
|
||||
|
||||
# gum paints its prompt on stderr, so that stream has to stay attached:
|
||||
# suppressing it leaves gum reading keys behind an unpainted screen, which
|
||||
# reads as a hung update.
|
||||
cat >"$stub_bin/gum" <<'STUB'
|
||||
#!/bin/bash
|
||||
echo "gum-prompt-painted" >&2
|
||||
exit 1
|
||||
STUB
|
||||
prompt_stderr="$test_dir/prompt-stderr"
|
||||
HOME="$home" PATH="$stub_bin:$PATH" bash -euo pipefail "$migration" >/dev/null 2>"$prompt_stderr" &&
|
||||
fail "migration defers when the browser prompt is declined"
|
||||
grep -q "gum-prompt-painted" "$prompt_stderr" || fail "migration keeps the browser prompt visible"
|
||||
pass "migration keeps the browser prompt visible"
|
||||
|
||||
# A browser holding a different profile root cannot revert this repair, so it
|
||||
# must not hold the update: the repair goes through without ever reaching the
|
||||
# prompt, which the still-declining gum stub would otherwise fail.
|
||||
close_browser
|
||||
mkdir -p "$home/.config/google-chrome"
|
||||
ln -sfn "test-host-1234" "$home/.config/google-chrome/SingletonLock"
|
||||
write_stale_preferences
|
||||
run_migration || fail "migration repairs while a different profile root is open"
|
||||
jq -e --arg pinned "$pinned_id" '.extensions.commands["linux:Alt+Shift+L"].extension == $pinned' "$preferences" >/dev/null ||
|
||||
fail "migration repairs the shortcut while a different profile root is open"
|
||||
pass "migration ignores a browser on a different profile root"
|
||||
rm -f "$home/.config/google-chrome/SingletonLock" "$preferences.omarchy-copy-url-repair.bak"
|
||||
|
||||
# Closing the affected profile and confirming the prompt lets the repair
|
||||
# proceed.
|
||||
write_stale_preferences
|
||||
open_browser
|
||||
cat >"$stub_bin/gum" <<'STUB'
|
||||
#!/bin/bash
|
||||
"$CLOSE_BROWSER"
|
||||
touch "${GUM_CALLED:?}"
|
||||
exit 0
|
||||
STUB
|
||||
cat >"$stub_bin/close-browser" <<'STUB'
|
||||
#!/bin/bash
|
||||
rm -f "$HOME/.config/chromium/SingletonLock"
|
||||
STUB
|
||||
chmod +x "$stub_bin/gum" "$stub_bin/close-browser"
|
||||
GUM_CALLED="$test_dir/gum-called" CLOSE_BROWSER="$stub_bin/close-browser" \
|
||||
HOME="$home" PATH="$stub_bin:$PATH" bash -euo pipefail "$migration" >/dev/null 2>&1 ||
|
||||
fail "migration proceeds once the profile is closed and the prompt confirmed"
|
||||
[[ -e $test_dir/gum-called ]] || fail "migration asks before repairing under a running browser"
|
||||
jq -e --arg pinned "$pinned_id" '.extensions.commands["linux:Alt+Shift+L"].extension == $pinned' "$preferences" >/dev/null ||
|
||||
fail "migration repairs after the browser prompt is confirmed"
|
||||
pass "migration asks to close the browser and repairs on confirmation"
|
||||
rm -f "$preferences.omarchy-copy-url-repair.bak"
|
||||
|
||||
# With the affected profile closed the ghost registration moves to the pinned id.
|
||||
printf '#!/bin/bash\nexit 1\n' >"$stub_bin/gum"
|
||||
close_browser
|
||||
write_stale_preferences
|
||||
run_migration || fail "migration repairs the shortcut when no browser is running"
|
||||
|
||||
jq -e --arg ghost "$ghost_id" --arg pinned "$pinned_id" '
|
||||
.extensions.commands["linux:Alt+Shift+L"].extension == $pinned and
|
||||
(.extensions.settings | has($ghost) | not) and
|
||||
.extensions.settings[$pinned].commands["copy-url"].was_assigned == true
|
||||
' "$preferences" >/dev/null || fail "migration rebinds the Copy URL shortcut to the pinned extension id"
|
||||
[[ -f $preferences.omarchy-copy-url-repair.bak ]] ||
|
||||
fail "migration backs up preferences before the repair"
|
||||
pass "migration rebinds the Copy URL shortcut to the pinned extension id"
|
||||
|
||||
# A repaired profile has no ghost registration left, so nothing is pending —
|
||||
# even while that same profile is open.
|
||||
rm "$preferences.omarchy-copy-url-repair.bak"
|
||||
repaired_hash=$(sha256sum "$preferences" | cut -d' ' -f1)
|
||||
open_browser
|
||||
run_migration || fail "migration reruns cleanly after the repair"
|
||||
[[ $(sha256sum "$preferences" | cut -d' ' -f1) == "$repaired_hash" && ! -e $preferences.omarchy-copy-url-repair.bak ]] ||
|
||||
fail "migration is idempotent after the repair"
|
||||
pass "migration is idempotent after the repair"
|
||||
close_browser
|
||||
|
||||
# A remapped shortcut keeps the user's chosen key while moving to the pinned id.
|
||||
jq -n --arg ghost "$ghost_id" '{extensions: {commands: {"linux:Ctrl+Alt+P": {command_name: "copy-url", extension: $ghost, global: false}}, settings: {}}}' >"$preferences"
|
||||
run_migration || fail "migration repairs remapped shortcuts"
|
||||
jq -e --arg pinned "$pinned_id" '.extensions.commands["linux:Ctrl+Alt+P"].extension == $pinned' "$preferences" >/dev/null ||
|
||||
fail "migration keeps the remapped key while rebinding to the pinned id"
|
||||
pass "migration keeps remapped shortcut keys"
|
||||
|
||||
# When the pinned extension already holds a copy-url binding (the user fixed
|
||||
# it by hand), the ghost is dropped rather than doubled into a second binding.
|
||||
jq -n --arg ghost "$ghost_id" --arg pinned "$pinned_id" '{extensions: {commands: {"linux:Ctrl+Alt+P": {command_name: "copy-url", extension: $pinned, global: false}, "linux:Alt+Shift+L": {command_name: "copy-url", extension: $ghost, global: false}}, settings: {}}}' >"$preferences"
|
||||
run_migration || fail "migration cleans ghosts alongside a manual repair"
|
||||
jq -e --arg pinned "$pinned_id" '
|
||||
(.extensions.commands | has("linux:Alt+Shift+L") | not) and
|
||||
.extensions.commands["linux:Ctrl+Alt+P"].extension == $pinned
|
||||
' "$preferences" >/dev/null || fail "migration drops the ghost instead of double-binding the pinned extension"
|
||||
pass "migration never double-binds the pinned extension"
|
||||
|
||||
# A browser starting mid-repair may write stale Preferences back on exit, so
|
||||
# the migration must stay pending for a later browser-free run to verify. A
|
||||
# stub hands the repair call through and opens the profile right after it.
|
||||
write_stale_preferences
|
||||
close_browser
|
||||
rm -f "$preferences.omarchy-copy-url-repair.bak"
|
||||
cat >"$stub_bin/python3" <<'STUB'
|
||||
#!/bin/bash
|
||||
# Called as `python3 -c <script> <preferences> <pinned_id> <check|repair>`, and
|
||||
# the check calls report a surviving ghost through their exit status.
|
||||
"${REAL_PYTHON}" "$@"
|
||||
status=$?
|
||||
[[ ${5:-} == "repair" ]] && ln -sfn "test-host-1234" "$HOME/.config/chromium/SingletonLock"
|
||||
exit $status
|
||||
STUB
|
||||
chmod +x "$stub_bin/python3"
|
||||
if HOME="$home" PATH="$stub_bin:$PATH" bash -euo pipefail "$migration" >/dev/null 2>&1; then
|
||||
fail "migration stays pending when a browser starts mid-repair"
|
||||
fi
|
||||
jq -e --arg pinned "$pinned_id" '.extensions.commands["linux:Alt+Shift+L"].extension == $pinned' "$preferences" >/dev/null ||
|
||||
fail "migration still repairs preferences before deferring on a late browser"
|
||||
pass "migration stays pending when a browser starts mid-repair"
|
||||
rm -f "$stub_bin/python3" "$preferences.omarchy-copy-url-repair.bak"
|
||||
close_browser
|
||||
|
||||
# A browser that started and exited mid-repair restores stale Preferences
|
||||
# before the final profile check; the post-repair file verification catches it.
|
||||
write_stale_preferences
|
||||
cp "$preferences" "$test_dir/stale-preferences"
|
||||
cat >"$stub_bin/python3" <<'STUB'
|
||||
#!/bin/bash
|
||||
"${REAL_PYTHON}" "$@"
|
||||
status=$?
|
||||
[[ ${5:-} == "repair" ]] && cp "${STALE_PREFERENCES:?}" "${REPAIRED_PREFERENCES:?}"
|
||||
exit $status
|
||||
STUB
|
||||
chmod +x "$stub_bin/python3"
|
||||
if HOME="$home" PATH="$stub_bin:$PATH" STALE_PREFERENCES="$test_dir/stale-preferences" \
|
||||
REPAIRED_PREFERENCES="$preferences" bash -euo pipefail "$migration" >/dev/null 2>&1; then
|
||||
fail "migration stays pending when a briefly-lived browser undoes the repair"
|
||||
fi
|
||||
pass "migration stays pending when a briefly-lived browser undoes the repair"
|
||||
rm -f "$stub_bin/python3"
|
||||
close_browser
|
||||
write_stale_preferences
|
||||
run_migration || fail "migration recovers after a reverted repair"
|
||||
rm -f "$preferences.omarchy-copy-url-repair.bak"
|
||||
|
||||
# A repair attempted while the affected profile was open leaves its backup
|
||||
# behind. A rerun that sees a clean disk while that profile still runs must
|
||||
# stay pending — the browser can restore the ghost on exit — and only a
|
||||
# browser-free rerun verifies the repair and completes.
|
||||
write_stale_preferences
|
||||
run_migration || fail "repair run before the verification scenario"
|
||||
[[ -f $preferences.omarchy-copy-url-repair.bak ]] || fail "verification scenario has a repair backup"
|
||||
open_browser
|
||||
run_migration && fail "migration must not complete an unverified repair while a browser runs"
|
||||
pass "migration keeps an unverified repair pending while a browser runs"
|
||||
close_browser
|
||||
run_migration || fail "migration completes once the repair is verified with browsers closed"
|
||||
pass "migration verifies an attempted repair on a browser-free rerun"
|
||||
rm -f "$preferences.omarchy-copy-url-repair.bak"
|
||||
|
||||
# An installed third-party extension with a command that happens to be named
|
||||
# copy-url keeps its own registration.
|
||||
jq -n '{extensions: {commands: {"linux:Alt+Shift+L": {command_name: "copy-url", extension: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", global: false}}, settings: {aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: {path: "/home/user/.config/some-extension", commands: {}}}}}' >"$preferences"
|
||||
untouched_hash=$(sha256sum "$preferences" | cut -d' ' -f1)
|
||||
run_migration || fail "migration leaves installed third-party extensions alone"
|
||||
[[ $(sha256sum "$preferences" | cut -d' ' -f1) == "$untouched_hash" ]] ||
|
||||
fail "migration does not steal a third-party copy-url command registration"
|
||||
pass "migration leaves installed third-party extensions alone"
|
||||
@@ -54,6 +54,334 @@ grep -F 'omarchy-crash-watch.service' "$ROOT/install/user/first-run/enable-user-
|
||||
fail "crash capture is no longer on by default for new installs"
|
||||
pass "crash capture is on by default"
|
||||
|
||||
require_command jq
|
||||
|
||||
# The per-program mute, driven through the real watcher with a stubbed journal:
|
||||
# these prove what a person sees -- a toast arriving or not -- where asserting
|
||||
# that a flag file was read would prove only that a flag file was read.
|
||||
watch_bin="$TMPDIR/watch-bin"
|
||||
watch_home="$TMPDIR/watch-home"
|
||||
NOTIFY_LOG="$TMPDIR/notify-log"
|
||||
JOURNAL_ENTRIES="$TMPDIR/journal-entries"
|
||||
|
||||
mkdir -p "$watch_bin" "$watch_home"
|
||||
|
||||
cat >"$watch_bin/journalctl" <<'SH'
|
||||
#!/bin/bash
|
||||
cat "$JOURNAL_ENTRIES"
|
||||
SH
|
||||
|
||||
cat >"$watch_bin/omarchy-default-agent" <<'SH'
|
||||
#!/bin/bash
|
||||
echo claude
|
||||
SH
|
||||
|
||||
cat >"$watch_bin/omarchy-notification-wait" <<'SH'
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
SH
|
||||
|
||||
cat >"$watch_bin/omarchy-notification-send" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$*" >>"$NOTIFY_LOG"
|
||||
SH
|
||||
|
||||
chmod +x "$watch_bin/journalctl" "$watch_bin/omarchy-default-agent" \
|
||||
"$watch_bin/omarchy-notification-wait" "$watch_bin/omarchy-notification-send"
|
||||
|
||||
reset_entries() {
|
||||
: >"$JOURNAL_ENTRIES"
|
||||
}
|
||||
|
||||
# One core dump as systemd-coredump journals it. The UID must be this user's, or
|
||||
# the watcher discards it as somebody else's crash before anything under test.
|
||||
crash_entry() {
|
||||
local comm="$1" exe="$2"
|
||||
|
||||
jq -cn --arg uid "$UID" --arg comm "$comm" --arg exe "$exe" \
|
||||
'{_UID: $uid, COREDUMP_COMM: $comm, COREDUMP_PID: "4242",
|
||||
COREDUMP_EXE: $exe, COREDUMP_SIGNAL_NAME: "SIGSEGV"}' >>"$JOURNAL_ENTRIES"
|
||||
}
|
||||
|
||||
# The stubbed journalctl ends after the entries, so the watcher's loop ends too.
|
||||
# Its exit status is asserted rather than discarded: a watcher that dies on a
|
||||
# muted crash notifies about nothing afterwards, which every assertion below
|
||||
# that expects silence would otherwise read as success.
|
||||
run_watch() {
|
||||
local status=0
|
||||
|
||||
: >"$NOTIFY_LOG"
|
||||
|
||||
PATH="$watch_bin:$ROOT/bin:$PATH" \
|
||||
JOURNAL_ENTRIES="$JOURNAL_ENTRIES" \
|
||||
NOTIFY_LOG="$NOTIFY_LOG" \
|
||||
HOME="$watch_home" \
|
||||
"$ROOT/bin/omarchy-crash-watch" || status=$?
|
||||
|
||||
(( status == 0 )) ||
|
||||
fail "the watcher exited $status rather than carrying on, so a mute takes the service down with it"
|
||||
}
|
||||
|
||||
# Through the real command rather than writing the flag by hand: these assertions
|
||||
# are then the guard that the thing the diagnosis runs and the thing the watcher
|
||||
# reads have not drifted apart.
|
||||
mute() {
|
||||
HOME="$watch_home" PATH="$ROOT/bin:$PATH" \
|
||||
"$ROOT/bin/omarchy-crash-mute" "$1" "$2" >/dev/null
|
||||
}
|
||||
|
||||
announced() {
|
||||
grep -Fq "Process crashed: $1" "$NOTIFY_LOG"
|
||||
}
|
||||
|
||||
reset_entries
|
||||
crash_entry hyprland /usr/bin/hyprland
|
||||
run_watch
|
||||
announced hyprland ||
|
||||
fail "a crash nobody muted still announces itself"
|
||||
pass "a crash nobody muted still announces itself"
|
||||
|
||||
mute hyprland on
|
||||
run_watch
|
||||
! announced hyprland ||
|
||||
fail "muting a program stops the crash notifications the diagnosis offered to stop"
|
||||
pass "muting a program stops its crash notifications"
|
||||
|
||||
reset_entries
|
||||
crash_entry nautilus /usr/bin/nautilus
|
||||
run_watch
|
||||
announced nautilus ||
|
||||
fail "muting one program silences every other program, which is the global toggle's job and not this one's"
|
||||
pass "muting one program leaves every other program announcing"
|
||||
|
||||
mute hyprland off
|
||||
reset_entries
|
||||
crash_entry hyprland /usr/bin/hyprland
|
||||
run_watch
|
||||
announced hyprland ||
|
||||
fail "un-muting a program brings its crash notifications back"
|
||||
pass "un-muting a program brings its crash notifications back"
|
||||
|
||||
# The diagnosis tells the user to mute the name the toast showed them, so the
|
||||
# toast has to show the name the watcher checks. COMM is truncated to 15
|
||||
# characters and the executable's basename is not, and announcing the truncated
|
||||
# one would leave a dutifully-followed mute matching nothing forever.
|
||||
reset_entries
|
||||
crash_entry chromium-browse /usr/lib/chromium/chromium-browser
|
||||
run_watch
|
||||
announced chromium-browser ||
|
||||
fail "the toast announces a name the mute cannot be keyed on, so following the diagnosis mutes nothing"
|
||||
pass "the toast announces the name the mute is keyed on"
|
||||
|
||||
mute chromium-browser on
|
||||
run_watch
|
||||
! announced chromium-browser ||
|
||||
fail "the mute is keyed on the name the notification announced, not on the truncated COMM"
|
||||
pass "muting the announced name silences a program whose COMM was truncated"
|
||||
|
||||
# A muted crash must not end the watcher. Restart=always would paper over it
|
||||
# with a five-second gap, and the watcher restarts on `journalctl -n 0`, which
|
||||
# never replays the crashes it missed while it was away.
|
||||
reset_entries
|
||||
crash_entry chromium-browse /usr/lib/chromium/chromium-browser
|
||||
crash_entry nautilus /usr/bin/nautilus
|
||||
run_watch
|
||||
announced nautilus ||
|
||||
fail "a muted crash stops the watcher reading the journal, losing every crash after it"
|
||||
pass "a muted crash does not stop the watcher reading the next one"
|
||||
|
||||
# A process can set its own comm to anything prctl takes, slashes included, and
|
||||
# a crash with no recorded executable falls back to it. A name that climbed out
|
||||
# of crash-ignore/ would let a crashing program silence itself against an
|
||||
# unrelated flag -- and have the diagnosis write one there on the user's behalf.
|
||||
# The fixture carries two slashes so that dropping only the first is not mistaken
|
||||
# for dropping all of them.
|
||||
reset_entries
|
||||
crash_entry a/../bar-off -
|
||||
sibling_flag="$watch_home/.local/state/omarchy/toggles/bar-off"
|
||||
touch "$sibling_flag"
|
||||
run_watch
|
||||
announced bar-off ||
|
||||
fail "a comm that climbs out of crash-ignore/ reads an unrelated toggle, letting a crash suppress its own notification"
|
||||
pass "a comm that climbs out of crash-ignore/ cannot reach an unrelated toggle"
|
||||
rm -f "$sibling_flag"
|
||||
|
||||
# Stripping to the last component does not always leave a component. An empty
|
||||
# name is no kind of array subscript and no kind of toast, and a dot component
|
||||
# names a directory the mute would touch and then never match.
|
||||
for empty_comm in / a/ . ..; do
|
||||
reset_entries
|
||||
crash_entry "$empty_comm" -
|
||||
run_watch
|
||||
announced unknown ||
|
||||
fail "a comm of '$empty_comm' leaves no usable name, so the toast cannot say what crashed and the mute has nothing to key on"
|
||||
done
|
||||
pass "a comm that strips down to nothing or a dot still announces under a name a mute can use"
|
||||
|
||||
# An empty comm is not a missing entry. Tab is IFS whitespace, so an empty field
|
||||
# collapses and every field after it shifts along one -- the pid becomes a path,
|
||||
# the crash reads as somebody else's, and it is dropped without a word.
|
||||
reset_entries
|
||||
crash_entry "" -
|
||||
crash_entry nautilus /usr/bin/nautilus
|
||||
run_watch
|
||||
announced unknown ||
|
||||
fail "a crash whose comm is empty is dropped instead of announced, because the empty field shifted every field after it"
|
||||
announced nautilus ||
|
||||
fail "an empty comm derails the rest of the journal entry"
|
||||
pass "an empty comm is announced rather than parsed into the next field"
|
||||
|
||||
# Only "." and ".." are special. A leading dot is an ordinary filename, and
|
||||
# folding those into the fallback would have one program's mute silence another.
|
||||
for dotted_comm in .hidden ...; do
|
||||
reset_entries
|
||||
crash_entry "$dotted_comm" -
|
||||
run_watch
|
||||
announced "$dotted_comm" ||
|
||||
fail "'$dotted_comm' is an ordinary name, but it lands in the fallback, so muting it would silence unrelated crashes"
|
||||
done
|
||||
pass "a leading dot is an ordinary name rather than a special component"
|
||||
|
||||
# And the name it settles on is mutable like any other.
|
||||
mute unknown on
|
||||
reset_entries
|
||||
crash_entry / -
|
||||
run_watch
|
||||
! announced unknown ||
|
||||
fail "the fallback name cannot be muted, so the one crash most likely to repeat is the one that cannot be silenced"
|
||||
pass "the fallback name can be muted like any other"
|
||||
mute unknown off
|
||||
|
||||
# What omarchy-crash-mute does on its own. That it agrees with the watcher is
|
||||
# already covered above, which drives it for every mute it makes.
|
||||
mute_home="$TMPDIR/mute-home"
|
||||
mkdir -p "$mute_home"
|
||||
|
||||
crash_mute() {
|
||||
HOME="$mute_home" PATH="$ROOT/bin:$PATH" "$ROOT/bin/omarchy-crash-mute" "$@"
|
||||
}
|
||||
|
||||
mute_flag() {
|
||||
[[ $1 == "--" ]] && shift
|
||||
printf '%s' "$mute_home/.local/state/omarchy/toggles/crash-ignore/$1"
|
||||
}
|
||||
|
||||
crash_mute | grep -Fq "No programs muted" ||
|
||||
fail "an empty mute list prints nothing, so a user cannot tell it from a broken command"
|
||||
pass "the command says so when nothing is muted"
|
||||
|
||||
crash_mute hyprland >/dev/null
|
||||
crash_mute | grep -Fqx hyprland ||
|
||||
fail "a muted program is missing from the list, so a mute cannot be found again to lift it"
|
||||
pass "the command lists what it muted"
|
||||
|
||||
# The watcher keys on the basename, so the command has to take the path a crash
|
||||
# recorded and land on the same flag the watcher will look for.
|
||||
crash_mute /usr/lib/chromium/chromium-browser >/dev/null
|
||||
[[ -f $(mute_flag chromium-browser) ]] ||
|
||||
fail "a binary's path is muted verbatim rather than by name, so the watcher never sees that flag"
|
||||
pass "the command reduces a path to the name the watcher checks"
|
||||
|
||||
crash_mute hyprland off >/dev/null
|
||||
[[ ! -f $(mute_flag hyprland) ]] ||
|
||||
fail "off leaves the program muted, making the mute a one-way door"
|
||||
pass "the command un-mutes"
|
||||
|
||||
# Muting is not flipping. The diagnosis offers this on a program the user may
|
||||
# already have muted, and asking for a mute twice has to leave it muted.
|
||||
crash_mute hyprland >/dev/null
|
||||
crash_mute hyprland >/dev/null
|
||||
[[ -f $(mute_flag hyprland) ]] ||
|
||||
fail "muting an already-muted program un-mutes it, so offering the mute a second time turns it back on"
|
||||
pass "asking to mute twice leaves it muted"
|
||||
|
||||
# A program may legitimately be called .hidden, and a mute nobody can see is a
|
||||
# mute nobody can lift.
|
||||
crash_mute .hidden >/dev/null
|
||||
crash_mute | grep -Fqx .hidden ||
|
||||
fail "a mute on a dotted name is missing from the list, so it can never be found and lifted"
|
||||
pass "the list shows a name that begins with a dot"
|
||||
|
||||
# It turns what it is given into a path, so it has to refuse whatever is not one
|
||||
# component of one.
|
||||
for bad_name in . .. /; do
|
||||
! crash_mute "$bad_name" >/dev/null 2>&1 ||
|
||||
fail "'$bad_name' is taken as a program name, and the flag that writes is not one the watcher will ever read"
|
||||
done
|
||||
pass "the command refuses a name that is not a name"
|
||||
|
||||
! crash_mute hyprland sideways >/dev/null 2>&1 ||
|
||||
fail "an action it does not know is treated as a mute, so a typo silences a program"
|
||||
pass "the command refuses an action it does not know"
|
||||
|
||||
# And says what it refused, or the user retypes the same thing. Captured rather
|
||||
# than piped: the command exits non-zero here, which pipefail would surface as
|
||||
# the pipeline's status and read as a failed assertion.
|
||||
refusal=$(crash_mute hyprland sideways 2>&1) || true
|
||||
grep -Fq "Not an action" <<<"$refusal" ||
|
||||
fail "an unknown action is refused without naming it, leaving the user nothing to correct"
|
||||
pass "the command names the action it refused"
|
||||
|
||||
crash_mute ../bar-off >/dev/null
|
||||
[[ ! -e "$mute_home/.local/state/omarchy/toggles/bar-off" ]] ||
|
||||
fail "a name that climbs out writes a sibling toggle, so muting a crash could turn off the bar instead"
|
||||
pass "the command cannot be talked into writing outside crash-ignore/"
|
||||
|
||||
# A program may be called -h, and the router answers that with its own help
|
||||
# before the command runs. A leading -- is the way through, so it has to be
|
||||
# consumed rather than taken for the program name.
|
||||
crash_mute -- -h >/dev/null 2>&1 ||
|
||||
fail "a leading -- is refused rather than consumed, so a program named like a flag cannot be muted at all"
|
||||
[[ -f $(mute_flag -- -h) ]] ||
|
||||
fail "a leading -- is taken for the program name, so muting -h mutes something else"
|
||||
pass "a leading -- lets a program named like a flag be muted"
|
||||
|
||||
# toggle is advertised, so it has to flip both ways rather than quietly mute.
|
||||
crash_mute toggler off >/dev/null
|
||||
crash_mute toggler toggle >/dev/null
|
||||
[[ -f $(mute_flag toggler) ]] ||
|
||||
fail "toggle does not mute an un-muted program"
|
||||
crash_mute toggler toggle >/dev/null
|
||||
[[ ! -f $(mute_flag toggler) ]] ||
|
||||
fail "toggle mutes but never un-mutes, so the advertised action only goes one way"
|
||||
pass "toggle flips a mute both ways"
|
||||
|
||||
# The listing means what the watcher means, and the watcher honours a regular
|
||||
# file. Anything else in there is not a mute, however much it looks like one.
|
||||
mkdir -p "$(mute_flag notactuallymuted)"
|
||||
! crash_mute | grep -Fqx notactuallymuted ||
|
||||
fail "a directory is reported as muted while that program's crashes keep arriving"
|
||||
pass "the listing counts only the flags the watcher honours"
|
||||
rmdir "$(mute_flag notactuallymuted)"
|
||||
|
||||
# A mute that could not be written must not be reported as one. Without this the
|
||||
# command can print success for a flag that was never created.
|
||||
failing_bin="$TMPDIR/failing-bin"
|
||||
mkdir -p "$failing_bin"
|
||||
cat >"$failing_bin/omarchy-toggle" <<'SH'
|
||||
#!/bin/bash
|
||||
exit 1
|
||||
SH
|
||||
chmod +x "$failing_bin/omarchy-toggle"
|
||||
|
||||
status=0
|
||||
refusal=$(HOME="$mute_home" PATH="$failing_bin:$ROOT/bin:$PATH" \
|
||||
"$ROOT/bin/omarchy-crash-mute" hyprland 2>&1) || status=$?
|
||||
(( status != 0 )) ||
|
||||
fail "a mute that could not be written exits zero, so nothing downstream learns it failed"
|
||||
! grep -Fq "Muted crash notifications" <<<"$refusal" ||
|
||||
fail "a mute that could not be written still reports success, so the user believes a program is silenced when it is not"
|
||||
pass "a mute that could not be written is not reported as one"
|
||||
|
||||
skill="$ROOT/default/agents/skills/diagnose-crash/SKILL.md"
|
||||
grep -Fq 'omarchy-crash-mute' "$skill" ||
|
||||
fail "the diagnosis no longer names the command that mutes, so the offer it makes cannot be carried out"
|
||||
pass "the diagnosis names the command that mutes"
|
||||
|
||||
grep -Fq 'GROUP_DESCRIPTIONS[crash]' "$ROOT/bin/omarchy" ||
|
||||
fail "the crash group has no description, so the router lists a group it cannot describe"
|
||||
pass "the crash group is described in the router"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const fs = require('fs')
|
||||
const menu = requireFromRoot('shell/plugins/menu/MenuModel.js')
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
packages="$ROOT/install/omarchy-base.packages"
|
||||
cups_browsed_conf="$ROOT/etc/cups/cups-browsed.conf"
|
||||
cups_files_conf="$ROOT/etc/cups/cups-files.conf"
|
||||
sysusers_conf="$ROOT/etc/sysusers.d/omarchy-cups-browsed.conf"
|
||||
service_dropin="$ROOT/etc/systemd/system/cups-browsed.service.d/10-omarchy.conf"
|
||||
|
||||
# Only discovery goes. Everything else printing needs stays, or this stops
|
||||
# being a removal of one daemon and becomes a removal of printing.
|
||||
grep -qxF cups "$packages" || fail "CUPS itself remains in the base package set"
|
||||
grep -qxF cups-filters "$packages" || fail "the CUPS filters remain in the base package set"
|
||||
grep -qxF system-config-printer "$packages" || fail "Print Settings remains in the base package set"
|
||||
grep -qxF cups-pk-helper "$packages" || fail "Polkit printer administration is installed"
|
||||
! grep -qxF cups-pdf "$packages" || fail "the root CUPS-PDF backend is removed"
|
||||
|
||||
# Automatic discovery is temporarily out of the default install while it is
|
||||
# reworked. The hardened configuration below stays as the baseline discovery
|
||||
# comes back onto.
|
||||
! grep -qxF cups-browsed "$packages" || fail "automatic printer discovery is out of the base package set"
|
||||
! grep -q 'cups-browsed' "$ROOT/install/config/enable-services.sh" ||
|
||||
fail "a fresh install does not enable a discovery service it no longer installs"
|
||||
! grep -q 'enable_system_service cups-browsed' "$ROOT/bin/omarchy-upgrade-to-quattro" ||
|
||||
fail "the Quattro upgrade does not enable a discovery service it no longer installs"
|
||||
|
||||
pass "the base install keeps CUPS and Polkit administration, without automatic discovery"
|
||||
|
||||
# CUPS still ships /etc/cups/cups-files.conf, so its authorization override is
|
||||
# applied after the ISO installs that package. cups-browsed is absent, so the
|
||||
# installer must not write any of its package-owned configuration.
|
||||
post_install_pacman="$ROOT/install/post-install/pacman.sh"
|
||||
|
||||
! grep -q 'cups-cups-browsed.conf' "$post_install_pacman" ||
|
||||
fail "a fresh install does not write configuration for absent printer discovery"
|
||||
grep -q 'cups-cups-files.conf && -f /etc/cups/cups-files.conf' "$post_install_pacman" ||
|
||||
fail "the CUPS authorization override waits for the file it replaces"
|
||||
|
||||
pass "the fresh install applies CUPS hardening without writing discovery configuration"
|
||||
|
||||
grep -qxF 'CacheDir /var/cache/cups-browsed' "$cups_browsed_conf" ||
|
||||
fail "cups-browsed keeps state outside the print-filter cache"
|
||||
grep -qxF 'CreateIPPPrinterQueues Driverless' "$cups_browsed_conf" ||
|
||||
fail "automatic queues are limited to driverless IPP printers"
|
||||
grep -qxF 'CreateRemoteCUPSPrinterQueues No' "$cups_browsed_conf" ||
|
||||
fail "remote CUPS queues are not created automatically"
|
||||
! grep -q 'CreateRemotePrinters' "$cups_browsed_conf" ||
|
||||
fail "the unsupported CreateRemotePrinters directive is gone"
|
||||
|
||||
pass "cups-browsed uses explicit supported discovery policy and an isolated cache"
|
||||
|
||||
grep -qxF 'SystemGroup cups-browsed sys root' "$cups_files_conf" ||
|
||||
fail "only the printer discovery account receives passwordless CUPS administration"
|
||||
grep -qxF 'PeerCred on' "$cups_files_conf" ||
|
||||
fail "the packaged CUPS policy enables peer credentials"
|
||||
[[ $(grep -ciE '^[[:space:]]*SystemGroup[[:space:]]' "$cups_files_conf") == 1 ]] ||
|
||||
fail "the packaged CUPS policy has one SystemGroup directive"
|
||||
[[ $(grep -ciE '^[[:space:]]*PeerCred[[:space:]]' "$cups_files_conf") == 1 ]] ||
|
||||
fail "the packaged CUPS policy has one PeerCred directive"
|
||||
[[ ! -e $ROOT/install/config/printing.sh ]] ||
|
||||
fail "printing policy is not rewritten by an install script"
|
||||
! grep -q 'config/printing.sh' "$ROOT/install/config/all.sh" "$ROOT/migrations/1787815267.sh" ||
|
||||
fail "neither install nor update invokes a printing rewrite script"
|
||||
|
||||
pass "CUPS authorization ships as a canonical package override"
|
||||
|
||||
grep -qxF 'u cups-browsed - "CUPS printer discovery" / -' "$sysusers_conf" ||
|
||||
fail "a locked cups-browsed system account is declared"
|
||||
|
||||
for setting in \
|
||||
'User=cups-browsed' \
|
||||
'Group=cups-browsed' \
|
||||
'CacheDirectory=cups-browsed' \
|
||||
'CacheDirectoryMode=0750' \
|
||||
'UMask=0027' \
|
||||
'NoNewPrivileges=yes' \
|
||||
'ProtectSystem=strict' \
|
||||
'ProtectHome=yes' \
|
||||
'PrivateTmp=yes' \
|
||||
'RestrictSUIDSGID=yes'; do
|
||||
grep -qxF "$setting" "$service_dropin" ||
|
||||
fail "cups-browsed service hardening includes $setting"
|
||||
done
|
||||
|
||||
! grep -q '^\(Ambient\|CapabilityBoundingSet\).*CAP_NET_BIND_SERVICE' "$service_dropin" ||
|
||||
fail "cups-browsed is not granted an unverified network capability"
|
||||
|
||||
pass "cups-browsed runs as its confined service account without added capabilities"
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
mock_bin="$test_tmp/bin"
|
||||
mkdir -p "$mock_bin" "$test_tmp/var/lib/omarchy/migrations"
|
||||
|
||||
passwd_db="$test_tmp/passwd"
|
||||
group_db="$test_tmp/group"
|
||||
touch "$passwd_db" "$group_db"
|
||||
|
||||
cat >"$mock_bin/getent" <<'SH'
|
||||
#!/bin/bash
|
||||
case "$1" in
|
||||
passwd) database="$OMARCHY_CUPS_TEST_PASSWD" ;;
|
||||
group) database="$OMARCHY_CUPS_TEST_GROUP" ;;
|
||||
*) exit 2 ;;
|
||||
esac
|
||||
|
||||
if (($# == 1)); then
|
||||
cat "$database"
|
||||
else
|
||||
awk -F: -v name="$2" '$1 == name { print; found = 1 } END { exit !found }' "$database"
|
||||
fi
|
||||
SH
|
||||
cat >"$mock_bin/omarchy-pkg-present" <<'SH'
|
||||
#!/bin/bash
|
||||
[[ $1 == "cups" || $1 == "cups-browsed" ]]
|
||||
SH
|
||||
for command in omarchy-pkg-add omarchy-pkg-drop; do
|
||||
cat >"$mock_bin/$command" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\t%s\n' "${0##*/}" "$*" >>"$OMARCHY_CUPS_TEST_LOG"
|
||||
SH
|
||||
done
|
||||
cat >"$mock_bin/systemctl" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'systemctl\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG"
|
||||
exit 0
|
||||
SH
|
||||
cat >"$mock_bin/sudo" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'sudo\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG"
|
||||
exec "$@"
|
||||
SH
|
||||
chmod +x "$mock_bin"/*
|
||||
|
||||
log="$test_tmp/actions.log"
|
||||
touch "$log"
|
||||
export OMARCHY_CUPS_TEST_LOG="$log"
|
||||
export OMARCHY_CUPS_TEST_PASSWD="$passwd_db"
|
||||
export OMARCHY_CUPS_TEST_GROUP="$group_db"
|
||||
|
||||
printf 'cups-browsed:x:1000:1000:Desktop user:/home/cups-browsed:/usr/bin/bash\n' >"$passwd_db"
|
||||
printf 'cups-browsed:x:1000:\n' >"$group_db"
|
||||
if PATH="$mock_bin:$PATH" \
|
||||
OMARCHY_PATH="$ROOT" \
|
||||
OMARCHY_CUPS_MIGRATION_MARKER="$test_tmp/desktop-collision-marker" \
|
||||
bash -euo pipefail "$ROOT/migrations/1787815267.sh" 2>/dev/null; then
|
||||
fail "the migration accepts an existing desktop user named cups-browsed"
|
||||
fi
|
||||
[[ ! -s $log ]] || fail "an account collision stops the migration before changing the system"
|
||||
|
||||
printf 'alice:x:1000:947:Desktop user:/home/alice:/usr/bin/bash\n' >"$passwd_db"
|
||||
printf 'cups-browsed:x:947:alice\n' >"$group_db"
|
||||
if PATH="$mock_bin:$PATH" \
|
||||
OMARCHY_PATH="$ROOT" \
|
||||
OMARCHY_CUPS_MIGRATION_MARKER="$test_tmp/group-collision-marker" \
|
||||
bash -euo pipefail "$ROOT/migrations/1787815267.sh" 2>/dev/null; then
|
||||
fail "the migration accepts an existing cups-browsed group with members"
|
||||
fi
|
||||
[[ ! -s $log ]] || fail "a group collision stops the migration before changing the system"
|
||||
|
||||
printf 'cups-browsed:x:947:947:CUPS printer discovery:/:/usr/bin/nologin\n' >"$passwd_db"
|
||||
printf 'cups-browsed:x:947:\n' >"$group_db"
|
||||
|
||||
pass "the migration rejects account and group collisions before changing printing"
|
||||
|
||||
marker="$test_tmp/var/lib/omarchy/migrations/1787815267"
|
||||
PATH="$mock_bin:$PATH" \
|
||||
OMARCHY_PATH="$ROOT" \
|
||||
OMARCHY_CUPS_MIGRATION_MARKER="$marker" \
|
||||
bash -euo pipefail "$ROOT/migrations/1787815267.sh"
|
||||
|
||||
grep -qxF $'omarchy-pkg-drop\tcups-pdf' "$log" ||
|
||||
fail "the migration removes CUPS-PDF"
|
||||
grep -qxF $'omarchy-pkg-add\tcups-pk-helper' "$log" ||
|
||||
fail "the migration installs authenticated printer administration"
|
||||
grep -qxF $'systemctl\tstop cups-browsed.service' "$log" ||
|
||||
fail "the migration stops the root cups-browsed process before reconfiguration"
|
||||
grep -qxF $'systemctl\tdaemon-reload' "$log" ||
|
||||
fail "the migration reloads the hardened service"
|
||||
grep -qxF $'systemctl\ttry-reload-or-restart cups.service' "$log" ||
|
||||
fail "the migration reloads the packaged CUPS authorization"
|
||||
grep -qxF $'systemctl\trestart cups-browsed.service' "$log" ||
|
||||
fail "the migration resumes an active cups-browsed service"
|
||||
[[ -f $marker ]] || fail "the migration records machine-wide completion"
|
||||
|
||||
actions_after_first_run=$(wc -l <"$log")
|
||||
PATH="$mock_bin:$PATH" \
|
||||
OMARCHY_PATH="$ROOT" \
|
||||
OMARCHY_CUPS_MIGRATION_MARKER="$marker" \
|
||||
bash -euo pipefail "$ROOT/migrations/1787815267.sh"
|
||||
[[ $(wc -l <"$log") == "$actions_after_first_run" ]] ||
|
||||
fail "the machine-wide migration repeats privileged work"
|
||||
|
||||
pass "the migration safely converts an active existing installation once"
|
||||
|
||||
# An interrupted earlier run leaves cups-browsed stopped. A retry still needs
|
||||
# to resume an enabled service before recording completion.
|
||||
cat >"$mock_bin/systemctl" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'systemctl\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG"
|
||||
[[ $1 == "is-active" ]] && exit 1
|
||||
exit 0
|
||||
SH
|
||||
chmod +x "$mock_bin/systemctl"
|
||||
|
||||
retry_log="$test_tmp/retry.log"
|
||||
retry_marker="$test_tmp/var/lib/omarchy/migrations/1787815267-retry"
|
||||
|
||||
OMARCHY_CUPS_TEST_LOG="$retry_log" \
|
||||
PATH="$mock_bin:$PATH" \
|
||||
OMARCHY_PATH="$ROOT" \
|
||||
OMARCHY_CUPS_MIGRATION_MARKER="$retry_marker" \
|
||||
bash -euo pipefail "$ROOT/migrations/1787815267.sh"
|
||||
|
||||
grep -qxF $'systemctl\trestart cups-browsed.service' "$retry_log" ||
|
||||
fail "the retry resumes cups-browsed after an interrupted earlier run"
|
||||
|
||||
pass "a run following an interrupted one still resumes printer discovery"
|
||||
|
||||
# A masked or disabled unit is deliberately left alone.
|
||||
cat >"$mock_bin/systemctl" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'systemctl\t%s\n' "$*" >>"$OMARCHY_CUPS_TEST_LOG"
|
||||
[[ $1 == "is-active" || $1 == "is-enabled" ]] && exit 1
|
||||
exit 0
|
||||
SH
|
||||
chmod +x "$mock_bin/systemctl"
|
||||
|
||||
masked_log="$test_tmp/masked.log"
|
||||
masked_marker="$test_tmp/var/lib/omarchy/migrations/1787815267-masked"
|
||||
|
||||
OMARCHY_CUPS_TEST_LOG="$masked_log" \
|
||||
PATH="$mock_bin:$PATH" \
|
||||
OMARCHY_PATH="$ROOT" \
|
||||
OMARCHY_CUPS_MIGRATION_MARKER="$masked_marker" \
|
||||
bash -euo pipefail "$ROOT/migrations/1787815267.sh"
|
||||
|
||||
! grep -qxF $'systemctl\trestart cups-browsed.service' "$masked_log" ||
|
||||
fail "the migration leaves a masked or disabled cups-browsed alone"
|
||||
[[ -f $masked_marker ]] || fail "the migration completes with cups-browsed masked"
|
||||
|
||||
pass "a masked or disabled cups-browsed is left alone and does not fail the migration"
|
||||
@@ -19,8 +19,18 @@ 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-install-chromium-claude" <<'SH'
|
||||
#!/bin/bash
|
||||
echo claude-extension >>"$OMARCHY_TEST_STUB_LOG"
|
||||
if [[ ${OMARCHY_TEST_EXTENSION_FAIL:-false} == "true" ]]; then
|
||||
echo "Extension installation failed" >&2
|
||||
exit 1
|
||||
fi
|
||||
SH
|
||||
|
||||
cat >"$mock_bin/omarchy-notification-send" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\0' "$@" >>"$OMARCHY_TEST_NOTIFICATION_HISTORY"
|
||||
@@ -69,6 +79,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,12 +117,16 @@ 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"
|
||||
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
|
||||
@@ -114,19 +144,61 @@ assert_lazy_stub() {
|
||||
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"
|
||||
|
||||
: >"$stub_log"
|
||||
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"
|
||||
@@ -208,6 +280,8 @@ touch "$test_home/.local/state/omarchy/preinstalls-removed"
|
||||
: >"$stub_log"
|
||||
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"
|
||||
|
||||
@@ -232,13 +306,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"
|
||||
|
||||
touch "$test_home/.local/bin/agy"
|
||||
"$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 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"
|
||||
|
||||
@@ -285,6 +376,8 @@ declare -A expected_agents=(
|
||||
[oh-my-pi]="omp"
|
||||
[opencode]="opencode"
|
||||
[open-code]="opencode"
|
||||
[ori]="ori"
|
||||
[openrouter]="ori"
|
||||
[claude]="claude"
|
||||
[claude-code]="claude"
|
||||
[codex]="codex"
|
||||
@@ -297,29 +390,48 @@ 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=(
|
||||
[pi]="pi"
|
||||
[omp]="$omp_package"
|
||||
[opencode]="opencode"
|
||||
[ori]="$ori_package"
|
||||
[claude]="claude"
|
||||
[codex]="codex"
|
||||
[crush]="$crush_package"
|
||||
[grok]="$grok_package"
|
||||
[agy]="$agy_package"
|
||||
[copilot]="copilot"
|
||||
[cursor-agent]="$cursor_agent_package"
|
||||
[muse]="$muse_package"
|
||||
)
|
||||
|
||||
for selection in "${!expected_agents[@]}"; do
|
||||
expected=${expected_agents[$selection]}
|
||||
: >"$agent_open_log"
|
||||
: >"$stub_log"
|
||||
OMARCHY_TEST_AGENT_INSTALLED=true omarchy-default-agent "$selection"
|
||||
[[ $(omarchy-default-agent) == $expected ]] || fail "default agent canonicalizes $selection"
|
||||
|
||||
if [[ $expected == "claude" ]]; then
|
||||
grep -qx claude-extension "$stub_log" || fail "Claude selection installs the browser extension"
|
||||
else
|
||||
[[ ! -s $stub_log ]] || fail "other agents do not install the Claude extension"
|
||||
fi
|
||||
|
||||
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" ]] ||
|
||||
@@ -330,6 +442,14 @@ pass "default agent selects and opens every supported provider and alias"
|
||||
fail "default agent stores its selection in Omarchy user config"
|
||||
pass "default agent stores its selection in Omarchy user config"
|
||||
|
||||
OMARCHY_TEST_AGENT_INSTALLED=true omarchy-default-agent pi
|
||||
: >"$agent_open_log"
|
||||
OMARCHY_TEST_AGENT_INSTALLED=true OMARCHY_TEST_EXTENSION_FAIL=true omarchy-default-agent claude >"$test_tmp/extension-failure" 2>&1
|
||||
[[ $(omarchy-default-agent) == "claude" ]] || fail "extension installation failure still selects Claude"
|
||||
mapfile -d '' -t agent_open_args <"$agent_open_log"
|
||||
[[ ${agent_open_args[*]} == "omarchy-agent" ]] || fail "extension installation failure still launches Claude"
|
||||
[[ ! -s $test_tmp/extension-failure ]] || fail "extension installation failure is silent"
|
||||
pass "extension installation failure silently continues selecting and launching Claude"
|
||||
OMARCHY_TEST_AGENT_INSTALLED=true omarchy-default-agent pi
|
||||
: >"$notification_history"
|
||||
: >"$agent_open_log"
|
||||
@@ -369,6 +489,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"
|
||||
@@ -403,6 +552,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
|
||||
|
||||
@@ -420,8 +639,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
|
||||
}
|
||||
|
||||
@@ -446,21 +667,42 @@ assert_bypass() {
|
||||
assert_launch pi pi "Review this project"
|
||||
assert_launch omp omp --auto-approve -- "Review this project"
|
||||
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"
|
||||
@@ -518,3 +760,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"
|
||||
@@ -61,13 +61,15 @@ if [[ $installer == "omarchy-install-browser" && ${OMARCHY_TEST_REAL_BROWSER_INS
|
||||
fi
|
||||
|
||||
case $installer in
|
||||
omarchy-pkg-add)
|
||||
omarchy-pkg-add|omarchy-pkg-aur-add)
|
||||
package=$1
|
||||
printf 'pkg:%s\n' "$package" >>"$OMARCHY_TEST_INSTALL_LOG"
|
||||
case $package in
|
||||
chromium) command=chromium ;;
|
||||
firefox) command=firefox ;;
|
||||
zen-browser-bin) command=zen-browser ;;
|
||||
cursor-bin) command=cursor ;;
|
||||
sublime-text-4) command=sublime_text ;;
|
||||
sublime-text-4) command=subl ;;
|
||||
vim) command=vim ;;
|
||||
neovim) command=nvim ;;
|
||||
esac
|
||||
@@ -107,6 +109,7 @@ SH
|
||||
|
||||
for installer in \
|
||||
omarchy-pkg-add \
|
||||
omarchy-pkg-aur-add \
|
||||
omarchy-install-browser \
|
||||
omarchy-install-terminal \
|
||||
omarchy-install-editor-vscode \
|
||||
@@ -166,7 +169,7 @@ editor_cases=(
|
||||
'code code editor:vscode'
|
||||
'cursor cursor pkg:cursor-bin'
|
||||
'zed zeditor editor:zed'
|
||||
'sublime_text sublime_text pkg:sublime-text-4'
|
||||
'sublime_text subl pkg:sublime-text-4'
|
||||
'helix helix editor:helix'
|
||||
'vim vim pkg:vim'
|
||||
'emacs emacs editor:emacs'
|
||||
@@ -205,10 +208,17 @@ OMARCHY_TEST_REAL_BROWSER_INSTALL=true omarchy-default-browser --install chromiu
|
||||
[[ $(omarchy-default-browser) == "chromium" ]] || fail "Chromium becomes the default after its full installer succeeds"
|
||||
cmp -s "$ROOT/config/chromium-flags.conf" "$test_home/.config/chromium-flags.conf" ||
|
||||
fail "Chromium browser installer copies the default flags"
|
||||
grep -Fxq 'sudo:mkdir -p /etc/chromium/policies/managed' "$setup_log" ||
|
||||
fail "Chromium browser installer creates its policy directory"
|
||||
grep -Fxq 'sudo:chmod a+rw /etc/chromium/policies/managed' "$setup_log" ||
|
||||
fail "Chromium browser installer makes its policy directory writable"
|
||||
grep -Fxq 'sudo:install -d -m 0755 -o root -g root /etc/chromium' "$setup_log" ||
|
||||
fail "Chromium browser installer creates a root-owned Chromium policy parent"
|
||||
grep -Fxq 'sudo:install -d -m 0755 -o root -g root /etc/chromium/policies' "$setup_log" ||
|
||||
fail "Chromium browser installer creates a root-owned Chromium policies parent"
|
||||
grep -Fxq 'sudo:install -d -m 0755 -o root -g root /etc/chromium/policies/managed' "$setup_log" ||
|
||||
fail "Chromium browser installer creates a root-owned managed policy directory"
|
||||
grep -Fxq 'sudo:find /etc/chromium/policies/managed -mindepth 1 -maxdepth 1 ! -user root -exec rm -rf -- {} +' "$setup_log" ||
|
||||
fail "Chromium browser installer drops non-root files from its policy directory"
|
||||
if grep -E 'groupadd|usermod|omarchy-browser-policy' "$setup_log" >/dev/null; then
|
||||
fail "Chromium browser installer does not create a browser-policy group" "$(cat "$setup_log")"
|
||||
fi
|
||||
grep -Fxq 'omarchy-install-chromium-copy-url:' "$setup_log" ||
|
||||
fail "Chromium browser installer registers the Copy URL host"
|
||||
grep -Fxq 'omarchy-install-chromium-ytdlp:' "$setup_log" ||
|
||||
@@ -217,6 +227,36 @@ grep -Fxq 'omarchy-theme-set-browser:' "$setup_log" ||
|
||||
fail "Chromium browser installer applies the current theme"
|
||||
pass "Chromium browser installer restores the complete Omarchy setup"
|
||||
|
||||
: >"$install_log"
|
||||
: >"$setup_log"
|
||||
rm -f "$installed_dir/firefox"
|
||||
OMARCHY_TEST_REAL_BROWSER_INSTALL=true omarchy-default-browser --install firefox >/dev/null
|
||||
[[ $(<"$install_log") == "pkg:firefox" ]] || fail "Firefox browser installer installs the package"
|
||||
[[ $(omarchy-default-browser) == "firefox" ]] || fail "Firefox becomes the default after its full installer succeeds"
|
||||
grep -Fxq 'sudo:install -d -m 0755 -o root -g root /usr/lib/firefox/distribution' "$setup_log" ||
|
||||
fail "Firefox browser installer creates its distribution directory"
|
||||
grep -Fxq 'sudo:find /usr/lib/firefox/distribution -mindepth 1 -maxdepth 1 ! -user root -exec rm -rf -- {} +' "$setup_log" ||
|
||||
fail "Firefox browser installer drops non-root files from its distribution directory"
|
||||
grep -Fxq "sudo:install -m 644 -o root -g root -T $ROOT/default/firefox/policies.json /usr/lib/firefox/distribution/policies.json" "$setup_log" ||
|
||||
fail "Firefox browser installer copies policies.json without following a destination symlink"
|
||||
[[ -e $installed_dir/firefox ]] || fail "Firefox browser installer marks firefox installed"
|
||||
pass "Firefox browser installer restores the complete Omarchy setup"
|
||||
|
||||
: >"$install_log"
|
||||
: >"$setup_log"
|
||||
rm -f "$installed_dir/zen-browser"
|
||||
OMARCHY_TEST_REAL_BROWSER_INSTALL=true omarchy-default-browser --install zen >/dev/null
|
||||
[[ $(<"$install_log") == "pkg:zen-browser-bin" ]] || fail "Zen browser installer installs the package"
|
||||
[[ $(omarchy-default-browser) == "zen" ]] || fail "Zen becomes the default after its full installer succeeds"
|
||||
grep -Fxq 'sudo:install -d -m 0755 -o root -g root /opt/zen-browser/distribution' "$setup_log" ||
|
||||
fail "Zen browser installer creates its distribution directory"
|
||||
grep -Fxq 'sudo:find /opt/zen-browser/distribution -mindepth 1 -maxdepth 1 ! -user root -exec rm -rf -- {} +' "$setup_log" ||
|
||||
fail "Zen browser installer drops non-root files from its distribution directory"
|
||||
grep -Fxq "sudo:install -m 644 -o root -g root -T $ROOT/default/firefox/policies.json /opt/zen-browser/distribution/policies.json" "$setup_log" ||
|
||||
fail "Zen browser installer copies policies.json without following a destination symlink"
|
||||
[[ -e $installed_dir/zen-browser ]] || fail "Zen browser installer marks zen-browser installed"
|
||||
pass "Zen browser installer restores the complete Omarchy setup"
|
||||
|
||||
omarchy-default-browser zen
|
||||
rm -f "$installed_dir/chromium"
|
||||
if OMARCHY_TEST_REAL_BROWSER_INSTALL=true OMARCHY_TEST_INSTALL_FAIL=true \
|
||||
|
||||
@@ -17,6 +17,11 @@ printf 'pkg:%s\n' "$*" >>"$OMARCHY_TEST_LOG"
|
||||
exit "${OMARCHY_TEST_PKG_STATUS:-0}"
|
||||
SH
|
||||
|
||||
cat >"$mock_bin/omarchy-font-set" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'font:%s\n' "$*" >>"$OMARCHY_TEST_LOG"
|
||||
SH
|
||||
|
||||
for command in omarchy-pkg-aur-add omarchy-install-emacs omazed omarchy-theme-set-vscode omarchy-install-gaming-gpu-lib32; do
|
||||
cat >"$mock_bin/$command" <<'SH'
|
||||
#!/bin/bash
|
||||
@@ -97,3 +102,136 @@ if grep -q '^launch:' "$OMARCHY_TEST_LOG"; then
|
||||
fail "generic installer does not launch after package installation failure"
|
||||
fi
|
||||
pass "generic installer does not launch after package installation failure"
|
||||
|
||||
run_presentation() {
|
||||
bash -c "sleep() { :; }; $(<"$OMARCHY_TEST_PRESENTATION")"
|
||||
}
|
||||
|
||||
bash "$ROOT/bin/omarchy-install-app" "LM Studio" "lmstudio-bin"
|
||||
presentation_command=$(<"$OMARCHY_TEST_PRESENTATION")
|
||||
[[ $presentation_command == *'echo Installing\ LM\ Studio...;'* ]] ||
|
||||
fail "install-app shell-quotes the display name" "$presentation_command"
|
||||
[[ $presentation_command == *'omarchy-pkg-add lmstudio-bin'* ]] ||
|
||||
fail "install-app still passes the package list through" "$presentation_command"
|
||||
pass "install-app shell-quotes the display name"
|
||||
|
||||
bash "$ROOT/bin/omarchy-install-app" "Example App" "alpha beta"
|
||||
presentation_command=$(<"$OMARCHY_TEST_PRESENTATION")
|
||||
[[ $presentation_command == 'echo Installing\ Example\ App...; omarchy-pkg-add alpha beta' ]] ||
|
||||
fail "install-app builds the presentation command with no stray argument" "$presentation_command"
|
||||
: >"$OMARCHY_TEST_LOG"
|
||||
run_presentation
|
||||
grep -Fxq 'pkg:alpha beta' "$OMARCHY_TEST_LOG" ||
|
||||
fail "install-app passes every package to the package helper"
|
||||
pass "install-app passes every package to the package helper"
|
||||
|
||||
bash "$ROOT/bin/omarchy-install-app" "Foo's App" "alpha"
|
||||
presentation_command=$(<"$OMARCHY_TEST_PRESENTATION")
|
||||
quoted_app_message="echo $(printf '%q' "Installing Foo's App...");"
|
||||
[[ $presentation_command == "$quoted_app_message"* ]] ||
|
||||
fail "install-app shell-quotes an apostrophe in the display name" "$presentation_command"
|
||||
: >"$OMARCHY_TEST_LOG"
|
||||
run_presentation >"$test_tmp/app-apostrophe.out"
|
||||
grep -Fxq 'pkg:alpha' "$OMARCHY_TEST_LOG" ||
|
||||
fail "install-app still installs when the display name has an apostrophe"
|
||||
pass "install-app still installs when the display name has an apostrophe"
|
||||
|
||||
bash "$ROOT/bin/omarchy-install-app" "a'; echo PWNED; echo '" "alpha"
|
||||
: >"$OMARCHY_TEST_LOG"
|
||||
run_presentation >"$test_tmp/app-inject.out"
|
||||
if grep -Fxq 'PWNED' "$test_tmp/app-inject.out"; then
|
||||
fail "install-app does not run extra commands from a quote in the display name" "$(<"$test_tmp/app-inject.out")"
|
||||
fi
|
||||
grep -Fxq 'pkg:alpha' "$OMARCHY_TEST_LOG" ||
|
||||
fail "install-app still installs after quoting a hostile display name"
|
||||
pass "install-app does not run extra commands from a quote in the display name"
|
||||
|
||||
bash "$ROOT/bin/omarchy-install-font" "Cascadia Mono" "ttf-cascadia-mono-nerd" "CaskaydiaMono Nerd Font"
|
||||
presentation_command=$(<"$OMARCHY_TEST_PRESENTATION")
|
||||
[[ $presentation_command == *'echo Installing\ Cascadia\ Mono...;'* ]] ||
|
||||
fail "install-font shell-quotes the display name" "$presentation_command"
|
||||
[[ $presentation_command == *'omarchy-font-set CaskaydiaMono\ Nerd\ Font'* ]] ||
|
||||
fail "install-font shell-quotes a font family with spaces" "$presentation_command"
|
||||
: >"$OMARCHY_TEST_LOG"
|
||||
run_presentation
|
||||
grep -Fxq 'pkg:ttf-cascadia-mono-nerd' "$OMARCHY_TEST_LOG" ||
|
||||
fail "install-font installs the font package"
|
||||
grep -Fxq 'font:CaskaydiaMono Nerd Font' "$OMARCHY_TEST_LOG" ||
|
||||
fail "install-font passes the family name through as one argument"
|
||||
pass "install-font shell-quotes the display name and family"
|
||||
|
||||
bash "$ROOT/bin/omarchy-install-font" "Foo's App" "alpha" "Foo's Font"
|
||||
: >"$OMARCHY_TEST_LOG"
|
||||
run_presentation >"$test_tmp/font-apostrophe.out"
|
||||
grep -Fxq 'pkg:alpha' "$OMARCHY_TEST_LOG" ||
|
||||
fail "install-font still installs when the display name has an apostrophe"
|
||||
grep -Fxq "font:Foo's Font" "$OMARCHY_TEST_LOG" ||
|
||||
fail "install-font still sets the family when it has an apostrophe"
|
||||
pass "install-font still installs when the name or family has an apostrophe"
|
||||
|
||||
bash "$ROOT/bin/omarchy-install-font" "a'; echo PWNED; echo '" "alpha" "a'; echo PWNED; echo '"
|
||||
: >"$OMARCHY_TEST_LOG"
|
||||
run_presentation >"$test_tmp/font-inject.out"
|
||||
if grep -Fxq 'PWNED' "$test_tmp/font-inject.out"; then
|
||||
fail "install-font does not run extra commands from a quote in the name or family" "$(<"$test_tmp/font-inject.out")"
|
||||
fi
|
||||
grep -Fxq 'pkg:alpha' "$OMARCHY_TEST_LOG" ||
|
||||
fail "install-font still installs after quoting a hostile display name"
|
||||
pass "install-font does not run extra commands from a quote in the name or family"
|
||||
|
||||
bash "$ROOT/bin/omarchy-install-app" "Example App" "alpha; echo PWNED"
|
||||
: >"$OMARCHY_TEST_LOG"
|
||||
run_presentation >"$test_tmp/app-pkg-inject.out"
|
||||
if grep -Fxq 'PWNED' "$test_tmp/app-pkg-inject.out"; then
|
||||
fail "install-app does not run extra commands from a package list" "$(<"$test_tmp/app-pkg-inject.out")"
|
||||
fi
|
||||
grep -Fxq 'pkg:alpha; echo PWNED' "$OMARCHY_TEST_LOG" ||
|
||||
fail "install-app hands a hostile package list to the package helper as arguments" "$(<"$OMARCHY_TEST_LOG")"
|
||||
pass "install-app does not run extra commands from a package list"
|
||||
|
||||
bash "$ROOT/bin/omarchy-install-app" "Example App" "$(printf 'alpha\nbeta')"
|
||||
: >"$OMARCHY_TEST_LOG"
|
||||
run_presentation
|
||||
grep -Fxq 'pkg:alpha beta' "$OMARCHY_TEST_LOG" ||
|
||||
fail "install-app keeps every package when the list is newline-separated" "$(<"$OMARCHY_TEST_LOG")"
|
||||
pass "install-app keeps every package when the list is newline-separated"
|
||||
|
||||
env SHELLOPTS=errexit bash "$ROOT/bin/omarchy-install-app" "Example App" "alpha beta" ||
|
||||
fail "install-app builds its command under an inherited errexit"
|
||||
[[ $(<"$OMARCHY_TEST_PRESENTATION") == 'echo Installing\ Example\ App...; omarchy-pkg-add alpha beta' ]] ||
|
||||
fail "install-app builds the same command under an inherited errexit" "$(<"$OMARCHY_TEST_PRESENTATION")"
|
||||
env SHELLOPTS=errexit bash "$ROOT/bin/omarchy-install-and-launch" "Example App" "alpha beta" "Disk Usage" ||
|
||||
fail "install-and-launch builds its command under an inherited errexit"
|
||||
grep -Fq 'omarchy-pkg-add alpha beta' "$OMARCHY_TEST_PRESENTATION" ||
|
||||
fail "install-and-launch keeps its package list under an inherited errexit" "$(<"$OMARCHY_TEST_PRESENTATION")"
|
||||
pass "the installers build their command under an inherited errexit"
|
||||
|
||||
bash "$ROOT/bin/omarchy-install-font" "Example Font" "alpha; echo PWNED" "Example Family"
|
||||
: >"$OMARCHY_TEST_LOG"
|
||||
run_presentation >"$test_tmp/font-pkg-inject.out"
|
||||
if grep -Fxq 'PWNED' "$test_tmp/font-pkg-inject.out"; then
|
||||
fail "install-font does not run extra commands from its package" "$(<"$test_tmp/font-pkg-inject.out")"
|
||||
fi
|
||||
grep -Fxq 'pkg:alpha; echo PWNED' "$OMARCHY_TEST_LOG" ||
|
||||
fail "install-font hands a hostile package to the package helper as one argument" "$(<"$OMARCHY_TEST_LOG")"
|
||||
pass "install-font does not run extra commands from its package"
|
||||
|
||||
bash "$ROOT/bin/omarchy-install-font" "Example Font" "alpha" "Example Family"
|
||||
: >"$OMARCHY_TEST_LOG"
|
||||
if OMARCHY_TEST_PKG_STATUS=1 run_presentation; then
|
||||
fail "install-font propagates package installation failure"
|
||||
fi
|
||||
if grep -q '^font:' "$OMARCHY_TEST_LOG"; then
|
||||
fail "install-font does not set the family after package installation failure" "$(<"$OMARCHY_TEST_LOG")"
|
||||
fi
|
||||
pass "install-font does not set the family after package installation failure"
|
||||
|
||||
bash "$ROOT/bin/omarchy-install-and-launch" "Example App" "alpha; echo PWNED" "Disk Usage"
|
||||
: >"$OMARCHY_TEST_LOG"
|
||||
run_presentation >"$test_tmp/launch-pkg-inject.out"
|
||||
if grep -Fxq 'PWNED' "$test_tmp/launch-pkg-inject.out"; then
|
||||
fail "install-and-launch does not run extra commands from a package list" "$(<"$test_tmp/launch-pkg-inject.out")"
|
||||
fi
|
||||
grep -Fxq 'pkg:alpha; echo PWNED' "$OMARCHY_TEST_LOG" ||
|
||||
fail "install-and-launch hands a hostile package list to the package helper as arguments" "$(<"$OMARCHY_TEST_LOG")"
|
||||
pass "install-and-launch does not run extra commands from a package list"
|
||||
@@ -30,10 +30,64 @@ grep -E 'sudo -n -l -l' "$dns" >/dev/null ||
|
||||
|
||||
pass "dns sudoers rule is scoped to the stock providers"
|
||||
|
||||
# The privileged half runs as root under sudo's secure_path, and a dev link
|
||||
# (etc/sudoers.d/omarchy-dev-path) prepends a user-writable checkout bin/ to it.
|
||||
# Every helper the script calls by bare name -- dirname, install, tee, nmcli,
|
||||
# systemctl, awk -- is a system tool, so once it holds root the script pins PATH
|
||||
# to trusted system directories and never resolves one of them out of the
|
||||
# checkout. The unprivileged wrapper phase keeps the caller's PATH, which is why
|
||||
# the pin is gated on EUID rather than set unconditionally.
|
||||
grep -Eq '^\s*export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin' "$dns" ||
|
||||
fail "omarchy-dns pins PATH to trusted system directories when it holds root"
|
||||
# require_root carries its own `(( EUID == 0 ))`, so matching that text alone
|
||||
# would pass with the pin deleted. Anchor on the unindented guard and require the
|
||||
# pin to be the line it opens.
|
||||
gated=$(grep -A1 -E '^if \(\( EUID == 0 \)\); then$' "$dns" || true)
|
||||
[[ $gated == *"export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin"* ]] ||
|
||||
fail "omarchy-dns gates the trusted-PATH pin on holding root"
|
||||
|
||||
# The no-argument path only reads DNS config, so exercise the privileged phase
|
||||
# directly when the suite is root and as namespaced root otherwise. This reaches
|
||||
# tr while EUID is 0 without giving an ordinary test run any host privileges.
|
||||
root_runner=()
|
||||
if (( EUID != 0 )); then
|
||||
root_runner=(unshare --user --map-root-user)
|
||||
fi
|
||||
|
||||
# A sandbox or a hardened kernel can refuse unprivileged user namespaces, and
|
||||
# the non-graphical suites have to stay green on any machine -- a skip is a
|
||||
# passing test. Only the runtime probe needs the namespace; the static checks
|
||||
# above and the elevation checks below run either way.
|
||||
if (( EUID == 0 )) || unshare --user --map-root-user true 2>/dev/null; then
|
||||
poison_dir=$(mktemp -d)
|
||||
poison_ran="$poison_dir/ran"
|
||||
for helper in tr awk dirname install tee; do
|
||||
cat >"$poison_dir/$helper" <<SH
|
||||
#!/bin/bash
|
||||
printf 'x' >"$poison_ran"
|
||||
exec "/usr/bin/$helper" "\$@"
|
||||
SH
|
||||
chmod +x "$poison_dir/$helper"
|
||||
done
|
||||
|
||||
if ! PATH="$poison_dir:$PATH" "${root_runner[@]}" bash "$dns" </dev/null >/dev/null 2>&1; then
|
||||
rm -rf "$poison_dir"
|
||||
fail "root omarchy-dns failed its read-only trusted-PATH probe"
|
||||
fi
|
||||
if [[ -e $poison_ran ]]; then
|
||||
rm -rf "$poison_dir"
|
||||
fail "root omarchy-dns resolved a bare helper from the front of PATH instead of a trusted system path"
|
||||
fi
|
||||
rm -rf "$poison_dir"
|
||||
pass "root omarchy-dns resolves system helpers from a trusted PATH, not the invocation PATH"
|
||||
else
|
||||
skip "no unprivileged user namespace; skipping the root trusted-PATH probe"
|
||||
fi
|
||||
|
||||
# require_root returns immediately for root, so the stubs below would not stand
|
||||
# between the script and the host's real NetworkManager and resolved config.
|
||||
if (( EUID == 0 )); then
|
||||
pass "running as root; skipping the elevation checks, which would rewrite this machine's DNS"
|
||||
skip "running as root; skipping the elevation checks, which would rewrite this machine's DNS"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# The docker-group opt-in migration must remove an existing install's user from
|
||||
# the root-equivalent docker group (only when they are in it), flag a reboot so
|
||||
# the group change actually takes effect (group membership is fixed at login),
|
||||
# refresh the stale Docker launcher entry, and stay idempotent on reruns.
|
||||
#
|
||||
# The real omarchy-remove-security-sudoless-docker and omarchy-state run here
|
||||
# (from the repo bin); only the privileged/system calls are stubbed, so the whole
|
||||
# chain — including the reboot flag — is exercised.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh"
|
||||
|
||||
migration="$ROOT/migrations/1787580187.sh"
|
||||
test_dir=$(mktemp -d)
|
||||
trap 'rm -rf "$test_dir"' EXIT
|
||||
|
||||
home="$test_dir/home"
|
||||
omarchy_path="$test_dir/omarchy"
|
||||
stub_bin="$test_dir/bin"
|
||||
mkdir -p "$home/.local/share/applications" "$omarchy_path/applications" "$stub_bin"
|
||||
|
||||
printf 'NEW-LAUNCHER\n' >"$omarchy_path/applications/Docker.desktop"
|
||||
printf 'OLD-LAUNCHER\n' >"$home/.local/share/applications/Docker.desktop"
|
||||
|
||||
# id reports a controllable group set; sudo just drops the prefix; gpasswd
|
||||
# records its call instead of touching the real system.
|
||||
cat >"$stub_bin/id" <<'STUB'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "${STUB_GROUPS:-wheel input}"
|
||||
STUB
|
||||
cat >"$stub_bin/sudo" <<'STUB'
|
||||
#!/bin/bash
|
||||
exec "$@"
|
||||
STUB
|
||||
cat >"$stub_bin/gpasswd" <<'STUB'
|
||||
#!/bin/bash
|
||||
echo "$@" >>"${GPASSWD_CALLS:?}"
|
||||
STUB
|
||||
# gum confirm always says yes, and reboot records that it fired: the migration
|
||||
# must still NOT reboot (it defers to omarchy-update-restart), so neither should
|
||||
# be reached.
|
||||
cat >"$stub_bin/gum" <<'STUB'
|
||||
#!/bin/bash
|
||||
[[ $1 == confirm ]] && exit 0
|
||||
exit 0
|
||||
STUB
|
||||
cat >"$stub_bin/omarchy-system-reboot" <<'STUB'
|
||||
#!/bin/bash
|
||||
touch "${REBOOT_CALLED:?}"
|
||||
STUB
|
||||
chmod +x "$stub_bin/id" "$stub_bin/sudo" "$stub_bin/gpasswd" "$stub_bin/gum" "$stub_bin/omarchy-system-reboot"
|
||||
|
||||
reboot_flag="$home/.local/state/omarchy/reboot-required"
|
||||
gpasswd_calls="$test_dir/gpasswd-calls"
|
||||
reboot_called="$test_dir/reboot-called"
|
||||
launcher="$home/.local/share/applications/Docker.desktop"
|
||||
|
||||
run_migration() {
|
||||
rm -f "$gpasswd_calls" "$reboot_flag" "$reboot_called"
|
||||
HOME="$home" OMARCHY_PATH="$omarchy_path" USER="tester" STUB_GROUPS="$1" \
|
||||
GPASSWD_CALLS="$gpasswd_calls" REBOOT_CALLED="$reboot_called" \
|
||||
PATH="$stub_bin:$ROOT/bin:$PATH" \
|
||||
bash -euo pipefail "$migration" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# In the docker group: user removed, reboot flagged, launcher refreshed.
|
||||
run_migration "wheel input docker" || fail "migration runs when the user is in the docker group"
|
||||
grep -q -- "-d tester docker" "$gpasswd_calls" || fail "migration removes the user from the docker group"
|
||||
[[ -f $reboot_flag ]] || fail "migration flags a reboot so the group change takes effect"
|
||||
[[ ! -f $reboot_called ]] || fail "migration must defer the reboot (not reboot mid-update)"
|
||||
[[ $(cat "$launcher") == "NEW-LAUNCHER" ]] || fail "migration refreshes the stale Docker launcher entry"
|
||||
pass "migration removes the group, flags a reboot, and refreshes the launcher"
|
||||
|
||||
# Not in the docker group (fresh install, or already migrated): nothing changes.
|
||||
printf 'OLD-LAUNCHER\n' >"$launcher"
|
||||
run_migration "wheel input" || fail "migration runs when the user is not in the docker group"
|
||||
[[ ! -f $gpasswd_calls ]] || fail "migration must not touch the group when the user is not in it"
|
||||
[[ ! -f $reboot_flag ]] || fail "migration must not flag a reboot when nothing changed"
|
||||
[[ $(cat "$launcher") == "NEW-LAUNCHER" ]] || fail "migration still refreshes the launcher when the group is already absent"
|
||||
pass "migration is a no-op on the group and reboot when already out"
|
||||
|
||||
# No launcher entry present: the refresh is skipped without error.
|
||||
rm -f "$launcher"
|
||||
run_migration "wheel input" || fail "migration tolerates a missing launcher entry"
|
||||
[[ ! -e $launcher ]] || fail "migration does not create a launcher entry that was not there"
|
||||
pass "migration skips the launcher refresh when no entry exists"
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
require_command jq
|
||||
|
||||
test_dir=$(mktemp -d)
|
||||
trap 'rm -rf "$test_dir"' EXIT
|
||||
mkdir -p "$test_dir/bin" "$test_dir/home"
|
||||
export CALL_LOG="$test_dir/calls"
|
||||
|
||||
cat >"$test_dir/bin/omarchy-pkg-add" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'package %s\n' "$*" >>"$CALL_LOG"
|
||||
exit "${PACKAGE_STATUS:-0}"
|
||||
SH
|
||||
cat >"$test_dir/bin/omarchy-shell" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$*" >>"$CALL_LOG"
|
||||
quiet=0
|
||||
if [[ $1 == "-q" ]]; then
|
||||
quiet=1
|
||||
shift
|
||||
fi
|
||||
if [[ ${SHELL_ABSENT:-0} == 1 ]]; then
|
||||
(( quiet )) && exit 0
|
||||
echo "omarchy-shell is not running" >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ $2 != "rescanPlugins" ]] || exit 0
|
||||
printf '%s\n' "${TEST_PUT_RESULT:-ok}"
|
||||
SH
|
||||
cat >"$test_dir/bin/omarchy-restart-shell" <<'SH'
|
||||
#!/bin/bash
|
||||
echo 'migration must leave the restart to omarchy update' >&2
|
||||
exit 1
|
||||
SH
|
||||
chmod +x "$test_dir/bin/"*
|
||||
|
||||
mkdir -p "$test_dir/packaged/shell/plugins/omacom.elsewhen"
|
||||
migration="$ROOT/migrations/1790042972.sh"
|
||||
sed "s|/usr/share/omarchy|$test_dir/packaged|g" "$migration" >"$test_dir/migration.sh"
|
||||
|
||||
plugin="$test_dir/home/.config/omarchy/plugins/omacom.elsewhen"
|
||||
run_migration() {
|
||||
: >"$CALL_LOG"
|
||||
HOME="$test_dir/home" OMARCHY_PATH="${1:-$test_dir/packaged}" PATH="$test_dir/bin:$ROOT/bin:$PATH" \
|
||||
bash -euo pipefail "$test_dir/migration.sh" >"$test_dir/output" 2>&1
|
||||
}
|
||||
|
||||
if PACKAGE_STATUS=1 run_migration; then
|
||||
fail "package failure stops the migration"
|
||||
fi
|
||||
[[ ! -e $plugin && ! -L $plugin && $(cat "$CALL_LOG") == "package elsewhen" ]] ||
|
||||
fail "package failure leaves the plugin and shell untouched"
|
||||
pass "package failure stops before changing the shell"
|
||||
|
||||
run_migration
|
||||
[[ ! -e $plugin && ! -L $plugin ]] || fail "migration does not create a user plugin link"
|
||||
[[ ! -e $ROOT/config/omarchy/plugins/omacom.elsewhen && ! -L $ROOT/config/omarchy/plugins/omacom.elsewhen ]] || fail "fresh installs do not ship a user plugin link"
|
||||
pass "migration and fresh installs rely on the packaged plugin directory"
|
||||
|
||||
expected=$'package elsewhen\n-q shell rescanPlugins\nshell putBarWidget omacom.elsewhen {"before":"omarchy.clock"}'
|
||||
[[ $(cat "$CALL_LOG") == "$expected" ]] || fail "install, scan and placement run in order" "$(cat "$CALL_LOG")"
|
||||
pass "real bar helper enables and places before the clock without restarting during reload"
|
||||
|
||||
run_migration
|
||||
[[ $(cat "$CALL_LOG") == "$expected" ]] || fail "migration can be rerun"
|
||||
[[ ! -e $plugin && ! -L $plugin ]] || fail "rerunning the migration does not create a user plugin link"
|
||||
pass "migration can be rerun without a user plugin link"
|
||||
|
||||
run_migration "$ROOT"
|
||||
[[ $(readlink "$plugin") == "$test_dir/packaged/shell/plugins/omacom.elsewhen" ]] || fail "dev checkout links the packaged plugin"
|
||||
pass "dev checkout discovers Elsewhen through a user plugin link"
|
||||
run_migration "$ROOT"
|
||||
[[ $(readlink "$plugin") == "$test_dir/packaged/shell/plugins/omacom.elsewhen" ]] || fail "dev link survives a rerun"
|
||||
pass "dev link is idempotent"
|
||||
rm "$plugin"
|
||||
|
||||
mkdir -p "$plugin"
|
||||
printf 'local work\n' >"$plugin/notes"
|
||||
run_migration "$ROOT"
|
||||
[[ ! -L $plugin && $(cat "$plugin/notes") == "local work" ]] || fail "existing checkout is preserved"
|
||||
pass "existing checkout and local files are preserved"
|
||||
|
||||
rm "$plugin/notes"
|
||||
rmdir "$plugin"
|
||||
ln -s "$test_dir/custom-plugin" "$plugin"
|
||||
run_migration "$ROOT"
|
||||
[[ $(readlink "$plugin") == "$test_dir/custom-plugin" ]] || fail "existing symlink is preserved"
|
||||
pass "existing symlink is preserved, including a missing target"
|
||||
|
||||
# An earlier revision, and a symlink once shipped under config/, pointed every
|
||||
# install at the package's old path.
|
||||
for tree in "$ROOT" "$test_dir/packaged"; do
|
||||
ln -sfn "$test_dir/packaged/plugins/omacom.elsewhen" "$plugin"
|
||||
run_migration "$tree"
|
||||
[[ $(readlink "$plugin") == "$test_dir/packaged/shell/plugins/omacom.elsewhen" ]] ||
|
||||
fail "a stranded link to the package's old path is re-pointed (OMARCHY_PATH=$tree)" "$(readlink "$plugin")"
|
||||
done
|
||||
pass "a stranded link to the package's old path is re-pointed on every install"
|
||||
rm "$plugin"
|
||||
|
||||
if env TEST_PUT_RESULT=unknown HOME="$test_dir/home" OMARCHY_PATH="$ROOT" PATH="$test_dir/bin:$ROOT/bin:$PATH" \
|
||||
bash -euo pipefail "$test_dir/migration.sh" >"$test_dir/output" 2>&1; then
|
||||
fail "an unknown widget must leave the migration pending"
|
||||
fi
|
||||
pass "an unknown widget leaves the migration pending"
|
||||
|
||||
# An update with no shell to ask, from a TTY or with the shell down, still
|
||||
# finishes: the package and link land, the placement is skipped, and the update
|
||||
# restarts the shell afterwards.
|
||||
rm "$plugin"
|
||||
: >"$CALL_LOG"
|
||||
if ! env SHELL_ABSENT=1 OMARCHY_SHELL_ABSENT_ATTEMPTS=1 HOME="$test_dir/home" OMARCHY_PATH="$ROOT" PATH="$test_dir/bin:$ROOT/bin:$PATH" \
|
||||
bash -euo pipefail "$test_dir/migration.sh" >"$test_dir/output" 2>&1; then
|
||||
fail "an absent shell must not fail the migration" "$(cat "$test_dir/output")"
|
||||
fi
|
||||
grep -q "omacom.elsewhen was not put on the bar" "$test_dir/output" || fail "an absent shell is reported" "$(cat "$test_dir/output")"
|
||||
[[ $(readlink "$plugin") == "$test_dir/packaged/shell/plugins/omacom.elsewhen" ]] || fail "the package and plugin link land without a shell"
|
||||
[[ $(cat "$CALL_LOG") == "$expected" ]] || fail "the rescan is best-effort and the put is still asked" "$(cat "$CALL_LOG")"
|
||||
pass "an absent shell leaves the update running with the package and link in place"
|
||||
|
||||
# The first run of this migration was under 1789581661.sh, before the re-point
|
||||
# existed; that marker must not stop the renamed file from running there.
|
||||
state="$test_dir/state"
|
||||
mkdir -p "$state"
|
||||
touch "$state/1789581661.sh"
|
||||
OMARCHY_MIGRATION_STATE="$state" OMARCHY_PATH="$ROOT" "$ROOT/bin/omarchy-migrate" --pending >"$test_dir/pending" || true
|
||||
grep -qx "$(basename "$migration")" "$test_dir/pending" || fail "the old marker must not satisfy the renamed migration" "$(cat "$test_dir/pending")"
|
||||
pass "a machine that applied the migration under its old name runs it again"
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
if (( EUID != 0 )); then
|
||||
if unshare --user --map-root-user true 2>/dev/null; then
|
||||
exec unshare --user --map-root-user bash "$0"
|
||||
fi
|
||||
skip "no unprivileged user namespace; skipping factory account cleanup"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
# Load the production functions without self-elevation or the reset entrypoint.
|
||||
awk '
|
||||
/^[a-z_]+\(\) \{/ { copying = 1 }
|
||||
copying { print }
|
||||
/^}/ { copying = 0 }
|
||||
' "$ROOT/bin/omarchy-system-factory-reset" >"$test_tmp/functions"
|
||||
|
||||
cat >"$test_tmp/reset" <<'SH'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
source "$1/functions"
|
||||
TOP_MNT="$2"
|
||||
NEXT_NAME=@omarchy-reset-next
|
||||
PROVISIONING_DIR=/var/lib/omarchy/provisioning
|
||||
LOG_FILE="$TOP_MNT/reset.log"
|
||||
|
||||
log() { printf '%s\n' "$1" >>"$LOG_FILE"; }
|
||||
fail() { log "$1"; exit 1; }
|
||||
|
||||
# Account tools are real. Only snapshots, boot rebuilding, and system services
|
||||
# are replaced: all writes stay inside this test's disposable directory.
|
||||
btrfs() {
|
||||
if [[ $1 == "subvolume" && $2 == "snapshot" ]]; then
|
||||
mkdir -p "$4"
|
||||
cp -a "$3/." "$4/"
|
||||
elif [[ $1 == "property" ]]; then
|
||||
printf '%s\n' "$6" >"$4/read-only"
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
systemd-id128() { printf '%032d\n' 1; }
|
||||
install_provisioning_units() { :; }
|
||||
encrypted_install() { return 1; }
|
||||
rebuild_next_boot() { touch "$TOP_MNT/rebuilt"; }
|
||||
sync() { :; }
|
||||
|
||||
userdel() {
|
||||
[[ ${FAIL_COMMAND:-} == "userdel" && $2 == "$FAIL_ROOT" ]] && return 42
|
||||
command userdel "$@"
|
||||
}
|
||||
usermod() {
|
||||
[[ ${FAIL_COMMAND:-} == "usermod" && $2 == "$FAIL_ROOT" ]] && return 42
|
||||
command usermod "$@"
|
||||
}
|
||||
rm() {
|
||||
[[ ${FAIL_COMMAND:-} == "rm" && $* == *"$FAIL_ROOT/etc/shadow-"* ]] && return 42
|
||||
command rm "$@"
|
||||
}
|
||||
|
||||
stage_full_reset
|
||||
SH
|
||||
|
||||
make_fixture() {
|
||||
local top="$1" root_hash="${2:-original-root-hash}"
|
||||
local factory="$top/@factory"
|
||||
mkdir -p "$top/@" "$factory/etc" "$factory/home/seller" \
|
||||
"$factory/usr/bin" "$factory/usr/share/omarchy/install/provisioning" \
|
||||
"$factory/var/lib/omarchy/provisioning/packages"
|
||||
touch "$top/@/old-system" "$factory/home/seller/private-file" \
|
||||
"$factory/usr/share/omarchy/install/provisioning/omarchy-provision-owner.service" \
|
||||
"$factory/var/lib/omarchy/provisioning/packages/node-v0.tar.gz"
|
||||
printf '#!/bin/bash\n' >"$factory/usr/bin/omarchy-provision-owner"
|
||||
chmod +x "$factory/usr/bin/omarchy-provision-owner"
|
||||
printf 'true\n' >"$factory/read-only"
|
||||
cat >"$factory/etc/passwd" <<'EOF'
|
||||
root:x:0:0:root:/root:/bin/bash
|
||||
daemon:x:1:1:daemon:/:/usr/bin/nologin
|
||||
seller:x:1000:1000:Seller:/home/seller:/bin/bash
|
||||
EOF
|
||||
printf 'root:%s:20000:0:99999:7:::\ndaemon:*:20000:0:99999:7:::\nseller:original-user-hash:20000:0:99999:7:::\n' \
|
||||
"$root_hash" >"$factory/etc/shadow"
|
||||
printf 'root:x:0:\ndaemon:x:1:\nseller:x:1000:\nwheel:x:998:seller\n' >"$factory/etc/group"
|
||||
printf 'root:!::\ndaemon:!::\nseller:!::\nwheel:!::seller\n' >"$factory/etc/gshadow"
|
||||
printf 'USERGROUPS_ENAB yes\n' >"$factory/etc/login.defs"
|
||||
printf 'seller:100000:65536\n' >"$factory/etc/subuid"
|
||||
printf 'seller:100000:65536\n' >"$factory/etc/subgid"
|
||||
chmod 600 "$factory/etc/"{shadow,gshadow}
|
||||
for file in passwd shadow group gshadow subuid subgid; do
|
||||
cp "$factory/etc/$file" "$factory/etc/$file-"
|
||||
done
|
||||
}
|
||||
|
||||
assert_scrubbed() {
|
||||
local root="$1" file
|
||||
[[ $(awk -F: '$1 == "root" { print $2 }' "$root/etc/shadow") == "!" ]] ||
|
||||
fail "reset erases the root hash while keeping the account locked"
|
||||
! grep -q 'original-.*-hash\|seller' "$root/etc/"{passwd,shadow,group,gshadow} ||
|
||||
fail "reset removes seller account credentials and group membership"
|
||||
[[ ! -e $root/home/seller ]] || fail "reset removes the seller's baseline home"
|
||||
grep -q '^daemon:\*:' "$root/etc/shadow" || fail "reset preserves service accounts"
|
||||
[[ $(stat -c '%a' "$root/etc/shadow") == "600" ]] || fail "shadow stays private"
|
||||
for file in passwd shadow group gshadow subuid subgid; do
|
||||
[[ ! -e $root/etc/$file- ]] || fail "reset removes the $file backup"
|
||||
done
|
||||
}
|
||||
|
||||
for scenario in normal locked; do
|
||||
top="$test_tmp/$scenario"
|
||||
if [[ $scenario == "locked" ]]; then
|
||||
make_fixture "$top" '!'
|
||||
else
|
||||
make_fixture "$top"
|
||||
fi
|
||||
bash "$test_tmp/reset" "$test_tmp" "$top" || fail "$scenario reset stages successfully"
|
||||
assert_scrubbed "$top/@factory"
|
||||
assert_scrubbed "$top/@"
|
||||
[[ $(cat "$top/@factory/read-only") == "true" ]] || fail "baseline returns to read-only"
|
||||
[[ -f $top/@/var/lib/omarchy/provisioning/pending && -f $top/rebuilt ]] ||
|
||||
fail "reset reaches provisioning after cleanup"
|
||||
|
||||
bash "$test_tmp/reset" "$test_tmp" "$top" || fail "$scenario reset can be repeated"
|
||||
assert_scrubbed "$top/@factory"
|
||||
assert_scrubbed "$top/@"
|
||||
pass "$scenario reset scrubs both roots, preserves service accounts, and can be repeated"
|
||||
done
|
||||
|
||||
for target in @omarchy-reset-next @factory; do
|
||||
for command in userdel usermod rm; do
|
||||
top="$test_tmp/fail-$target-$command"
|
||||
make_fixture "$top"
|
||||
if FAIL_COMMAND="$command" FAIL_ROOT="$top/$target" bash "$test_tmp/reset" "$test_tmp" "$top"; then
|
||||
fail "reset accepted failed $command in $target"
|
||||
fi
|
||||
[[ -f $top/@/old-system && ! -e $top/rebuilt ]] ||
|
||||
fail "failed cleanup must not activate or rebuild the reset system"
|
||||
[[ $(cat "$top/@factory/read-only") == "true" ]] ||
|
||||
fail "failed cleanup must leave the baseline read-only"
|
||||
pass "failed $command in $target aborts reset before activation"
|
||||
done
|
||||
done
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
#!/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)
|
||||
if [[ $2 == "--" ]]; then
|
||||
shift 2
|
||||
else
|
||||
shift
|
||||
fi
|
||||
if grep -qx -- "$1" <<< "${INSTALLED:-}"; then
|
||||
:
|
||||
else
|
||||
grep -qx -- "$1" "$INSTALLED_LOG"
|
||||
fi
|
||||
;;
|
||||
-S)
|
||||
printf 'pacman %s\n' "$*" >> "$CALL_LOG"
|
||||
if [[ $4 == "--" ]]; then
|
||||
shift 4
|
||||
for arg in "$@"; do
|
||||
printf '%s\n' "$arg" >> "$INSTALLED_LOG"
|
||||
done
|
||||
fi
|
||||
;;
|
||||
*) 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"
|
||||
@@ -4,7 +4,7 @@ source "$(dirname "$0")/base-test.sh"
|
||||
|
||||
# The hook guards on the real /etc/pam.d path, which can't be mocked via PATH.
|
||||
if [[ -f /etc/pam.d/omarchy-lock-fingerprint ]]; then
|
||||
pass "fingerprint invitation test skipped: host already has fingerprint auth configured"
|
||||
skip "fingerprint invitation test skipped: host already has fingerprint auth configured"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -31,10 +31,12 @@ chmod +x "$test_bin/omarchy-hw-fingerprint"
|
||||
cat >"$test_bin/omarchy-notification-send" <<'EOF'
|
||||
#!/bin/bash
|
||||
echo notification >>"$TEST_LOG"
|
||||
exec_args=()
|
||||
while (($# > 0)); do
|
||||
[[ $1 == "--exec" ]] && echo "exec:$2" >>"$TEST_LOG"
|
||||
if [[ $1 == "--exec" ]]; then shift; exec_args=("$@"); break; fi
|
||||
shift
|
||||
done
|
||||
((${#exec_args[@]})) && echo "exec:${exec_args[*]}" >>"$TEST_LOG"
|
||||
EOF
|
||||
chmod +x "$test_bin/omarchy-notification-send"
|
||||
|
||||
|
||||
Executable
+100
@@ -0,0 +1,100 @@
|
||||
#!/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)
|
||||
if [[ $2 == "--" ]]; then
|
||||
shift 2
|
||||
else
|
||||
shift
|
||||
fi
|
||||
grep -qx -- "$1" <<< "${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,3 @@
|
||||
Stock Omarchy icon font from `config/omarchy.ttf` at `babfafa5^`, before fonts moved into the settings package. SHA-256: `e55e67119e82f56f92d90cbf54b7ccc1b2946b32c535a29370439d7ef5215966`.
|
||||
|
||||
The migration test uses the real font so it exercises the exact-content guard without mocking `sha256sum`.
|
||||
Binary file not shown.
@@ -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,69 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
|
||||
ShellRoot {
|
||||
id: root
|
||||
property var view
|
||||
property var wallpaper
|
||||
property var feed
|
||||
property int step: 0
|
||||
|
||||
Item { id: host; width: 1000; height: 700 }
|
||||
|
||||
function findItem(item, name) {
|
||||
if (item.objectName === name) return item
|
||||
for (var i = 0; i < item.children.length; i++) {
|
||||
var found = findItem(item.children[i], name)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function check(condition, message) {
|
||||
if (!condition) throw new Error(message)
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 150
|
||||
repeat: true
|
||||
running: true
|
||||
onTriggered: {
|
||||
try {
|
||||
if (root.step === 0) {
|
||||
var component = Qt.createComponent("file://" + Quickshell.env("OMARCHY_PATH") + "/shell/plugins/lock/LockView.qml")
|
||||
check(component.status === Component.Ready, component.errorString())
|
||||
root.view = component.createObject(host, {width: 1000, height: 700, backgroundPath: "/still.png", loadBackground: false})
|
||||
check(root.view !== null, component.errorString())
|
||||
root.wallpaper = findItem(root.view, "lockWallpaper")
|
||||
root.feed = findItem(root.view, "lockFeedLoader")
|
||||
check(root.wallpaper.path === "", "hidden still must not decode")
|
||||
root.view.backgroundPath = "/video.mp4"
|
||||
root.view.videoPosterPath = "/poster.jpg"
|
||||
} else if (root.step === 1) {
|
||||
check(!root.feed.active && root.feed.item === null, "hidden video must not load a feed client")
|
||||
root.view.loadBackground = true
|
||||
root.view.powerSaverActive = true
|
||||
} else if (root.step === 2) {
|
||||
check(root.wallpaper.path === "/poster.jpg", "power saver keeps the video poster")
|
||||
check(!root.feed.active, "power saver must not connect to the feed")
|
||||
root.view.powerSaverActive = false
|
||||
} else if (root.step === 3) {
|
||||
check(root.feed.active, "visible video enables the isolated feed loader")
|
||||
check(root.wallpaper.path === "/poster.jpg", "poster stays beneath the feed before its first frame")
|
||||
root.view.displaysBlank = true
|
||||
} else if (root.step === 4) {
|
||||
check(!root.feed.active && root.feed.item === null, "blanking releases the feed client")
|
||||
root.view.loadBackground = false
|
||||
} else {
|
||||
check(root.wallpaper.path === "", "unlock releases the poster image")
|
||||
console.log("OWE_LOCK_TEST_PASS")
|
||||
Qt.quit()
|
||||
}
|
||||
root.step++
|
||||
} catch (error) {
|
||||
console.error("OWE_LOCK_TEST_FAIL: " + error)
|
||||
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")
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
if omarchy-battery-present; then
|
||||
# omarchy:heredoc-expands paths=none -- only interpolates the omarchy bin path
|
||||
cat <<EOF | sudo tee "/etc/udev/rules.d/99-power-profile.rules"
|
||||
SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-power-profile --property=After=power-profiles-daemon.service $HOME/.local/share/omarchy/bin/omarchy-powerprofiles-set"
|
||||
SUBSYSTEM=="power_supply", ATTR{type}=="USB", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-power-profile --property=After=power-profiles-daemon.service $HOME/.local/share/omarchy/bin/omarchy-powerprofiles-set"
|
||||
EOF
|
||||
|
||||
sudo systemctl enable power-profiles-daemon
|
||||
|
||||
sudo udevadm control --reload 2>/dev/null
|
||||
sudo udevadm trigger --subsystem-match=power_supply 2>/dev/null
|
||||
fi
|
||||
@@ -0,0 +1,5 @@
|
||||
# omarchy:heredoc-expands paths=none -- the positional argument is a scalar
|
||||
sudo tee /etc/omarchy/example.conf <<EOF
|
||||
argument=$1
|
||||
command=$HOME/.local/share/omarchy/bin/example
|
||||
EOF
|
||||
@@ -0,0 +1,5 @@
|
||||
mask=$((1 << bits))
|
||||
|
||||
cat >/etc/omarchy/agent.conf <<EOF
|
||||
helper=$HOME/.local/share/omarchy/bin/omarchy-agent
|
||||
EOF
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Two hops. The scan resolves omarchy_bin into helper, so the value it ends up
|
||||
# judging still carries an unresolved $HOME rather than a literal path.
|
||||
omarchy_bin="$HOME/.local/share/omarchy/bin"
|
||||
helper="$omarchy_bin/omarchy-agent"
|
||||
|
||||
# omarchy:heredoc-expands paths=none -- helper names the agent, no path is baked in
|
||||
cat <<EOF | sudo tee /etc/udev/rules.d/99-omarchy-agent.rules >/dev/null
|
||||
SUBSYSTEM=="power_supply", RUN+="$helper"
|
||||
EOF
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
|
||||
# One hop between the expansion and the home path it carries. The token in the
|
||||
# heredoc has no slash and the value never resolves to a literal path, so a scan
|
||||
# that rescues unresolved values would exempt a unit baking the user's home into
|
||||
# /etc/systemd/system.
|
||||
helper="$HOME/.local/share/omarchy/bin/omarchy-agent"
|
||||
|
||||
# omarchy:heredoc-expands paths=none -- helper is just the agent command name
|
||||
cat <<EOF | sudo tee /etc/systemd/system/omarchy-agent.service >/dev/null
|
||||
[Service]
|
||||
ExecStart=$helper
|
||||
EOF
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:heredoc-expands paths=none -- review regression fixture
|
||||
sudo tee /etc/omarchy/review.conf >/dev/null <<EOF
|
||||
ExecStart=${target:-$HOME/.local/bin/payload}
|
||||
EOF
|
||||
@@ -0,0 +1,5 @@
|
||||
cat >/etc/omarchy/agent.conf <<EOF
|
||||
enabled=true
|
||||
EOF
|
||||
helper=$HOME/.local/share/omarchy/bin/omarchy-agent
|
||||
EOF
|
||||
@@ -0,0 +1,3 @@
|
||||
cat >>/etc/omarchy/agent.conf <<EOF
|
||||
helper=$HOME/.local/share/omarchy/bin/omarchy-agent
|
||||
EOF
|
||||
@@ -0,0 +1,4 @@
|
||||
cat <<EOF |
|
||||
command=$HOME/.local/share/omarchy/bin/example
|
||||
EOF
|
||||
sudo tee /etc/omarchy/example.conf
|
||||
@@ -0,0 +1,5 @@
|
||||
if true; then
|
||||
cat <<-EOF | sudo tee /etc/omarchy/indented.conf >/dev/null
|
||||
helper=$HOME/.local/share/omarchy/bin/omarchy-agent
|
||||
EOF
|
||||
fi
|
||||
@@ -0,0 +1,6 @@
|
||||
tmp=/tmp/omarchy-generated
|
||||
copy=$tmp
|
||||
cat >"$tmp" <<EOF
|
||||
command=$HOME/.local/share/omarchy/bin/example
|
||||
EOF
|
||||
sudo install -m644 "$copy" /etc/omarchy/example.conf
|
||||
@@ -0,0 +1,5 @@
|
||||
tmp=/tmp/omarchy-generated
|
||||
cat >"$tmp" <<EOF
|
||||
command=$HOME/.local/share/omarchy/bin/example
|
||||
EOF
|
||||
sudo install -m644 "${tmp}" /etc/omarchy/example.conf
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
cat <<EOF >/tmp/omarchy-review-unit
|
||||
[Service]
|
||||
ExecStart=$HOME/.local/bin/payload
|
||||
EOF
|
||||
sudo install -m 644 /tmp/omarchy-review-unit /etc/systemd/system/review.service
|
||||
@@ -0,0 +1,8 @@
|
||||
tmp=$(mktemp)
|
||||
|
||||
cat >"$tmp" <<EOF
|
||||
#!/bin/bash
|
||||
exec "$HOME/.local/share/omarchy/bin/omarchy-agent" "$@"
|
||||
EOF
|
||||
|
||||
sudo install -m 0755 "$tmp" /usr/local/bin/omarchy-agent-shim
|
||||
@@ -0,0 +1,4 @@
|
||||
# `>|` is a plain redirect with noclobber overridden, not a redirect into a pipe.
|
||||
cat >|/etc/omarchy/agent.conf <<EOF
|
||||
helper=$HOME/.local/share/omarchy/bin/omarchy-agent
|
||||
EOF
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
cat <<EOF | \
|
||||
sudo tee /etc/omarchy/review.conf
|
||||
ExecStart=$HOME/.local/bin/payload
|
||||
EOF
|
||||
@@ -0,0 +1,4 @@
|
||||
# A plain redirect into /etc, no sudo: the command re-execs itself as root.
|
||||
cat >/etc/omarchy/agent.conf <<EOF
|
||||
helper=$HOME/.local/share/omarchy/bin/omarchy-agent
|
||||
EOF
|
||||
@@ -0,0 +1,3 @@
|
||||
sudo dd status=none of=/etc/omarchy/boot.conf <<EOF
|
||||
cmdline=$boot_params
|
||||
EOF
|
||||
@@ -0,0 +1,6 @@
|
||||
DROP_IN=/etc/systemd/system/omarchy-agent.service.d/override.conf
|
||||
|
||||
cat <<EOF | sudo tee "$DROP_IN" >/dev/null
|
||||
[Service]
|
||||
ExecStart=$OMARCHY_PATH/bin/omarchy-agent
|
||||
EOF
|
||||
@@ -0,0 +1,8 @@
|
||||
storage="$HOME/storage"
|
||||
shared="$HOME/shared"
|
||||
|
||||
# omarchy:heredoc-expands paths=shared,storage -- both sources are validated before use
|
||||
cat >/etc/omarchy/mounts.conf <<EOF
|
||||
storage=$storage:/storage
|
||||
shared=$shared:/shared
|
||||
EOF
|
||||
@@ -0,0 +1,11 @@
|
||||
servers="1.1.1.1 9.9.9.9"
|
||||
|
||||
# omarchy:heredoc-expands paths=none -- $servers is a validated IP list, not a path
|
||||
cat <<EOF | sudo tee /etc/omarchy/dns.conf >/dev/null
|
||||
servers=$servers
|
||||
EOF
|
||||
|
||||
# omarchy:heredoc-expands paths=storage -- validated by valid_path and symlink-checked before use
|
||||
cat <<EOF | sudo tee /var/lib/omarchy/mounts.conf >/dev/null
|
||||
source=$storage:/storage
|
||||
EOF
|
||||
@@ -0,0 +1,7 @@
|
||||
resolved="RemoteCommand none"
|
||||
|
||||
grep -qvi '^remotecommand none$' <<<"$resolved" || true
|
||||
|
||||
sudo tee /etc/omarchy/plain.conf >/dev/null <<'EOF'
|
||||
ok=1
|
||||
EOF
|
||||
@@ -0,0 +1,3 @@
|
||||
cat <<EOF | sudo tee /etc/udev/rules.d/99-omarchy.rules >/dev/null
|
||||
SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="/usr/bin/omarchy-powerprofiles-set"
|
||||
EOF
|
||||
@@ -0,0 +1,7 @@
|
||||
cat <<'EOF' | sudo tee /etc/udev/rules.d/99-omarchy.rules >/dev/null
|
||||
SUBSYSTEM=="power_supply", RUN+="/usr/bin/omarchy-powerprofiles-set $HOME"
|
||||
EOF
|
||||
|
||||
cat <<"XML" | sudo tee /etc/omarchy/agent.xml >/dev/null
|
||||
<config path="$HOME/.local/share/omarchy" />
|
||||
XML
|
||||
@@ -0,0 +1,11 @@
|
||||
unit=omarchy-agent.service
|
||||
|
||||
# "/etc/systemd/system/$unit" is a path, but one anchored where root already
|
||||
# owns everything, so paths=none is the truthful declaration -- the check must
|
||||
# not demand paths=unit here.
|
||||
# omarchy:heredoc-expands paths=none -- $unit is a unit name interpolated only into absolute /etc paths
|
||||
cat >"/etc/systemd/system/$unit" <<UNIT
|
||||
[Service]
|
||||
ExecStart=/usr/bin/omarchy-agent
|
||||
ExecStopPost=/usr/bin/rm -f /etc/systemd/system/$unit
|
||||
UNIT
|
||||
@@ -0,0 +1,6 @@
|
||||
# \$TERM stays literal so systemd expands it at runtime; nothing is baked in.
|
||||
cat <<EOF | sudo tee /etc/systemd/system/getty@tty1.service.d/override.conf >/dev/null
|
||||
[Service]
|
||||
Environment=TERM=\$TERM
|
||||
ExecStart=-/usr/bin/agetty --noclear %I \$TERM
|
||||
EOF
|
||||
@@ -0,0 +1,9 @@
|
||||
mkdir -p ~/.config/omarchy
|
||||
|
||||
cat >~/.config/omarchy/agent.conf <<EOF
|
||||
helper=$HOME/.local/share/omarchy/bin/omarchy-agent
|
||||
EOF
|
||||
|
||||
cat >"$HOME/.local/bin/omarchy-shim" <<EOF
|
||||
exec "$OMARCHY_PATH/bin/omarchy-agent" "$@"
|
||||
EOF
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
|
||||
# The name is introduced with a packaged root-owned value and then reassigned to
|
||||
# one under the user's home. Judging the first assignment would read this as the
|
||||
# path it no longer holds.
|
||||
target=/usr/share/omarchy/bin/agent
|
||||
target="$HOME/.local/share/omarchy/bin/agent"
|
||||
|
||||
# omarchy:heredoc-expands paths=none -- target is the packaged agent path under /usr
|
||||
cat <<EOF | sudo tee /etc/systemd/system/omarchy-agent.service >/dev/null
|
||||
[Service]
|
||||
ExecStart=$target
|
||||
EOF
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Install Plymouth package
|
||||
echo "Installing Plymouth..."
|
||||
yay -S --noconfirm --needed plymouth
|
||||
|
||||
# Skip if plymouth already exists for some reason
|
||||
if ! grep -q "plymouth" /etc/mkinitcpio.conf; then
|
||||
# Backup original mkinitcpio.conf just in case
|
||||
backup_timestamp=$(date +"%Y%m%d%H%M%S")
|
||||
sudo cp /etc/mkinitcpio.conf "/etc/mkinitcpio.conf.bak.${backup_timestamp}"
|
||||
|
||||
# Add plymouth to HOOKS array. Should be added:
|
||||
# - After 'base' and 'udev' (or 'systemd' if using systemd hook)
|
||||
# - Before 'encrypt' or 'sd-encrypt' if present
|
||||
|
||||
# Use sed to add plymouth in-place
|
||||
if grep -q "systemd" /etc/mkinitcpio.conf; then
|
||||
# Add after systemd
|
||||
sudo sed -i '/^HOOKS=/s/systemd/systemd plymouth/' /etc/mkinitcpio.conf
|
||||
elif grep -q "udev" /etc/mkinitcpio.conf; then
|
||||
# Add after udev
|
||||
sudo sed -i '/^HOOKS=/s/udev/udev plymouth/' /etc/mkinitcpio.conf
|
||||
else
|
||||
# Fallback: add after base
|
||||
sudo sed -i '/^HOOKS=/s/base/base plymouth/' /etc/mkinitcpio.conf
|
||||
fi
|
||||
fi
|
||||
|
||||
# Regenerate initramfs
|
||||
sudo mkinitcpio -P
|
||||
|
||||
# Add kernel parameters for Plymouth (systemd-boot only)
|
||||
if [ -d "/boot/loader/entries" ]; then
|
||||
echo "Detected systemd-boot"
|
||||
|
||||
for entry in /boot/loader/entries/*.conf; do
|
||||
if [ -f "$entry" ]; then
|
||||
# Skip fallback entries
|
||||
if [[ "$(basename "$entry")" == *"fallback"* ]]; then
|
||||
echo "Skipped: $(basename "$entry") (fallback entry)"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Skip if splash it already present for some reason
|
||||
if ! grep -q "splash" "$entry"; then
|
||||
sudo sed -i '/^options/ s/$/ splash quiet/' "$entry"
|
||||
else
|
||||
echo "Skipped: $(basename "$entry") (splash already present)"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo ""
|
||||
echo "systemd-boot not detected. Please manually add these kernel parameters:"
|
||||
echo " - splash (to see the graphical splash screen)"
|
||||
echo " - quiet (for silent boot)"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Touch .plymouth-sync-needed to signal rebuild on shutdown / reboot
|
||||
touch "$HOME/.config/omarchy/.plymouth-sync-needed"
|
||||
|
||||
# Create the systemd service
|
||||
sudo tee /etc/systemd/system/omarchy-plymouth-shutdown.service >/dev/null <<EOF
|
||||
[Unit]
|
||||
Description=Sync Plymouth Theme on Shutdown
|
||||
DefaultDependencies=yes
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=/bin/true
|
||||
ExecStop=$HOME/.local/share/omarchy/bin/omarchy-plymouth-shutdown-sync
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
# Reload systemd and enable the service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable omarchy-plymouth-shutdown.service
|
||||
sudo systemctl start omarchy-plymouth-shutdown.service
|
||||
@@ -0,0 +1,11 @@
|
||||
if omarchy-battery-present; then
|
||||
cat <<EOF | sudo tee "/etc/udev/rules.d/99-power-profile.rules"
|
||||
SUBSYSTEM=="power_supply", ATTR{type}=="Mains", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-power-profile --property=After=power-profiles-daemon.service $HOME/.local/share/omarchy/bin/omarchy-powerprofiles-set"
|
||||
SUBSYSTEM=="power_supply", ATTR{type}=="USB", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-power-profile --property=After=power-profiles-daemon.service $HOME/.local/share/omarchy/bin/omarchy-powerprofiles-set"
|
||||
EOF
|
||||
|
||||
sudo systemctl enable power-profiles-daemon
|
||||
|
||||
sudo udevadm control --reload 2>/dev/null
|
||||
sudo udevadm trigger --subsystem-match=power_supply 2>/dev/null
|
||||
fi
|
||||
@@ -0,0 +1,9 @@
|
||||
if omarchy-battery-present; then
|
||||
cat <<EOF | sudo tee "/etc/udev/rules.d/99-wifi-powersave.rules"
|
||||
SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="0", RUN+="$HOME/.local/share/omarchy/bin/omarchy-wifi-powersave on"
|
||||
SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="1", RUN+="$HOME/.local/share/omarchy/bin/omarchy-wifi-powersave off"
|
||||
EOF
|
||||
|
||||
sudo udevadm control --reload
|
||||
sudo udevadm trigger --subsystem-match=power_supply
|
||||
fi
|
||||
Executable
+79
@@ -0,0 +1,79 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# omarchy-git-url-check decides which URLs omarchy-theme-install and
|
||||
# omarchy-plugin-add are willing to hand to `git clone`. git resolves a remote
|
||||
# helper -- a program it runs at clone time -- from exactly two URL shapes,
|
||||
# `<helper>::<address>` and `<scheme>://<address>`, so those are the two shapes
|
||||
# asserted here, alongside every legitimate form a user is likely to paste.
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
check() {
|
||||
"$ROOT/bin/omarchy-git-url-check" "$@" 2>&1
|
||||
}
|
||||
|
||||
# `<helper>::<address>`, the shape that runs a program. `ext::` is the dangerous
|
||||
# one: git runs the rest as a shell command once protocol.ext.allow permits it.
|
||||
for url in "ext::sh -c id" "fd::0,1" "gcrypt::x" "a+b::x" "a.b::x" "a-b::x" "1::x"; do
|
||||
output=$(check "$url") &&
|
||||
fail "omarchy-git-url-check refuses the transport helper '$url'" "$output"
|
||||
grep -qF "names a git option or transport helper" <<<"$output" ||
|
||||
fail "omarchy-git-url-check names the helper rejection for '$url'" "$output"
|
||||
done
|
||||
|
||||
pass "a <helper>::<address> URL is refused"
|
||||
|
||||
# `<scheme>://<address>`, the shape #8067 left open: git looks up
|
||||
# git-remote-<scheme> for any scheme it does not implement itself, so an
|
||||
# allowlist is the only form of this check that holds.
|
||||
for url in "ext://sh -c id" "fd://17" "gcrypt://example.com/x" "zzz://a" "ZZZ://a" "HTTPS://github.com/a/b"; do
|
||||
output=$(check "$url") &&
|
||||
fail "omarchy-git-url-check refuses the '$url' transport" "$output"
|
||||
grep -qF "which Omarchy does not clone from" <<<"$output" ||
|
||||
fail "omarchy-git-url-check names the transport rejection for '$url'" "$output"
|
||||
done
|
||||
|
||||
pass "a <scheme>://<address> URL outside git's own transports is refused"
|
||||
|
||||
# A leading dash is an option to git, not a URL.
|
||||
for url in "-x" "--upload-pack=touch /tmp/pwned" "-oProxyCommand=x"; do
|
||||
output=$(check "$url") &&
|
||||
fail "omarchy-git-url-check refuses the option '$url'" "$output"
|
||||
done
|
||||
|
||||
pass "a URL shaped like a git option is refused"
|
||||
|
||||
output=$(check "") && fail "omarchy-git-url-check refuses an empty URL" "$output"
|
||||
output=$(check) && fail "omarchy-git-url-check refuses a missing URL" "$output"
|
||||
|
||||
pass "an empty URL is refused"
|
||||
|
||||
# Everything a user actually pastes. The scp-style forms carry a single colon,
|
||||
# which git never reads as a helper, and the IPv6 host carries `::` inside
|
||||
# brackets rather than at the start.
|
||||
for url in \
|
||||
"https://github.com/acme/omarchy-weather.git" \
|
||||
"http://example.com/a/b.git" \
|
||||
"https://user:token@github.com/acme/repo.git" \
|
||||
"ssh://git@github.com/acme/repo.git" \
|
||||
"ssh://git@[2001:db8::1]:22/org/repo.git" \
|
||||
"git://example.com/repo.git" \
|
||||
"git+ssh://git@example.com/acme/repo.git" \
|
||||
"ssh+git://git@example.com/acme/repo.git" \
|
||||
"ftp://example.com/repo.git" \
|
||||
"ftps://example.com/repo.git" \
|
||||
"file:///home/me/repo" \
|
||||
"git@github.com:acme/repo.git" \
|
||||
"git@[2001:db8::1]:org/repo.git" \
|
||||
"host:-s/foo.git" \
|
||||
"/home/me/repo" \
|
||||
"./repo" \
|
||||
"../repo" \
|
||||
"repo"; do
|
||||
output=$(check "$url") ||
|
||||
fail "omarchy-git-url-check accepts the legitimate URL '$url'" "$output"
|
||||
done
|
||||
|
||||
pass "the URL forms a user pastes are accepted"
|
||||
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,374 @@
|
||||
#!/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"
|
||||
|
||||
# New releases moved the stamp writer out of main. Keep the earlier cases on
|
||||
# the old layout and exercise a fresh installation with the relocated helper.
|
||||
git -C "$test_tmp/seed" mv hermes_cli/main.py hermes_cli/main_desktop.py
|
||||
printf 'raise AssertionError("legacy module imported after desktop split")\n' >"$test_tmp/seed/hermes_cli/main.py"
|
||||
git -C "$test_tmp/seed" add hermes_cli/main.py
|
||||
git -C "$test_tmp/seed" -c user.name=Test -c user.email=test@example.invalid commit -qm split-desktop
|
||||
release_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"
|
||||
new_home split-desktop
|
||||
run_installer || fail "setup supports the relocated desktop helper" "$(cat "$test_tmp/output")"
|
||||
[[ $(cat "$hermes_home/desktop-build-stamp.json") == 'upstream build stamp' ]] || fail "relocated helper writes the build stamp"
|
||||
grep -qx launch "$test_tmp/events" || fail "setup launches after the relocated helper writes the stamp"
|
||||
pass "new releases use the relocated desktop stamp writer"
|
||||
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"
|
||||
@@ -0,0 +1,158 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh"
|
||||
|
||||
work_dir=$(mktemp -d)
|
||||
trap 'rm -rf "$work_dir"' EXIT
|
||||
|
||||
fake_home="$work_dir/home"
|
||||
mkdir -p "$fake_home/.config/omarchy/hooks" "$fake_home/.local/state/omarchy"
|
||||
|
||||
# --- omarchy-hook --------------------------------------------------------------
|
||||
|
||||
# A hook name is a label, not a path. One carrying a slash, or one that is a
|
||||
# bare `.` or `..`, would run a script from outside the hooks directory.
|
||||
|
||||
cat >"$fake_home/.config/omarchy/hooks/test-hook" <<'SH'
|
||||
touch "$HOME/hook-ran"
|
||||
SH
|
||||
|
||||
HOME="$fake_home" "$ROOT/bin/omarchy-hook" test-hook
|
||||
[[ -f $fake_home/hook-ran ]] ||
|
||||
fail "omarchy hook runs a named hook from the hooks directory"
|
||||
pass "omarchy hook runs a named hook from the hooks directory"
|
||||
|
||||
# Dots inside a name are not a path. a..b stays inside the hooks directory.
|
||||
cat >"$fake_home/.config/omarchy/hooks/a..b" <<'SH'
|
||||
touch "$HOME/dotted-hook-ran"
|
||||
SH
|
||||
|
||||
HOME="$fake_home" "$ROOT/bin/omarchy-hook" a..b
|
||||
[[ -f $fake_home/dotted-hook-ran ]] ||
|
||||
fail "omarchy hook accepts a hook name with dots in the middle"
|
||||
pass "omarchy hook accepts a hook name with dots in the middle"
|
||||
|
||||
for name in . ..; do
|
||||
status=0
|
||||
HOME="$fake_home" "$ROOT/bin/omarchy-hook" "$name" >/dev/null 2>&1 || status=$?
|
||||
(( status == 2 )) ||
|
||||
fail "omarchy hook refuses a hook name of $name" "exit: $status"
|
||||
pass "omarchy hook refuses a hook name of $name"
|
||||
done
|
||||
|
||||
# This file sits where a name of ../../evil would resolve: hooks/../.. is
|
||||
# ~/.config.
|
||||
cat >"$fake_home/.config/evil" <<'SH'
|
||||
touch "$HOME/escape-ran"
|
||||
SH
|
||||
chmod +x "$fake_home/.config/evil"
|
||||
|
||||
status=0
|
||||
HOME="$fake_home" "$ROOT/bin/omarchy-hook" "../../evil" >/dev/null 2>&1 || status=$?
|
||||
(( status == 2 )) ||
|
||||
fail "omarchy hook refuses a hook name with a dot-dot" "exit: $status"
|
||||
[[ ! -e $fake_home/escape-ran ]] ||
|
||||
fail "omarchy hook runs nothing when it refuses the name"
|
||||
pass "omarchy hook refuses a hook name with a dot-dot"
|
||||
|
||||
status=0
|
||||
HOME="$fake_home" "$ROOT/bin/omarchy-hook" "sub/dir" >/dev/null 2>&1 || status=$?
|
||||
(( status == 2 )) ||
|
||||
fail "omarchy hook refuses a hook name with a slash" "exit: $status"
|
||||
pass "omarchy hook refuses a hook name with a slash"
|
||||
|
||||
# --- omarchy-hook-install ------------------------------------------------------
|
||||
|
||||
# The installer joins the type into ~/.config/omarchy/hooks/<type>.d before
|
||||
# mkdir/cp. The runner already refuses a slashed type; install must too, or a
|
||||
# name the runner will not run still lands on disk.
|
||||
|
||||
source_hook="$work_dir/source-hook"
|
||||
cat >"$source_hook" <<'SH'
|
||||
#!/bin/bash
|
||||
true
|
||||
SH
|
||||
|
||||
HOME="$fake_home" "$ROOT/bin/omarchy-hook-install" post-update "$source_hook" >/dev/null
|
||||
[[ -f $fake_home/.config/omarchy/hooks/post-update.d/source-hook ]] ||
|
||||
fail "omarchy hook install still installs a named hook"
|
||||
pass "omarchy hook install still installs a named hook"
|
||||
|
||||
HOME="$fake_home" "$ROOT/bin/omarchy-hook-install" a..b "$source_hook" >/dev/null
|
||||
[[ -f $fake_home/.config/omarchy/hooks/a..b.d/source-hook ]] ||
|
||||
fail "omarchy hook install accepts a hook name with dots in the middle"
|
||||
pass "omarchy hook install accepts a hook name with dots in the middle"
|
||||
|
||||
for name in . ..; do
|
||||
status=0
|
||||
HOME="$fake_home" "$ROOT/bin/omarchy-hook-install" "$name" "$source_hook" >/dev/null 2>&1 || status=$?
|
||||
(( status == 2 )) ||
|
||||
fail "omarchy hook install refuses a hook name of $name" "exit: $status"
|
||||
[[ ! -e $fake_home/.config/omarchy/hooks/${name}.d ]] ||
|
||||
fail "omarchy hook install creates no directory for a hook name of $name"
|
||||
pass "omarchy hook install refuses a hook name of $name"
|
||||
done
|
||||
|
||||
# hooks/../../evil.d is ~/.config/evil.d. The guard must fire before mkdir.
|
||||
status=0
|
||||
HOME="$fake_home" "$ROOT/bin/omarchy-hook-install" "../../evil" "$source_hook" >/dev/null 2>&1 || status=$?
|
||||
(( status == 2 )) ||
|
||||
fail "omarchy hook install refuses a hook name with a dot-dot" "exit: $status"
|
||||
[[ ! -e $fake_home/.config/evil.d ]] ||
|
||||
fail "omarchy hook install creates nothing outside the hooks directory"
|
||||
pass "omarchy hook install refuses a hook name with a dot-dot"
|
||||
|
||||
status=0
|
||||
HOME="$fake_home" "$ROOT/bin/omarchy-hook-install" "sub/dir" "$source_hook" >/dev/null 2>&1 || status=$?
|
||||
(( status == 2 )) ||
|
||||
fail "omarchy hook install refuses a hook name with a slash" "exit: $status"
|
||||
[[ ! -e $fake_home/.config/omarchy/hooks/sub ]] ||
|
||||
fail "omarchy hook install creates no nested directory from a slashed name"
|
||||
pass "omarchy hook install refuses a hook name with a slash"
|
||||
|
||||
# --- omarchy-state -------------------------------------------------------------
|
||||
|
||||
state_dir="$fake_home/.local/state/omarchy"
|
||||
|
||||
HOME="$fake_home" "$ROOT/bin/omarchy-state" set reboot-required
|
||||
[[ -f $state_dir/reboot-required ]] ||
|
||||
fail "omarchy state set still creates a plain state file"
|
||||
pass "omarchy state set still creates a plain state file"
|
||||
|
||||
HOME="$fake_home" "$ROOT/bin/omarchy-state" set v1..2
|
||||
[[ -f $state_dir/v1..2 ]] ||
|
||||
fail "omarchy state set accepts a state name with dots in the middle"
|
||||
pass "omarchy state set accepts a state name with dots in the middle"
|
||||
|
||||
for name in . ..; do
|
||||
status=0
|
||||
HOME="$fake_home" "$ROOT/bin/omarchy-state" set "$name" >/dev/null 2>&1 || status=$?
|
||||
(( status == 2 )) ||
|
||||
fail "omarchy state set refuses a state name of $name" "exit: $status"
|
||||
pass "omarchy state set refuses a state name of $name"
|
||||
done
|
||||
|
||||
# state/../.. is ~/.local. The guard must fire before touch gets there.
|
||||
status=0
|
||||
HOME="$fake_home" "$ROOT/bin/omarchy-state" set "../../escape" >/dev/null 2>&1 || status=$?
|
||||
(( status == 2 )) ||
|
||||
fail "omarchy state set refuses a state name with a dot-dot" "exit: $status"
|
||||
[[ ! -e $fake_home/.local/escape ]] ||
|
||||
fail "omarchy state set creates nothing outside the state directory"
|
||||
pass "omarchy state set refuses a state name with a dot-dot"
|
||||
|
||||
status=0
|
||||
HOME="$fake_home" "$ROOT/bin/omarchy-state" set "sub/dir" >/dev/null 2>&1 || status=$?
|
||||
(( status == 2 )) ||
|
||||
fail "omarchy state set refuses a state name with a slash" "exit: $status"
|
||||
pass "omarchy state set refuses a state name with a slash"
|
||||
|
||||
# clear takes patterns by design ("state-name-or-pattern") and matches
|
||||
# basenames through find -name, so it can never walk out of the directory.
|
||||
touch "$state_dir/restart-a-required" "$state_dir/restart-b-required" "$state_dir/keep-me"
|
||||
HOME="$fake_home" "$ROOT/bin/omarchy-state" clear "restart-*-required"
|
||||
[[ ! -e $state_dir/restart-a-required && ! -e $state_dir/restart-b-required && -f $state_dir/keep-me ]] ||
|
||||
fail "omarchy state clear still clears matching patterns only"
|
||||
pass "omarchy state clear still clears matching patterns only"
|
||||
@@ -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"
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
require_command lua
|
||||
|
||||
run_paths() {
|
||||
lua - <<'LUA'
|
||||
package.path = os.getenv("OMARCHY_PATH") .. "/?.lua;" .. package.path
|
||||
local paths = require("default.hypr.paths")
|
||||
assert(paths.config_home == os.getenv("EXPECTED_CONFIG"), "config_home: " .. paths.config_home)
|
||||
assert(paths.state_home == os.getenv("EXPECTED_STATE"), "state_home: " .. paths.state_home)
|
||||
LUA
|
||||
}
|
||||
|
||||
HOME="/home/test-user" OMARCHY_PATH="$ROOT" \
|
||||
XDG_CONFIG_HOME= XDG_STATE_HOME= \
|
||||
EXPECTED_CONFIG="/home/test-user/.config" EXPECTED_STATE="/home/test-user/.local/state" \
|
||||
run_paths
|
||||
pass "empty XDG path variables fall back to their defaults"
|
||||
|
||||
HOME="/home/test-user" OMARCHY_PATH="$ROOT" \
|
||||
XDG_CONFIG_HOME="/custom/config" XDG_STATE_HOME="/custom/state" \
|
||||
EXPECTED_CONFIG="/custom/config" EXPECTED_STATE="/custom/state" \
|
||||
run_paths
|
||||
pass "set XDG path variables are honored"
|
||||
@@ -4,15 +4,16 @@ source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh"
|
||||
|
||||
require_command lua
|
||||
|
||||
# The console is sized by the gap underneath it, recomputed from the monitor,
|
||||
# The console is sized by the gaps around it, recomputed from the monitor,
|
||||
# because a window rule's size would freeze at whatever the screen measured when
|
||||
# the console first opened. The arithmetic is what keeps it half a screen on a
|
||||
# scaled display, so it is worth pinning down.
|
||||
# the console first opened. The arithmetic is what keeps it a half-height panel
|
||||
# on a scaled display, so it is worth pinning down.
|
||||
# base-test.sh does not set -e, so the assertions have to fail the file
|
||||
# themselves rather than leaving the pass below to run regardless.
|
||||
OMARCHY_PATH="$ROOT" lua - <<'LUA' || fail "the console covers half the work area at any monitor scale"
|
||||
OMARCHY_PATH="$ROOT" lua - <<'LUA' || fail "the console is a centered panel until a second app joins it"
|
||||
local rules, handlers = {}, {}
|
||||
local monitor = nil
|
||||
local workspace = nil
|
||||
|
||||
hl = {
|
||||
config = function() end,
|
||||
@@ -20,6 +21,16 @@ hl = {
|
||||
workspace_rule = function(rule) table.insert(rules, rule) end,
|
||||
on = function(event, callback) handlers[event] = callback end,
|
||||
get_active_monitor = function() return monitor end,
|
||||
get_workspace = function() return workspace end,
|
||||
-- workspace.windows is the tiled count here and workspace.floats the floating
|
||||
-- one, so the fixtures can say which kind of window is on the console.
|
||||
get_workspace_windows = function()
|
||||
local list = {}
|
||||
for _ = 1, workspace and workspace.windows or 0 do table.insert(list, { floating = false }) end
|
||||
for _ = 1, workspace and workspace.floats or 0 do table.insert(list, { floating = true }) end
|
||||
return list
|
||||
end,
|
||||
exec_scheduled_prop_refresh_immediately = function() end,
|
||||
}
|
||||
|
||||
dofile(os.getenv("OMARCHY_PATH") .. "/default/hypr/bootstrap.lua")
|
||||
@@ -29,6 +40,11 @@ local function current()
|
||||
return rules[#rules]
|
||||
end
|
||||
|
||||
local function gaps()
|
||||
local g = current().gaps_out
|
||||
return g.top, g.right, g.bottom, g.left
|
||||
end
|
||||
|
||||
-- Config loads before the outputs are up, so the first pass has no monitor to
|
||||
-- read. It still has to leave a rule behind, or the console would open unseeded.
|
||||
assert(#rules > 0, "console is ruled even before a monitor can be read")
|
||||
@@ -38,7 +54,7 @@ assert(current().on_created_empty:find("^%[workspace special:scratchpad silent%]
|
||||
assert(current().workspace == "special:scratchpad")
|
||||
|
||||
local function rescale(height, scale, bar)
|
||||
monitor = { height = height, scale = scale, reserved = { top = bar, bottom = 0, left = 0, right = 0 } }
|
||||
monitor = { width = 1920, height = height, scale = scale, transform = 0, reserved = { top = bar, bottom = 0, left = 0, right = 0 } }
|
||||
handlers["monitor.layout_changed"]()
|
||||
return current().gaps_out.bottom
|
||||
end
|
||||
@@ -52,11 +68,9 @@ assert(rescale(2160, 1.5, 40) == 700, "and at a fractional scale")
|
||||
-- console short.
|
||||
assert(rescale(1440, 1, 0) == 720, "a monitor with nothing reserved")
|
||||
|
||||
-- The console stays flush with the top and the sides, the way a Quake console
|
||||
-- drops in, and keeps its seed across every refit.
|
||||
local final = current()
|
||||
assert(final.gaps_out.top == 0 and final.gaps_out.left == 0 and final.gaps_out.right == 0,
|
||||
"the console is flush to the top and sides")
|
||||
assert(final.gaps_out.top == 0, "the console stays flush with the top, the way a drop-down arrives")
|
||||
assert(final.gaps_out.left == final.gaps_out.right, "the panel is centered")
|
||||
assert(final.on_created_empty:find("omarchy%-agent"), "refitting keeps the console seeded")
|
||||
assert(final.no_border == true, "the console drops the active window border")
|
||||
|
||||
@@ -69,22 +83,197 @@ assert(current().gaps_out.bottom == before, "an absent monitor leaves the consol
|
||||
-- A monitor handle outliving its output answers nil to everything, which is
|
||||
-- what a layout change looks like mid-flight. Reading height or reserved off
|
||||
-- that would throw, so the scale guard has to catch it first.
|
||||
monitor = setmetatable({}, { __index = function() return nil end })
|
||||
local expired = setmetatable({}, { __index = function() return nil end })
|
||||
monitor = expired
|
||||
handlers["monitor.layout_changed"]()
|
||||
assert(current().gaps_out.bottom == before, "an expired monitor handle is not read to pieces")
|
||||
|
||||
-- Refitting to the size it already is would still cost a state refresh, and
|
||||
-- monitor.focused fires on every hop between screens.
|
||||
monitor = { height = 1440, scale = 1, reserved = { top = 0, bottom = 0, left = 0, right = 0 } }
|
||||
monitor = { width = 2560, height = 1440, scale = 1, transform = 0, reserved = { top = 0, bottom = 0, left = 0, right = 0 } }
|
||||
handlers["monitor.layout_changed"]()
|
||||
local written = #rules
|
||||
handlers["monitor.focused"]()
|
||||
handlers["monitor.layout_changed"]()
|
||||
assert(#rules == written, "refitting to the same size does not rewrite the rule")
|
||||
|
||||
monitor.scale = 2
|
||||
-- A centered 2:1 panel, not a full-width drop-down.
|
||||
monitor = { width = 1920, height = 1080, scale = 1, transform = 0, reserved = { top = 0, bottom = 0, left = 0, right = 0 } }
|
||||
handlers["monitor.layout_changed"]()
|
||||
assert(#rules == written + 1, "a real change still rewrites it")
|
||||
assert(current().gaps_out.bottom == 360, "and lands on half the rescaled screen")
|
||||
local top, right, bottom, left = gaps()
|
||||
assert(top == 0 and left == 420 and right == 420 and bottom == 540, "16:9 leaves a 1080x540 panel")
|
||||
|
||||
local dell = { name = "DP-1", width = 6144, height = 2560, scale = 1, transform = 0, reserved = { top = 30, bottom = 0, left = 0, right = 0 } }
|
||||
monitor = dell
|
||||
handlers["monitor.layout_changed"]()
|
||||
top, right, bottom, left = gaps()
|
||||
assert(left == 1807 and right == 1807 and bottom == 1265, "the same 2:1 panel on 6K")
|
||||
|
||||
-- Same logical box at scale 2x (physical 12288x5120): scale does not change the
|
||||
-- panel's logical size.
|
||||
monitor = { name = "DP-1", width = 12288, height = 5120, scale = 2, transform = 0, reserved = { top = 30, bottom = 0, left = 0, right = 0 } }
|
||||
handlers["monitor.layout_changed"]()
|
||||
top, right, bottom, left = gaps()
|
||||
assert(left == 1807 and right == 1807 and bottom == 1265, "the 6K box is in logical pixels")
|
||||
|
||||
-- Same height, different width: the sides have to move even though the bottom
|
||||
-- gap is identical, so the cache cannot key on height alone.
|
||||
monitor = { width = 2560, height = 1440, scale = 1, transform = 0, reserved = { top = 0, bottom = 0, left = 0, right = 0 } }
|
||||
handlers["monitor.layout_changed"]()
|
||||
written = #rules
|
||||
monitor = { width = 3440, height = 1440, scale = 1, transform = 0, reserved = { top = 0, bottom = 0, left = 0, right = 0 } }
|
||||
handlers["monitor.layout_changed"]()
|
||||
assert(#rules == written + 1, "a same-height ultrawide hop still rewrites the sides")
|
||||
top, right, bottom, left = gaps()
|
||||
assert(left == 1000 and right == 1000 and bottom == 720, "3440x1440 leaves a 1440x720 box")
|
||||
|
||||
-- A panel wider than the screen is just the screen: a portrait monitor has no
|
||||
-- room for a 2:1 box and falls back to the full width rather than a negative gap.
|
||||
monitor = { width = 1080, height = 1920, scale = 1, transform = 0, reserved = { top = 0, bottom = 0, left = 0, right = 0 } }
|
||||
handlers["monitor.layout_changed"]()
|
||||
top, right, bottom, left = gaps()
|
||||
assert(left == 0 and right == 0 and bottom == 960, "a portrait monitor keeps the full width")
|
||||
assert(left >= 0 and right >= 0 and bottom >= 0, "gaps are never negative")
|
||||
|
||||
-- A monitor turned on its side still reports the panel's own pixels, so the
|
||||
-- work area has to be turned with it. Measured against Hyprland 0.56.2: a
|
||||
-- rotated 1920x1080 lays its windows out in 1080x1920 while width and height
|
||||
-- still read 1920 and 1080. Quarter turns are the odd transforms; a half turn
|
||||
-- leaves the shape alone.
|
||||
monitor = { width = 1920, height = 1080, scale = 1, transform = 1, reserved = { top = 30, bottom = 0, left = 0, right = 0 } }
|
||||
handlers["monitor.layout_changed"]()
|
||||
top, right, bottom, left = gaps()
|
||||
assert(left == 0 and right == 0 and bottom == 945, "a quarter-turned monitor is sized portrait")
|
||||
|
||||
monitor = { width = 1920, height = 1080, scale = 1, transform = 3, reserved = { top = 30, bottom = 0, left = 0, right = 0 } }
|
||||
handlers["monitor.layout_changed"]()
|
||||
top, right, bottom, left = gaps()
|
||||
assert(left == 0 and right == 0 and bottom == 945, "and so is the other quarter turn")
|
||||
|
||||
monitor = { width = 1920, height = 1080, scale = 1, transform = 2, reserved = { top = 30, bottom = 0, left = 0, right = 0 } }
|
||||
handlers["monitor.layout_changed"]()
|
||||
top, right, bottom, left = gaps()
|
||||
assert(left == 435 and right == 435 and bottom == 525, "a half turn is still landscape")
|
||||
|
||||
-- Special workspaces open on the monitor they are toggled on, not on whichever
|
||||
-- output was focused when the rule was last written. Opening on 1080p after a
|
||||
-- 6K fit has to resize the box.
|
||||
local acer = { name = "HDMI-A-1", width = 1920, height = 1080, scale = 1, transform = 0, reserved = { top = 30, bottom = 0, left = 0, right = 0 } }
|
||||
monitor = dell
|
||||
handlers["monitor.layout_changed"]()
|
||||
handlers["workspace.special_active"]({ name = "special:scratchpad" }, acer)
|
||||
top, right, bottom, left = gaps()
|
||||
assert(left == 435 and right == 435 and bottom == 525, "opening on 1080p after a 6K fit resizes the box")
|
||||
assert(1920 - left - right > 0 and 1080 - 30 - bottom > 0, "1080p leftover is never negative")
|
||||
|
||||
-- follow_mouse onto the 6K while the console is already showing on 1080p must
|
||||
-- not steal the rule; that is what oversized the Dell after a hop.
|
||||
workspace = { name = "special:scratchpad", visible = true, monitor = acer, windows = 1 }
|
||||
monitor = dell
|
||||
written = #rules
|
||||
handlers["monitor.focused"](dell)
|
||||
assert(#rules == written, "focus on another output does not rewrite an open console")
|
||||
|
||||
-- The output the console is showing on can go away mid-layout-change. Its
|
||||
-- handle then answers nil to everything, and preferring it blindly would leave
|
||||
-- the console stranded at the gaps of the monitor that is gone. The rule is
|
||||
-- still the 1080p one here, so only refitting on the Dell can satisfy this.
|
||||
workspace = { name = "special:scratchpad", visible = true, monitor = expired, windows = 1 }
|
||||
monitor = dell
|
||||
handlers["monitor.layout_changed"]()
|
||||
top, right, bottom, left = gaps()
|
||||
assert(left == 1807 and right == 1807 and bottom == 1265,
|
||||
"a console whose output vanished refits on the monitor that is still there")
|
||||
|
||||
-- Back onto the 1080p panel for the window-count checks below.
|
||||
workspace = { name = "special:scratchpad", visible = true, monitor = acer, windows = 1 }
|
||||
monitor = acer
|
||||
handlers["monitor.layout_changed"]()
|
||||
top, right, bottom, left = gaps()
|
||||
assert(left == 435 and right == 435 and bottom == 525, "and refits again once it is back on a live output")
|
||||
|
||||
-- One window reads as a console and keeps the panel. A second app has turned
|
||||
-- the scratchpad into a workspace, and a workspace wants the whole width.
|
||||
workspace = { name = "special:scratchpad", visible = true, monitor = acer, windows = 2 }
|
||||
handlers["window.open"]()
|
||||
top, right, bottom, left = gaps()
|
||||
assert(left == 0 and right == 0, "a second app on the scratchpad restores the full width")
|
||||
assert(bottom == 525, "and the console keeps its half-height drop")
|
||||
|
||||
workspace.windows = 3
|
||||
written = #rules
|
||||
handlers["window.open"]()
|
||||
assert(#rules == written, "a third app changes nothing that is already full width")
|
||||
|
||||
workspace.windows = 1
|
||||
handlers["window.destroy"]()
|
||||
top, right, bottom, left = gaps()
|
||||
assert(left == 435 and right == 435, "closing back down to one window recenters the panel")
|
||||
|
||||
-- An empty scratchpad is about to be seeded with a single agent, so it is sized
|
||||
-- as a console rather than as a workspace.
|
||||
workspace.windows = 0
|
||||
handlers["window.destroy"]()
|
||||
top, right, bottom, left = gaps()
|
||||
assert(left == 435 and right == 435, "an empty console is still a console")
|
||||
|
||||
-- A hidden console is refitted on its way back in, so the count does not have to
|
||||
-- be chased while it is off screen; every window on the desktop would otherwise
|
||||
-- rewrite the rule.
|
||||
workspace = { name = "special:scratchpad", visible = false, monitor = acer, windows = 4 }
|
||||
monitor = dell
|
||||
written = #rules
|
||||
handlers["window.open"]()
|
||||
assert(#rules == written, "a window opening elsewhere does not rewrite a hidden console")
|
||||
|
||||
-- A scratchpad nothing has opened yet has no workspace to read at all, and is
|
||||
-- sized as the console the seed is about to put a single agent into.
|
||||
workspace = nil
|
||||
monitor = { width = 1920, height = 1080, scale = 1, transform = 0, reserved = { top = 0, bottom = 0, left = 0, right = 0 } }
|
||||
handlers["monitor.layout_changed"]()
|
||||
top, right, bottom, left = gaps()
|
||||
assert(left == 420 and right == 420 and bottom == 540, "a scratchpad that does not exist yet is sized as a console")
|
||||
|
||||
-- Back to a console on screen for the move and float checks.
|
||||
workspace = { name = "special:scratchpad", visible = true, monitor = acer, windows = 1 }
|
||||
monitor = acer
|
||||
handlers["monitor.layout_changed"]()
|
||||
top, right, bottom, left = gaps()
|
||||
assert(left == 435 and right == 435, "a single tiled window is a console")
|
||||
|
||||
-- A floating window on top of the console is not laid out by the gaps, so it
|
||||
-- must not stretch the panel out from under the agent.
|
||||
workspace.floats = 1
|
||||
handlers["window.open"]()
|
||||
top, right, bottom, left = gaps()
|
||||
assert(left == 435 and right == 435, "a floating window on the console keeps the panel")
|
||||
|
||||
-- Tiling that float (Super+T) makes it a second app, and floating it again
|
||||
-- gives the panel back. Both arrive as window.update_rules.
|
||||
workspace.floats, workspace.windows = 0, 2
|
||||
handlers["window.update_rules"]()
|
||||
top, right, bottom, left = gaps()
|
||||
assert(left == 0 and right == 0, "tiling a float on the console restores the full width")
|
||||
|
||||
workspace.floats, workspace.windows = 1, 1
|
||||
handlers["window.update_rules"]()
|
||||
top, right, bottom, left = gaps()
|
||||
assert(left == 435 and right == 435, "floating it again recenters the panel")
|
||||
|
||||
-- Sending an app onto the scratchpad (Super+Alt+S) or off it (Super+Shift+1)
|
||||
-- does not open or destroy anything, so the move itself has to refit.
|
||||
workspace.floats, workspace.windows = 0, 2
|
||||
handlers["window.move_to_workspace"]()
|
||||
top, right, bottom, left = gaps()
|
||||
assert(left == 0 and right == 0, "moving a second app onto the console restores the full width")
|
||||
|
||||
workspace.windows = 1
|
||||
handlers["window.move_to_workspace"]()
|
||||
top, right, bottom, left = gaps()
|
||||
assert(left == 435 and right == 435, "moving it back off recenters the panel")
|
||||
|
||||
-- window.close still counts the window on its way out, and window.destroy
|
||||
-- follows it with the settled count, so only destroy is hooked.
|
||||
assert(handlers["window.close"] == nil, "window.close counts the window on its way out")
|
||||
LUA
|
||||
pass "the console covers half the work area at any monitor scale"
|
||||
pass "the console is a centered panel until a second app joins it"
|
||||
@@ -21,7 +21,7 @@ trap cleanup EXIT
|
||||
require_compositor "QML contract test"
|
||||
|
||||
if ! command -v quickshell >/dev/null 2>&1; then
|
||||
pass "quickshell not installed; skipping QML contract test"
|
||||
skip "quickshell not installed; skipping QML contract test"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(dirname "$0")/base-test.sh"
|
||||
|
||||
migration="$ROOT/migrations/1787865477.sh"
|
||||
test_dir=$(mktemp -d)
|
||||
trap 'rm -rf "$test_dir"' EXIT
|
||||
|
||||
stub_bin="$test_dir/bin"
|
||||
mkdir -p "$stub_bin"
|
||||
|
||||
cat >"$stub_bin/id" <<'STUB'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "${STUB_GROUPS:-wheel}"
|
||||
STUB
|
||||
cat >"$stub_bin/pacman" <<'STUB'
|
||||
#!/bin/bash
|
||||
[[ $1 == "-Qq" ]] || exit 2
|
||||
[[ " ${STUB_PACKAGES:-} " == *" $2 "* ]]
|
||||
STUB
|
||||
cat >"$stub_bin/sudo" <<'STUB'
|
||||
#!/bin/bash
|
||||
exec "$@"
|
||||
STUB
|
||||
cat >"$stub_bin/gpasswd" <<'STUB'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$*" >>"${GPASSWD_CALLS:?}"
|
||||
STUB
|
||||
cat >"$stub_bin/omarchy-state" <<'STUB'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$*" >>"${STATE_CALLS:?}"
|
||||
STUB
|
||||
chmod +x "$stub_bin"/*
|
||||
|
||||
gpasswd_calls="$test_dir/gpasswd-calls"
|
||||
state_calls="$test_dir/state-calls"
|
||||
|
||||
run_migration() {
|
||||
rm -f "$gpasswd_calls" "$state_calls"
|
||||
USER=tester STUB_GROUPS="$1" STUB_PACKAGES="${2:-}" \
|
||||
GPASSWD_CALLS="$gpasswd_calls" STATE_CALLS="$state_calls" \
|
||||
PATH="$stub_bin:$PATH" bash -euo pipefail "$migration"
|
||||
}
|
||||
|
||||
run_migration "wheel input" >/dev/null
|
||||
grep -qxF -- "-d tester input" "$gpasswd_calls" || fail "migration removes default input membership"
|
||||
grep -qxF "set reboot-required" "$state_calls" || fail "migration flags the session change for reboot"
|
||||
pass "migration removes the blanket input grant"
|
||||
|
||||
run_migration "wheel" >/dev/null
|
||||
[[ ! -e $gpasswd_calls ]] || fail "migration does not remove an already-absent group"
|
||||
[[ ! -e $state_calls ]] || fail "migration does not flag a reboot when nothing changed"
|
||||
pass "migration is idempotent after input membership is gone"
|
||||
|
||||
run_migration "wheel input" xpadneo-dkms >/dev/null
|
||||
[[ ! -e $gpasswd_calls ]] || fail "migration preserves input for controller support"
|
||||
[[ ! -e $state_calls ]] || fail "preserved controller support does not flag a reboot"
|
||||
|
||||
run_migration "wheel input" ydotool >/dev/null
|
||||
[[ ! -e $gpasswd_calls ]] || fail "migration preserves input for ydotool"
|
||||
[[ ! -e $state_calls ]] || fail "preserved ydotool support does not flag a reboot"
|
||||
pass "migration preserves deliberate input-group opt-ins"
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
#!/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"
|
||||
export INSTALLED_PACKAGES="$tmp_dir/installed" CALL_LOG="$tmp_dir/calls"
|
||||
export PATH="$tmp_dir/bin:$ROOT/bin:$PATH"
|
||||
|
||||
# Keep the real package helpers, but contain every pacman transaction here.
|
||||
cat > "$tmp_dir/bin/pacman" <<'SH'
|
||||
#!/bin/bash
|
||||
case "$1" in
|
||||
-Q) grep -Fxq -- "$2" "$INSTALLED_PACKAGES" ;;
|
||||
-S)
|
||||
[[ ${FAIL_INSTALL:-0} == 0 ]] || exit 1
|
||||
shift 3 # -S --noconfirm --needed
|
||||
printf '%s\n' "$@" >> "$INSTALLED_PACKAGES"
|
||||
printf '%s\n' "$@" >> "$CALL_LOG"
|
||||
;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
SH
|
||||
cat > "$tmp_dir/bin/sudo" <<'SH'
|
||||
#!/bin/bash
|
||||
[[ $1 == "pacman" ]] || exit 1
|
||||
"$@"
|
||||
SH
|
||||
chmod +x "$tmp_dir/bin/"*
|
||||
|
||||
migration="$ROOT/migrations/1789444024.sh"
|
||||
for kernels in linux-omarchy linux-t2 'linux-omarchy linux-t2'; do
|
||||
read -ra installed <<< "$kernels"
|
||||
printf '%s\n' linux linux-headers "${installed[@]}" > "$INSTALLED_PACKAGES"
|
||||
: > "$CALL_LOG"
|
||||
bash -euo pipefail "$migration" >/dev/null
|
||||
for kernel in "${installed[@]}"; do
|
||||
grep -Fxq "$kernel-headers" "$INSTALLED_PACKAGES" || fail "$kernel gets its headers"
|
||||
done
|
||||
: > "$CALL_LOG"
|
||||
bash -euo pipefail "$migration" >/dev/null
|
||||
[[ ! -s $CALL_LOG ]] || fail "header repair is idempotent"
|
||||
pass "missing headers are repaired once for $kernels"
|
||||
done
|
||||
|
||||
printf '%s\n' linux linux-aarch64 > "$INSTALLED_PACKAGES"
|
||||
: > "$CALL_LOG"
|
||||
bash -euo pipefail "$migration" >/dev/null
|
||||
[[ ! -s $CALL_LOG ]] || fail "header repair skips unrelated kernels"
|
||||
pass "header repair skips unrelated kernels"
|
||||
|
||||
echo linux-omarchy > "$INSTALLED_PACKAGES"
|
||||
if FAIL_INSTALL=1 bash -euo pipefail "$migration" >/dev/null; then
|
||||
fail "a failed header installation must leave the migration pending"
|
||||
fi
|
||||
pass "header installation failure is propagated"
|
||||
Executable
+222
@@ -0,0 +1,222 @@
|
||||
#!/bin/bash
|
||||
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh"
|
||||
|
||||
require_command lua
|
||||
require_command xkbcli
|
||||
|
||||
tmpdir=$(mktemp -d) && [[ -n $tmpdir && -d $tmpdir ]] ||
|
||||
fail "the test gets a temporary directory to stub Hyprland in"
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
|
||||
home="$tmpdir/home"
|
||||
stub_bin="$tmpdir/bin"
|
||||
mkdir -p "$home/.config" "$stub_bin"
|
||||
cp -r "$ROOT/config/hypr" "$home/.config/hypr"
|
||||
|
||||
# The menu reads binds from Hyprland, which is not running here, so stand in for
|
||||
# it. A Lua bind reports dispatcher __lua and no arg, and the menu recovers both
|
||||
# from the Lua source; an exec bind carries its own command. Both shapes matter:
|
||||
# what two chords dispatch is what decides whether they share a row.
|
||||
lua_bind() {
|
||||
printf 'bind\n\tmodmask: %s\n\tsubmap: \n\tkey: %s\n\tkeycode: 0\n\tcatchall: false\n\tdescription: %s\n\tdispatcher: __lua\n\targ: \n' "$1" "$2" "$3"
|
||||
}
|
||||
|
||||
exec_bind() {
|
||||
printf 'bind\n\tmodmask: %s\n\tsubmap: \n\tkey: %s\n\tkeycode: 0\n\tcatchall: false\n\tdescription: %s\n\tdispatcher: exec\n\targ: %s\n' "$1" "$2" "$3" "$4"
|
||||
}
|
||||
|
||||
stub_hyprctl() {
|
||||
{
|
||||
echo '#!/bin/bash'
|
||||
echo 'case "$1" in'
|
||||
echo ' binds) cat <<'"'"'BINDS'"'"''
|
||||
cat
|
||||
echo 'BINDS'
|
||||
echo ' ;;'
|
||||
echo ' devices) echo "active keymap: English (US)" ;;'
|
||||
echo 'esac'
|
||||
} >"$stub_bin/hyprctl"
|
||||
chmod +x "$stub_bin/hyprctl"
|
||||
}
|
||||
|
||||
keybindings() {
|
||||
env -i PATH="$stub_bin:$ROOT/bin:$PATH" HOME="$home" \
|
||||
XDG_CACHE_HOME="$tmpdir/cache" OMARCHY_PATH="$ROOT" \
|
||||
bash "$ROOT/bin/omarchy-menu-keybindings" --print
|
||||
}
|
||||
|
||||
# Closing a window and toggling the scratchpad are two of the actions Omarchy
|
||||
# binds twice on purpose. The last bind carries the longest description Omarchy
|
||||
# ships, which is what puts a row closest to the width the menu allows.
|
||||
stub_hyprctl <<BINDS
|
||||
$(lua_bind 64 "SUPER + W" "Close window")
|
||||
$(lua_bind 64 "SUPER + Q" "Close window")
|
||||
$(lua_bind 64 "SUPER + F" "Full screen")
|
||||
$(lua_bind 64 "SUPER + S" "Toggle scratchpad")
|
||||
$(lua_bind 64 "SUPER + grave" "Toggle scratchpad")
|
||||
$(exec_bind 73 "SUPER SHIFT ALT + 0" "Move window silently to workspace 10" "true")
|
||||
BINDS
|
||||
|
||||
rendered=$(keybindings)
|
||||
[[ -n $rendered ]] || fail "the keybindings menu renders with a stubbed Hyprland"
|
||||
|
||||
grep -q 'SUPER + F *→ Full screen' <<<"$rendered" ||
|
||||
fail "a chord with no alternative renders on its own" "$rendered"
|
||||
pass "the keybindings menu renders its entries"
|
||||
|
||||
(( $(grep -c '→ Close window$' <<<"$rendered") == 1 )) ||
|
||||
fail "an alternative chord joins the row of the first one" "$rendered"
|
||||
grep -q 'SUPER + W / SUPER + Q *→ Close window' <<<"$rendered" ||
|
||||
fail "a shared row names both chords" "$rendered"
|
||||
pass "an alternative chord joins the row of the first one"
|
||||
|
||||
# Which chord leads is the whole point of keeping Hyprland's order: SUPER + W is
|
||||
# the documented default and SUPER + Q the alternative bound after it.
|
||||
grep -q '^SUPER + W / SUPER + Q' <<<"$rendered" ||
|
||||
fail "the chord declared first leads a shared row" "$rendered"
|
||||
pass "the chord declared first leads a shared row"
|
||||
|
||||
# Hyprland calls the key left of 1 "grave". Nobody reads their keyboard that way.
|
||||
grep -q 'SUPER + S / SUPER + ~ *→ Toggle scratchpad' <<<"$rendered" ||
|
||||
fail "the grave key reads as the symbol printed on it" "$rendered"
|
||||
! grep -q 'grave' <<<"$rendered" ||
|
||||
fail "no entry still says grave" "$rendered"
|
||||
pass "the grave key reads as the symbol printed on it"
|
||||
|
||||
# Monospace menu: every arrow sits in one column, and nothing is allowed past
|
||||
# it. A row that overruns pushes its own arrow out of line.
|
||||
[[ $(awk -F '→' '{ print length($1) }' <<<"$rendered" | sort -u) == "36" ]] ||
|
||||
fail "every entry pads its chords to the same column" "$rendered"
|
||||
pass "every entry pads its chords to the same column"
|
||||
|
||||
# The menu elides a row that outgrows its card: 754px of label, 78 monospace
|
||||
# characters at the heading size. The longest entry Omarchy ships sits at 74, so
|
||||
# a row has four characters of room and no more.
|
||||
(( $(awk '{ print length($0) }' <<<"$rendered" | sort -rn | head -1) <= 78 )) ||
|
||||
fail "no entry outgrows the width the menu gives it" "$rendered"
|
||||
pass "no entry outgrows the width the menu gives it"
|
||||
|
||||
# Priority ordering reads the row, and the chord sharing it must not reclassify
|
||||
# the entry: XF86Calculator alone belongs in the tail the menu keeps for media
|
||||
# keys, while the calculator itself sits in the body of the list.
|
||||
stub_hyprctl <<BINDS
|
||||
$(exec_bind 68 "SUPER CTRL + Q" "Calculator" "omacalc")
|
||||
$(exec_bind 0 "XF86Calculator" "Calculator" "omacalc")
|
||||
$(exec_bind 8 "ALT + TAB" "Reveal active window on top" "true")
|
||||
BINDS
|
||||
|
||||
rendered=$(keybindings)
|
||||
(( $(grep -n '→ Calculator$' <<<"$rendered" | cut -d: -f1) <
|
||||
$(grep -n '→ Reveal active window on top$' <<<"$rendered" | cut -d: -f1) )) ||
|
||||
fail "a shared chord does not change where its entry ranks" "$rendered"
|
||||
pass "a shared chord does not change where its entry ranks"
|
||||
|
||||
# The same key written as a keycode arrives by the other road: Hyprland reports
|
||||
# the code and the keymap resolves it, after the rename above has run.
|
||||
stub_hyprctl <<'BINDS'
|
||||
bind
|
||||
modmask: 64
|
||||
submap:
|
||||
key:
|
||||
keycode: 49
|
||||
catchall: false
|
||||
description: Toggle scratchpad
|
||||
dispatcher: exec
|
||||
arg: true
|
||||
BINDS
|
||||
|
||||
rendered=$(keybindings)
|
||||
grep -q 'SUPER + ~ *→ Toggle scratchpad' <<<"$rendered" ||
|
||||
fail "a keycode resolves to the symbol printed on the key too" "$rendered"
|
||||
pass "a keycode resolves to the symbol printed on the key too"
|
||||
|
||||
# A chord refused for width opens a row of its own, and the next chord tries
|
||||
# that row rather than reaching back past it and printing out of order.
|
||||
stub_hyprctl <<BINDS
|
||||
$(exec_bind 64 "SUPER + A" "Calculator" "omacalc")
|
||||
$(exec_bind 77 "SUPER SHIFT CTRL ALT + BACKSPACE" "Calculator" "omacalc")
|
||||
$(exec_bind 64 "SUPER + B" "Calculator" "omacalc")
|
||||
BINDS
|
||||
|
||||
rendered=$(keybindings)
|
||||
(( $(grep -c '→ Calculator$' <<<"$rendered") == 3 )) ||
|
||||
fail "a refused chord does not let the next one jump the queue" "$rendered"
|
||||
pass "a refused chord does not let the next one jump the queue"
|
||||
|
||||
# Sharing a row is something Omarchy names an action for, not something two
|
||||
# chords earn by looking alike. Alt + Tab and Shift + Alt + Tab both read
|
||||
# "Reveal active window on top" and cycle opposite ways.
|
||||
stub_hyprctl <<BINDS
|
||||
$(exec_bind 64 "SUPER + Y" "Zoom in" "omarchy-zoom in")
|
||||
$(exec_bind 64 "SUPER + Z" "Zoom in" "omarchy-zoom in")
|
||||
BINDS
|
||||
|
||||
rendered=$(keybindings)
|
||||
(( $(grep -c '→ Zoom in$' <<<"$rendered") == 2 )) ||
|
||||
fail "an action Omarchy did not name keeps its chords on separate rows" "$rendered"
|
||||
pass "an action Omarchy did not name keeps its chords on separate rows"
|
||||
|
||||
# Even a named action gives up the shared row rather than overrun the column:
|
||||
# two rows in line beat one that juts out of it.
|
||||
stub_hyprctl <<BINDS
|
||||
$(exec_bind 73 "SUPER SHIFT ALT + BACKSPACE" "Calculator" "omacalc")
|
||||
$(exec_bind 69 "SUPER SHIFT CTRL + BACKSPACE" "Calculator" "omacalc")
|
||||
BINDS
|
||||
|
||||
rendered=$(keybindings)
|
||||
(( $(grep -c '→ Calculator$' <<<"$rendered") == 2 )) ||
|
||||
fail "chords too wide to share a row stay on their own" "$rendered"
|
||||
[[ $(awk -F '→' '{ print length($1) }' <<<"$rendered" | sort -u) == "36" ]] ||
|
||||
fail "chords too wide to share a row leave the column alone" "$rendered"
|
||||
pass "chords too wide to share a row stay on their own"
|
||||
|
||||
# A shared label is not a shared action. Two chords that merely read alike have
|
||||
# to stay apart, or the menu hides one of them behind the other.
|
||||
stub_hyprctl <<BINDS
|
||||
$(lua_bind 64 "SUPER + W" "Close window")
|
||||
$(exec_bind 64 "SUPER + X" "Close window" "omarchy-hyprland-window-close-all")
|
||||
BINDS
|
||||
|
||||
rendered=$(keybindings)
|
||||
(( $(grep -c '→ Close window$' <<<"$rendered") == 2 )) ||
|
||||
fail "chords with the same label but different actions stay apart" "$rendered"
|
||||
pass "chords with the same label but different actions stay apart"
|
||||
|
||||
# An unresolved Lua bind reports no dispatcher at all, so nothing says the two
|
||||
# chords run the same thing, whatever their label promises.
|
||||
stub_hyprctl <<BINDS
|
||||
$(lua_bind 64 "SUPER + Y" "Close window")
|
||||
$(lua_bind 64 "SUPER + Z" "Close window")
|
||||
BINDS
|
||||
|
||||
rendered=$(keybindings)
|
||||
(( $(grep -c '→ Close window$' <<<"$rendered") == 2 )) ||
|
||||
fail "chords whose dispatch is unknown stay apart" "$rendered"
|
||||
pass "chords whose dispatch is unknown stay apart"
|
||||
|
||||
# What the menu is expected to pair up, written out here rather than read from
|
||||
# the script, so dropping an action from the list fails instead of shrinking
|
||||
# what gets checked.
|
||||
expected_alternatives=(
|
||||
"Close window"
|
||||
"Calculator"
|
||||
"Toggle scratchpad"
|
||||
"Move window to scratchpad"
|
||||
)
|
||||
|
||||
eval "$(sed -n '/^alternative_chord_actions()/,/^}/p' "$ROOT/bin/omarchy-menu-keybindings")"
|
||||
|
||||
[[ $(alternative_chord_actions) == "$(printf '%s\n' "${expected_alternatives[@]}")" ]] ||
|
||||
fail "the menu pairs up the actions Omarchy means it to" "$(alternative_chord_actions)"
|
||||
pass "the menu pairs up the actions Omarchy means it to"
|
||||
|
||||
# A renamed description would leave an action named here matching nothing, and
|
||||
# the row it was meant to share would quietly split in two. Only real binds
|
||||
# count: a commented-out example is not a second chord.
|
||||
for action in "${expected_alternatives[@]}"; do
|
||||
(( $(grep -rhE '^[[:space:]]*o\.bind\(' "$ROOT/default/hypr/bindings" |
|
||||
grep -cF ", \"$action\",") >= 2 )) ||
|
||||
fail "every action named as having an alternative is bound twice" "$action"
|
||||
done
|
||||
pass "every action named as having an alternative is bound twice"
|
||||
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
|
||||
skip "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
+267
@@ -0,0 +1,267 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
# Start with color enabled; the no-color scenarios opt in below.
|
||||
unset NO_COLOR
|
||||
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
# The launcher ends by taking over the process, so source it short of that line
|
||||
# and its half of the question can be asked here, without a terminal to draw on.
|
||||
about="$ROOT/bin/omarchy-launch-about"
|
||||
grep -q '^presize_window$' "$about" || fail "About launcher can be sourced short of its launch"
|
||||
sed '/^presize_window$/,$d' "$about" >"$tmp_dir/about.bash"
|
||||
|
||||
export HOME="$tmp_dir/home"
|
||||
export PATH="$ROOT/bin:$PATH"
|
||||
mkdir -p "$HOME/.config/omarchy/branding"
|
||||
printf '%s\n' '████████' '████████' >"$HOME/.config/omarchy/branding/about.txt"
|
||||
|
||||
source "$tmp_dir/about.bash"
|
||||
[[ $(type -t sheen_build) == "function" ]] || fail "the launcher finds the sheen it sources"
|
||||
pass "the launcher finds the sheen it sources"
|
||||
|
||||
# Kept before the stubs replace it, so the real one can be exercised below.
|
||||
real_measure_layout=$(declare -f measure_layout)
|
||||
|
||||
# Stand in for the terminal, and for the fastfetch run that measures the layout.
|
||||
rows_by_cols="45 140"
|
||||
layout_rows=20
|
||||
logo_row=3
|
||||
logo_column=3
|
||||
logo_color=$'\e[1m\e[32m'
|
||||
stty() { printf '%s\n' "$rows_by_cols"; }
|
||||
measure_layout() {
|
||||
LAYOUT_ROWS=$layout_rows
|
||||
LOGO_COLOR=$logo_color
|
||||
LOGO_ROW=$logo_row
|
||||
LOGO_COLUMN=$logo_column
|
||||
}
|
||||
|
||||
# fastfetch resolves the home directory from the passwd database rather than
|
||||
# $HOME, so it would answer for the real one. Stand in for it with the search
|
||||
# order it prints, pointed at this test's directories.
|
||||
config_paths=(
|
||||
"$HOME/.config/fastfetch/"
|
||||
"$HOME/fastfetch/"
|
||||
"$OMARCHY_FASTFETCH_DIR/ (*)"
|
||||
"$HOME/searched-later/fastfetch/"
|
||||
)
|
||||
fastfetch() {
|
||||
[[ ${1:-} == "--list-config-paths" ]] || return 1
|
||||
printf '%s\n' "${config_paths[@]}"
|
||||
}
|
||||
|
||||
# Record what the launcher hands the sheen rather than building any frames.
|
||||
handed=()
|
||||
sheen_build() { handed=("$@"); }
|
||||
|
||||
refuses() {
|
||||
if build_sheen; then
|
||||
fail "$1"
|
||||
else
|
||||
pass "$1"
|
||||
fi
|
||||
}
|
||||
|
||||
# The sheen repaints the cells fastfetch drew the logo on, so the launcher's idea
|
||||
# of the padding has to be the config's. Read them from the config rather than
|
||||
# from the launcher, or a drift between the two would agree with itself.
|
||||
config_top=$(jq -r '.logo.padding.top' "$ROOT/etc/fastfetch/config.jsonc")
|
||||
config_left=$(jq -r '.logo.padding.left' "$ROOT/etc/fastfetch/config.jsonc")
|
||||
config_right=$(jq -r '.logo.padding.right' "$ROOT/etc/fastfetch/config.jsonc")
|
||||
|
||||
[[ $LOGO_PAD_TOP == "$config_top" && $LOGO_PAD_LEFT == "$config_left" && $LOGO_PAD_RIGHT == "$config_right" ]] ||
|
||||
fail "the launcher's padding is the fastfetch config's" "config: $config_top/$config_left/$config_right, launcher: $LOGO_PAD_TOP/$LOGO_PAD_LEFT/$LOGO_PAD_RIGHT"
|
||||
pass "the launcher's padding is the fastfetch config's"
|
||||
|
||||
build_sheen || fail "a roomy window animates"
|
||||
pass "a roomy window animates"
|
||||
|
||||
# The sheen is told where the logo is, what colour to hand the cells back in, and
|
||||
# how much room it has left of the module column.
|
||||
[[ ${handed[0]} == "$HOME/.config/omarchy/branding/about.txt" ]] || fail "the sheen is given the logo About draws" "${handed[0]}"
|
||||
pass "the sheen is given the logo About draws"
|
||||
[[ ${handed[1]} == "$logo_row" && ${handed[2]} == "$logo_column" ]] || fail "the sheen is given the cell the logo starts on" "${handed[1]}/${handed[2]}"
|
||||
pass "the sheen is given the cell the logo starts on"
|
||||
[[ ${handed[3]} == $'\e[0m'"$logo_color" ]] || fail "the sheen is given fastfetch's own colour to restore" "$(printf '%q' "${handed[3]}")"
|
||||
pass "the sheen is given fastfetch's own colour to restore"
|
||||
[[ ${handed[4]} == "$((140 - logo_column + 1))" ]] || fail "the sheen is given the columns left of the module column" "${handed[4]}"
|
||||
pass "the sheen is given the columns left of the module column"
|
||||
|
||||
# fastfetch reads the first config it finds across several directories, and any
|
||||
# of them ahead of Omarchy's own can put the logo somewhere else entirely.
|
||||
for directory in .config/fastfetch fastfetch; do
|
||||
mkdir -p "$HOME/$directory"
|
||||
touch "$HOME/$directory/config.jsonc"
|
||||
refuses "a fastfetch config in ~/$directory leaves the logo still"
|
||||
rm -r "${HOME:?}/$directory"
|
||||
done
|
||||
|
||||
# One fastfetch would never read, because Omarchy's own comes first, is not a
|
||||
# reason to stop: the logo on screen is still the one About drew.
|
||||
mkdir -p "$HOME/searched-later/fastfetch"
|
||||
touch "$HOME/searched-later/fastfetch/config.jsonc"
|
||||
build_sheen || fail "a config fastfetch searches after Omarchy's own still animates"
|
||||
pass "a config fastfetch searches after Omarchy's own still animates"
|
||||
rm -r "${HOME:?}/searched-later"
|
||||
|
||||
# A window with no room for the cursor past the layout's last line has scrolled,
|
||||
# and the logo is no longer on the rows the frames address.
|
||||
rows_by_cols="$((layout_rows + 1)) 140"
|
||||
build_sheen || fail "a window with one row past the layout animates"
|
||||
pass "a window with one row past the layout animates"
|
||||
|
||||
rows_by_cols="$layout_rows 140"
|
||||
refuses "a window level with the layout's last line leaves it still"
|
||||
rows_by_cols="45 140"
|
||||
|
||||
|
||||
# The loop plays whatever frames are left lying about, so a build that failed has
|
||||
# to leave none of the last one's.
|
||||
SHEEN_FRAMES=(stale frames)
|
||||
NO_COLOR=1
|
||||
build_sheen || true
|
||||
unset NO_COLOR
|
||||
(( ${#SHEEN_FRAMES[@]} == 0 )) || fail "a build that failed leaves no frames to replay" "${#SHEEN_FRAMES[@]} left"
|
||||
pass "a build that failed leaves no frames to replay"
|
||||
|
||||
# fastfetch drops the logo's colour for a terminal that asked for none, but not
|
||||
# for the measurement, so the colour to restore would be measured wrong — and a
|
||||
# glint is colour besides.
|
||||
NO_COLOR=1
|
||||
refuses "a session that asked for no colour leaves the logo still"
|
||||
unset NO_COLOR
|
||||
|
||||
# A home directory may contain a space, and the marker fastfetch puts beside the
|
||||
# config it settled on is not part of the path.
|
||||
spacey="$tmp_dir/example user/.config/fastfetch"
|
||||
mkdir -p "$spacey"
|
||||
touch "$spacey/config.jsonc"
|
||||
config_paths=("$tmp_dir/example user/.config/fastfetch/" "$OMARCHY_FASTFETCH_DIR/ (*)")
|
||||
custom_fastfetch_config || fail "a fastfetch config in a path with a space is found"
|
||||
pass "a fastfetch config in a path with a space is found"
|
||||
rm -r "$tmp_dir/example user"
|
||||
config_paths=("$HOME/.config/fastfetch/" "$HOME/fastfetch/" "$OMARCHY_FASTFETCH_DIR/ (*)")
|
||||
|
||||
# An enumeration that said nothing is not the same answer as "none of them".
|
||||
mkdir -p "$HOME/.config/fastfetch"
|
||||
touch "$HOME/.config/fastfetch/config.jsonc"
|
||||
listing=$(declare -f fastfetch)
|
||||
fastfetch() { return 7; }
|
||||
custom_fastfetch_config || fail "an enumeration that failed does not read as no config"
|
||||
pass "an enumeration that failed does not read as no config"
|
||||
eval "$listing"
|
||||
rm -r "${HOME:?}/.config/fastfetch"
|
||||
|
||||
# The grid costs a process and is only read on the poll interval, so a resize can
|
||||
# land while it is being read. The sweep has to see that before it paints again.
|
||||
SHEEN_FRAMES=("first" "second" "third")
|
||||
tick() { :; }
|
||||
resized=false
|
||||
content_changed() { resized=true; return 1; }
|
||||
painted=$(play_sheen || true)
|
||||
[[ -z $painted ]] || fail "a resize landing during the check stops the sweep before it paints" "$(printf '%q' "$painted")"
|
||||
pass "a resize landing during the check stops the sweep before it paints"
|
||||
|
||||
resized=false
|
||||
content_changed() { return 1; }
|
||||
painted=$(play_sheen || true)
|
||||
[[ $painted == "firstsecondthird" ]] || fail "an undisturbed sweep writes every frame" "$(printf '%q' "$painted")"
|
||||
pass "an undisturbed sweep writes every frame"
|
||||
|
||||
# Every builder above can be exercised while nothing on screen ever animates, so
|
||||
# check that the render loop is what calls them.
|
||||
render_block=$(sed -n '/--render/,$p' "$about")
|
||||
for called in build_sheen play_sheen rest_sheen; do
|
||||
[[ $render_block == *"$called"* ]] || fail "the render loop plays the sheen" "it never calls $called"
|
||||
done
|
||||
pass "the render loop plays the sheen"
|
||||
|
||||
measure_layout() { return 1; }
|
||||
refuses "a layout fastfetch cannot be measured from leaves it still"
|
||||
|
||||
# Once the logo is taller than the module column, fastfetch writes a row more
|
||||
# than logo-plus-padding, so a window sized by that arithmetic scrolls its top
|
||||
# padding away — and a scrolled layout is one the sheen then refuses. The fit
|
||||
# asks fastfetch how tall the layout came out instead.
|
||||
rm -rf "${HOME:?}/.local"
|
||||
printf '%s\n' $(for i in $(seq 40); do echo '██████████'; done) >"$HOME/.config/omarchy/branding/about.txt"
|
||||
layout_rows=43
|
||||
measure_layout() {
|
||||
LAYOUT_ROWS=$layout_rows
|
||||
LOGO_COLOR=$logo_color
|
||||
}
|
||||
fastfetch() {
|
||||
case ${1:-} in
|
||||
--list-config-paths) printf '%s\n' "${config_paths[@]}" ;;
|
||||
--logo) for i in $(seq 29); do printf '%065d\n' 0; done ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
hyprctl() {
|
||||
[[ $1 == "clients" ]] && printf '[{"class":"org.omarchy.about","address":"0x1","size":[800,600]}]\n'
|
||||
return 0
|
||||
}
|
||||
|
||||
# logo 10 wide + the config's padding + a 65-column module block, plus the spare
|
||||
# the fit keeps in hand for content that grows after it was measured.
|
||||
fit_cols=$(( config_left + 10 + config_right + 65 + config_left + FIT_SPARE_COLUMNS ))
|
||||
fit_rows=$(( layout_rows + 1 + FIT_SPARE_ROWS ))
|
||||
|
||||
rows_by_cols="$fit_rows $fit_cols"
|
||||
fit_window || fail "the fit is satisfied by a window with the spare in it"
|
||||
pass "the fit is satisfied by a window with the spare in it"
|
||||
|
||||
# Exactly the content and not a cell more is what used to be asked for, and it is
|
||||
# the size that clips or scrolls as soon as an uptime turns over.
|
||||
rows_by_cols="$(( layout_rows + 1 )) $(( fit_cols - FIT_SPARE_COLUMNS ))"
|
||||
if fit_window; then
|
||||
fail "the fit asks for more than the bare content"
|
||||
else
|
||||
pass "the fit asks for more than the bare content"
|
||||
fi
|
||||
|
||||
rows_by_cols="$layout_rows $fit_cols"
|
||||
if fit_window; then
|
||||
fail "the fit is not satisfied by a window that scrolls the layout"
|
||||
else
|
||||
pass "the fit is not satisfied by a window that scrolls the layout"
|
||||
fi
|
||||
|
||||
# The padding this file was written against is the config in the repo; the config
|
||||
# that runs is the one in /etc, which a checkout does not replace. Measure the
|
||||
# logo in what fastfetch drew rather than working it out from an assumption, or a
|
||||
# logo drawn two rows lower is a logo the sheen picks up and moves.
|
||||
eval "$real_measure_layout"
|
||||
LOGO_FILE="$tmp_dir/landmark.txt"
|
||||
printf '%s\n' '████████' '██ ██' '████████' >"$LOGO_FILE"
|
||||
render_with_padding() {
|
||||
local top=$1 left=$2 i line
|
||||
for (( i = 0; i < top; i++ )); do printf '\n'; done
|
||||
while IFS= read -r line; do printf '\e[1m\e[32m%*s%s\e[m\n' "$left" '' "$line"; done <"$LOGO_FILE"
|
||||
for (( i = 0; i < 6; i++ )); do printf 'module line\n'; done
|
||||
}
|
||||
for pad in "2 2" "4 5" "0 0" "6 10"; do
|
||||
set -- $pad
|
||||
eval "fastfetch() { render_with_padding $1 $2; }"
|
||||
LAYOUT_ROWS=""
|
||||
measure_layout || fail "the logo is found wherever fastfetch drew it" "padding $1/$2"
|
||||
[[ $LOGO_ROW == "$(( $1 + 1 ))" && $LOGO_COLUMN == "$(( $2 + 1 ))" ]] ||
|
||||
fail "the logo is found wherever fastfetch drew it" "padding $1/$2 measured $LOGO_ROW/$LOGO_COLUMN"
|
||||
done
|
||||
pass "the logo is found wherever fastfetch drew it"
|
||||
|
||||
# A render that does not contain the file's own text is not this logo, whatever
|
||||
# the reason — a config that restyled it, a placeholder fastfetch substituted.
|
||||
fastfetch() { printf 'something else entirely\n'; }
|
||||
LAYOUT_ROWS=""
|
||||
if measure_layout; then
|
||||
fail "a render without the logo in it is not measured"
|
||||
else
|
||||
pass "a render without the logo in it is not measured"
|
||||
fi
|
||||
Loaded 100 of 582 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user