* Replay the history a dismissal or a clear was still being written into The popup files a replay reads are written by a serialized queue of shell jobs, and the read ran as its own process alongside it. A dismissal issued a moment earlier could still be queued when the directory was read, leaving the notification out of the replay it was the newest entry of, and a clear issued a moment earlier could still be queued too, replaying entries it was about to remove. The read now waits for the queue to go idle, so the replay shows the history as of the moment it was asked for rather than whichever jobs happened to have landed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Catch up on an update that arrived before its popup had a row Watching a notification for in-place updates starts the moment it is handed over, but the row those updates write to is inserted a tick later, deferred to keep a mid-incubation Repeater from being mutated underneath. A client fast enough to update inside that window found no row to write to, and a property that has already changed does not change again — so the toast and its file sat on the superseded content until something else moved. The row is now refreshed from the live notification once it exists. That reads the same object the signals would have, so an update that beat the insert is picked up and one that did not costs nothing: a refresh whose content matches the row it would write is dropped, which also collapses the several signals a single multi-property update emits into one rewrite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Hold queued file work behind the replay's read, not just ahead of it The read waited for everything queued before it, but nothing stopped the queue from running on while it worked. A clear or an archive issued during the read could delete or move files out from under awk mid-glob, so a replay could still show a partial history — some of what a clear was in the middle of emptying. The read is a barrier in both directions now: the queue holds until it exits, and it releases on exit rather than on output, so a read that comes back empty or fails cannot park the queue behind it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Queue the replay's read instead of waiting for the queue to empty Waiting for the queue to go idle before starting the read still let work overtake it. A clear or an archive enqueued after the replay was asked for, while the current job was running, was dequeued the moment that job exited — the read only starts once nothing is left — so the replay showed the state after those jobs, which is the race this was meant to close. Unbroken file traffic could postpone the read indefinitely for the same reason. The read is now an entry in that queue rather than a process running beside it. It takes its place in line behind the work queued before the request and ahead of everything queued after, so no later job can overtake it and no amount of traffic can push it back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
458 lines
18 KiB
Bash
458 lines
18 KiB
Bash
#!/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('<img src="x">Hello', 'Slack', ''),
|
|
'Hello',
|
|
'notifications strip inline image tags'
|
|
)
|
|
|
|
assertEqual(
|
|
notifications.sanitizeBody('<a href="https://example.com">example.com</a> 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')
|
|
|
|
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'
|
|
)
|
|
|
|
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'
|
|
)
|
|
|
|
// A click action carried as a command 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-exec': 'omarchy-menu-keybindings' } }, 1).exec,
|
|
'omarchy-menu-keybindings',
|
|
'notifications capture the click command from the exec hint'
|
|
)
|
|
assertEqual(
|
|
notifications.snapshotOf({ id: 3, hints: { 'omarchy-glyph': '!' } }, 1).exec,
|
|
'',
|
|
'notifications leave the click command empty without an exec hint'
|
|
)
|
|
assertEqual(
|
|
notifications.popupEntry(
|
|
JSON.parse(notifications.serializePopup({ id: 1, originalId: 1, timestamp: 5, exec: "mpv '/tmp/a b.mp4'" }, 1)),
|
|
1
|
|
).exec,
|
|
"mpv '/tmp/a b.mp4'",
|
|
'notifications round-trip the click command through popup files'
|
|
)
|
|
assertEqual(
|
|
notifications.popupEntry({ id: 1, originalId: 1, timestamp: 5 }, 1).exec,
|
|
'',
|
|
'notifications restore an empty click command for popups without one'
|
|
)
|
|
assertEqual(
|
|
notifications.historyEntry({ id: 1, exec: 'xdg-open /tmp/received' }, 1).exec,
|
|
'xdg-open /tmp/received',
|
|
'notifications keep the click command on history rows'
|
|
)
|
|
|
|
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 \\"-\$2\\"/.test(serviceQml),
|
|
'notifications service trims history to the newest entries in the same job'
|
|
)
|
|
assert(
|
|
/if \(!isEphemeral\(notification\)\) writeHistoryFile\(snapshot\)/.test(serviceQml),
|
|
'notifications service records DND-silenced notifications straight into history'
|
|
)
|
|
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(
|
|
/var command = entry \? String\(entry\.exec \|\| ""\) : ""[\s\S]{0,300}?Util\.execDetached\(command\)/.test(serviceQml),
|
|
'notifications service runs the popup click command 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
|