Merge remote-tracking branch 'origin/quattro' into fix-snapshot-locate-and-timeline-leak

# Conflicts:
#	docs/file-layout.md
This commit is contained in:
David Heinemeier Hansson
2026-07-26 19:14:09 -07:00
240 changed files with 6263 additions and 2472 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ wait_until "omarchy-shell responds to ping" 60 omarchy-shell shell ping
plugins=$(omarchy-shell shell listPlugins)
for plugin in \
omarchy.audio omarchy.background omarchy.bar omarchy.bluetooth \
omarchy.clipboard omarchy.emojis omarchy.launcher omarchy.menu \
omarchy.clipboard omarchy.emojis omarchy.menu \
omarchy.monitor omarchy.network omarchy.notifications omarchy.power \
omarchy.reminders omarchy.weather; do
[[ $plugins == *"$plugin"* ]] || fail "shell plugin is loaded: $plugin" "loaded plugins: $plugins"
+9 -8
View File
@@ -94,23 +94,24 @@ screenshot "success-notification-popup"
omarchy-shell notifications dismissAll >/dev/null
wait_until "notification popup closes" 15 layer_absent "omarchy-notifications"
# The launcher does the full loop: open, search by typing, launch the top hit.
# The menu's Apps submenu does the full launcher loop: open, search by
# typing, launch the top hit.
if window_present "(?i)omawrite" >/dev/null 2>&1; then
fail "launcher test starts with no Omawrite window" "an Omawrite window is already open"
fail "app launch test starts with no Omawrite window" "an Omawrite window is already open"
fi
omarchy-shell shell summon omarchy.launcher >/dev/null
wait_until "launcher opens" 15 layer_present "omarchy-launcher"
omarchy-menu summon apps >/dev/null
wait_until "apps menu opens" 15 layer_present "omarchy-menu"
sleep 1
screenshot "success-launcher-open"
screenshot "success-apps-menu-open"
wtype "omawrite"
sleep 1
screenshot "success-launcher-search"
screenshot "success-apps-menu-search"
wtype -k Return
wait_until "launcher launches the top search hit" 60 window_present "(?i)omawrite"
wait_until "launcher closes after launching" 15 layer_absent "omarchy-launcher"
wait_until "apps menu launches the top search hit" 60 window_present "(?i)omawrite"
wait_until "apps menu closes after launching" 15 layer_absent "omarchy-menu"
close_windows "(?i)omawrite"
wait_until "Omawrite window closes" 30 window_absent "(?i)omawrite"
+22
View File
@@ -423,6 +423,28 @@ for nvim_legacy_target in \
done
pass "nvim theme migration relinks current theme"
for nvim_legacy_target in \
"../../../omarchy/current/theme/neovim.lua" \
"../../../../.config/omarchy/current/theme/neovim.lua" \
"~/.config/omarchy/current/theme/neovim.lua" \
"$NVIM_MIGRATION_TMPDIR/.config/omarchy/current/theme/neovim.lua"; do
ln -sfn "$nvim_legacy_target" "$nvim_theme_link"
HOME="$NVIM_MIGRATION_TMPDIR" bash -euo pipefail "$ROOT/migrations/1785002349.sh" >/dev/null
nvim_actual_target=$(readlink "$nvim_theme_link")
nvim_resolved_target=$(readlink -f "$nvim_theme_link")
[[ $nvim_actual_target == $nvim_expected_target ]] || fail "nvim theme repair migration updates $nvim_legacy_target"
[[ $nvim_resolved_target == $nvim_expected_resolved_target ]] || fail "nvim theme repair migration points to current theme"
done
HOME="$NVIM_MIGRATION_TMPDIR" bash -euo pipefail "$ROOT/migrations/1785002349.sh" >/dev/null
[[ $(readlink "$nvim_theme_link") == $nvim_expected_target ]] || fail "nvim theme repair migration is idempotent"
touch "$NVIM_MIGRATION_TMPDIR/custom-theme.lua"
ln -sfn "../../../../custom-theme.lua" "$nvim_theme_link"
HOME="$NVIM_MIGRATION_TMPDIR" bash -euo pipefail "$ROOT/migrations/1785002349.sh" >/dev/null
[[ $(readlink "$nvim_theme_link") == "../../../../custom-theme.lua" ]] || fail "nvim theme repair migration leaves custom symlinks alone"
pass "nvim theme repair migration relinks every legacy spelling"
HYPR_MIGRATION_TMPDIR=$(mktemp -d)
mkdir -p "$HYPR_MIGRATION_TMPDIR/.config/hypr"
cat >"$HYPR_MIGRATION_TMPDIR/.config/hypr/hyprland.lua" <<'LUA'
+130
View File
@@ -0,0 +1,130 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS'
const fs = require('fs')
const search = requireFromRoot('shell/services/AppSearch.js')
const menuQml = fs.readFileSync(path.join(root, 'shell/plugins/menu/Menu.qml'), 'utf8')
const appLibraryQml = fs.readFileSync(path.join(root, 'shell/services/AppLibrary.qml'), 'utf8')
const entries = [
{
name: 'Google Contacts',
genericName: 'Address Book',
comment: 'Manage contacts',
keywords: ['contacts', 'address book', 'people'],
id: 'google-contacts.desktop'
},
{
name: 'Calculator',
genericName: 'Calculator',
comment: 'Perform arithmetic, scientific or financial calculations',
keywords: ['calculation', 'arithmetic', 'scientific', 'financial'],
id: 'org.gnome.Calculator.desktop'
},
{
name: 'OBS Studio',
genericName: 'Streaming/Recording Software',
comment: 'Free and Open Source Streaming/Recording Software',
keywords: ['streaming', 'recording', 'capture'],
id: 'com.obsproject.Studio.desktop'
},
{
name: 'Aether',
genericName: '',
comment: 'Minimal internet radio player',
keywords: ['audio', 'music', 'radio'],
id: 'io.github.taqi.aether.desktop'
},
{
name: 'Xournal++',
genericName: 'Notetaking',
comment: 'Take handwritten notes',
keywords: ['notes', 'pdf', 'annotation'],
id: 'com.github.xournalpp.xournalpp.desktop'
},
{
name: 'RustDesk',
genericName: 'Remote Desktop',
comment: 'Remote desktop control',
keywords: ['remote', 'desktop', 'control'],
id: 'com.rustdesk.RustDesk.desktop'
}
]
const contactMatches = search.sortedEntries(entries, 'contact').map(row => search.entryName(row.entry))
assertDeepEqual(contactMatches, ['Google Contacts'], 'contact search only returns direct contact matches')
assert(
search.fuzzyScore(entries[1], 'contact') < 0,
'calculator does not match contact as a loose subsequence'
)
const acronymMatches = search.sortedEntries(entries, 'gc').map(row => search.entryName(row.entry))
assertEqual(acronymMatches[0], 'Google Contacts', 'short acronym matching still works')
const directMatches = search.sortedEntries(entries, 'obs').map(row => search.entryName(row.entry))
assertEqual(directMatches[0], 'OBS Studio', 'direct app-name matching still works')
// The menu's Apps submenu is the launcher now: app rows launch and uninstall
// through the shared app library instead of running commands themselves.
const activateMatch = menuQml.match(/function activateIndex\(index, fromPointer\) \{([\s\S]*?)\n \}/)
assert(activateMatch, 'menu activateIndex function exists')
assert(
activateMatch[1].includes('root.appLibrary.launch('),
'menu routes app launch through the shared app library'
)
assert(
!activateMatch[1].includes('entry.execute()'),
'menu does not execute desktop entries directly'
)
const confirmDeleteMatch = menuQml.match(/function confirmDelete\(\) \{([\s\S]*?)\n \}/)
assert(confirmDeleteMatch, 'menu confirmDelete function exists')
assert(
confirmDeleteMatch[1].includes('root.appLibrary.remove('),
'menu delete routes through the shared app library'
)
assert(
confirmDeleteMatch[1].includes('root.cancel()'),
'menu delete closes the menu after confirmation'
)
assert(
/function remove\(desktopId, name\) \{[\s\S]*?omarchy-remove-launcher-entry[\s\S]*?\n \}/.test(appLibraryQml),
'app library remove runs the remover through the shell'
)
assert(
/function launch\(desktopId, name\) \{[\s\S]*?gtk-launch[\s\S]*?\n \}/.test(appLibraryQml) &&
appLibraryQml.includes('Util.execDetached("gtk-launch "'),
'app library runs desktop entry launch through the shell'
)
assert(
/function iconIndexScanCommand\(\)[\s\S]*-path "\*\/apps\/\*" -o -path "\*\/devices\/\*"/.test(appLibraryQml),
'app library fallback icon index includes device icons'
)
assert(
/if \(active === "apps"\) \{[\s\S]*?rows\.sort\(function\(a, b\)/.test(menuQml),
'apps menu enforces alphabetical display order after provider refreshes'
)
const iconSourceMatch = appLibraryQml.match(/function iconSource\(icon\) \{([\s\S]*?)\n \}/)
assert(iconSourceMatch, 'app library iconSource function exists')
assert(
iconSourceMatch[1].indexOf('root.iconIndex[value]') < iconSourceMatch[1].indexOf('Quickshell.iconPath(value, true)'),
'app library prefers indexed app icons over ambiguous themed icons'
)
const openMatch = menuQml.match(/function openExistingMenu\(initialMenu\) \{([\s\S]*?)\n \}/)
assert(openMatch, 'menu openExistingMenu function exists')
assert(
openMatch[1].includes('root.appLibrary.refreshIcons()'),
'menu refreshes the shared icon index when opened'
)
JS
+3 -1
View File
@@ -12,7 +12,9 @@ cleanup() {
kill "$QS_PID" 2>/dev/null || true
wait "$QS_PID" 2>/dev/null || true
fi
[[ -n $TMPDIR && -d $TMPDIR ]] && rm -rf "$TMPDIR"
if [[ -n $TMPDIR && -d $TMPDIR ]]; then
rm -rf "$TMPDIR"
fi
}
trap cleanup EXIT
+51 -49
View File
@@ -37,9 +37,16 @@ for arg in "$@"; do printf "\t%s" "$arg" >>"$OMARCHY_CHANNEL_TEST_LOG"; done
printf "\n" >>"$OMARCHY_CHANNEL_TEST_LOG"
'
write_stub omarchy-state '#!/bin/bash
printf "state" >>"$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-update '#!/bin/bash
printf "update" >>"$OMARCHY_CHANNEL_TEST_LOG"
for arg in "$@"; do printf "\t%s" "$arg" >>"$OMARCHY_CHANNEL_TEST_LOG"; done
printf "\tOMARCHY_PATH=%s" "$OMARCHY_PATH" >>"$OMARCHY_CHANNEL_TEST_LOG"
printf "\n" >>"$OMARCHY_CHANNEL_TEST_LOG"
'
@@ -47,22 +54,9 @@ write_stub gum '#!/bin/bash
printf "gum" >>"$OMARCHY_CHANNEL_TEST_LOG"
for arg in "$@"; do printf "\t%s" "$arg" >>"$OMARCHY_CHANNEL_TEST_LOG"; done
printf "\n" >>"$OMARCHY_CHANNEL_TEST_LOG"
if [[ $1 == "input" ]]; then
printf "%s\n" "${OMARCHY_TEST_GUM_INPUT:-$HOME/Work/omarchy}"
fi
exit 0
'
write_stub omarchy-cmd-missing '#!/bin/bash
[[ $1 == "git" ]] && exit 1
exit 0
'
write_stub omarchy-cmd-present '#!/bin/bash
[[ $1 == "omarchy-dev-unlink" || $1 == "gum" || $1 == "git" ]] && exit 0
exit 1
'
write_stub git '#!/bin/bash
printf "git" >>"$OMARCHY_CHANNEL_TEST_LOG"
for arg in "$@"; do printf "\t%s" "$arg" >>"$OMARCHY_CHANNEL_TEST_LOG"; done
@@ -96,9 +90,7 @@ esac
run_channel() {
: >"$log_file"
OMARCHY_CHANNEL_TEST_LOG="$log_file" \
OMARCHY_DEV_PATH="${OMARCHY_DEV_PATH:-}" \
OMARCHY_TEST_GUM_INPUT="${OMARCHY_TEST_GUM_INPUT:-}" \
OMARCHY_PATH="$ROOT" \
OMARCHY_PATH="${OMARCHY_TEST_PATH:-/usr/share/omarchy}" \
HOME="$test_tmp/home" \
PATH="$stub_bin:$ROOT/bin:$PATH" \
"$ROOT/bin/omarchy-channel-set" "$@"
@@ -112,49 +104,35 @@ assert_log_line() {
pass "$description"
}
assert_numbered_log_line() {
local number="$1"
local expected="$2"
local description="$3"
local actual=""
actual=$(sed -n "${number}p" "$log_file")
[[ $actual == $expected ]] || fail "$description" "$(cat "$log_file")"
pass "$description"
}
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 'unlink' "stable restores the package-backed Omarchy path"
assert_log_line $'update\t-y' "stable runs the normal update pipeline"
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
fail "stable does not require reboot when already package-backed" "$(cat "$log_file")"
fi
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 'unlink' "rc restores the package-backed Omarchy path"
assert_log_line $'update\t-y' "rc runs the normal update pipeline"
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"
run_channel edge
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 'unlink' "edge remains package-backed"
assert_log_line $'update\t-y' "edge runs the normal update pipeline"
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' ]] ||
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"
checkout="$test_tmp/dev-checkout"
default_checkout="$test_tmp/home/Work/omarchy"
OMARCHY_TEST_GUM_INPUT="$checkout" run_channel dev
assert_numbered_log_line 1 $'gum\tconfirm\t--default=false\tEnable Dev anyway?' "dev warns before changing packages"
assert_numbered_log_line 2 $'gum\tinput\t--value\t'"$default_checkout"$'\t--placeholder\t'"$default_checkout"$'\t--header\tWhere should Dev checkout live? Existing non-checkout paths will not be overwritten.' "dev prompts for the checkout path before changing packages"
assert_log_line $'gum\tconfirm\t--default=false\tEnable Dev anyway?' "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\t--branch\tquattro\t--single-branch\thttps://github.com/basecamp/omarchy.git\t'"$checkout" "dev clones the quattro checkout"
assert_log_line $'link\t'"$checkout" "dev links the source checkout"
occupied_checkout="$test_tmp/occupied"
mkdir -p "$occupied_checkout"
if OMARCHY_TEST_GUM_INPUT="$occupied_checkout" run_channel dev >"$test_tmp/occupied.out" 2>"$test_tmp/occupied.err"; then
checkout="$test_tmp/home/omarchy"
mkdir -p "$checkout"
if run_channel dev >"$test_tmp/occupied.out" 2>"$test_tmp/occupied.err"; then
fail "dev refuses to use an occupied non-checkout path"
fi
@@ -164,6 +142,30 @@ if grep -Fx $'refresh\tedge' "$log_file" >/dev/null; then
fi
pass "dev refuses occupied non-checkout paths before package changes"
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 $'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" ]] ||
fail "dev activates the checkout before changing or updating packages" "$(cat "$log_file")"
pass "dev activates the checkout before changing or updating packages"
OMARCHY_TEST_PATH="$checkout" run_channel stable
assert_log_line $'unlink\t--no-reboot' "switching from dev to stable unlinks without an early reboot prompt"
assert_log_line $'state\tset\treboot-required' "switching from dev to stable marks reboot required"
run_channel dev
if grep -q $'^git\tclone\t' "$log_file"; then
fail "dev reuses an existing checkout" "$(cat "$log_file")"
fi
assert_log_line $'link\t'"$checkout"$'\t--no-reboot' "switching back to dev links ~/omarchy"
pass "switching back to dev reuses the existing ~/omarchy checkout"
current_channel() {
OMARCHY_TEST_VERSION_CHANNEL="$1" \
OMARCHY_TEST_PACKAGES="$2" \
@@ -182,4 +184,4 @@ pass "current channel detects rc"
pass "current channel detects package-backed edge"
[[ $(current_channel edge dev "$test_tmp/dev-checkout") == "dev" ]] || fail "current channel detects dev from OMARCHY_PATH"
pass "current channel detects dev from OMARCHY_PATH"
pass "current channel honors a dev link outside ~/omarchy"
+73 -3
View File
@@ -94,12 +94,29 @@ import sys
from pathlib import Path
root = Path(os.environ["ROOT"])
home = Path.home()
pkgs_candidates = [
root.parent / "omarchy-pkgs/pkgbuilds",
root.parent / "omarchy/omarchy-pkgs/pkgbuilds",
root.parent.parent / "omarchy-pkgs/pkgbuilds",
root.parent / "omacom/omarchy-pkgs/pkgbuilds",
root.parent.parent / "omacom/omarchy-pkgs/pkgbuilds",
home / "Work/omacom/omarchy-pkgs/pkgbuilds",
]
pkgs_root = next((path for path in pkgs_candidates if path.exists()), pkgs_candidates[0])
# Checkouts differ per machine, so allow an explicit pointer at the sibling repo.
# Accepts either the omarchy-pkgs checkout or its pkgbuilds/ directory.
override = os.environ.get("OMARCHY_PKGS_PATH")
if override:
pkgs_candidates = [Path(override) / "pkgbuilds", Path(override)] + pkgs_candidates
pkgs_root = next((path for path in pkgs_candidates if path.exists()), None)
if pkgs_root is None:
print("not ok - omarchy-pkgs checkout found for PKGBUILD coverage", file=sys.stderr)
print(
"looked in:\n " + "\n ".join(str(path) for path in pkgs_candidates) +
"\nset OMARCHY_PKGS_PATH to the omarchy-pkgs checkout",
file=sys.stderr,
)
sys.exit(1)
settings_pkgbuild_path = pkgs_root / "omarchy-settings/PKGBUILD"
omarchy_pkgbuild_path = pkgs_root / "omarchy/PKGBUILD"
if not settings_pkgbuild_path.exists():
@@ -120,8 +137,8 @@ package_defaults = [
("default/systemd/user/bt-agent.service", "/usr/lib/systemd/user/bt-agent.service", "systemd/user/bt-agent.service"),
("default/systemd/user/omarchy-sleep-lock.service", "/usr/lib/systemd/user/omarchy-sleep-lock.service", "systemd/user/omarchy-sleep-lock.service"),
("default/systemd/user/omarchy-recover-internal-monitor.service", "/usr/lib/systemd/user/omarchy-recover-internal-monitor.service", "systemd/user/omarchy-recover-internal-monitor.service"),
("default/systemd/user/omarchy-update-user-notify.service", "/usr/lib/systemd/user/omarchy-update-user-notify.service", "systemd/user/omarchy-update-user-notify.service"),
("default/systemd/user/omarchy-update-user-notify.path", "/usr/lib/systemd/user/omarchy-update-user-notify.path", "systemd/user/omarchy-update-user-notify.path"),
("default/systemd/user/omarchy-migrate-notify.service", "/usr/lib/systemd/user/omarchy-migrate-notify.service", "systemd/user/omarchy-migrate-notify.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/fonts/omarchy/omarchy.ttf", "/usr/share/fonts/omarchy/omarchy.ttf", "omarchy.ttf"),
("default/snapper/root", "/etc/snapper/config-templates/omarchy", "snapper/root"),
]
@@ -134,6 +151,16 @@ for source, destination, legacy in package_defaults:
if destination and (source not in pkgbuild or destination not in pkgbuild):
errors.append(f"PKGBUILD does not explicitly install {source} -> {destination}")
# Existing users have an absolute wants symlink to the old unit path, and the
# migration that repoints it only runs for users who run an update -- the
# opposite of who the notifier is for. Dropping this alias strands them.
notify_alias = 'ln -sfn omarchy-migrate-notify.service "$pkgdir/usr/lib/systemd/user/omarchy-update-user-notify.service"'
if notify_alias not in pkgbuild:
errors.append(
"PKGBUILD does not ship the omarchy-update-user-notify.service compatibility "
"alias, so users who have not run migration 1785095882 lose the login notifier"
)
alpm_hooks = [
"00-omarchy-update-guard.hook",
"10-omarchy-hyprland-reload-pause.hook",
@@ -249,6 +276,27 @@ jq -e '
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
pass "shell config moves existing widgets without duplicates"
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar-plugin move omarchy.active-window right
jq -e '
def ids: map(.id // .);
(.bar.layout.left | ids == ["omarchy.menu", "omarchy.workspaces"]) and
(.bar.layout.right | ids == ["omarchy.tray", "omarchy.active-window", "omarchy.microphone", "omarchy.tailscale", "omarchy.bluetooth"])
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
pass "bar plugin move accepts a positional target section"
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar-plugin move omarchy.active-window left
jq -e '
def ids: map(.id // .);
(.bar.layout.left | ids == ["omarchy.menu", "omarchy.workspaces", "omarchy.active-window"]) and
(.bar.layout.right | ids == ["omarchy.tray", "omarchy.microphone", "omarchy.tailscale", "omarchy.bluetooth"])
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
pass "bar plugin move can restore a widget with positional syntax"
if HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar-plugin move omarchy.active-window left --section right 2>/dev/null; then
fail "bar plugin move accepted positional and flagged target sections"
fi
pass "bar plugin move rejects conflicting target section syntax"
if HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar-plugin add local.nonexistent-widget 2>/dev/null; then
fail "bar plugin add accepted an unknown widget"
fi
@@ -289,6 +337,28 @@ jq -e '
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
pass "shell config removes widgets with remove alias"
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar-plugin set omarchy.bluetooth enabled false --json
jq -e '
any(.bar.layout.right[]; .id == "omarchy.bluetooth" and .enabled == false)
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
pass "bar plugin set accepts false JSON values"
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar-plugin set omarchy.bluetooth optional null --json
jq -e '
any(.bar.layout.right[]; .id == "omarchy.bluetooth" and has("optional") and .optional == null)
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
pass "bar plugin set accepts null JSON values"
if HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar-plugin set omarchy.bluetooth broken '{' --json 2>/dev/null; then
fail "bar plugin set accepted malformed JSON"
fi
pass "bar plugin set rejects malformed JSON"
if HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar-plugin set omarchy.bluetooth broken 'false null' --json 2>/dev/null; then
fail "bar plugin set accepted multiple JSON values"
fi
pass "bar plugin set rejects multiple JSON values"
mock_bin="$TMPDIR/mock-bin"
mkdir -p "$mock_bin"
+82
View File
@@ -0,0 +1,82 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
test_tmp=$(mktemp -d)
trap 'rm -rf "$test_tmp"' EXIT
stub_bin="$test_tmp/bin"
log_file="$test_tmp/dev-unlink.log"
conf_file="$test_tmp/omarchy.conf"
mkdir -p "$stub_bin" "$test_tmp/home"
cat >"$stub_bin/sudo" <<'SH'
#!/bin/bash
printf 'sudo' >>"$OMARCHY_DEV_UNLINK_TEST_LOG"
for arg in "$@"; do
printf '\t%s' "$arg" >>"$OMARCHY_DEV_UNLINK_TEST_LOG"
done
printf '\n' >>"$OMARCHY_DEV_UNLINK_TEST_LOG"
if [[ $1 == "tee" ]]; then
cat >"$OMARCHY_DEV_UNLINK_TEST_CONF"
fi
SH
chmod +x "$stub_bin/sudo"
cat >"$stub_bin/gum" <<'SH'
#!/bin/bash
printf 'gum' >>"$OMARCHY_DEV_UNLINK_TEST_LOG"
for arg in "$@"; do
printf '\t%s' "$arg" >>"$OMARCHY_DEV_UNLINK_TEST_LOG"
done
printf '\n' >>"$OMARCHY_DEV_UNLINK_TEST_LOG"
SH
chmod +x "$stub_bin/gum"
cat >"$stub_bin/omarchy-system-reboot" <<'SH'
#!/bin/bash
printf 'reboot\n' >>"$OMARCHY_DEV_UNLINK_TEST_LOG"
SH
chmod +x "$stub_bin/omarchy-system-reboot"
run_unlink() {
HOME="$test_tmp/home" \
OMARCHY_DEV_UNLINK_TEST_LOG="$log_file" \
OMARCHY_DEV_UNLINK_TEST_CONF="$conf_file" \
PATH="$stub_bin:$PATH" \
"$ROOT/bin/omarchy-dev-unlink" "$@"
}
: >"$log_file"
run_unlink --no-reboot
grep -Fx $'sudo\ttee\t/etc/omarchy.conf' "$log_file" >/dev/null ||
fail "dev unlink writes the package path without rebooting" "$(cat "$log_file")"
[[ $(<"$conf_file") == 'export OMARCHY_PATH="/usr/share/omarchy"' ]] ||
fail "dev unlink writes the package path guard" "$(<"$conf_file")"
if grep -Eq '^(gum|reboot)' "$log_file"; then
fail "dev unlink --no-reboot skips the reboot prompt" "$(cat "$log_file")"
fi
pass "dev unlink --no-reboot skips the reboot prompt"
: >"$log_file"
run_unlink
grep -Fx $'gum\tconfirm\tReboot now to activate?' "$log_file" >/dev/null ||
fail "interactive dev unlink still prompts for reboot" "$(cat "$log_file")"
grep -Fx 'reboot' "$log_file" >/dev/null ||
fail "interactive dev unlink still reboots after confirmation" "$(cat "$log_file")"
pass "interactive dev unlink keeps its reboot prompt"
if run_unlink --invalid >"$test_tmp/invalid.out" 2>"$test_tmp/invalid.err"; then
fail "dev unlink rejects unknown arguments"
fi
grep -F 'Usage: omarchy dev unlink [--no-reboot]' "$test_tmp/invalid.err" >/dev/null ||
fail "dev unlink explains valid arguments" "$(cat "$test_tmp/invalid.err")"
pass "dev unlink rejects unknown arguments"
@@ -0,0 +1,86 @@
#!/bin/bash
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"
exit 0
fi
test_home=$(mktemp -d)
test_bin=$(mktemp -d)
log_file=$(mktemp)
hw_marker=$(mktemp -u)
hook_path="$test_home/.config/omarchy/hooks/post-update.d/setup-fingerprint.hook"
cleanup() {
rm -rf "$test_home" "$test_bin"
rm -f "$log_file" "$hw_marker"
}
trap cleanup EXIT
mkdir -p "$(dirname "$hook_path")"
cat >"$test_bin/omarchy-hw-fingerprint" <<'EOF'
#!/bin/bash
[[ -f $TEST_HW_MARKER ]]
EOF
chmod +x "$test_bin/omarchy-hw-fingerprint"
cat >"$test_bin/omarchy-notification-send" <<'EOF'
#!/bin/bash
echo notification >>"$TEST_LOG"
echo action
EOF
chmod +x "$test_bin/omarchy-notification-send"
cat >"$test_bin/omarchy-launch-floating-terminal-with-presentation" <<'EOF'
#!/bin/bash
echo launch >>"$TEST_LOG"
EOF
chmod +x "$test_bin/omarchy-launch-floating-terminal-with-presentation"
cat >"$test_bin/systemd-run" <<'EOF'
#!/bin/bash
echo "systemd-run:$*" >>"$TEST_LOG"
while (($# > 0)); do
case $1 in
-p) shift 2 ;;
-*) shift ;;
*) break ;;
esac
done
exec "$@"
EOF
chmod +x "$test_bin/systemd-run"
run_invitation_hook() {
cp "$ROOT/install/user/first-run/setup-fingerprint.hook" "$hook_path"
HOME="$test_home" PATH="$test_bin:$ROOT/bin:$PATH" TEST_LOG="$log_file" TEST_HW_MARKER="$hw_marker" bash "$hook_path"
}
run_invitation_hook
[[ ! -f $test_home/.local/state/omarchy/done/fingerprint-setup-invitation ]] || fail "fingerprint invitation stays pending without a reader"
[[ ! -s $log_file ]] || fail "fingerprint invitation does nothing without a reader"
touch "$hw_marker"
run_invitation_hook
[[ -f $test_home/.local/state/omarchy/done/fingerprint-setup-invitation ]] || fail "fingerprint invitation records completion"
[[ -f $hook_path ]] || fail "fingerprint invitation keeps its hook installed"
[[ $(grep -c '^systemd-run:' "$log_file") -eq 2 ]] || fail "fingerprint invitation uses durable user services"
grep -q -- '--user --collect --quiet --service-type=exec --unit=omarchy-fingerprint-setup-invitation' "$log_file" || fail "fingerprint invitation configures its user service"
# KillMode=process keeps the launcher's setsid child alive once the short-lived
# main process exits, otherwise the setup terminal never appears.
grep -q -- '--user --collect --quiet -p KillMode=process --unit=omarchy-setup-security-fingerprint ' "$log_file" || fail "fingerprint invitation outlives its launcher unit"
[[ $(grep -c '^notification$' "$log_file") -eq 1 ]] || fail "fingerprint invitation sends one notification"
[[ $(grep -c '^launch$' "$log_file") -eq 1 ]] || fail "fingerprint invitation handles the notification action"
HOME="$test_home" PATH="$test_bin:$ROOT/bin:$PATH" TEST_LOG="$log_file" TEST_HW_MARKER="$hw_marker" bash "$hook_path"
[[ $(grep -c '^systemd-run:' "$log_file") -eq 2 ]] || fail "completed fingerprint invitation does not schedule again"
[[ $(grep -c '^notification$' "$log_file") -eq 1 ]] || fail "completed fingerprint invitation hook does not notify again"
pass "fingerprint invitation waits for a reader and only runs once"
@@ -110,24 +110,13 @@ ShellRoot {
Item { id: host }
QtObject {
id: mockNotificationService
property bool doNotDisturb: false
property ListModel pendingModel: ListModel {}
property ListModel pastModel: ListModel {}
function setDoNotDisturb(value) { doNotDisturb = !!value }
}
QtObject {
id: mockShell
property var bar: fakeBar
property var barConfig: ({ position: "top" })
property var shellConfig: ({ version: 1, idle: {}, plugins: [], bar: { layout: { left: [], center: [], right: [] } } })
function firstPartyServiceFor(id) {
if (id === "omarchy.notifications") return mockNotificationService
return null
}
function serviceFor(id) { return firstPartyServiceFor(id) }
function firstPartyServiceFor(id) { return null }
function serviceFor(id) { return null }
function summon(id, payloadJson) { return true }
function hide(id) { return true }
function toggle(id, payloadJson) { return true }
@@ -0,0 +1,108 @@
import QtQuick
import Quickshell
import qs.Commons
ShellRoot {
id: root
readonly property string resultPath: Quickshell.env("OMARCHY_QML_TEST_RESULT")
readonly property string rootPath: Quickshell.env("OMARCHY_PATH")
property var failures: []
function fail(message) {
failures.push(String(message))
}
function assertTrue(condition, message) {
if (!condition) fail(message)
}
function shellQuote(value) {
return "'" + String(value).replace(/'/g, "'\\''") + "'"
}
function writeResult() {
var payload = JSON.stringify({
ok: failures.length === 0,
failures: failures
})
if (resultPath) {
Quickshell.execDetached(["bash", "-lc", "printf '%s' " + shellQuote(payload) + " > " + shellQuote(resultPath)])
}
}
Item { id: host; width: 800; height: 600 }
TextMetrics {
id: probe
font.family: Style.font.family
}
Timer {
interval: 1
running: true
repeat: false
onTriggered: {
try {
var component = Qt.createComponent("file://" + root.rootPath + "/shell/plugins/lock/LockView.qml", Component.PreferSynchronous)
if (component.status !== Component.Ready) {
root.fail("LockView failed to load: " + component.errorString())
return
}
var view = component.createObject(host, { width: 800, height: 600, loadBackground: false })
if (!view) {
root.fail("LockView failed to instantiate: " + component.errorString())
return
}
var indicator = view.children ? findByObjectName(view, "fingerprintIndicator") : null
root.assertTrue(indicator !== null, "fingerprint indicator exists in the lock view")
if (indicator) {
view.fingerprintConfigured = false
root.assertTrue(!indicator.visible, "fingerprint indicator is hidden when no sensor is configured")
view.fingerprintConfigured = true
root.assertTrue(indicator.visible, "fingerprint indicator is shown when a sensor is configured")
// The field reserves space for the icon so a long password can never
// slide underneath it. The reserve must exceed the icon's own width
// (leaving a gap), and the shrunk dots must fit the reserved-clear
// area even at extreme lengths.
root.assertTrue(view.fingerprintReserve > indicator.width,
"reserved space exceeds the icon width, got reserve " + view.fingerprintReserve + " vs icon " + indicator.width)
view.passwordText = "x".repeat(80)
var clearWidth = view.fieldWidth - 2 * view.fingerprintReserve
probe.font.pixelSize = Math.max(1, Math.floor(view.passwordDotFontSize * view.passwordDotScale))
probe.font.letterSpacing = view.passwordDotLetterSpacing * view.passwordDotScale
probe.text = "●".repeat(80)
root.assertTrue(probe.advanceWidth <= clearWidth,
"80 dots stay clear of the fingerprint icon, need " + probe.advanceWidth + "px of " + clearWidth)
view.fingerprintConfigured = false
root.assertTrue(view.fingerprintReserve === 0, "no space is reserved when no sensor is configured")
}
view.destroy()
} catch (error) {
root.fail("lock fingerprint indicator fixture threw: " + error)
} finally {
root.writeResult()
}
}
}
function findByObjectName(node, name) {
if (!node) return null
if (node.objectName === name) return node
var kids = node.children || []
for (var i = 0; i < kids.length; i++) {
var found = findByObjectName(kids[i], name)
if (found) return found
}
return null
}
}
@@ -137,24 +137,13 @@ ShellRoot {
function rescan() {}
}
QtObject {
id: mockNotificationService
property bool doNotDisturb: false
property ListModel pendingModel: ListModel {}
property ListModel pastModel: ListModel {}
function setDoNotDisturb(value) { doNotDisturb = !!value }
}
QtObject {
id: mockShell
property var bar: fakeBar
property var barConfig: ({ position: "top" })
property var shellConfig: ({ version: 1, idle: {}, plugins: [], bar: { layout: { left: [], center: [], right: [] } } })
function firstPartyServiceFor(id) {
if (id === "omarchy.notifications") return mockNotificationService
return null
}
function serviceFor(id) { return firstPartyServiceFor(id) }
function firstPartyServiceFor(id) { return null }
function serviceFor(id) { return null }
function summon(id, payloadJson) { return true }
function hide(id) { return true }
function toggle(id, payloadJson) { return true }
@@ -105,6 +105,20 @@ grep -Fq $'SUPER + RETURN Terminal' <<<"$fresh_output" || fail "default applicat
grep -Fq $'SUPER + SHIFT + A ChatGPT' <<<"$fresh_output" || fail "default application bindings include preinstalled web apps"
pass "default application bindings load from package defaults"
grep -F 'hl.dsp.send_key_state({ mods = mods, key = key, state = "down" })' "$ROOT/default/hypr/bindings/clipboard.lua" >/dev/null ||
fail "universal clipboard shortcuts send explicit mods to the focused surface"
pass "universal clipboard shortcuts send explicit mods to the focused surface"
if grep -E 'send_key_state\(\{[^}]*window' "$ROOT/default/hypr/bindings/clipboard.lua" >/dev/null; then
fail "universal clipboard shortcuts do not target only normal windows"
fi
pass "universal clipboard shortcuts do not exclude layer-shell fields"
if grep -F 'wtype -M' "$ROOT/default/hypr/bindings/clipboard.lua" >/dev/null; then
fail "universal clipboard shortcuts avoid the virtual keyboard so held SUPER cannot merge in"
fi
pass "universal clipboard shortcuts avoid virtual keyboard modifier merging"
removed_home="$tmpdir/removed-home"
mkdir -p "$removed_home/.local/state/omarchy"
touch "$removed_home/.local/state/omarchy/preinstalls-removed"
@@ -0,0 +1,85 @@
#!/bin/bash
source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh"
require_command lua
resolved_input() {
OMARCHY_PATH="$ROOT" OMARCHY_VCONSOLE="${1-}" lua <<'LUA'
package.path = os.getenv("OMARCHY_PATH") .. "/?.lua;" .. package.path
local vconsole = os.getenv("OMARCHY_VCONSOLE")
local real_open = io.open
io.open = function(path, mode)
if path ~= "/etc/vconsole.conf" then
return real_open(path, mode)
end
if not vconsole then
return nil
end
local file = io.tmpfile()
file:write(vconsole)
file:seek("set")
return file
end
hl = {
config = function(config)
local input = config.input
print(("[%s] [%s] [%s]"):format(input.kb_layout, input.kb_variant, input.kb_options))
end,
}
o = { window = function() end }
require("default.hypr.input")
LUA
}
assert_input() {
local description="$1"
local expected="$2"
local actual
if (( $# > 2 )); then
actual=$(resolved_input "$3")
else
actual=$(resolved_input)
fi
[[ $actual == "$expected" ]] ||
fail "$description" "expected: $expected"$'\n'"actual: $actual"
pass "$description"
}
base_options="compose:caps,shift:both_capslock"
toggle_options="$base_options,grp:alts_toggle"
assert_input "missing vconsole.conf falls back to us" "[us] [] [$base_options]"
assert_input "us layout passes through" "[us] [intl] [$base_options]" 'XKBLAYOUT=us
XKBVARIANT=intl
'
assert_input "latin layouts are left alone" "[de] [nodeadkeys] [$base_options]" 'XKBLAYOUT=de
XKBVARIANT=nodeadkeys
'
assert_input "non-latin layout gains us in front" "[us,ara] [,] [$toggle_options]" 'XKBLAYOUT=ara
'
assert_input "prepended us keeps variants aligned" "[us,ru] [,phonetic] [$toggle_options]" 'XKBLAYOUT=ru
XKBVARIANT=phonetic
'
assert_input "non-latin layout in front gains us even when us trails" "[us,il,us] [,] [$toggle_options]" 'XKBLAYOUT=il,us
'
hooks_conf="$ROOT/etc/mkinitcpio.conf.d/omarchy_hooks.conf"
input_lua="$ROOT/default/hypr/input.lua"
hooks_layouts=$(awk -F')' '/\) ;;$/ { gsub(/[[:space:]|]+/, "\n", $1); print $1 }' "$hooks_conf" | grep '^[a-z]\+$' | sort)
lua_layouts=$(sed -n '/^local non_latin_layouts =/,+1p' "$input_lua" | grep -o '"[^"]*"' | tr -d '"' | tr ' ' '\n' | grep '^[a-z]\+$' | sort)
[[ -n $hooks_layouts ]] || fail "non-latin layout list is readable from omarchy_hooks.conf"
[[ $hooks_layouts == "$lua_layouts" ]] ||
fail "non-latin layout lists stay in sync" "$(diff <(echo "$hooks_layouts") <(echo "$lua_layouts"))"
pass "non-latin layout lists stay in sync with the initramfs hook"
+69
View File
@@ -0,0 +1,69 @@
#!/bin/bash
source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh"
require_command lua
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
stub_dir="$tmpdir/bin"
home_dir="$tmpdir/home"
log_file="$tmpdir/hyprctl.log"
mkdir -p "$stub_dir" "$home_dir"
cat >"$stub_dir/hyprctl" <<'EOF'
#!/bin/bash
if [[ $1 == "activeworkspace" && -n $HYPRCTL_BROKEN ]]; then
printf '{}\n'
elif [[ $1 == "activeworkspace" ]]; then
printf '{"id":3,"tiledLayout":"dwindle"}\n'
else
printf '%s\n' "$*" >>"$HYPRCTL_LOG"
fi
EOF
chmod +x "$stub_dir/hyprctl"
cat >"$stub_dir/omarchy-notification-send" <<'EOF'
#!/bin/bash
:
EOF
chmod +x "$stub_dir/omarchy-notification-send"
HOME="$home_dir" HYPRCTL_LOG="$log_file" PATH="$stub_dir:$PATH" \
"$ROOT/bin/omarchy-hyprland-workspace-layout-toggle"
layout_file="$home_dir/.local/state/omarchy/workspace-layouts/3.lua"
[[ -f $layout_file ]] || fail "workspace layout toggle saves a workspace rule"
grep -Fx 'hl.workspace_rule({ workspace = "3", layout = "scrolling" })' "$layout_file" >/dev/null ||
fail "workspace layout toggle saves the selected layout"
grep -Fx 'eval hl.workspace_rule({ workspace = "3", layout = "scrolling" })' "$log_file" >/dev/null ||
fail "workspace layout toggle applies the selected layout immediately"
pass "workspace layout toggle persists and applies the selected layout"
if HOME="$home_dir" HYPRCTL_LOG="$log_file" HYPRCTL_BROKEN=1 PATH="$stub_dir:$PATH" \
"$ROOT/bin/omarchy-hyprland-workspace-layout-toggle" 2>/dev/null; then
fail "workspace layout toggle exits nonzero without a workspace id"
fi
[[ -f "$home_dir/.local/state/omarchy/workspace-layouts/null.lua" ]] &&
fail "workspace layout toggle does not persist a rule without a workspace id"
pass "workspace layout toggle ignores broken hyprctl output"
HOME="$home_dir" OMARCHY_PATH="$ROOT" lua <<'LUA'
local rules = {}
hl = {
workspace_rule = function(rule)
table.insert(rules, rule)
end,
}
dofile(os.getenv("OMARCHY_PATH") .. "/default/hypr/bootstrap.lua")
require("default.hypr.workspace-layouts")
assert(#rules == 1)
assert(rules[1].workspace == "3")
assert(rules[1].layout == "scrolling")
LUA
pass "saved workspace layouts load into Hyprland configuration"
-145
View File
@@ -1,145 +0,0 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS'
const fs = require('fs')
const search = requireFromRoot('shell/plugins/launcher/LauncherSearch.js')
const launcherQml = fs.readFileSync(path.join(root, 'shell/plugins/launcher/Launcher.qml'), 'utf8')
const entries = [
{
name: 'Google Contacts',
genericName: 'Address Book',
comment: 'Manage contacts',
keywords: ['contacts', 'address book', 'people'],
id: 'google-contacts.desktop'
},
{
name: 'Calculator',
genericName: 'Calculator',
comment: 'Perform arithmetic, scientific or financial calculations',
keywords: ['calculation', 'arithmetic', 'scientific', 'financial'],
id: 'org.gnome.Calculator.desktop'
},
{
name: 'OBS Studio',
genericName: 'Streaming/Recording Software',
comment: 'Free and Open Source Streaming/Recording Software',
keywords: ['streaming', 'recording', 'capture'],
id: 'com.obsproject.Studio.desktop'
},
{
name: 'Aether',
genericName: '',
comment: 'Minimal internet radio player',
keywords: ['audio', 'music', 'radio'],
id: 'io.github.taqi.aether.desktop'
},
{
name: 'Xournal++',
genericName: 'Notetaking',
comment: 'Take handwritten notes',
keywords: ['notes', 'pdf', 'annotation'],
id: 'com.github.xournalpp.xournalpp.desktop'
},
{
name: 'RustDesk',
genericName: 'Remote Desktop',
comment: 'Remote desktop control',
keywords: ['remote', 'desktop', 'control'],
id: 'com.rustdesk.RustDesk.desktop'
}
]
const contactMatches = search.sortedEntries(entries, 'contact').map(row => search.entryName(row.entry))
assertDeepEqual(contactMatches, ['Google Contacts'], 'contact search only returns direct contact matches')
assert(
search.fuzzyScore(entries[1], 'contact') < 0,
'calculator does not match contact as a loose subsequence'
)
const acronymMatches = search.sortedEntries(entries, 'gc').map(row => search.entryName(row.entry))
assertEqual(acronymMatches[0], 'Google Contacts', 'short acronym matching still works')
const directMatches = search.sortedEntries(entries, 'obs').map(row => search.entryName(row.entry))
assertEqual(directMatches[0], 'OBS Studio', 'direct app-name matching still works')
assert(
/function select\(delta\)[\s\S]*root\.disarmHover\(\)[\s\S]*root\.selectedIndex =/.test(launcherQml),
'launcher keyboard navigation disarms stale hover before moving selection'
)
assert(
/PointerMoveGate\s*\{[\s\S]*id: pointerGate[\s\S]*referenceItem: card[\s\S]*\}/.test(launcherQml),
'launcher uses shared pointer movement gate in card coordinates'
)
assert(
/function disarmHover\(\)[\s\S]*pointerGate\.reset\(\)/.test(launcherQml),
'launcher resets pointer movement gate when hover is disarmed'
)
const openMatch = launcherQml.match(/function open\(payloadJson\) \{([\s\S]*?)\n \}/)
assert(openMatch, 'launcher open function exists')
assert(
openMatch[1].indexOf('root.disarmHover()') < openMatch[1].indexOf('root.opened = true')
&& !openMatch[1].includes('pointerGate.allowInitialSample()'),
'launcher ignores a stale hidden-pointer position when becoming visible'
)
assert(
/function selectFromPointer\(index, item, mouse\)[\s\S]*pointerGate\.moved\(item, mouse\)[\s\S]*root\.selectedIndex = index/.test(launcherQml),
'launcher only selects from pointer after real movement'
)
assert(
/onPositionChanged: function\(mouse\) \{\s*root\.selectFromPointer\(row\.index, row, mouse\)\s*\}/.test(launcherQml),
'launcher row hover routes through pointer movement gate'
)
assert(
/onEntered: root\.selectFromPointer\(row\.index, row, \{\s*x: mouseArea\.mouseX,\s*y: mouseArea\.mouseY\s*\}\)/.test(launcherQml),
'launcher samples pointer movement immediately when entering a row'
)
assert(
!/onContainsMouseChanged:[\s\S]*root\.selectedIndex/.test(launcherQml),
'launcher does not select rows from containsMouse'
)
const confirmDeleteMatch = launcherQml.match(/function confirmDelete\(\) \{([\s\S]*?)\n \}/)
assert(confirmDeleteMatch, 'launcher confirmDelete function exists')
assert(
confirmDeleteMatch[1].includes('root.dismiss()'),
'launcher delete closes launcher after confirmation'
)
assert(
confirmDeleteMatch[1].includes('Util.execDetached(command)'),
'launcher delete runs remover through the shell'
)
const activateMatch = launcherQml.match(/function activateIndex\(index\) \{([\s\S]*?)\n \}/)
assert(activateMatch, 'launcher activateIndex function exists')
assert(
!activateMatch[1].includes('entry.execute()'),
'launcher does not execute desktop entries directly'
)
assert(
activateMatch[1].includes('gtk-launch') && activateMatch[1].includes('Util.execDetached'),
'launcher runs desktop entry launch through the shell'
)
assert(
/function iconIndexScanCommand\(\)[\s\S]*-path "\*\/apps\/\*" -o -path "\*\/devices\/\*"/.test(launcherQml),
'launcher fallback icon index includes device icons'
)
const iconSourceMatch = launcherQml.match(/function iconSource\(icon\) \{([\s\S]*?)\n \}/)
assert(iconSourceMatch, 'launcher iconSource function exists')
assert(
iconSourceMatch[1].indexOf('root.iconIndex[value]') < iconSourceMatch[1].indexOf('Quickshell.iconPath(value, true)'),
'launcher prefers indexed app icons over ambiguous themed icons'
)
assert(
openMatch[1].includes('if (!iconIndexScan.running) iconIndexScan.running = true'),
'launcher refreshes its icon index when opened'
)
JS
+93
View File
@@ -0,0 +1,93 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
lid_close="$ROOT/bin/omarchy-system-lid-close"
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
# closed/docked are the two facts logind uses to decide whether a lid close
# suspends, so each scenario pins them and records what the lid handler did.
setup_scenario() {
scenario_dir="$tmpdir/$1"
mock_bin="$scenario_dir/bin"
call_log="$scenario_dir/calls"
mkdir -p "$mock_bin"
: >"$call_log"
local closed="$2" docked="$3"
cat >"$mock_bin/omarchy-hw-laptop-closed" <<SH
#!/bin/bash
exit $closed
SH
cat >"$mock_bin/omarchy-hw-external-monitors" <<SH
#!/bin/bash
exit $docked
SH
for command in omarchy-system-lock omarchy-hyprland-monitor-clamshell; do
cat >"$mock_bin/$command" <<SH
#!/bin/bash
echo $command >>"\$CALL_LOG"
SH
done
chmod +x "$mock_bin"/*
}
run_lid_close() {
CALL_LOG="$call_log" PATH="$mock_bin:$PATH" "$lid_close"
mapfile -t calls <"$call_log"
}
# An undocked lid close is about to suspend, and logind's inhibitor window is a
# timer rather than a promise, so the lock has to start now instead of waiting
# for PrepareForSleep.
setup_scenario undocked 0 1
run_lid_close
[[ ${calls[0]} == "omarchy-system-lock" ]] ||
fail "undocked lid close locks before anything else" "calls: ${calls[*]}"
pass "undocked lid close locks before anything else"
[[ ${calls[1]} == "omarchy-hyprland-monitor-clamshell" ]] ||
fail "undocked lid close still reconciles displays" "calls: ${calls[*]}"
pass "undocked lid close still reconciles displays"
# A docked lid close is clamshell mode: logind leaves the machine awake and the
# session stays in use on the external display, so locking it would be wrong.
setup_scenario docked 0 0
run_lid_close
[[ ${calls[*]} != *omarchy-system-lock* ]] ||
fail "docked lid close does not lock the session" "calls: ${calls[*]}"
pass "docked lid close does not lock the session"
[[ ${calls[0]} == "omarchy-hyprland-monitor-clamshell" ]] ||
fail "docked lid close reconciles displays" "calls: ${calls[*]}"
pass "docked lid close reconciles displays"
# Hyprland can replay a switch binding when the lid is already open, and an
# open lid must never lock the machine the user is sitting at.
setup_scenario open 1 1
run_lid_close
[[ ${calls[*]} != *omarchy-system-lock* ]] ||
fail "an open lid never locks the session" "calls: ${calls[*]}"
pass "an open lid never locks the session"
# The lid handler runs from a Hyprland binding, so a lock that hangs or fails
# must not stop the display reconciliation behind it.
setup_scenario failing_lock 0 1
cat >"$mock_bin/omarchy-system-lock" <<'SH'
#!/bin/bash
echo omarchy-system-lock >>"$CALL_LOG"
exit 1
SH
chmod +x "$mock_bin/omarchy-system-lock"
run_lid_close
[[ ${calls[1]} == "omarchy-hyprland-monitor-clamshell" ]] ||
fail "a failing lock still reconciles displays" "calls: ${calls[*]}"
pass "a failing lock still reconciles displays"
+71
View File
@@ -0,0 +1,71 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
TMPDIR=""
QS_PID=""
cleanup() {
if [[ -n $QS_PID ]] && kill -0 "$QS_PID" 2>/dev/null; then
kill "$QS_PID" 2>/dev/null || true
wait "$QS_PID" 2>/dev/null || true
fi
[[ -n $TMPDIR && -d $TMPDIR ]] && rm -rf "$TMPDIR"
}
trap cleanup EXIT
if [[ -z ${WAYLAND_DISPLAY:-} ]]; then
pass "no Wayland compositor; skipping lock fingerprint indicator test"
exit 0
fi
if ! command -v quickshell >/dev/null 2>&1; then
pass "quickshell not installed; skipping lock fingerprint indicator test"
exit 0
fi
require_command jq
TMPDIR=$(mktemp -d)
result="$TMPDIR/result.json"
log="$TMPDIR/quickshell.log"
config_dir="$TMPDIR/lock-fingerprint-indicator"
mkdir -p "$config_dir" "$TMPDIR/home"
cp "$SHELL_TEST_DIR/fixtures/lock-fingerprint-indicator/shell.qml" "$config_dir/shell.qml"
ln -s "$ROOT/shell/Ui" "$config_dir/Ui"
ln -s "$ROOT/shell/Commons" "$config_dir/Commons"
OMARCHY_PATH="$ROOT" \
OMARCHY_QML_TEST_RESULT="$result" \
HOME="$TMPDIR/home" \
QML2_IMPORT_PATH="$ROOT/shell${QML2_IMPORT_PATH:+:$QML2_IMPORT_PATH}" \
QML_IMPORT_PATH="$ROOT/shell${QML_IMPORT_PATH:+:$QML_IMPORT_PATH}" \
PATH="$ROOT/bin:$PATH" \
quickshell -p "$config_dir" --no-color >"$log" 2>&1 &
QS_PID=$!
for _ in {1..80}; do
[[ -s $result ]] && break
if ! kill -0 "$QS_PID" 2>/dev/null; then
sed -n '1,220p' "$log" >&2
fail "lock fingerprint indicator quickshell exited before writing result"
fi
sleep 0.1
done
[[ -s $result ]] || {
sed -n '1,220p' "$log" >&2
fail "lock fingerprint indicator test timed out"
}
if ! jq -e '.ok == true' "$result" >/dev/null; then
printf 'Lock fingerprint indicator result:\n' >&2
jq . "$result" >&2
printf 'Lock fingerprint indicator log:\n' >&2
sed -n '1,220p' "$log" >&2
fail "fingerprint indicator tracks the configured sensor"
fi
pass "fingerprint indicator tracks the configured sensor"
+120 -1
View File
@@ -92,6 +92,8 @@ assertDeepEqual(
kind: 'action',
icon: '',
iconFont: '',
appIcon: '',
appId: '',
label: 'Theme picker',
target: 'style.theme',
detail: 'Style',
@@ -107,6 +109,36 @@ assertDeepEqual(
const defaultItems = menu.parseMenuJsonc(defaultMenuJsonc)
const defaultById = Object.fromEntries(defaultItems.map(item => [item.id, item]))
// Needs the real menu: app rows sort after all menu items, and only at that
// item count does the order tiebreak alone bury an installed app.
const rankBase = menu.mergeMenuSources(defaultItems, [])
const ranked = menu.mergeAppRows(rankBase.items, rankBase.itemOrder, [
{ id: 'apps.brave', parent: 'apps', kind: 'app', label: 'Brave', description: '', aliases: [] },
{ id: 'apps.fontforge', parent: 'apps', kind: 'app', label: 'FontForge', description: '', aliases: [] }
])
const rankScore = (id, query) => menu.searchScore(ranked.items, ranked.items[id], query)
assert(
['install.browser.brave', 'remove.browser.brave', 'setup.default.browser.brave'].every(
id => rankScore('apps.brave', 'brave') < rankScore(id, 'brave')
),
'menu ranks an installed app above menu entries matching the query equally well'
)
assert(
rankScore('style.font', 'font') < rankScore('apps.fontforge', 'font'),
'menu keeps a better-matching menu entry above a weaker app match'
)
const triggerItems = defaultItems.filter(item => item.parent === 'trigger')
assertEqual(
triggerItems[0].id,
'trigger.emoji',
'menu lists Emoji first under Trigger'
)
assertEqual(
defaultById['trigger.emoji'].action,
'omarchy-menu-emoji',
'menu opens the emoji picker from Trigger'
)
assert(
defaultById['update.omarchy'].icon === '\ue900',
'menu update Omarchy entry uses the Omarchy glyph'
@@ -120,7 +152,7 @@ assert(
'menu keeps Input as a direct config action'
)
assert(
defaultById['setup.direct-boot'].action.includes('omarchy-config-direct-boot'),
defaultById['setup.direct-boot'].action.includes('omarchy-setup-direct-boot'),
'menu places Direct Boot directly under Setup'
)
assertEqual(
@@ -159,6 +191,11 @@ assertEqual(
'omarchy-hw-laptop',
'menu only shows Mirror Display on laptops'
)
assertEqual(
defaultById['trigger.capture.screenrecord.webcam'].when,
'omarchy-hw-webcam',
'menu only shows webcam screen recording when a webcam is available'
)
assert(
/font\.family: row\.iconFont\.length > 0 \? row\.iconFont : root\.fontFamily/.test(menuQml),
'menu rows support per-icon font families'
@@ -188,6 +225,88 @@ assert(
/function disarmPointer\(\)[\s\S]*pointerGate\.reset\(\)/.test(menuQml),
'menu resets pointer movement gate when pointer selection is disarmed'
)
// App rows are rebuilt from scratch on every desktop-entry rescan. The merge
// must be idempotent and must never carry an orphan id forward, or a single
// lost write turns into an app listed twice (and thrice, and so on).
const nonAppItems = {
root: { id: 'root', kind: 'menu', label: 'Go' },
apps: { id: 'apps', kind: 'menu', label: 'Apps', provider: 'apps' }
}
const nonAppOrder = ['root', 'apps']
const appRowsFor = ids => ids.map(id => ({ id: `apps.${id}`, kind: 'app', parent: 'apps', label: id, appId: id }))
const firstMerge = menu.mergeAppRows(nonAppItems, nonAppOrder, appRowsFor(['alacritty', 'youtube']))
assert(
firstMerge.itemOrder.join(',') === 'root,apps,apps.alacritty,apps.youtube',
'app merge appends app rows after the static menu items'
)
const secondMerge = menu.mergeAppRows(firstMerge.items, firstMerge.itemOrder, appRowsFor(['alacritty', 'youtube']))
assert(
secondMerge.itemOrder.join(',') === 'root,apps,apps.alacritty,apps.youtube',
'repeating the app merge with the same entries does not duplicate rows'
)
assert(
menu.mergeAppRows(secondMerge.items, secondMerge.itemOrder, appRowsFor(['alacritty'])).itemOrder.join(',')
=== 'root,apps,apps.alacritty',
'app merge drops rows for entries that went away'
)
assert(
menu.mergeAppRows(nonAppItems, nonAppOrder, appRowsFor(['youtube', 'youtube'])).itemOrder.join(',')
=== 'root,apps,apps.youtube',
'app merge lists an app once even when two desktop entries share an id'
)
const orphanedItems = {}
for (const key in firstMerge.items) orphanedItems[key] = firstMerge.items[key]
delete orphanedItems['apps.youtube']
const healed = menu.mergeAppRows(orphanedItems, firstMerge.itemOrder, appRowsFor(['alacritty', 'youtube']))
assert(
healed.itemOrder.join(',') === 'root,apps,apps.alacritty,apps.youtube'
&& !!healed.items['apps.youtube'],
'app merge heals an order entry whose item went missing instead of duplicating it'
)
assert(
!firstMerge.items['apps.youtube'].hasOwnProperty('__probe')
&& (() => {
const before = Object.keys(nonAppItems).length
menu.mergeAppRows(nonAppItems, nonAppOrder, appRowsFor(['gimp']))
return Object.keys(nonAppItems).length === before
})(),
'app merge leaves the map it was handed untouched'
)
const providerRowsFor = values => values.map(value => ({ id: `style.font.${value}`, kind: 'action', parent: 'style.font', label: value }))
const firstProviderMerge = menu.mergeRowsById(nonAppItems, nonAppOrder, providerRowsFor(['mono', 'serif']))
assert(
firstProviderMerge.itemOrder.join(',') === 'root,apps,style.font.mono,style.font.serif',
'provider merge appends its rows'
)
assert(
menu.mergeRowsById(firstProviderMerge.items, firstProviderMerge.itemOrder, providerRowsFor(['mono', 'serif']))
.itemOrder.join(',') === 'root,apps,style.font.mono,style.font.serif',
'repeating a provider merge does not duplicate rows'
)
// The maps live in QML `var` properties, where an in-place write is
// occasionally dropped by the engine, so both merges must hand back fresh
// objects for the caller to assign in one shot.
assert(
/var merged = MenuModel\.mergeAppRows\(root\.items, root\.itemOrder, appRows\)\s*\n\s*root\.items = merged\.items\s*\n\s*root\.itemOrder = merged\.itemOrder/.test(menuQml),
'menu assigns the rebuilt app item map instead of mutating it in place'
)
assert(
/var merged = MenuModel\.mergeRowsById\(root\.items, root\.itemOrder, providerRows\)\s*\n\s*root\.items = merged\.items\s*\n\s*root\.itemOrder = merged\.itemOrder/.test(menuQml),
'menu assigns the rebuilt provider item map instead of mutating it in place'
)
assert(
!/root\.items\[[^\]]+\] =/.test(menuQml) && !/delete root\.items\[/.test(menuQml),
'menu never writes into the item map held by the var property'
)
for (const functionName of ['openExistingMenu', 'openDmenu']) {
const openMatch = menuQml.match(new RegExp(`function ${functionName}\\([^)]*\\) \\{([\\s\\S]*?)\\n \\}`))
assert(openMatch, `menu ${functionName} function exists`)
+6
View File
@@ -33,6 +33,12 @@ bash -c "$command"
SH
chmod +x "$stub_bin/systemd-run"
cat >"$stub_bin/omarchy-notification-wait" <<'SH'
#!/bin/bash
exit 0
SH
chmod +x "$stub_bin/omarchy-notification-wait"
cat >"$stub_bin/omarchy-notification-send" <<'SH'
#!/bin/bash
printf '%s\n' "$@" >"$OMARCHY_TEST_NOTIFY_ARGS"
@@ -65,6 +65,13 @@ local omarchy_monitor_scale = "auto"
LUA
}
write_internal_monitor_config() {
cat >"$monitor_lua" <<'LUA'
hl.monitor({ output = "eDP-1", mode = "preferred", position = "0x0", scale = 1.25 })
hl.monitor({ output = "", mode = "preferred", position = "auto-right", scale = 1 })
LUA
}
run_clamshell() {
HOME="$home_dir" \
PATH="$stub_bin:$PATH" \
@@ -102,3 +109,10 @@ OMARCHY_TEST_INTERNAL_DISABLED=true run_clamshell
grep -F 'scale = 1.6' "$eval_log" >/dev/null || fail "clamshell recovery uses remembered internal scale"
! grep -F 'scale = "auto"' "$eval_log" >/dev/null || fail "clamshell recovery avoids auto after disabled internal display"
pass "clamshell recovery uses remembered internal scale"
write_internal_monitor_config
: >"$eval_log"
OMARCHY_TEST_INTERNAL_DISABLED=true run_clamshell
grep -F 'position = "0x0"' "$eval_log" >/dev/null || fail "clamshell recovery uses configured internal position"
grep -F 'scale = 1.25' "$eval_log" >/dev/null || fail "clamshell recovery uses configured internal scale"
pass "clamshell recovery uses configured internal monitor rule"
+8 -10
View File
@@ -7,12 +7,13 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
monitor_watch="$ROOT/bin/omarchy-hyprland-monitor-watch"
monitor_internal="$ROOT/bin/omarchy-hyprland-monitor-internal"
monitor_mirror="$ROOT/bin/omarchy-hyprland-monitor-internal-mirror"
monitor_laptop="$ROOT/bin/omarchy-hyprland-monitor-laptop"
monitor_external_active="$ROOT/bin/omarchy-hyprland-monitor-external-active"
sleep_lock="$ROOT/bin/omarchy-system-sleep-lock"
system_wake="$ROOT/bin/omarchy-system-wake"
clamshell="$ROOT/bin/omarchy-hyprland-monitor-clamshell"
lock_service="$ROOT/shell/plugins/lock/Service.qml"
hw_clamshell="$ROOT/bin/omarchy-hw-clamshell"
hw_laptop_closed="$ROOT/bin/omarchy-hw-laptop-closed"
utilities="$ROOT/default/hypr/bindings/utilities.lua"
grep -F 'sleep "$delay"' "$monitor_watch" >/dev/null
@@ -35,8 +36,8 @@ grep -F 'sync_poll_state' "$monitor_watch" >/dev/null
grep -F 'done < <(socat' "$monitor_watch" >/dev/null
pass "clamshell poll only runs on a docked laptop, not desktops or undocked laptops"
grep -F '/proc/acpi/button/lid/*/state' "$hw_clamshell" >/dev/null
grep -F 'omarchy-hw-external-monitors' "$hw_clamshell" >/dev/null
grep -F 'omarchy-hw-laptop-closed && omarchy-hw-external-monitors' "$hw_clamshell" >/dev/null
grep -F '/proc/acpi/button/lid/*/state' "$hw_laptop_closed" >/dev/null
pass "clamshell helper detects closed-lid external monitor state"
grep -F 'hyprctl monitors -j' "$monitor_external_active" >/dev/null
@@ -60,7 +61,8 @@ grep -F 'omarchy-hw-clamshell' "$clamshell" >/dev/null
pass "clamshell monitor sync disables laptop output and force-recovers it"
grep -F "hyprctl dispatch 'hl.dsp.dpms({ action = \"enable\" })' >/dev/null 2>&1 || true" "$monitor_internal" >/dev/null
grep -F 'hyprctl monitors all -j' "$monitor_internal" >/dev/null
grep -F 'omarchy-hyprland-monitor-laptop' "$monitor_internal" >/dev/null
grep -F 'hyprctl monitors all -j' "$monitor_laptop" >/dev/null
grep -F 'omarchy-hyprland-monitor-external-active' "$monitor_internal" >/dev/null
grep -F 'wake' "$monitor_internal" >/dev/null
grep -F 'omarchy-hyprland-toggle-enabled $TOGGLE || return 0' "$monitor_internal" >/dev/null
@@ -70,13 +72,9 @@ pass "internal monitor recovery only wakes displays when it re-enables one"
grep -F 'omarchy-hyprland-monitor-external-active' "$monitor_mirror" >/dev/null
pass "internal mirror helper recovers when no active external display remains"
grep -F 'switch:on:Lid Switch", nil, "omarchy-hyprland-monitor-clamshell"' "$utilities" >/dev/null
grep -F 'switch:on:Lid Switch", nil, "omarchy-system-lid-close"' "$utilities" >/dev/null
grep -F 'switch:off:Lid Switch", nil, "omarchy-hyprland-monitor-clamshell"' "$utilities" >/dev/null
pass "lid switch bindings reconcile clamshell display state"
grep -F 'omarchy-hyprland-monitor-clamshell >/dev/null 2>&1 || true' "$sleep_lock" >/dev/null
grep -F '(( attempt % 5 == 0 )) && sync_clamshell' "$sleep_lock" >/dev/null
pass "sleep lock syncs clamshell display state while waiting for secure lock"
pass "lid switch bindings lock on close and reconcile clamshell display state"
grep -F 'omarchy-hyprland-monitor-clamshell >/dev/null 2>&1 || true' "$system_wake" >/dev/null
pass "system wake resyncs clamshell display state"
+34 -6
View File
@@ -74,14 +74,42 @@ scale=$(OMARCHY_TEST_MONITOR_SCALE=3 run_scaling)
[[ $scale == "3" ]] || fail "monitor scaling reports explicit 3x scale" "actual: $scale"
pass "monitor scaling reports explicit 3x scale"
# 1280x800 (QEMU virtio-gpu) can't divide cleanly by 3; expect a snap up to 3.2.
scale=$(OMARCHY_TEST_MONITOR_SCALE=3.2 run_scaling)
[[ $scale == "3.2" ]] || fail "monitor scaling reports the actual non-preset scale" "actual: $scale"
pass "monitor scaling reports the actual non-preset scale"
# 1280x800 approximates the 3x preset as 3.2x.
write_monitor_config
OMARCHY_TEST_MONITOR_SCALE=2 OMARCHY_TEST_MONITOR_WIDTH=1280 OMARCHY_TEST_MONITOR_HEIGHT=800 run_scaling 3
grep -F 'scale = 3.2' "$eval_out" >/dev/null || fail "monitor scaling snaps unclean 3x up to 3.2x"
grep -Fx 'local omarchy_monitor_scale = 3.2' "$monitor_lua" >/dev/null || fail "monitor scaling persists snapped 3.2x"
pass "monitor scaling snaps unclean 3x up to 3.2x"
grep -F 'scale = 3.2' "$eval_out" >/dev/null || fail "monitor scaling approximates explicit 3x as 3.2x"
grep -Fx 'local omarchy_monitor_scale = 3.2' "$monitor_lua" >/dev/null ||
fail "monitor scaling persists approximated 3.2x"
pass "monitor scaling approximates explicit 3x as 3.2x"
write_monitor_config
OMARCHY_TEST_MONITOR_SCALE=2 OMARCHY_TEST_MONITOR_WIDTH=1280 OMARCHY_TEST_MONITOR_HEIGHT=800 run_scaling up
grep -F 'scale = 3.2' "$eval_out" >/dev/null || fail "monitor scaling up snaps unclean preset to 3.2x"
pass "monitor scaling up snaps unclean preset to 3.2x"
grep -F 'scale = 3.2' "$eval_out" >/dev/null || fail "monitor scaling up reaches approximated 3.2x"
pass "monitor scaling up reaches approximated 3.2x"
write_monitor_config
OMARCHY_TEST_MONITOR_SCALE=4 OMARCHY_TEST_MONITOR_WIDTH=1280 OMARCHY_TEST_MONITOR_HEIGHT=800 run_scaling down
grep -F 'scale = 3.2' "$eval_out" >/dev/null || fail "monitor scaling down reaches approximated 3.2x"
pass "monitor scaling down reaches approximated 3.2x"
write_monitor_config
OMARCHY_TEST_MONITOR_SCALE=2 OMARCHY_TEST_MONITOR_WIDTH=6016 OMARCHY_TEST_MONITOR_HEIGHT=3384 run_scaling 1.25
grep -F 'scale = 1.33333' "$eval_out" >/dev/null || fail "monitor scaling approximates explicit 1.25x"
pass "monitor scaling approximates explicit 1.25x"
write_monitor_config
OMARCHY_TEST_MONITOR_SCALE=2 OMARCHY_TEST_MONITOR_WIDTH=1280 OMARCHY_TEST_MONITOR_HEIGHT=800 run_scaling 3.2
grep -F 'scale = 3.2' "$eval_out" >/dev/null || fail "monitor scaling accepts displayed approximate values"
pass "monitor scaling accepts displayed approximate values"
# On a mode where both 3x and 4x resolve to 4x, the duplicate is one step.
write_monitor_config
OMARCHY_TEST_MONITOR_SCALE=4 OMARCHY_TEST_MONITOR_WIDTH=1280 OMARCHY_TEST_MONITOR_HEIGHT=804 run_scaling down
grep -F 'scale = 2' "$eval_out" >/dev/null || fail "monitor scaling down skips duplicate 4x approximation"
grep -Fx 'local omarchy_monitor_scale = 2' "$monitor_lua" >/dev/null ||
fail "monitor scaling down persists 2x after skipping duplicate approximation"
pass "monitor scaling down skips duplicate approximation"
+45 -6
View File
@@ -14,21 +14,60 @@ assertEqual(monitor.clampBrightness('nope'), 1, 'monitor rejects invalid brightn
assertEqual(monitor.normalizeScale('1.250'), '1.25', 'monitor normalizes fractional scale')
assertEqual(monitor.normalizeScale('nope'), '', 'monitor rejects invalid scale')
assertEqual(monitor.cleanScale(3, 1280, 800), '3.2', 'monitor matches clean VM scale')
assertEqual(monitor.cleanScale(1.25, 1280, 800), '1.25', 'monitor preserves an already clean scale')
assertEqual(monitor.cleanScale(1.25, 6016, 3384), '1.33', 'monitor matches clean physical display scale')
assertEqual(monitor.cleanScale(1.6, 0, 800), '', 'monitor rejects a missing display mode')
assertEqual(
monitor.matchingScaleIndex(['1', '1.25', '1.6', '2', '3', '4'], 3.2, 1280, 800),
4,
'monitor selects an approximated VM scale'
)
assertEqual(
monitor.matchingScaleIndex(['1', '1.25', '1.6', '2', '3', '4'], 4, 4, 4),
5,
'monitor selects an exact preset'
)
assertDeepEqual(
monitor.availableScales(['1', '1.25', '1.6', '2', '3', '4'], 1280, 800),
['1', '1.25', '1.6', '2', '3', '4'],
'monitor keeps distinct approximated VM scales'
)
assertDeepEqual(
monitor.availableScales(['1', '1.25', '1.6', '2', '3', '4'], 6016, 3384),
['1', '1.25', '1.6', '2', '3', '4'],
'monitor keeps distinct approximated physical display scales'
)
assertDeepEqual(
monitor.availableScales(['1', '1.25', '1.6', '2', '3', '4'], 1280, 804),
['1', '1.25', '2', '4'],
'monitor collapses presets with duplicate effective scales'
)
assertDeepEqual(
monitor.availableScales(['1', '1.25', '1.6', '2', '3', '4'], 5968, 3230),
['1', '2'],
'monitor hides presets the current mode cannot reach'
)
assertDeepEqual(
monitor.availableScales(['1', '1.25', '1.6', '2', '3', '4'], 0, 0),
['1', '1.25', '1.6', '2', '3', '4'],
'monitor keeps presets until display dimensions are known'
)
assertEqual(monitor.brightnessName(96), 'Sun blast', 'monitor names very bright displays')
assertEqual(monitor.brightnessName(12), 'Candlelit', 'monitor names dim displays')
assertDeepEqual(
monitor.parseDisplays(JSON.stringify([
{ name: 'eDP-1', enabled: true },
{ name: 'HDMI-A-1', enabled: false },
{ name: 'DP-1', enabled: true }
{ name: 'eDP-1', enabled: true, focused: false, width: 1920, height: 1080 },
{ name: 'HDMI-A-1', enabled: false, focused: false, width: 0, height: 0 },
{ name: 'DP-1', enabled: true, focused: true, width: 1280, height: 800 }
])),
{
displays: [
{ name: 'eDP-1', enabled: true },
{ name: 'HDMI-A-1', enabled: false },
{ name: 'DP-1', enabled: true }
{ name: 'eDP-1', enabled: true, focused: false, width: 1920, height: 1080 },
{ name: 'HDMI-A-1', enabled: false, focused: false, width: 0, height: 0 },
{ name: 'DP-1', enabled: true, focused: true, width: 1280, height: 800 }
],
enabledDisplayCount: 2
},
-5
View File
@@ -188,7 +188,6 @@ assertEqual(notifications.imageExtension('/tmp/no-extension'), 'png', 'notificat
assertEqual(notifications.imageExtension('/tmp/archive.reallylong'), 'png', 'notifications reject suspicious image extensions')
const serviceQml = fs.readFileSync(path.join(root, 'shell/plugins/notifications/Service.qml'), 'utf8')
const barWidgetQml = fs.readFileSync(path.join(root, 'shell/plugins/notifications/BarWidget.qml'), 'utf8')
assert(
/readonly property int historyReplayLimit: 5/.test(serviceQml),
'notifications service limits history replay to five rows'
@@ -197,8 +196,4 @@ assert(
/function showHistory\(\): string \{\s*return service\.showRecentHistory\(\)\s*\}/.test(serviceQml),
'notifications history IPC replays recent notifications'
)
assert(
!barWidgetQml.includes('historyOpenRequested'),
'notifications history IPC does not depend on the bar widget'
)
JS
+3 -19
View File
@@ -11,6 +11,7 @@ assertEqual(osd.iconFor('', 0), osd.iconFor('muted', 50), 'osd falls back to mut
assertEqual(osd.iconFor('volume-high', 1), osd.iconFor('', 100), 'osd maps high volume aliases')
assertEqual(osd.iconFor('logout', 50), '󰍃', 'osd maps logout icon')
assertEqual(osd.iconFor('custom-symbol', 50), 'custom-symbol', 'osd preserves unknown explicit icons')
assertEqual(osd.widestIcon, osd.iconFor('volume-high', 100), 'osd sizes the icon column to a glyph it can show')
assertDeepEqual(
osd.stateForShow('volume', '', '75', '100', '', '800'),
@@ -21,8 +22,7 @@ assertDeepEqual(
value: 75,
message: '75%',
icon: osd.iconFor('volume', 75),
duration: 800,
fit: false
duration: 800
},
'osd builds progress state'
)
@@ -36,24 +36,8 @@ assertDeepEqual(
value: 0,
message: 'Paused',
icon: osd.iconFor('media-pause', -1),
duration: 1200,
fit: false
duration: 1200
},
'osd builds message state'
)
assertDeepEqual(
osd.stateForShow('shutdown', 'Shutting down…', '', '100', '', '5000', '1'),
{
iconKey: 'shutdown',
maxValue: 100,
hasProgress: false,
value: 0,
message: 'Shutting down…',
icon: osd.iconFor('shutdown', -1),
duration: 5000,
fit: true
},
'osd parses fit flag'
)
JS
+13 -5
View File
@@ -23,19 +23,27 @@ assertEqual(
)
assert(
polkit.fingerprintFirstFromPamConfig(`
polkit.fingerprintConfiguredFromPamConfig(`
# comment
auth sufficient pam_fprintd.so
auth include system-auth
`),
'polkit detects fingerprint-first PAM config'
'polkit detects fingerprint in a PAM config'
)
assert(
!polkit.fingerprintFirstFromPamConfig(`
polkit.fingerprintConfiguredFromPamConfig(`
auth [success=1 default=ignore] pam_exec.so quiet /usr/bin/omarchy-hw-laptop-closed
auth sufficient pam_fprintd.so
auth required pam_unix.so
`),
'polkit detects fingerprint even behind a clamshell gate'
)
assert(
!polkit.fingerprintConfiguredFromPamConfig(`
account include system-auth
auth include system-auth
auth sufficient pam_fprintd.so
auth required pam_unix.so
`),
'polkit detects password-first PAM config'
'polkit reports no fingerprint when pam_fprintd is absent'
)
JS
+28 -6
View File
@@ -70,7 +70,9 @@ printf '%s\n' "$*" >>"$OMARCHY_TEST_IPC_LOG"
case "$*" in
*'shell ping')
grep -Fx '303' "$OMARCHY_TEST_QS_STATE" >/dev/null && printf 'ok\n'
[[ $* == *"-p $OMARCHY_TEST_SESSION_PATH/shell"* ]] &&
grep -Fx '303' "$OMARCHY_TEST_QS_STATE" >/dev/null &&
printf 'ok\n'
;;
esac
SH
@@ -107,14 +109,27 @@ if [[ ${1:-} == "-j" && ${2:-} == "monitors" ]]; then
fi
elif [[ ${1:-} == "dispatch" && ${2:-} == hl.dsp.exec_cmd* ]]; then
printf '%s\n' "${2:-}" >>"$OMARCHY_TEST_DISPATCH_LOG"
env -u OMARCHY_TEST_TRANSIENT_ENV quickshell -n -p "$OMARCHY_PATH/shell"
OMARCHY_PATH="$OMARCHY_TEST_SESSION_PATH" \
env -u OMARCHY_TEST_TRANSIENT_ENV quickshell -n -p "$OMARCHY_TEST_SESSION_PATH/shell"
printf 'ok\n'
elif [[ ${1:-} == "dispatch" ]]; then
exit 1
fi
SH
chmod +x "$restart_bin/qs" "$restart_bin/quickshell" "$restart_bin/hyprctl"
cat >"$restart_bin/systemctl" <<'SH'
#!/bin/bash
if [[ ${1:-} == "--user" && ${2:-} == "show-environment" ]]; then
printf 'OMARCHY_PATH=%s\n' "$OMARCHY_TEST_SESSION_PATH"
elif [[ ${1:-} == "--user" && ${2:-} == "try-restart" ]]; then
exit 0
else
exit 1
fi
SH
chmod +x "$restart_bin/qs" "$restart_bin/quickshell" "$restart_bin/hyprctl" "$restart_bin/systemctl"
sleep 30 &
restart_pid_one=$!
@@ -122,14 +137,19 @@ sleep 30 &
restart_pid_two=$!
printf '%s\n%s\n' "$restart_pid_one" "$restart_pid_two" >"$restart_state"
caller_root="$test_tmp/caller-root"
mkdir -p "$caller_root/shell"
touch "$caller_root/shell/shell.qml"
PATH="$restart_bin:$PATH" \
OMARCHY_PATH="$restart_root" \
OMARCHY_PATH="$caller_root" \
XDG_RUNTIME_DIR="$runtime_dir" \
OMARCHY_TEST_QS_STATE="$restart_state" \
OMARCHY_TEST_QS_LOG="$restart_log" \
OMARCHY_TEST_QS_ENV_LOG="$restart_env_log" \
OMARCHY_TEST_DISPATCH_LOG="$dispatch_log" \
OMARCHY_TEST_IPC_LOG="$ipc_log" \
OMARCHY_TEST_SESSION_PATH="$restart_root" \
OMARCHY_TEST_TRANSIENT_ENV=leaked \
timeout 5 "$ROOT/bin/omarchy-restart-shell"
@@ -145,10 +165,11 @@ restart_pid_one=""
restart_pid_two=""
[[ $(<"$restart_state") == 303 ]] || fail "restart leaves exactly one fresh shell instance"
[[ $(grep -c '^-n -p ' "$restart_log") == 1 ]] || fail "restart launches one fresh shell process"
grep -F "kill -p $restart_root/shell --any-display" "$restart_log" >/dev/null || fail "restart stops the shell from the session checkout"
[[ $(<"$restart_env_log") == "unset" ]] || fail "restart uses the Hyprland session environment for the fresh shell"
grep -F 'hl.dsp.exec_cmd("quickshell -n -p $OMARCHY_PATH/shell")' "$dispatch_log" >/dev/null || fail "restart launches the fresh shell through Hyprland"
grep -F 'shell ping' "$ipc_log" >/dev/null || fail "restart waits for fresh shell IPC readiness"
pass "restart replaces duplicate shell instances"
grep -F "ipc -n -p $restart_root/shell call -- shell ping" "$ipc_log" >/dev/null || fail "restart checks readiness in the session checkout"
pass "restart replaces duplicate shell instances from the session checkout"
: >"$restart_log"
printf '404\n' >"$restart_state"
@@ -161,6 +182,7 @@ locked_error=$(PATH="$restart_bin:$PATH" \
OMARCHY_TEST_QS_LOG="$restart_log" \
OMARCHY_TEST_DISPATCH_LOG="$dispatch_log" \
OMARCHY_TEST_IPC_LOG="$ipc_log" \
OMARCHY_TEST_SESSION_PATH="$restart_root" \
"$ROOT/bin/omarchy-restart-shell" 2>&1) && fail "restart refuses while the shell lock is active"
[[ $locked_error == "Refusing to restart Omarchy shell while the session is locked." ]] || fail "locked restart explains why it was refused" "$locked_error"
-11
View File
@@ -5,7 +5,6 @@ run_node_test <<'JS'
const fs = require('fs')
const menuQml = fs.readFileSync(path.join(root, 'shell/plugins/menu/Menu.qml'), 'utf8')
const launcherQml = fs.readFileSync(path.join(root, 'shell/plugins/launcher/Launcher.qml'), 'utf8')
assert(
/rowReservedBorderLeft:\s*Border\.left\(selectedBorderSpec\)/.test(menuQml)
@@ -18,14 +17,4 @@ assert(
'Menu row content does not depend on current selected border state'
)
assert(
/rowReservedBorderLeft:\s*Border\.left\(selectedBorderSpec\)/.test(launcherQml)
&& /rowReservedBorderRight:\s*Border\.right\(selectedBorderSpec\)/.test(launcherQml),
'Launcher rows reserve selected border insets'
)
assert(
!/anchors\.(left|right)Margin:[^\n]*\brow\.border(Left|Right)\b/.test(launcherQml),
'Launcher row content does not depend on current selected border state'
)
JS
+2 -2
View File
@@ -127,8 +127,8 @@ jq -e '
}
pass "shell IPC returns effective shell config"
[[ $(shell_ipc shell summon omarchy.launcher '{"query":"term"}') == "ok" ]] || fail_with_log "shell IPC summons launcher overlay"
shell_ipc_quiet shell hide omarchy.launcher >/dev/null
[[ $(shell_ipc shell summon omarchy.menu '{"menu":"apps"}') == "ok" ]] || fail_with_log "shell IPC summons menu apps overlay"
shell_ipc_quiet shell hide omarchy.menu >/dev/null
[[ $(shell_ipc shell summon missing.plugin "{}") == "unknown" ]] || fail_with_log "shell IPC rejects unknown plugin"
pass "shell IPC summon and hide contract works"
+69
View File
@@ -13,6 +13,8 @@ mkdir -p "$stub_bin"
cat >"$stub_bin/v4l2-ctl" <<'SH'
#!/bin/bash
[[ ${OMARCHY_TEST_NO_WEBCAM:-false} == "true" ]] && exit 0
printf '%s\n' "Built-in Webcam: Integrated Camera"
printf '\t%s\n' "/dev/video0"
printf '\t%s\n' "/dev/video1"
@@ -43,10 +45,24 @@ SH
chmod +x "$stub_bin"/*
export PATH="$stub_bin:$ROOT/bin:$PATH"
# The resize helper anchors to a region file here, so keep it out of the real one
export XDG_RUNTIME_DIR="$tmp_dir"
export OMARCHY_TEST_MENU_ARGS="$tmp_dir/menu-args"
export OMARCHY_TEST_RECORDER_ARGS="$tmp_dir/recorder-args"
export OMARCHY_TEST_NOTIFICATION_ARGS="$tmp_dir/notification-args"
if "$ROOT/bin/omarchy-hw-webcam"; then
pass "webcam hardware detection succeeds when a video device is available"
else
fail "webcam hardware detection succeeds when a video device is available"
fi
if OMARCHY_TEST_NO_WEBCAM=true "$ROOT/bin/omarchy-hw-webcam"; then
fail "webcam hardware detection fails when no video device is available"
else
pass "webcam hardware detection fails when no video device is available"
fi
"$ROOT/bin/omarchy-capture-screenrecording-with-webcam"
expected_menu_args="$tmp_dir/expected-menu-args"
@@ -139,6 +155,59 @@ if [[ -s $OMARCHY_TEST_HYPRCTL_ARGS ]]; then
fi
pass "webcam resize ignores other windows"
region_file="$XDG_RUNTIME_DIR/omarchy-screenrecord-region"
: >"$OMARCHY_TEST_HYPRCTL_ARGS"
echo "800x600+100+100" >"$region_file"
"$ROOT/bin/omarchy-capture-webcam-resize" reset
printf '%s\n' \
'dispatch hl.dsp.window.resize({ window = "address:0xabc", x = 133, y = 150 })' \
'dispatch hl.dsp.window.move({ window = "address:0xabc", x = 727, y = 510 })' >"$expected_hyprctl_args"
if ! cmp -s "$OMARCHY_TEST_HYPRCTL_ARGS" "$expected_hyprctl_args"; then
fail "webcam anchors to the recorded region" "$(diff -u "$expected_hyprctl_args" "$OMARCHY_TEST_HYPRCTL_ARGS")"
fi
pass "webcam anchors to the recorded region"
printf '%s\n' \
'dispatch hl.dsp.window.resize({ window = "address:0xabc", x = 178, y = 200 })' \
'dispatch hl.dsp.window.move({ window = "address:0xabc", x = 2342, y = 460 })' >"$expected_hyprctl_args"
for region in "not-a-region" ""; do
: >"$OMARCHY_TEST_HYPRCTL_ARGS"
printf '%s' "$region" >"$region_file"
"$ROOT/bin/omarchy-capture-webcam-resize" reset
if ! cmp -s "$OMARCHY_TEST_HYPRCTL_ARGS" "$expected_hyprctl_args"; then
fail "webcam falls back to the monitor for an unusable region" "$(diff -u "$expected_hyprctl_args" "$OMARCHY_TEST_HYPRCTL_ARGS")"
fi
done
pass "webcam falls back to the monitor for an unusable region"
# A region too narrow for presets scaled from its height shrinks the whole
# ladder, so the three sizes stay distinct and each one fits inside the margins
: >"$OMARCHY_TEST_HYPRCTL_ARGS"
echo "200x1200+0+0" >"$region_file"
for size in small medium large; do
"$ROOT/bin/omarchy-capture-webcam-resize" "$size"
done
printf '%s\n' \
'dispatch hl.dsp.window.resize({ window = "address:0xabc", x = 64, y = 72 })' \
'dispatch hl.dsp.window.move({ window = "address:0xabc", x = 96, y = 1088 })' \
'dispatch hl.dsp.window.resize({ window = "address:0xabc", x = 89, y = 100 })' \
'dispatch hl.dsp.window.move({ window = "address:0xabc", x = 71, y = 1060 })' \
'dispatch hl.dsp.window.resize({ window = "address:0xabc", x = 120, y = 135 })' \
'dispatch hl.dsp.window.move({ window = "address:0xabc", x = 40, y = 1025 })' >"$expected_hyprctl_args"
if ! cmp -s "$OMARCHY_TEST_HYPRCTL_ARGS" "$expected_hyprctl_args"; then
fail "webcam sizes stay distinct and inside a narrow region" "$(diff -u "$expected_hyprctl_args" "$OMARCHY_TEST_HYPRCTL_ARGS")"
fi
pass "webcam sizes stay distinct and inside a narrow region"
rm -f "$region_file"
grep -F 'o.bind("SUPER + ALT + code:34", "Make webcam overlay smaller", "omarchy-capture-webcam-resize smaller")' \
"$ROOT/default/hypr/bindings/utilities.lua" >/dev/null || fail "webcam smaller hotkey is configured"
grep -F 'o.bind("SUPER + ALT + code:35", "Make webcam overlay larger", "omarchy-capture-webcam-resize larger")' \
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
set -euo pipefail
source "$(dirname "$0")/base-test.sh"
test_dir=$(mktemp -d)
trap 'rm -rf "$test_dir"' EXIT
mkdir -p "$test_dir/bin" "$test_dir/run"
touch "$test_dir/run/wayland-1" "$test_dir/run/wayland-1.lock"
cat >"$test_dir/bin/qs" <<'STUB'
#!/bin/bash
echo "display=[$WAYLAND_DISPLAY]"
STUB
chmod +x "$test_dir/bin/qs"
export PATH="$test_dir/bin:$PATH"
export OMARCHY_PATH="$ROOT"
export XDG_RUNTIME_DIR="$test_dir/run"
# tmux hooks run without WAYLAND_DISPLAY, and qs matches instances by display.
output=$(env -u WAYLAND_DISPLAY "$ROOT/bin/omarchy-shell" omarchy.indicators refresh)
[[ $output == "display=[wayland-1]" ]] || fail "shell ipc recovers a missing display" "$output"
pass "shell ipc recovers a missing display"
output=$(WAYLAND_DISPLAY=wayland-9 "$ROOT/bin/omarchy-shell" omarchy.indicators refresh)
[[ $output == "display=[wayland-9]" ]] || fail "shell ipc keeps an existing display" "$output"
pass "shell ipc keeps an existing display"
+336
View File
@@ -0,0 +1,336 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
sleep_lock="$ROOT/bin/omarchy-system-sleep-lock"
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
# Each scenario gets its own mock PATH and call log, then runs the sleep lock
# with a short budget so a stalled shell cannot slow the suite down.
setup_scenario() {
scenario_dir="$tmpdir/$1"
mock_bin="$scenario_dir/bin"
call_log="$scenario_dir/calls"
state_dir="$scenario_dir/state"
notify_log="$scenario_dir/notifications"
journal_log="$scenario_dir/journal"
mkdir -p "$mock_bin" "$state_dir"
: >"$notify_log"
: >"$journal_log"
# The budget is derived from logind, so pin the window rather than letting the
# host's own configuration decide what these scenarios are testing.
mock_logind_window 5000000
# Capture the desktop warning instead of firing a real one at whoever is
# running the suite.
cat >"$mock_bin/omarchy-notification-send" <<SH
#!/bin/bash
printf '%s\n' "\$*" >>"$notify_log"
SH
chmod +x "$mock_bin/omarchy-notification-send"
}
mock_logind_window() {
cat >"$mock_bin/busctl" <<SH
#!/bin/bash
printf 't %s\n' $1
SH
chmod +x "$mock_bin/busctl"
}
mock_clamshell() {
cat >"$mock_bin/omarchy-hyprland-monitor-clamshell" <<SH
#!/bin/bash
echo clamshell >>"\$CALL_LOG"
sleep ${1:-0}
SH
chmod +x "$mock_bin/omarchy-hyprland-monitor-clamshell"
}
# Called with no budget to exercise the value derived from logind's window.
run_sleep_lock() {
local args=()
[[ -n ${1:-} ]] && args=("$1")
start_us=${EPOCHREALTIME//[!0-9]/}
set +e
CALL_LOG="$call_log" STATE_DIR="$state_dir" PATH="$mock_bin:$PATH" \
"$sleep_lock" "${args[@]}" 2>"$journal_log"
exit_status=$?
set -e
elapsed_us=$((10#${EPOCHREALTIME//[!0-9]/} - 10#$start_us))
mapfile -t calls <"$call_log"
}
# A responsive shell locks immediately, even when the clamshell sync stalls.
setup_scenario responsive
cat >"$mock_bin/omarchy-shell" <<'SH'
#!/bin/bash
printf 'shell %s\n' "$*" >>"$CALL_LOG"
if [[ $* == "lock lock" ]]; then
printf 'ok\n'
elif [[ $* == "lock status" ]]; then
printf '{"secure":true}\n'
fi
SH
chmod +x "$mock_bin/omarchy-shell"
mock_clamshell 2
run_sleep_lock 4000
(( exit_status == 0 )) ||
fail "sleep lock succeeds once the session reports secure" "exit: $exit_status"
pass "sleep lock succeeds once the session reports secure"
[[ ${calls[0]} == "shell lock lock" ]] ||
fail "sleep lock requests the session lock first" "first call: ${calls[0]}"
pass "sleep lock requests the session lock first"
[[ ${calls[1]} == "clamshell" && ${calls[2]} == "shell lock status" ]] ||
fail "sleep lock checks security after clamshell reconciliation"
pass "sleep lock checks security after clamshell reconciliation"
(( elapsed_us < 1500000 )) ||
fail "sleep lock bounds a stalled clamshell sync" "elapsed: ${elapsed_us}us"
pass "sleep lock bounds a stalled clamshell sync"
# A shell that never secures the session must give up inside the budget rather
# than hold logind's delay inhibitor open.
setup_scenario never_secure
cat >"$mock_bin/omarchy-shell" <<'SH'
#!/bin/bash
printf 'shell %s\n' "$*" >>"$CALL_LOG"
if [[ $* == "lock lock" ]]; then
printf 'ok\n'
elif [[ $* == "lock status" ]]; then
printf '{"secure":false}\n'
fi
SH
chmod +x "$mock_bin/omarchy-shell"
mock_clamshell
run_sleep_lock 1500
(( exit_status != 0 )) ||
fail "sleep lock reports failure when the session never secures"
pass "sleep lock reports failure when the session never secures"
# The contract is the budget plus at most one poll interval, since the pause
# between polls is not itself clipped. Derived budgets hold back a full second
# for logind, so that overshoot is always well inside the reserve.
(( elapsed_us <= 1600000 )) ||
fail "sleep lock gives up within its budget" "elapsed: ${elapsed_us}us"
pass "sleep lock gives up within its budget"
polls=0
for call in "${calls[@]}"; do
[[ $call == "shell lock status" ]] && (( ++polls ))
done
(( polls > 1 )) ||
fail "sleep lock keeps polling until the deadline" "polls: $polls"
pass "sleep lock keeps polling until the deadline"
# A lock request that times out may never have landed, so the wait retries it
# instead of suspending an unlocked session over one slow IPC call.
setup_scenario retry_lock
cat >"$mock_bin/omarchy-shell" <<'SH'
#!/bin/bash
printf 'shell %s\n' "$*" >>"$CALL_LOG"
if [[ $* == "lock lock" ]]; then
if [[ -f $STATE_DIR/requested ]]; then
touch "$STATE_DIR/locked"
printf 'ok\n'
exit 0
fi
touch "$STATE_DIR/requested"
exit 1
fi
if [[ $* == "lock status" ]]; then
if [[ -f $STATE_DIR/locked ]]; then
printf '{"secure":true}\n'
else
printf '{"secure":false}\n'
fi
fi
SH
chmod +x "$mock_bin/omarchy-shell"
mock_clamshell
run_sleep_lock 4000
(( exit_status == 0 )) ||
fail "sleep lock retries a failed lock request" "exit: $exit_status"
pass "sleep lock retries a failed lock request"
requests=0
for call in "${calls[@]}"; do
[[ $call == "shell lock lock" ]] && (( ++requests ))
done
(( requests == 2 )) ||
fail "sleep lock stops requesting once the lock lands" "requests: $requests"
pass "sleep lock stops requesting once the lock lands"
# A request can land even when its IPC response times out. Pending status proves
# that Quickshell is already securing the session, so do not spend the remaining
# inhibitor budget sending the same request again.
setup_scenario pending_lock
cat >"$mock_bin/omarchy-shell" <<'SH'
#!/bin/bash
printf 'shell %s\n' "$*" >>"$CALL_LOG"
if [[ $* == "lock lock" ]]; then
exit 1
fi
if [[ $* == "lock status" ]]; then
if [[ -f $STATE_DIR/pending_seen ]]; then
printf '{"secure":true}\n'
else
touch "$STATE_DIR/pending_seen"
printf '{"secure":false,"requested":true,"pending":true,"sessionLocked":false}\n'
fi
fi
SH
chmod +x "$mock_bin/omarchy-shell"
mock_clamshell
run_sleep_lock 4000
(( exit_status == 0 )) ||
fail "sleep lock succeeds after observing a pending lock" "exit: $exit_status"
pass "sleep lock succeeds after observing a pending lock"
requests=0
for call in "${calls[@]}"; do
[[ $call == "shell lock lock" ]] && (( ++requests ))
done
(( requests == 1 )) ||
fail "sleep lock does not retry an observed pending lock" "requests: $requests"
pass "sleep lock does not retry an observed pending lock"
# The shell reports a refusal on stdout with a zero exit, so a lock it can never
# perform has to end the wait instead of burning the rest of the window on it.
setup_scenario missing_pam
cat >"$mock_bin/omarchy-shell" <<'SH'
#!/bin/bash
printf 'shell %s\n' "$*" >>"$CALL_LOG"
if [[ $* == "lock lock" ]]; then
printf 'missing-pam\n'
fi
exit 0
SH
chmod +x "$mock_bin/omarchy-shell"
mock_clamshell
run_sleep_lock 4000
(( exit_status != 0 )) ||
fail "sleep lock fails fast when the shell cannot lock at all"
(( elapsed_us < 500000 )) ||
fail "sleep lock fails fast when the shell cannot lock at all" "elapsed: ${elapsed_us}us"
pass "sleep lock fails fast when the shell cannot lock at all"
[[ ${calls[*]} != *"lock status"* ]] ||
fail "sleep lock stops polling a shell that refused to lock" "calls: ${calls[*]}"
pass "sleep lock stops polling a shell that refused to lock"
# logind suspends regardless of this exit status, so an unlocked suspend is
# otherwise invisible. The warning is the only trace the user ever sees, and the
# journal line is what makes it diagnosable after the fact.
grep -qF "did not lock before suspend" "$notify_log" ||
fail "sleep lock warns that the session was left unlocked" \
"notifications: $(< "$notify_log")"
pass "sleep lock warns that the session was left unlocked"
grep -qF "suspending without a secure lock" "$journal_log" ||
fail "sleep lock records the unlocked suspend in the journal" \
"journal: $(< "$journal_log")"
pass "sleep lock records the unlocked suspend in the journal"
# A never-securing shell is the scenario that runs out the whole budget, so it
# is also the one that shows which budget was derived.
never_secures() {
cat >"$mock_bin/omarchy-shell" <<'SH'
#!/bin/bash
printf 'shell %s\n' "$*" >>"$CALL_LOG"
if [[ $* == "lock lock" ]]; then
printf 'ok\n'
elif [[ $* == "lock status" ]]; then
printf '{"secure":false,"requested":true,"pending":true,"sessionLocked":false}\n'
fi
SH
chmod +x "$mock_bin/omarchy-shell"
mock_clamshell
}
# The drop-in only counts once logind has reloaded it, and a machine can carry
# its own override, so the budget follows whatever logind actually enforces.
setup_scenario derived_short_window
mock_logind_window 2000000
never_secures
run_sleep_lock
(( elapsed_us <= 1300000 )) ||
fail "sleep lock derives its budget from logind's window" "elapsed: ${elapsed_us}us"
pass "sleep lock derives its budget from logind's window"
# Without a readable window there is no way to know what logind will tolerate,
# so fall back to the budget that was safe before the drop-in existed.
setup_scenario unreadable_window
cat >"$mock_bin/busctl" <<'SH'
#!/bin/bash
exit 1
SH
chmod +x "$mock_bin/busctl"
never_secures
run_sleep_lock
(( elapsed_us > 1300000 && elapsed_us <= 4300000 )) ||
fail "sleep lock falls back to a conservative budget" "elapsed: ${elapsed_us}us"
pass "sleep lock falls back to a conservative budget when logind cannot be read"
# A hand-raised window must not strand a closed laptop awake in a bag. This
# scenario runs for the whole capped budget by design.
setup_scenario capped_window
mock_logind_window 600000000
never_secures
run_sleep_lock
(( exit_status != 0 )) ||
fail "sleep lock caps the budget a huge logind window would allow"
(( elapsed_us <= 12500000 )) ||
fail "sleep lock caps the budget a huge logind window would allow" \
"elapsed: ${elapsed_us}us"
pass "sleep lock caps the budget a huge logind window would allow"
# The cap is only reachable because the shipped drop-in widens logind's window
# past it. Ship one without the other and the cap is dead weight.
inhibit_delay=$(sed -n 's/^InhibitDelayMaxSec=//p' "$ROOT/etc/systemd/logind.conf.d/20-inhibit-delay.conf")
budget_cap_ms=$(sed -n 's/^budget_cap_ms=//p' "$sleep_lock")
[[ -n $inhibit_delay && -n $budget_cap_ms ]] ||
fail "sleep lock cap and logind window are both declared" \
"window: ${inhibit_delay:-unset} cap: ${budget_cap_ms:-unset}"
(( budget_cap_ms < inhibit_delay * 1000 )) ||
fail "sleep lock cap leaves logind room to act" \
"cap: ${budget_cap_ms}ms window: ${inhibit_delay}s"
pass "sleep lock cap stays inside the shipped logind inhibitor window"
+14 -7
View File
@@ -28,12 +28,19 @@ grep -F 'ExecStart=/usr/bin/omarchy-system-sleep-monitor' "$upgrade_to_quattro"
grep -F 'reset-failed omarchy-sleep-lock.service' "$upgrade_to_quattro" >/dev/null
pass "Omarchy 4 upgrade repairs the legacy sleep lock unit path"
notify_path="$ROOT/default/systemd/user/omarchy-update-user-notify.path"
! grep -q 'PathExistsGlob' "$notify_path"
grep -Fx 'PathModified=/usr/share/omarchy/migrations' "$notify_path" >/dev/null
pass "migration watcher is edge-triggered so applied migrations on disk cannot re-trigger it"
[[ -e $ROOT/default/systemd/user/omarchy-update-user-notify.path ]] &&
fail "the retired migration watcher is back; pacman writing the migration directory during omarchy update would notify about migrations that update is already applying"
grep -rlE '^(Path[A-Za-z]+|DirectoryNotEmpty)=.*/usr/share/omarchy/migrations' "$ROOT/default/systemd/user" >/dev/null 2>&1 &&
fail "a user unit watches the migration directory again; the notifier must stay login-only"
pass "no unit watches the migration directory, so package updates cannot trigger the notifier"
notify_service="$ROOT/default/systemd/user/omarchy-update-user-notify.service"
! grep -q 'StartLimit' "$notify_service"
notify_service="$ROOT/default/systemd/user/omarchy-migrate-notify.service"
grep -Fx 'ExecStart=/usr/bin/omarchy-migrate-notify' "$notify_service" >/dev/null
grep -Fx 'WantedBy=graphical-session.target' "$notify_service" >/dev/null
pass "migration notifier keeps its start-rate limit and still runs once per login"
pass "migration notifier only checks once per login"
grep -F 'omarchy-migrate-notify.service' "$first_run_units" >/dev/null ||
fail "first-run does not enable the login migration notifier"
grep -F 'omarchy-update-user-notify' "$first_run_units" >/dev/null &&
fail "first-run still enables the retired notifier units"
pass "first-run enables the login-only migration notifier"
+117
View File
@@ -0,0 +1,117 @@
#!/bin/bash
set -euo pipefail
source "$(dirname "$0")/base-test.sh"
require_command jq
run_node_test <<'JS'
const tmux = requireFromRoot('shell/plugins/services/tmux/TmuxModel.js')
assertEqual(tmux.waitingFromOutput('{"count":2,"tooltip":"claude (Work:2)"}').count, 2, 'tmux model reads the waiting count')
assertEqual(tmux.waitingFromOutput('{"count":2,"tooltip":"claude (Work:2)"}').tooltip, 'claude (Work:2)', 'tmux model reads the tooltip')
assertEqual(tmux.waitingFromOutput('shell noise\n{"count":1,"tooltip":"editor (Work:1)"}').count, 1, 'tmux model ignores output before the json line')
assertEqual(tmux.waitingFromOutput('').count, 0, 'tmux model treats empty output as nothing waiting')
assertEqual(tmux.waitingFromOutput('not json').count, 0, 'tmux model treats unparseable output as nothing waiting')
assertEqual(tmux.waitingFromOutput('not json').tooltip, '', 'tmux model blanks the tooltip on unparseable output')
assertEqual(tmux.waitingFromOutput('{"count":-3}').count, 0, 'tmux model clamps a negative count')
assertEqual(tmux.waitingFromOutput('{"tooltip":"editor (Work:1)"}').count, 0, 'tmux model defaults a missing count')
JS
test_dir=$(mktemp -d)
trap 'rm -rf "$test_dir"' EXIT
cat >"$test_dir/tmux" <<'STUB'
#!/bin/bash
if [[ $1 == "list-windows" ]]; then
cat "$TMUX_STUB_WINDOWS"
elif [[ $1 == "list-clients" ]]; then
cat "$TMUX_STUB_CLIENTS"
elif [[ $1 == "set-option" ]]; then
printf '%s\n' "$*" >>"$TMUX_STUB_CALLS"
fi
STUB
chmod +x "$test_dir/tmux"
cat >"$test_dir/omarchy-shell" <<'STUB'
#!/bin/bash
printf '%s\n' "$*" >>"$TMUX_STUB_CALLS"
STUB
chmod +x "$test_dir/omarchy-shell"
export TMUX_STUB_WINDOWS="$test_dir/windows"
export TMUX_STUB_CLIENTS="$test_dir/clients"
export TMUX_STUB_CALLS="$test_dir/calls"
export PATH="$test_dir:$PATH"
cat >"$TMUX_STUB_WINDOWS" <<'WINDOWS'
@1:000:100::Work:1:editor
@2:100:110::Work:2:claude
@3:001:120::Side|Gig:1:server: still going
WINDOWS
: >"$TMUX_STUB_CLIENTS"
output=$("$ROOT/bin/omarchy-tmux-alert" show --json)
expected='{"count":2,"tooltip":"claude (Work:2), server: still going (Side|Gig:1)"}'
[[ $output == "$expected" ]] || fail "tmux alert reports alerted windows" "expected: $expected"$'\n'"actual: $output"
pass "tmux alert reports alerted windows"
output=$("$ROOT/bin/omarchy-tmux-alert" show)
[[ $output == "claude (Work:2), server: still going (Side|Gig:1)" ]] || fail "tmux alert describes alerted windows" "$output"
pass "tmux alert describes alerted windows"
cat >"$TMUX_STUB_WINDOWS" <<'WINDOWS'
@1:000:200:100:Work:1:editor
WINDOWS
echo 'attached,UTF-8:@1' >"$TMUX_STUB_CLIENTS"
output=$("$ROOT/bin/omarchy-tmux-alert" show --json)
expected='{"count":1,"tooltip":"editor (Work:1)"}'
[[ $output == "$expected" ]] || fail "tmux alert reports output in an unfocused active window" "expected: $expected"$'\n'"actual: $output"
pass "tmux alert reports output in an unfocused active window"
echo 'attached,focused,UTF-8:@1' >"$TMUX_STUB_CLIENTS"
output=$("$ROOT/bin/omarchy-tmux-alert" show --json)
[[ $output == '{"count":0,"tooltip":""}' ]] || fail "tmux alert ignores output in a focused active window" "$output"
pass "tmux alert ignores output in a focused active window"
cat >"$TMUX_STUB_CLIENTS" <<'CLIENTS'
attached,UTF-8:@1
attached,focused,UTF-8:@1
CLIENTS
output=$("$ROOT/bin/omarchy-tmux-alert" show --json)
[[ $output == '{"count":0,"tooltip":""}' ]] || fail "tmux alert ignores a window visible in another focused client" "$output"
pass "tmux alert ignores a window visible in another focused client"
echo 'attached,UTF-8:@1' >"$TMUX_STUB_CLIENTS"
cat >"$TMUX_STUB_WINDOWS" <<'WINDOWS'
@1:000:200:200:Work:1:editor
WINDOWS
output=$("$ROOT/bin/omarchy-tmux-alert" show --json)
[[ $output == '{"count":0,"tooltip":""}' ]] || fail "tmux alert reports no alerted windows" "$output"
pass "tmux alert reports no alerted windows"
[[ -z $("$ROOT/bin/omarchy-tmux-alert" show) ]] || fail "tmux alert stays quiet without alerts"
pass "tmux alert stays quiet without alerts"
: >"$TMUX_STUB_WINDOWS"
: >"$TMUX_STUB_CLIENTS"
output=$("$ROOT/bin/omarchy-tmux-alert" show --json)
[[ $output == '{"count":0,"tooltip":""}' ]] || fail "tmux alert handles a missing tmux server" "$output"
pass "tmux alert handles a missing tmux server"
: >"$TMUX_STUB_CALLS"
"$ROOT/bin/omarchy-tmux-alert" track @3 345
expected=$'set-option -wq -t @3 @omarchy_unfocused_activity 345\n-q omarchy.indicators refresh'
output=$(cat "$TMUX_STUB_CALLS")
[[ $output == "$expected" ]] || fail "tmux alert records the activity watermark and refreshes indicators" "expected: $expected"$'\n'"actual: $output"
pass "tmux alert records the activity watermark and refreshes indicators"
: >"$TMUX_STUB_CALLS"
"$ROOT/bin/omarchy-tmux-alert" track invalid nope
[[ ! -s $TMUX_STUB_CALLS ]] || fail "tmux alert ignores invalid tracking arguments" "$(cat "$TMUX_STUB_CALLS")"
pass "tmux alert ignores invalid tracking arguments"
@@ -0,0 +1,54 @@
#!/bin/bash
set -euo pipefail
source "$(dirname "$0")/base-test.sh"
migration="$ROOT/migrations/1784955584.sh"
test_dir=$(mktemp -d)
trap 'rm -rf "$test_dir"' EXIT
mkdir -p "$test_dir/home/.config/tmux" "$test_dir/bin"
cat >"$test_dir/home/.config/tmux/tmux.conf" <<'EOF'
# Alerts
set-hook -g alert-bell 'run-shell -b "omarchy-shell -q omarchy.indicators refresh"'
set-hook -g after-select-window 'run-shell -b "omarchy-shell -q omarchy.indicators refresh"'
set-hook -g client-session-changed 'run-shell -b "omarchy-shell -q omarchy.indicators refresh"'
set-hook -g client-focus-out[42] 'display-message "custom focus hook"'
EOF
cat >"$test_dir/bin/omarchy-restart-tmux" <<'EOF'
#!/bin/bash
echo restart >>"$TMUX_MIGRATION_RESTART_LOG"
EOF
chmod +x "$test_dir/bin/omarchy-restart-tmux"
export TMUX_MIGRATION_RESTART_LOG="$test_dir/restarts"
tmux_config="$test_dir/home/.config/tmux/tmux.conf"
HOME="$test_dir/home" PATH="$test_dir/bin:$PATH" bash -euo pipefail "$migration" >/dev/null
grep -Fq 'after-select-window '"'"'run-shell -b "omarchy-tmux-alert track #{window_id} #{window_activity}"'"'" "$tmux_config" ||
fail "tmux activity migration tracks newly selected windows"
grep -Fq 'client-session-changed '"'"'run-shell -b "omarchy-tmux-alert track #{window_id} #{window_activity}"'"'" "$tmux_config" ||
fail "tmux activity migration tracks session changes"
grep -Fq 'client-focus-out[100] '"'"'run-shell -b "omarchy-tmux-alert track #{window_id} #{window_activity}"'"'" "$tmux_config" ||
fail "tmux activity migration tracks terminal focus loss"
grep -Fq 'client-focus-in[100] '"'"'run-shell -b "omarchy-tmux-alert track #{window_id} #{window_activity}"'"'" "$tmux_config" ||
fail "tmux activity migration tracks terminal focus gain"
grep -Fq 'client-focus-out[42] '"'"'display-message "custom focus hook"'"'" "$tmux_config" ||
fail "tmux activity migration preserves custom indexed focus hooks"
restart_count=$(wc -l <"$TMUX_MIGRATION_RESTART_LOG")
((restart_count == 1)) || fail "tmux activity migration reloads tmux once"
pass "tmux activity migration installs focus-aware hooks"
before=$(sha256sum "$tmux_config")
HOME="$test_dir/home" PATH="$test_dir/bin:$PATH" bash -euo pipefail "$migration" >/dev/null
after=$(sha256sum "$tmux_config")
[[ $before == "$after" ]] || fail "tmux activity migration is idempotent"
restart_count=$(wc -l <"$TMUX_MIGRATION_RESTART_LOG")
((restart_count == 1)) || fail "idempotent tmux activity migration does not reload tmux"
pass "tmux activity migration is idempotent"
+88 -1
View File
@@ -8,6 +8,7 @@ test_tmp=$(mktemp -d)
trap 'rm -rf "$test_tmp"' EXIT
stub_bin="$test_tmp/bin"
git_log="$test_tmp/git.log"
mkdir -p "$stub_bin"
cat >"$stub_bin/checkupdates" <<'SH'
@@ -52,8 +53,48 @@ exit 0
SH
chmod +x "$stub_bin/pacman"
cat >"$stub_bin/git" <<'SH'
#!/bin/bash
printf '%s\n' "$*" >>"$TEST_GIT_LOG"
[[ $1 == "-C" ]] || exit 1
shift 2
case "$1" in
fetch)
[[ ${TEST_GIT_FETCH:-ok} == "ok" ]]
;;
rev-parse)
case "$2" in
--is-inside-work-tree)
[[ ${TEST_GIT_CHECKOUT:-yes} == "yes" ]] || exit 1
echo true
;;
--abbrev-ref)
[[ ${TEST_GIT_UPSTREAM:-origin/quattro} != "none" ]] || exit 1
echo "${TEST_GIT_UPSTREAM:-origin/quattro}"
;;
*)
exit 1
;;
esac
;;
rev-list)
echo "${TEST_GIT_BEHIND:-0}"
;;
*)
exit 1
;;
esac
SH
chmod +x "$stub_bin/git"
run_checker() {
PATH="$stub_bin:$PATH" "$ROOT/bin/omarchy-update-available"
OMARCHY_PATH="${TEST_OMARCHY_PATH:-/usr/share/omarchy}" \
TEST_GIT_LOG="$git_log" \
PATH="$stub_bin:$PATH" \
"$ROOT/bin/omarchy-update-available"
}
capture_checker() {
@@ -124,3 +165,49 @@ fi
[[ $status -eq 1 ]] || fail "update checker exits non-zero when no updates are available"
grep -q '^Omarchy is up to date$' "$stdout" || fail "update checker prints up-to-date message"
pass "update checker reports up-to-date Omarchy packages"
: >"$git_log"
if capture_checker "$stdout" "$stderr" \
TEST_CHECKUPDATES=none \
TEST_INSTALLED_PACKAGE=none \
TEST_OMARCHY_PATH="$test_tmp/checkout" \
TEST_GIT_BEHIND=2; then
status=0
else
status=$?
fi
[[ $status -eq 0 ]] || fail "update checker exits successfully when dev commits are available"
grep -Fx 'omarchy-dev-checkout 2 new commits on origin/quattro' "$stdout" >/dev/null ||
fail "update checker reports available dev commits" "$(cat "$stdout")"
grep -Fx -- "-C $test_tmp/checkout fetch --quiet" "$git_log" >/dev/null ||
fail "update checker fetches the dev checkout upstream" "$(cat "$git_log")"
pass "update checker detects new commits in the dev checkout"
if capture_checker "$stdout" "$stderr" \
TEST_CHECKUPDATES=none \
TEST_INSTALLED_PACKAGE=none \
TEST_OMARCHY_PATH="$test_tmp/checkout" \
TEST_GIT_BEHIND=0; then
status=0
else
status=$?
fi
[[ $status -eq 1 ]] || fail "update checker exits non-zero when the dev checkout is current"
grep -q '^Omarchy is up to date$' "$stdout" || fail "update checker reports a current dev checkout"
pass "update checker ignores a current dev checkout"
if capture_checker "$stdout" "$stderr" \
TEST_CHECKUPDATES=none \
TEST_INSTALLED_PACKAGE=none \
TEST_OMARCHY_PATH="$test_tmp/checkout" \
TEST_GIT_BEHIND=1 \
TEST_GIT_FETCH=fail; then
status=0
else
status=$?
fi
[[ $status -eq 0 ]] || fail "update checker uses cached upstream state when fetch fails"
grep -Fx 'omarchy-dev-checkout 1 new commit on origin/quattro' "$stdout" >/dev/null ||
fail "update checker reports cached dev commits after a fetch failure" "$(cat "$stdout")"
[[ ! -s $stderr ]] || fail "update checker keeps dev fetch failures quiet" "$(cat "$stderr")"
pass "update checker uses cached dev state when fetching is unavailable"
+84
View File
@@ -0,0 +1,84 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
test_tmp=$(mktemp -d)
trap 'rm -rf "$test_tmp"' EXIT
stub_bin="$test_tmp/bin"
git_log="$test_tmp/git.log"
checkout="$test_tmp/checkout"
mkdir -p "$stub_bin" "$checkout"
cat >"$stub_bin/git" <<'SH'
#!/bin/bash
printf '%s\n' "$*" >>"$TEST_GIT_LOG"
[[ $1 == "-C" ]] || exit 1
shift 2
case "$1" in
rev-parse)
case "$2" in
--is-inside-work-tree)
[[ ${TEST_GIT_CHECKOUT:-yes} == "yes" ]] || exit 1
echo true
;;
--abbrev-ref)
[[ ${TEST_GIT_UPSTREAM:-origin/quattro} != "none" ]] || exit 1
echo "${TEST_GIT_UPSTREAM:-origin/quattro}"
;;
*)
exit 1
;;
esac
;;
pull)
[[ $2 == "--ff-only" ]]
;;
*)
exit 1
;;
esac
SH
chmod +x "$stub_bin/git"
run_dev_update() {
OMARCHY_PATH="$1" \
TEST_GIT_LOG="$git_log" \
PATH="$stub_bin:$PATH" \
"$ROOT/bin/omarchy-update-dev"
}
: >"$git_log"
run_dev_update /usr/share/omarchy
[[ ! -s $git_log ]] || fail "package-backed updates do not invoke git" "$(cat "$git_log")"
pass "package-backed updates skip the dev checkout step"
: >"$git_log"
run_dev_update "$checkout"
grep -Fx -- "-C $checkout pull --ff-only" "$git_log" >/dev/null ||
fail "dev checkout update pulls its upstream with fast-forward only" "$(cat "$git_log")"
pass "dev checkout update pulls its configured upstream"
: >"$git_log"
TEST_GIT_UPSTREAM=none run_dev_update "$checkout"
if grep -q ' pull ' "$git_log"; then
fail "dev checkout without an upstream is not pulled" "$(cat "$git_log")"
fi
pass "dev checkout without an upstream is skipped safely"
: >"$git_log"
if TEST_GIT_CHECKOUT=no run_dev_update "$checkout" >"$test_tmp/invalid.out" 2>"$test_tmp/invalid.err"; then
fail "invalid dev checkout fails the update"
fi
grep -F "OMARCHY_PATH is not a git checkout: $checkout" "$test_tmp/invalid.err" >/dev/null ||
fail "invalid dev checkout reports the configured path" "$(cat "$test_tmp/invalid.err")"
pass "invalid dev checkout fails with a useful error"
grep -qE '^ *omarchy-update-dev$' "$ROOT/bin/omarchy-update" ||
fail "top-level update includes the dev checkout step"
pass "top-level update includes the dev checkout step"
+34
View File
@@ -34,6 +34,7 @@ SH
for command in \
omarchy-toggle-idle \
systemd-inhibit \
omarchy-update-dev \
omarchy-update-keyring \
omarchy-update-system-pkgs \
omarchy-migrate \
@@ -99,3 +100,36 @@ wait "$perform_pid"
grep -q "already running" "$test_tmp/perform-second.out" || fail "second omarchy-update-perform reports held update lock"
[[ ! -f $test_tmp/perform-second-started ]] || fail "second omarchy-update-perform did not snapshot while lock was held"
pass "omarchy-update-perform compatibility wrapper respects update lock"
# Update-owned Stay Awake state must be cleared before the restart helper can
# reboot the machine, rather than relying on an EXIT trap during shutdown.
write_stub omarchy-snapshot 'exit 0'
write_stub omarchy-update-keyring 'exit 0'
write_stub omarchy-toggle-idle '
state_file="$HOME/.local/state/omarchy/indicators/stay-awake"
case "$1" in
stay-awake)
mkdir -p "$(dirname "$state_file")"
touch "$state_file"
;;
allow-idle)
rm -f "$state_file"
;;
esac'
write_stub omarchy-update-restart '
state_file="$HOME/.local/state/omarchy/indicators/stay-awake"
if [[ ${EXPECT_STAY_AWAKE:-0} == "1" ]]; then
[[ -f $state_file ]]
else
[[ ! -f $state_file ]]
fi'
rm -f "$test_home/.local/state/omarchy/indicators/stay-awake"
OMARCHY_UPDATE_LOGGED=1 run_with_lock_env "$ROOT/bin/omarchy-update" -y
[[ ! -f $test_home/.local/state/omarchy/indicators/stay-awake ]] || fail "update clears its Stay Awake state before restart handling"
mkdir -p "$test_home/.local/state/omarchy/indicators"
touch "$test_home/.local/state/omarchy/indicators/stay-awake"
OMARCHY_UPDATE_LOGGED=1 EXPECT_STAY_AWAKE=1 run_with_lock_env "$ROOT/bin/omarchy-update" -y
[[ -f $test_home/.local/state/omarchy/indicators/stay-awake ]] || fail "update preserves pre-existing Stay Awake state"
pass "omarchy-update restores only its own Stay Awake state before restart handling"
+11 -5
View File
@@ -32,10 +32,13 @@ cat >"$test_bin/systemd-run" <<'EOF'
#!/bin/bash
echo "systemd-run:$*" >>"$TEST_LOG"
while (($# > 0)); do
[[ $1 == "bash" ]] && exec "$@"
shift
case $1 in
-p) shift 2 ;;
-*) shift ;;
*) break ;;
esac
done
exit 1
exec "$@"
EOF
chmod +x "$test_bin/systemd-run"
@@ -48,15 +51,18 @@ run_invitation_hook
[[ -f $test_home/.local/state/omarchy/done/voxtype-install-invitation ]] || fail "Voxtype invitation records completion"
[[ -f $hook_path ]] || fail "Voxtype invitation keeps its hook installed"
[[ $(grep -c '^systemd-run:' "$log_file") -eq 1 ]] || fail "Voxtype invitation uses a durable user service"
[[ $(grep -c '^systemd-run:' "$log_file") -eq 2 ]] || fail "Voxtype invitation uses durable user services"
grep -q -- '--user --collect --quiet --service-type=exec --unit=omarchy-voxtype-install-invitation' "$log_file" || fail "Voxtype invitation configures its user service"
# KillMode=process keeps the launcher's setsid child alive once the short-lived
# main process exits, otherwise the install terminal never appears.
grep -q -- '--user --collect --quiet -p KillMode=process --unit=omarchy-voxtype-install ' "$log_file" || fail "Voxtype invitation outlives its launcher unit"
[[ $(grep -c '^notification$' "$log_file") -eq 1 ]] || fail "Voxtype invitation sends one notification"
[[ $(grep -c '^launch$' "$log_file") -eq 1 ]] || fail "Voxtype invitation handles the notification action"
HOME="$test_home" PATH="$test_bin:$ROOT/bin:$PATH" TEST_LOG="$log_file" bash "$hook_path"
[[ -f $hook_path ]] || fail "completed Voxtype invitation keeps its hook installed"
[[ $(grep -c '^systemd-run:' "$log_file") -eq 1 ]] || fail "completed Voxtype invitation does not schedule again"
[[ $(grep -c '^systemd-run:' "$log_file") -eq 2 ]] || fail "completed Voxtype invitation does not schedule again"
[[ $(grep -c '^notification$' "$log_file") -eq 1 ]] || fail "completed Voxtype invitation hook does not notify again"
[[ $(grep -c '^launch$' "$log_file") -eq 1 ]] || fail "completed Voxtype invitation hook does not launch again"
+104
View File
@@ -0,0 +1,104 @@
#!/bin/bash
source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh"
migration=$(grep -rl 'Move zram tuning to a vendor drop-in' "$ROOT/migrations" | head -n 1 || true)
[[ -n $migration ]] || fail "zram drop-in migration exists"
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
# The migration shells out to pacman (ownership check) and sudo (removal).
# Stub both so the test never touches the real system, and let each case pick
# what `pacman -Qo` reports through PACMAN_OWNS.
stub_bin="$TMPDIR/bin"
mkdir -p "$stub_bin"
cat >"$stub_bin/pacman" <<'STUB'
#!/bin/bash
[[ ${PACMAN_OWNS:-0} == 1 ]]
STUB
cat >"$stub_bin/sudo" <<'STUB'
#!/bin/bash
exec "$@"
STUB
chmod +x "$stub_bin/pacman" "$stub_bin/sudo"
# The migration removes the /etc copy only once the drop-in that replaces it is
# installed. Point that at a fixture so the result does not depend on whether
# the machine running the tests happens to carry the real one.
dropin="$TMPDIR/90-omarchy.conf"
: >"$dropin"
# omarchy-migrate runs each migration with `bash -euo pipefail` and stops the
# whole chain on a non-zero exit, so match that invocation exactly.
run_migration() {
local conf="$1"
PATH="$stub_bin:$PATH" OMARCHY_ZRAM_CONF="$conf" OMARCHY_ZRAM_DROPIN="$dropin" \
bash -euo pipefail "$migration" >/dev/null ||
fail "migration exits clean for $(basename "$conf")"
}
# archinstall's own output: a [zram0] section with nothing but the algorithm.
conf="$TMPDIR/archinstall.conf"
printf '[zram0]\ncompression-algorithm = zstd\n' >"$conf"
run_migration "$conf"
[[ -f $conf ]] && fail "migration removes archinstall's generated config"
pass "migration removes archinstall's generated config"
# Same shape, different algorithm, plus comments and blank lines.
conf="$TMPDIR/commented.conf"
printf '# written by archinstall\n\n[zram0]\ncompression-algorithm = lz4\n\n' >"$conf"
run_migration "$conf"
[[ -f $conf ]] && fail "migration ignores comments and a non-zstd algorithm"
pass "migration ignores comments and a non-zstd algorithm"
# A config that sets nothing decides nothing, and must not take the migration
# chain down with it.
conf="$TMPDIR/comments-only.conf"
printf '# nothing to see here\n\n' >"$conf"
run_migration "$conf"
[[ -f $conf ]] && fail "migration removes a config that sets nothing"
pass "migration removes a config that sets nothing"
conf="$TMPDIR/empty.conf"
: >"$conf"
run_migration "$conf"
[[ -f $conf ]] && fail "migration removes an empty config"
pass "migration removes an empty config"
# A local override must survive.
conf="$TMPDIR/local.conf"
printf '[zram0]\ncompression-algorithm = zstd\nzram-size = ram / 4\n' >"$conf"
run_migration "$conf"
[[ -f $conf ]] || fail "migration keeps a locally edited config"
pass "migration keeps a locally edited config"
# Package-owned copies go away with their package; the migration must not touch
# them.
conf="$TMPDIR/owned.conf"
printf '[zram0]\ncompression-algorithm = zstd\n' >"$conf"
PATH="$stub_bin:$PATH" PACMAN_OWNS=1 OMARCHY_ZRAM_CONF="$conf" OMARCHY_ZRAM_DROPIN="$dropin" \
bash -euo pipefail "$migration" >/dev/null ||
fail "migration exits clean for a package-owned config"
[[ -f $conf ]] || fail "migration keeps a package-owned config"
pass "migration keeps a package-owned config"
# Nothing to do, and running twice must stay clean.
conf="$TMPDIR/absent.conf"
run_migration "$conf"
run_migration "$conf"
pass "migration no-ops when the config is already gone"
# Without the drop-in installed, the /etc copy is the only thing configuring
# zram at all. Removing it would leave the machine with no zram device, so the
# migration has to leave it alone and stay clean doing it.
conf="$TMPDIR/no-dropin.conf"
printf '[zram0]\ncompression-algorithm = zstd\n' >"$conf"
PATH="$stub_bin:$PATH" OMARCHY_ZRAM_CONF="$conf" OMARCHY_ZRAM_DROPIN="$TMPDIR/absent-dropin.conf" \
bash -euo pipefail "$migration" >/dev/null ||
fail "migration exits clean when the drop-in is missing"
[[ -f $conf ]] || fail "migration keeps the config when the drop-in is missing"
pass "migration keeps the config until the drop-in is installed"
+109
View File
@@ -0,0 +1,109 @@
#!/bin/bash
source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh"
require_command /usr/lib/systemd/system-generators/zram-generator
migration=$(grep -rl 'Resize zram to match the shipped config' "$ROOT/migrations" | head -n 1 || true)
[[ -n $migration ]] || fail "zram resize migration exists"
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
# The migration shells out to sudo, systemctl and omarchy-state. Stub all three
# so the test never touches the real system, and record what each run did.
stub_bin="$TMPDIR/bin"
mkdir -p "$stub_bin"
cat >"$stub_bin/sudo" <<'STUB'
#!/bin/bash
exec "$@"
STUB
cat >"$stub_bin/systemctl" <<'STUB'
#!/bin/bash
echo "systemctl $*" >>"$ACTIONS"
[[ ${SYSTEMCTL_FAIL:-0} == 1 ]] && exit 1
exit 0
STUB
cat >"$stub_bin/omarchy-state" <<'STUB'
#!/bin/bash
echo "omarchy-state $*" >>"$ACTIONS"
STUB
chmod +x "$stub_bin/sudo" "$stub_bin/systemctl" "$stub_bin/omarchy-state"
# ZRAM_GENERATOR_ROOT redirects both the config search and /proc/meminfo, so the
# migration's own generator call resolves against this fixture instead of the
# host. 16G of RAM against the shipped config is a 8192MB device.
gen_root="$TMPDIR/genroot"
mkdir -p "$gen_root/usr/lib/systemd/zram-generator.conf.d" "$gen_root/proc"
cp "$ROOT/default/systemd/zram-generator.conf.d/90-omarchy.conf" \
"$gen_root/usr/lib/systemd/zram-generator.conf.d/"
printf 'MemTotal: %d kB\n' $((16 * 1024 * 1024)) >"$gen_root/proc/meminfo"
desired_bytes=$((8192 * 1024 * 1024))
# omarchy-migrate runs each migration with `bash -euo pipefail` and stops the
# whole chain on a non-zero exit, so match that invocation exactly.
run_migration() {
local disksize="$1" used="$2" fail_reload="${3:-0}"
printf '%s' "$disksize" >"$TMPDIR/disksize"
printf 'Filename\tType\tSize\tUsed\tPriority\n' >"$TMPDIR/swaps"
[[ -n $used ]] &&
printf '/dev/zram0 partition 8388604 %s 100\n' "$used" >>"$TMPDIR/swaps"
: >"$TMPDIR/actions"
PATH="$stub_bin:$PATH" \
ACTIONS="$TMPDIR/actions" \
SYSTEMCTL_FAIL="$fail_reload" \
ZRAM_GENERATOR_ROOT="$gen_root" \
OMARCHY_ZRAM_DISKSIZE="$TMPDIR/disksize" \
OMARCHY_SWAPS="$TMPDIR/swaps" \
bash -euo pipefail "$migration" >/dev/null ||
fail "migration exits clean (disksize=$disksize used=$used reload_fail=$fail_reload)"
}
did() { grep -qF "$1" "$TMPDIR/actions"; }
# Already the size the config asks for: the device is correct however it got
# there, so the migration must not restart it or ask for a reboot.
run_migration "$desired_bytes" 4193612
[[ -s $TMPDIR/actions ]] && fail "correctly sized device is left alone" \
"expected no privileged calls, got: $(cat "$TMPDIR/actions")"
pass "correctly sized device is left alone"
pass "correctly sized device asks for no reboot"
# Right size but still in use, and the reload never even happens.
run_migration "$desired_bytes" 0
did "systemctl restart" && fail "correctly sized empty device is left alone"
pass "correctly sized empty device is left alone"
# Wrong size and empty: safe to resize now.
run_migration $((4096 * 1024 * 1024)) 0
did "systemctl restart dev-zram0.swap" || fail "empty device is restarted"
did "omarchy-state set reboot-required" && fail "empty device asks for no reboot"
pass "empty device is restarted"
pass "empty device asks for no reboot"
# Wrong size with pages stored: resizing would fault them all back in, so defer.
run_migration $((4096 * 1024 * 1024)) 4193612
did "systemctl restart" && fail "device in use is not restarted"
did "omarchy-state set reboot-required" || fail "device in use asks for a reboot"
pass "device in use is not restarted"
pass "device in use asks for a reboot"
# No zram device at all reads as empty, and the restart brings it up.
run_migration "" ""
did "systemctl restart dev-zram0.swap" || fail "absent device is created"
pass "absent device is created"
# A failed daemon-reload must fall back to asking for a reboot.
run_migration $((4096 * 1024 * 1024)) 0 1
did "systemctl restart" && fail "failed reload does not restart"
did "omarchy-state set reboot-required" || fail "failed reload asks for a reboot"
pass "failed reload does not restart"
pass "failed reload asks for a reboot"