Files
hermes-webui/tests/test_issue6612_update_channel_autosave.py
T
Rod Boevandrodboev 2260a393f0 fix(#6612): isolate update_channel writes from generic Preferences autosave (#6666)
Removes update_channel from the generic preferences autosave payload and gives the channel selector a dedicated sequenced write path with FIFO settings-POST serialization and revert-on-failure, so a stale tab cannot silently flip the release channel.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-08-08 20:36:47 +00:00

969 lines
41 KiB
Python

"""Regression tests for issue #6612: update_channel autosave ownership.
The fix removes update_channel from the generic preferences autosave payload
(_preferencesPayloadFromUi) and gives the settingsUpdateChannel selector a
dedicated write path (_saveUpdateChannelFromSelector) that sends only
update_channel and re-syncs the selector from the confirmed server response.
Node tests execute the real JS under controlled stubs; server-side tests call
save_settings() directly. Node tests are skipped when node is not on PATH.
"""
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
# ---------------------------------------------------------------------------
# Module-scope sources and fixtures
# ---------------------------------------------------------------------------
REPO_ROOT = Path(__file__).parent.parent.resolve()
PANELS_JS_PATH = REPO_ROOT / "static" / "panels.js"
PANELS_JS = PANELS_JS_PATH.read_text(encoding="utf-8")
_REPRO_PATH = REPO_ROOT / "tests" / "fixtures" / "issue6612_update_channel_repro.json"
with _REPRO_PATH.open(encoding="utf-8") as _f:
_REPRO = json.load(_f)
STALE_AUTOSAVE_PAYLOAD = _REPRO["stale_autosave_payload"]
EXPLICIT_CHANNEL_PAYLOADS = _REPRO["explicit_channel_payloads"]
NORMALIZATION_CASES = _REPRO["normalization_cases"]
EXPECTED_AFTER_STALE = _REPRO["expected_persisted_channel_after_stale_autosave"]
SEQUENCE = _REPRO["sequence"]
NODE = shutil.which("node")
def _run_node(source: str) -> str:
result = subprocess.run(
[NODE],
input=source,
cwd=str(REPO_ROOT),
capture_output=True,
encoding="utf-8",
text=True,
timeout=30,
)
if result.returncode != 0:
raise RuntimeError(result.stderr or result.stdout)
return result.stdout.strip()
def _base_panels_js() -> str:
r = subprocess.run(
["git", "show", "320789ae:static/panels.js"],
cwd=str(REPO_ROOT),
capture_output=True,
encoding="utf-8",
timeout=30,
)
if r.returncode != 0:
pytest.skip(f"cannot read base panels.js: {r.stderr.strip()}")
return r.stdout
def _node_prelude(panels_src: str) -> str:
"""Embed panels_src and define extractFunc. Sets _channelSaveSeq and
_confirmedUpdateChannel on global so the extracted async function can
access them via indirect eval (global scope)."""
return f"""
const panelsSrc = {panels_src!r};
function extractFunc(src, name) {{
const re = new RegExp('(?:async\\\\s+)?function\\\\s+' + name + '\\\\s*\\\\(');
const start = src.search(re);
if (start < 0) throw new Error(name + ' not found in source');
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);
}}
// Module-scope variables referenced as free variables inside the extracted functions.
global._channelSaveSeq = 0;
global._confirmedUpdateChannel = null;
global._settingsPanelPostQueue = Promise.resolve();
"""
def _preference_payload_script(panels_src: str, stale_selector_value: str = "experimental") -> str:
"""Node script that executes _preferencesPayloadFromUi() under controlled stubs.
Stubs $() to return:
- settingsShowTps -> {checked: true} (unrelated preference)
- settingsUpdateChannel -> {value: stale_selector} (stale or current value)
- all other selectors -> null
On base code: payload.update_channel = stale_selector_value (the bug).
On head code: selector never read -> update_channel absent (the fix).
"""
return _node_prelude(panels_src) + f"""
global.$ = function(id) {{
if (id === 'settingsShowTps') return {{ checked: true }};
if (id === 'settingsUpdateChannel') return {{ value: {json.dumps(stale_selector_value)} }};
return null;
}};
global._speechPreferencesPayloadFromUi = function() {{ return {{}}; }};
global._preferencesPayloadFromUi = (0, eval)('(' + extractFunc(panelsSrc, '_preferencesPayloadFromUi') + ')');
const payload = _preferencesPayloadFromUi();
console.log(JSON.stringify(payload));
"""
def _function_block(src: str, name: str) -> str:
marker = re.search(
rf"(^|\n)(?:async\s+)?function\s+{re.escape(name)}\(", src
)
assert marker is not None, f"{name}() not found in panels.js"
start = marker.start()
next_marker = re.search(
r"\n(?:function\s+\w+\(|async\s+function\s+\w+\()", src[start + 1:]
)
end = start + 1 + next_marker.start() if next_marker else len(src)
return src[start:end]
def _channel_writer_script_prelude(panels_src: str) -> str:
"""Shared prelude for node tests that exercise _saveUpdateChannelFromSelector."""
return _node_prelude(panels_src) + """
global._saveUpdateChannelFromSelector = (0, eval)(
'(' + extractFunc(panelsSrc, '_saveUpdateChannelFromSelector') + ')'
);
global._enqueueSettingsPost = (0, eval)(
'(' + extractFunc(panelsSrc, '_enqueueSettingsPost') + ')'
);
"""
# ---------------------------------------------------------------------------
# 1. Reproduction: stale tab overwrite
# Drive the full REPRO["sequence"] step-by-step.
# ---------------------------------------------------------------------------
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_payload_builder_excludes_update_channel_head():
"""Head: _preferencesPayloadFromUi() must not return update_channel even
when the stale selector stub holds 'experimental' (SEQUENCE[0]).
base-fails/head-passes behavioral pair from the actual builder.
"""
head_payload = json.loads(_run_node(_preference_payload_script(PANELS_JS)))
assert "update_channel" not in head_payload, (
f"head: builder must not return update_channel; "
f"got keys: {list(head_payload.keys())}"
)
assert head_payload.get("show_tps") is True, (
f"head: show_tps must be in payload (builder ran); got {head_payload!r}"
)
base_js = _base_panels_js()
base_payload = json.loads(_run_node(_preference_payload_script(base_js)))
assert base_payload.get("update_channel") == "experimental", (
f"base: builder must return update_channel='experimental'; got {base_payload!r}"
)
def test_sequence_replay(tmp_path, monkeypatch):
"""Replay the full REPRO sequence step-by-step.
SEQUENCE[0]: Tab A holds 'experimental' (stale selector value).
SEQUENCE[1]: Tab B saves Stable.
SEQUENCE[2-3]: Tab A fires a generic autosave (unrelated preference only;
no update_channel on head because the builder no longer reads
the stale selector).
SEQUENCE[4]: On head the channel stays 'stable' (base: would flip back).
"""
import api.config as config
settings_file = tmp_path / "settings.json"
monkeypatch.setattr(config, "SETTINGS_FILE", settings_file)
# SEQUENCE[0]: Tab A's selector is stale 'experimental'. Verify via builder
# that the generic autosave payload would NOT carry that value on head.
if NODE is not None:
tab_a_payload = json.loads(
_run_node(_preference_payload_script(PANELS_JS, stale_selector_value="experimental"))
)
assert "update_channel" not in tab_a_payload, (
f"SEQUENCE[0]: {SEQUENCE[0]!r}\n"
f"builder payload must not carry update_channel; got {tab_a_payload!r}"
)
# SEQUENCE[1]: Tab B selects Stable and saves.
config.save_settings({"update_channel": "stable"})
after_tab_b = config.load_settings().get("update_channel")
assert after_tab_b == "stable", (
f"SEQUENCE[1]: {SEQUENCE[1]!r}\n"
f"expected 'stable', got {after_tab_b!r}"
)
# SEQUENCE[2]: Tab A toggles an unrelated preference (TPS display).
# SEQUENCE[3]: Tab A's generic autosave posts its payload.
# On head: payload has no update_channel. On base: it would.
if NODE is not None:
generic_payload = json.loads(
_run_node(_preference_payload_script(PANELS_JS, stale_selector_value="experimental"))
)
assert "update_channel" not in generic_payload, (
f"SEQUENCE[2-3]: {SEQUENCE[2]!r}\n"
f"generic payload must not carry update_channel; got {generic_payload!r}"
)
config.save_settings(generic_payload)
else:
# node unavailable: use REPRO payload minus the channel key
config.save_settings(
{k: v for k, v in STALE_AUTOSAVE_PAYLOAD.items() if k != "update_channel"}
)
# SEQUENCE[4]: On head, channel stays 'stable'. Base would flip it back.
persisted = config.load_settings().get("update_channel", "stable")
assert persisted == EXPECTED_AFTER_STALE, (
f"SEQUENCE[4]: {SEQUENCE[4]!r}\n"
f"expected {EXPECTED_AFTER_STALE!r}, got {persisted!r}"
)
# ---------------------------------------------------------------------------
# 2. Negative space: explicit channel change
# ---------------------------------------------------------------------------
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_explicit_channel_writer_sends_only_channel():
"""_saveUpdateChannelFromSelector() must POST exactly {update_channel: <val>}
for each REPRO explicit_channel_payload, and re-sync the selector from the
confirmed server response.
"""
script = _channel_writer_script_prelude(PANELS_JS) + """
(async () => {
const calls = [];
const statuses = [];
global._setPreferencesAutosaveStatus = function(s) { statuses.push(s); };
global.api = async function(url, opts) {
const body = JSON.parse(opts.body);
calls.push({ url, method: opts.method, body });
return { update_channel: body.update_channel };
};
const sel1 = { value: 'stable' };
global._confirmedUpdateChannel = null;
await _saveUpdateChannelFromSelector(sel1);
const sel2 = { value: 'experimental' };
global._confirmedUpdateChannel = 'stable';
await _saveUpdateChannelFromSelector(sel2);
// Re-sync: server overrides to 'stable' (e.g., persisted was already stable)
const sel3 = { value: 'experimental' };
global._confirmedUpdateChannel = 'stable';
global.api = async function(url, opts) {
calls.push({ url, method: opts.method, body: JSON.parse(opts.body) });
return { update_channel: 'stable' };
};
await _saveUpdateChannelFromSelector(sel3);
console.log(JSON.stringify({ calls, statuses, resyncedValue: sel3.value, confirmedAfterResync: _confirmedUpdateChannel }));
})().catch(err => { console.error(err.message); process.exit(1); });
"""
result = json.loads(_run_node(script))
calls = result["calls"]
assert len(calls) == 3, f"expected 3 api calls, got {len(calls)}: {calls!r}"
assert calls[0]["url"] == "/api/settings"
assert calls[0]["method"] == "POST"
assert calls[0]["body"] == {"update_channel": "stable"}, (
f"stable call body wrong: {calls[0]['body']!r}"
)
assert calls[1]["body"] == {"update_channel": "experimental"}, (
f"experimental call body wrong: {calls[1]['body']!r}"
)
assert result["resyncedValue"] == "stable", (
f"selector must re-sync to 'stable'; got {result['resyncedValue']!r}"
)
assert result["confirmedAfterResync"] == "stable", (
f"_confirmedUpdateChannel must be updated to 'stable'; got {result['confirmedAfterResync']!r}"
)
# Every call should have emitted 'saving' then 'saved'
assert "saving" in result["statuses"], f"'saving' status not emitted; got {result['statuses']!r}"
assert "saved" in result["statuses"], f"'saved' status not emitted; got {result['statuses']!r}"
explicit_vals = {p["update_channel"] for p in EXPLICIT_CHANNEL_PAYLOADS}
sent_vals = {c["body"]["update_channel"] for c in calls[:2]}
assert explicit_vals == sent_vals
def test_dedicated_channel_writer_exists():
"""Source shape: _saveUpdateChannelFromSelector exists with the right structure."""
assert "async function _saveUpdateChannelFromSelector(" in PANELS_JS
block = _function_block(PANELS_JS, "_saveUpdateChannelFromSelector")
assert "update_channel" in block
assert "_enqueueSettingsPost" in block
assert "channelSel.value" in block
# Monotonic guard
assert "_channelSaveSeq" in block
assert "seq!==_channelSaveSeq" in block or "seq !== _channelSaveSeq" in block
# Confirmed-value tracking
assert "_confirmedUpdateChannel" in block
# saving/saved feedback
assert "_setPreferencesAutosaveStatus" in block
assert "'saving'" in block
assert "'saved'" in block
assert block.index("_confirmedUpdateChannel=confirmed") < block.index("if(seq!==_channelSaveSeq)"), (
"confirmed baseline must update before the stale-response UI guard"
)
def test_settings_panel_posts_use_shared_queue_and_channel_is_single_owner():
"""Every settings-panel writer shares the queue and Save Settings carries no channel."""
assert PANELS_JS.count("_enqueueSettingsPost({") == 10
direct_settings_calls = re.findall(r"api\(\s*'/api/settings'\s*,", PANELS_JS)
assert direct_settings_calls == ["api('/api/settings',"], (
f"only the queue helper may call settings POST/GET with options; got {direct_settings_calls!r}"
)
assert "body.update_channel=" not in _function_block(PANELS_JS, "saveSettings")
assert "_settingsPanelPostQueue=Promise.resolve()" in PANELS_JS
producer_blocks = (
"_autosaveAppearanceSettings",
"_autosavePreferencesSettings",
"_saveUpdateChannelFromSelector",
"handlePluginEnableToggle",
"_setAuthDisabledAck",
"saveSettings",
"goPasswordless",
"disableAuth",
)
for producer in producer_blocks:
assert "_enqueueSettingsPost" in _function_block(PANELS_JS, producer), producer
provider_block = _function_block(PANELS_JS, "_attachBudgetControls")
assert "_enqueueSettingsPost" in provider_block, "provider budget writer must use the queue"
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_settings_post_queue_serializes_and_recovers_after_failure():
"""FIFO queue preserves request order and a rejected request does not poison its tail."""
script = _channel_writer_script_prelude(PANELS_JS) + """
(async () => {
const started = [];
let releaseFirst;
const firstBlocker = new Promise(resolve => { releaseFirst = resolve; });
global.api = async function(url, opts) {
const body = JSON.parse(opts.body);
started.push(body);
if (started.length === 1) await firstBlocker;
return { ok: true };
};
const p1 = _enqueueSettingsPost({method: 'POST', body: JSON.stringify({first: true})});
const p2 = _enqueueSettingsPost({method: 'POST', body: JSON.stringify({second: true})});
await Promise.resolve();
const beforeRelease = started.length;
releaseFirst();
await Promise.all([p1, p2]);
global._settingsPanelPostQueue = Promise.resolve();
let attempts = 0;
global.api = async function() {
attempts++;
return attempts === 1 ? undefined : { ok: true };
};
const rejected = _enqueueSettingsPost({method: 'POST', body: '{}'}).then(
() => 'resolved', err => err.message
);
const follows = _enqueueSettingsPost({method: 'POST', body: '{}'});
console.log(JSON.stringify({
started,
beforeRelease,
rejected: await rejected,
following: await follows,
attempts,
}));
})().catch(err => { console.error(err.stack || err.message); process.exit(1); });
"""
result = json.loads(_run_node(script))
assert result["beforeRelease"] == 1, result
assert result["started"] == [{"first": True}, {"second": True}], result
assert result["rejected"] == "Invalid settings response", result
assert result["following"] == {"ok": True}, result
assert result["attempts"] == 2, result
def test_channel_listener_calls_dedicated_writer():
"""Source shape: settingsUpdateChannel listener calls _saveUpdateChannelFromSelector
only; checkUpdatesNow and _syncUpdateChannelBadge must be inside the writer."""
load_block = _function_block(PANELS_JS, "loadSettingsPanel")
idx = load_block.find("settingsUpdateChannel")
assert idx != -1
window = load_block[idx: idx + 600]
assert "_saveUpdateChannelFromSelector" in window
assert "_schedulePreferencesAutosave" not in window
# Side effects must NOT be in the listener anymore; they belong inside the writer.
assert "checkUpdatesNow" not in window, (
"checkUpdatesNow must be inside _saveUpdateChannelFromSelector, not in the listener"
)
assert "_syncUpdateChannelBadge" not in window, (
"_syncUpdateChannelBadge must be inside _saveUpdateChannelFromSelector, not in the listener"
)
def test_explicit_channel_payloads_persist(tmp_path, monkeypatch):
"""Server-side: both REPRO explicit_channel_payloads persist correctly."""
import api.config as config
settings_file = tmp_path / "settings.json"
monkeypatch.setattr(config, "SETTINGS_FILE", settings_file)
for payload in EXPLICIT_CHANNEL_PAYLOADS:
expected = payload["update_channel"]
result = config.save_settings(payload)
assert result.get("update_channel") == expected, (
f"explicit payload {payload!r} must persist {expected!r}; got {result.get('update_channel')!r}"
)
assert config.load_settings().get("update_channel") == expected
# ---------------------------------------------------------------------------
# 3. Unrelated preference preservation
# ---------------------------------------------------------------------------
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_unrelated_preference_payload_contains_no_channel():
"""Builder carries show_tps but NOT update_channel, even with stale selector."""
payload = json.loads(_run_node(_preference_payload_script(PANELS_JS)))
assert payload.get("show_tps") is True, f"show_tps absent; got {payload!r}"
assert "update_channel" not in payload, f"update_channel present; keys: {list(payload.keys())}"
def test_unrelated_autosave_does_not_touch_channel(tmp_path, monkeypatch):
"""Server-side: posting an unrelated preference must leave the channel unchanged."""
import api.config as config
settings_file = tmp_path / "settings.json"
monkeypatch.setattr(config, "SETTINGS_FILE", settings_file)
config.save_settings({"update_channel": "stable"})
config.save_settings({"show_tps": True})
assert config.load_settings().get("update_channel") == "stable"
# ---------------------------------------------------------------------------
# 4. Normalization: invalid/missing values leave the persisted channel unchanged
# (api/config.py ignores keys whose value is not in _SETTINGS_ENUM_VALUES[k];
# the persisted value is NOT overwritten or normalized to stable)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("case", NORMALIZATION_CASES)
def test_normalization_invalid_channel_leaves_unchanged(tmp_path, monkeypatch, case):
"""Unknown, empty, and missing update_channel values leave the persisted
channel unchanged. Seeded to 'experimental' so that the expected value
in each case is 'experimental' — discriminating between "left alone" and
"overwritten" and "silently set to stable".
"""
import api.config as config
settings_file = tmp_path / "settings.json"
monkeypatch.setattr(config, "SETTINGS_FILE", settings_file)
config.save_settings({"update_channel": "experimental"})
result = config.save_settings(case["input"])
# invalid/missing keys are ignored by save_settings; persisted value unchanged
assert result.get("update_channel") == case["expected"], (
f"normalization_case {case!r}: got {result.get('update_channel')!r}"
)
# ---------------------------------------------------------------------------
# 5. Mode and state matrix
# stale/current tab x unrelated/explicit change x stable/experimental/invalid/missing
# ---------------------------------------------------------------------------
# (tab_kind, change_kind, channel_value, initial_persisted, expected_persisted)
#
# tab_kind:
# "stale" = the selector holds channel_value while the persisted state is
# initial_persisted (set by another tab); initial != channel_value
# for unrelated rows — the core bug scenario.
# "current" = selector matches initial_persisted.
#
# change_kind:
# "unrelated" = generic autosave fires; payload has no update_channel.
# channel_value describes what the stale/current selector holds.
# "explicit" = user selects channel_value; raw value sent to server.
#
# channel_value for explicit rows: "invalid" = "nightly", "missing" = send {}.
# Server ignores invalid/missing -> persisted stays at initial_persisted.
_MATRIX = [
# stale/unrelated — the primary bug scenario and its variants
("stale", "unrelated", "experimental", "stable", "stable"), # BUG CASE: stale:exp, persisted:stable
("stale", "unrelated", "stable", "experimental", "experimental"), # stale:stable, persisted:exp
("stale", "unrelated", "invalid", "stable", "stable"), # stale selector holds invalid value
("stale", "unrelated", "missing", "stable", "stable"), # stale selector empty
# current/unrelated
("current", "unrelated", "stable", "stable", "stable"),
("current", "unrelated", "experimental", "experimental", "experimental"),
# current/explicit — user makes a deliberate channel selection
("current", "explicit", "stable", "experimental", "stable"), # switch to stable
("current", "explicit", "experimental", "stable", "experimental"), # switch to experimental
("current", "explicit", "invalid", "experimental", "experimental"), # 'nightly' ignored -> stays experimental
("current", "explicit", "missing", "experimental", "experimental"), # {} -> no key -> stays experimental
]
@pytest.mark.parametrize(
"tab_kind,change_kind,channel_value,initial_persisted,expected_persisted",
_MATRIX,
ids=[f"{t}-{c}-{v}" for t, c, v, _, _ in _MATRIX],
)
def test_mode_and_state_matrix(
tmp_path,
monkeypatch,
tab_kind,
change_kind,
channel_value,
initial_persisted,
expected_persisted,
):
"""Full stale/current x unrelated/explicit x stable/experimental/invalid/missing matrix.
For stale/unrelated rows, channel_value is the STALE selector value (different
from initial_persisted). The generic autosave fires with no update_channel,
so the persisted channel must stay at initial_persisted regardless.
For explicit rows, channel_value is sent raw to save_settings; server-side
validation rejects 'nightly' (invalid) and ignores absent keys (missing),
leaving the channel at initial_persisted.
"""
import api.config as config
settings_file = tmp_path / "settings.json"
monkeypatch.setattr(config, "SETTINGS_FILE", settings_file)
# Set the persisted state (what another tab or the previous state established).
config.save_settings({"update_channel": initial_persisted})
if change_kind == "unrelated":
# For stale rows: channel_value is the stale selector value, which differs
# from initial_persisted. Verify via node that the builder ignores it.
if NODE is not None and tab_kind == "stale":
payload = json.loads(
_run_node(_preference_payload_script(PANELS_JS, channel_value or ""))
)
assert "update_channel" not in payload, (
f"stale ({channel_value!r}) builder payload must not carry update_channel; "
f"got {payload!r}"
)
# Fire the unrelated autosave (no update_channel).
config.save_settings({"show_tps": True})
elif change_kind == "explicit":
if channel_value == "missing":
config.save_settings({})
elif channel_value == "invalid":
config.save_settings({"update_channel": "nightly"}) # server ignores
else:
config.save_settings({"update_channel": channel_value})
persisted = config.load_settings().get("update_channel", "stable")
assert persisted == expected_persisted, (
f"Matrix ({tab_kind},{change_kind},{channel_value!r}): "
f"initial={initial_persisted!r}, expected {expected_persisted!r}, got {persisted!r}"
)
# ---------------------------------------------------------------------------
# 6. Behavioral coverage for _saveUpdateChannelFromSelector edge cases
# ---------------------------------------------------------------------------
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_channel_writer_null_selector():
"""Null or undefined selector must return without calling api."""
script = _channel_writer_script_prelude(PANELS_JS) + """
(async () => {
const calls = [];
global.api = async function(url, opts) { calls.push({url, opts}); return {}; };
await _saveUpdateChannelFromSelector(null);
await _saveUpdateChannelFromSelector(undefined);
console.log(JSON.stringify({ calls }));
})().catch(err => { console.error(err.message); process.exit(1); });
"""
result = json.loads(_run_node(script))
assert result["calls"] == [], (
f"api must not be called for null/undefined selector; got {result['calls']!r}"
)
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_channel_writer_rejected_post():
"""Failed POST must revert the selector to the last confirmed server value
(not the new value the user just picked, which is what the browser already
applied to the <select> before the change event fires), sync the badge to
match, and clear the status indicator without injecting a retry button.
The stub models real listener semantics: _confirmedUpdateChannel='stable'
(what the server last confirmed), sel.value='experimental' (user's new pick,
already applied by the browser). After the failed POST the selector must
show 'stable' again and the badge must be synced to 'stable'.
"""
script = _channel_writer_script_prelude(PANELS_JS) + """
(async () => {
const statusCalls = [];
const badgeCalls = [];
global._setPreferencesAutosaveStatus = function(s) { statusCalls.push(s); };
global._syncUpdateChannelBadge = function(v) { badgeCalls.push(v); };
global.api = async function() { throw new Error('network error'); };
// Simulate real browser behavior: server last confirmed 'stable',
// user picks 'experimental' -> browser applies it -> change event fires.
global._confirmedUpdateChannel = 'stable';
const sel = { value: 'experimental' }; // already the new value
await _saveUpdateChannelFromSelector(sel);
console.log(JSON.stringify({ selectorValue: sel.value, statusCalls, badgeCalls }));
})().catch(err => { console.error(err.message); process.exit(1); });
"""
result = json.loads(_run_node(script))
assert result["selectorValue"] == "stable", (
f"selector must revert to _confirmedUpdateChannel ('stable') on failure; "
f"got {result['selectorValue']!r}"
)
assert result["badgeCalls"] == ["stable"], (
f"badge must be synced to 'stable' on failure; got {result['badgeCalls']!r}"
)
# Status sequence: 'saving' before POST, then null to clear on failure.
# Must NOT contain 'failed' (its retry button replays the wrong payload).
assert "saving" in result["statusCalls"], (
f"'saving' status must be emitted before POST; got {result['statusCalls']!r}"
)
assert "failed" not in result["statusCalls"], (
f"'failed' must not be used (retry button replays wrong payload); "
f"got {result['statusCalls']!r}"
)
# null clears the 'saving' indicator
assert None in result["statusCalls"], (
f"null status must be emitted to clear the saving indicator on failure; "
f"got {result['statusCalls']!r}"
)
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_channel_writer_undefined_response():
"""api() returning undefined on the 401 path must enter the failure branch."""
script = _channel_writer_script_prelude(PANELS_JS) + """
(async () => {
const statuses = [];
const badges = [];
global._setPreferencesAutosaveStatus = function(s) { statuses.push(s); };
global._syncUpdateChannelBadge = function(v) { badges.push(v); };
global.api = async function() { return undefined; };
global._confirmedUpdateChannel = 'stable';
const sel = { value: 'experimental' };
await _saveUpdateChannelFromSelector(sel);
console.log(JSON.stringify({ selectorValue: sel.value, confirmed: _confirmedUpdateChannel, statuses, badges }));
})().catch(err => { console.error(err.message); process.exit(1); });
"""
result = json.loads(_run_node(script))
assert result["selectorValue"] == "stable", (
f"undefined api() response must revert to the confirmed value 'stable'; "
f"got {result['selectorValue']!r}"
)
assert result["confirmed"] == "stable", (
f"_confirmedUpdateChannel must remain 'stable'; got {result['confirmed']!r}"
)
assert result["badges"] == ["stable"]
assert result["statuses"] == ["saving", None]
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_channel_writer_string_response():
"""api() returning a string body on a non-JSON 200 must enter the failure branch."""
script = _channel_writer_script_prelude(PANELS_JS) + """
(async () => {
const statuses = [];
const badges = [];
global._setPreferencesAutosaveStatus = function(s) { statuses.push(s); };
global._syncUpdateChannelBadge = function(v) { badges.push(v); };
global.api = async function() { return '<html>login required</html>'; };
global._confirmedUpdateChannel = 'stable';
const sel = { value: 'experimental' };
await _saveUpdateChannelFromSelector(sel);
console.log(JSON.stringify({ selectorValue: sel.value, confirmed: _confirmedUpdateChannel, statuses, badges }));
})().catch(err => { console.error(err.message); process.exit(1); });
"""
result = json.loads(_run_node(script))
assert result["selectorValue"] == "stable", (
f"string api() response must revert to the confirmed value 'stable'; "
f"got {result['selectorValue']!r}"
)
assert result["confirmed"] == "stable", (
f"_confirmedUpdateChannel must remain 'stable'; got {result['confirmed']!r}"
)
assert result["badges"] == ["stable"]
assert result["statuses"] == ["saving", None]
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
@pytest.mark.parametrize("response", [None, [], 7, False], ids=["null", "array", "number", "boolean"])
def test_channel_writer_non_object_response_reverts(response):
"""All JSON non-object response shapes are failed saves, not confirmations."""
script = _channel_writer_script_prelude(PANELS_JS) + f"""
(async () => {{
const statuses = [];
global._setPreferencesAutosaveStatus = function(s) {{ statuses.push(s); }};
global._syncUpdateChannelBadge = function() {{}};
global.api = async function() {{ return {json.dumps(response)}; }};
global._confirmedUpdateChannel = 'stable';
const sel = {{ value: 'experimental' }};
await _saveUpdateChannelFromSelector(sel);
console.log(JSON.stringify({{ selectorValue: sel.value, confirmed: _confirmedUpdateChannel, statuses }}));
}})().catch(err => {{ console.error(err.message); process.exit(1); }});
"""
result = json.loads(_run_node(script))
assert result == {
"selectorValue": "stable",
"confirmed": "stable",
"statuses": ["saving", None],
}
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_channel_writer_valid_object_missing_or_invalid_channel_falls_back_to_posted_value():
"""A valid JSON object with no usable channel preserves the server merge contract."""
script = _channel_writer_script_prelude(PANELS_JS) + """
(async () => {
const confirmed = [];
let response = {};
global._setPreferencesAutosaveStatus = function() {};
global._syncUpdateChannelBadge = function(v) { confirmed.push(v); };
global.api = async function() { return response; };
const missing = { value: 'experimental' };
global._confirmedUpdateChannel = 'stable';
await _saveUpdateChannelFromSelector(missing);
response = { update_channel: 'nightly' };
const invalid = { value: 'experimental' };
await _saveUpdateChannelFromSelector(invalid);
console.log(JSON.stringify({ missing: missing.value, invalid: invalid.value, confirmedValue: _confirmedUpdateChannel, confirmed }));
})().catch(err => { console.error(err.message); process.exit(1); });
"""
result = json.loads(_run_node(script))
assert result == {
"missing": "experimental",
"invalid": "experimental",
"confirmedValue": "experimental",
"confirmed": ["experimental", "experimental"],
}
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_status_slot_ownership():
"""Two writers share the single preferences autosave status slot.
Scenario 1: generic autosave sets 'failed' with owner='preferences'.
Channel write succeeds; its 'saved' call must be blocked — the 'failed'+Retry
must stay visible so the user can retry the unsaved generic payload.
Scenario 2: stale channel failure (superseded by a second write) fires a
null-clear via the seq guard. That null-clear must NOT fire because
seq!==_channelSaveSeq, so the generic autosave's 'failed' state is preserved.
"""
script = _node_prelude(PANELS_JS) + """
global._preferencesAutosaveStatusOwner = null;
// DOM stub: tracks className and textContent/innerHTML separately.
function makeStatusEl() {
return {
_cls: '',
_content: '',
get className() { return this._cls; },
set className(v) { this._cls = v; },
classList: null, // set below after object creation
set textContent(v) { this._content = v; },
get textContent() { return this._content; },
set innerHTML(v) { this._content = v; },
};
}
const statusEl = makeStatusEl();
statusEl.classList = {
add(c) { statusEl._cls += (' ' + c); },
contains(c) { return statusEl._cls.split(' ').includes(c); },
};
global.$ = function(id) {
if (id === 'settingsPreferencesAutosaveStatus') return statusEl;
return null;
};
global.t = function(k) { return k; };
global.esc = function(s) { return String(s); };
global._setPreferencesAutosaveStatus = (0, eval)(
'(' + extractFunc(panelsSrc, '_setPreferencesAutosaveStatus') + ')'
);
global._saveUpdateChannelFromSelector = (0, eval)(
'(' + extractFunc(panelsSrc, '_saveUpdateChannelFromSelector') + ')'
);
global._enqueueSettingsPost = (0, eval)(
'(' + extractFunc(panelsSrc, '_enqueueSettingsPost') + ')'
);
(async () => {
const results = {};
// ── Scenario 1: generic autosave 'failed'; channel success must not overwrite ──
_setPreferencesAutosaveStatus('failed', 'preferences');
results.s1_afterGenericFailed = {
cls: statusEl._cls.trim(),
owner: _preferencesAutosaveStatusOwner,
};
// Channel write fires and succeeds
global.api = async function() { return { update_channel: 'experimental' }; };
global._confirmedUpdateChannel = 'stable';
const sel1 = { value: 'experimental' };
await _saveUpdateChannelFromSelector(sel1);
results.s1_afterChannelSuccess = {
cls: statusEl._cls.trim(),
owner: _preferencesAutosaveStatusOwner,
selectorValue: sel1.value,
};
// ── Scenario 2: stale channel failure's null-clear must not fire ──
// Reset state for a clean scenario.
statusEl._cls = '';
statusEl._content = '';
global._preferencesAutosaveStatusOwner = null;
global._channelSaveSeq = 0;
global._confirmedUpdateChannel = 'stable';
// Generic autosave sets 'failed'.
_setPreferencesAutosaveStatus('failed', 'preferences');
results.s2_afterGenericFailed = { cls: statusEl._cls.trim() };
// Channel write 1 starts (seq=1) but blocks.
let resolveBlock;
const block = new Promise(r => { resolveBlock = r; });
let callCount = 0;
global.api = async function(url, opts) {
callCount++;
if (callCount === 1) { await block; throw new Error('stale fail'); }
return { update_channel: 'stable' };
};
const p1 = _saveUpdateChannelFromSelector({ value: 'experimental' }); // seq=1, blocks
await Promise.resolve();
// Channel write 2 is queued behind write 1; seq advances but api is not called.
const sel2 = { value: 'stable' };
const p2 = _saveUpdateChannelFromSelector(sel2); // seq=2
await Promise.resolve();
results.s2_whileFirstBlocked = { cls: statusEl._cls.trim(), owner: _preferencesAutosaveStatusOwner, callCount };
// Unblock write 1; its catch fires but seq(1)!==_channelSaveSeq(2) -> no null-clear.
resolveBlock();
await p1;
await p2;
results.s2_afterSecondWrite = { cls: statusEl._cls.trim(), owner: _preferencesAutosaveStatusOwner };
results.s2_afterStaleFailure = { cls: statusEl._cls.trim(), owner: _preferencesAutosaveStatusOwner };
console.log(JSON.stringify(results));
})().catch(err => { console.error(err.stack || err.message); process.exit(1); });
"""
result = json.loads(_run_node(script))
# Scenario 1
assert "is-failed" in result["s1_afterGenericFailed"]["cls"], (
f"generic 'failed' must be set; got {result['s1_afterGenericFailed']!r}"
)
assert result["s1_afterGenericFailed"]["owner"] == "preferences", (
f"owner must be 'preferences'; got {result['s1_afterGenericFailed']['owner']!r}"
)
# Channel write succeeded but must NOT overwrite 'failed' from 'preferences'
assert "is-failed" in result["s1_afterChannelSuccess"]["cls"], (
f"channel 'saved' must be blocked by ownership guard; "
f"cls is now {result['s1_afterChannelSuccess']['cls']!r}"
)
assert result["s1_afterChannelSuccess"]["selectorValue"] == "experimental", (
f"selector must still update even when status write is blocked; "
f"got {result['s1_afterChannelSuccess']['selectorValue']!r}"
)
# Scenario 2
assert "is-failed" in result["s2_afterGenericFailed"]["cls"], (
f"generic 'failed' must be set before channel write; got {result['s2_afterGenericFailed']!r}"
)
assert result["s2_whileFirstBlocked"]["callCount"] == 1, (
f"queued write must not reach api before write 1 resolves; got {result['s2_whileFirstBlocked']!r}"
)
# After write 2 succeeds: write 2 tried to write 'saving' then 'saved', but
# generic 'failed' blocks both → slot still shows 'failed'
assert "is-failed" in result["s2_afterSecondWrite"]["cls"], (
f"generic 'failed' must survive write 2; got {result['s2_afterSecondWrite']!r}"
)
# After stale write 1's failure: seq guard blocks the null-clear → slot unchanged
assert "is-failed" in result["s2_afterStaleFailure"]["cls"], (
f"stale failure's null-clear must be blocked by seq guard; "
f"cls is now {result['s2_afterStaleFailure']['cls']!r}"
)
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_channel_writer_concurrent_selection(tmp_path, monkeypatch):
"""Two rapid selections serialize server writes and retain the latest UI value."""
script = _channel_writer_script_prelude(PANELS_JS) + """
(async () => {
const results = [];
let resolveFirst;
const firstCallBlocker = new Promise(resolve => { resolveFirst = resolve; });
let apiCallCount = 0;
const completed = [];
global.api = async function(url, opts) {
apiCallCount++;
const body = JSON.parse(opts.body);
if (apiCallCount === 1) await firstCallBlocker;
completed.push(body);
return { update_channel: body.update_channel };
};
const sel = { value: 'experimental' };
global._confirmedUpdateChannel = null;
const p1 = _saveUpdateChannelFromSelector(sel); // seq=1, blocked
sel.value = 'stable'; // user changes selection again
const p2 = _saveUpdateChannelFromSelector(sel); // seq=2, queued
await Promise.resolve();
results.push({ while_first_blocked: sel.value, apiCallCount, completed });
resolveFirst(null); // unblock first call's response, then start the queued second call
await Promise.all([p1, p2]);
results.push({ after_both_complete: sel.value, apiCallCount, completed, confirmed: _confirmedUpdateChannel });
console.log(JSON.stringify({ results, finalSeq: _channelSaveSeq }));
})().catch(err => { console.error(err.message); process.exit(1); });
"""
result = json.loads(_run_node(script))
results = result["results"]
import api.config as config
settings_file = tmp_path / "settings.json"
monkeypatch.setattr(config, "SETTINGS_FILE", settings_file)
config.save_settings({"update_channel": "stable"})
for body in results[1]["completed"]:
config.save_settings(body)
persisted = config.load_settings().get("update_channel")
assert results[0]["while_first_blocked"] == "stable", (
f"second selection must remain visible while first request is queued; got {results[0]!r}"
)
assert results[0]["apiCallCount"] == 1, (
f"only the first settings POST may be in flight; got {results[0]!r}"
)
assert results[1]["after_both_complete"] == "stable", (
f"after both serialized writes, selector must be 'stable'; "
f"got {results[1]!r}"
)
assert results[1]["completed"] == [
{"update_channel": "experimental"},
{"update_channel": "stable"},
], results
assert persisted == "stable", results
assert results[1]["confirmed"] == "stable", results
assert results[1]["apiCallCount"] == 2, results
assert result["finalSeq"] == 2, (
f"_channelSaveSeq must be 2 after two calls; got {result['finalSeq']!r}"
)