mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-09 16:32:21 +02:00
Squash Odysseus development history
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user