Files
hermes-webui/tests/test_issue6819_auto_scroll_follow_persistence.py
T
a662e37f89 fix(settings): honor persisted auto-follow across boot failures and partial bodies (#6856)
* fix(settings): honor persisted auto-follow across boot failures and partial bodies (#6819)

The Auto-follow new content toggle was silently re-enabled by two paths:
- boot.js settings-fetch-failure fallback hardcoded _autoScrollFollow=true,
  clobbering an explicit OFF for the whole session (post-turn scroll yanks
  until refresh);
- _applySavedSettingsUi assigned body.auto_scroll_follow!==false
  unconditionally, so a partial settings body without the key evaluated
  undefined!==false -> true.

Fix: mirror the resolved value into profile-namespaced localStorage
(JSON map keyed by active profile), make the boot fallback read the mirror
instead of hardcoding ON, and only override the runtime flag when a settings
body actually owns the key. Fresh users (no mirror) still default to ON.

Tests: update test_issue4006 fallback assertion; add
test_issue6819_auto_scroll_follow_persistence covering source-shape guards
and extracted-helper behavior (fresh default, persisted OFF, profile
isolation, partial-body no-clobber, save-path persist).

* fix(settings): resolve auto-follow mirror profile from cookie before S.activeProfile init (#6856 review)

P1 (greptile-apps review): the profile-namespaced mirror was keyed by
S.activeProfile, but the boot settings-fetch-failure fallback runs before
boot resolves S.activeProfile, so a non-default profile with a failed fetch
read the 'default' mirror entry. Resolve the key from the hermes_profile
cookie (set per-client by /api/profile/switch, readable synchronously at
page load) first, falling back to S.activeProfile, then 'default'.

P2: document the persistence contract in the helper block (storage shape,
profile-key resolution order, read semantics, upgrade path).

Tests: extend the extracted-helper node test with the P1 scenario (cookie
authoritative over stale S.activeProfile, per-profile isolation via cookie,
no-cookie fallback) and add source-shape guards for the cookie read.

* fix(settings): defer auto-follow mirror read until profile resolves (P1 follow-up)

greptile-apps review: the hermes_profile cookie is HttpOnly — document.cookie
can never read it, so the cookie-based profile key from 39fed70b always fell
back to 'default', re-introducing the wrong-namespace bug for non-default
profiles on a failed settings fetch.

Correct model: the active profile is only known asynchronously via
/api/profile/active. The boot settings-fetch-failure path now sets a safe
default (ON) plus a deferred-reapply flag; once S.activeProfile resolves,
the mirror is read with the profile-correct namespace and the flag clears.
The success path (which already applied+persisted the authoritative value)
never touches the flag.

Tests updated to the deferred model: source-shape guard asserts the re-apply
runs AFTER profile resolution (fallback < profile-resolve < reapply ordering),
no document.cookie read in the profile key, and the node behavioral test
simulates boot-failure -> profile-resolve -> deferred re-apply.

* fix(settings): defer auto-follow mirror write until profile resolves (P1 round 3)

greptile-apps review: the SUCCESS boot path called _persistAutoScrollFollow
before S.activeProfile initialized, so a non-default profile's successful
settings fetch stored its value in the 'default' namespace. A later
failed-fetch boot would then find no entry for the real profile and restore
auto-follow ON.

The success path now defers the mirror write the same way the failure path
defers its read: it records the value in _autoScrollFollowDeferredPersist,
and the post-profile-resolution block persists it under the correct
namespace (then clears the flag). Both deferred branches now run together
after S.activeProfile resolves.

Tests: success path must not call _persistAutoScrollFollow before profile
resolution (source-shape), deferred-persist anchor ordered after profile
resolve, and the node behavioral test covers success-boot -> profile-resolve
-> deferred write under the resolved profile (and NOT under default).

* fix(settings): make auto-follow mirror global to match the backend contract (maintainer review)

Maintainer review on #6856: the mirror was profile-keyed, but the backend
authority is GLOBAL — SETTINGS_FILE = STATE_DIR / 'settings.json'
(api/config.py), and /api/settings calls load_settings() with no
profile-specific file. A profile-keyed map leaves a freshly-opened profile
with no mirror entry, so a failed settings fetch on that profile wrongly
restored ON despite the global OFF — the original failure stayed
reproducible.

Revert to a single global last-known value ('1'/'0' under one key):
- _persistAutoScrollFollow / _readPersistedAutoScrollFollow now use a
  plain scalar, not a JSON profile map.
- Boot success path persists synchronously (no deferred write — the global
  mirror has no profile dimension).
- Boot failure fallback reads synchronously (no deferred re-apply — no
  profile resolution needed).
- Remove _autoScrollFollowProfileKey, _autoScrollFollowDeferredPersist,
  _autoScrollFollowDeferredReapply entirely.

Tests: behavioral node test now asserts the maintainer-requested regression
(global OFF survives profile change + failed fetch, single scalar storage);
source-shape guards assert the profile map and deferred machinery are gone;
boot success persists; partial-body and save-path assertions unchanged.
14/14 pass.

* fix(settings): never persist synthesized auto-follow from a failed autosave (Greptile P1)

Greptile review on #6856: in _autosaveAppearanceSettings, when the POST
fails (saved is falsy), '!saved' synthesizes _autoScrollFollow=true, and
the added persist call wrote that synthesized ON into the mirror. A later
boot settings-fetch failure would then restore ON from the corrupted
mirror, reintroducing post-turn scroll yanks for a user who explicitly
disabled auto-follow.

Persist ONLY from an explicit boolean in the server response:
  if(saved && typeof saved.auto_scroll_follow==='boolean' && typeof _persistAutoScrollFollow==='function'){
    _persistAutoScrollFollow(saved.auto_scroll_follow);
  }
A failed save or a partial response without the key leaves the mirror
untouched (last authoritative value preserved).

Tests: source-shape guard asserts the explicit-boolean requirement; the
node behavioral test replicates the failed-autosave path (mirror stays
OFF) and the successful-autosave path (explicit boolean persists).

* fix(settings): sync auto-follow mirror on successful settings-panel GET

Gate review (Codex) found a stale-mirror gap in #6856's own contract: a
successful loadSettingsPanel() GET applied the authoritative auto_scroll_follow
value to window._autoScrollFollow but did not persist the global mirror, so an
explicit OFF applied via the panel left a stale ON mirror that a later
boot-fetch failure would restore (reopening #6819). Persist from the panel GET
too, only when the server sent an explicit boolean (never a synthesized
default) — matching the boot + autosave contract. Adds a bite-tested regression.

Co-authored-by: sand01chi <sand01chi@users.noreply.github.com>

---------

Co-authored-by: sand01chi <sand01chi@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-08-09 03:36:49 +00:00

187 lines
8.6 KiB
Python

"""Regression coverage for #6819 — the Auto-follow toggle must survive
transient settings-fetch failures and partial settings bodies.
Root cause fixed: the boot settings-fetch-failure path hardcoded
`window._autoScrollFollow=true`, silently clobbering an explicit OFF for the
whole session (post-turn scroll yanks until refresh), and
`_applySavedSettingsUi` assigned `body.auto_scroll_follow!==false`
unconditionally — a partial body without the key evaluates `undefined!==false`
→ `true`, re-enabling follow mid-session.
The fix mirrors the resolved value into GLOBAL localStorage (the backend
setting is one global settings.json — a profile-keyed map would leave a
freshly-opened profile with no entry and wrongly restore ON, per maintainer
review on #6856), makes the boot fallback read the mirror instead of
hardcoding ON, and only overrides the runtime flag when a settings body
actually owns the key.
"""
import json
import pathlib
import re
ROOT = pathlib.Path(__file__).resolve().parent.parent
def _read(rel):
return (ROOT / rel).read_text(encoding="utf-8")
# ── Source-shape guards (what the code must contain) ────────────────────────
def test_boot_has_global_mirror_helpers():
src = _read("static/boot.js")
assert "_persistAutoScrollFollow" in src, "persist helper must exist"
assert "_readPersistedAutoScrollFollow" in src, "read helper must exist"
assert "_AUTO_SCROLL_FOLLOW_KEY" in src, "storage key constant must exist"
# Maintainer review (#6856): the backend setting is GLOBAL (one
# settings.json), so the mirror must NOT be profile-keyed — a profile map
# would leave a freshly-opened profile with no entry and wrongly restore
# ON. The helper must be a single global scalar, read synchronously in
# the boot fallback (no profile resolution, no deferral).
assert "_autoScrollFollowProfileKey" not in src, (
"mirror must NOT be profile-keyed (maintainer review, #6856)"
)
assert "_autoScrollFollowDeferredReapply" not in src, (
"no deferred re-apply needed — global mirror is read synchronously"
)
assert "_autoScrollFollowDeferredPersist" not in src, (
"no deferred persist needed — global mirror is written synchronously"
)
def test_boot_success_path_persists_mirror():
src = _read("static/boot.js")
assert "window._autoScrollFollow=_persistAutoScrollFollow(" in src, (
"boot settings path must persist the resolved value into the mirror"
)
def test_boot_fallback_reads_mirror_not_hardcoded_true():
src = _read("static/boot.js")
assert "window._autoScrollFollow=_readPersistedAutoScrollFollow()" in src, (
"the boot-failure fallback must read the mirror synchronously"
)
# The old hardcoded fallback must be gone (ui.js keeps its own
# undefined-before-init default at _autoScrollFollow===undefined, #6614 —
# a different, legitimate case not touched by #6819).
assert "_sessionEndlessScrollEnabled=false;" in src, (
"fallback anchor missing"
)
def test_partial_body_does_not_override_follow():
src = _read("static/panels.js")
assert (
"hasOwnProperty.call(body,'auto_scroll_follow')" in src
or "hasOwnProperty.call(body, 'auto_scroll_follow')" in src
), (
"_applySavedSettingsUi must guard the override with hasOwnProperty so "
"a partial body without the key cannot silently re-enable follow"
)
def test_settings_save_persists_mirror():
src = _read("static/panels.js")
# Greptile P1 (#6856): the autosave path must persist ONLY from an
# explicit boolean in the server response — a failed save (`saved` falsy)
# or a response without the key must not write the synthesized default
# (ON) into the mirror.
assert "_persistAutoScrollFollow(saved.auto_scroll_follow)" in src, (
"autosave must persist from the explicit server boolean"
)
assert "typeof saved.auto_scroll_follow==='boolean'" in src, (
"autosave must require an explicit boolean before persisting (P1)"
)
def test_settings_panel_load_persists_mirror():
src = _read("static/panels.js")
# Regression (#6856 gate finding): a successful settings-panel GET applies
# the authoritative value to window._autoScrollFollow — it must ALSO sync the
# global mirror, or an explicit OFF applied here leaves a stale ON mirror that
# a later boot-fetch failure restores (reopening #6819). Persist only from an
# explicit server boolean (never a synthesized default), matching the contract.
panel_block = src.split("const autoScrollFollowCb=$('settingsAutoScrollFollow');", 1)[1][:900]
assert "typeof settings.auto_scroll_follow==='boolean'" in panel_block, (
"settings-panel load must require an explicit boolean before persisting"
)
assert "_persistAutoScrollFollow(settings.auto_scroll_follow)" in panel_block, (
"settings-panel load must sync the global mirror from the authoritative GET value"
)
# ── Behavioral guards (extracted helper logic must behave correctly) ────────
def test_mirror_helpers_behavior_via_source_extraction():
"""Extract the real JS helper functions from boot.js and execute them under
node with a fake localStorage to prove the semantics end-to-end."""
import re as _re
import shutil
import subprocess
import sys
assert shutil.which("node"), "node required for this test"
src = _read("static/boot.js")
m = _re.search(
r"const _AUTO_SCROLL_FOLLOW_KEY=.*?window\._readPersistedAutoScrollFollow=_readPersistedAutoScrollFollow;",
src,
_re.S,
)
assert m, "helper block not found in boot.js"
block = m.group(0)
js = r"""
const store = {};
const localStorage = {
getItem(k){ return (k in store) ? store[k] : null; },
setItem(k,v){ store[k] = v; }
};
const window = {};
""" + block + r"""
// 1. fresh user: default ON
if (_readPersistedAutoScrollFollow() !== true) throw new Error('fresh default must be ON');
// 2. user turns OFF -> persisted (global)
if (_persistAutoScrollFollow(false) !== false) throw new Error('persist OFF failed');
if (_readPersistedAutoScrollFollow() !== false) throw new Error('persisted OFF must be honored');
// 3. MAINTAINER-REVIEW REGRESSION (#6856): the backend setting is GLOBAL
// (one settings.json), so the mirror must be too. Saving OFF under one
// profile, then a failed settings fetch on ANY other profile, must still
// restore OFF — a profile-keyed map would find no entry and wrongly
// return ON. With the global mirror there is no profile dimension at all:
// simulate the boot-failure path directly.
const bootFailureFallbackValue = _readPersistedAutoScrollFollow();
if (bootFailureFallbackValue !== false) throw new Error('global mirror must survive profile change + failed fetch (maintainer review)');
// 4. ON round-trip
if (_persistAutoScrollFollow(true) !== true) throw new Error('persist ON failed');
if (_readPersistedAutoScrollFollow() !== true) throw new Error('persisted ON must be honored');
// 5. GREPTILE P1 (#6856): a FAILED autosave (`saved` falsy) must NOT write
// the synthesized default into the mirror — replicate the autosave guard:
// only an explicit boolean from the server response persists.
// (a) mirror holds OFF (user's real preference)
if (_persistAutoScrollFollow(false) !== false) throw new Error('setup OFF failed');
// (b) failed autosave: saved=null -> guard rejects -> mirror stays OFF
const savedFailed = null;
if (savedFailed && typeof savedFailed.auto_scroll_follow === 'boolean' && typeof _persistAutoScrollFollow === 'function') {
_persistAutoScrollFollow(savedFailed.auto_scroll_follow);
}
if (_readPersistedAutoScrollFollow() !== false) throw new Error('failed autosave must NOT corrupt the mirror (Greptile P1)');
// (c) successful autosave with explicit boolean -> mirror updates
const savedOk = { auto_scroll_follow: true };
if (savedOk && typeof savedOk.auto_scroll_follow === 'boolean' && typeof _persistAutoScrollFollow === 'function') {
_persistAutoScrollFollow(savedOk.auto_scroll_follow);
}
if (_readPersistedAutoScrollFollow() !== true) throw new Error('successful autosave must persist the explicit boolean');
// 6. storage is a single global scalar, not a profile map
if (store['hermes-auto-scroll-follow'] !== '1') throw new Error('storage must be a single scalar value: '+store['hermes-auto-scroll-follow']);
console.log('MIRROR-HELPERS-OK');
"""
proc = subprocess.run(
["node", "-e", js],
capture_output=True,
text=True,
cwd=ROOT,
timeout=30,
)
assert proc.returncode == 0, proc.stderr
assert "MIRROR-HELPERS-OK" in proc.stdout, proc.stdout