Files
odysseus/tests/test_spinner_stops_when_never_attached_js.py
T

320 lines
11 KiB
Python

"""Pin the self-termination contract of the canvas spinners in static/js/spinner.js.
Background: the whirlpool spinner drives itself with requestAnimationFrame and
decides whether to keep going by looking at `element.isConnected`. It used to
re-arm forever whenever the element had *never* been connected, on the theory
that start() runs before the caller appends the element. Callers that start a
spinner and then take an early return - an aborted request, a panel that
resolved from cache before the loading row was inserted - therefore left a rAF
loop redrawing an 84-segment spiral into a detached canvas until the tab closed.
Measured on an idle app: ~110 whirlpool frames per second with zero canvases in
the document.
These tests lock in all four exits (never attached, attached-then-removed,
stop(), tab hidden) and, just as importantly, the one case that must NOT stop:
a spinner that is actually on screen.
Driven through `node --input-type=module` so the real module runs, same idiom as
test_esc_menu_stack_js.py. The module source is inlined rather than imported by
path because the repo has no `"type": "module"` in package.json; spinner.js has
no imports of its own, so inlining is exact. A fake clock and a manual frame
pump replace performance.now()/requestAnimationFrame, so nothing here depends on
wall-clock time or real frame timing.
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
_REPO = Path(__file__).resolve().parent.parent
_MODULE = _REPO / "static" / "js" / "spinner.js"
_HAS_NODE = shutil.which("node") is not None
_SRC = _MODULE.read_text(encoding="utf-8") if _MODULE.exists() else ""
# Browser stand-ins, installed before the module body runs. `clock` is advanced
# only by pump(), so every timing decision in the module is deterministic.
_STUBS = r"""
let clock = 0;
Object.defineProperty(globalThis, 'performance', {
value: { now: () => clock }, configurable: true, writable: true,
});
const pending = new Map();
let nextFrameId = 1;
let framesRun = 0;
globalThis.requestAnimationFrame = (cb) => {
const id = nextFrameId++;
pending.set(id, cb);
return id;
};
globalThis.cancelAnimationFrame = (id) => { pending.delete(id); };
/** Advance the clock `steps` frames of `msPerFrame` and run whatever is queued. */
function pump(steps, msPerFrame = 16) {
for (let i = 0; i < steps; i++) {
clock += msPerFrame;
const due = [...pending.values()];
pending.clear();
for (const cb of due) { framesRun++; cb(); }
}
}
function framesPending() { return pending.size; }
function framesSince(mark) { return framesRun - mark; }
function frameMark() { return framesRun; }
function makeCtx() {
const noop = () => {};
return {
clearRect: noop, beginPath: noop, arc: noop, moveTo: noop, lineTo: noop,
stroke: noop, fill: noop, save: noop, restore: noop,
strokeStyle: '', fillStyle: '', lineWidth: 0, globalAlpha: 1,
lineCap: '', lineJoin: '',
};
}
function makeElement(tag) {
const el = {
tagName: tag, className: '', textContent: '', innerHTML: '',
width: 0, height: 0, isConnected: false, parentNode: null,
style: { cssText: '' },
children: [],
classList: { add: () => {}, remove: () => {}, contains: () => false },
getContext: () => makeCtx(),
appendChild(child) {
child.parentNode = this;
this.children.push(child);
return child;
},
removeChild(child) {
this.children = this.children.filter((c) => c !== child);
child.parentNode = null;
return child;
},
};
return el;
}
const docListeners = [];
globalThis.document = {
hidden: false,
documentElement: makeElement('html'),
createElement: makeElement,
createTextNode: (t) => ({ textContent: t }),
addEventListener: (type, fn) => { docListeners.push([type, fn]); },
removeEventListener: (type, fn) => {
const i = docListeners.findIndex(([t, f]) => t === type && f === fn);
if (i >= 0) docListeners.splice(i, 1);
},
};
globalThis.getComputedStyle = () => ({ getPropertyValue: () => '' });
function visibilityListeners() {
return docListeners.filter(([t]) => t === 'visibilitychange').length;
}
function fireVisibility(hidden) {
document.hidden = hidden;
for (const [t, fn] of [...docListeners]) if (t === 'visibilitychange') fn();
}
/** A started whirlpool spinner whose element is not in the document. */
function startedWhirlpool() {
const sp = new Spinner('', 'clean', 'whirlpool');
sp.createElement();
sp.start();
return sp;
}
"""
def _run(body: str) -> dict:
"""Run `body` with the real spinner module and the browser stubs in scope."""
js = _STUBS + "\n" + _SRC + "\n" + body
proc = subprocess.run(
["node", "--input-type=module"],
input=js, capture_output=True, text=True, encoding="utf-8",
cwd=str(_REPO), timeout=30,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout.strip())
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_never_attached_whirlpool_stops_itself():
# The leak: element created, spinner started, element never inserted. Past
# the grace window it must give up rather than re-arm forever.
body = """
const sp = startedWhirlpool();
pump(30); // 480 ms - inside the grace window
const early = { running: sp.isRunning, pending: framesPending() };
pump(120); // ~2.4 s total - past the grace window
const mark = frameMark();
pump(60); // nothing should be left to run
console.log(JSON.stringify({
early,
running: sp.isRunning,
rafId: sp.rafId,
pending: framesPending(),
framesAfterStop: framesSince(mark),
}));
"""
out = _run(body)
assert out["early"] == {"running": True, "pending": 1}, "gave up during the grace window"
assert out["running"] is False
assert out["rafId"] is None
assert out["pending"] == 0
assert out["framesAfterStop"] == 0, "loop kept drawing after it gave up"
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_attached_whirlpool_keeps_running_past_the_grace_window():
# The converse guard: the fix must not kill spinners that are on screen.
body = """
const sp = new Spinner('', 'clean', 'whirlpool');
sp.createElement();
sp.element.isConnected = true;
sp.start();
pump(400); // ~6.4 s, far past the grace window
const mark = frameMark();
pump(10);
console.log(JSON.stringify({
running: sp.isRunning,
pending: framesPending(),
framesDrawn: framesSince(mark),
}));
"""
out = _run(body)
assert out["running"] is True
assert out["pending"] == 1
assert out["framesDrawn"] == 10, "a visible spinner stopped animating"
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_attached_then_removed_whirlpool_stops():
# The pre-existing exit - a loading row replaced by results - still works.
body = """
const sp = new Spinner('', 'clean', 'whirlpool');
sp.createElement();
sp.element.isConnected = true;
sp.start();
pump(200);
const whileAttached = sp.isRunning;
sp.element.isConnected = false; // results arrived, row swapped out
pump(3);
const mark = frameMark();
pump(20);
console.log(JSON.stringify({
whileAttached,
running: sp.isRunning,
pending: framesPending(),
framesAfterRemoval: framesSince(mark),
}));
"""
out = _run(body)
assert out["whileAttached"] is True
assert out["running"] is False
assert out["pending"] == 0
assert out["framesAfterRemoval"] == 0
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_loading_row_helper_stops_when_the_row_is_never_inserted():
# createLoadingRow() starts the spinner for the caller and hands back a
# detached row, so a caller that early-returns is the real leak shape.
body = """
const row = createLoadingRow('Loading...', 16);
pump(200);
const mark = frameMark();
pump(40);
console.log(JSON.stringify({
pending: framesPending(),
framesAfterStop: framesSince(mark),
rowHasChildren: row.children.length > 0,
}));
"""
out = _run(body)
assert out["rowHasChildren"] is True, "harness built the wrong row"
assert out["pending"] == 0
assert out["framesAfterStop"] == 0
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_stop_cancels_the_pending_frame_and_releases_the_listener():
# stop() must be authoritative: no queued frame survives it, and it leaves
# no visibilitychange listener behind on a dead spinner.
body = """
const before = visibilityListeners();
const sp = new Spinner('', 'clean', 'whirlpool');
sp.createElement();
sp.element.isConnected = true;
sp.start();
const armed = visibilityListeners();
sp.stop();
const mark = frameMark();
pump(20);
console.log(JSON.stringify({
before, armed, after: visibilityListeners(),
running: sp.isRunning,
rafId: sp.rafId,
pending: framesPending(),
framesAfterStop: framesSince(mark),
}));
"""
out = _run(body)
assert (out["before"], out["armed"], out["after"]) == (0, 1, 0)
assert out["running"] is False
assert out["rafId"] is None
assert out["pending"] == 0
assert out["framesAfterStop"] == 0
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_hidden_tab_pauses_frames_and_showing_resumes_them():
body = """
const sp = new Spinner('', 'clean', 'whirlpool');
sp.createElement();
sp.element.isConnected = true;
sp.start();
pump(5);
fireVisibility(true);
const hiddenMark = frameMark();
pump(30);
const whileHidden = { drawn: framesSince(hiddenMark), pending: framesPending() };
fireVisibility(false);
const shownMark = frameMark();
pump(10);
console.log(JSON.stringify({
whileHidden,
running: sp.isRunning,
drawnAfterShow: framesSince(shownMark),
}));
"""
out = _run(body)
assert out["whileHidden"] == {"drawn": 0, "pending": 0}, "kept drawing in a hidden tab"
assert out["running"] is True
assert out["drawnAfterShow"] == 10, "did not resume when the tab came back"
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_restarted_spinner_gets_a_fresh_grace_window():
# The grace deadline is per-run. A spinner reused after stop() must not
# inherit the previous run's timestamp and die on its first frame.
body = """
const sp = startedWhirlpool();
pump(200); // times out, never attached
const stopped = sp.isRunning;
sp.element.isConnected = true; // now inserted for real
sp.start();
pump(30);
console.log(JSON.stringify({
stopped,
running: sp.isRunning,
pending: framesPending(),
}));
"""
out = _run(body)
assert out["stopped"] is False
assert out["running"] is True
assert out["pending"] == 1