Files
odysseus/tests/test_startup_shell_js.py
T
RaresKeY dbeed4b63f perf(ui): stop session loading from blocking shell (#5927)
* perf(ui): stop session loading from blocking shell

* fix(startup): open routes on their own data, retire the loader for good

Follow-up to review on #5927.

- Route openers are now classified by the data they actually read. Only
  /email touches the hydrated session list (its new-chat path falls back to
  the most recent session's model when no default chat is set), so every
  other route opens as soon as module wiring completes instead of queueing
  behind /api/sessions. This is the deferred-route half of #5926, which the
  first pass left unimplemented.
- index.html's 5s fallback removes the loader node again. Leaving it in the
  DOM indefinitely kept _shouldPreserveStartupComposer true forever on a
  hung /api/sessions, so the composer stopped clearing on session switch.
- A missing session module settles hydration instead of leaving the sidebar
  on "Loading chats…" and dropping the user's route on the floor.
- Startup sequencing moved to static/js/startupShell.js so it can be run by
  tests. The source-text assertions in test_startup_shell_session_loading.py
  are replaced by node-driven behavioural tests, per tests/TESTING_STANDARD.md.
- Reverted the unrequested loader a11y rework, removed the duplicated inert
  writes (the module stops the wave interval through a callback), and moved
  the bootstrap row's inline styles into .session-list-bootstrap.

* fix: preserve session bootstrap failure state

---------

Co-authored-by: Léo <leograndcontact@gmail.com>
2026-08-10 19:37:21 +01:00

378 lines
13 KiB
Python

"""Pin the startup shell contract (static/js/startupShell.js).
Driven through `node --input-type=module` against a stub DOM and a manually
pumped frame/timer clock, so the real module runs without a browser (same
approach as test_composer_arrow_up_recall_js.py). Skips when `node` is absent.
Locks in the behaviour #5926 asks for: the shell is revealed one paint after
wiring and does not wait on /api/sessions; the loader node survives hydration
as a startup sentinel but is always retired once hydration settles; the sidebar
owns its own loading/failure row and a successful zero-session render never
shows a false failure; and a URL route opens only after the data it actually
needs is authoritatively available.
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
_REPO = Path(__file__).resolve().parent.parent
_MODULE = _REPO / "static" / "js" / "startupShell.js"
_MODULE_URL = _MODULE.as_uri()
_HAS_NODE = shutil.which("node") is not None
_HARNESS = r"""
const MODULE_URL = 'MODULE_PATH';
// ── Stub DOM + a clock we pump by hand ────────────────────────────────────
function makeWorld() {
const byId = new Map();
const frames = [];
const timers = [];
const world = {
byId,
waveStops: 0,
addElement(id, { statusText = null } = {}) {
const el = {
id,
dataset: {},
style: {},
attrs: {},
removed: false,
status: null,
setAttribute(k, v) { this.attrs[k] = v; },
getAttribute(k) { return this.attrs[k]; },
remove() { this.removed = true; byId.delete(this.id); },
querySelector(sel) {
return sel === '[data-session-list-status]' ? this.status : null;
},
};
if (statusText !== null) el.status = { textContent: statusText };
byId.set(id, el);
return el;
},
// One "paint" = one round of already-queued rAF callbacks. afterNextPaint
// chains two, so a committed paint takes two rounds.
paint(rounds = 1) {
for (let i = 0; i < rounds; i++) {
const due = frames.splice(0, frames.length);
for (const fn of due) fn();
}
},
runTimers() {
const due = timers.splice(0, timers.length);
for (const t of due) t.fn();
},
pendingTimers() { return timers.length; },
};
globalThis.document = { getElementById: (id) => byId.get(id) || null };
globalThis.window = { __odysseusLoaderWaveStop: () => { world.waveStops += 1; } };
globalThis.requestAnimationFrame = (fn) => { frames.push(fn); return frames.length; };
globalThis.setTimeout = (fn, ms) => { timers.push({ fn, ms }); return timers.length; };
return world;
}
// Fresh module instance per case so deferred-route state cannot leak.
let _instance = 0;
async function loadModule() {
_instance += 1;
return import(MODULE_URL + '?case=' + _instance);
}
function loaderSnapshot(loader) {
return {
revealed: loader.dataset.shellRevealed === 'true',
opacity: loader.style.opacity ?? null,
pointerEvents: loader.style.pointerEvents ?? null,
ariaHidden: loader.getAttribute('aria-hidden') ?? null,
removed: loader.removed,
};
}
const cases = {};
cases.reveal_waits_one_paint_then_keeps_node = async () => {
const w = makeWorld();
const loader = w.addElement('app-loader');
const shell = await loadModule();
shell.revealApplicationShellAfterPaint();
const beforePaint = loaderSnapshot(loader);
w.paint(1);
const afterOneFrame = loaderSnapshot(loader);
w.paint(1);
return {
beforePaint,
afterOneFrame,
afterPaint: loaderSnapshot(loader),
waveStops: w.waveStops,
stillInDocument: w.byId.has('app-loader'),
};
};
cases.reveal_is_idempotent = async () => {
const w = makeWorld();
const loader = w.addElement('app-loader');
const shell = await loadModule();
shell.revealApplicationShellAfterPaint();
shell.revealApplicationShellAfterPaint();
w.paint(2);
shell.revealApplicationShellAfterPaint();
w.paint(2);
return { waveStops: w.waveStops, snapshot: loaderSnapshot(loader) };
};
cases.remove_retires_the_loader_node = async () => {
const w = makeWorld();
const loader = w.addElement('app-loader');
const shell = await loadModule();
shell.removeApplicationLoader();
const beforeTimers = loaderSnapshot(loader);
w.runTimers();
return { beforeTimers, afterTimers: loaderSnapshot(loader) };
};
cases.failed_hydration_marks_sidebar_row = async () => {
const w = makeWorld();
w.addElement('app-loader');
const row = w.addElement('session-list-loading', { statusText: 'Loading chats…' });
const shell = await loadModule();
await shell.settleSessionHydration(() => Promise.reject(new Error('boom')));
const beforePaint = row.status.textContent;
w.paint(2);
w.runTimers();
return {
beforePaint,
afterPaint: row.status.textContent,
loaderRemoved: !w.byId.has('app-loader'),
};
};
// A successful load with zero sessions must not schedule a failure write.
cases.zero_session_success_shows_no_failure = async () => {
const w = makeWorld();
w.addElement('app-loader');
const row = w.addElement('session-list-loading', { statusText: 'Loading chats…' });
const shell = await loadModule();
await shell.settleSessionHydration(() => Promise.resolve(true));
w.paint(1);
row.remove(); // renderSessionList() clearing #session-list
w.paint(1);
return { statusText: row.status.textContent, rowRemoved: row.removed };
};
// The whole point is getting /api/sessions off the critical path, not later.
cases.hydration_starts_synchronously = async () => {
const w = makeWorld();
w.addElement('app-loader');
const shell = await loadModule();
let started = false;
const done = shell.settleSessionHydration(() => { started = true; return Promise.resolve(true); });
const startedBeforeAwait = started;
await done;
return { startedBeforeAwait };
};
cases.synchronous_load_failure_still_settles = async () => {
const w = makeWorld();
w.addElement('app-loader');
const row = w.addElement('session-list-loading', { statusText: 'Loading chats…' });
const shell = await loadModule();
let opened = 0;
shell.deferRouteOpener('/email', () => { opened += 1; });
let threw = false;
let succeeded = true;
try {
succeeded = await shell.settleSessionHydration(() => { throw new Error('module blew up'); });
} catch (_) { threw = true; }
w.paint(2);
w.runTimers();
return {
threw,
succeeded,
opened,
statusText: row.status.textContent,
loaderRemoved: !w.byId.has('app-loader'),
ranAfterFailure: shell.runDeferredRouteOpener({ sessionsSettled: true }),
};
};
cases.route_without_session_data_opens_before_hydration = async () => {
const w = makeWorld();
w.addElement('app-loader');
const shell = await loadModule();
let opened = 0;
shell.deferRouteOpener('/notes', () => { opened += 1; });
const ranEarly = shell.runDeferredRouteOpener();
const openedAfterEarly = opened;
const ranAgain = shell.runDeferredRouteOpener({ sessionsSettled: true });
return { ranEarly, openedAfterEarly, ranAgain, opened };
};
cases.route_with_session_data_waits_for_hydration = async () => {
const w = makeWorld();
w.addElement('app-loader');
const shell = await loadModule();
let opened = 0;
shell.deferRouteOpener('/email', () => { opened += 1; });
const ranEarly = shell.runDeferredRouteOpener();
const openedAfterEarly = opened;
const succeeded = await shell.settleSessionHydration(() => Promise.resolve(true));
return {
ranEarly,
openedAfterEarly,
openedAfterHydration: opened,
succeeded,
needsSessions: [shell.routeNeedsSessionData('/email'), shell.routeNeedsSessionData('/notes')],
};
};
cases.missing_session_module_keeps_route_deferred = async () => {
const w = makeWorld();
w.addElement('app-loader');
const row = w.addElement('session-list-loading', { statusText: 'Loading chats…' });
const shell = await loadModule();
let opened = 0;
shell.deferRouteOpener('/email', () => { opened += 1; });
const succeeded = await shell.settleSessionHydration(null);
w.paint(2);
w.runTimers();
return {
opened,
succeeded,
statusText: row.status.textContent,
loaderRemoved: !w.byId.has('app-loader'),
ranAfterFailure: shell.runDeferredRouteOpener({ sessionsSettled: true }),
};
};
cases.throwing_route_opener_is_contained = async () => {
const w = makeWorld();
w.addElement('app-loader');
const shell = await loadModule();
shell.deferRouteOpener('/notes', () => { throw new Error('opener blew up'); });
let threw = false;
let ran = false;
try { ran = shell.runDeferredRouteOpener(); } catch (_) { threw = true; }
return { threw, ran, ranAgain: shell.runDeferredRouteOpener({ sessionsSettled: true }) };
};
const out = {};
for (const [name, fn] of Object.entries(cases)) out[name] = await fn();
console.log(JSON.stringify(out));
""".replace("MODULE_PATH", _MODULE_URL)
@pytest.fixture(scope="module")
def results():
if not _HAS_NODE:
pytest.skip("node is not installed")
proc = subprocess.run(
["node", "--input-type=module", "-e", _HARNESS],
capture_output=True,
text=True,
timeout=60,
)
assert proc.returncode == 0, f"node harness failed:\n{proc.stderr}"
return json.loads(proc.stdout.strip().splitlines()[-1])
def test_module_exists():
assert _MODULE.is_file(), f"missing {_MODULE}"
def test_shell_is_revealed_one_paint_after_wiring(results):
r = results["reveal_waits_one_paint_then_keeps_node"]
assert r["beforePaint"]["revealed"] is False, "revealed before any frame ran"
assert r["afterOneFrame"]["revealed"] is False, "revealed before the paint committed"
assert r["afterPaint"] == {
"revealed": True,
"opacity": "0",
"pointerEvents": "none",
"ariaHidden": "true",
"removed": False,
}
assert r["waveStops"] == 1, "loader wave interval kept running after reveal"
def test_revealed_loader_stays_as_startup_sentinel(results):
# sessions.js / sidebar-layout.js read #app-loader as "startup in progress".
r = results["reveal_waits_one_paint_then_keeps_node"]
assert r["stillInDocument"] is True
assert r["afterPaint"]["removed"] is False
def test_reveal_is_idempotent(results):
r = results["reveal_is_idempotent"]
assert r["waveStops"] == 1, "reveal ran its side effects more than once"
assert r["snapshot"]["revealed"] is True
def test_loader_node_is_retired_after_the_fade(results):
r = results["remove_retires_the_loader_node"]
assert r["beforeTimers"]["revealed"] is True, "removal should hide immediately"
assert r["beforeTimers"]["removed"] is False, "removal should wait for the fade"
assert r["afterTimers"]["removed"] is True, "loader node outlived hydration"
def test_failed_session_load_marks_the_sidebar_row(results):
r = results["failed_hydration_marks_sidebar_row"]
assert r["beforePaint"] == "Loading chats…", "failure written before the render frame"
assert r["afterPaint"] == "Chats unavailable"
assert r["loaderRemoved"] is True, "a failed load must still free the shell"
def test_zero_session_success_never_shows_a_failure(results):
r = results["zero_session_success_shows_no_failure"]
assert r["rowRemoved"] is True
assert r["statusText"] == "Loading chats…", "false 'Chats unavailable' on empty success"
def test_hydration_request_starts_synchronously(results):
r = results["hydration_starts_synchronously"]
assert r["startedBeforeAwait"] is True, "/api/sessions start was deferred a microtask"
def test_synchronous_load_failure_still_settles(results):
r = results["synchronous_load_failure_still_settles"]
assert r["threw"] is False, "a throwing loadSessions must not escape"
assert r["succeeded"] is False
assert r["opened"] == 0, "session-dependent route opened without session data"
assert r["ranAfterFailure"] is False, "failed startup left a stale route opener"
assert r["statusText"] == "Chats unavailable"
assert r["loaderRemoved"] is True
def test_route_needing_no_session_data_opens_before_hydration(results):
r = results["route_without_session_data_opens_before_hydration"]
assert r["ranEarly"] is True, "/notes waited on /api/sessions it does not read"
assert r["openedAfterEarly"] == 1
assert r["ranAgain"] is False, "route opener fired twice"
assert r["opened"] == 1
def test_route_needing_session_data_waits_for_hydration(results):
r = results["route_with_session_data_waits_for_hydration"]
assert r["ranEarly"] is False, "/email opened before the session list was there"
assert r["openedAfterEarly"] == 0
assert r["openedAfterHydration"] == 1
assert r["succeeded"] is True
assert r["needsSessions"] == [True, False]
def test_missing_session_module_still_settles_without_opening_data_route(results):
r = results["missing_session_module_keeps_route_deferred"]
assert r["succeeded"] is False
assert r["opened"] == 0, "route opened without the session module it depends on"
assert r["ranAfterFailure"] is False, "missing module left a stale route opener"
assert r["statusText"] == "Chats unavailable"
assert r["loaderRemoved"] is True
def test_throwing_route_opener_is_contained(results):
r = results["throwing_route_opener_is_contained"]
assert r["threw"] is False
assert r["ran"] is True
assert r["ranAgain"] is False, "a failed opener must not be retried"