Files
hermes-webui/tests/test_issue4662_profile_switch_skeleton_static.py
T
18793a1ba4 Release exp-v0.52.42: ?profile= deep links (#5730) + Russian locale refresh (#5778) (#5953)
* Refresh Russian localization bundle

* feat(#5682): honor profile query during boot

* fix(profile): refresh deep-link reasoning state (#5682)

* test(ci): initialize reasoning transition harness state

* test(ci): accept reasoning fetch context override

* fix(#5682): preserve retryable profile query intent

* test(#5682): accept profile switch result contract

* fix(#5682): bind reasoning fetches to profile transitions

* Release exp-v0.52.42: ?profile= deep links (#5730) + Russian locale refresh (#5778)

---------

Co-authored-by: DrMaks22 <DrMaks22@users.noreply.github.com>
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
2026-07-11 15:58:47 -07:00

377 lines
21 KiB
Python

"""Static-source assertions for the #4662 profile-switch loading skeletons.
These don't execute JS — they assert the source wiring so the behaviour can't
silently regress:
* switchToProfile() shows both skeletons up front (clears stale content),
parallelizes the independent list+workspace refreshes, and restores real
content on failure so a skeleton never strands.
* renderSessionListFromCache() clears the skeleton-active flag on real render.
* style.css defines the skeleton classes, the sheen + fade keyframes, the
reduced-motion fallback, and dark-mode tokens.
"""
import re
from pathlib import Path
REPO_ROOT = Path(__file__).parent.parent.resolve()
PANELS = (REPO_ROOT / "static" / "panels.js").read_text(encoding="utf-8")
SESSIONS = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
WORKSPACE = (REPO_ROOT / "static" / "workspace.js").read_text(encoding="utf-8")
CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8")
def _switch_body() -> str:
start = PANELS.index("async function switchToProfile(")
# grab a generous slice (the function is long); next top-level function after it
end = PANELS.index("function openProfileCreate(", start)
return PANELS[start:end]
def _show_session_skeleton_call_idx(body: str) -> int:
match = re.search(
r"(?:^|\n)\s*(?:if\s*\([^\n;]*showSessionListSkeleton[^\n;]*\)\s*)?showSessionListSkeleton\s*\(",
body,
)
assert match, "switchToProfile() must call showSessionListSkeleton(...)"
return match.start()
class TestSwitchWiring:
def test_shows_session_skeleton_up_front(self):
body = _switch_body()
skeleton_idx = _show_session_skeleton_call_idx(body)
# ...and before the awaited /api/profile/switch POST, so stale rows clear immediately
assert skeleton_idx < body.index("await api('/api/profile/switch'")
def test_shows_workspace_skeleton_when_panel_open(self):
body = _switch_body()
assert "showWorkspaceTreeSkeleton()" in body
assert "_workspaceVisibleAtStart" in body
def test_workspace_refresh_after_switch_guard(self):
# The workspace tree refresh (loadDir) must run AFTER the stale-switch
# generation guard — loadDir paints the tree with only a session-id
# check, and empty-session switches reuse the session id, so starting it
# before the guard could let an older switch paint over a newer one
# (Codex gate #4662). The skeletons shown up front already provide the
# immediate cross-surface feedback.
body = _switch_body()
guard = "if (_switchGen !== _profileSwitchGeneration) return false;"
# In the non-sessionInProgress branch, the loadDir('.') call must come
# after an occurrence of the guard.
dir_idx = body.index("const dirLoad = loadDir('.');")
guard_before = body.rfind(guard, 0, dir_idx)
assert guard_before != -1, "loadDir('.') must be preceded by the switch-generation guard"
def test_restores_real_content_on_failure(self):
body = _switch_body()
catch = body[body.index("} catch (e) {"):]
# #4671: failure-restore clears the skeleton flag (asserted in
# test_switch_failure_restore_clears_skeleton) then re-renders the real list.
assert "renderSessionListFromCache()" in catch, "failed switch must restore real list"
def test_failure_path_clears_workspace_skeleton_when_no_workspace(self):
# The success path clears a stranded workspace skeleton when the profile
# has no bound workspace; the failure path must do the same — otherwise a
# switch failure while the workspace panel is open and the (still-current)
# session has no workspace strands the up-front skeleton forever (#4662).
body = _switch_body()
catch = body[body.index("} catch (e) {"):]
assert "clearWorkspaceTreeSkeleton()" in catch, (
"failed switch must clear the workspace skeleton when there's no workspace to restore"
)
def test_noop_self_switch_early_returns(self):
# Opus gate #4662: a switch to the already-active profile must bail before
# showing a skeleton (activateCurrentProfile() doesn't pre-check), else it
# flashes skeleton→restore.
body = _switch_body()
head = body[: _show_session_skeleton_call_idx(body)]
assert "name === S.activeProfile" in head, "missing no-op self-switch early-return"
assert "return true;" in head
def test_dismisses_rename_and_menu_before_skeleton(self):
# Opus gate #4662: renderSessionListFromCache() early-returns while
# _renamingSid / _sessionActionMenu is set — which would strand the
# skeleton. switchToProfile must dismiss both before showing it.
body = _switch_body()
pre = body[: _show_session_skeleton_call_idx(body)]
assert "_renamingSid = null" in pre, "must clear inline-rename state before skeleton"
assert "closeSessionActionMenu()" in pre, "must close row action menu before skeleton"
def test_clears_workspace_skeleton_when_no_workspace(self):
# Opus gate #4662 (blocker): if the new profile has no bound workspace the
# real loadDir is skipped, so the up-front workspace skeleton must be
# explicitly cleared or it strands forever.
body = _switch_body()
assert body.count("clearWorkspaceTreeSkeleton()") >= 2, (
"both switch branches must clear a stranded workspace skeleton"
)
def test_switch_post_suppresses_generic_timeout_toast(self):
# @rodboev review #4662: the switch POST must pass timeoutToast:false so
# api()'s generic "Request timed out" toast can't fire for a superseded or
# transient-but-eventually-successful switch. Failures surface only through
# the generation-guarded catch handler, which is the single source of truth.
body = _switch_body()
post_idx = body.index("await api('/api/profile/switch'")
# The same api(...) call expression that targets /api/profile/switch must
# carry timeoutToast: false. Scope to a small window around the call.
window = body[post_idx: post_idx + 300]
assert "timeoutToast: false" in window or "timeoutToast:false" in window.replace(" ", ""), (
"the /api/profile/switch POST must suppress the generic timeout toast"
)
def test_in_progress_branch_reguards_after_list_render(self):
# @rodboev/greptile review #4662: the sessionInProgress branch awaits
# renderSessionList(); without a generation re-check after that await a
# rapid second switch could have its workspace skeleton cleared / a stale
# toast popped by the slower earlier switch. Mirror the no-messages guard.
body = _switch_body()
guard = "if (_switchGen !== _profileSwitchGeneration) return false;"
# Inside the sessionInProgress branch: locate its "await renderSessionList()"
# then the profile_switched_new_conversation toast; a guard must sit between.
new_toast_idx = body.index("profile_switched_new_conversation")
list_render_before = body.rfind("await renderSessionList();", 0, new_toast_idx)
assert list_render_before != -1
guard_between = body.find(guard, list_render_before, new_toast_idx)
assert guard_between != -1, (
"the in-progress switch branch must re-check the switch generation "
"after its awaited renderSessionList()"
)
class TestSessionsWiring:
def test_skeleton_flag_cleared_on_real_render(self):
# The authoritative clear now happens in _applySessionListPayload()
# immediately before the real render, while renderSessionListFromCache()
# keeps the guard that blocks stale cached rows during the skeleton phase.
render_idx = SESSIONS.index("function renderSessionListFromCache(")
render_body = SESSIONS[render_idx: render_idx + 1200]
apply_idx = SESSIONS.index("function _applySessionListPayload(")
# Slice to the END of _applySessionListPayload()'s body (the next top-level
# function), not a fixed char window — #4748 added lines inside this function
# that pushed `_sessionListSkeletonActive = false;` past a fixed 4000-char
# window. Anchoring on the next function def keeps the assertion robust.
apply_end = SESSIONS.index("function _mergeRenderSessionListOptions(", apply_idx)
apply_body = SESSIONS[apply_idx: apply_end]
assert "if(_sessionListSkeletonActive)return;" in render_body.replace(" ", "")
assert "_sessionListSkeletonActive = false;" in apply_body
assert "renderSessionListFromCache();" in apply_body
def test_builder_defines_groups_and_function(self):
assert "const _SESSION_SKELETON_GROUPS" in SESSIONS
assert "function showSessionListSkeleton(" in SESSIONS
def test_skeleton_tears_down_virtual_scroll_state(self):
# #4662 Codex gate: on long virtualized sidebars, leaving the
# data-session-virtual-* window state + a queued scroll RAF active would
# let _scheduleSessionVirtualizedRender() repaint the PREVIOUS profile's
# cached rows over the skeleton. The builder must clear that state.
idx = SESSIONS.index("function showSessionListSkeleton(")
body = SESSIONS[idx: idx + 2000]
assert "cancelAnimationFrame(_sessionVirtualScrollRaf)" in body
assert "delete list.dataset.sessionVirtualTotal" in body
assert "delete list.dataset.sessionVirtualStart" in body
assert "delete list.dataset.sessionVirtualEnd" in body
def test_virtual_render_guarded_by_skeleton_flag(self):
# The virtual-scroll scheduler must bail while a skeleton is up.
idx = SESSIONS.index("function _scheduleSessionVirtualizedRender(")
body = SESSIONS[idx: idx + 600]
assert "if(_sessionListSkeletonActive) return;" in body
class TestWorkspaceWiring:
def test_builder_defined(self):
assert "const _WS_SKELETON_ROWS" in WORKSPACE
assert "function showWorkspaceTreeSkeleton(" in WORKSPACE
def test_skeleton_clear_helper_defined(self):
# The strand-clear helper must exist and only empty #fileTree when it
# still holds a skeleton (so it can't clobber a real render).
assert "function clearWorkspaceTreeSkeleton(" in WORKSPACE
idx = WORKSPACE.index("function clearWorkspaceTreeSkeleton(")
body = WORKSPACE[idx: idx + 400]
assert ".skeleton-tree" in body, "clear helper must check for a skeleton before emptying"
class TestSkeletonCss:
def test_core_classes_present(self):
for cls in (".skeleton-list", ".skeleton-row", ".skeleton-bar",
".skeleton-group-label", ".skeleton-tree", ".skeleton-tree-row",
".skeleton-glyph"):
assert cls in CSS, f"missing skeleton CSS class {cls}"
def test_sheen_and_fade_keyframes(self):
assert "@keyframes skeletonSheen" in CSS
assert "@keyframes skeletonFadeIn" in CSS
assert "animation:skeletonSheen" in CSS.replace(" ", "")
def test_group_label_fade_settles_at_dim_opacity(self):
# @rodboev/greptile review #4662: the shared skeletonFadeIn ends at
# opacity:1 with fill-mode:both, which would override the group label's
# intended resting opacity:.5 for the whole time the skeleton is up. Labels
# must use a dedicated keyframe that ends at .5.
compact = CSS.replace(" ", "")
assert "@keyframesskeletonFadeInDim{from{opacity:0;}to{opacity:.5;}}" in compact, (
"group labels need a dim fade-in that settles at opacity:.5"
)
assert "skeleton-group-label{animation:skeletonFadeInDim" in compact, (
"group labels must use the dim fade-in, not the full-brightness skeletonFadeIn"
)
def test_reduced_motion_disables_animation(self):
# There must be a prefers-reduced-motion block that turns the skeleton
# sheen animation off (accessibility contract).
compact = CSS.replace(" ", "")
assert "prefers-reduced-motion" in compact
# The reduced-motion rule names .skeleton-bar and sets animation:none.
rm_blocks = [b for b in compact.split("@media(prefers-reduced-motion:reduce){")
if ".skeleton-bar" in b[:400]]
assert rm_blocks, "no reduced-motion block scoping .skeleton-bar"
assert "animation:none" in rm_blocks[0][:400], "reduced-motion must disable the sheen"
def test_theme_tokens_defined_for_light_and_dark(self):
compact = CSS.replace(" ", "")
assert "--skeleton-base:" in compact
assert "--skeleton-sheen:" in compact
assert ":root.dark{--skeleton-base:" in compact
def _iter_indices(haystack: str, needle: str):
"""Yield every start index of needle in haystack."""
i = haystack.find(needle)
while i != -1:
yield i
i = haystack.find(needle, i + 1)
class TestSwitchRaceGuards:
"""#4671: the two render races the Codex gate caught on the rebased stage."""
def test_session_skeleton_guard_blocks_non_switch_renders(self):
# Race-2: while the profile-switch skeleton is up, renderSessionListFromCache()
# must early-return, so a gateway-poll/SSE/timer/cache render can't repaint the
# PREVIOUS profile's cached _allSessions over the skeleton in the window before
# /api/sessions resolves.
idx = SESSIONS.index("function renderSessionListFromCache(")
head = SESSIONS[idx: idx + 800]
# signature unchanged (many static tests anchor on the empty-paren marker)
assert "function renderSessionListFromCache(){" in SESSIONS, (
"renderSessionListFromCache signature must stay () — tests anchor on it"
)
assert "if(_sessionListSkeletonActive) return;" in head, (
"renderSessionListFromCache must bail while the profile-switch skeleton is up"
)
def test_skeleton_flag_cleared_by_authoritative_render_when_data_is_fresh(self):
# Race-2 (the other half): the skeleton flag must be cleared ONLY when fresh data
# is in hand, so the bail above can't strand the skeleton. _applySessionListPayload
# runs on the resolved /api/sessions payload (superseded responses already discarded
# by the generation guard), so it clears the flag right before painting.
apply_idx = SESSIONS.index("function _applySessionListPayload(")
apply_body = SESSIONS[apply_idx: SESSIONS.index("\nfunction _mergeRenderSessionListOptions(", apply_idx)]
clear_pos = apply_body.find("_sessionListSkeletonActive = false;")
paint_pos = apply_body.find("renderSessionListFromCache();")
assert clear_pos != -1, "_applySessionListPayload must clear the skeleton flag"
assert paint_pos != -1 and clear_pos < paint_pos, (
"the skeleton flag must be cleared right before the authoritative paint"
)
# And the fetch-failure path must clear it too (else a failed switch strands it):
refresh = SESSIONS[SESSIONS.index("async function _runRenderSessionListRefresh("):]
refresh = refresh[: refresh.index("\nasync function _drainRenderSessionListQueue(")]
assert refresh.count("_sessionListSkeletonActive = false;") >= 1, (
"the /api/sessions failure path must clear the skeleton flag so it can't strand"
)
def test_switch_failure_restore_clears_skeleton(self):
# The switchToProfile catch (failure on the still-current previous profile) must
# clear the skeleton flag before restoring the real list, or the skeleton strands.
body = _switch_body()
catch = body[body.index("} catch (e) {"):]
clear_pos = catch.find("_sessionListSkeletonActive = false;")
restore_pos = catch.find("renderSessionListFromCache()")
assert clear_pos != -1, "switch failure path must clear the skeleton flag"
assert restore_pos != -1 and clear_pos < restore_pos, (
"skeleton flag must be cleared before the failure-restore render"
)
def test_switch_invalidates_inflight_renders_before_skeleton(self):
# Race-2 (in-flight variant, Codex re-gate 2): a renderSessionList() already in
# flight BEFORE the switch (old profile's /api/sessions) would resolve, pass the
# generation guard (its _gen still current — the switch hasn't bumped it yet),
# clear the skeleton flag, and paint stale rows. switchToProfile must invalidate
# in-flight/queued renders at switch start, BEFORE showing the skeleton.
assert "function _invalidateSessionListRenders(){" in SESSIONS, (
"must have a helper that invalidates in-flight/queued session-list renders"
)
inv = SESSIONS[SESSIONS.index("function _invalidateSessionListRenders(){"):]
inv = inv[: inv.index("\n}")]
assert "_renderSessionListGen++" in inv, "invalidation must bump the render generation"
assert "_pendingSessionListPayload = null" in inv, "invalidation must drop a deferred payload"
assert "_renderSessionListQueuedRequest = null" in inv, "invalidation must drop the queued request"
# ...and the switch must call it BEFORE showSessionListSkeleton(). Anchor on the
# actual call guard (not the bare name, which also appears in nearby comments).
body = _switch_body()
inv_pos = body.find("_invalidateSessionListRenders === 'function')")
skel_pos = body.find("showSessionListSkeleton === 'function')")
assert inv_pos != -1, "switchToProfile must invalidate in-flight renders at switch start"
assert skel_pos != -1 and inv_pos < skel_pos, (
"the in-flight invalidation must run BEFORE the skeleton is shown"
)
def test_switch_embargoes_session_list_renders_during_switch_window(self):
# Race-2 (mid-switch variant, Codex re-gate 3): a renderSessionList() that STARTS
# after the skeleton shows but before /api/profile/switch sets the new-profile
# cookie fetches the OLD profile's rows and would clobber the skeleton. An embargo
# (set before the skeleton, lifted right before the switch-owned render and in the
# finally) makes _runRenderSessionListRefresh drop ALL payloads during the window.
assert "function _setProfileSwitchListEmbargo(" in SESSIONS, "missing embargo setter"
# _runRenderSessionListRefresh must drop payloads while embargoed, after the gen guard
refresh = SESSIONS[SESSIONS.index("async function _runRenderSessionListRefresh("):]
refresh = refresh[: refresh.index("\nasync function _drainRenderSessionListQueue(")]
assert "if (_profileSwitchListEmbargo) return;" in refresh, (
"_runRenderSessionListRefresh must drop payloads while the profile-switch embargo is on"
)
body = _switch_body()
# embargo set before the skeleton, and lifted (false) before the switch-owned render(s)
set_pos = body.find("_setProfileSwitchListEmbargo === 'function') _setProfileSwitchListEmbargo(true)")
skel_pos = body.find("showSessionListSkeleton === 'function')")
assert set_pos != -1 and set_pos < skel_pos, "embargo must be set before the skeleton"
assert body.count("_setProfileSwitchListEmbargo(false)") >= 2, (
"embargo must be lifted before the switch-owned render(s) / on failure"
)
# and guaranteed-lifted in a finally so it can't freeze the sidebar on an early-return/throw
assert "finally {" in body and "_setProfileSwitchListEmbargo(false)" in body[body.index("finally {"):], (
"embargo must be lifted in the switch's finally as a safety net"
)
def test_workspace_tree_generation_token_guards_loaddir(self):
# Race-1 (CORE): an empty-session profile switch reuses the same session_id, so
# loadDir()'s session_id guard alone can't reject a stale pre-switch /api/list.
# A _wsTreeGen generation token, bumped UNCONDITIONALLY at switch start (even when
# the workspace panel is closed, since loadDir('.') still runs), gates loadDir().
assert "_wsTreeGen" in WORKSPACE, "missing workspace-tree generation token"
assert "function bumpWorkspaceTreeGen(" in WORKSPACE, "missing bumpWorkspaceTreeGen helper"
# switchToProfile bumps it unconditionally (NOT only inside the panel-gated skeleton call)
body = _switch_body()
assert "bumpWorkspaceTreeGen()" in body, (
"switchToProfile must bump the workspace-tree generation at switch start"
)
bump_idx = body.index("bumpWorkspaceTreeGen()")
wsskel_idx = body.index("showWorkspaceTreeSkeleton()")
assert bump_idx < wsskel_idx, (
"the unconditional bump must precede the panel-gated showWorkspaceTreeSkeleton"
)
# loadDir captures + re-checks the generation after BOTH awaited /api/list points
ld = WORKSPACE[WORKSPACE.index("async function loadDir("):]
ld = ld[: ld.index("\nfunction refreshWorkspacePanel(")]
assert "const treeGen=_wsTreeGen" in ld, "loadDir must capture the tree generation at call time"
assert ld.count("treeGen!==_wsTreeGen") >= 2, (
"loadDir must re-check the tree generation after BOTH awaited /api/list points "
"(root render + expanded-dirs prefetch) and discard stale renders"
)