#!/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' card="$repo_dir/config/dot/quickshell/modules/notifications/NotificationCard.qml" schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml" settings="$repo_dir/config/dot/quickshell/config/Settings.qml" [[ -f "$card" ]] || fail 'notification card is missing' [[ -f "$schema" ]] || fail 'preference schema is missing' [[ -f "$settings" ]] || fail 'settings singleton is missing' # ── The gate is decided once ──────────────────────────────────────────────── # # handleNotification weighs Do Not Disturb against its two exceptions and puts # what survives into `popups`. The banner window then re-tested # !Notifs.doNotDisturb on its `visible` binding, which is not a narrowing of # that decision -- it is a replacement of it. An application on a focus mode's # allow list, or a critical notification with breakthrough switched on, was # admitted by the service, chimed, and then rendered onto a window that was # hidden. The exceptions existed, were configurable, and did nothing. toasts="$repo_dir/config/dot/quickshell/modules/notifications/Toasts.qml" [[ -f "$toasts" ]] || fail 'the banner window is missing' rg -Fq 'visible: Notifs.popups.length > 0' "$toasts" \ || fail 'the banner window does not show exactly what the service admitted' if rg -q 'visible:.*doNotDisturb' "$toasts"; then fail 'the banner window re-gates on Do Not Disturb, overriding the allow-list and breakthrough exceptions the service already applied' fi # The expanded row body lives in its own component now. It is part of the same # surface as the page, so the per-application assertions read both rather than # only the file that happens to hold the card today -- otherwise pulling a row # into a component reads as the feature being deleted. Scoped to the # Notification* components so a check cannot be satisfied by some unrelated # page that also happens to mention Notifs. page_family="$(find "$repo_dir/config/dot/quickshell/modules/settings" \ -maxdepth 1 -name 'Notification*.qml' -not -name 'NotificationsPage.qml' \ | sort | tr '\n' ':')" SERVICE_PATH="$service" PAGE_PATH="$page" CARD_PATH="$card" \ SCHEMA_PATH="$schema" SETTINGS_PATH="$settings" PAGE_FAMILY="$page_family" bun -e ' const source = await Bun.file(process.env.SERVICE_PATH).text(); const page = await Bun.file(process.env.PAGE_PATH).text(); const card = await Bun.file(process.env.CARD_PATH).text(); const schema = await Bun.file(process.env.SCHEMA_PATH).text(); const settings = await Bun.file(process.env.SETTINGS_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}`); } // ── The rule shape ────────────────────────────────────────────────────────── // // The rule grew from a single `enabled` bool to seven fields. Two properties // of that growth are what this checks, because both are invisible from the // code and both corrupt somebody stored preferences if they break: // // 1. Every new field is OPTIONAL. A rule written by the version that only // knew `enabled` is still a valid rule, and reads back with the new // fields at their defaults rather than as undefined. // 2. The field set is CLOSED. Anything unrecognized is dropped on the way // through -- including the two lock-screen keys that once shipped -- so // nothing can appear to be stored policy that nothing reads. // // The enum fields are checked for the same closure: a stored typo reads back // as the default, never as a third display mode or a fourth urgency. const ruleDefaults = { enabled: true, sound: true, display: "banners", urgency: "auto", lastSeenMs: 0, name: "", icon: "" }; function sameRule(stored, expected, why) { const actual = normalizedAppRule(stored); const actualKeys = Object.keys(actual).sort().join(","); const expectedKeys = Object.keys(expected).sort().join(","); if (actualKeys !== expectedKeys) fail(`${why}: rule fields are [${actualKeys}], expected [${expectedKeys}]`); for (const key of Object.keys(expected)) { if (actual[key] !== expected[key]) fail(`${why}: ${key} read back as ${JSON.stringify(actual[key])}, expected ${JSON.stringify(expected[key])}`); } } sameRule({}, ruleDefaults, "an empty rule"); sameRule(undefined, ruleDefaults, "a missing rule"); sameRule(null, ruleDefaults, "a null rule"); sameRule([], ruleDefaults, "a rule stored as an array"); // The back-compat case: exactly what the previous version wrote. sameRule({ enabled: true }, ruleDefaults, "an old enabled-only rule"); sameRule({ enabled: false }, Object.assign({}, ruleDefaults, { enabled: false }), "an old muted rule"); // Stale lock-screen fields, plus a key from no version at all. sameRule( { enabled: false, showOnLockScreen: false, showContentOnLockScreen: false, invented: 7 }, Object.assign({}, ruleDefaults, { enabled: false }), "stale and unknown fields" ); // A fully specified rule round-trips unchanged. const fullRule = { enabled: false, sound: false, display: "history", urgency: "critical", lastSeenMs: 1700000000000, name: "Signal", icon: "signal-desktop" }; sameRule(fullRule, fullRule, "a fully specified rule"); sameRule({ urgency: "low" }, Object.assign({}, ruleDefaults, { urgency: "low" }), "a low urgency override"); // Closed vocabularies. A value outside them is a stored typo, not a mode. sameRule({ display: "shouty" }, ruleDefaults, "an unknown display mode"); sameRule({ urgency: "urgent" }, ruleDefaults, "an unknown urgency override"); sameRule({ display: "banners", urgency: "auto" }, ruleDefaults, "the defaults spelled out"); // lastSeenMs feeds relative-time rendering, so a non-number or a negative has // to read as "never seen" rather than as a date in 1970 or in the future. for (const bad of ["nope", "", null, NaN, -1, undefined]) { sameRule({ lastSeenMs: bad }, ruleDefaults, `lastSeenMs stored as ${JSON.stringify(bad)}`); } for (const required of [ "rememberApplication", "appRule", "setAppRule", "handleNotification", // New in the redesign: deleting a rule outright, and the one answer the // bell, the timeout, the breakthrough gate and the card all read. "forgetApp", "effectiveUrgency" ]) { functionBody(required); } // Forgetting an application must DELETE its rule, not write a rule that // happens to be all-defaults. The difference shows up on the settings list: // a defaulted rule keeps the row, a deleted one lets it disappear until the // application notifies again. const forget = functionBody("forgetApp"); if (!/for \(const knownAppId of Object\.keys\(root\.appRules\)\)/.test(forget) || !/knownAppId !== appId/.test(forget)) fail("forgetApp does not rebuild the rule map without the forgotten key"); if (!forget.includes("DesktopPreferences.set(\"notificationAppRules\", next)")) fail("forgetApp does not persist the deletion"); // rememberApplication stamps the bookkeeping fields on every notification. // Without lastSeenMs nothing can be sorted into "Recent"; without the cached // name and icon an application that is not installed here draws as a raw id. const remember = functionBody("rememberApplication"); for (const stamped of ["lastSeenMs", "name:", "icon:"]) { if (!remember.includes(stamped)) fail(`rememberApplication does not stamp ${stamped}`); } if (!remember.includes("Date.now()")) fail("rememberApplication stamps a last-seen time that is not the clock"); // ── effectiveUrgency and its four consumers ──────────────────────────────── // // The override is only worth having if everything that asks about urgency // asks the same function. Reading notification.urgency directly anywhere in // this list would mean "treat as critical" changed the colour but not the // sound, or the sound but not the banner duration. const effective = functionBody("effectiveUrgency"); for (const required of ["rule.urgency === \"low\"", "rule.urgency === \"critical\"", "notification.urgency"]) { if (!effective.includes(required)) fail(`effectiveUrgency is missing ${required}`); } // The DECISION PATH of the bell, rather than the text of one function. Visual // alerts split the shared gates out into `bellWouldRing`, so the screen flash // can follow the same rules as the chime without following the event-sounds // switch (the rule is pinned above that function). The gates below did not // change; they moved one call up, and a contract reading only the body of // playBell would have reported the per-application sound switch as dropped. // // NOTE: this whole block is a bun -e argument in single quotes. No apostrophes. const playBell = functionBody("playBell"); const bell = playBell + functionBody("bellWouldRing"); // ...and the split only holds while playBell actually consults the predicate. // A playBell that stopped calling it would leave every gate below in a // function nothing runs, which reads exactly like a passing contract. if (!/bellWouldRing\(notification\)/.test(playBell)) fail("playBell no longer consults bellWouldRing, so the per-application, urgency and suppress-sound gates sit in a function the bell never calls"); if (!bell.includes("root.effectiveUrgency(notification) === NotificationUrgency.Low")) fail("the bell reads the claimed urgency rather than the effective one, so treat-as-low would still chime"); // The per-application sound switch is narrower than the enabled switch: the // notification still arrives and still shows, it just makes no noise. if (!/appRule\(root\.notificationAppId\(notification\)\)\.sound/.test(bell)) fail("the bell does not consult the per-application sound switch"); const timeout = functionBody("notificationTimeoutMs"); if (!timeout.includes("root.effectiveUrgency(notification) === NotificationUrgency.Critical")) fail("the banner duration reads the claimed urgency, so treat-as-critical would look critical but vanish at the normal timeout"); if (!card.includes("Notifs.effectiveUrgency(")) fail("the notification card draws its critical edge from the claimed urgency rather than the effective one"); // ── The Do Not Disturb gate ──────────────────────────────────────────────── // // It had exactly one exception (a focus mode allow list) and now has two. The // literal is asserted rather than a loosened shape: a bare if (!doNotDisturb) // would mean neither exception is consulted, and anything wider would let a // manual Do Not Disturb be overridden by something nobody switched on. const handler = functionBody("handleNotification"); if (!handler.includes("!root.doNotDisturb || FocusModes.allows(appId) || breaksThrough")) fail("the popup gate is no longer Do Not Disturb plus its two named exceptions"); if (!handler.includes("Settings.criticalBreaksThrough") || !handler.includes("root.effectiveUrgency(notification) === NotificationUrgency.Critical")) fail("the breakthrough exception is not the preference ANDed with the effective critical urgency"); // History-only files the notification and stops. It must reach pushHistory // and the unread count -- that is the whole point of it -- and must not reach // the popup list or playBell. if (!handler.includes("rule.display === \"history\"")) fail("the delivery path never consults the per-application display mode"); const historyOnlyAt = handler.indexOf("const historyOnly"); const pushAt = handler.indexOf("root.pushHistory(notification)"); const popupAt = handler.indexOf("root.popups = [notification].concat(root.popups)"); if (historyOnlyAt === -1 || pushAt === -1 || popupAt === -1) fail("could not locate the history-only branch, the history push and the popup push"); if (!(pushAt < popupAt)) fail("history-only would skip history as well as the banner, which is not what it means"); if (!/if \(historyOnly\)/.test(handler)) fail("history-only is not a branch that bypasses the banner and the bell together"); // The preference behind the gate, where the search index and the docs find it. if (!/key: "criticalBreaksThrough", type: "bool", def: false/.test(schema)) fail("criticalBreaksThrough is missing from the schema, or does not default to off"); if (!/key: "criticalBreaksThrough"[\s\S]{0,200}?group: "notifications"/.test(schema)) fail("criticalBreaksThrough is not in the notifications group, so it would route to the wrong page"); if (!settings.includes("criticalBreaksThrough: DesktopPreferences.get(\"criticalBreaksThrough\")")) fail("Settings does not expose criticalBreaksThrough, so the gate reads nothing"); // A muted application is rejected before anything is tracked, filed, counted // or shown. Everything past this point in the handler costs something, so the // order is the assertion. const trackedAt = handler.indexOf("notification.tracked = true"); const mutedAt = handler.indexOf("if (!rule.enabled)"); if (trackedAt === -1 || mutedAt === -1 || mutedAt > trackedAt) 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"); // Every rule in the map is rewritten through appRule() on the way out, so a // write can never persist a shape normalization would have rejected -- which // is what makes the closed field set above hold for stored data and not only // for what is read back. if (!source.includes("next[knownAppId] = root.appRule(knownAppId)")) fail("persisted rules are not normalized on the way to disk"); if (!source.includes("root.fallbackAppRules = next")) fail("missing-schema preference writes do not retain an in-memory fallback"); 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}`); } // The per-application controls are asserted over the page AND the components // it delegates rows to. The rebuild moved the expanded row body into its own // component, and pinning the page file alone would have made that refactor // look like the feature being deleted. const familyPaths = (process.env.PAGE_FAMILY ?? "").split(":").filter(path => path !== ""); const familyText = [page] .concat(await Promise.all(familyPaths.map(path => Bun.file(path).text()))) .join("\n"); 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 (!familyText.includes(required)) fail(`settings page is missing ${required}`); } // The four rules the redesign added are only real if the page offers all of // them. Each was shipped as a field on the rule before the UI existed, and a // stored field nothing can reach is the same bug the lock-screen pair was. for (const [required, why] of [ ["Notifs.forgetApp(", "no way to forget an application, so a rule is permanent once written"], ["sound:", "no per-application sound switch"], ["display:", "no banners-versus-history choice"], ["urgency:", "no per-application urgency override"] ]) { if (!familyText.includes(required)) fail(`the application rules card offers ${why} (missing ${required})`); } // Wording the spec pins, because it is the only place the consequence of // forgetting is explained. if (!familyText.includes("Forget this app")) fail("the expanded row has no Forget this app action"); if (!/returns on its next notification/.test(familyText)) fail("Forget this app does not say the rule comes back when the application notifies again"); // The Quiet card owns the breakthrough switch, and it belongs on the page the // notifications schema group routes to. if (!page.includes("criticalBreaksThrough")) fail("the Notifications page does not offer criticalBreaksThrough, which is in its schema group"); // 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 (familyText.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" # The rule store is a shipped schema key now. This contract used to graft a # temporary one into its copy of the schema, because the runtime half was # written before the key landed; grafting it today would define the key twice. rg -q 'key: "notificationAppRules", type: "json"' "$config_path/config/PreferenceSchema.qml" \ || fail 'the schema does not define notificationAppRules, so nothing here can persist' rg -q 'key: "criticalBreaksThrough", type: "bool"' "$config_path/config/PreferenceSchema.qml" \ || fail 'the schema does not define criticalBreaksThrough, so the breakthrough fixture would prove nothing' 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)" # The stored rule is checked field by field rather than against a literal, # because lastSeenMs is a wall clock: it has to be a real stamp (so "Recent" # can sort by it) and cannot be written down here. jq -e ' .appId == "org.signal.Signal.desktop" and (.initialRules | keys) == ["org.signal.Signal.desktop"] and (.initialRules["org.signal.Signal.desktop"] | keys | sort) == ["display", "enabled", "icon", "lastSeenMs", "name", "sound", "urgency"] and (.initialRules["org.signal.Signal.desktop"] | .enabled == true and .sound == true and .display == "banners" and .urgency == "auto" and .name == "Signal" and (.lastSeenMs | type) == "number" and .lastSeenMs > 0 and (.icon | type) == "string") 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" and .fallback.application.id == "Fallback Terminal" and .fallback.application.name == "Fallback Terminal" ' <<<"$exercise" >/dev/null || fail "runtime notification policy fixture failed: $exercise" # "History only" is the quiet filing cabinet: it still lands in history and # still counts as unread, and that is the whole of it -- no banner. The counts # distinguish it from muting, which is the mistake it would be easy to ship. history_only="$(qs_for_test ipc call notification-app-rules-test historyOnly)" jq -e '. == { tracked: true, history: 1, popups: 0, unread: 1 }' <<<"$history_only" >/dev/null \ || fail "a history-only application did not file quietly: $history_only" # Forgetting deletes the key. It comes back on the next notification at its # defaults -- which is what makes forgetting an undo rather than a mute. forgotten="$(qs_for_test ipc call notification-app-rules-test forgetting)" jq -e '. == { before: true, after: false, returned: true, soundAfterReturn: true, forgotUnknown: false }' <<<"$forgotten" >/dev/null || fail "forgetApp did not delete and restore a rule: $forgotten" # The urgency override is one answer, read by everything. The timeouts prove # the banner duration followed it and not only the colour. urgency="$(qs_for_test ipc call notification-app-rules-test urgency)" jq -e '. == { claimed: true, escalated: true, lowered: true, normalTimeout: true, escalatedTimeout: true }' <<<"$urgency" >/dev/null || fail "the per-application urgency override was not honored: $urgency" # The one exception to Do Not Disturb that is not a focus mode. Off by # default; on, it lets through exactly the effective-critical notifications # and nothing else. breakthrough="$(qs_for_test ipc call notification-app-rules-test breakthrough)" jq -e '. == { held: 0, through: 1, ordinary: 0, escalated: 1 }' <<<"$breakthrough" >/dev/null \ || fail "the critical breakthrough gate is wrong: $breakthrough" persisted="$(qs_for_test ipc call notification-app-rules-test persist)" jq -e ' (keys) == ["org.persist.App.desktop"] and (.["org.persist.App.desktop"] | .enabled == false and .sound == true and .display == "banners" and .urgency == "auto" and (.lastSeenMs | type) == "number" and .lastSeenMs > 0) ' <<<"$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'