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>
This commit is contained in:
1 parent
7a94e34a6d
commit
2260a393f0
4 files changed
+1104
-26
No files matched your search
+103
-25
@@ -8520,7 +8520,7 @@ function _scheduleAppearanceAutosave(){
|
||||
|
||||
async function _autosaveAppearanceSettings(payload){
|
||||
try{
|
||||
const saved=await api('/api/settings',{method:'POST',body:JSON.stringify(payload)});
|
||||
const saved=await _enqueueSettingsPost({method:'POST',body:JSON.stringify(payload)});
|
||||
_settingsAppearanceAutosaveRetryPayload=null;
|
||||
_rememberAppearanceSaved(payload);
|
||||
if(saved&&saved.font_size){
|
||||
@@ -8684,8 +8684,9 @@ function _preferencesPayloadFromUi(){
|
||||
if(syncCb) payload.sync_to_insights=syncCb.checked;
|
||||
const updateCb=$('settingsCheckUpdates');
|
||||
if(updateCb) payload.check_for_updates=updateCb.checked;
|
||||
const updateChannelSel=$('settingsUpdateChannel');
|
||||
if(updateChannelSel) payload.update_channel=updateChannelSel.value;
|
||||
// update_channel is NOT included here — it has its own dedicated write path
|
||||
// (_saveUpdateChannelFromSelector) so a stale tab's generic autosave cannot
|
||||
// overwrite a newer channel selection made in another tab. (#6612)
|
||||
const ignoreAgentUpdatesCb=$('settingsIgnoreAgentUpdates');
|
||||
if(ignoreAgentUpdatesCb) payload.ignore_agent_updates=ignoreAgentUpdatesCb.checked;
|
||||
const whatsNewSummaryCb=$('settingsWhatsNewSummary');
|
||||
@@ -8738,9 +8739,42 @@ function _speechPreferencesPayloadFromUi(){
|
||||
return payload;
|
||||
}
|
||||
|
||||
function _setPreferencesAutosaveStatus(state){
|
||||
// Keep same-page settings merges FIFO so one response cannot race the next read-modify-write.
|
||||
let _settingsPanelPostQueue=Promise.resolve();
|
||||
|
||||
function _enqueueSettingsPost(options){
|
||||
const requestOptions={...(options||{})};
|
||||
if(typeof requestOptions.body==='string') requestOptions.body=String(requestOptions.body);
|
||||
const run=_settingsPanelPostQueue.then(async()=>{
|
||||
const saved=await api('/api/settings',requestOptions);
|
||||
if(!saved||typeof saved!=='object'||Array.isArray(saved)){
|
||||
throw new Error('Invalid settings response');
|
||||
}
|
||||
return saved;
|
||||
});
|
||||
_settingsPanelPostQueue=run.catch(()=>{});
|
||||
return run;
|
||||
}
|
||||
|
||||
// Ownership token for the shared preferences autosave status slot. Prevents
|
||||
// the channel writer from clearing or overwriting a 'failed'+Retry state the
|
||||
// generic preferences autosave set; that Retry button has exactly one call
|
||||
// site and becomes unreachable if another writer replaces the node.
|
||||
let _preferencesAutosaveStatusOwner=null;
|
||||
|
||||
function _setPreferencesAutosaveStatus(state,owner){
|
||||
owner=owner||'preferences';
|
||||
const el=$('settingsPreferencesAutosaveStatus');
|
||||
if(!el) return;
|
||||
// Guard: if the slot shows 'failed' from a different writer, do not overwrite.
|
||||
// The DOM class is the source of truth; an external clear would remove
|
||||
// 'is-failed' and unblock writes without needing an extra variable.
|
||||
if(_preferencesAutosaveStatusOwner&&
|
||||
_preferencesAutosaveStatusOwner!==owner&&
|
||||
el.classList.contains('is-failed')){
|
||||
return;
|
||||
}
|
||||
_preferencesAutosaveStatusOwner=state?owner:null;
|
||||
el.className='settings-autosave-status';
|
||||
if(!state){
|
||||
el.textContent='';
|
||||
@@ -8782,7 +8816,7 @@ function _schedulePreferencesAutosave(){
|
||||
|
||||
async function _autosavePreferencesSettings(payload){
|
||||
try{
|
||||
const saved=await api('/api/settings',{method:'POST',body:JSON.stringify(payload)});
|
||||
const saved=await _enqueueSettingsPost({method:'POST',body:JSON.stringify(payload)});
|
||||
if(payload&&payload.terminal_auto_expand_on_output!==undefined){
|
||||
window._terminalAutoExpandOnOutput=!!(saved&&saved.terminal_auto_expand_on_output);
|
||||
}
|
||||
@@ -8869,6 +8903,57 @@ function _retryPreferencesAutosave(){
|
||||
_autosavePreferencesSettings(payload);
|
||||
}
|
||||
|
||||
let _channelSaveSeq=0;
|
||||
// Last server-confirmed update_channel value. Seeded at panel hydration so the
|
||||
// failure-revert path always has a known-good value. _confirmedUpdateChannel is
|
||||
// the only reliable "previous" value: by the time a change event fires the
|
||||
// browser has already applied the picked option to the <select>, so
|
||||
// channelSel.value inside the handler IS the new value, not the old one.
|
||||
let _confirmedUpdateChannel=null;
|
||||
|
||||
async function _saveUpdateChannelFromSelector(channelSel){
|
||||
// #6612: dedicated write path for update_channel so the generic preferences
|
||||
// autosave payload never carries this field. A stale tab toggling an unrelated
|
||||
// preference must not overwrite a newer channel selection from another tab.
|
||||
if(!channelSel) return;
|
||||
const val=channelSel.value==='experimental'?'experimental':'stable';
|
||||
const seq=++_channelSaveSeq;
|
||||
if(typeof _setPreferencesAutosaveStatus==='function') _setPreferencesAutosaveStatus('saving','channel');
|
||||
try{
|
||||
const saved=await _enqueueSettingsPost({method:'POST',body:JSON.stringify({update_channel:val})});
|
||||
const confirmed=(saved&&(saved.update_channel==='experimental'||saved.update_channel==='stable'))
|
||||
?saved.update_channel:val;
|
||||
_confirmedUpdateChannel=confirmed;
|
||||
// The queue makes the server state FIFO; the sequence guard protects only
|
||||
// the selector and other response-driven UI from stale completions.
|
||||
if(seq!==_channelSaveSeq) return;
|
||||
channelSel.value=confirmed;
|
||||
if(typeof _setPreferencesAutosaveStatus==='function') _setPreferencesAutosaveStatus('saved','channel');
|
||||
// Run the update check and badge sync against the confirmed server value,
|
||||
// not the optimistic pre-save value.
|
||||
if(typeof checkUpdatesNow==='function'){
|
||||
try{checkUpdatesNow(confirmed);}catch(_){}
|
||||
}
|
||||
if(typeof _syncUpdateChannelBadge==='function') _syncUpdateChannelBadge(confirmed);
|
||||
}catch(e){
|
||||
console.warn('[settings] update_channel save failed',e);
|
||||
// Revert selector and badge to the last server-confirmed value so both
|
||||
// controls agree with what the server actually holds. Status clear and
|
||||
// revert are both inside the seq guard so a superseded in-flight failure
|
||||
// does not clear status that a newer write or the generic autosave owns.
|
||||
if(seq===_channelSaveSeq){
|
||||
const revertTo=_confirmedUpdateChannel||'stable';
|
||||
channelSel.value=revertTo;
|
||||
if(typeof _syncUpdateChannelBadge==='function') _syncUpdateChannelBadge(revertTo);
|
||||
// Do not call _setPreferencesAutosaveStatus('failed','channel'): its retry
|
||||
// button replays _retryPreferencesAutosave(), which cannot contain
|
||||
// update_channel (#6612). Clear the saving indicator instead; the selector
|
||||
// snap-back is the user's signal.
|
||||
if(typeof _setPreferencesAutosaveStatus==='function') _setPreferencesAutosaveStatus(null,'channel');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _syncSettingsMaxTokensPlaceholder(field, fallbackValue){
|
||||
if(!field) return;
|
||||
const parsedFallback=parseInt(fallbackValue,10);
|
||||
@@ -9318,19 +9403,13 @@ async function loadSettingsPanel(){
|
||||
const updateChannelSel=$('settingsUpdateChannel');
|
||||
if(updateChannelSel){
|
||||
updateChannelSel.value=settings.update_channel==='experimental'?'experimental':'stable';
|
||||
_confirmedUpdateChannel=updateChannelSel.value; // #6612: seed revert baseline
|
||||
updateChannelSel.addEventListener('change',function(){
|
||||
// Persist the channel, then invalidate the cached update check and
|
||||
// re-check so the banner reflects the newly-selected channel. Changing
|
||||
// the channel changes WHAT is offered, never WHAT is installed — the
|
||||
// update banner still gates the actual apply behind "Update Now".
|
||||
_schedulePreferencesAutosave();
|
||||
if(typeof checkUpdatesNow==='function'){
|
||||
// Pass the just-selected channel EXPLICITLY so the re-check cannot race
|
||||
// the debounced autosave PUT and answer for the previous channel.
|
||||
const _picked=updateChannelSel.value;
|
||||
setTimeout(function(){try{checkUpdatesNow(_picked);}catch(e){}},400);
|
||||
}
|
||||
if(typeof _syncUpdateChannelBadge==='function') _syncUpdateChannelBadge(updateChannelSel.value);
|
||||
// #6612: use the dedicated channel writer so generic preference autosaves
|
||||
// from a stale tab cannot overwrite a newer explicit channel selection.
|
||||
// Update check, badge sync, and failure status are handled inside
|
||||
// _saveUpdateChannelFromSelector after the POST is confirmed.
|
||||
_saveUpdateChannelFromSelector(updateChannelSel);
|
||||
},{once:false});
|
||||
}
|
||||
const ignoreAgentUpdatesCb=$('settingsIgnoreAgentUpdates');
|
||||
@@ -10517,7 +10596,7 @@ async function handlePluginEnableToggle(pluginKey, checked){
|
||||
try{
|
||||
const body={dashboard_plugins:{}};
|
||||
body.dashboard_plugins[pluginKey]=!!checked;
|
||||
await api('/api/settings',{method:'POST',body:JSON.stringify(body)});
|
||||
await _enqueueSettingsPost({method:'POST',body:JSON.stringify(body)});
|
||||
loadPluginsPanel();
|
||||
}catch(e){
|
||||
showToast(t('settings_save_failed')+e.message);
|
||||
@@ -11132,7 +11211,7 @@ function _attachBudgetControls(wrap,history,card,paceNum){
|
||||
|
||||
async function _saveBudget(value){
|
||||
try{
|
||||
await api('/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({provider_cost_budget:value})});
|
||||
await _enqueueSettingsPost({method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({provider_cost_budget:value})});
|
||||
const existing=card.querySelector('.provider-cost-chart-wrap');
|
||||
if(existing) existing.remove();
|
||||
renderProviderCostChart(card);
|
||||
@@ -11749,7 +11828,7 @@ function _updateAuthDisabledWarning(authStatus){
|
||||
|
||||
async function _setAuthDisabledAck(checked){
|
||||
try{
|
||||
await api('/api/settings',{method:'POST',body:JSON.stringify({_auth_disabled_acknowledged:!!checked})});
|
||||
await _enqueueSettingsPost({method:'POST',body:JSON.stringify({_auth_disabled_acknowledged:!!checked})});
|
||||
try{
|
||||
const authStatus=await api('/api/auth/status');
|
||||
_updateAuthWarningBadge(authStatus);
|
||||
@@ -12538,7 +12617,6 @@ async function saveSettings(andClose){
|
||||
body.pinned_sessions_limit=pinnedSessionsLimit;
|
||||
body.sync_to_insights=!!($('settingsSyncInsights')||{}).checked;
|
||||
body.check_for_updates=!!($('settingsCheckUpdates')||{}).checked;
|
||||
body.update_channel=($('settingsUpdateChannel')||{}).value==='experimental'?'experimental':'stable';
|
||||
body.ignore_agent_updates=!!($('settingsIgnoreAgentUpdates')||{}).checked;
|
||||
body.whats_new_summary_enabled=!!($('settingsWhatsNewSummary')||{}).checked;
|
||||
body.sound_enabled=!!($('settingsSoundEnabled')||{}).checked;
|
||||
@@ -12562,7 +12640,7 @@ async function saveSettings(andClose){
|
||||
const payload={...body,_set_password:pw.trim()};
|
||||
if(_settingsPasswordAuthEnabled) payload._current_password=currentPw;
|
||||
try{
|
||||
const saved=await api('/api/settings',{method:'POST',body:JSON.stringify(payload)});
|
||||
const saved=await _enqueueSettingsPost({method:'POST',body:JSON.stringify(payload)});
|
||||
if(modelChanged && model){
|
||||
try{
|
||||
await api('/api/default-model',{method:'POST',body:JSON.stringify({model,provider:modelState.model_provider||null})});
|
||||
@@ -12592,7 +12670,7 @@ async function saveSettings(andClose){
|
||||
}catch(e){showToast(t('settings_save_failed')+e.message);return;}
|
||||
}
|
||||
try{
|
||||
const saved=await api('/api/settings',{method:'POST',body:JSON.stringify(body)});
|
||||
const saved=await _enqueueSettingsPost({method:'POST',body:JSON.stringify(body)});
|
||||
if(modelChanged && model){
|
||||
try{
|
||||
await api('/api/default-model',{method:'POST',body:JSON.stringify({model,provider:modelState.model_provider||null})});
|
||||
@@ -12629,7 +12707,7 @@ async function goPasswordless(){
|
||||
const payload={_passwordless:true};
|
||||
if(_settingsPasswordAuthEnabled && currentPw) payload._current_password=currentPw;
|
||||
try{
|
||||
const saved=await api('/api/settings',{method:'POST',body:JSON.stringify(payload)});
|
||||
const saved=await _enqueueSettingsPost({method:'POST',body:JSON.stringify(payload)});
|
||||
showToast('Password removed. Passkey sign-in remains enabled.');
|
||||
_setSettingsAuthButtonsVisible(!!saved.auth_enabled);
|
||||
_syncPasswordlessButton({auth_enabled:saved.auth_enabled,password_auth_enabled:false,passkeys_count:1});
|
||||
@@ -12659,7 +12737,7 @@ async function disableAuth(){
|
||||
const payload={_clear_password:true};
|
||||
if(_settingsPasswordAuthEnabled) payload._current_password=currentPw;
|
||||
try{
|
||||
const saved=await api('/api/settings',{method:'POST',body:JSON.stringify(payload)});
|
||||
const saved=await _enqueueSettingsPost({method:'POST',body:JSON.stringify(payload)});
|
||||
showToast(t('auth_disabled'));
|
||||
const disableBtn=$('btnDisableAuth');
|
||||
if(disableBtn) disableBtn.style.display='none';
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"source": "https://github.com/nesquena/hermes-webui/issues/6612",
|
||||
"symptom": "A stale WebUI tab overwrites a newer update-channel selection when the user changes any unrelated preference.",
|
||||
"sequence": [
|
||||
"Select Experimental and leave Tab A open; its settingsUpdateChannel control holds 'experimental'.",
|
||||
"In Tab B, select Stable and let that save complete.",
|
||||
"Without refreshing Tab A, toggle any unrelated preference there, such as TPS display or sidebar density.",
|
||||
"Tab A's generic Preferences autosave posts its full stale payload to /api/settings.",
|
||||
"save_settings() accepts the explicit allowed enum value, so the server silently switches back to Experimental."
|
||||
],
|
||||
"stale_autosave_payload": {
|
||||
"update_channel": "experimental",
|
||||
"show_tps": true
|
||||
},
|
||||
"expected_persisted_channel_after_stale_autosave": "stable",
|
||||
"explicit_channel_payloads": [
|
||||
{ "update_channel": "stable" },
|
||||
{ "update_channel": "experimental" }
|
||||
],
|
||||
"normalization_cases": [
|
||||
{ "input": { "update_channel": "nightly" }, "expected": "experimental", "note": "invalid enum value is ignored; persisted value unchanged" },
|
||||
{ "input": { "update_channel": "" }, "expected": "experimental", "note": "empty string is not a valid enum value; persisted value unchanged" },
|
||||
{ "input": {}, "expected": "experimental", "note": "absent key leaves persisted value unchanged" }
|
||||
],
|
||||
"acceptance_criteria": [
|
||||
"Tab A may hold a stale channel value while Tab B changes the persisted channel.",
|
||||
"Changing an unrelated preference in Tab A does not alter the persisted channel.",
|
||||
"An explicit channel change still persists and immediately drives channel-scoped check/apply state.",
|
||||
"A regression test exercises the two-client stale-value sequence.",
|
||||
"Unknown/invalid/missing channel values leave the persisted channel unchanged."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,968 @@
|
||||
"""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}"
|
||||
)
|
||||
|
||||
@@ -94,7 +94,7 @@ def test_save_settings_password_change_preflights_current_password_before_api():
|
||||
body = match.group(1)
|
||||
assert "currentPwField=$('settingsCurrentPassword')" in body
|
||||
assert "showToast(t('current_password_required'))" in body
|
||||
assert body.index("showToast(t('current_password_required'))") < body.index("api('/api/settings'")
|
||||
assert body.index("showToast(t('current_password_required'))") < body.index("_enqueueSettingsPost(")
|
||||
|
||||
|
||||
def test_disable_auth_typed_confirm_locales_show_literal_phrase():
|
||||
|
||||
Reference in new issue
Block a user