mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-13 03:32:21 +02:00
378 lines
13 KiB
Python
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"
|