Files
hermes-webui/tests/test_issue4662_profile_switch_skeleton_behaviour.py
T
nesquena-hermes 42d1fbc728 Phase 1 (#4662): address remaining Greptile review (P2 + stagger)
Builds on Nathan's switch-failure workspace-skeleton-clear fix (which already
covered Greptile P1). Remaining two:

- [P2] Removed dead _clearSessionListSkeletonFlag() — the flag is reset inline in
  renderSessionListFromCache(); the helper had no call site.
- [stagger] The .skeleton-row:nth-child(N) fade-in delays skipped most rows
  because group-label siblings are interleaved with rows in the DOM (positions
  shift: rows land at child 2, 4-6, 8-11, so 9/10/11 got no delay). Replaced the
  CSS :nth-child rules with an inline per-row animationDelay set in JS (rowIndex
  * 0.025s, capped 0.2s) so every row gets its correct stagger regardless of
  group structure.

Tests: +1 behavioural (every row carries a real inline stagger delay; last row
not skipped). 26 pass.
2026-06-22 18:12:55 +00:00

226 lines
9.0 KiB
Python

"""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: {},
dataset: {},
_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;
var _sessionVirtualScrollRaf = 0;
global.cancelAnimationFrame = function(){};
global.requestAnimationFrame = function(){ return 0; };
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,
// Every row should carry an inline stagger delay (set in JS, not via a
// CSS :nth-child that would skip rows because group labels are interleaved).
rows_with_delay: list.querySelectorAll('.skeleton-row').filter(function(r){
return r.style && typeof r.style.animationDelay === 'string' && r.style.animationDelay.length > 0;
}).length,
last_row_delay: (function(){
var rs = list.querySelectorAll('.skeleton-row');
var last = rs[rs.length - 1];
return last && last.style ? last.style.animationDelay : null;
})(),
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_rows_have_inline_stagger_delay(outcome):
# Greptile: the stagger must be applied inline per row, not via a CSS
# :nth-child rule (group labels are interleaved with rows, so :nth-child
# would skip most rows). Every row carries an animationDelay, and the last
# row's delay is non-empty (capped at 0.2s).
assert outcome["rows_with_delay"] == 8, (
f"all 8 rows must carry an inline stagger delay: {outcome}"
)
assert outcome["last_row_delay"] not in (None, "", "0s"), (
f"the last row must have a real (non-zero) stagger delay, not be skipped: {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}"