"""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" )