feat: keep background Updates badge current (Closes #1281)
CI / lint (pull_request) Successful in 4m52s
CI / build-release (pull_request) Successful in 15s
CI / browser-journey (pull_request) Successful in 7m5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-22 22:11:52 +00:00
1 parent b18a50fec5
commit 05405bf2e8
11 files changed
+172 -12

No files matched your search

+5 -5
View File
@@ -364,7 +364,7 @@ process.stdout.write(JSON.stringify(state));
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
]
assert result["remaining"] == ["gitea.preference"]
assert result["replaced"] == [
@@ -392,7 +392,7 @@ process.stdout.write(JSON.stringify(state));
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
@@ -525,7 +525,7 @@ process.stdout.write(JSON.stringify(state));
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
@@ -550,7 +550,7 @@ process.stdout.write(JSON.stringify(state));
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
@@ -648,7 +648,7 @@ process.stdout.write(JSON.stringify(state));
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["assigned"] == "/dashboard/login"
+24
View File
@@ -186,3 +186,27 @@ console.log(JSON.stringify({changed, saved:saved.has('stackchain.app-badge.enabl
"saved": False,
"status": "App icon badge is off, but the browser could not clear the old count.",
}
def test_app_badge_synchronizes_opt_in_with_the_active_service_worker():
result = run_badge("""
const listeners = {};
const messages = [];
const control = {checked:false, disabled:false, addEventListener(name, callback) { listeners[name] = callback; }};
const controller = createMobileAppBadge({
control, status:{textContent:''}, container:{hidden:false},
navigator:{async setAppBadge() {}, async clearAppBadge() {}},
storage:{getItem() { return null; }, setItem() {}, removeItem() {}},
serviceWorker:{ready:Promise.resolve({active:{postMessage(message) { messages.push(message); }}})},
});
controller.start();
control.checked = true;
await listeners.change();
control.checked = false;
await listeners.change();
console.log(JSON.stringify(messages));
""")
assert result == [
{"type": "stackchain-app-badge-preference", "enabled": True},
{"type": "stackchain-app-badge-preference", "enabled": False},
]
+1
View File
@@ -34,6 +34,7 @@ process.stdout.write(JSON.stringify(databases));
"stackchain-search-reply-drafts-v1",
"stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
"stackchain-app-badge-preference-v1",
}
+1
View File
@@ -51,6 +51,7 @@ const clear = createPrivateDeviceDataPurger({{
"stackchain-search-reply-drafts-v1",
"stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
"stackchain-app-badge-preference-v1",
]
assert state["caches"] == ["stackchain-dashboard-shell-v37"]
assert state["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
+4
View File
@@ -613,6 +613,7 @@ async def test_dispatch_sends_one_privacy_safe_deep_link_per_new_thread(tmp_path
"route": "#/my-work/update/42",
"tag": "stackchain-update-42",
"notification_id": 42,
"unread_count": 1,
}
assert "private/repo" not in json.dumps(sent)
assert "Secret title" not in json.dumps(sent)
@@ -692,7 +693,9 @@ async def test_update_burst_sends_bounded_individual_pushes_and_one_private_dige
"route": "#/my-work/updates",
"tag": "stackchain-update-digest",
"update_count": 3,
"unread_count": 5,
}
assert [payload["unread_count"] for payload in sent] == [5, 5, 5]
assert "private/repo" not in json.dumps(sent)
assert "Secret update" not in json.dumps(sent)
assert await dispatch_unread_updates(
@@ -792,6 +795,7 @@ async def test_production_notification_page_dispatches_one_unread_push(tmp_path)
"route": "#/my-work/update/42",
"tag": "stackchain-update-42",
"notification_id": 42,
"unread_count": 1,
}]
assert "private/repo" not in json.dumps(sent)
assert "Secret title" not in json.dumps(sent)
+44 -1
View File
@@ -14,7 +14,7 @@ def run_worker_scenario(scenario: str) -> dict:
const fs = require('fs');
const vm = require('vm');
const listeners = {{}};
const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, todayCommands: {{}}, failTodayCommandPut: false, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], closedNotifications: 0, focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, todayCommands: {{}}, failTodayCommandPut: false, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], closedNotifications: 0, focused: [], opened: [], appBadges: [], clearedAppBadges: 0, badgeEnabled: false, failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
const storedResponses = new Map();
storedResponses.set(
'https://forge.example/dashboard/__offline-session-lease',
@@ -78,6 +78,10 @@ const context = {{
}},
purge: async () => {{ state.todayCommands = {{}}; }},
}},
__STACKCHAIN_APP_BADGE_PREFERENCE: {{
get: async () => state.badgeEnabled,
set: async enabled => {{ state.badgeEnabled = enabled; }},
}},
addEventListener: (name, handler) => {{ listeners[name] = handler; }},
skipWaiting: async () => {{ state.skipped = true; }},
clients: {{
@@ -88,6 +92,8 @@ const context = {{
registration: {{
showNotification: async (title, options) => state.notifications.push({{title,options}}),
getNotifications: async () => [{{close:()=>{{state.closedNotifications += 1;}}}}],
setAppBadge: async value => state.appBadges.push(value),
clearAppBadge: async () => {{ state.clearedAppBadges += 1; }},
}},
__issueSync: {{
flush: async () => {{ state.backgroundFlushes += 1; state.outboxLifecycle.push('flush'); return state.flushResult; }},
@@ -479,6 +485,7 @@ def test_revoked_background_session_purges_worker_data_and_notifies_dashboard_cl
"stackchain-search-reply-drafts-v1",
"stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
"stackchain-app-badge-preference-v1",
]
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
assert result["state"]["clientMessages"] == [
@@ -800,6 +807,7 @@ def test_device_purge_message_stops_worker_outbox_and_acknowledges_completion():
"stackchain-search-reply-drafts-v1",
"stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
"stackchain-app-badge-preference-v1",
]
assert result["replies"] == [{"ok": True}]
@@ -973,6 +981,40 @@ def test_update_digest_push_opens_unread_inbox_without_item_actions_or_private_c
assert "must-not-render" not in json.dumps(result["notifications"])
def test_opted_in_background_updates_reconcile_authoritative_badge_without_churn():
result = run_worker_scenario(
"""
await dispatchMessage({type:'stackchain-app-badge-preference', enabled:true}, [], {url:'https://forge.example/dashboard/'});
await dispatchPush({tag:'stackchain-update-42', route:'#/my-work/update/42', notification_id:42, unread_count:7});
await dispatchPush({tag:'stackchain-update-digest', route:'#/my-work/updates', update_count:2, unread_count:7});
await dispatchPush({tag:'stackchain-deadline-digest-2026-08-13', route:'#/my-work/agenda', protect_route:'#/my-work/agenda/protect-today', deadline_count:1, unread_count:99});
await dispatchPush({tag:'stackchain-update-43', route:'#/my-work/update/43', notification_id:43, unread_count:'invalid'});
process.stdout.write(JSON.stringify(state));
"""
)
assert result["badgeEnabled"] is True
assert result["appBadges"] == [7]
assert result["clearedAppBadges"] == 0
assert len(result["notifications"]) == 4
def test_disabling_background_badge_clears_it_and_future_updates_leave_it_off():
result = run_worker_scenario(
"""
state.badgeEnabled = true;
await dispatchPush({tag:'stackchain-update-42', route:'#/my-work/update/42', notification_id:42, unread_count:3});
await dispatchMessage({type:'stackchain-app-badge-preference', enabled:false}, [], {url:'https://forge.example/dashboard/'});
await dispatchPush({tag:'stackchain-update-43', route:'#/my-work/update/43', notification_id:43, unread_count:4});
process.stdout.write(JSON.stringify(state));
"""
)
assert result["badgeEnabled"] is False
assert result["appBadges"] == [3]
assert result["clearedAppBadges"] == 1
def test_deadline_digest_push_offers_protect_today_and_snooze_without_rendering_private_copy():
result = run_worker_scenario(
"""
@@ -1565,6 +1607,7 @@ def test_expired_offline_lease_purges_private_worker_data_and_refuses_cached_she
"stackchain-search-reply-drafts-v1",
"stackchain-conversation-reply-drafts-v1",
"stackchain-today-action-mailbox-v1",
"stackchain-app-badge-preference-v1",
]
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
assert "private cached dashboard" not in result["body"]