Phase 1 (#4662): tests for profile-switch loading skeletons

- behaviour: node-driven, runs the actual showSessionListSkeleton() /
  showWorkspaceTreeSkeleton() builders against a mock DOM and asserts the
  produced structure (3 group labels + 8 single-line rows with title+stamp;
  8 tree rows with glyph+name, 7 file rows with a size bar, 1 dir without).
- static: asserts switchToProfile() shows both skeletons up front before the
  /api/profile/switch POST, parallelizes the list+workspace refresh, restores
  real content on failure; renderSessionListFromCache clears the skeleton flag;
  style.css has the classes, sheen+fade keyframes, reduced-motion disable, and
  light+dark theme tokens.
This commit is contained in:
nesquena-hermes authored and nesquena-hermes committed 2026-06-22 18:12:25 +00:00
1 parent bafd7ddfd2
commit 4abdb54ede
2 files changed
+298

No files matched your search

@@ -0,0 +1,198 @@
"""Behavioural tests for the #4662 profile-switch loading skeletons.
Phase 1 adds two skeleton builders that render loading placeholders the instant
a profile switch begins, so the user never sees the previous profile's
conversation list / workspace tree while the new profile's data fetches:
* showSessionListSkeleton() (static/sessions.js) -> #sessionList
* showWorkspaceTreeSkeleton() (static/workspace.js) -> #fileTree
These drive the ACTUAL functions from the source via node with a mock DOM and
assert the produced structure (group labels + single-line rows, tree rows with
glyph/name/size), so a regression in the skeleton shape is caught. Pairs with
the static-assertion tests in test_issue4662_profile_switch_skeleton_static.py.
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).parent.parent.resolve()
SESSIONS_JS = REPO_ROOT / "static" / "sessions.js"
WORKSPACE_JS = REPO_ROOT / "static" / "workspace.js"
NODE = shutil.which("node")
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
_DRIVER_SRC = r"""
const fs = require('fs');
// ── Minimal DOM ─────────────────────────────────────────────────────────────
function makeEl(tag) {
const el = {
tagName: (tag || 'div').toUpperCase(),
className: '',
style: {},
_attrs: {},
children: [],
innerHTML: '',
scrollTop: 0,
setAttribute(k, v) { this._attrs[k] = String(v); },
getAttribute(k) { return Object.prototype.hasOwnProperty.call(this._attrs, k) ? this._attrs[k] : null; },
appendChild(c) {
// setting innerHTML='' elsewhere clears children; mimic that contract
this.children.push(c); c.parentNode = this; return c;
},
querySelectorAll(sel) { return _matchAll(this, sel); },
};
// innerHTML setter that clears children when set to ''
Object.defineProperty(el, 'innerHTML', {
get() { return this.__html || ''; },
set(v) { this.__html = v; if (v === '') this.children = []; },
});
return el;
}
function _walk(node, out) {
for (const c of node.children || []) { out.push(c); _walk(c, out); }
}
function _matchAll(root, sel) {
const all = []; _walk(root, all);
// support simple ".class" and ".a .b" (descendant) and ".a.b" (compound)
sel = sel.trim();
const parts = sel.split(/\s+/);
function matchOne(el, s) {
const classes = s.split('.').filter(Boolean);
return classes.every(c => (el.className || '').split(/\s+/).includes(c));
}
if (parts.length === 1) return all.filter(el => matchOne(el, parts[0]));
// descendant: just match the LAST segment (sufficient for our assertions)
return all.filter(el => matchOne(el, parts[parts.length - 1]));
}
const els = {};
global.document = {
createElement: (t) => makeEl(t),
createDocumentFragment: () => makeEl('frag'),
};
global.$ = (id) => els[id] || null;
// Provide the containers the builders write into.
els.sessionList = makeEl('div');
els.fileTree = makeEl('div');
function extractFunc(src, name) {
const re = new RegExp('function\\s+' + name + '\\s*\\(');
const start = src.search(re);
if (start < 0) throw new Error(name + ' not found');
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);
}
function extractConst(src, name) {
// grab `const NAME = [...];` (array literal, possibly multiline) and rewrite
// to a global assignment so eval'd functions in other scopes can see it.
const re = new RegExp('const\\s+' + name + '\\s*=\\s*\\[');
const start = src.search(re);
if (start < 0) throw new Error(name + ' not found');
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++;
}
const literal = src.slice(src.indexOf('[', start), i);
return 'global.' + name + ' = ' + literal + ';';
}
const sessSrc = fs.readFileSync(process.argv[2], 'utf8');
const wsSrc = fs.readFileSync(process.argv[3], 'utf8');
// Module-scope state the session builder references.
var _sessionListSkeletonActive = false;
eval(extractConst(sessSrc, '_SESSION_SKELETON_GROUPS'));
eval(extractFunc(sessSrc, 'showSessionListSkeleton'));
eval(extractConst(wsSrc, '_WS_SKELETON_ROWS'));
eval(extractFunc(wsSrc, 'showWorkspaceTreeSkeleton'));
// ── Run both builders and report structure ─────────────────────────────────
showSessionListSkeleton();
showWorkspaceTreeSkeleton();
const list = els.sessionList;
const tree = els.fileTree;
const result = {
list_wrap_class: (list.children[0] || {}).className || '',
list_wrap_aria: (list.children[0] || {}).getAttribute ? list.children[0].getAttribute('aria-hidden') : null,
group_labels: list.querySelectorAll('.skeleton-group-label').length,
rows: list.querySelectorAll('.skeleton-row').length,
titles: list.querySelectorAll('.skeleton-title').length,
stamps: list.querySelectorAll('.skeleton-stamp').length,
skeleton_active_flag: _sessionListSkeletonActive,
list_scrolltop: list.scrollTop,
tree_wrap_class: (tree.children[0] || {}).className || '',
tree_rows: tree.querySelectorAll('.skeleton-tree-row').length,
tree_glyphs: tree.querySelectorAll('.skeleton-glyph').length,
tree_names: tree.querySelectorAll('.skeleton-name').length,
tree_sizes: tree.querySelectorAll('.skeleton-size').length,
};
process.stdout.write(JSON.stringify(result));
"""
@pytest.fixture(scope="module")
def outcome(tmp_path_factory):
driver = tmp_path_factory.mktemp("skel") / "driver.js"
driver.write_text(_DRIVER_SRC, encoding="utf-8")
res = subprocess.run(
[NODE, str(driver), str(SESSIONS_JS), str(WORKSPACE_JS)],
capture_output=True, text=True, timeout=30,
)
if res.returncode != 0:
raise RuntimeError(f"node driver failed: {res.stderr}")
return json.loads(res.stdout)
def test_session_skeleton_wrap_is_aria_hidden(outcome):
assert outcome["list_wrap_class"] == "skeleton-list"
assert outcome["list_wrap_aria"] == "true", "skeleton is decorative; must be aria-hidden"
def test_session_skeleton_has_grouped_rows(outcome):
# 3 groups in the spec; rows = 1 + 3 + 4 = 8
assert outcome["group_labels"] == 3, f"expected 3 group labels: {outcome}"
assert outcome["rows"] == 8, f"expected 8 skeleton rows: {outcome}"
def test_session_skeleton_rows_have_title_and_stamp(outcome):
# Each row mirrors the real .session-item: title bar + timestamp bar.
assert outcome["titles"] == 8, f"every row needs a title bar: {outcome}"
assert outcome["stamps"] == 8, f"every row needs a timestamp bar: {outcome}"
def test_session_skeleton_sets_active_flag_and_resets_scroll(outcome):
assert outcome["skeleton_active_flag"] is True, "must flag skeleton active for the clear-on-render path"
assert outcome["list_scrolltop"] == 0, "skeleton should reset scroll to top"
def test_workspace_skeleton_wrap_is_aria_hidden(outcome):
assert outcome["tree_wrap_class"] == "skeleton-tree"
def test_workspace_skeleton_has_tree_rows(outcome):
# 8 rows in the spec, each with a glyph + name bar.
assert outcome["tree_rows"] == 8, f"expected 8 tree rows: {outcome}"
assert outcome["tree_glyphs"] == 8, f"every tree row needs a glyph: {outcome}"
assert outcome["tree_names"] == 8, f"every tree row needs a name bar: {outcome}"
def test_workspace_skeleton_dir_rows_have_no_size(outcome):
# Spec marks exactly 1 row as a directory (dir:true), which omits the size
# bar; the other 7 (files) show a size bar on the right.
assert outcome["tree_sizes"] == 7, f"only file rows show a size bar: {outcome}"
@@ -0,0 +1,100 @@
"""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.
"""
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]
class TestSwitchWiring:
def test_shows_session_skeleton_up_front(self):
body = _switch_body()
assert "showSessionListSkeleton()" in body
# ...and before the awaited /api/profile/switch POST, so stale rows clear immediately
assert body.index("showSessionListSkeleton()") < body.index("/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_parallelizes_list_and_workspace_refresh(self):
body = _switch_body()
# The non-sessionInProgress branch kicks off both fetches before awaiting.
assert "const listLoad = renderSessionList();" in body
assert "loadDir('.')" in body
assert "await listLoad;" in body
def test_restores_real_content_on_failure(self):
body = _switch_body()
catch = body[body.index("} catch (e) {"):]
assert "renderSessionListFromCache()" in catch, "failed switch must restore real list"
class TestSessionsWiring:
def test_skeleton_flag_cleared_on_real_render(self):
# renderSessionListFromCache clears the skeleton-active flag when it
# writes real rows (so a strand can't persist).
idx = SESSIONS.index("function renderSessionListFromCache(")
body = SESSIONS[idx: idx + 4000]
assert "_sessionListSkeletonActive=false" in body.replace(" ", "")
def test_builder_defines_groups_and_function(self):
assert "const _SESSION_SKELETON_GROUPS" in SESSIONS
assert "function showSessionListSkeleton(" in SESSIONS
class TestWorkspaceWiring:
def test_builder_defined(self):
assert "const _WS_SKELETON_ROWS" in WORKSPACE
assert "function showWorkspaceTreeSkeleton(" in WORKSPACE
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_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