227 lines
6.7 KiB
Python
227 lines
6.7 KiB
Python
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
|
|
POLLER = Path(__file__).parents[1] / "frontend" / "context-poller.js"
|
|
|
|
|
|
def run_node(script: str) -> dict:
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
def test_context_poller_is_single_flight_and_pauses_until_visible():
|
|
script = f"""
|
|
const createContextPoller = require({json.dumps(str(POLLER))});
|
|
let calls = 0;
|
|
let hidden = false;
|
|
let resolveRequest;
|
|
const timers = [];
|
|
const poller = createContextPoller({{
|
|
fetchContext: () => {{
|
|
calls += 1;
|
|
if (calls > 1) return Promise.resolve({{ repos: [] }});
|
|
return new Promise((resolve) => {{ resolveRequest = resolve; }});
|
|
}},
|
|
onSnapshot: () => {{}},
|
|
onError: () => {{}},
|
|
isHidden: () => hidden,
|
|
setTimer: (callback) => {{ timers.push(callback); return timers.length; }},
|
|
clearTimer: () => {{}},
|
|
intervalMs: 8000,
|
|
}});
|
|
|
|
(async () => {{
|
|
const first = poller.start();
|
|
const joined = poller.refresh();
|
|
const callsWhilePending = calls;
|
|
resolveRequest({{ repos: [] }});
|
|
await Promise.all([first, joined]);
|
|
|
|
hidden = true;
|
|
poller.setVisible(false);
|
|
const scheduled = timers.shift();
|
|
if (scheduled) scheduled();
|
|
await Promise.resolve();
|
|
const callsWhileHidden = calls;
|
|
|
|
hidden = false;
|
|
await poller.setVisible(true);
|
|
process.stdout.write(JSON.stringify({{
|
|
callsWhilePending,
|
|
callsWhileHidden,
|
|
callsAfterResume: calls,
|
|
}}));
|
|
}})();
|
|
"""
|
|
|
|
assert run_node(script) == {
|
|
"callsWhilePending": 1,
|
|
"callsWhileHidden": 1,
|
|
"callsAfterResume": 2,
|
|
}
|
|
|
|
|
|
def test_context_poller_sends_revisions_and_merges_changed_sections():
|
|
script = f"""
|
|
const createContextPoller = require({json.dumps(str(POLLER))});
|
|
const requested = [];
|
|
const rendered = [];
|
|
const responses = [
|
|
{{ context: {{ user: {{ login: 'timmy' }} }}, events: [{{ id: 1 }}], notifications: [],
|
|
sections: {{ context: 'fresh', events: 'fresh', notifications: 'fresh' }},
|
|
revisions: {{ context: 1, events: 1, notifications: 1 }}, freshness: {{ age_seconds: 0 }} }},
|
|
{{ sections: {{ context: 'fresh', events: 'fresh', notifications: 'fresh' }},
|
|
revisions: {{ context: 1, events: 2, notifications: 1 }}, events: [{{ id: 2 }}],
|
|
freshness: {{ age_seconds: 1 }} }},
|
|
{{ sections: {{ context: 'fresh', events: 'fresh', notifications: 'fresh' }},
|
|
revisions: {{ context: 1, events: 2, notifications: 1 }}, freshness: {{ age_seconds: 2 }} }},
|
|
];
|
|
const poller = createContextPoller({{
|
|
fetchContext: revisions => {{ requested.push({{ ...revisions }}); return Promise.resolve(responses.shift()); }},
|
|
onSnapshot: (snapshot, changed) => rendered.push({{
|
|
context: snapshot.context.user.login,
|
|
event: snapshot.events[0].id,
|
|
age: snapshot.freshness.age_seconds,
|
|
changed,
|
|
}}),
|
|
onError: error => {{ throw error; }},
|
|
setTimer: () => 1,
|
|
clearTimer: () => {{}},
|
|
}});
|
|
(async () => {{
|
|
await poller.refresh();
|
|
await poller.refresh();
|
|
await poller.refresh();
|
|
process.stdout.write(JSON.stringify({{ requested, rendered }}));
|
|
}})();
|
|
"""
|
|
|
|
assert run_node(script) == {
|
|
"requested": [
|
|
{},
|
|
{"context": 1, "events": 1, "notifications": 1},
|
|
{"context": 1, "events": 2, "notifications": 1},
|
|
],
|
|
"rendered": [
|
|
{"context": "timmy", "event": 1, "age": 0,
|
|
"changed": ["context", "events", "notifications"]},
|
|
{"context": "timmy", "event": 2, "age": 1,
|
|
"changed": ["events"]},
|
|
{"context": "timmy", "event": 2, "age": 2, "changed": []},
|
|
],
|
|
}
|
|
|
|
|
|
def test_context_poller_aborts_a_stalled_request_and_recovers_on_schedule():
|
|
script = f"""
|
|
const createContextPoller = require({json.dumps(str(POLLER))});
|
|
const timers = [];
|
|
const deadlineTimers = [];
|
|
const errors = [];
|
|
const snapshots = [];
|
|
const signals = [];
|
|
let calls = 0;
|
|
const poller = createContextPoller({{
|
|
fetchContext: (revisions, options) => {{
|
|
calls += 1;
|
|
signals.push(options.signal);
|
|
if (calls === 1) return new Promise(() => {{}});
|
|
return Promise.resolve({{ context: {{ user: {{ login: 'timmy' }} }} }});
|
|
}},
|
|
onSnapshot: snapshot => snapshots.push(snapshot.context.user.login),
|
|
onError: error => errors.push({{ name: error.name, message: error.message }}),
|
|
setTimer: (callback, delay) => {{
|
|
const timer = {{ callback, delay, cancelled: false }};
|
|
timers.push(timer);
|
|
return timer;
|
|
}},
|
|
clearTimer: timer => {{ timer.cancelled = true; }},
|
|
setDeadlineTimer: (callback, delay) => {{
|
|
const timer = {{ callback, delay, cancelled: false }};
|
|
deadlineTimers.push(timer);
|
|
return timer;
|
|
}},
|
|
clearDeadlineTimer: timer => {{ timer.cancelled = true; }},
|
|
intervalMs: 8000,
|
|
timeoutMs: 25,
|
|
}});
|
|
|
|
(async () => {{
|
|
const first = poller.start();
|
|
const deadline = deadlineTimers.find(timer => timer.delay === 25 && !timer.cancelled);
|
|
deadline.callback();
|
|
await first;
|
|
const retry = timers.find(timer => timer.delay === 8000 && !timer.cancelled);
|
|
retry.callback();
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
process.stdout.write(JSON.stringify({{
|
|
calls,
|
|
firstAborted: signals[0].aborted,
|
|
errors,
|
|
snapshots,
|
|
}}));
|
|
}})();
|
|
"""
|
|
|
|
assert run_node(script) == {
|
|
"calls": 2,
|
|
"firstAborted": True,
|
|
"errors": [
|
|
{"name": "TimeoutError", "message": "Live update timed out after 25ms."}
|
|
],
|
|
"snapshots": ["timmy"],
|
|
}
|
|
|
|
|
|
def test_forced_refresh_retires_stale_generation_without_late_overwrite():
|
|
script = f"""
|
|
const createContextPoller = require({json.dumps(str(POLLER))});
|
|
let resolveFirst;
|
|
let firstSignal;
|
|
const rendered = [];
|
|
const errors = [];
|
|
let calls = 0;
|
|
const poller = createContextPoller({{
|
|
fetchContext: (revisions, options) => {{
|
|
calls += 1;
|
|
if (calls === 1) {{
|
|
firstSignal = options.signal;
|
|
return new Promise(resolve => {{ resolveFirst = resolve; }});
|
|
}}
|
|
return Promise.resolve({{ context: {{ user: {{ login: 'fresh' }} }} }});
|
|
}},
|
|
onSnapshot: snapshot => rendered.push(snapshot.context.user.login),
|
|
onError: error => errors.push(error.name),
|
|
setTimer: () => 1,
|
|
clearTimer: () => {{}},
|
|
setDeadlineTimer: () => 1,
|
|
clearDeadlineTimer: () => {{}},
|
|
}});
|
|
|
|
(async () => {{
|
|
const stale = poller.start();
|
|
const fresh = poller.refresh({{ force: true }});
|
|
await fresh;
|
|
resolveFirst({{ context: {{ user: {{ login: 'stale' }} }} }});
|
|
await stale;
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
process.stdout.write(JSON.stringify({{
|
|
calls,
|
|
firstAborted: firstSignal.aborted,
|
|
rendered,
|
|
errors,
|
|
}}));
|
|
}})();
|
|
"""
|
|
|
|
assert run_node(script) == {
|
|
"calls": 2,
|
|
"firstAborted": True,
|
|
"rendered": ["fresh"],
|
|
"errors": [],
|
|
}
|