#!/usr/bin/env bash set -euo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" service="$repo_dir/config/dot/quickshell/services/Notifs.qml" page="$repo_dir/config/dot/quickshell/modules/settings/NotificationsPage.qml" harness_fixture="$repo_dir/tests/quickshell/NotificationAppRulesHarness.qml" desktop_entry_fixture="$repo_dir/tests/quickshell/fixtures/org.persist.App.desktop" fail() { printf 'notification application rules contract: %s\n' "$1" >&2 exit 1 } [[ -f "$service" ]] || fail 'notification service is missing' [[ -f "$page" ]] || fail 'notification settings page is missing' [[ -f "$harness_fixture" ]] || fail 'runtime harness fixture is missing' [[ -f "$desktop_entry_fixture" ]] || fail 'runtime desktop entry fixture is missing' SERVICE_PATH="$service" PAGE_PATH="$page" bun -e ' const source = await Bun.file(process.env.SERVICE_PATH).text(); const page = await Bun.file(process.env.PAGE_PATH).text(); function fail(message) { console.error(`notification application rules contract: ${message}`); process.exit(1); } function functionBody(name) { const start = source.indexOf(`function ${name}(`); if (start === -1) fail(`missing ${name}()`); const open = source.indexOf("{", start); let depth = 0; for (let index = open; index < source.length; index++) { if (source[index] === "{") depth++; if (source[index] === "}" && --depth === 0) return source.slice(open + 1, index); } fail(`${name}() is unterminated`); } const notificationAppId = Function("notification", functionBody("notificationAppId")); const normalizedAppRule = Function("rule", functionBody("normalizedAppRule")); const identityFixtures = [ { notification: { desktopEntry: "org.signal.Signal.desktop", appName: "Signal" }, expected: "org.signal.Signal.desktop" }, { notification: { desktopEntry: "", appName: "Terminal" }, expected: "Terminal" }, { notification: { desktopEntry: "", appName: "" }, expected: "Notifications" } ]; for (const fixture of identityFixtures) { const actual = notificationAppId(fixture.notification); if (actual !== fixture.expected) fail(`stable app identity expected ${fixture.expected}, got ${actual}`); } const defaultRule = normalizedAppRule({}); if (JSON.stringify(defaultRule) !== JSON.stringify({ enabled: true })) fail(`missing rule fields did not default safely: ${JSON.stringify(defaultRule)}`); // Old stored rules may still carry lock-screen fields from the era when the // page offered switches for them; normalization must drop dead fields, not // carry them forward as if something read them. const explicitRule = normalizedAppRule({ enabled: false, showOnLockScreen: false, showContentOnLockScreen: false }); if (JSON.stringify(explicitRule) !== JSON.stringify({ enabled: false })) fail(`stale lock-screen fields were not dropped: ${JSON.stringify(explicitRule)}`); for (const required of ["rememberApplication", "appRule", "setAppRule", "handleNotification"]) { functionBody(required); } const handler = source.indexOf("function handleNotification(notification: var)"); const tracked = source.indexOf("notification.tracked = true", handler); const muted = source.indexOf("!root.appRule(appId).enabled", handler); if (handler === -1 || tracked === -1 || muted === -1 || muted > tracked) fail("muted applications are not rejected before tracking/history/unread/toast work"); if (!source.includes("root.handleNotification(notification)")) fail("NotificationServer does not delegate delivery to the callable handler"); if (!source.includes("PreferenceSchema.has(\"notificationAppRules\")")) fail("the schema dependency is not explicit"); if (!source.includes("DesktopPreferences.get(\"notificationAppRules\")")) fail("rules are not read through DesktopPreferences"); if (!source.includes("DesktopPreferences.set(\"notificationAppRules\", next)")) fail("rules are not written through DesktopPreferences"); if (!source.includes("next[knownAppId] = root.appRule(knownAppId)")) fail("persisted rules are not normalized to the required three-field shape"); if (!source.includes("root.fallbackAppRules = next")) fail("missing-schema preference writes do not retain an in-memory fallback"); // Do Not Disturb still gates popups; it gained exactly one exception, and the // assertion names that exception rather than being loosened. A bare // if (!doNotDisturb) would mean the allow list of the active focus mode is // never consulted. Anything wider would let a manual Do Not Disturb be // overridden. No apostrophes here: this block is inside a single-quoted shell // string, and one closes it. if (!source.includes("if (!root.doNotDisturb || FocusModes.allows(")) fail("Do Not Disturb no longer gates popups with a focus-mode exception"); for (const required of [ "DesktopEntries.applications.values", "DesktopEntries.byId(appId)", "DesktopEntries.heuristicLookup(appId)" ]) { if (!source.includes(required)) fail(`persisted desktop entry ids are not reactively resolved through ${required}`); } for (const required of [ "Notifs.applications", // Read through a binding on Notifs.appRule rather than a stored copy, so a // rule changed elsewhere still reaches the row. The exact expression moved // when the per-app rows were collapsed behind a summary; what must hold is // that the call is still in a binding, not that it is spelled one way. "Notifs.appRule(", ".enabled", "Notifs.setAppRule" ]) { if (!page.includes(required)) fail(`settings page is missing ${required}`); } // The lock screen is hyprlock, which cannot render notifications. Two per-app // lock-screen switches once shipped anyway, controlling nothing -- the page // must not grow controls the session cannot honor. for (const forbidden of ["showOnLockScreen", "showContentOnLockScreen"]) { if (page.includes(forbidden)) fail(`settings page offers ${forbidden}, which nothing in a hyprlock session reads`); } console.log("notification application rules contract: PASS"); ' state_home="$(mktemp -d /tmp/panama-notification-rules-state.XXXXXX)" config_home="$(mktemp -d /tmp/panama-notification-rules-config.XXXXXX)" data_home="$(mktemp -d /tmp/panama-notification-rules-data.XXXXXX)" config_path="$state_home/quickshell" harness="$config_path/notification-app-rules-harness.qml" shell_log="$state_home/notification-app-rules.log" cleanup() { if [[ -n "${bus_pid:-}" ]]; then kill "$bus_pid" >/dev/null 2>&1 || true fi rm -rf "$state_home" "$config_home" "$data_home" } trap cleanup EXIT cp -a "$repo_dir/config/dot/quickshell" "$config_path" cp "$harness_fixture" "$harness" mkdir -p "$data_home/applications" cp "$desktop_entry_fixture" "$data_home/applications/org.persist.App.desktop" # This is intentionally a copy-local integration dependency. The production # schema is Claude's change; the runtime contract proves persistence only once # that key exists and never stages a schema edit from this branch. perl -0pi -e 's@(\n // ── Capture)@\n {\n key: "notificationAppRules", type: "json", def: {}, group: "notifications", internal: true\n },$1@' \ "$config_path/config/PreferenceSchema.qml" rg -q 'key: "notificationAppRules", type: "json"' "$config_path/config/PreferenceSchema.qml" \ || fail 'temporary schema integration key was not installed' mapfile -t dbus_info < <(dbus-daemon --session --fork --print-address=1 --print-pid=1) bus_address="${dbus_info[0]:-}" bus_pid="${dbus_info[1]:-}" [[ -n "$bus_address" && "$bus_pid" =~ ^[0-9]+$ ]] || fail 'private D-Bus session did not start' qs_for_test() { DBUS_SESSION_BUS_ADDRESS="$bus_address" \ XDG_STATE_HOME="$state_home" XDG_CONFIG_HOME="$config_home" \ XDG_DATA_HOME="$data_home" XDG_DATA_DIRS="$data_home" \ qs -p "$harness" "$@" } stop_harness() { qs_for_test kill >/dev/null 2>&1 || true for _ in $(seq 1 40); do ! qs_for_test ipc show >/dev/null 2>&1 && return sleep 0.1 done fail 'isolated notification harness did not stop cleanly' } start_harness() { qs_for_test --daemonize >"$shell_log" 2>&1 for _ in $(seq 1 40); do qs_for_test ipc show 2>/dev/null | rg -q '^target notification-app-rules-test$' && return sleep 0.1 done sed -n '1,240p' "$shell_log" >&2 fail 'isolated notification harness did not start' } start_harness exercise="$(qs_for_test ipc call notification-app-rules-test exercise)" jq -e ' .appId == "org.signal.Signal.desktop" and .initialRules == { "org.signal.Signal.desktop": { enabled: true } } and .muted == { tracked: false, history: 0, popups: 0, unread: 0 } and .dnd == { tracked: true, history: 1, popups: 0, unread: 1 } and .fallback == { id: "Fallback Terminal", application: { id: "Fallback Terminal", name: "Fallback Terminal" } } ' <<<"$exercise" >/dev/null || fail "runtime notification policy fixture failed: $exercise" persisted="$(qs_for_test ipc call notification-app-rules-test persist)" jq -e '. == { "org.persist.App.desktop": { enabled: false } }' <<<"$persisted" >/dev/null || fail "runtime persistence fixture wrote the wrong shape: $persisted" settings_file="$config_home/panama/settings.json" for _ in $(seq 1 40); do [[ -f "$settings_file" ]] && jq -e '.notificationAppRules["org.persist.App.desktop"].enabled == false' "$settings_file" >/dev/null && break sleep 0.1 done [[ -f "$settings_file" ]] || fail 'runtime persistence fixture did not write settings.json' wait_for_persisted_application() { local expected="$1" local applications="" for _ in $(seq 1 80); do applications="$(qs_for_test ipc call notification-app-rules-test applications)" if jq -e '.[] | select(.id == "org.persist.App.desktop" and .name == "Persisted Fixture App")' \ <<<"$applications" >/dev/null; then [[ "$applications" == *"$expected"* ]] && printf '%s' "$applications" && return fi sleep 0.1 done fail "persisted desktop entry did not resolve to a friendly name: $applications" } before_restart_applications="$(wait_for_persisted_application 'Persisted Fixture App')" stop_harness start_harness restored="$(qs_for_test ipc call notification-app-rules-test restored)" [[ "$restored" == "$persisted" ]] || fail "notification rules did not survive isolated restart: $restored" after_restart_applications="$(wait_for_persisted_application 'Persisted Fixture App')" [[ "$before_restart_applications" == *'"id":"org.persist.App.desktop","name":"Persisted Fixture App"'* ]] \ || fail "persisted application name was wrong before restart: $before_restart_applications" [[ "$after_restart_applications" == *'"id":"org.persist.App.desktop","name":"Persisted Fixture App"'* ]] \ || fail "persisted application name was wrong after restart: $after_restart_applications" stop_harness printf 'notification application rules runtime contract: PASS\n'