From a004e49ebfdccd5c47f0a2069595e6c6e3ae0e02 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 15 Jun 2026 10:52:52 -0700 Subject: [PATCH] Release OW (v0.51.436): cron sessions mark unread on new runs (#3460) (#4253) * stage-3921: cron-session unread disambiguation (#3460, rodboev) * Release OW: cron sessions mark unread on new runs (#3460) Self-rebased rodboev's #3921 onto v0.51.435; full Codex+Opus+suite gate. Fixes cron-session unread cross-attribution: resolver now considers all known job ids and attributes each session to the longest matching cron__ prefix (provably unambiguous). Read-only state.db scan, fires only on real completions. Co-authored-by: rodboev --------- Co-authored-by: nesquena-hermes Co-authored-by: rodboev --- CHANGELOG.md | 6 +- api/routes.py | 101 +++- static/panels.js | 3 + static/sessions.js | 19 + tests/test_cron_toast_notifications.py | 15 + tests/test_issue3460_cron_session_unread.py | 536 ++++++++++++++++++++ 6 files changed, 678 insertions(+), 2 deletions(-) create mode 100644 tests/test_issue3460_cron_session_unread.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 75571dbf..c31d1ea8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,11 @@ ## [Unreleased] -## [v0.51.435] — 2026-06-15 — Release OV (supported local pytest runner) +## [v0.51.436] — 2026-06-15 — Release OW (cron sessions mark unread on new runs) + +### Fixed + +- **Cron sessions in the sidebar now mark unread when a new run lands, attributed to the correct job.** When a scheduled job finished, the sidebar's unread resolver matched a session id against `cron__` prefixes built only from the just-completed jobs, so a job whose id is a prefix of another's (e.g. `backup` vs `backup_full`) could steal the other's session (`cron_backup_full_…` resolving to `backup`). The resolver now considers all known job ids and attributes each session to the longest matching job-id prefix, which is provably unambiguous (the matching prefixes of a single session id are nested, so the longest is unique). The lookup is a read-only `state.db` scan that only runs when a cron actually completed, degrades gracefully on contention, and filters in Python rather than via SQL `LIKE`. (#3460) ### Added diff --git a/api/routes.py b/api/routes.py index f82ea13c..8b600949 100644 --- a/api/routes.py +++ b/api/routes.py @@ -249,6 +249,95 @@ _CLIENT_EVENT_ALLOWED_FIELDS = { } +def _normalize_cron_job_ids(job_ids) -> list[str]: + seen = set() + normalized = [] + for job_id in job_ids or []: + jid = str(job_id or "").strip() + if not jid or jid in seen: + continue + seen.add(jid) + normalized.append(jid) + return normalized + + +def _latest_cron_session_info_for_jobs( + job_ids, completed_job_ids=None +) -> dict[str, dict[str, int | str | None]]: + """Return newest persisted cron session info keyed by completed cron job id.""" + normalized = _normalize_cron_job_ids(job_ids) + requested = _normalize_cron_job_ids(completed_job_ids if completed_job_ids is not None else job_ids) + if not requested: + return {} + if not normalized: + return {jid: {"session_id": "", "message_count": None} for jid in requested} + db_path = _active_state_db_path() + if not db_path or not Path(db_path).exists(): + return {jid: {"session_id": "", "message_count": None} for jid in requested} + try: + with closing(sqlite3.connect(str(db_path))) as conn: + conn.row_factory = sqlite3.Row + cur = conn.cursor() + cur.execute("PRAGMA table_info(sessions)") + session_cols = {row[1] for row in cur.fetchall()} + if "id" not in session_cols or "source" not in session_cols: + return {jid: {"session_id": "", "message_count": None} for jid in requested} + select_message_count = ( + "s.message_count AS message_count" + if "message_count" in session_cols + else "NULL AS message_count" + ) + if "started_at" in session_cols: + query = f""" + SELECT s.id, + {select_message_count} + FROM sessions s + WHERE LOWER(COALESCE(s.source, '')) = 'cron' + ORDER BY COALESCE(s.started_at, 0) DESC, s.id DESC -- newest start, not last activity + """ + else: + query = f""" + SELECT s.id, + {select_message_count} + FROM sessions s + WHERE LOWER(COALESCE(s.source, '')) = 'cron' + ORDER BY s.id DESC + """ + cur.execute(query) + results = { + jid: {"session_id": "", "message_count": None} for jid in requested + } + requested_ids = set(requested) + prefixes = {jid: f"cron_{jid}_" for jid in normalized} + for row in cur.fetchall(): + sid = str(row["id"] or "") + if not sid: + continue + matches = [ + jid + for jid in normalized + if sid.startswith(prefixes[jid]) + ] + if matches: + jid = max(matches, key=len) + if jid not in requested_ids or results[jid]["session_id"]: + continue + results[jid] = { + "session_id": sid, + "message_count": ( + int(row["message_count"]) + if row["message_count"] is not None + else None + ), + } + if all(info["session_id"] for info in results.values()): + break + return results + except sqlite3.Error: + return {jid: {"session_id": "", "message_count": None} for jid in requested} + + + def _session_field(session, field, default=None): if isinstance(session, dict): return session.get(field, default) @@ -12596,6 +12685,7 @@ def _handle_cron_recent(handler, parsed): jobs = list_jobs(include_disabled=True) completions = [] for job in jobs: + job_id = str(job.get("id", "") or "") last_run = job.get("last_run_at") if not last_run: continue @@ -12611,13 +12701,22 @@ def _handle_cron_recent(handler, parsed): if ts > since: completions.append( { - "job_id": job.get("id", ""), + "job_id": job_id, "name": job.get("name", "Unknown"), "status": job.get("last_status", "unknown"), "completed_at": ts, "toast_notifications": job.get("toast_notifications") is not False, } ) + latest_session_info = _latest_cron_session_info_for_jobs( + [job.get("id", "") for job in jobs], + [c["job_id"] for c in completions], + ) + for completion in completions: + info = latest_session_info.get(str(completion.get("job_id", "") or ""), {}) + completion["session_id"] = str(info.get("session_id", "") or "") + if info.get("message_count") is not None: + completion["message_count"] = int(info["message_count"]) return j(handler, {"completions": completions, "since": since}) except ImportError: return j(handler, {"completions": [], "since": since}) diff --git a/static/panels.js b/static/panels.js index 1e5dc259..210fb593 100644 --- a/static/panels.js +++ b/static/panels.js @@ -8361,6 +8361,9 @@ function startCronPolling(){ } _cronPollSince=Math.max(_cronPollSince,c.completed_at); if(c.job_id) _cronNewJobIds.add(String(c.job_id)); + if(c.session_id && typeof _markSessionCompletionUnreadIfBackground === 'function'){ + _markSessionCompletionUnreadIfBackground(c.session_id, c.message_count); + } } // _cronUnreadCount is derived from _cronNewJobIds.size in updateCronBadge. updateCronBadge(); diff --git a/static/sessions.js b/static/sessions.js index 9255a011..ce2f8668 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -255,6 +255,25 @@ function _markSessionCompletionUnread(sid, messageCount = 0) { _saveSessionCompletionUnread(); } +function _markSessionCompletionUnreadIfBackground(sid, messageCount = null) { + if (!sid) return false; + let count = Number.isFinite(messageCount) ? Number(messageCount) : NaN; + if (!Number.isFinite(count)) { + const snapshot = _sessionListSnapshotById.get(sid) + || (_allSessions || []).find(s => s && s.session_id === sid) + || null; + count = Number(snapshot && snapshot.message_count) || 0; + } + if (_isSessionActivelyViewedForList(sid)) { + _setSessionViewedCount(sid, count); + if (typeof renderSessionListFromCache === 'function') renderSessionListFromCache(); + return false; + } + _markSessionCompletionUnread(sid, count); + if (typeof renderSessionListFromCache === 'function') renderSessionListFromCache(); + return true; +} + function _clearSessionCompletionUnread(sid) { if (!sid) return; const unread = _getSessionCompletionUnread(); diff --git a/tests/test_cron_toast_notifications.py b/tests/test_cron_toast_notifications.py index 4d14befb..94f19922 100644 --- a/tests/test_cron_toast_notifications.py +++ b/tests/test_cron_toast_notifications.py @@ -87,6 +87,17 @@ def test_cron_recent_marks_muted_jobs_without_requesting_toast(monkeypatch): "toast_notifications": False, }, ] + monkeypatch.setattr( + routes, + "_latest_cron_session_info_for_jobs", + lambda job_ids, completed_job_ids=None: { + str(job_id): { + "session_id": f"cron_{job_id}_latest", + "message_count": 3 if str(job_id) == "loud" else 5, + } + for job_id in (completed_job_ids or job_ids) + }, + ) monkeypatch.setitem(sys.modules, "cron", cron_pkg) monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs) @@ -97,7 +108,11 @@ def test_cron_recent_marks_muted_jobs_without_requesting_toast(monkeypatch): assert handler.status == 200 by_id = {item["job_id"]: item for item in body["completions"]} assert by_id["loud"]["toast_notifications"] is True + assert by_id["loud"]["session_id"] == "cron_loud_latest" + assert by_id["loud"]["message_count"] == 3 assert by_id["muted"]["toast_notifications"] is False + assert by_id["muted"]["session_id"] == "cron_muted_latest" + assert by_id["muted"]["message_count"] == 5 def test_cron_create_persists_muted_toast_setting_after_create(monkeypatch): diff --git a/tests/test_issue3460_cron_session_unread.py b/tests/test_issue3460_cron_session_unread.py new file mode 100644 index 00000000..f3cbbf3a --- /dev/null +++ b/tests/test_issue3460_cron_session_unread.py @@ -0,0 +1,536 @@ +"""Regression coverage for #3460 cron session unread badges.""" + +from __future__ import annotations + +import io +import json +import shutil +import sqlite3 +import subprocess +import sys +import types +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +REPO = Path(__file__).resolve().parents[1] +SESSIONS_JS_PATH = REPO / "static" / "sessions.js" +PANELS_JS_PATH = REPO / "static" / "panels.js" +NODE = shutil.which("node") + +pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH") + + +class _JSONHandler: + def __init__(self): + self.status = None + self.headers = {} + self.response_headers = [] + self.wfile = io.BytesIO() + + def send_response(self, status): + self.status = status + + def send_header(self, key, value): + self.response_headers.append((key, value)) + + def end_headers(self): + pass + + +def _payload(handler): + return json.loads(handler.wfile.getvalue().decode("utf-8")) + + +def _run_node(script: str) -> dict: + assert NODE is not None + result = subprocess.run( + [NODE, "-e", script], + check=True, + capture_output=True, + text=True, + timeout=10, + ) + return json.loads(result.stdout) + + +def test_cron_recent_returns_latest_session_id_for_job(monkeypatch, tmp_path): + import api.routes as routes + + db_path = tmp_path / "state.db" + with sqlite3.connect(str(db_path)) as conn: + conn.execute( + """ + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source TEXT, + started_at REAL + ) + """ + ) + conn.execute( + "INSERT INTO sessions(id, source, started_at) VALUES (?, ?, ?)", + ("cron_job3460_20260610_060000", "cron", 100.0), + ) + conn.execute( + "INSERT INTO sessions(id, source, started_at) VALUES (?, ?, ?)", + ("cron_job3460_20260610_070000", "cron", 200.0), + ) + conn.execute( + "INSERT INTO sessions(id, source, started_at) VALUES (?, ?, ?)", + ("telegram_job3460_20260610_080000", "telegram", 300.0), + ) + + cron_pkg = types.ModuleType("cron") + cron_pkg.__path__ = [] + cron_jobs = types.ModuleType("cron.jobs") + cron_jobs.list_jobs = lambda include_disabled=True: [ + { + "id": "job3460", + "name": "Morning Briefing", + "last_run_at": 250, + "last_status": "success", + } + ] + monkeypatch.setattr(routes, "_active_state_db_path", lambda: db_path) + monkeypatch.setitem(sys.modules, "cron", cron_pkg) + monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs) + + handler = _JSONHandler() + routes._handle_cron_recent(handler, SimpleNamespace(query="since=0")) + + body = _payload(handler) + assert handler.status == 200 + assert body["completions"][0]["job_id"] == "job3460" + assert body["completions"][0]["session_id"] == "cron_job3460_20260610_070000" + assert body["completions"][0].get("message_count") is None + + +def test_cron_recent_falls_back_to_id_order_when_started_at_missing(monkeypatch, tmp_path): + import api.routes as routes + + db_path = tmp_path / "state.db" + with sqlite3.connect(str(db_path)) as conn: + conn.execute( + """ + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source TEXT + ) + """ + ) + conn.execute( + "INSERT INTO sessions(id, source) VALUES (?, ?)", + ("cron_job3460_20260610_060000", "cron"), + ) + conn.execute( + "INSERT INTO sessions(id, source) VALUES (?, ?)", + ("cron_job3460_20260610_070000", "cron"), + ) + + cron_pkg = types.ModuleType("cron") + cron_pkg.__path__ = [] + cron_jobs = types.ModuleType("cron.jobs") + cron_jobs.list_jobs = lambda include_disabled=True: [ + { + "id": "job3460", + "name": "Morning Briefing", + "last_run_at": 250, + "last_status": "success", + } + ] + monkeypatch.setattr(routes, "_active_state_db_path", lambda: db_path) + monkeypatch.setitem(sys.modules, "cron", cron_pkg) + monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs) + + handler = _JSONHandler() + routes._handle_cron_recent(handler, SimpleNamespace(query="since=0")) + + body = _payload(handler) + assert handler.status == 200 + assert body["completions"][0]["session_id"] == "cron_job3460_20260610_070000" + + +def test_cron_recent_escapes_like_wildcards_in_job_id(monkeypatch, tmp_path): + import api.routes as routes + + db_path = tmp_path / "state.db" + with sqlite3.connect(str(db_path)) as conn: + conn.execute( + """ + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source TEXT, + started_at REAL, + message_count INTEGER + ) + """ + ) + conn.execute( + "INSERT INTO sessions(id, source, started_at, message_count) VALUES (?, ?, ?, ?)", + ("cron_job_3460%_20260610_090000", "cron", 300.0, 11), + ) + conn.execute( + "INSERT INTO sessions(id, source, started_at, message_count) VALUES (?, ?, ?, ?)", + ("cron_jobA3460x_20260610_100000", "cron", 400.0, 99), + ) + + cron_pkg = types.ModuleType("cron") + cron_pkg.__path__ = [] + cron_jobs = types.ModuleType("cron.jobs") + cron_jobs.list_jobs = lambda include_disabled=True: [ + { + "id": "job_3460%", + "name": "Wildcard Job", + "last_run_at": 500, + "last_status": "success", + } + ] + monkeypatch.setattr(routes, "_active_state_db_path", lambda: db_path) + monkeypatch.setitem(sys.modules, "cron", cron_pkg) + monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs) + + handler = _JSONHandler() + routes._handle_cron_recent(handler, SimpleNamespace(query="since=0")) + + body = _payload(handler) + assert handler.status == 200 + assert body["completions"][0]["session_id"] == "cron_job_3460%_20260610_090000" + assert body["completions"][0]["message_count"] == 11 + + +def test_cron_recent_does_not_cross_match_shared_job_prefixes(monkeypatch, tmp_path): + import api.routes as routes + + db_path = tmp_path / "state.db" + with sqlite3.connect(str(db_path)) as conn: + conn.execute( + """ + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source TEXT, + started_at REAL, + message_count INTEGER + ) + """ + ) + conn.execute( + "INSERT INTO sessions(id, source, started_at, message_count) VALUES (?, ?, ?, ?)", + ("cron_backup_20260610_090000", "cron", 100.0, 4), + ) + conn.execute( + "INSERT INTO sessions(id, source, started_at, message_count) VALUES (?, ?, ?, ?)", + ("cron_backup_full_20260610_100000", "cron", 200.0, 8), + ) + + cron_pkg = types.ModuleType("cron") + cron_pkg.__path__ = [] + cron_jobs = types.ModuleType("cron.jobs") + cron_jobs.list_jobs = lambda include_disabled=True: [ + { + "id": "backup", + "name": "Backup", + "last_run_at": 150, + "last_status": "success", + }, + { + "id": "backup_full", + "name": "Backup Full", + "last_run_at": 250, + "last_status": "success", + }, + ] + monkeypatch.setattr(routes, "_active_state_db_path", lambda: db_path) + monkeypatch.setitem(sys.modules, "cron", cron_pkg) + monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs) + + handler = _JSONHandler() + routes._handle_cron_recent(handler, SimpleNamespace(query="since=0")) + + body = _payload(handler) + assert handler.status == 200 + by_id = {item["job_id"]: item for item in body["completions"]} + assert by_id["backup"]["session_id"] == "cron_backup_20260610_090000" + assert by_id["backup"]["message_count"] == 4 + assert by_id["backup_full"]["session_id"] == "cron_backup_full_20260610_100000" + assert by_id["backup_full"]["message_count"] == 8 + + +def test_cron_recent_does_not_steal_older_long_prefix_history_for_short_job( + monkeypatch, tmp_path +): + import api.routes as routes + + db_path = tmp_path / "state.db" + with sqlite3.connect(str(db_path)) as conn: + conn.execute( + """ + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source TEXT, + started_at REAL, + message_count INTEGER + ) + """ + ) + conn.execute( + "INSERT INTO sessions(id, source, started_at, message_count) VALUES (?, ?, ?, ?)", + ("cron_backup_full_20260610_110000", "cron", 300.0, 12), + ) + conn.execute( + "INSERT INTO sessions(id, source, started_at, message_count) VALUES (?, ?, ?, ?)", + ("cron_backup_full_20260610_100000", "cron", 200.0, 8), + ) + conn.execute( + "INSERT INTO sessions(id, source, started_at, message_count) VALUES (?, ?, ?, ?)", + ("cron_backup_20260610_090000", "cron", 100.0, 4), + ) + + cron_pkg = types.ModuleType("cron") + cron_pkg.__path__ = [] + cron_jobs = types.ModuleType("cron.jobs") + cron_jobs.list_jobs = lambda include_disabled=True: [ + { + "id": "backup", + "name": "Backup", + "last_run_at": 150, + "last_status": "success", + }, + { + "id": "backup_full", + "name": "Backup Full", + "last_run_at": 50, + "last_status": "success", + }, + ] + monkeypatch.setattr(routes, "_active_state_db_path", lambda: db_path) + monkeypatch.setitem(sys.modules, "cron", cron_pkg) + monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs) + + handler = _JSONHandler() + routes._handle_cron_recent(handler, SimpleNamespace(query="since=0")) + + body = _payload(handler) + assert handler.status == 200 + assert body["completions"][0]["job_id"] == "backup" + assert body["completions"][0]["session_id"] == "cron_backup_20260610_090000" + assert body["completions"][0]["message_count"] == 4 + + +def test_cron_recent_does_not_cross_match_newer_long_prefix_session_when_only_short_job_completed( + monkeypatch, tmp_path +): + import api.routes as routes + + db_path = tmp_path / "state.db" + with sqlite3.connect(str(db_path)) as conn: + conn.execute( + """ + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source TEXT, + started_at REAL, + message_count INTEGER + ) + """ + ) + conn.execute( + "INSERT INTO sessions(id, source, started_at, message_count) VALUES (?, ?, ?, ?)", + ("cron_backup_full_20260610_110000", "cron", 300.0, 12), + ) + conn.execute( + "INSERT INTO sessions(id, source, started_at, message_count) VALUES (?, ?, ?, ?)", + ("cron_backup_20260610_090000", "cron", 100.0, 4), + ) + + cron_pkg = types.ModuleType("cron") + cron_pkg.__path__ = [] + cron_jobs = types.ModuleType("cron.jobs") + cron_jobs.list_jobs = lambda include_disabled=True: [ + { + "id": "backup", + "name": "Backup", + "last_run_at": 250, + "last_status": "success", + }, + { + "id": "backup_full", + "name": "Backup Full", + "last_run_at": 150, + "last_status": "success", + }, + ] + monkeypatch.setattr(routes, "_active_state_db_path", lambda: db_path) + monkeypatch.setitem(sys.modules, "cron", cron_pkg) + monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs) + + handler = _JSONHandler() + routes._handle_cron_recent(handler, SimpleNamespace(query="since=200")) + + body = _payload(handler) + assert handler.status == 200 + assert body["completions"] == [ + { + "job_id": "backup", + "name": "Backup", + "status": "success", + "completed_at": 250.0, + "toast_notifications": True, + "session_id": "cron_backup_20260610_090000", + "message_count": 4, + } + ] + + +def test_sessions_helper_marks_background_completion_with_existing_snapshot(): + script = f""" +const fs = require('fs'); +const src = fs.readFileSync({json.dumps(str(SESSIONS_JS_PATH))}, 'utf8'); +function extractFunc(name) {{ + const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\('); + const start = src.search(re); + if (start < 0) throw new Error(name + ' not found'); + let i = src.indexOf('{{', start); + let depth = 1; i++; + while (depth > 0 && i < src.length) {{ + if (src[i] === '{{') depth++; + else if (src[i] === '}}') depth--; + i++; + }} + return src.slice(start, i); +}} +let _sessionListSnapshotById = new Map([['cron_1', {{message_count: 7}}]]); +let _allSessions = []; +let viewed = []; +let unread = []; +let renders = 0; +function _isSessionActivelyViewedForList() {{ return false; }} +function _setSessionViewedCount(sid, count) {{ viewed.push([sid, count]); }} +function _markSessionCompletionUnread(sid, count) {{ unread.push([sid, count]); }} +function renderSessionListFromCache() {{ renders += 1; }} +global.window = {{}}; + eval(extractFunc('_markSessionCompletionUnreadIfBackground')); + const result = _markSessionCompletionUnreadIfBackground('cron_1'); + console.log(JSON.stringify({{result, viewed, unread, renders}})); +""" + payload = _run_node(script) + + assert payload == { + "result": True, + "viewed": [], + "unread": [["cron_1", 7]], + "renders": 1, + } + + +def test_sessions_helper_marks_actively_viewed_completion_as_read(): + script = f""" +const fs = require('fs'); +const src = fs.readFileSync({json.dumps(str(SESSIONS_JS_PATH))}, 'utf8'); +function extractFunc(name) {{ + const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\('); + const start = src.search(re); + if (start < 0) throw new Error(name + ' not found'); + let i = src.indexOf('{{', start); + let depth = 1; i++; + while (depth > 0 && i < src.length) {{ + if (src[i] === '{{') depth++; + else if (src[i] === '}}') depth--; + i++; + }} + return src.slice(start, i); +}} +let _sessionListSnapshotById = new Map(); +let _allSessions = [{{session_id: 'cron_2', message_count: 9}}]; +let viewed = []; +let unread = []; +let renders = 0; +function _isSessionActivelyViewedForList(sid) {{ return sid === 'cron_2'; }} +function _setSessionViewedCount(sid, count) {{ viewed.push([sid, count]); }} +function _markSessionCompletionUnread(sid, count) {{ unread.push([sid, count]); }} +function renderSessionListFromCache() {{ renders += 1; }} +global.window = {{}}; +eval(extractFunc('_markSessionCompletionUnreadIfBackground')); +const result = _markSessionCompletionUnreadIfBackground('cron_2'); +console.log(JSON.stringify({{result, viewed, unread, renders}})); +""" + payload = _run_node(script) + + assert payload == { + "result": False, + "viewed": [["cron_2", 9]], + "unread": [], + "renders": 1, + } + + +def test_cron_polling_marks_sidebar_unread_without_needing_toast(): + script = f""" +const fs = require('fs'); +const src = fs.readFileSync({json.dumps(str(PANELS_JS_PATH))}, 'utf8'); +function extractFunc(name) {{ + const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\('); + const start = src.search(re); + if (start < 0) throw new Error(name + ' not found'); + let i = src.indexOf('{{', start); + let depth = 1; i++; + while (depth > 0 && i < src.length) {{ + if (src[i] === '{{') depth++; + else if (src[i] === '}}') depth--; + i++; + }} + return src.slice(start, i); +}} +let _cronPollSince = 10; +let _cronPollTimer = null; +let _cronUnreadCount = 0; +const _cronNewJobIds = new Set(); +const markCalls = []; +const toastCalls = []; +let badgeUpdates = 0; +global.document = {{ hidden: false }}; +global.setInterval = (fn, _ms) => {{ global.__tick = fn; return 1; }}; +async function api(_url) {{ + return {{ + completions: [{{ + job_id: 'job3460', + session_id: 'cron_job3460_20260610_070000', + message_count: 12, + name: 'Morning Briefing', + status: 'success', + completed_at: 25, + toast_notifications: false + }}] + }}; +}} +function showToast(...args) {{ toastCalls.push(args); }} +function t(...args) {{ return args.join('|'); }} +function updateCronBadge() {{ badgeUpdates += 1; }} +function _markSessionCompletionUnreadIfBackground(sid, count) {{ markCalls.push([sid, count]); }} +eval(extractFunc('startCronPolling')); +(async() => {{ + startCronPolling(); + await global.__tick(); + console.log(JSON.stringify({{ + since: _cronPollSince, + unreadJobs: Array.from(_cronNewJobIds), + markCalls, + toastCalls, + badgeUpdates + }})); +}})().catch(err => {{ + console.error(err); + process.exit(1); +}}); +""" + payload = _run_node(script) + + assert payload == { + "since": 25, + "unreadJobs": ["job3460"], + "markCalls": [["cron_job3460_20260610_070000", 12]], + "toastCalls": [], + "badgeUpdates": 1, + }