Files
omarchycn/test/shell.d/config-test.sh
T
David Heinemeier HanssonandClaude Opus 5 6e07fd0e82 Supervise fcitx5 so XCompose sequences can't silently die
fcitx5 is what turns the CapsLock compose sequences in ~/.XCompose into
text for Wayland clients -- CapsLock m s for an emoji, CapsLock space n
for your name. It was launched fire-and-forget from Hyprland's autostart
via uwsm-app, so nothing supervised it, nothing restarted it, and it
logged nowhere. When it went away, every compose sequence stopped
working for the rest of the session with no visible symptom beyond
"emoji input is busted", and no record of why.

That is not hypothetical: it was found dead on a running machine with no
coredump, no OOM kill, and nothing in the journal to explain it.

Move it to a systemd user service:

- Restart=always, not on-failure. fcitx5 exits 0 when it finds another
  instance already owning org.fcitx.Fcitx5, and a clean exit still
  leaves the user with no input method.
- After=/PartOf=graphical-session.target. It needs WAYLAND_DISPLAY and
  DISPLAY, which uwsm imports into the user manager before reaching the
  target, and its wayland connection dies with the compositor.
- ConditionEnvironment=WAYLAND_DISPLAY. After= is ordering only and does
  not stop the unit from being started while the target is inactive. An
  update over SSH has a live user manager (pam_systemd) and no graphical
  session, and a fcitx5 started there comes up blind -- then stays
  active, so the later target activation won't pull in a working one,
  because Wants= does not restart what is already running. Skipping the
  start leaves the unit enabled and healthy for the next graphical login.

The migration hands over inside a live session only: it enables without
--now, and only when graphical-session.target is active does it drop the
autostart-launched process and start the unit. Because that kills a
fcitx5 that was working a moment ago, a failed start is reported instead
of leaving the session mute with the migration marked complete.

omarchy-restart-xcompose now drives the unit. It still clears any fcitx5
running outside it first: that process owns the bus name, so the unit's
instance would exit on arrival and the stale one would keep serving the
old table -- a restart that reports success and changes nothing.

Side benefit: fcitx5 now logs to the journal under its own unit, so the
next disappearance leaves a record.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 09:49:15 -07:00

479 lines
20 KiB
Bash
Executable File

