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:
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
|
||||
Reference in new issue
Block a user