491 lines
24 KiB
Bash
Executable File
491 lines
24 KiB
Bash
Executable File
#!/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 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}`);
|
|
}
|
|
|
|
const bell = functionBody("playBell");
|
|
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'
|