#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
export PATH="$ROOT/bin:$PATH"
require_command jq
require_command lua
require_command python3
jq empty "$ROOT/config/omarchy/shell.json"
pass "default shell.json is valid JSON"
jq -e '.version == 1 and (.bar.layout.left | type == "array") and (.bar.layout.center | type == "array") and (.bar.layout.right | type == "array")' "$ROOT/config/omarchy/shell.json" >/dev/null
pass "default shell.json has versioned bar layout"
# Pinning the whole row made this fail every time an unrelated widget moved,
# so assert the adjacency the name is about and let the rest of the row change.
jq -e '
def ids: map(.id // .);
(.bar.layout.center | ids) as $ids |
($ids | index("omarchy.weather")) as $weather |
($ids | index("omarchy.system-update")) as $update |
$weather != null and $update == $weather + 1
' "$ROOT/config/omarchy/shell.json" >/dev/null
pass "default center layout keeps update next to weather"
jq -e '
(.bar.centerAnchor // "") as $anchor |
any(.bar.layout.center[]; (.id // .) == $anchor)
' "$ROOT/config/omarchy/shell.json" >/dev/null
pass "default center anchor exists in center layout"
jq -e '
any(.bar.layout.center[]; (.id // .) == "omarchy.clock" and (.formatAlt // "") == "d MMMM \u0027W\u0027ww yyyy")
' "$ROOT/config/omarchy/shell.json" >/dev/null
pass "default clock date format has no leading zero"
ROOT="$ROOT" python3 <<'PY'
import json
import os
import sys
from pathlib import Path
root = Path(os.environ["ROOT"])
config = json.loads((root / "config/omarchy/shell.json").read_text())
manifests = {}
for manifest_path in (root / "shell/plugins").glob("**/*.manifest.json"):
data = json.loads(manifest_path.read_text())
manifests[data.get("id", "")] = (manifest_path, data)
for manifest_path in (root / "shell/plugins").glob("**/manifest.json"):
data = json.loads(manifest_path.read_text())
manifests[data.get("id", "")] = (manifest_path, data)
entries = []
for section in ("left", "center", "right"):
entries.extend(config["bar"]["layout"][section])
missing = []
bad = []
for entry in entries:
widget_id = entry["id"] if isinstance(entry, dict) else str(entry)
if not widget_id.startswith("omarchy."):
continue
row = manifests.get(widget_id)
if row is None:
missing.append(widget_id)
continue
manifest_path, manifest = row
if "bar-widget" not in manifest.get("kinds", []):
bad.append(f"{widget_id}: missing bar-widget kind")
entry_point = manifest.get("entryPoints", {}).get("barWidget")
if not entry_point:
bad.append(f"{widget_id}: missing barWidget entry point")
elif not (manifest_path.parent / entry_point).exists():
bad.append(f"{widget_id}: missing {entry_point}")
if missing or bad:
for item in missing:
print(f"missing manifest for {item}", file=sys.stderr)
for item in bad:
print(item, file=sys.stderr)
sys.exit(1)
PY
pass "default bar widget ids resolve to manifests and entry points"
ROOT="$ROOT" python3 <<'PY'
import os
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",
]
# 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():
settings_pkgbuild_path = pkgs_root / "omarchy-settings-dev/PKGBUILD"
if not omarchy_pkgbuild_path.exists():
omarchy_pkgbuild_path = pkgs_root / "omarchy-dev/PKGBUILD"
pkgbuild = settings_pkgbuild_path.read_text()
omarchy_pkgbuild = omarchy_pkgbuild_path.read_text()
errors = []
package_defaults = [
("default/uwsm/env.d/10-omarchy", "/usr/share/uwsm/env.d/10-omarchy", "uwsm/env"),
("default/uwsm/default", None, "uwsm/default"),
("default/environment.d/10-omarchy-fcitx.conf", "/usr/lib/environment.d/10-omarchy-fcitx.conf", "environment.d/fcitx.conf"),
("default/fontconfig/conf.avail/50-omarchy.conf", "/usr/share/fontconfig/conf.avail/50-omarchy.conf", "fontconfig/fonts.conf"),
("default/xdg-terminal-exec/hyprland-xdg-terminals.list", "/usr/share/xdg-terminal-exec/hyprland-xdg-terminals.list", "xdg-terminals.list"),
("default/applications/mimeapps.list", "/usr/share/applications/mimeapps.list", "mimeapps.list"),
("etc/fastfetch/config.jsonc", "/etc/fastfetch/config.jsonc", "fastfetch/config.jsonc"),
("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-migrate-notify.service", "/usr/lib/systemd/user/omarchy-migrate-notify.service", "systemd/user/omarchy-migrate-notify.service"),
("default/systemd/user/omarchy-tailscale-receive.service", "/usr/lib/systemd/user/omarchy-tailscale-receive.service", "systemd/user/omarchy-tailscale-receive.service"),
("default/systemd/user/omarchy-fcitx5.service", "/usr/lib/systemd/user/omarchy-fcitx5.service", "systemd/user/omarchy-fcitx5.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"),
]
for source, destination, legacy in package_defaults:
if not (root / source).exists():
errors.append(f"missing package default source: {source}")
if (root / "config" / legacy).exists():
errors.append(f"legacy path still in config/: {legacy}")
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",
"90-omarchy-hyprland-reload-resume.hook",
]
for hook in alpm_hooks:
source = f"default/libalpm/hooks/{hook}"
destination = f"/usr/share/libalpm/hooks/{hook}"
if not (root / source).exists():
errors.append(f"missing package default source: {source}")
if source not in omarchy_pkgbuild or destination not in omarchy_pkgbuild:
errors.append(f"omarchy PKGBUILD does not install {source} -> {destination}")
if errors:
print("\n".join(errors), file=sys.stderr)
sys.exit(1)
PY
pass "package-owned defaults live outside config"
grep -F 'dofile((os.getenv("OMARCHY_PATH") or "/usr/share/omarchy") .. "/default/hypr/bootstrap.lua")' "$ROOT/config/hypr/hyprland.lua" >/dev/null
grep -F 'require("default.hypr.omarchy")' "$ROOT/config/hypr/hyprland.lua" >/dev/null
grep -F 'package.path = home' "$ROOT/default/hypr/bootstrap.lua" >/dev/null
grep -F '/.local/state/?.lua;' "$ROOT/default/hypr/bootstrap.lua" >/dev/null
pass "Hyprland user entrypoint keeps package and state path bootstrap in defaults"
OMARCHY_PATH="$ROOT" lua <<'LUA'
package.loaded["default.hypr.omarchy"] = true
package.loaded["default.hypr.require_optional"] = true
package.loaded["hypr.looknfeel"] = true
package.loaded["omarchy.current.theme.hyprland"] = true
package.loaded["unrelated.module"] = true
dofile(os.getenv("OMARCHY_PATH") .. "/default/hypr/bootstrap.lua")
assert(package.loaded["default.hypr.omarchy"] == nil)
assert(package.loaded["default.hypr.require_optional"] == nil)
assert(package.loaded["hypr.looknfeel"] == nil)
assert(package.loaded["omarchy.current.theme.hyprland"] == nil)
assert(package.loaded["unrelated.module"] == true)
LUA
pass "Hyprland bootstrap reloads cached Omarchy config modules"
TMPDIR=$(mktemp -d)
mkdir -p "$TMPDIR/home/.config/omarchy"
cat >"$TMPDIR/home/.config/omarchy/shell.json" <<'JSON'
{
"version": 1,
"bar": {
"layout": {
"left": [{ "id": "omarchy.menu" }, { "id": "omarchy.workspaces" }],
"center": [{ "id": "omarchy.clock" }, { "id": "omarchy.weather" }],
"right": [{ "id": "omarchy.tray" }, { "id": "omarchy.bluetooth" }]
}
},
"plugins": []
}
JSON
mkdir -p "$TMPDIR/home/.config/omarchy/plugins/local.demo-bar"
cat >"$TMPDIR/home/.config/omarchy/plugins/local.demo-bar/manifest.json" <<'JSON'
{
"schemaVersion": 1,
"id": "local.demo-bar",
"name": "Demo bar",
"version": "1.0.0",
"author": "Test",
"description": "Replacement bar for config tests",
"kinds": ["bar"],
"entryPoints": { "bar": "Bar.qml" }
}
JSON
touch "$TMPDIR/home/.config/omarchy/plugins/local.demo-bar/Bar.qml"
if HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar use local.nonexistent-bar 2>/dev/null; then
fail "bar use accepted an unknown bar option"
fi
pass "bar use rejects an unknown bar option"
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar use local.demo-bar
jq -e '.bar.id == "local.demo-bar"' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
pass "shell config selects a bar option"
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar reset
jq -e '.bar.id == null' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
pass "shell config resets to built-in bar option"
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar-plugin add omarchy.tailscale
jq -e '
def ids: map(.id // .);
.bar.layout.right | ids == ["omarchy.tray", "omarchy.tailscale", "omarchy.bluetooth"]
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
pass "shell config appends widgets to right by default"
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar-plugin add omarchy.active-window left
jq -e '
def ids: map(.id // .);
.bar.layout.left | ids == ["omarchy.menu", "omarchy.workspaces", "omarchy.active-window"]
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
pass "shell config appends left widgets after workspaces"
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar-plugin add omarchy.system-update center
jq -e '
def ids: map(.id // .);
.bar.layout.center | ids == ["omarchy.clock", "omarchy.weather", "omarchy.system-update"]
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
pass "shell config appends center widgets after weather"
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar-plugin add omarchy.microphone right
jq -e '
def ids: map(.id // .);
.bar.layout.right | ids == ["omarchy.tray", "omarchy.microphone", "omarchy.tailscale", "omarchy.bluetooth"]
' "$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
pass "bar plugin add rejects an unknown widget"
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar position bottom
jq -e '
.bar.position == "bottom" and
.plugins == []
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
pass "shell config sets bar position"
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar transparent true
jq -e '
.bar.transparent == true and
.bar.position == "bottom" and
.plugins == []
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
pass "shell config sets bar transparency"
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar transparent toggle
jq -e '.bar.transparent == false' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
pass "shell config toggles bar transparency"
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar-plugin drop omarchy.active-window
jq -e '
def ids: map(.id // .);
(.bar.layout.left | ids == ["omarchy.menu", "omarchy.workspaces"]) and
(.bar.layout.center | ids == ["omarchy.clock", "omarchy.weather", "omarchy.system-update"]) and
(.bar.layout.right | ids == ["omarchy.tray", "omarchy.microphone", "omarchy.tailscale", "omarchy.bluetooth"])
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
pass "shell config drops widgets from any section"
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar-plugin remove omarchy.system-update
jq -e '
def ids: map(.id // .);
.bar.layout.center | ids == ["omarchy.clock", "omarchy.weather"]
' "$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"
cat >"$mock_bin/omarchy-refresh-config" <<'SH'
#!/bin/bash
set -euo pipefail
relative_path="${1:-}"
[[ -n $relative_path ]] || exit 1
mkdir -p "$HOME/.config/$(dirname "$relative_path")"
cp "$OMARCHY_PATH/config/$relative_path" "$HOME/.config/$relative_path"
SH
cat >"$mock_bin/omarchy-restart-shell" <<'SH'
#!/bin/bash
set -euo pipefail
mkdir -p "$HOME/.local/state/omarchy"
touch "$HOME/.local/state/omarchy/restart-shell-called"
SH
cat >"$mock_bin/omarchy-shell" <<'SH'
#!/bin/bash
exit 0
SH
cat >"$mock_bin/omarchy-installed-service-dropbox" <<'SH'
#!/bin/bash
set -euo pipefail
[[ ${OMARCHY_TEST_DROPBOX:-0} == "1" ]]
SH
cat >"$mock_bin/omarchy-installed-service-tailscale" <<'SH'
#!/bin/bash
set -euo pipefail
[[ ${OMARCHY_TEST_TAILSCALE:-0} == "1" ]]
SH
chmod +x "$mock_bin"/*
mock_path="$mock_bin:$ROOT/bin:$PATH"
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" PATH="$mock_path" OMARCHY_TEST_DROPBOX=0 OMARCHY_TEST_TAILSCALE=0 omarchy-bar defaults
jq -e --slurpfile defaults "$ROOT/config/omarchy/shell.json" '
.bar == $defaults[0].bar and
.plugins == []
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
pass "bar defaults restores the stock bar"
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" PATH="$mock_path" OMARCHY_TEST_DROPBOX=1 OMARCHY_TEST_TAILSCALE=1 omarchy-bar defaults
jq -e '
def ids: map(.id // .);
(.bar.layout.right | ids | index("omarchy.dropbox") != null) and
(.bar.layout.right | ids | index("omarchy.tailscale") != null)
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
pass "bar defaults adds widgets for running optional services"
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" PATH="$mock_path" OMARCHY_TEST_DROPBOX=0 OMARCHY_TEST_TAILSCALE=0 omarchy-refresh-shell
jq -e '
def ids: map(.id // .);
(.bar.layout.right | ids | index("omarchy.dropbox") == null) and
(.bar.layout.right | ids | index("omarchy.tailscale") == null)
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
pass "shell refresh keeps optional service widgets absent when services are unavailable"
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" PATH="$mock_path" OMARCHY_TEST_DROPBOX=1 OMARCHY_TEST_TAILSCALE=1 omarchy-refresh-shell
jq -e '
def ids: map(.id // .);
(.bar.layout.right | ids | index("omarchy.dropbox") != null) and
(.bar.layout.right | ids | index("omarchy.tailscale") != null)
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
[[ -f $TMPDIR/home/.local/state/omarchy/restart-shell-called ]] || fail "shell refresh restarts shell"
pass "shell refresh adds optional service widgets when services are available"
if grep -RIl 'upgrade-to-quattro\|Omarchy 4\.0 is upgraded' "$ROOT/migrations" >/dev/null; then
fail "4.0 upgrade is not modeled as a migration"
fi
pass "4.0 upgrade is handled outside the migration runner"
clock_migration=$(grep -rl 'Remove leading zero from bar clock date' "$ROOT/migrations" | head -n 1 || true)
[[ -n $clock_migration ]] || fail "clock date format user migration exists"
cat >"$TMPDIR/home/.config/omarchy/shell.json" <<'JSON'
{
"version": 1,
"bar": {
"layout": {
"left": [],
"center": [
{ "id": "omarchy.clock", "formatAlt": "dd MMMM 'W'ww yyyy" },
{ "id": "omarchy.weather" }
],
"right": [
{ "id": "local.clock", "formatAlt": "dd MMMM 'W'ww yyyy" }
]
}
},
"plugins": []
}
JSON
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" bash "$clock_migration"
jq -e '
.bar.layout.center[0].formatAlt == "d MMMM \u0027W\u0027ww yyyy" and
.bar.layout.right[0].formatAlt == "dd MMMM \u0027W\u0027ww yyyy"
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
pass "clock date format migration removes leading zero from clock"
before=$(sha256sum "$TMPDIR/home/.config/omarchy/shell.json" | awk '{print $1}')
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" bash "$clock_migration"
after=$(sha256sum "$TMPDIR/home/.config/omarchy/shell.json" | awk '{print $1}')
[[ $before == "$after" ]] || fail "clock date format migration is idempotent"
pass "clock date format migration is idempotent"