From daa3aaaa75d649b93f15f3745c0c8db1aabe9091 Mon Sep 17 00:00:00 2001 From: ChechiDev Date: Sun, 13 Sep 2026 15:59:22 +0200 Subject: [PATCH] fix(clearance): start keepalive when extension config changes --- extensions/sportcrawl-chrome/background.js | 31 ++++ .../extension/test_background_js_patterns.py | 142 ++++++++++++++++++ 2 files changed, 173 insertions(+) diff --git a/extensions/sportcrawl-chrome/background.js b/extensions/sportcrawl-chrome/background.js index fee7d4f..b6b4ea3 100644 --- a/extensions/sportcrawl-chrome/background.js +++ b/extensions/sportcrawl-chrome/background.js @@ -16,10 +16,20 @@ const KEEPALIVE_PERIOD_MINUTES = 0.5; // Chrome MV3 minimum; fires every 30s to const BACKOFF_BASE_MS = 2000; const BACKOFF_CAP_MS = 60000; +const CONFIG_CHANGE_KEYS = new Set([ + "enable_sw_keepalive", + "work_server_url", + "work_server_token", + "allowed_clearance_domain", + "disable_task_polling", + "fatalStop", +]); + let _config = { work_server_url: "", work_server_token: "", profile_id: "", worker_id: "", disable_task_polling: false, enable_sw_keepalive: false }; let _allowedClearanceDomain = ""; // kept separate from _config to allow independent reset during service worker restart let _backoffMs = BACKOFF_BASE_MS; let _fatalStop = false; +let _configReactionInFlight = false; // --------------------------------------------------------------------------- // Config @@ -432,3 +442,24 @@ chrome.runtime.onStartup.addListener(async () => { await loadConfig(); await startAlarmIfNeeded(); }); + +// --------------------------------------------------------------------------- +// Config change reactions — re-evaluate alarms when harness injects config +// Config reload only; poll and clearance POST are handled by their own events. +// --------------------------------------------------------------------------- + +chrome.storage.onChanged.addListener(async (changes, area) => { + if (area !== "local") return; + if (!Array.from(CONFIG_CHANGE_KEYS).some((k) => k in changes)) return; + if (_configReactionInFlight) return; + _configReactionInFlight = true; + try { + await loadConfig(); + await startAlarmIfNeeded(); + } catch (err) { + console.error("[SportCrawl] config-change reaction failed:", err); + persistStatus("err"); + } finally { + _configReactionInFlight = false; + } +}); diff --git a/tests/unit/extension/test_background_js_patterns.py b/tests/unit/extension/test_background_js_patterns.py index c381c17..15ba4bf 100644 --- a/tests/unit/extension/test_background_js_patterns.py +++ b/tests/unit/extension/test_background_js_patterns.py @@ -603,3 +603,145 @@ def test_task_polling_remains_gated_by_disable_flag(self) -> None: assert "disable_task_polling" in func_body, ( "pollNextTask() must still check _config.disable_task_polling" ) + + +class TestStorageOnChanged: + """background.js must react to chrome.storage.onChanged for config injection.""" + + def _get_storage_onchanged_body(self) -> str: + lines = _BG.splitlines() + start = next( + ( + i for i, ln in enumerate(lines) + if "chrome.storage.onChanged.addListener" in ln + ), + None, + ) + assert start is not None, ( + "chrome.storage.onChanged.addListener not found in background.js" + ) + depth = 0 + body_lines: list[str] = [] + in_body = False + for ln in lines[start:]: + opens = ln.count("{") + closes = ln.count("}") + if not in_body and opens > 0: + in_body = True + depth += opens - closes + body_lines.append(ln) + if in_body and depth == 0: + break + return "\n".join(body_lines) + + def test_storage_onchanged_listener_registered(self) -> None: + assert "chrome.storage.onChanged.addListener" in _BG, ( + "chrome.storage.onChanged.addListener must be registered in background.js" + ) + + def test_storage_onchanged_listener_is_async(self) -> None: + idx = _BG.index("chrome.storage.onChanged.addListener") + fragment = _BG[idx : idx + 120] + assert "async" in fragment, ( + "chrome.storage.onChanged listener callback must be async" + ) + + def test_storage_onchanged_filters_local_area(self) -> None: + body = self._get_storage_onchanged_body() + assert '"local"' in body or "'local'" in body, ( + 'storage.onChanged listener must guard on area === "local"' + ) + + def test_storage_onchanged_calls_loadconfig(self) -> None: + body = self._get_storage_onchanged_body() + assert "loadConfig()" in body, ( + "storage.onChanged listener must call loadConfig()" + ) + + def test_storage_onchanged_calls_start_alarm_if_needed(self) -> None: + body = self._get_storage_onchanged_body() + assert "startAlarmIfNeeded()" in body, ( + "storage.onChanged listener must call startAlarmIfNeeded()" + ) + + def test_storage_onchanged_does_not_call_poll_next_task(self) -> None: + body = self._get_storage_onchanged_body() + assert "pollNextTask" not in body, ( + "storage.onChanged listener must not call pollNextTask()" + ) + + def test_storage_onchanged_does_not_call_fetch(self) -> None: + body = self._get_storage_onchanged_body() + assert "fetch(" not in body, ( + "storage.onChanged listener must not call fetch()" + ) + + def test_storage_onchanged_checks_relevant_key(self) -> None: + # Key must appear in the module-level CONFIG_CHANGE_KEYS constant + assert "enable_sw_keepalive" in _BG, ( + '"enable_sw_keepalive" must appear in CONFIG_CHANGE_KEYS' + ) + assert "CONFIG_CHANGE_KEYS" in _BG, ( + "Module-level CONFIG_CHANGE_KEYS set must be defined in background.js" + ) + + def _get_config_change_keys_block(self) -> str: + """Extract the CONFIG_CHANGE_KEYS constant definition block.""" + lines = _BG.splitlines() + start = next( + ( + i for i, ln in enumerate(lines) + if "CONFIG_CHANGE_KEYS" in ln and "new Set" in ln + ), + None, + ) + assert start is not None, "CONFIG_CHANGE_KEYS Set definition not found" + depth = 0 + body_lines: list[str] = [] + for ln in lines[start:]: + depth += ln.count("(") - ln.count(")") + body_lines.append(ln) + if depth == 0 and body_lines: + break + return "\n".join(body_lines) + + def test_storage_onchanged_includes_token_key(self) -> None: + block = self._get_config_change_keys_block() + assert "work_server_token" in block, ( + '"work_server_token" must appear inside CONFIG_CHANGE_KEYS Set definition' + ) + + def test_storage_onchanged_includes_clearance_domain_key(self) -> None: + block = self._get_config_change_keys_block() + assert "allowed_clearance_domain" in block, ( + '"allowed_clearance_domain" must appear inside CONFIG_CHANGE_KEYS' + ) + + def test_storage_onchanged_does_not_log_raw_values(self) -> None: + body = self._get_storage_onchanged_body() + assert "changes[" not in body and "newValue" not in body, ( + "storage.onChanged listener must not access changes[key].newValue " + "(raw value exposure)" + ) + + def test_storage_onchanged_loadconfig_before_start_alarm(self) -> None: + body = self._get_storage_onchanged_body() + load_pos = body.find("loadConfig()") + alarm_pos = body.find("startAlarmIfNeeded()") + assert load_pos != -1, "loadConfig() not found in storage.onChanged listener" + assert alarm_pos != -1, ( + "startAlarmIfNeeded() not found in storage.onChanged listener" + ) + assert load_pos < alarm_pos, ( + f"loadConfig() (pos {load_pos}) must appear before " + f"startAlarmIfNeeded() (pos {alarm_pos}) in storage.onChanged listener" + ) + + def test_storage_onchanged_has_error_handling(self) -> None: + body = self._get_storage_onchanged_body() + assert "try {" in body or "try{" in body, ( + "storage.onChanged listener must have a try block for error handling" + ) + assert "catch" in body, ( + "storage.onChanged listener must have a catch block" + )